Imporve UI and token

This commit is contained in:
Urtzi Alfaro
2026-01-11 07:50:34 +01:00
parent bf1db7cb9e
commit 5533198cab
14 changed files with 1370 additions and 670 deletions

View File

@@ -1,552 +0,0 @@
# Code-Level Architecture Analysis: Notification & Subscription Endpoints
**Date:** 2026-01-10
**Analysis Method:** SigNoz Distributed Tracing + Deep Code Review
**Status:** ARCHITECTURAL FLAWS IDENTIFIED
---
## 🎯 Executive Summary
After deep code analysis, I've identified **SEVERE architectural problems** causing the 2.5s notification latency and 5.5s subscription latency. The issues are NOT simple missing indexes - they're **fundamental design flaws** in the auth/authorization chain.
### Critical Problems Found:
1. **Gateway makes 5 SYNCHRONOUS external HTTP calls** for EVERY request
2. **No caching layer** - same auth checks repeated millions of times
3. **Decorators stacked incorrectly** - causing redundant checks
4. **Header extraction overhead** - parsing on every request
5. **Subscription data fetched from database** instead of being cached in JWT
---
## 🔍 Problem 1: Notification Endpoint Architecture (2.5s latency)
### Current Implementation
**File:** `services/notification/app/api/notification_operations.py:46-56`
```python
@router.post(
route_builder.build_base_route("send"),
response_model=NotificationResponse,
status_code=201
)
@track_endpoint_metrics("notification_send") # Decorator 1
async def send_notification(
notification_data: Dict[str, Any],
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep), # Decorator 2 (hidden)
notification_service: EnhancedNotificationService = Depends(get_enhanced_notification_service)
):
```
### The Authorization Chain
When a request hits this endpoint, here's what happens:
#### Step 1: `get_current_user_dep` (line 55)
**File:** `shared/auth/decorators.py:448-510`
```python
async def get_current_user_dep(request: Request) -> Dict[str, Any]:
# Logs EVERY request (expensive string operations)
logger.debug(
"Authentication attempt", # Line 452
path=request.url.path,
method=request.method,
has_auth_header=bool(request.headers.get("authorization")),
# ... 8 more header checks
)
# Try header extraction first
try:
user = get_current_user(request) # Line 468 - CALL 1
except HTTPException:
# Fallback to JWT extraction
auth_header = request.headers.get("authorization", "")
if auth_header.startswith("Bearer "):
user = extract_user_from_jwt(auth_header) # Line 473 - CALL 2
```
#### Step 2: `get_current_user()` extracts headers
**File:** `shared/auth/decorators.py:320-333`
```python
def get_current_user(request: Request) -> Dict[str, Any]:
if hasattr(request.state, 'user') and request.state.user:
return request.state.user
# Fallback to headers (for dev/testing)
user_info = extract_user_from_headers(request) # CALL 3
if not user_info:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not authenticated"
)
return user_info
```
#### Step 3: `extract_user_from_headers()` - THE BOTTLENECK
**File:** `shared/auth/decorators.py:343-374`
```python
def extract_user_from_headers(request: Request) -> Optional[Dict[str, Any]]:
"""Extract user information from forwarded headers"""
user_id = request.headers.get("x-user-id") # HTTP call to gateway?
if not user_id:
return None
# Build user context from 15+ headers
user_context = {
"user_id": user_id,
"email": request.headers.get("x-user-email", ""), # Another header
"role": request.headers.get("x-user-role", "user"), # Another
"tenant_id": request.headers.get("x-tenant-id"), # Another
"permissions": request.headers.get("X-User-Permissions", "").split(","),
"full_name": request.headers.get("x-user-full-name", ""),
"subscription_tier": request.headers.get("x-subscription-tier", ""), # Gateway lookup!
"is_demo": request.headers.get("x-is-demo", "").lower() == "true",
"demo_session_id": request.headers.get("x-demo-session-id", ""),
"demo_account_type": request.headers.get("x-demo-account-type", "")
}
return user_context
```
### 🔴 **ROOT CAUSE: Gateway Performs 5 Sequential Database/Service Calls**
The trace shows that **BEFORE** the notification service is even called, the gateway makes these calls:
```
Gateway Middleware Chain:
1. GET /tenants/{tenant_id}/access/{user_id} 294ms ← Verify user access
2. GET /subscriptions/{tenant_id}/tier 110ms ← Get subscription tier
3. GET /tenants/{tenant_id}/access/{user_id} 12ms ← DUPLICATE! Why?
4. GET (unknown - maybe features?) 2ms ← Unknown call
5. GET /subscriptions/{tenant_id}/status 102ms ← Get subscription status
─────────────────────────────────────────────────────────
TOTAL OVERHEAD: 520ms (43% of total request time!)
```
### Where This Happens (Hypothesis - needs gateway code)
Based on the headers being injected, the gateway likely does:
```python
# Gateway middleware (not in repo, but this is what's happening)
async def inject_user_context_middleware(request, call_next):
# Extract tenant_id and user_id from JWT
token = extract_token(request)
user_id = token.get("user_id")
tenant_id = extract_tenant_from_path(request.url.path)
# PROBLEM: Make external HTTP calls to get auth data
# Call 1: Check if user has access to tenant (294ms)
access = await tenant_service.check_access(tenant_id, user_id)
# Call 2: Get subscription tier (110ms)
subscription = await tenant_service.get_subscription_tier(tenant_id)
# Call 3: DUPLICATE access check? (12ms)
access2 = await tenant_service.check_access(tenant_id, user_id) # WHY?
# Call 4: Unknown (2ms)
something = await tenant_service.get_something(tenant_id)
# Call 5: Get subscription status (102ms)
status = await tenant_service.get_subscription_status(tenant_id)
# Inject into headers
request.headers["x-user-role"] = access.role
request.headers["x-subscription-tier"] = subscription.tier
request.headers["x-subscription-status"] = status.status
# Forward request
return await call_next(request)
```
### Why This is BAD Architecture:
1.**Service-to-Service HTTP calls** instead of shared cache
2.**Sequential execution** (each waits for previous)
3.**No caching** - every request makes ALL calls
4.**Redundant checks** - access checked twice
5.**Wrong layer** - auth data should be in JWT, not fetched per request
---
## 🔍 Problem 2: Subscription Tier Query (772ms!)
### Current Query (Hypothesis)
**File:** `services/tenant/app/repositories/subscription_repository.py` (lines not shown, but likely exists)
```python
async def get_subscription_by_tenant(tenant_id: str) -> Subscription:
query = select(Subscription).where(
Subscription.tenant_id == tenant_id,
Subscription.status == 'active'
)
result = await self.session.execute(query)
return result.scalar_one_or_none()
```
### Why It's Slow:
**Missing Index!**
```sql
-- Current situation: Full table scan
EXPLAIN ANALYZE
SELECT * FROM subscriptions
WHERE tenant_id = 'uuid' AND status = 'active';
-- Result: Seq Scan on subscriptions (cost=0.00..1234.56 rows=1)
-- Planning Time: 0.5 ms
-- Execution Time: 772.3 ms ← SLOW!
```
**Database Metrics Confirm:**
```
Average Block Reads: 396 blocks/query
Max Block Reads: 369,161 blocks (!!)
Average Index Scans: 0.48 per query ← Almost no indexes used!
```
### The Missing Indexes:
```sql
-- Check existing indexes
SELECT
tablename,
indexname,
indexdef
FROM pg_indexes
WHERE tablename = 'subscriptions';
-- Result: Probably only has PRIMARY KEY on `id`
-- Missing:
-- - Index on tenant_id
-- - Composite index on (tenant_id, status)
-- - Covering index including tier, status, valid_until
```
---
## 🔧 Architectural Solutions
### Solution 1: Move Auth Data Into JWT (BEST FIX)
**Current (BAD):**
```
User Request → Gateway → 5 HTTP calls to tenant-service → Inject headers → Forward
```
**Better:**
```
User Login → Generate JWT with ALL auth data → Gateway validates JWT → Forward
```
**Implementation:**
#### Step 1: Update JWT Payload
**File:** Create `shared/auth/jwt_builder.py`
```python
from datetime import datetime, timedelta
import jwt
def create_access_token(user_data: dict, subscription_data: dict) -> str:
"""
Create JWT with ALL required auth data embedded
No need for runtime lookups!
"""
now = datetime.utcnow()
payload = {
# Standard JWT claims
"sub": user_data["user_id"],
"iat": now,
"exp": now + timedelta(hours=24),
"type": "access",
# User data (already available at login)
"user_id": user_data["user_id"],
"email": user_data["email"],
"role": user_data["role"],
"full_name": user_data.get("full_name", ""),
"tenant_id": user_data["tenant_id"],
# Subscription data (fetch ONCE at login, cache in JWT)
"subscription": {
"tier": subscription_data["tier"], # professional, enterprise
"status": subscription_data["status"], # active, cancelled
"valid_until": subscription_data["valid_until"].isoformat(),
"features": subscription_data["features"], # list of enabled features
"limits": {
"max_users": subscription_data.get("max_users", -1),
"max_products": subscription_data.get("max_products", -1),
"max_locations": subscription_data.get("max_locations", -1)
}
},
# Permissions (computed once at login)
"permissions": compute_user_permissions(user_data, subscription_data)
}
return jwt.encode(payload, SECRET_KEY, algorithm="HS256")
```
**Impact:**
- Gateway calls: 5 → **0** (everything in JWT)
- Latency: 520ms → **<1ms** (JWT decode)
- Database load: **99% reduction**
---
#### Step 2: Simplify Gateway Middleware
**File:** Gateway middleware (Kong/nginx/custom)
```python
# BEFORE: 520ms of HTTP calls
async def auth_middleware(request):
# 5 HTTP calls...
pass
# AFTER: <1ms JWT decode
async def auth_middleware(request):
# Extract JWT
token = request.headers.get("Authorization", "").replace("Bearer ", "")
# Decode (no verification needed if from trusted source)
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
# Inject ALL data into headers at once
request.headers["x-user-id"] = payload["user_id"]
request.headers["x-user-email"] = payload["email"]
request.headers["x-user-role"] = payload["role"]
request.headers["x-tenant-id"] = payload["tenant_id"]
request.headers["x-subscription-tier"] = payload["subscription"]["tier"]
request.headers["x-subscription-status"] = payload["subscription"]["status"]
request.headers["x-permissions"] = ",".join(payload.get("permissions", []))
return await call_next(request)
```
---
### Solution 2: Add Database Indexes (Complementary)
Even with JWT optimization, some endpoints still query subscriptions directly:
```sql
-- Critical indexes for tenant service
CREATE INDEX CONCURRENTLY idx_subscriptions_tenant_status
ON subscriptions (tenant_id, status)
WHERE status IN ('active', 'trial');
-- Covering index (avoids table lookup)
CREATE INDEX CONCURRENTLY idx_subscriptions_tenant_covering
ON subscriptions (tenant_id)
INCLUDE (tier, status, valid_until, features, max_users, max_products);
-- Index for status checks
CREATE INDEX CONCURRENTLY idx_subscriptions_status_valid
ON subscriptions (status, valid_until DESC)
WHERE status = 'active';
```
**Expected Impact:**
- Query time: 772ms **5-10ms** (99% improvement)
- Block reads: 369K **<100 blocks**
---
### Solution 3: Add Redis Cache Layer (Defense in Depth)
Even with JWT, cache critical data:
```python
# shared/caching/subscription_cache.py
import redis
import json
class SubscriptionCache:
def __init__(self, redis_client):
self.redis = redis_client
self.TTL = 300 # 5 minutes
async def get_subscription(self, tenant_id: str):
"""Get subscription from cache or database"""
cache_key = f"subscription:{tenant_id}"
# Try cache
cached = await self.redis.get(cache_key)
if cached:
return json.loads(cached)
# Fetch from database
subscription = await self._fetch_from_db(tenant_id)
# Cache it
await self.redis.setex(
cache_key,
self.TTL,
json.dumps(subscription)
)
return subscription
async def invalidate(self, tenant_id: str):
"""Invalidate cache when subscription changes"""
cache_key = f"subscription:{tenant_id}"
await self.redis.delete(cache_key)
```
**Usage:**
```python
# services/tenant/app/api/subscription.py
@router.get("/api/v1/subscriptions/{tenant_id}/tier")
async def get_subscription_tier(tenant_id: str):
# Try cache first
subscription = await subscription_cache.get_subscription(tenant_id)
return {"tier": subscription["tier"]}
```
---
## 📈 Expected Performance Improvements
| Component | Before | After (JWT) | After (JWT + Index + Cache) | Improvement |
|-----------|--------|-------------|----------------------------|-------------|
| **Gateway Auth Calls** | 520ms (5 calls) | <1ms (JWT decode) | <1ms | **99.8%** |
| **Subscription Query** | 772ms | 772ms | 2ms (cache hit) | **99.7%** |
| **Notification POST** | 2,500ms | 1,980ms (20% faster) | **50ms** | **98%** |
| **Subscription GET** | 5,500ms | 4,780ms | **20ms** | **99.6%** |
### Overall Impact:
**Notification endpoint:** 2.5s **50ms** (98% improvement)
**Subscription endpoint:** 5.5s **20ms** (99.6% improvement)
---
## 🎯 Implementation Priority
### CRITICAL (Day 1-2): JWT Auth Data
**Why:** Eliminates 520ms overhead on EVERY request across ALL services
**Steps:**
1. Update JWT payload to include subscription data
2. Modify login endpoint to fetch subscription once
3. Update gateway to use JWT data instead of HTTP calls
4. Test with 1-2 endpoints first
**Risk:** Low - JWT is already used, just adding more data
**Impact:** **98% latency reduction** on auth-heavy endpoints
---
### HIGH (Day 3-4): Database Indexes
**Why:** Fixes 772ms subscription queries
**Steps:**
1. Add indexes to subscriptions table
2. Analyze `pg_stat_statements` for other slow queries
3. Add covering indexes where needed
4. Monitor query performance
**Risk:** Low - indexes don't change logic
**Impact:** **99% query time reduction**
---
### MEDIUM (Day 5-7): Redis Cache Layer
**Why:** Defense in depth, handles JWT expiry edge cases
**Steps:**
1. Implement subscription cache service
2. Add cache to subscription repository
3. Add cache invalidation on updates
4. Monitor cache hit rates
**Risk:** Medium - cache invalidation can be tricky
**Impact:** **Additional 50% improvement** for cache hits
---
## 🚨 Critical Architectural Lesson
### The Real Problem:
**"Microservices without proper caching become a distributed monolith with network overhead"**
Every request was:
1. JWT decode (cheap)
2. 5 HTTP calls to tenant-service (expensive!)
3. 5 database queries in tenant-service (very expensive!)
4. Forward to actual service
5. Actual work finally happens
**Solution:**
- **Move static/slow-changing data into JWT** (subscription tier, role, permissions)
- **Cache everything else** in Redis (user preferences, feature flags)
- **Only query database** for truly dynamic data (current notifications, real-time stats)
This is a **classic distributed systems anti-pattern** that's killing your performance!
---
## 📊 Monitoring After Fix
```sql
-- Monitor gateway performance
SELECT
name,
quantile(0.95)(durationNano) / 1000000 as p95_ms
FROM signoz_traces.signoz_index_v3
WHERE serviceName = 'gateway'
AND timestamp >= now() - INTERVAL 1 DAY
GROUP BY name
ORDER BY p95_ms DESC;
-- Target: All gateway calls < 10ms
-- Current: 520ms average
-- Monitor subscription queries
SELECT
query,
calls,
mean_exec_time,
max_exec_time
FROM pg_stat_statements
WHERE query LIKE '%subscriptions%'
ORDER BY mean_exec_time DESC;
-- Target: < 5ms average
-- Current: 772ms max
```
---
## 🚀 Conclusion
The performance issues are caused by **architectural choices**, not missing indexes:
1. **Auth data fetched via HTTP** instead of embedded in JWT
2. **5 sequential database/HTTP calls** on every request
3. **No caching layer** - same data fetched millions of times
4. **Wrong separation of concerns** - gateway doing too much
**The fix is NOT to add caching to the current architecture.**
**The fix is to CHANGE the architecture to not need those calls.**
Embedding auth data in JWT is the **industry standard** for exactly this reason - it eliminates the need for runtime authorization lookups!

828
STRIPE_TESTING_GUIDE.md Normal file
View File

@@ -0,0 +1,828 @@
# Stripe Integration Testing Guide
## Table of Contents
1. [Prerequisites](#prerequisites)
2. [Environment Setup](#environment-setup)
3. [Stripe Dashboard Configuration](#stripe-dashboard-configuration)
4. [Test Card Numbers](#test-card-numbers)
5. [Testing Scenarios](#testing-scenarios)
6. [Webhook Testing](#webhook-testing)
7. [Common Issues & Solutions](#common-issues--solutions)
8. [Production Checklist](#production-checklist)
---
## Prerequisites
Before you begin testing, ensure you have:
- ✅ Stripe account created (sign up at [stripe.com](https://stripe.com))
- ✅ Node.js and Python environments set up
- ✅ Frontend application running (React + Vite)
- ✅ Backend API running (FastAPI)
- ✅ Database configured and accessible
- ✅ Redis instance running (for caching)
---
## Environment Setup
### Step 1: Access Stripe Test Mode
1. Log in to your Stripe Dashboard: [https://dashboard.stripe.com](https://dashboard.stripe.com)
2. Click on your profile icon in the top right corner
3. Ensure **Test Mode** is enabled (you'll see "TEST DATA" banner at the top)
4. If not enabled, toggle to "Switch to test data"
### Step 2: Retrieve API Keys
1. Navigate to **Developers****API keys**
2. You'll see two types of keys:
- **Publishable key** (starts with `pk_test_...`) - Used in frontend
- **Secret key** (starts with `sk_test_...`) - Used in backend
3. Click "Reveal test key" for the Secret key and copy both keys
### Step 3: Configure Environment Variables
#### Frontend `.env` file:
```bash
# Create or update: /frontend/.env
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key_here
```
#### Backend `.env` file:
```bash
# Create or update: /services/tenant/.env
STRIPE_SECRET_KEY=sk_test_your_secret_key_here
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here
```
**Note:** The webhook secret will be obtained in Step 4 when setting up webhooks.
### Step 4: Install/Update Dependencies
#### Backend:
```bash
cd services/tenant
pip install -r requirements.txt
# This will install stripe==14.1.0
```
#### Frontend:
```bash
cd frontend
npm install
# Verifies @stripe/react-stripe-js and @stripe/stripe-js are installed
```
---
## Stripe Dashboard Configuration
### Step 1: Create Products and Prices
1. In Stripe Dashboard (Test Mode), go to **Products****Add product**
2. **Create Starter Plan:**
- Product name: `Starter Plan`
- Description: `Basic subscription for small businesses`
- Pricing:
- Price: `$29.00`
- Billing period: `Monthly`
- Currency: `USD` (or your preferred currency)
- Click **Save product**
- Copy the **Price ID** (starts with `price_...`) - you'll need this
3. **Create Professional Plan:**
- Product name: `Professional Plan`
- Description: `Advanced subscription for growing businesses`
- Pricing:
- Price: `$99.00`
- Billing period: `Monthly`
- Currency: `USD`
- Click **Save product**
- Copy the **Price ID**
4. **Update your application configuration:**
- Store these Price IDs in your application settings
- You'll use these when creating subscriptions
### Step 2: Configure Webhooks
1. Navigate to **Developers****Webhooks**
2. Click **+ Add endpoint**
3. **For Local Development:**
- Endpoint URL: `https://your-ngrok-url.ngrok.io/webhooks/stripe`
- (We'll set up ngrok later for local testing)
4. **Select events to listen to:**
- `checkout.session.completed`
- `customer.subscription.created`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- `invoice.payment_succeeded`
- `invoice.payment_failed`
- `customer.subscription.trial_will_end`
5. Click **Add endpoint**
6. **Copy the Webhook Signing Secret:**
- Click on the newly created endpoint
- Click **Reveal** next to "Signing secret"
- Copy the secret (starts with `whsec_...`)
- Add it to your backend `.env` file as `STRIPE_WEBHOOK_SECRET`
---
## Test Card Numbers
Stripe provides test card numbers to simulate different scenarios. **Never use real card details in test mode.**
### Basic Test Cards
| Scenario | Card Number | CVC | Expiry Date |
|----------|-------------|-----|-------------|
| **Successful payment** | `4242 4242 4242 4242` | Any 3 digits | Any future date |
| **Visa (debit)** | `4000 0566 5566 5556` | Any 3 digits | Any future date |
| **Mastercard** | `5555 5555 5555 4444` | Any 3 digits | Any future date |
| **American Express** | `3782 822463 10005` | Any 4 digits | Any future date |
### Authentication & Security
| Scenario | Card Number | Notes |
|----------|-------------|-------|
| **3D Secure authentication required** | `4000 0025 0000 3155` | Triggers authentication modal |
| **3D Secure 2 authentication** | `4000 0027 6000 3184` | Requires SCA authentication |
### Declined Cards
| Scenario | Card Number | Error Message |
|----------|-------------|---------------|
| **Generic decline** | `4000 0000 0000 0002` | Card declined |
| **Insufficient funds** | `4000 0000 0000 9995` | Insufficient funds |
| **Lost card** | `4000 0000 0000 9987` | Lost card |
| **Stolen card** | `4000 0000 0000 9979` | Stolen card |
| **Expired card** | `4000 0000 0000 0069` | Expired card |
| **Incorrect CVC** | `4000 0000 0000 0127` | Incorrect CVC |
| **Processing error** | `4000 0000 0000 0119` | Processing error |
| **Card declined (rate limit)** | `4000 0000 0000 9954` | Exceeds velocity limit |
### Additional Scenarios
| Scenario | Card Number | Notes |
|----------|-------------|-------|
| **Charge succeeds, then fails** | `4000 0000 0000 0341` | Attaches successfully but charge fails |
| **Dispute (fraudulent)** | `4000 0000 0000 0259` | Creates a fraudulent dispute |
| **Dispute (warning)** | `4000 0000 0000 2685` | Creates early fraud warning |
**Important Notes:**
- For **expiry date**: Use any future date (e.g., 12/30)
- For **CVC**: Use any 3-digit number (e.g., 123) or 4-digit for Amex (e.g., 1234)
- For **postal code**: Use any valid format (e.g., 12345)
---
## Testing Scenarios
### Scenario 1: Successful Registration with Payment
**Objective:** Test the complete registration flow with valid payment method.
**Steps:**
1. **Start your applications:**
```bash
# Terminal 1 - Backend
cd services/tenant
uvicorn app.main:app --reload --port 8000
# Terminal 2 - Frontend
cd frontend
npm run dev
```
2. **Navigate to registration page:**
- Open browser: `http://localhost:5173/register` (or your frontend URL)
3. **Fill in user details:**
- Full Name: `John Doe`
- Email: `john.doe+test@example.com`
- Company: `Test Company`
- Password: Create a test password
4. **Fill in payment details:**
- Card Number: `4242 4242 4242 4242`
- Expiry: `12/30`
- CVC: `123`
- Cardholder Name: `John Doe`
- Email: `john.doe+test@example.com`
- Address: `123 Test Street`
- City: `Test City`
- State: `CA`
- Postal Code: `12345`
- Country: `US`
5. **Select a plan:**
- Choose `Starter Plan` or `Professional Plan`
6. **Submit the form**
**Expected Results:**
- ✅ Payment method created successfully
- ✅ User account created
- ✅ Subscription created in Stripe
- ✅ Database records created
- ✅ User redirected to dashboard
- ✅ No console errors
**Verification:**
1. **In Stripe Dashboard:**
- Go to **Customers** → Find "John Doe"
- Go to **Subscriptions** → See active subscription
- Status should be `active`
2. **In your database:**
```sql
SELECT * FROM subscriptions WHERE tenant_id = 'your-tenant-id';
```
- Verify subscription record exists
- Status should be `active`
- Check `stripe_customer_id` is populated
3. **Check application logs:**
- Look for successful subscription creation messages
- Verify no error logs
---
### Scenario 2: Payment with 3D Secure Authentication
**Objective:** Test Strong Customer Authentication (SCA) flow.
**Steps:**
1. Follow steps 1-3 from Scenario 1
2. **Fill in payment details with 3DS card:**
- Card Number: `4000 0025 0000 3155`
- Expiry: `12/30`
- CVC: `123`
- Fill remaining details as before
3. **Submit the form**
4. **Complete authentication:**
- Stripe will display an authentication modal
- Click **"Complete"** (in test mode, no real auth needed)
**Expected Results:**
- ✅ Authentication modal appears
- ✅ After clicking "Complete", payment succeeds
- ✅ Subscription created successfully
- ✅ User redirected to dashboard
**Note:** This simulates European and other markets requiring SCA.
---
### Scenario 3: Declined Payment
**Objective:** Test error handling for declined cards.
**Steps:**
1. Follow steps 1-3 from Scenario 1
2. **Use a declined test card:**
- Card Number: `4000 0000 0000 0002`
- Fill remaining details as before
3. **Submit the form**
**Expected Results:**
- ❌ Payment fails with error message
- ✅ Error displayed to user: "Your card was declined"
- ✅ No customer created in Stripe
- ✅ No subscription created
- ✅ No database records created
- ✅ User remains on payment form
- ✅ Can retry with different card
**Verification:**
- Check Stripe Dashboard → Customers (should not see new customer)
- Check application logs for error handling
- Verify user-friendly error message displayed
---
### Scenario 4: Insufficient Funds
**Objective:** Test specific decline reason handling.
**Steps:**
1. Use card number: `4000 0000 0000 9995`
2. Follow same process as Scenario 3
**Expected Results:**
- ❌ Payment fails
- ✅ Error message: "Your card has insufficient funds"
- ✅ Proper error handling and logging
---
### Scenario 5: Subscription Cancellation
**Objective:** Test subscription cancellation flow.
**Steps:**
1. **Create an active subscription** (use Scenario 1)
2. **Cancel the subscription:**
- Method 1: Through your application UI (if implemented)
- Method 2: API call:
```bash
curl -X POST http://localhost:8000/api/v1/subscriptions/cancel \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_AUTH_TOKEN" \
-d '{
"tenant_id": "your-tenant-id",
"reason": "Testing cancellation"
}'
```
**Expected Results:**
- ✅ Subscription status changes to `pending_cancellation`
- ✅ `cancellation_effective_date` is set
- ✅ User retains access until end of billing period
- ✅ Response includes days remaining
- ✅ Subscription cache invalidated
**Verification:**
1. Check database:
```sql
SELECT status, cancellation_effective_date, cancelled_at
FROM subscriptions
WHERE tenant_id = 'your-tenant-id';
```
2. Verify API response:
```json
{
"success": true,
"message": "Subscription cancelled successfully...",
"status": "pending_cancellation",
"cancellation_effective_date": "2026-02-10T...",
"days_remaining": 30
}
```
---
### Scenario 6: Subscription Reactivation
**Objective:** Test reactivating a cancelled subscription.
**Steps:**
1. **Cancel a subscription** (use Scenario 5)
2. **Reactivate the subscription:**
```bash
curl -X POST http://localhost:8000/api/v1/subscriptions/reactivate \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_AUTH_TOKEN" \
-d '{
"tenant_id": "your-tenant-id",
"plan": "starter"
}'
```
**Expected Results:**
- ✅ Subscription status changes back to `active`
- ✅ `cancelled_at` and `cancellation_effective_date` cleared
- ✅ Next billing date set
- ✅ Subscription cache invalidated
---
### Scenario 7: Retrieve Invoices
**Objective:** Test invoice retrieval from Stripe.
**Steps:**
1. **Create subscription with successful payment** (Scenario 1)
2. **Retrieve invoices:**
```bash
curl -X GET http://localhost:8000/api/v1/subscriptions/{tenant_id}/invoices \
-H "Authorization: Bearer YOUR_AUTH_TOKEN"
```
**Expected Results:**
- ✅ List of invoices returned
- ✅ Each invoice contains:
- `id`
- `date`
- `amount`
- `currency`
- `status`
- `invoice_pdf` URL
- `hosted_invoice_url` URL
**Verification:**
- Click on `hosted_invoice_url` to view invoice in browser
- Download PDF from `invoice_pdf` URL
---
## Webhook Testing
Webhooks are critical for handling asynchronous events from Stripe. Test them thoroughly.
### Option 1: Using Stripe CLI (Recommended for Local Development)
#### Step 1: Install Stripe CLI
**macOS:**
```bash
brew install stripe/stripe-cli/stripe
```
**Windows:**
Download from: https://github.com/stripe/stripe-cli/releases
**Linux:**
```bash
wget https://github.com/stripe/stripe-cli/releases/latest/download/stripe_linux_x86_64.tar.gz
tar -xvf stripe_linux_x86_64.tar.gz
sudo mv stripe /usr/local/bin/
```
#### Step 2: Login to Stripe
```bash
stripe login
```
This opens a browser to authorize the CLI.
#### Step 3: Forward Webhooks to Local Server
```bash
stripe listen --forward-to localhost:8000/webhooks/stripe
```
**Expected Output:**
```
> Ready! Your webhook signing secret is whsec_abc123... (^C to quit)
```
**Important:** Copy this webhook signing secret and add it to your backend `.env`:
```bash
STRIPE_WEBHOOK_SECRET=whsec_abc123...
```
#### Step 4: Trigger Test Events
Open a new terminal and run:
```bash
# Test subscription created
stripe trigger customer.subscription.created
# Test payment succeeded
stripe trigger invoice.payment_succeeded
# Test payment failed
stripe trigger invoice.payment_failed
# Test subscription updated
stripe trigger customer.subscription.updated
# Test subscription deleted
stripe trigger customer.subscription.deleted
# Test trial ending
stripe trigger customer.subscription.trial_will_end
```
#### Step 5: Verify Webhook Processing
**Check your application logs for:**
- ✅ "Processing Stripe webhook event"
- ✅ Event type logged
- ✅ Database updates (check subscription status)
- ✅ No signature verification errors
**Example log output:**
```
INFO Processing Stripe webhook event event_type=customer.subscription.updated
INFO Subscription updated in database subscription_id=sub_123 tenant_id=tenant-id
```
### Option 2: Using ngrok (For Public URL Testing)
#### Step 1: Install ngrok
Download from: https://ngrok.com/download
#### Step 2: Start ngrok
```bash
ngrok http 8000
```
**Output:**
```
Forwarding https://abc123.ngrok.io -> http://localhost:8000
```
#### Step 3: Update Stripe Webhook Endpoint
1. Go to Stripe Dashboard → Developers → Webhooks
2. Click on your endpoint
3. Update URL to: `https://abc123.ngrok.io/webhooks/stripe`
4. Save changes
#### Step 4: Test by Creating Real Events
Create a test subscription through your app, and webhooks will be sent to your ngrok URL.
### Option 3: Testing Webhook Handlers Directly
You can also test webhook handlers by sending test payloads:
```bash
curl -X POST http://localhost:8000/webhooks/stripe \
-H "Content-Type: application/json" \
-H "stripe-signature: test-signature" \
-d @webhook-test-payload.json
```
**Note:** This will fail signature verification unless you disable it temporarily for testing.
---
## Common Issues & Solutions
### Issue 1: "Stripe.js has not loaded correctly"
**Symptoms:**
- Error when submitting payment form
- Console error about Stripe not being loaded
**Solutions:**
1. Check internet connection (Stripe.js loads from CDN)
2. Verify `VITE_STRIPE_PUBLISHABLE_KEY` is set correctly
3. Check browser console for loading errors
4. Ensure no ad blockers blocking Stripe.js
### Issue 2: "Invalid signature" on Webhook
**Symptoms:**
- Webhook endpoint returns 400 error
- Log shows "Invalid webhook signature"
**Solutions:**
1. Verify `STRIPE_WEBHOOK_SECRET` matches Stripe Dashboard
2. For Stripe CLI, use the secret from `stripe listen` output
3. Ensure you're using the test mode secret, not live mode
4. Check that you're not modifying the request body before verification
### Issue 3: Payment Method Not Attaching
**Symptoms:**
- PaymentMethod created but subscription fails
- Error about payment method not found
**Solutions:**
1. Verify you're passing `paymentMethod.id` to backend
2. Check that payment method is being attached to customer
3. Ensure customer_id exists before creating subscription
4. Review backend logs for detailed error messages
### Issue 4: Test Mode vs Live Mode Confusion
**Symptoms:**
- Keys not working
- Data not appearing in dashboard
**Solutions:**
1. **Always check the mode indicator** in Stripe Dashboard
2. Test keys start with `pk_test_` and `sk_test_`
3. Live keys start with `pk_live_` and `sk_live_`
4. Never mix test and live keys
5. Use separate databases for test and live environments
### Issue 5: CORS Errors
**Symptoms:**
- Browser console shows CORS errors
- Requests to backend failing
**Solutions:**
1. Ensure FastAPI CORS middleware is configured:
```python
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
```
### Issue 6: Webhook Events Not Processing
**Symptoms:**
- Webhooks received but database not updating
- Events logged but handlers not executing
**Solutions:**
1. Check event type matches handler (case-sensitive)
2. Verify database session is committed
3. Check for exceptions in handler functions
4. Review logs for specific error messages
5. Ensure subscription exists in database before update
### Issue 7: Card Element vs Payment Element Confusion
**Symptoms:**
- TypeError when calling `stripe.createPaymentMethod()`
- Elements not rendering correctly
**Solutions:**
- Use `PaymentElement` (modern, recommended)
- Call `elements.submit()` before creating payment method
- Pass `elements` object to `createPaymentMethod({ elements })`
- **Our implementation now uses the correct PaymentElement API**
---
## Production Checklist
Before going live with Stripe payments:
### Security
- [ ] All API keys stored in environment variables (never in code)
- [ ] Webhook signature verification enabled and working
- [ ] HTTPS enabled on all endpoints
- [ ] Rate limiting implemented on payment endpoints
- [ ] Input validation on all payment-related forms
- [ ] SQL injection prevention (using parameterized queries)
- [ ] XSS protection enabled
- [ ] CSRF tokens implemented where needed
### Stripe Configuration
- [ ] Live mode API keys obtained from Stripe Dashboard
- [ ] Live mode webhook endpoints configured
- [ ] Webhook signing secret updated for live mode
- [ ] Products and prices created in live mode
- [ ] Business information completed in Stripe Dashboard
- [ ] Bank account added for payouts
- [ ] Tax settings configured (if applicable)
- [ ] Stripe account activated and verified
### Application Configuration
- [ ] Environment variables updated for production
- [ ] Database migrations run on production database
- [ ] Redis cache configured and accessible
- [ ] Error monitoring/logging configured (e.g., Sentry)
- [ ] Payment failure notifications set up
- [ ] Trial ending notifications configured
- [ ] Invoice email delivery tested
### Testing
- [ ] All test scenarios passed (see above)
- [ ] Webhook handling verified for all event types
- [ ] 3D Secure authentication tested
- [ ] Subscription lifecycle tested (create, update, cancel, reactivate)
- [ ] Error handling tested for all failure scenarios
- [ ] Invoice retrieval tested
- [ ] Load testing completed
- [ ] Security audit performed
### Monitoring
- [ ] Stripe Dashboard monitoring set up
- [ ] Application logs reviewed regularly
- [ ] Webhook delivery monitoring configured
- [ ] Payment success/failure metrics tracked
- [ ] Alert thresholds configured
- [ ] Failed payment retry logic implemented
### Compliance
- [ ] Terms of Service updated to mention subscriptions
- [ ] Privacy Policy updated for payment data handling
- [ ] GDPR compliance verified (if applicable)
- [ ] PCI compliance requirements reviewed
- [ ] Customer data retention policy defined
- [ ] Refund policy documented
### Documentation
- [ ] API documentation updated
- [ ] Internal team trained on Stripe integration
- [ ] Customer support documentation created
- [ ] Troubleshooting guide prepared
- [ ] Subscription management procedures documented
---
## Quick Reference Commands
### Stripe CLI Commands
```bash
# Login to Stripe
stripe login
# Listen for webhooks (local development)
stripe listen --forward-to localhost:8000/webhooks/stripe
# Trigger test events
stripe trigger customer.subscription.created
stripe trigger invoice.payment_succeeded
stripe trigger invoice.payment_failed
# View recent events
stripe events list
# Get specific event
stripe events retrieve evt_abc123
# Test webhook endpoint
stripe webhooks test --endpoint-secret whsec_abc123
```
### Testing Shortcuts
```bash
# Start backend (from project root)
cd services/tenant && uvicorn app.main:app --reload --port 8000
# Start frontend (from project root)
cd frontend && npm run dev
# Update backend dependencies
cd services/tenant && pip install -r requirements.txt
# Run database migrations (if using Alembic)
cd services/tenant && alembic upgrade head
```
---
## Additional Resources
- **Stripe Documentation:** https://stripe.com/docs
- **Stripe API Reference:** https://stripe.com/docs/api
- **Stripe Testing Guide:** https://stripe.com/docs/testing
- **Stripe Webhooks Guide:** https://stripe.com/docs/webhooks
- **Stripe CLI Documentation:** https://stripe.com/docs/stripe-cli
- **React Stripe.js Docs:** https://stripe.com/docs/stripe-js/react
- **Stripe Support:** https://support.stripe.com
---
## Support
If you encounter issues not covered in this guide:
1. **Check Stripe Dashboard Logs:**
- Developers → Logs
- View detailed request/response information
2. **Review Application Logs:**
- Check backend logs for detailed error messages
- Look for structured log output from `structlog`
3. **Test in Isolation:**
- Test frontend separately
- Test backend API with cURL
- Verify webhook handling with Stripe CLI
4. **Contact Stripe Support:**
- Live chat available in Stripe Dashboard
- Email support: support@stripe.com
- Community forum: https://stripe.com/community
---
**Last Updated:** January 2026
**Stripe Library Versions:**
- Frontend: `@stripe/stripe-js@4.0.0`, `@stripe/react-stripe-js@3.0.0`
- Backend: `stripe@14.1.0`

View File

@@ -18,8 +18,8 @@
"@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7", "@radix-ui/react-tooltip": "^1.0.7",
"@stripe/react-stripe-js": "^2.7.3", "@stripe/react-stripe-js": "^3.0.0",
"@stripe/stripe-js": "^3.0.10", "@stripe/stripe-js": "^4.0.0",
"@tanstack/react-query": "^5.12.0", "@tanstack/react-query": "^5.12.0",
"axios": "^1.6.2", "axios": "^1.6.2",
"chart.js": "^4.5.0", "chart.js": "^4.5.0",
@@ -6037,23 +6037,23 @@
} }
}, },
"node_modules/@stripe/react-stripe-js": { "node_modules/@stripe/react-stripe-js": {
"version": "2.9.0", "version": "3.10.0",
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-2.9.0.tgz", "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-3.10.0.tgz",
"integrity": "sha512-+/j2g6qKAKuWSurhgRMfdlIdKM+nVVJCy/wl0US2Ccodlqx0WqfIIBhUkeONkCG+V/b+bZzcj4QVa3E/rXtT4Q==", "integrity": "sha512-UPqHZwMwDzGSax0ZI7XlxR3tZSpgIiZdk3CiwjbTK978phwR/fFXeAXQcN/h8wTAjR4ZIAzdlI9DbOqJhuJdeg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"prop-types": "^15.7.2" "prop-types": "^15.7.2"
}, },
"peerDependencies": { "peerDependencies": {
"@stripe/stripe-js": "^1.44.1 || ^2.0.0 || ^3.0.0 || ^4.0.0", "@stripe/stripe-js": ">=1.44.1 <8.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0", "react": ">=16.8.0 <20.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" "react-dom": ">=16.8.0 <20.0.0"
} }
}, },
"node_modules/@stripe/stripe-js": { "node_modules/@stripe/stripe-js": {
"version": "3.5.0", "version": "4.10.0",
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-3.5.0.tgz", "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-4.10.0.tgz",
"integrity": "sha512-pKS3wZnJoL1iTyGBXAvCwduNNeghJHY6QSRSNNvpYnrrQrLZ6Owsazjyynu0e0ObRgks0i7Rv+pe2M7/MBTZpQ==", "integrity": "sha512-KrMOL+sH69htCIXCaZ4JluJ35bchuCCznyPyrbN8JXSGQfwBI1SuIEMZNwvy8L8ykj29t6sa5BAAiL7fNoLZ8A==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"engines": { "engines": {

View File

@@ -39,8 +39,8 @@
"@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7", "@radix-ui/react-tooltip": "^1.0.7",
"@stripe/react-stripe-js": "^2.7.3", "@stripe/react-stripe-js": "^3.0.0",
"@stripe/stripe-js": "^3.0.10", "@stripe/stripe-js": "^4.0.0",
"@tanstack/react-query": "^5.12.0", "@tanstack/react-query": "^5.12.0",
"axios": "^1.6.2", "axios": "^1.6.2",
"chart.js": "^4.5.0", "chart.js": "^4.5.0",

View File

@@ -279,14 +279,29 @@ class ApiClient {
try { try {
// Dynamically import to avoid circular dependency // Dynamically import to avoid circular dependency
const { useAuthStore } = await import('../../stores/auth.store'); const { useAuthStore } = await import('../../stores/auth.store');
const { getSubscriptionFromJWT, getTenantAccessFromJWT, getPrimaryTenantIdFromJWT } = await import('../../utils/jwt');
const setState = useAuthStore.setState; const setState = useAuthStore.setState;
// Update the store with new tokens // CRITICAL: Extract fresh subscription data from new JWT
const jwtSubscription = getSubscriptionFromJWT(accessToken);
const jwtTenantAccess = getTenantAccessFromJWT(accessToken);
const primaryTenantId = getPrimaryTenantIdFromJWT(accessToken);
// Update the store with new tokens AND subscription data
setState(state => ({ setState(state => ({
...state, ...state,
token: accessToken, token: accessToken,
refreshToken: refreshToken || state.refreshToken, refreshToken: refreshToken || state.refreshToken,
// IMPORTANT: Update subscription from fresh JWT
jwtSubscription,
jwtTenantAccess,
primaryTenantId,
})); }));
console.log('✅ Auth store updated with new token and subscription:', jwtSubscription?.tier);
// Broadcast change to all Zustand subscribers
console.log('📢 Zustand state updated - all useJWTSubscription() hooks will re-render');
} catch (error) { } catch (error) {
console.warn('Failed to update auth store:', error); console.warn('Failed to update auth store:', error);
} }

View File

@@ -262,6 +262,10 @@ export interface PlanUpgradeResult {
message: string; message: string;
new_plan: SubscriptionTier; new_plan: SubscriptionTier;
effective_date: string; effective_date: string;
old_plan?: string;
new_monthly_price?: number;
validation?: any;
requires_token_refresh?: boolean; // Backend signals that token should be refreshed
} }
export interface SubscriptionInvoice { export interface SubscriptionInvoice {

View File

@@ -1,11 +1,11 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Card, Input, Button } from '../../ui'; import { Card, Input, Button } from '../../ui';
import { CardElement, useStripe, useElements } from '@stripe/react-stripe-js'; import { PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js';
import { AlertCircle, CheckCircle, CreditCard, Lock } from 'lucide-react'; import { AlertCircle, CheckCircle, CreditCard, Lock } from 'lucide-react';
interface PaymentFormProps { interface PaymentFormProps {
onPaymentSuccess: () => void; onPaymentSuccess: (paymentMethodId?: string) => void;
onPaymentError: (error: string) => void; onPaymentError: (error: string) => void;
className?: string; className?: string;
bypassPayment?: boolean; bypassPayment?: boolean;
@@ -50,7 +50,7 @@ const PaymentForm: React.FC<PaymentFormProps> = ({
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!stripe || !elements) { if (!stripe || !elements) {
// Stripe.js has not loaded yet // Stripe.js has not loaded yet
onPaymentError('Stripe.js no ha cargado correctamente'); onPaymentError('Stripe.js no ha cargado correctamente');
@@ -67,29 +67,41 @@ const PaymentForm: React.FC<PaymentFormProps> = ({
setError(null); setError(null);
try { try {
// Create payment method // Submit the payment element to validate all inputs
const { error, paymentMethod } = await stripe.createPaymentMethod({ const { error: submitError } = await elements.submit();
type: 'card',
card: elements.getElement('card')!,
billing_details: {
name: billingDetails.name,
email: billingDetails.email,
address: billingDetails.address,
},
});
if (error) { if (submitError) {
setError(error.message || 'Error al procesar el pago'); setError(submitError.message || 'Error al validar el formulario');
onPaymentError(error.message || 'Error al procesar el pago'); onPaymentError(submitError.message || 'Error al validar el formulario');
setLoading(false); setLoading(false);
return; return;
} }
// In a real application, you would send the paymentMethod.id to your server // Create payment method using the PaymentElement
// to create a subscription. For now, we'll simulate success. // This is the correct way to create a payment method with PaymentElement
const { error: paymentError, paymentMethod } = await stripe.createPaymentMethod({
elements,
params: {
billing_details: {
name: billingDetails.name,
email: billingDetails.email,
address: billingDetails.address,
},
},
});
if (paymentError) {
setError(paymentError.message || 'Error al procesar el pago');
onPaymentError(paymentError.message || 'Error al procesar el pago');
setLoading(false);
return;
}
// Send the paymentMethod.id to your server to create a subscription
console.log('Payment method created:', paymentMethod); console.log('Payment method created:', paymentMethod);
onPaymentSuccess(); // Pass the payment method ID to the parent component for server-side processing
onPaymentSuccess(paymentMethod?.id);
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Error desconocido al procesar el pago'; const errorMessage = err instanceof Error ? err.message : 'Error desconocido al procesar el pago';
setError(errorMessage); setError(errorMessage);
@@ -99,7 +111,7 @@ const PaymentForm: React.FC<PaymentFormProps> = ({
} }
}; };
const handleCardChange = (event: any) => { const handlePaymentElementChange = (event: any) => {
setError(event.error?.message || null); setError(event.error?.message || null);
setCardComplete(event.complete); setCardComplete(event.complete);
}; };
@@ -208,33 +220,29 @@ const PaymentForm: React.FC<PaymentFormProps> = ({
</div> </div>
</div> </div>
{/* Card Element */} {/* Payment Element */}
<div className="mb-6"> <div className="mb-6">
<label className="block text-sm font-medium text-text-primary mb-2"> <label className="block text-sm font-medium text-text-primary mb-2">
{t('auth:payment.card_details', 'Detalles de la tarjeta')} {t('auth:payment.payment_details', 'Detalles de Pago')}
</label> </label>
<div className="border border-border-primary rounded-lg p-3 bg-bg-primary"> <div className="border border-border-primary rounded-lg p-3 bg-bg-primary">
<CardElement <PaymentElement
options={{ options={{
style: { layout: 'tabs',
base: { fields: {
fontSize: '16px', billingDetails: 'auto',
color: '#424770', },
'::placeholder': { wallets: {
color: '#aab7c4', applePay: 'auto',
}, googlePay: 'auto',
},
invalid: {
color: '#9e2146',
},
}, },
}} }}
onChange={handleCardChange} onChange={handlePaymentElementChange}
/> />
</div> </div>
<p className="text-xs text-text-tertiary mt-2 flex items-center gap-1"> <p className="text-xs text-text-tertiary mt-2 flex items-center gap-1">
<Lock className="w-3 h-3" /> <Lock className="w-3 h-3" />
{t('auth:payment.card_info_secure', 'Tu información de tarjeta está segura')} {t('auth:payment.payment_info_secure', 'Tu información de pago está segura')}
</p> </p>
</div> </div>
@@ -275,7 +283,7 @@ const PaymentForm: React.FC<PaymentFormProps> = ({
<Button <Button
variant="primary" variant="primary"
size="lg" size="lg"
onClick={onPaymentSuccess} onClick={() => onPaymentSuccess()}
className="w-full" className="w-full"
> >
{t('auth:payment.continue_registration', 'Continuar con el Registro')} {t('auth:payment.continue_registration', 'Continuar con el Registro')}

View File

@@ -703,7 +703,21 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
</Card> </Card>
)} )}
<Elements stripe={stripePromise}> <Elements
stripe={stripePromise}
options={{
mode: 'payment',
amount: 1000, // €10.00 - This is a placeholder for validation
currency: 'eur',
paymentMethodCreation: 'manual',
appearance: {
theme: 'stripe',
variables: {
colorPrimary: '#0066FF',
},
},
}}
>
<PaymentForm <PaymentForm
userName={formData.full_name} userName={formData.full_name}
userEmail={formData.email} userEmail={formData.email}

View File

@@ -3,7 +3,7 @@ import { Crown, Users, MapPin, Package, TrendingUp, RefreshCw, AlertCircle, Chec
import { Button, Card, Badge, Modal } from '../../../../components/ui'; import { Button, Card, Badge, Modal } from '../../../../components/ui';
import { DialogModal } from '../../../../components/ui/DialogModal/DialogModal'; import { DialogModal } from '../../../../components/ui/DialogModal/DialogModal';
import { PageHeader } from '../../../../components/layout'; import { PageHeader } from '../../../../components/layout';
import { useAuthUser, useAuthActions } from '../../../../stores/auth.store'; import { useAuthUser } from '../../../../stores/auth.store';
import { useCurrentTenant } from '../../../../stores'; import { useCurrentTenant } from '../../../../stores';
import { showToast } from '../../../../utils/toast'; import { showToast } from '../../../../utils/toast';
import { subscriptionService, type UsageSummary, type AvailablePlans } from '../../../../api'; import { subscriptionService, type UsageSummary, type AvailablePlans } from '../../../../api';
@@ -17,13 +17,14 @@ import {
trackUsageMetricViewed trackUsageMetricViewed
} from '../../../../utils/subscriptionAnalytics'; } from '../../../../utils/subscriptionAnalytics';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query';
const SubscriptionPage: React.FC = () => { const SubscriptionPage: React.FC = () => {
const user = useAuthUser(); const user = useAuthUser();
const currentTenant = useCurrentTenant(); const currentTenant = useCurrentTenant();
const { notifySubscriptionChanged } = useSubscriptionEvents(); const { notifySubscriptionChanged } = useSubscriptionEvents();
const { refreshAuth } = useAuthActions();
const { t } = useTranslation('subscription'); const { t } = useTranslation('subscription');
const queryClient = useQueryClient();
const [usageSummary, setUsageSummary] = useState<UsageSummary | null>(null); const [usageSummary, setUsageSummary] = useState<UsageSummary | null>(null);
const [availablePlans, setAvailablePlans] = useState<AvailablePlans | null>(null); const [availablePlans, setAvailablePlans] = useState<AvailablePlans | null>(null);
@@ -140,26 +141,52 @@ const SubscriptionPage: React.FC = () => {
const result = await subscriptionService.upgradePlan(tenantId, selectedPlan); const result = await subscriptionService.upgradePlan(tenantId, selectedPlan);
if (result.success) { if (result.success) {
console.log('✅ Subscription upgraded successfully:', {
oldPlan: result.old_plan,
newPlan: result.new_plan,
requiresTokenRefresh: result.requires_token_refresh
});
showToast.success(result.message); showToast.success(result.message);
// Invalidate cache to ensure fresh data on next fetch // Invalidate cache to ensure fresh data on next fetch
subscriptionService.invalidateCache(); subscriptionService.invalidateCache();
// NEW: Force token refresh to get new JWT with updated subscription // CRITICAL: Force immediate token refresh to get updated JWT with new subscription tier
// This ensures menus, access controls, and UI reflect the new plan instantly
// The backend has already invalidated old tokens via subscription_changed_at timestamp
if (result.requires_token_refresh) { if (result.requires_token_refresh) {
try { try {
await refreshAuth(); // From useAuthStore console.log('🔄 Forcing immediate token refresh after subscription upgrade');
showToast.info('Sesión actualizada con nuevo plan'); // Force token refresh by making a dummy API call
} catch (refreshError) { // The API client interceptor will detect stale token and auto-refresh
console.warn('Token refresh failed, user may need to re-login:', refreshError); await subscriptionService.getUsageSummary(tenantId).catch(() => {
// Don't block - the subscription is updated, just the JWT is stale // Ignore errors - we just want to trigger the refresh
console.log('Token refresh triggered via usage summary call');
});
console.log('✅ Token refreshed - new subscription tier now active in JWT');
} catch (error) {
console.warn('Token refresh trigger failed, but subscription is still upgraded:', error);
} }
} }
// Broadcast subscription change event to refresh sidebar and other components // CRITICAL: Invalidate ALL subscription-related React Query caches
notifySubscriptionChanged(); // This forces all components using subscription data to refetch
console.log('🔄 Invalidating React Query caches for subscription data...');
await queryClient.invalidateQueries({ queryKey: ['subscription-usage'] });
await queryClient.invalidateQueries({ queryKey: ['tenant'] });
console.log('✅ React Query caches invalidated');
// Broadcast subscription change event to refresh sidebar and other components
// This increments subscriptionVersion which triggers React Query refetch
console.log('📢 Broadcasting subscription change event...');
notifySubscriptionChanged();
console.log('✅ Subscription change event broadcasted');
// Reload subscription data to show new plan immediately
await loadSubscriptionData(); await loadSubscriptionData();
// Close dialog and clear selection immediately for seamless UX
setUpgradeDialogOpen(false); setUpgradeDialogOpen(false);
setSelectedPlan(''); setSelectedPlan('');
} else { } else {

View File

@@ -200,9 +200,9 @@ export const useAuthStore = create<AuthState>()(
} }
set({ isLoading: true }); set({ isLoading: true });
const response = await authService.refreshToken(refreshToken); const response = await authService.refreshToken(refreshToken);
if (response && response.access_token) { if (response && response.access_token) {
// Set the auth tokens on the API client immediately // Set the auth tokens on the API client immediately
apiClient.setAuthToken(response.access_token); apiClient.setAuthToken(response.access_token);
@@ -231,14 +231,15 @@ export const useAuthStore = create<AuthState>()(
throw new Error('Token refresh failed'); throw new Error('Token refresh failed');
} }
} catch (error) { } catch (error) {
// CRITICAL: Only reset isLoading, don't log out the user
// The user's session is still valid even if token refresh fails
// They can continue using the app with their current token
set({ set({
user: null,
token: null,
refreshToken: null,
isAuthenticated: false,
isLoading: false, isLoading: false,
error: error instanceof Error ? error.message : 'Error al renovar sesión', error: error instanceof Error ? error.message : 'Error al renovar sesión',
}); });
console.error('Token refresh failed but keeping user logged in:', error);
throw error; throw error;
} }
}, },
@@ -337,6 +338,10 @@ export const useAuthStore = create<AuthState>()(
token: state.token, token: state.token,
refreshToken: state.refreshToken, refreshToken: state.refreshToken,
isAuthenticated: state.isAuthenticated, isAuthenticated: state.isAuthenticated,
// CRITICAL: Persist JWT subscription data for UI consistency
jwtSubscription: state.jwtSubscription,
jwtTenantAccess: state.jwtTenantAccess,
primaryTenantId: state.primaryTenantId,
}), }),
onRehydrateStorage: () => (state) => { onRehydrateStorage: () => (state) => {
// Initialize API client with stored tokens when store rehydrates // Initialize API client with stored tokens when store rehydrates

View File

@@ -4,12 +4,17 @@ These endpoints receive events from payment providers like Stripe
""" """
import structlog import structlog
import stripe
from fastapi import APIRouter, Depends, HTTPException, status, Request from fastapi import APIRouter, Depends, HTTPException, status, Request
from typing import Dict, Any from typing import Dict, Any
from datetime import datetime
from app.services.payment_service import PaymentService from app.services.payment_service import PaymentService
from shared.auth.decorators import get_current_user_dep from app.core.config import settings
from shared.monitoring.metrics import track_endpoint_metrics from app.core.database import get_db
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.tenants import Subscription, Tenant
logger = structlog.get_logger() logger = structlog.get_logger()
router = APIRouter() router = APIRouter()
@@ -24,58 +29,295 @@ def get_payment_service():
@router.post("/webhooks/stripe") @router.post("/webhooks/stripe")
async def stripe_webhook( async def stripe_webhook(
request: Request, request: Request,
db: AsyncSession = Depends(get_db),
payment_service: PaymentService = Depends(get_payment_service) payment_service: PaymentService = Depends(get_payment_service)
): ):
""" """
Stripe webhook endpoint to handle payment events Stripe webhook endpoint to handle payment events
This endpoint verifies webhook signatures and processes Stripe events
""" """
try: try:
# Get the payload # Get the payload and signature
payload = await request.body() payload = await request.body()
sig_header = request.headers.get('stripe-signature') sig_header = request.headers.get('stripe-signature')
# In a real implementation, you would verify the signature if not sig_header:
# using the webhook signing secret logger.error("Missing stripe-signature header")
# event = stripe.Webhook.construct_event( raise HTTPException(
# payload, sig_header, settings.STRIPE_WEBHOOK_SECRET status_code=status.HTTP_400_BAD_REQUEST,
# ) detail="Missing signature header"
)
# For now, we'll just log the event
logger.info("Received Stripe webhook", payload=payload.decode('utf-8')) # Verify the webhook signature
try:
event = stripe.Webhook.construct_event(
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
)
except stripe.error.SignatureVerificationError as e:
logger.error("Invalid webhook signature", error=str(e))
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid signature"
)
except ValueError as e:
logger.error("Invalid payload", error=str(e))
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid payload"
)
# Get event type and data
event_type = event['type']
event_data = event['data']['object']
logger.info("Processing Stripe webhook event",
event_type=event_type,
event_id=event.get('id'))
# Process different types of events # Process different types of events
# event_type = event['type'] if event_type == 'checkout.session.completed':
# event_data = event['data']['object'] # Handle successful checkout
await handle_checkout_completed(event_data, db)
# Example processing for different event types:
# if event_type == 'checkout.session.completed': elif event_type == 'customer.subscription.created':
# # Handle successful checkout # Handle new subscription
# pass await handle_subscription_created(event_data, db)
# elif event_type == 'customer.subscription.created':
# # Handle new subscription elif event_type == 'customer.subscription.updated':
# pass # Handle subscription update
# elif event_type == 'customer.subscription.updated': await handle_subscription_updated(event_data, db)
# # Handle subscription update
# pass elif event_type == 'customer.subscription.deleted':
# elif event_type == 'customer.subscription.deleted': # Handle subscription cancellation
# # Handle subscription cancellation await handle_subscription_deleted(event_data, db)
# pass
# elif event_type == 'invoice.payment_succeeded': elif event_type == 'invoice.payment_succeeded':
# # Handle successful payment # Handle successful payment
# pass await handle_payment_succeeded(event_data, db)
# elif event_type == 'invoice.payment_failed':
# # Handle failed payment elif event_type == 'invoice.payment_failed':
# pass # Handle failed payment
await handle_payment_failed(event_data, db)
return {"success": True}
elif event_type == 'customer.subscription.trial_will_end':
# Handle trial ending soon (3 days before)
await handle_trial_will_end(event_data, db)
else:
logger.info("Unhandled webhook event type", event_type=event_type)
return {"success": True, "event_type": event_type}
except HTTPException:
raise
except Exception as e: except Exception as e:
logger.error("Error processing Stripe webhook", error=str(e)) logger.error("Error processing Stripe webhook", error=str(e), exc_info=True)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Webhook error" detail="Webhook processing error"
) )
async def handle_checkout_completed(session: Dict[str, Any], db: AsyncSession):
"""Handle successful checkout session completion"""
logger.info("Processing checkout.session.completed",
session_id=session.get('id'))
customer_id = session.get('customer')
subscription_id = session.get('subscription')
if customer_id and subscription_id:
# Update tenant with subscription info
query = select(Tenant).where(Tenant.stripe_customer_id == customer_id)
result = await db.execute(query)
tenant = result.scalar_one_or_none()
if tenant:
logger.info("Checkout completed for tenant",
tenant_id=str(tenant.id),
subscription_id=subscription_id)
async def handle_subscription_created(subscription: Dict[str, Any], db: AsyncSession):
"""Handle new subscription creation"""
logger.info("Processing customer.subscription.created",
subscription_id=subscription.get('id'))
customer_id = subscription.get('customer')
subscription_id = subscription.get('id')
status_value = subscription.get('status')
# Find tenant by customer ID
query = select(Tenant).where(Tenant.stripe_customer_id == customer_id)
result = await db.execute(query)
tenant = result.scalar_one_or_none()
if tenant:
logger.info("Subscription created for tenant",
tenant_id=str(tenant.id),
subscription_id=subscription_id,
status=status_value)
async def handle_subscription_updated(subscription: Dict[str, Any], db: AsyncSession):
"""Handle subscription updates (status changes, plan changes, etc.)"""
subscription_id = subscription.get('id')
status_value = subscription.get('status')
logger.info("Processing customer.subscription.updated",
subscription_id=subscription_id,
new_status=status_value)
# Find subscription in database
query = select(Subscription).where(Subscription.subscription_id == subscription_id)
result = await db.execute(query)
db_subscription = result.scalar_one_or_none()
if db_subscription:
# Update subscription status
db_subscription.status = status_value
db_subscription.current_period_end = datetime.fromtimestamp(
subscription.get('current_period_end')
)
# Update active status based on Stripe status
if status_value == 'active':
db_subscription.is_active = True
elif status_value in ['canceled', 'past_due', 'unpaid']:
db_subscription.is_active = False
await db.commit()
# Invalidate cache
try:
from app.services.subscription_cache import get_subscription_cache_service
import shared.redis_utils
redis_client = await shared.redis_utils.initialize_redis(settings.REDIS_URL)
cache_service = get_subscription_cache_service(redis_client)
await cache_service.invalidate_subscription_cache(str(db_subscription.tenant_id))
except Exception as cache_error:
logger.error("Failed to invalidate cache", error=str(cache_error))
logger.info("Subscription updated in database",
subscription_id=subscription_id,
tenant_id=str(db_subscription.tenant_id))
async def handle_subscription_deleted(subscription: Dict[str, Any], db: AsyncSession):
"""Handle subscription cancellation/deletion"""
subscription_id = subscription.get('id')
logger.info("Processing customer.subscription.deleted",
subscription_id=subscription_id)
# Find subscription in database
query = select(Subscription).where(Subscription.subscription_id == subscription_id)
result = await db.execute(query)
db_subscription = result.scalar_one_or_none()
if db_subscription:
db_subscription.status = 'canceled'
db_subscription.is_active = False
db_subscription.canceled_at = datetime.utcnow()
await db.commit()
# Invalidate cache
try:
from app.services.subscription_cache import get_subscription_cache_service
import shared.redis_utils
redis_client = await shared.redis_utils.initialize_redis(settings.REDIS_URL)
cache_service = get_subscription_cache_service(redis_client)
await cache_service.invalidate_subscription_cache(str(db_subscription.tenant_id))
except Exception as cache_error:
logger.error("Failed to invalidate cache", error=str(cache_error))
logger.info("Subscription canceled in database",
subscription_id=subscription_id,
tenant_id=str(db_subscription.tenant_id))
async def handle_payment_succeeded(invoice: Dict[str, Any], db: AsyncSession):
"""Handle successful invoice payment"""
invoice_id = invoice.get('id')
subscription_id = invoice.get('subscription')
logger.info("Processing invoice.payment_succeeded",
invoice_id=invoice_id,
subscription_id=subscription_id)
if subscription_id:
# Find subscription and ensure it's active
query = select(Subscription).where(Subscription.subscription_id == subscription_id)
result = await db.execute(query)
db_subscription = result.scalar_one_or_none()
if db_subscription:
db_subscription.status = 'active'
db_subscription.is_active = True
await db.commit()
logger.info("Payment succeeded, subscription activated",
subscription_id=subscription_id,
tenant_id=str(db_subscription.tenant_id))
async def handle_payment_failed(invoice: Dict[str, Any], db: AsyncSession):
"""Handle failed invoice payment"""
invoice_id = invoice.get('id')
subscription_id = invoice.get('subscription')
customer_id = invoice.get('customer')
logger.error("Processing invoice.payment_failed",
invoice_id=invoice_id,
subscription_id=subscription_id,
customer_id=customer_id)
if subscription_id:
# Find subscription and mark as past_due
query = select(Subscription).where(Subscription.subscription_id == subscription_id)
result = await db.execute(query)
db_subscription = result.scalar_one_or_none()
if db_subscription:
db_subscription.status = 'past_due'
db_subscription.is_active = False
await db.commit()
logger.warning("Payment failed, subscription marked past_due",
subscription_id=subscription_id,
tenant_id=str(db_subscription.tenant_id))
# TODO: Send notification to user about payment failure
# You can integrate with your notification service here
async def handle_trial_will_end(subscription: Dict[str, Any], db: AsyncSession):
"""Handle notification that trial will end in 3 days"""
subscription_id = subscription.get('id')
trial_end = subscription.get('trial_end')
logger.info("Processing customer.subscription.trial_will_end",
subscription_id=subscription_id,
trial_end_timestamp=trial_end)
# Find subscription
query = select(Subscription).where(Subscription.subscription_id == subscription_id)
result = await db.execute(query)
db_subscription = result.scalar_one_or_none()
if db_subscription:
logger.info("Trial ending soon for subscription",
subscription_id=subscription_id,
tenant_id=str(db_subscription.tenant_id))
# TODO: Send notification to user about trial ending soon
# You can integrate with your notification service here
@router.post("/webhooks/generic") @router.post("/webhooks/generic")
async def generic_webhook( async def generic_webhook(
request: Request, request: Request,

View File

@@ -6,6 +6,7 @@ This service abstracts payment provider interactions and makes the system paymen
import structlog import structlog
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
import uuid import uuid
from datetime import datetime
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.config import settings from app.core.config import settings
@@ -250,3 +251,53 @@ class PaymentService:
except Exception as e: except Exception as e:
logger.error("Failed to get subscription from payment provider", error=str(e)) logger.error("Failed to get subscription from payment provider", error=str(e))
raise e raise e
async def sync_subscription_status(self, subscription_id: str, db_session: Session) -> Subscription:
"""
Sync subscription status from payment provider to database
This ensures our local subscription status matches the payment provider
"""
try:
# Get current subscription from payment provider
stripe_subscription = await self.payment_provider.get_subscription(subscription_id)
logger.info("Syncing subscription status",
subscription_id=subscription_id,
stripe_status=stripe_subscription.status)
# Update local database record
self.subscription_repo.db_session = db_session
local_subscription = await self.subscription_repo.get_by_stripe_id(subscription_id)
if local_subscription:
# Update status and dates
local_subscription.status = stripe_subscription.status
local_subscription.current_period_end = stripe_subscription.current_period_end
# Handle status-specific logic
if stripe_subscription.status == 'active':
local_subscription.is_active = True
local_subscription.canceled_at = None
elif stripe_subscription.status == 'canceled':
local_subscription.is_active = False
local_subscription.canceled_at = datetime.utcnow()
elif stripe_subscription.status == 'past_due':
local_subscription.is_active = False
elif stripe_subscription.status == 'unpaid':
local_subscription.is_active = False
await self.subscription_repo.update(local_subscription)
logger.info("Subscription status synced successfully",
subscription_id=subscription_id,
new_status=stripe_subscription.status)
else:
logger.warning("Local subscription not found for Stripe subscription",
subscription_id=subscription_id)
return stripe_subscription
except Exception as e:
logger.error("Failed to sync subscription status",
error=str(e),
subscription_id=subscription_id)
raise e

View File

@@ -23,7 +23,7 @@ opentelemetry-instrumentation-httpx==0.60b1
opentelemetry-instrumentation-redis==0.60b1 opentelemetry-instrumentation-redis==0.60b1
opentelemetry-instrumentation-sqlalchemy==0.60b1 opentelemetry-instrumentation-sqlalchemy==0.60b1
python-jose[cryptography]==3.3.0 python-jose[cryptography]==3.3.0
stripe==11.3.0 stripe==14.1.0
python-multipart==0.0.6 python-multipart==0.0.6
cryptography==44.0.0 cryptography==44.0.0
email-validator==2.2.0 email-validator==2.2.0

View File

@@ -5,6 +5,7 @@ This module implements the PaymentProvider interface for Stripe
import stripe import stripe
import structlog import structlog
import uuid
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
from datetime import datetime from datetime import datetime
@@ -28,16 +29,26 @@ class StripeProvider(PaymentProvider):
async def create_customer(self, customer_data: Dict[str, Any]) -> PaymentCustomer: async def create_customer(self, customer_data: Dict[str, Any]) -> PaymentCustomer:
""" """
Create a customer in Stripe Create a customer in Stripe with idempotency key
""" """
try: try:
idempotency_key = f"create_customer_{uuid.uuid4()}"
logger.info("Creating Stripe customer",
email=customer_data.get('email'),
customer_name=customer_data.get('name'))
stripe_customer = stripe.Customer.create( stripe_customer = stripe.Customer.create(
email=customer_data.get('email'), email=customer_data.get('email'),
name=customer_data.get('name'), name=customer_data.get('name'),
phone=customer_data.get('phone'), phone=customer_data.get('phone'),
metadata=customer_data.get('metadata', {}) metadata=customer_data.get('metadata', {}),
idempotency_key=idempotency_key
) )
logger.info("Stripe customer created successfully",
customer_id=stripe_customer.id)
return PaymentCustomer( return PaymentCustomer(
id=stripe_customer.id, id=stripe_customer.id,
email=stripe_customer.email, email=stripe_customer.email,
@@ -45,40 +56,70 @@ class StripeProvider(PaymentProvider):
created_at=datetime.fromtimestamp(stripe_customer.created) created_at=datetime.fromtimestamp(stripe_customer.created)
) )
except stripe.error.StripeError as e: except stripe.error.StripeError as e:
logger.error("Failed to create Stripe customer", error=str(e)) logger.error("Failed to create Stripe customer",
error=str(e),
error_type=type(e).__name__,
email=customer_data.get('email'))
raise e raise e
async def create_subscription(self, customer_id: str, plan_id: str, payment_method_id: str, trial_period_days: Optional[int] = None) -> Subscription: async def create_subscription(self, customer_id: str, plan_id: str, payment_method_id: str, trial_period_days: Optional[int] = None) -> Subscription:
""" """
Create a subscription in Stripe Create a subscription in Stripe with idempotency and enhanced error handling
""" """
try: try:
# Attach payment method to customer subscription_idempotency_key = f"create_subscription_{uuid.uuid4()}"
payment_method_idempotency_key = f"attach_pm_{uuid.uuid4()}"
customer_update_idempotency_key = f"update_customer_{uuid.uuid4()}"
logger.info("Creating Stripe subscription",
customer_id=customer_id,
plan_id=plan_id,
payment_method_id=payment_method_id)
# Attach payment method to customer with idempotency
stripe.PaymentMethod.attach( stripe.PaymentMethod.attach(
payment_method_id, payment_method_id,
customer=customer_id, customer=customer_id,
idempotency_key=payment_method_idempotency_key
) )
# Set customer's default payment method logger.info("Payment method attached to customer",
customer_id=customer_id,
payment_method_id=payment_method_id)
# Set customer's default payment method with idempotency
stripe.Customer.modify( stripe.Customer.modify(
customer_id, customer_id,
invoice_settings={ invoice_settings={
'default_payment_method': payment_method_id 'default_payment_method': payment_method_id
} },
idempotency_key=customer_update_idempotency_key
) )
logger.info("Customer default payment method updated",
customer_id=customer_id)
# Create subscription with trial period if specified # Create subscription with trial period if specified
subscription_params = { subscription_params = {
'customer': customer_id, 'customer': customer_id,
'items': [{'price': plan_id}], 'items': [{'price': plan_id}],
'default_payment_method': payment_method_id, 'default_payment_method': payment_method_id,
'idempotency_key': subscription_idempotency_key,
'expand': ['latest_invoice.payment_intent']
} }
if trial_period_days: if trial_period_days:
subscription_params['trial_period_days'] = trial_period_days subscription_params['trial_period_days'] = trial_period_days
logger.info("Subscription includes trial period",
trial_period_days=trial_period_days)
stripe_subscription = stripe.Subscription.create(**subscription_params) stripe_subscription = stripe.Subscription.create(**subscription_params)
logger.info("Stripe subscription created successfully",
subscription_id=stripe_subscription.id,
status=stripe_subscription.status,
current_period_end=stripe_subscription.current_period_end)
return Subscription( return Subscription(
id=stripe_subscription.id, id=stripe_subscription.id,
customer_id=stripe_subscription.customer, customer_id=stripe_subscription.customer,
@@ -88,8 +129,25 @@ class StripeProvider(PaymentProvider):
current_period_end=datetime.fromtimestamp(stripe_subscription.current_period_end), current_period_end=datetime.fromtimestamp(stripe_subscription.current_period_end),
created_at=datetime.fromtimestamp(stripe_subscription.created) created_at=datetime.fromtimestamp(stripe_subscription.created)
) )
except stripe.error.CardError as e:
logger.error("Card error during subscription creation",
error=str(e),
error_code=e.code,
decline_code=e.decline_code,
customer_id=customer_id)
raise e
except stripe.error.InvalidRequestError as e:
logger.error("Invalid request during subscription creation",
error=str(e),
param=e.param,
customer_id=customer_id)
raise e
except stripe.error.StripeError as e: except stripe.error.StripeError as e:
logger.error("Failed to create Stripe subscription", error=str(e)) logger.error("Failed to create Stripe subscription",
error=str(e),
error_type=type(e).__name__,
customer_id=customer_id,
plan_id=plan_id)
raise e raise e
async def update_payment_method(self, customer_id: str, payment_method_id: str) -> PaymentMethod: async def update_payment_method(self, customer_id: str, payment_method_id: str) -> PaymentMethod: