1986 lines
58 KiB
Markdown
1986 lines
58 KiB
Markdown
# Frontend 3D Secure Implementation Guide
|
||
|
||
## 📊 Visual Flow Overview
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
Start([User Clicks Subscribe]) --> Immediate{Immediate<br/>Charge?}
|
||
|
||
Immediate -->|YES| Direct[Direct Subscription Pattern]
|
||
Immediate -->|NO| Setup[Setup Intent Pattern]
|
||
|
||
Direct --> CreateSub[POST /api/subscription/update]
|
||
CreateSub --> Check3DS{Requires<br/>3DS?}
|
||
|
||
Check3DS -->|YES| Show3DS[Show 3DS Popup]
|
||
Check3DS -->|NO| Success1[Subscription Active ✓]
|
||
|
||
Show3DS --> Customer3DS[Customer Authenticates]
|
||
Customer3DS --> Auto[Stripe Auto-Charges]
|
||
Auto --> Webhook[Webhook Updates Status]
|
||
Webhook --> Success2[Subscription Active ✓]
|
||
|
||
Setup --> SetupCard[POST /api/subscription/setupCard]
|
||
SetupCard --> CheckSetup{Requires<br/>3DS?}
|
||
|
||
CheckSetup -->|YES| Setup3DS[Show 3DS Popup]
|
||
CheckSetup -->|NO| CardAuth[Card Authenticated]
|
||
|
||
Setup3DS --> SetupComplete[Customer Authenticates]
|
||
SetupComplete --> CardAuth
|
||
CardAuth --> CreateNoCharge[Create Subscriptions<br/>No Immediate Charge]
|
||
CreateNoCharge --> Success3[Subscriptions Active ✓]
|
||
|
||
style Direct fill:#e1f5e1
|
||
style Setup fill:#fff3cd
|
||
style Success1 fill:#d4edda
|
||
style Success2 fill:#d4edda
|
||
style Success3 fill:#d4edda
|
||
style Show3DS fill:#f8d7da
|
||
style Setup3DS fill:#f8d7da
|
||
```
|
||
|
||
## Problem Statement
|
||
|
||
When creating subscriptions with 3D Secure (3DS) cards:
|
||
- Payment intent returns `requires_action` status requiring customer authentication
|
||
- Creating multiple subscriptions (package + addons) with same card triggers 3DS twice
|
||
- Results in partial subscription creation if first 3DS not completed before second charge
|
||
- Complex error handling and poor user experience
|
||
|
||
## Solutions
|
||
|
||
### ⭐ Solution 1: Direct Subscription Pattern (Recommended for Immediate Charges)
|
||
|
||
**UPDATED RECOMMENDATION**: For subscriptions with immediate charges, handle 3DS during subscription creation.
|
||
|
||
**Benefits**:
|
||
- ✅ **Single authentication per subscription**
|
||
- ✅ No double 3DS (Setup Intent + Payment)
|
||
- ✅ Works for single OR multiple subscriptions
|
||
- ✅ Simpler flow
|
||
|
||
**When to Use**:
|
||
- ✅ Creating subscription with immediate charge (no trial)
|
||
- ✅ Upgrading/downgrading existing subscription
|
||
- ✅ Package + addon creation with immediate billing
|
||
- ✅ Any scenario where first payment happens NOW
|
||
|
||
**⚠️ CRITICAL: Multiple Subscriptions Require Multiple 3DS**
|
||
|
||
**Test Date**: January 16, 2026 - **Confirmed Behavior**:
|
||
|
||
```javascript
|
||
// Calling /update twice with package + addon:
|
||
// 1st call: Package subscription → requires 3DS ✓
|
||
// 2nd call: Addon subscription → requires 3DS AGAIN ✓
|
||
// Result: Frontend must handle 3DS for EACH subscription
|
||
```
|
||
|
||
**Why?**
|
||
- Each subscription creates its own PaymentIntent
|
||
- Stripe does NOT reuse 3DS authentication between PaymentIntents
|
||
- Even using the same payment method seconds apart requires separate authentication
|
||
- Security policy - each payment attempt is independent
|
||
|
||
**Implementation Impact**:
|
||
- Frontend MUST be ready to handle 3DS popup for EACH `/update` call
|
||
- Cannot batch authenticate multiple subscriptions
|
||
- User sees 3DS prompt multiple times (expected Stripe behavior)
|
||
|
||
**💡 In Practice (Recommended Approach)**:
|
||
- **This scenario is RARE** - most cards don't require 3DS
|
||
- **When it happens**: Users understand multiple popups for multiple subscriptions
|
||
- **Accept multiple popups** - simpler implementation, immediate charging
|
||
- **No workarounds needed** - trial period adds unnecessary complexity
|
||
|
||
**See**: [Direct Subscription Pattern](#direct-subscription-pattern) section below
|
||
|
||
---
|
||
|
||
### Solution 2: Setup Intent Pattern (For Future Charges Only)
|
||
|
||
Pre-authenticate the card for **future off-session payments** only.
|
||
|
||
**Benefits**:
|
||
- ✅ Validates card without charging
|
||
- ✅ Good for trial periods
|
||
- ✅ For Reactivating/renewing subscriptions (cancel_at_period_end=false) work without immediate charge
|
||
|
||
**When to Use**:
|
||
- ✅ User updates cancel_at_period_end=false with NEW unverified card
|
||
- ✅ Adding payment method during trial (no immediate charge)
|
||
- ✅ Changing (default) payment method on active subscription (next charge is future)
|
||
- ✅ Pre-validating card for scheduled billing
|
||
|
||
**⚠️ DO NOT Use For**:
|
||
- ❌ Creating subscription with immediate charge
|
||
- ❌ Upgrading/downgrading (causes double authentication)
|
||
|
||
**Why Avoid for Immediate Charges?**
|
||
- Setup Intent authenticates for **future off-session payments**
|
||
- First payment is **on-session** → requires 3DS AGAIN
|
||
- Results in double authentication (bad UX)
|
||
|
||
**See**: [Setup Intent Pattern](#setup-intent-pattern) section below
|
||
|
||
---
|
||
|
||
## 🔄 Special Case: Reactivating Subscription with New Card
|
||
|
||
### Scenario
|
||
|
||
User has subscription with `cancel_at_period_end=true` and wants to:
|
||
1. Update to `cancel_at_period_end=false` (reactivate)
|
||
2. Use a NEW payment method that requires 3DS
|
||
|
||
### Problem
|
||
|
||
- Subscription is already active (no immediate charge)
|
||
- Just changing `cancel_at_period_end` doesn't trigger payment
|
||
- But new card needs authentication for FUTURE recurring charges
|
||
|
||
### Solution: Use Setup Intent
|
||
|
||
**Step 1**: Authenticate new card with Setup Intent
|
||
```typescript
|
||
// Frontend calls setupCard endpoint
|
||
const result = await stripeService.setupCardAuthentication(custId, newPmId);
|
||
|
||
if (result.requiresAction) {
|
||
// Handle 3DS
|
||
const { error } = await stripe.confirmCardSetup(result.clientSecret);
|
||
if (error) {
|
||
// Handle error
|
||
return;
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 2**: Update subscription settings
|
||
```typescript
|
||
// Now update cancel_at_period_end with authenticated card
|
||
await subscriptionService.updateSettings({
|
||
cancel_at_period_end: false,
|
||
default_payment_method: newPmId // Already authenticated via Setup Intent
|
||
});
|
||
```
|
||
|
||
**Why This Works**:
|
||
- ✅ No immediate charge (subscription already active)
|
||
- ✅ Card authenticated for future recurring payments
|
||
- ✅ Next billing cycle uses authenticated payment method
|
||
- ✅ No double authentication
|
||
|
||
---
|
||
|
||
## 📋 Decision Flow Chart
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
Q1{Is there an<br/>immediate charge?}
|
||
|
||
Q1 -->|YES| UseCase1["Use Direct Subscription Pattern<br/>• New subscription<br/>• Upgrade/Downgrade<br/>• Package + Addons"]
|
||
Q1 -->|NO| UseCase2["Use Setup Intent Pattern<br/>• Trial period<br/>• Reactivation (cancel_at_period_end=false)<br/>• Future billing"]
|
||
|
||
UseCase1 --> Direct["Direct Pattern Flow:<br/>1. POST /update<br/>2. Handle 3DS if required<br/>3. Stripe auto-charges after 3DS"]
|
||
|
||
UseCase2 --> Setup["Setup Intent Flow:<br/>1. POST /setupCard<br/>2. Handle 3DS if required<br/>3. POST /update (no charge)<br/>4. Subscriptions active"]
|
||
|
||
Direct --> Result1["✓ Immediate charge<br/>✓ May require 3DS popup<br/>✓ Simple implementation"]
|
||
Setup --> Result2["✓ No immediate charge<br/>✓ Pre-authenticated card<br/>✓ Avoid double 3DS"]
|
||
|
||
style UseCase1 fill:#e1f5e1
|
||
style UseCase2 fill:#fff3cd
|
||
style Result1 fill:#d4edda
|
||
style Result2 fill:#d4edda
|
||
```
|
||
|
||
---
|
||
|
||
## 🎯 Setup Intent Pattern
|
||
|
||
### 📊 Flow Diagram: Setup Intent for Future Charges
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant User
|
||
participant Frontend
|
||
participant Backend
|
||
participant Stripe
|
||
participant Bank
|
||
|
||
Note over User,Bank: Phase 1: Authenticate Card (No Charge)
|
||
|
||
User->>Frontend: Reactivate Subscription<br/>(cancel_at_period_end=false)
|
||
Frontend->>Backend: POST /api/subscription/setupCard<br/>{custId, pmId}
|
||
|
||
Backend->>Stripe: Create SetupIntent
|
||
|
||
alt Card Requires 3DS
|
||
Stripe-->>Backend: {requiresAction: true,<br/>client_secret}
|
||
Backend-->>Frontend: {requiresAction: true,<br/>client_secret}
|
||
|
||
Frontend->>User: Show 3DS Popup
|
||
Frontend->>Stripe: confirmCardSetup(client_secret)
|
||
Stripe->>Bank: Request Authentication
|
||
Bank->>User: Show 3DS Challenge
|
||
User->>Bank: Complete Authentication
|
||
Bank-->>Stripe: Authentication Success
|
||
Stripe-->>Frontend: SetupIntent: succeeded
|
||
|
||
else No 3DS Required
|
||
Stripe-->>Backend: {requiresAction: false}
|
||
Backend-->>Frontend: {requiresAction: false}
|
||
end
|
||
|
||
Note over User,Bank: Phase 2: Update Subscription (No Charge)
|
||
|
||
Frontend->>Backend: POST /api/subscription/updateSettings<br/>{cancel_at_period_end: false}
|
||
Backend->>Stripe: Update Subscription
|
||
Stripe-->>Backend: Subscription updated
|
||
Backend-->>Frontend: Success
|
||
Frontend->>User: Show Success ✓
|
||
|
||
Note over User,Bank: Phase 3: Future Billing (Off-Session)
|
||
|
||
Stripe->>Stripe: Billing cycle ends
|
||
Stripe->>Stripe: Auto-charge (no popup)<br/>using pre-authenticated card
|
||
Stripe->>Backend: Webhook: invoice.payment_succeeded
|
||
Backend->>User: Send Receipt Email
|
||
```
|
||
|
||
### Overview
|
||
|
||
This pattern separates card authentication from subscription creation:
|
||
|
||
1. **Step 1**: Authenticate card with `/api/subscription/setupCard`
|
||
2. **Step 2**: Handle 3DS if required (single popup)
|
||
3. **Step 3**: Create all subscriptions with authenticated card
|
||
|
||
### Backend Implementation
|
||
|
||
**New Endpoint**: `POST /api/subscription/setupCard`
|
||
|
||
**Code**: [controllers/subscription.js](../controllers/subscription.js#L769)
|
||
|
||
**Request**:
|
||
```json
|
||
{
|
||
"custId": "cus_xxx",
|
||
"pmId": "pm_xxx"
|
||
}
|
||
```
|
||
|
||
**Response (No 3DS)**:
|
||
```json
|
||
{
|
||
"requiresAction": false,
|
||
"status": "succeeded",
|
||
"setupIntentId": "seti_xxx",
|
||
"message": "Card authenticated successfully"
|
||
}
|
||
```
|
||
|
||
**Response (3DS Required)**:
|
||
```json
|
||
{
|
||
"requiresAction": true,
|
||
"clientSecret": "seti_xxx_secret_xxx",
|
||
"setupIntentId": "seti_xxx",
|
||
"status": "requires_action",
|
||
"message": "Card authentication required"
|
||
}
|
||
```
|
||
|
||
### Frontend Implementation - Complete Example
|
||
|
||
#### 1. Update Stripe Service
|
||
|
||
**File**: `client/app/services/stripe.service.ts`
|
||
|
||
```typescript
|
||
import { Injectable } from '@angular/core';
|
||
|
||
declare var Stripe: any;
|
||
|
||
@Injectable()
|
||
export class StripeService {
|
||
private stripe: any;
|
||
|
||
constructor() {
|
||
// Initialize with your publishable key
|
||
this.stripe = Stripe('pk_test_51LlCfSJxyI1MWs2Ty9utAc7QHhAa4YT6VPosvDdFtRaRQJchCLgd4NGvnarZQsCKiQUfJeOmnzs81w0AktP0N1o300Jd4q4m8n');
|
||
}
|
||
|
||
/**
|
||
* Authenticate card using SetupIntent (for pre-authentication)
|
||
* @param clientSecret SetupIntent client_secret from backend
|
||
* @returns Promise that resolves when authentication completes
|
||
*/
|
||
async confirmCardSetup(clientSecret: string): Promise<any> {
|
||
try {
|
||
const result = await this.stripe.confirmCardSetup(clientSecret);
|
||
|
||
if (result.error) {
|
||
throw new Error(result.error.message);
|
||
}
|
||
|
||
return result.setupIntent;
|
||
|
||
} catch (error) {
|
||
console.error('Card setup authentication error:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle 3DS authentication for payment (legacy - for single subscriptions)
|
||
* @param clientSecret Payment Intent client_secret from backend
|
||
* @returns Promise with payment intent result
|
||
*/
|
||
async handleCardAction(clientSecret: string): Promise<any> {
|
||
try {
|
||
const result = await this.stripe.handleCardAction(clientSecret);
|
||
|
||
if (result.error) {
|
||
throw new Error(result.error.message);
|
||
}
|
||
|
||
return result.paymentIntent;
|
||
|
||
} catch (error) {
|
||
console.error('3DS authentication error:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 🔄 Multi-Subscription 3DS Handling
|
||
|
||
### 📊 Flow Diagram: Package + Addon with 3DS
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant User
|
||
participant Frontend
|
||
participant Backend
|
||
participant Stripe
|
||
|
||
Note over User,Stripe: First Subscription: Package
|
||
|
||
User->>Frontend: Select Package + Addon
|
||
Frontend->>Backend: POST /update<br/>{package: ess_1, addons: []}
|
||
Backend->>Stripe: Create Package Subscription
|
||
Stripe-->>Backend: Subscription: incomplete<br/>requires_action, client_secret
|
||
Backend-->>Frontend: {requiresAction: true, client_secret}
|
||
|
||
Frontend->>User: 3DS Popup #1 (Package)
|
||
User->>Frontend: Complete Authentication
|
||
Frontend->>Stripe: confirmCardPayment(client_secret)
|
||
Stripe-->>Frontend: PaymentIntent: succeeded
|
||
|
||
Note over Frontend,Stripe: ⚠️ Subscription still 'incomplete'!
|
||
|
||
Frontend->>Frontend: Poll subscription status
|
||
Stripe->>Stripe: Auto-charge card (background)
|
||
Stripe->>Stripe: Update subscription → 'active'
|
||
Stripe->>Backend: Webhook: subscription.updated
|
||
Backend->>Backend: Update DB
|
||
|
||
Frontend->>Backend: GET /subscription/status
|
||
Backend-->>Frontend: Subscription: active
|
||
Frontend->>User: ✓ Package activated
|
||
|
||
Note over User,Stripe: Second Subscription: Addon (Same Card!)
|
||
|
||
Frontend->>Backend: POST /update<br/>{package: ess_1, addons: [addon_1]}
|
||
Backend->>Stripe: Create Addon Subscription
|
||
|
||
rect rgb(255, 200, 200)
|
||
Note over Stripe: Each PaymentIntent = New 3DS Required!
|
||
Stripe-->>Backend: Subscription: incomplete<br/>requires_action, client_secret
|
||
Backend-->>Frontend: {requiresAction: true, client_secret}
|
||
end
|
||
|
||
Frontend->>User: 3DS Popup #2 (Addon)
|
||
User->>Frontend: Complete Authentication AGAIN
|
||
Frontend->>Stripe: confirmCardPayment(client_secret)
|
||
Stripe-->>Frontend: PaymentIntent: succeeded
|
||
|
||
Note over Frontend,Stripe: ⚠️ Subscription still 'incomplete'!
|
||
|
||
Frontend->>Frontend: Poll subscription status
|
||
Stripe->>Stripe: Auto-charge card (background)
|
||
Stripe->>Stripe: Update subscription → 'active'
|
||
Stripe->>Backend: Webhook: subscription.updated
|
||
Backend->>Backend: Update DB
|
||
|
||
Frontend->>Backend: GET /subscription/status
|
||
Backend-->>Frontend: Subscription: active
|
||
Frontend->>User: ✓ Addon activated
|
||
|
||
Note over User,Stripe: Result: 2 subscriptions = 2 popups + 2 polling cycles ⚠️
|
||
```
|
||
|
||
### 📈 Frequency Analysis
|
||
|
||
```mermaid
|
||
pie title "Multi-Subscription 3DS Frequency"
|
||
"No 3DS (most cards)" : 85
|
||
"Single subscription with 3DS" : 13
|
||
"Multi-subscription with 3DS" : 2
|
||
```
|
||
|
||
**Recommendation**: Accept multiple popups - this is a <2% edge case.
|
||
|
||
### How Rare Is This?
|
||
|
||
**In Production**:
|
||
- 3DS required: ~5-15% of cards (mainly European/UK cards)
|
||
- Multiple subscriptions: ~10-20% of checkouts
|
||
- **Both together: <2% of checkouts**
|
||
|
||
**Recommendation**: Accept multiple popups for this rare edge case. Don't over-engineer.
|
||
|
||
---
|
||
|
||
### Critical Behavior (Confirmed January 16, 2026)
|
||
|
||
**Each subscription requires its own 3DS authentication**, even when:
|
||
- Using the same payment method
|
||
- Created seconds apart
|
||
- Within the same user session
|
||
|
||
### Example Flow: Package + Addon Creation
|
||
|
||
```typescript
|
||
// Step 1: Create package subscription
|
||
const packageResult = await this.subscriptionService.updateSubscription({
|
||
package: 'ess_1',
|
||
addons: [] // Will add addon after
|
||
});
|
||
|
||
if (packageResult.requiresAction) {
|
||
// Handle 3DS for package
|
||
await this.stripeService.handleCardAction(packageResult.client_secret);
|
||
console.log('✓ Package 3DS completed');
|
||
}
|
||
|
||
// Step 2: Create addon subscription (SAME card, seconds later)
|
||
const addonResult = await this.subscriptionService.updateSubscription({
|
||
package: 'ess_1', // Already exists, no change
|
||
addons: [{ price: 'addon_1', quantity: 1 }]
|
||
});
|
||
|
||
if (addonResult.requiresAction) {
|
||
// ⚠️ WILL REQUIRE 3DS AGAIN (not reused from package)
|
||
await this.stripeService.handleCardAction(addonResult.client_secret);
|
||
console.log('✓ Addon 3DS completed');
|
||
}
|
||
```
|
||
|
||
### Why Multiple 3DS Prompts?
|
||
|
||
**Stripe Security Design**:
|
||
- Each PaymentIntent is an independent payment attempt
|
||
- 3DS authentication is tied to specific PaymentIntent (not payment method)
|
||
- Cannot "carry over" authentication between PaymentIntents
|
||
- Prevents replay attacks and ensures each charge is authorized
|
||
|
||
### User Experience Considerations
|
||
|
||
**What User Sees**:
|
||
1. Selects package + addon
|
||
2. Clicks "Subscribe"
|
||
3. **First 3DS popup** - authenticates package subscription
|
||
4. Package created successfully
|
||
5. **Second 3DS popup** - authenticates addon subscription
|
||
6. Both subscriptions active
|
||
|
||
**Best Practices**:
|
||
- ✅ Show loading indicator between 3DS prompts
|
||
- ✅ Display message: "Authenticating package..." then "Authenticating addon..."
|
||
- ✅ Don't let user close dialog during authentication sequence
|
||
- ✅ Handle cancellation gracefully (partial subscription state)
|
||
- ❌ Don't promise "one authentication" for multiple subscriptions
|
||
|
||
### Alternative: Delay First Charge with Trial Period
|
||
|
||
**⚠️ EDGE CASE ONLY** - Not recommended for normal checkout flow!
|
||
|
||
**When to Consider**:
|
||
- ✅ Creating 5+ subscriptions at once (very rare)
|
||
- ✅ Specific customer requirement to avoid multiple popups
|
||
- ❌ **DO NOT use by default** - adds complexity for rare scenario
|
||
|
||
**What `trial_period_days` Does**:
|
||
- Subscription starts immediately (customer gets access NOW)
|
||
- First charge is DELAYED by X days
|
||
- No immediate PaymentIntent = no 3DS popup during creation
|
||
- After trial ends, Stripe automatically charges (off-session, using pre-authenticated card)
|
||
|
||
**Example: Avoid Multiple 3DS by Delaying Charge**:
|
||
|
||
```typescript
|
||
// Step 1: Pre-authenticate card (ONE 3DS popup)
|
||
const setupResult = await this.subscriptionService.setupCard(customerId, pmId);
|
||
if (setupResult.requiresAction) {
|
||
await this.stripeService.confirmCardSetup(setupResult.clientSecret);
|
||
// ✓ Customer completed 3DS authentication
|
||
}
|
||
|
||
// Step 2: Create both subscriptions with 1-day trial (NO additional 3DS)
|
||
const result = await this.subscriptionService.updateSubscription({
|
||
package: 'ess_1',
|
||
addons: [{ price: 'addon_1', quantity: 1 }],
|
||
trial_period_days: 1 // Delay charge by 1 day
|
||
});
|
||
// ✓ Both subscriptions active immediately
|
||
// ✓ Customer gets access NOW
|
||
// ✓ In 1 day, Stripe charges automatically (no popup)
|
||
```
|
||
|
||
**Timeline**:
|
||
- **Day 0 (now)**: Customer authenticates card (1 popup) → Both subscriptions active → Customer gets access
|
||
- **Day 1**: Stripe charges automatically (no popup needed)
|
||
|
||
**⚠️ Business Considerations**:
|
||
- Customer is charged tomorrow, not today
|
||
- Revenue recognition delayed by 1 day
|
||
- Customer expects immediate charge when they click "Subscribe"
|
||
- May need to explain "Payment will be processed within 24 hours"
|
||
|
||
**When NOT to Use**:
|
||
- ❌ Customer expects immediate charge confirmation
|
||
- ❌ Accounting requires same-day revenue
|
||
- ❌ Customer might cancel within 24 hours (before charge)
|
||
|
||
**When to Consider**:
|
||
- ✅ Creating 3+ subscriptions (avoid 3+ popups)
|
||
- ✅ UX priority (single authentication > immediate charge)
|
||
- ✅ Can handle delayed revenue recognition
|
||
|
||
#### 2. Update Subscription Service
|
||
|
||
**File**: `client/app/services/subscription.service.ts`
|
||
|
||
```typescript
|
||
import { Injectable } from '@angular/core';
|
||
import { HttpClient } from '@angular/common/http';
|
||
import { Observable } from 'rxjs';
|
||
|
||
@Injectable()
|
||
export class SubscriptionService {
|
||
private apiUrl = '/api/subscription';
|
||
|
||
constructor(private http: HttpClient) {}
|
||
|
||
/**
|
||
* Pre-authenticate card before creating subscriptions
|
||
*/
|
||
setupCard(custId: string, pmId: string): Observable<any> {
|
||
return this.http.post(`${this.apiUrl}/setupCard`, {
|
||
custId,
|
||
pmId
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Create/update subscriptions
|
||
*/
|
||
updateSubscriptions(data: {
|
||
pmId?: string,
|
||
package?: string,
|
||
addons?: any[],
|
||
defaultPM?: boolean,
|
||
coupon?: string
|
||
}): Observable<any> {
|
||
return this.http.post(`${this.apiUrl}/update`, data);
|
||
}
|
||
|
||
/**
|
||
* Check subscription status (for polling after 3DS)
|
||
*/
|
||
checkSubscriptionStatus(subscriptionId: string): Observable<any> {
|
||
return this.http.get(`${this.apiUrl}/status/${subscriptionId}`);
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 3. Update Subscription Component
|
||
|
||
**File**: `client/app/components/subscription/subscription.component.ts`
|
||
|
||
```typescript
|
||
import { Component } from '@angular/core';
|
||
import { SubscriptionService } from '../../services/subscription.service';
|
||
import { StripeService } from '../../services/stripe.service';
|
||
|
||
@Component({
|
||
selector: 'app-subscription',
|
||
templateUrl: './subscription.component.html'
|
||
})
|
||
export class SubscriptionComponent {
|
||
loading = false;
|
||
loadingMessage = '';
|
||
customerId: string; // From user session/auth
|
||
|
||
constructor(
|
||
private subscriptionService: SubscriptionService,
|
||
private stripeService: StripeService
|
||
) {}
|
||
|
||
/**
|
||
* Create subscriptions with Setup Intent pattern
|
||
* Recommended for package + addons
|
||
*/
|
||
async createSubscriptionsWithSetupIntent(
|
||
packageId: string,
|
||
addons: any[],
|
||
pmId: string,
|
||
couponCode?: string
|
||
) {
|
||
this.loading = true;
|
||
this.loadingMessage = 'Verifying payment method...';
|
||
|
||
try {
|
||
// STEP 1: Pre-authenticate card
|
||
console.log('Authenticating card...');
|
||
const setupResult = await this.subscriptionService
|
||
.setupCard(this.customerId, pmId)
|
||
.toPromise();
|
||
|
||
// STEP 2: Handle 3DS if required
|
||
if (setupResult.requiresAction && setupResult.clientSecret) {
|
||
console.log('3DS authentication required...');
|
||
this.loadingMessage = 'Please complete authentication...';
|
||
|
||
const setupIntent = await this.stripeService.confirmCardSetup(
|
||
setupResult.clientSecret
|
||
);
|
||
|
||
if (setupIntent.status !== 'succeeded') {
|
||
throw new Error('Card authentication failed or was cancelled');
|
||
}
|
||
|
||
console.log('Card authenticated successfully via 3DS');
|
||
} else {
|
||
console.log('Card authenticated (no 3DS required)');
|
||
}
|
||
|
||
// STEP 3: Create all subscriptions with authenticated card
|
||
this.loadingMessage = 'Creating subscriptions...';
|
||
console.log('Creating subscriptions with authenticated card...');
|
||
|
||
const subscriptions = await this.subscriptionService
|
||
.updateSubscriptions({
|
||
pmId: pmId,
|
||
package: packageId,
|
||
addons: addons,
|
||
defaultPM: true,
|
||
coupon: couponCode
|
||
})
|
||
.toPromise();
|
||
|
||
// Success!
|
||
console.log('All subscriptions created successfully');
|
||
this.handleSuccess(subscriptions);
|
||
|
||
} catch (error) {
|
||
console.error('Subscription error:', error);
|
||
this.handleError(error);
|
||
} finally {
|
||
this.loading = false;
|
||
this.loadingMessage = '';
|
||
}
|
||
}
|
||
|
||
private handleSuccess(subscriptions: any) {
|
||
alert('Subscriptions created successfully!');
|
||
// Navigate to dashboard or show success message
|
||
}
|
||
|
||
private handleError(error: any) {
|
||
let message = 'Failed to create subscriptions';
|
||
|
||
if (error.message?.includes('authentication')) {
|
||
message = 'Card authentication failed. Please try again.';
|
||
} else if (error.message?.includes('cancelled')) {
|
||
message = 'Authentication was cancelled. Please try again.';
|
||
} else if (error.error?.error?.message) {
|
||
message = error.error.error.message;
|
||
}
|
||
|
||
alert('Error: ' + message);
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 🚨 Error Handling Flow Diagram
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
Start[Subscription Request] --> CreateSub[Backend Creates Subscription]
|
||
CreateSub --> Check{Payment<br/>Status?}
|
||
|
||
Check -->|requires_action| Return3DS[Return client_secret]
|
||
Check -->|succeeded| Success[Subscription Active ✓]
|
||
Check -->|requires_payment_method| Failed[Payment Failed]
|
||
|
||
Return3DS --> Show[Show 3DS Popup]
|
||
Show --> User3DS{User<br/>Action?}
|
||
|
||
User3DS -->|Completes| Confirm[confirmCardPayment]
|
||
User3DS -->|Cancels| Cancel[User Cancelled]
|
||
User3DS -->|Timeout| Timeout[3DS Timeout]
|
||
|
||
Confirm --> Result{Payment<br/>Result?}
|
||
|
||
Result -->|succeeded| Auto[Stripe Auto-Updates<br/>Subscription to Active]
|
||
Result -->|failed| Declined[Card Declined]
|
||
|
||
Auto --> Webhook[Webhook Updates DB]
|
||
Webhook --> Email[Send Confirmation Email]
|
||
Email --> FinalSuccess[Show Success Message ✓]
|
||
|
||
Cancel --> ShowCancel[Show: Authentication Cancelled<br/>Try Again]
|
||
Timeout --> ShowTimeout[Show: Authentication Timeout<br/>Try Again]
|
||
Declined --> ShowDeclined[Show: Card Declined<br/>Try Different Card]
|
||
Failed --> ShowFailed[Show: Payment Failed<br/>Check Card Details]
|
||
|
||
ShowCancel --> Retry{User<br/>Retries?}
|
||
ShowTimeout --> Retry
|
||
ShowDeclined --> Retry
|
||
ShowFailed --> Retry
|
||
|
||
Retry -->|YES| Start
|
||
Retry -->|NO| End[Subscription Incomplete]
|
||
|
||
style Success fill:#d4edda
|
||
style FinalSuccess fill:#d4edda
|
||
style Failed fill:#f8d7da
|
||
style Cancel fill:#fff3cd
|
||
style Timeout fill:#fff3cd
|
||
style Declined fill:#f8d7da
|
||
style End fill:#f8d7da
|
||
```
|
||
|
||
### Error Scenarios & Frontend Responses
|
||
|
||
```mermaid
|
||
graph TB
|
||
subgraph "Error Type"
|
||
E1[Card Declined]
|
||
E2[User Cancelled 3DS]
|
||
E3[3DS Timeout]
|
||
E4[Network Error]
|
||
E5[Card Requires Action<br/>But No client_secret]
|
||
end
|
||
|
||
subgraph "User Message"
|
||
M1[Your card was declined.<br/>Please try a different card.]
|
||
M2[Authentication was cancelled.<br/>Click Subscribe to try again.]
|
||
M3[Authentication timed out.<br/>Please try again.]
|
||
M4[Network error occurred.<br/>Please check connection and retry.]
|
||
M5[Payment processing error.<br/>Please contact support.]
|
||
end
|
||
|
||
subgraph "Action"
|
||
A1[Allow retry with<br/>different card]
|
||
A2[Allow retry with<br/>same card]
|
||
A3[Allow retry]
|
||
A4[Retry button]
|
||
A5[Contact support button]
|
||
end
|
||
|
||
E1 --> M1 --> A1
|
||
E2 --> M2 --> A2
|
||
E3 --> M3 --> A3
|
||
E4 --> M4 --> A4
|
||
E5 --> M5 --> A5
|
||
|
||
style E1 fill:#f8d7da
|
||
style E2 fill:#fff3cd
|
||
style E3 fill:#fff3cd
|
||
style E4 fill:#e7f3ff
|
||
style E5 fill:#f8d7da
|
||
```
|
||
|
||
---
|
||
```
|
||
|
||
#### 4. HTML Template with Loading States
|
||
|
||
**File**: `client/app/components/subscription/subscription.component.html`
|
||
|
||
```html
|
||
<div class="subscription-form">
|
||
<h2>Subscribe</h2>
|
||
|
||
<!-- Package Selection -->
|
||
<select [(ngModel)]="selectedPackage">
|
||
<option value="ess_1">Essential - $10/month</option>
|
||
<option value="ess_2">Professional - $20/month</option>
|
||
</select>
|
||
|
||
<!-- Addon Selection -->
|
||
<div *ngFor="let addon of availableAddons">
|
||
<input type="checkbox" [(ngModel)]="addon.selected">
|
||
{{addon.name}} - ${{addon.price}}/month
|
||
</div>
|
||
|
||
<!-- Payment Method -->
|
||
<select [(ngModel)]="selectedPaymentMethod">
|
||
<option *ngFor="let pm of paymentMethods" [value]="pm.id">
|
||
{{pm.card.brand}} ending in {{pm.card.last4}}
|
||
</option>
|
||
</select>
|
||
|
||
<!-- Submit Button -->
|
||
<button
|
||
[disabled]="loading"
|
||
(click)="onSubscribe()">
|
||
|
||
<span *ngIf="!loading">Subscribe Now</span>
|
||
<span *ngIf="loading">
|
||
{{loadingMessage || 'Processing...'}}
|
||
<i class="spinner"></i>
|
||
</span>
|
||
</button>
|
||
|
||
<!-- Status Messages -->
|
||
<div *ngIf="loading && loadingMessage.includes('authentication')" class="auth-notice">
|
||
<p>🔐 Please complete authentication in the popup window</p>
|
||
<p class="text-sm">You may see a verification screen from your bank</p>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
```typescript
|
||
// Component method
|
||
onSubscribe() {
|
||
const selectedAddons = this.availableAddons
|
||
.filter(a => a.selected)
|
||
.map(a => ({ price: a.priceKey, quantity: 1 }));
|
||
|
||
this.createSubscriptionsWithSetupIntent(
|
||
this.selectedPackage,
|
||
selectedAddons,
|
||
this.selectedPaymentMethod,
|
||
this.couponCode
|
||
);
|
||
}
|
||
```
|
||
|
||
### Testing Setup Intent Pattern
|
||
|
||
**Test Card Numbers**:
|
||
- `4242424242424242` - No 3DS (immediate success)
|
||
- `4000000000003220` - 3DS required (popup shown)
|
||
- `4000000000000341` - Always fails (error handling)
|
||
|
||
**Test Script**:
|
||
```bash
|
||
# Create test script: test_setup_intent.js
|
||
node test_setup_intent.js
|
||
```
|
||
|
||
**Expected Behavior**:
|
||
1. **Regular Card**: No popup, subscriptions created immediately
|
||
2. **3DS Card**: One popup, customer authenticates, all subscriptions created
|
||
3. **Failed Card**: Clear error message, no subscriptions created
|
||
|
||
### Flow Comparison
|
||
|
||
**Without Setup Intent** (Old):
|
||
```
|
||
1. Create package subscription → 3DS popup
|
||
2. (If 3DS not completed) Create addon subscription → Fails
|
||
3. Result: Package active, Addon missing ❌
|
||
```
|
||
|
||
**With Setup Intent** (New):
|
||
```
|
||
1. Setup card authentication → 3DS popup (if needed)
|
||
2. Create package subscription → No popup ✅
|
||
3. Create addon subscription → No popup ✅
|
||
4. Result: Both subscriptions active ✅
|
||
```
|
||
|
||
---
|
||
|
||
## 🎯 Direct Subscription Pattern (For Single Subscriptions)
|
||
|
||
This is the EXISTING implementation for handling 3DS during subscription creation.
|
||
|
||
### 📊 Flow Diagram: Direct Subscription with 3DS
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant User
|
||
participant Frontend
|
||
participant Backend
|
||
participant Stripe
|
||
participant Bank
|
||
|
||
User->>Frontend: Click "Subscribe"
|
||
Frontend->>Backend: POST /api/subscription/update<br/>{package, pmId}
|
||
|
||
Backend->>Stripe: Create Subscription<br/>payment_behavior: default_incomplete
|
||
Stripe->>Stripe: Create PaymentIntent
|
||
|
||
alt Card Requires 3DS
|
||
Stripe-->>Backend: Subscription: incomplete<br/>PaymentIntent: requires_action
|
||
Backend->>Backend: handleSubscriptionPayment()
|
||
Backend-->>Frontend: {requiresAction: true,<br/>client_secret, subscription}
|
||
|
||
Frontend->>User: Show 3DS Popup
|
||
Frontend->>Stripe: confirmCardPayment(client_secret)
|
||
Stripe->>Bank: Request Authentication
|
||
Bank->>User: Show 3DS Challenge
|
||
User->>Bank: Complete Authentication
|
||
Bank-->>Stripe: Authentication Success
|
||
Stripe-->>Frontend: PaymentIntent: succeeded
|
||
|
||
Note over Frontend: ⚠️ Subscription still 'incomplete'!
|
||
|
||
Frontend->>Frontend: Poll subscription status<br/>(every 2 seconds)
|
||
|
||
Stripe->>Stripe: Auto-charge card (background)
|
||
Stripe->>Stripe: Update subscription: active
|
||
Stripe->>Backend: Webhook: customer.subscription.updated
|
||
Backend->>Backend: Update DB, Send Email
|
||
|
||
Frontend->>Backend: GET /subscription/status
|
||
Backend-->>Frontend: Subscription: active
|
||
Frontend->>User: Show Success ✓
|
||
|
||
else No 3DS Required
|
||
Stripe->>Stripe: Charge card immediately
|
||
Stripe-->>Backend: Subscription: active
|
||
Backend-->>Frontend: {subscription: active}
|
||
Frontend->>User: Show Success ✓
|
||
end
|
||
```
|
||
|
||
### 🔄 What Happens Automatically After 3DS?
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
A[Customer Completes 3DS] --> B[Stripe Receives Auth]
|
||
B --> C[Stripe Auto-Charges Card]
|
||
C --> D{Payment<br/>Successful?}
|
||
|
||
D -->|YES| E[Stripe Updates Subscription<br/>incomplete → active]
|
||
D -->|NO| F[Subscription Stays Incomplete]
|
||
|
||
E --> G[Stripe Sends Webhook<br/>customer.subscription.updated]
|
||
G --> H[Backend Updates DB]
|
||
H --> I[Backend Sends Email]
|
||
|
||
F --> J[User Sees Error<br/>Add Different Card]
|
||
|
||
style C fill:#e1f5e1
|
||
style E fill:#d4edda
|
||
style H fill:#d4edda
|
||
style F fill:#f8d7da
|
||
style J fill:#f8d7da
|
||
```
|
||
|
||
**Key Point**: After customer completes 3DS, **NO additional frontend action needed** - Stripe handles everything automatically!
|
||
|
||
### Backend Implementation
|
||
|
||
**Location**: [controllers/subscription.js](../controllers/subscription.js) Line ~1734
|
||
|
||
```javascript
|
||
// After finalizing invoice
|
||
if (finalizedInvoice.status === 'open' || finalizedInvoice.payment_intent) {
|
||
let pi = finalizedInvoice.payment_intent
|
||
? (typeof finalizedInvoice.payment_intent === 'string'
|
||
? await stripe.paymentIntents.retrieve(finalizedInvoice.payment_intent)
|
||
: finalizedInvoice.payment_intent)
|
||
: null;
|
||
|
||
let piStatus = pi?.status;
|
||
debug(`Payment intent initial status: ${piStatus}`);
|
||
|
||
// NEW: Check if payment requires customer action (3DS)
|
||
if (piStatus === 'requires_action') {
|
||
debug(`Payment requires customer action (3DS) for subscription ${subscription.id}`);
|
||
|
||
// Return subscription with client_secret for frontend to handle
|
||
return res.json([{
|
||
...subscription,
|
||
requires_action: true,
|
||
client_secret: pi.client_secret,
|
||
payment_intent_id: pi.id
|
||
}]);
|
||
}
|
||
|
||
// EXISTING: Confirm payment intent for regular cards
|
||
if (piStatus === 'requires_confirmation' && pi) {
|
||
debug(`Confirming payment intent ${pi.id} to trigger payment attempt...`);
|
||
try {
|
||
pi = await stripe.paymentIntents.confirm(pi.id);
|
||
piStatus = pi.status;
|
||
debug(`Payment intent confirmed, new status: ${piStatus}`);
|
||
|
||
// NEW: Check again if confirmation resulted in requires_action
|
||
if (piStatus === 'requires_action') {
|
||
debug(`Payment confirmation requires customer action (3DS)`);
|
||
return res.json([{
|
||
...subscription,
|
||
requires_action: true,
|
||
client_secret: pi.client_secret,
|
||
payment_intent_id: pi.id
|
||
}]);
|
||
}
|
||
|
||
} catch (confirmErr) {
|
||
debug(`Payment intent confirmation failed: ${confirmErr.message}`);
|
||
piStatus = 'requires_payment_method';
|
||
}
|
||
}
|
||
|
||
// EXISTING: Fail only on actual payment failures
|
||
if (piStatus === 'requires_payment_method') {
|
||
// ... delete subscription and throw error
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 🎨 Frontend Changes (Angular 2)
|
||
|
||
### 1. Install/Import Stripe.js
|
||
|
||
**In `client/index.html`** (if not already added):
|
||
```html
|
||
<head>
|
||
<!-- Add Stripe.js -->
|
||
<script src="https://js.stripe.com/v3/"></script>
|
||
</head>
|
||
```
|
||
|
||
### 2. Create Stripe Service
|
||
|
||
**File: `client/app/services/stripe.service.ts`**
|
||
|
||
```typescript
|
||
import { Injectable } from '@angular/core';
|
||
|
||
declare var Stripe: any;
|
||
|
||
@Injectable()
|
||
export class StripeService {
|
||
private stripe: any;
|
||
|
||
constructor() {
|
||
// Initialize with your publishable key from environment
|
||
this.stripe = Stripe('pk_test_51LlCfSJxyI1MWs2Ty9utAc7QHhAa4YT6VPosvDdFtRaRQJchCLgd4NGvnarZQsCKiQUfJeOmnzs81w0AktP0N1o300Jd4q4m8n');
|
||
}
|
||
|
||
/**
|
||
* Handle 3D Secure authentication
|
||
* @param clientSecret Payment Intent client_secret from backend
|
||
* @returns Promise that resolves when authentication completes
|
||
*/
|
||
async handleCardAction(clientSecret: string): Promise<any> {
|
||
try {
|
||
const result = await this.stripe.handleCardAction(clientSecret);
|
||
|
||
if (result.error) {
|
||
// Authentication failed
|
||
throw new Error(result.error.message);
|
||
}
|
||
|
||
// Authentication succeeded - but subscription is still 'incomplete'!
|
||
// PaymentIntent is 'succeeded' but subscription needs time to activate
|
||
return result.paymentIntent;
|
||
|
||
} catch (error) {
|
||
console.error('3DS authentication error:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Confirm payment (alternative method)
|
||
* @param clientSecret Payment Intent client_secret
|
||
* @returns Promise with payment intent result
|
||
*/
|
||
async confirmCardPayment(clientSecret: string): Promise<any> {
|
||
try {
|
||
const result = await this.stripe.confirmCardPayment(clientSecret);
|
||
|
||
if (result.error) {
|
||
throw new Error(result.error.message);
|
||
}
|
||
|
||
// Authentication succeeded - but subscription is still 'incomplete'!
|
||
return result.paymentIntent;
|
||
|
||
} catch (error) {
|
||
console.error('Payment confirmation error:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3. Update Subscription Component
|
||
|
||
**File: `client/app/components/subscription/subscription.component.ts`**
|
||
|
||
```typescript
|
||
import { Component } from '@angular/core';
|
||
import { SubscriptionService } from '../../services/subscription.service';
|
||
import { StripeService } from '../../services/stripe.service';
|
||
|
||
@Component({
|
||
selector: 'app-subscription',
|
||
templateUrl: './subscription.component.html'
|
||
})
|
||
export class SubscriptionComponent {
|
||
|
||
loading = false;
|
||
requires3DS = false;
|
||
|
||
constructor(
|
||
private subscriptionService: SubscriptionService,
|
||
private stripeService: StripeService
|
||
) {}
|
||
|
||
async createSubscription(packageId: string, couponCode?: string) {
|
||
this.loading = true;
|
||
|
||
try {
|
||
// Call backend to create subscription
|
||
const response = await this.subscriptionService.createSubscription(
|
||
packageId,
|
||
couponCode
|
||
).toPromise();
|
||
|
||
// Check if 3DS authentication is required
|
||
if (response.requires_action && response.client_secret) {
|
||
this.requires3DS = true;
|
||
|
||
// Handle 3D Secure authentication
|
||
await this.handle3DSAuthentication(response);
|
||
|
||
} else {
|
||
// Normal subscription created successfully
|
||
this.handleSubscriptionSuccess(response);
|
||
}
|
||
|
||
} catch (error) {
|
||
this.handleSubscriptionError(error);
|
||
} finally {
|
||
this.loading = false;
|
||
this.requires3DS = false;
|
||
}
|
||
}
|
||
|
||
private async handle3DSAuthentication(subscription: any) {
|
||
try {
|
||
console.log('Starting 3DS authentication...');
|
||
|
||
// Use Stripe.js to handle card action (3DS popup)
|
||
const paymentIntent = await this.stripeService.handleCardAction(
|
||
subscription.client_secret
|
||
);
|
||
|
||
console.log('3DS authentication completed:', paymentIntent.status);
|
||
|
||
// ⚠️ CRITICAL: After 3DS completion, PaymentIntent is 'succeeded'
|
||
// BUT subscription is still 'incomplete'!
|
||
// Must poll subscription status until it becomes 'active'
|
||
|
||
if (paymentIntent.status === 'succeeded') {
|
||
// Payment authenticated - now wait for Stripe to charge and activate subscription
|
||
console.log('⏳ Waiting for subscription to activate...');
|
||
await this.pollSubscriptionStatus(subscription.id);
|
||
|
||
} else if (paymentIntent.status === 'requires_payment_method') {
|
||
// Authentication failed
|
||
throw new Error('3D Secure authentication failed. Please try a different payment method.');
|
||
|
||
} else {
|
||
// Other status (processing, etc.)
|
||
console.log('Payment status:', paymentIntent.status);
|
||
await this.pollSubscriptionStatus(subscription.id);
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('3DS authentication error:', error);
|
||
throw new Error('Payment authentication failed: ' + error.message);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Poll subscription status until it becomes 'active'
|
||
* Stripe automatically charges and activates after 3DS completion
|
||
* This usually takes 1-3 seconds
|
||
*/
|
||
private async pollSubscriptionStatus(subscriptionId: string, maxAttempts = 10) {
|
||
let attempts = 0;
|
||
|
||
console.log('📊 Polling subscription status...');
|
||
|
||
while (attempts < maxAttempts) {
|
||
await this.delay(2000); // Wait 2 seconds between checks
|
||
attempts++;
|
||
|
||
const status = await this.subscriptionService.checkSubscriptionStatus(
|
||
subscriptionId
|
||
).toPromise();
|
||
|
||
console.log(`Poll attempt ${attempts}: status = ${status.status}`);
|
||
|
||
if (status.status === 'active') {
|
||
console.log('✓ Subscription activated!');
|
||
this.handleSubscriptionSuccess(status);
|
||
return;
|
||
}
|
||
|
||
if (status.status === 'incomplete_expired' || status.status === 'canceled') {
|
||
throw new Error('Subscription expired or was canceled during payment processing.');
|
||
}
|
||
|
||
// Still incomplete or past_due - keep polling
|
||
if (status.status === 'incomplete' || status.status === 'past_due') {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// Timeout - subscription didn't become active
|
||
throw new Error('Subscription payment processing timeout. Please check your subscription status or contact support.');
|
||
}
|
||
|
||
private delay(ms: number): Promise<void> {
|
||
return new Promise(resolve => setTimeout(resolve, ms));
|
||
}
|
||
|
||
private handleSubscriptionSuccess(subscription: any) {
|
||
console.log('Subscription created successfully:', subscription);
|
||
// Show success message, redirect, etc.
|
||
alert('Subscription activated successfully!');
|
||
// Navigate to dashboard or subscription page
|
||
}
|
||
|
||
private handleSubscriptionError(error: any) {
|
||
console.error('Subscription error:', error);
|
||
const errorMessage = error.error?.message || error.message || 'Subscription failed';
|
||
alert('Error: ' + errorMessage);
|
||
}
|
||
}
|
||
```
|
||
|
||
### 4. Update Subscription Service
|
||
|
||
**File: `client/app/services/subscription.service.ts`**
|
||
|
||
```typescript
|
||
import { Injectable } from '@angular/core';
|
||
import { HttpClient } from '@angular/common/http';
|
||
import { Observable } from 'rxjs';
|
||
|
||
@Injectable()
|
||
export class SubscriptionService {
|
||
|
||
private apiUrl = '/api/subscription';
|
||
|
||
constructor(private http: HttpClient) {}
|
||
|
||
createSubscription(packageId: string, couponCode?: string): Observable<any> {
|
||
return this.http.post(`${this.apiUrl}/updateSubscriptions`, {
|
||
package: packageId,
|
||
coupon: couponCode
|
||
});
|
||
}
|
||
|
||
checkSubscriptionStatus(subscriptionId: string): Observable<any> {
|
||
return this.http.get(`${this.apiUrl}/status/${subscriptionId}`);
|
||
}
|
||
}
|
||
```
|
||
|
||
### 5. Add Backend Endpoint for Status Check
|
||
|
||
**File: `controllers/subscription.js`**
|
||
|
||
```javascript
|
||
/**
|
||
* @api {get} /api/subscription/status/:subscriptionId Check Subscription Status
|
||
* @apiName CheckSubscriptionStatus
|
||
* @apiGroup Subscription
|
||
* @apiDescription Check current subscription status (for polling after 3DS)
|
||
*/
|
||
exports.checkSubscriptionStatus = async (req, res) => {
|
||
const { subscriptionId } = req.params;
|
||
|
||
try {
|
||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||
|
||
res.json({
|
||
id: subscription.id,
|
||
status: subscription.status,
|
||
current_period_start: subscription.current_period_start,
|
||
current_period_end: subscription.current_period_end
|
||
});
|
||
|
||
} catch (error) {
|
||
throw new AppError(Errors.UNKNOWN_APP_ERROR,
|
||
'Failed to retrieve subscription status');
|
||
}
|
||
};
|
||
```
|
||
|
||
**File: `routes/subscription.js`**
|
||
|
||
```javascript
|
||
router.get('/api/subscription/status/:subscriptionId',
|
||
requiresAuth,
|
||
subscriptionController.checkSubscriptionStatus
|
||
);
|
||
```
|
||
|
||
---
|
||
|
||
## 🎨 UI/UX Considerations
|
||
|
||
### Loading States
|
||
|
||
```html
|
||
<!-- subscription.component.html -->
|
||
<div class="subscription-form">
|
||
<button
|
||
[disabled]="loading"
|
||
(click)="createSubscription(selectedPackage, couponCode)">
|
||
|
||
<span *ngIf="!loading && !requires3DS">Subscribe Now</span>
|
||
<span *ngIf="loading && !requires3DS">Creating subscription...</span>
|
||
<span *ngIf="requires3DS">Waiting for authentication...</span>
|
||
</button>
|
||
|
||
<div *ngIf="requires3DS" class="auth-notice">
|
||
<p>🔐 Please complete authentication in the popup window</p>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
### Error Handling
|
||
|
||
```typescript
|
||
private handleSubscriptionError(error: any) {
|
||
let message = 'Subscription failed';
|
||
|
||
if (error.error?.error?.message) {
|
||
message = error.error.error.message;
|
||
} else if (error.message) {
|
||
message = error.message;
|
||
}
|
||
|
||
// Show user-friendly messages
|
||
if (message.includes('authentication')) {
|
||
message = 'Payment authentication was not completed. Please try again.';
|
||
} else if (message.includes('payment_method')) {
|
||
message = 'Payment failed. Please check your card details and try again.';
|
||
}
|
||
|
||
this.showErrorNotification(message);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 📊 Pattern Comparison Diagram
|
||
|
||
```mermaid
|
||
graph TB
|
||
subgraph Direct["Direct Subscription Pattern"]
|
||
D1[POST /api/subscription/update]
|
||
D2{3DS<br/>Required?}
|
||
D3[Show 3DS Popup]
|
||
D4[Stripe Auto-Charges]
|
||
D5[Subscription Active ✓]
|
||
|
||
D1 --> D2
|
||
D2 -->|YES| D3
|
||
D2 -->|NO| D5
|
||
D3 --> D4
|
||
D4 --> D5
|
||
end
|
||
|
||
subgraph Setup["Setup Intent Pattern"]
|
||
S1[POST /api/subscription/setupCard]
|
||
S2{3DS<br/>Required?}
|
||
S3[Show 3DS Popup]
|
||
S4[Card Authenticated]
|
||
S5[POST /api/subscription/update]
|
||
S6[Subscriptions Active ✓]
|
||
|
||
S1 --> S2
|
||
S2 -->|YES| S3
|
||
S2 -->|NO| S4
|
||
S3 --> S4
|
||
S4 --> S5
|
||
S5 --> S6
|
||
end
|
||
|
||
style D5 fill:#d4edda
|
||
style S6 fill:#d4edda
|
||
style D3 fill:#f8d7da
|
||
style S3 fill:#f8d7da
|
||
```
|
||
|
||
### When to Use Which Pattern?
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
Start{What are you doing?}
|
||
|
||
Start -->|Creating NEW subscription<br/>with immediate charge| DirectUse[Use Direct Pattern]
|
||
Start -->|Upgrading/Downgrading<br/>existing subscription| DirectUse
|
||
Start -->|Adding addon<br/>with immediate charge| DirectUse
|
||
|
||
Start -->|Reactivating subscription<br/>cancel_at_period_end=false| SetupUse[Use Setup Intent]
|
||
Start -->|Adding card during trial<br/>no charge yet| SetupUse
|
||
Start -->|Changing payment method<br/>next charge is future| SetupUse
|
||
|
||
DirectUse --> DirectFlow["✓ Immediate charge<br/>✓ Simple flow<br/>✓ May require 3DS popup<br/>✓ Stripe auto-handles after 3DS"]
|
||
|
||
SetupUse --> SetupFlow["✓ No immediate charge<br/>✓ Pre-authenticate card<br/>✓ May require 3DS popup<br/>✓ Avoids double authentication"]
|
||
|
||
style DirectUse fill:#e1f5e1
|
||
style SetupUse fill:#fff3cd
|
||
style DirectFlow fill:#d4edda
|
||
style SetupFlow fill:#d4edda
|
||
```
|
||
|
||
---
|
||
|
||
## 🧪 Testing
|
||
|
||
### Test Cards
|
||
|
||
Use these Stripe test cards:
|
||
- `4242424242424242` - No 3DS (immediate success)
|
||
- `4000000000003220` - 3DS required (authentication popup)
|
||
- `4000000000000341` - Always fails (test error handling)
|
||
|
||
### Test Scenarios
|
||
|
||
#### Scenario 1: Setup Intent Pattern (Multiple Subscriptions)
|
||
|
||
```typescript
|
||
// Package: ess_1, Addons: [addon_1, addon_2]
|
||
// Card: 4000000000003220 (3DS required)
|
||
|
||
// Expected Flow:
|
||
// 1. Call setupCard → Returns requiresAction: true
|
||
// 2. Frontend shows 3DS popup ONCE
|
||
// 3. Customer authenticates
|
||
// 4. Create subscriptions → All succeed without additional popups
|
||
// 5. Result: Package + 2 addons all active ✅
|
||
```
|
||
|
||
#### Scenario 2: Direct Pattern (Single Subscription)
|
||
|
||
```typescript
|
||
// Package: ess_1 only
|
||
// Card: 4000000000003220 (3DS required)
|
||
|
||
// Expected Flow:
|
||
// 1. Call updateSubscriptions → Returns requires_action: true
|
||
// 2. Frontend shows 3DS popup
|
||
// 3. Customer authenticates
|
||
// 4. Poll subscription status
|
||
// 5. Result: Package active ✅
|
||
```
|
||
|
||
#### Scenario 3: No 3DS Required
|
||
|
||
```typescript
|
||
// Card: 4242424242424242
|
||
|
||
// Expected Flow (both patterns):
|
||
// 1. Call setupCard OR updateSubscriptions
|
||
// 2. No popup shown
|
||
// 3. Immediate success
|
||
// 4. Result: All subscriptions active ✅
|
||
```
|
||
|
||
#### Scenario 4: Failed Card
|
||
|
||
```typescript
|
||
// Card: 4000000000000341
|
||
|
||
// Expected Flow:
|
||
// 1. Setup Intent: Authentication fails immediately
|
||
// 2. Direct: Subscription creation fails
|
||
// 3. Clear error message shown
|
||
// 4. Result: No subscriptions created ✅
|
||
```
|
||
|
||
### Test Implementation
|
||
|
||
Create test file: `test_setup_intent_pattern.js`
|
||
|
||
```javascript
|
||
// Test Setup Intent Pattern
|
||
const axios = require('axios');
|
||
|
||
async function testSetupIntentPattern() {
|
||
console.log('Testing Setup Intent Pattern...\n');
|
||
|
||
// Test 1: Regular card (no 3DS)
|
||
console.log('Test 1: Regular card');
|
||
let result = await axios.post('http://localhost:4100/api/subscription/setupCard', {
|
||
custId: 'cus_test123',
|
||
pmId: 'pm_card_visa' // 4242 card
|
||
});
|
||
console.log('Result:', result.data);
|
||
console.log('Expected: requiresAction=false ✅\n');
|
||
|
||
// Test 2: 3DS card
|
||
console.log('Test 2: 3DS card');
|
||
result = await axios.post('http://localhost:4100/api/subscription/setupCard', {
|
||
custId: 'cus_test123',
|
||
pmId: 'pm_card_threeDSecure' // 3220 card
|
||
});
|
||
console.log('Result:', result.data);
|
||
console.log('Expected: requiresAction=true, clientSecret present ✅\n');
|
||
|
||
console.log('All tests passed!');
|
||
}
|
||
|
||
testSetupIntentPattern();
|
||
```
|
||
|
||
---
|
||
|
||
## 📊 Implementation Decision Guide
|
||
|
||
### When to Use Setup Intent Pattern
|
||
|
||
**Use Setup Intent if**:
|
||
- ✅ Creating multiple subscriptions (package + addons)
|
||
- ✅ Payment method might require 3DS
|
||
- ✅ Want to prevent partial subscription creation
|
||
- ✅ Need atomic all-or-nothing behavior
|
||
- ✅ Better user experience is priority
|
||
|
||
**Example**: Customer subscribing to Professional plan + 2 addons
|
||
|
||
### When to Use Direct Pattern
|
||
|
||
**Use Direct Pattern if**:
|
||
- ✅ Creating single subscription only
|
||
- ✅ Quick checkout flow
|
||
- ✅ Minimal code changes needed
|
||
- ✅ Legacy code compatibility
|
||
|
||
**Example**: Customer subscribing to Essential plan only, no addons
|
||
|
||
### Comparison Matrix
|
||
|
||
| Feature | Setup Intent Pattern | Direct Pattern |
|
||
|---------|---------------------|----------------|
|
||
| **Best For** | Multiple subscriptions | Single subscription |
|
||
| **3DS Handling** | Pre-authentication | During subscription creation |
|
||
| **Popups** | 1 popup max | 1 popup per subscription |
|
||
| **Partial Creation Risk** | ⛔ None | ⚠️ Possible |
|
||
| **User Experience** | ⭐⭐⭐⭐⭐ Excellent | ⭐⭐⭐ Good |
|
||
| **Implementation** | New endpoint + frontend | Existing flow |
|
||
| **Code Complexity** | Medium | Low |
|
||
| **Reliability** | ⭐⭐⭐⭐⭐ Excellent | ⭐⭐⭐⭐ Good |
|
||
| **SCA Compliant** | ✅ Yes | ✅ Yes |
|
||
|
||
### Migration Path
|
||
|
||
**Phase 1** (Immediate):
|
||
- Implement Setup Intent endpoint ✅
|
||
- Use for new multi-subscription flows ✅
|
||
|
||
**Phase 2** (Gradual):
|
||
- Update frontend components one by one
|
||
- Keep Direct Pattern for backward compatibility
|
||
|
||
**Phase 3** (Future):
|
||
- Migrate all flows to Setup Intent
|
||
- Remove Direct Pattern 3DS handling
|
||
|
||
---
|
||
|
||
## 📝 Summary
|
||
|
||
### Problem Solved
|
||
|
||
**Before**:
|
||
- Multiple subscriptions with 3DS card → Multiple popups
|
||
- Second subscription fails if first 3DS not complete
|
||
- Partial subscription creation (package works, addon fails)
|
||
- Confused customers and lost revenue
|
||
|
||
**After**:
|
||
- Setup Intent pre-authenticates card once
|
||
- Single 3DS popup for all subscriptions
|
||
- Atomic creation (all succeed or none created)
|
||
- Clear authentication flow
|
||
|
||
### Implementation Status
|
||
|
||
**Completed**:
|
||
- ✅ Backend `/api/subscription/setupCard` endpoint
|
||
- ✅ Full JSDoc/apidoc documentation
|
||
- ✅ Route configuration
|
||
- ✅ Error handling (card errors, invalid requests)
|
||
- ✅ Stripe API integration
|
||
- ✅ Frontend implementation guide
|
||
- ✅ Testing scenarios
|
||
|
||
**Next Steps**:
|
||
1. Test endpoint with Postman or test script
|
||
2. Update frontend components to use appropriate pattern (Direct vs Setup Intent)
|
||
3. Test with all three card types
|
||
4. Monitor production for 3DS success rates
|
||
|
||
---
|
||
|
||
## 💡 Additional Recommendations & Best Practices
|
||
|
||
### 1. Multi-Subscription Creation Strategy
|
||
|
||
**Current Behavior** (Immediate Charge):
|
||
- Package subscription created first
|
||
- If 3DS required → returns `client_secret`, subscription incomplete
|
||
- Addon subscription NOT created (error interrupts flow)
|
||
- Frontend completes 3DS for package
|
||
- Frontend calls `/update` again → addon subscription created
|
||
|
||
**This is CORRECT** - prevents partial creation.
|
||
|
||
**Optimization Option**: Use trial period to avoid sequential 3DS:
|
||
```javascript
|
||
{
|
||
"package": "ess_1",
|
||
"addons": [{"price": "addon_1", "quantity": 1}],
|
||
"trial_period_days": 1 // Both created without immediate charge
|
||
}
|
||
```
|
||
|
||
### 2. Payment Method Validation
|
||
|
||
**Recommendation**: Validate payment method BEFORE subscription creation:
|
||
|
||
```typescript
|
||
// Use Setup Intent for validation (no charge)
|
||
async validatePaymentMethod(custId: string, pmId: string) {
|
||
const result = await this.setupCardAuthentication(custId, pmId);
|
||
|
||
if (result.requiresAction) {
|
||
// Show 3DS popup
|
||
const { error } = await this.stripe.confirmCardSetup(result.clientSecret);
|
||
if (error) throw error;
|
||
}
|
||
|
||
return true; // Card validated
|
||
}
|
||
|
||
// Then create subscriptions with validated card
|
||
await this.validatePaymentMethod(custId, pmId);
|
||
await this.updateSubscriptions({ package, addons });
|
||
```
|
||
|
||
**When to Use**:
|
||
- User adding new card to profile (no charge yet)
|
||
- Changing default payment method on active subscription
|
||
- Before charging for any service
|
||
|
||
### 3. Error Handling Best Practices
|
||
|
||
**Always check for 3DS requirement**:
|
||
```typescript
|
||
try {
|
||
const subscriptions = await api.updateSubscriptions(params);
|
||
|
||
// Check if any subscription requires action
|
||
const requires3DS = subscriptions.some(sub => sub.requires_action);
|
||
|
||
if (requires3DS) {
|
||
for (const sub of subscriptions) {
|
||
if (sub.requires_action && sub.client_secret) {
|
||
// Handle 3DS for this subscription
|
||
await this.handle3DS(sub.client_secret);
|
||
}
|
||
}
|
||
|
||
// Refresh subscriptions after 3DS completion
|
||
return await api.getSubscriptions();
|
||
}
|
||
|
||
return subscriptions;
|
||
} catch (error) {
|
||
// Handle errors
|
||
}
|
||
```
|
||
|
||
### 4. User Experience Improvements
|
||
|
||
**Show clear messaging**:
|
||
```typescript
|
||
// Before 3DS
|
||
showMessage('Your bank requires additional verification. Please complete the authentication.');
|
||
|
||
// During 3DS
|
||
showSpinner('Authenticating with your bank...');
|
||
|
||
// After 3DS success
|
||
showSuccess('Payment method verified! Completing subscription...');
|
||
|
||
// After subscription complete
|
||
showSuccess('Subscription activated successfully!');
|
||
```
|
||
|
||
**Handle edge cases**:
|
||
- User closes 3DS popup → Show retry option
|
||
- 3DS fails → Clear error message + retry with different card
|
||
- Network timeout → Retry mechanism with exponential backoff
|
||
|
||
### 5. Testing Strategy
|
||
|
||
**Test with Stripe test cards**:
|
||
```javascript
|
||
// No 3DS required
|
||
'4242424242424242'
|
||
|
||
// 3DS required - authentication succeeds
|
||
'4000002500003155'
|
||
|
||
// Always fails (declined)
|
||
'4000000000000341'
|
||
|
||
// 3DS required with specific challenge flow
|
||
'4000002760003184'
|
||
```
|
||
|
||
**Test scenarios**:
|
||
1. ✅ Single subscription, no 3DS
|
||
2. ✅ Single subscription, 3DS required
|
||
3. ✅ Multiple subscriptions (package + addons), no 3DS
|
||
4. ✅ Multiple subscriptions, 3DS required (sequential authentication)
|
||
5. ✅ Reactivation with new 3DS card (cancel_at_period_end=false)
|
||
6. ✅ Upgrade/downgrade with 3DS card
|
||
7. ✅ User cancels 3DS popup
|
||
8. ✅ Card declined after 3DS completion
|
||
9. ✅ Trial period + 3DS card (no immediate authentication)
|
||
10. ✅ Promo coupon + 3DS card
|
||
|
||
### 6. Backend Configuration Checklist
|
||
|
||
**Ensure proper payment_behavior** (already implemented):
|
||
```javascript
|
||
// ✅ CORRECT - in createSubscription()
|
||
{
|
||
payment_behavior: 'default_incomplete', // Allows 3DS handling
|
||
expand: ['latest_invoice.payment_intent'] // Get payment details
|
||
}
|
||
```
|
||
|
||
**❌ AVOID** (Old Implementation - Before Jan 16, 2026):
|
||
```javascript
|
||
{
|
||
payment_behavior: 'error_if_incomplete' // Throws error on 3DS - BAD!
|
||
// Problem: 3DS cards throw errors instead of returning client_secret
|
||
// Fixed: Changed to 'default_incomplete'
|
||
}
|
||
```
|
||
|
||
### 7. Monitoring and Analytics
|
||
|
||
**Track 3DS metrics**:
|
||
- % subscriptions requiring 3DS
|
||
- % successful 3DS completions
|
||
- % failed/abandoned 3DS (conversion funnel)
|
||
- Average time to complete 3DS authentication
|
||
- 3DS success rate by card type/issuer
|
||
|
||
**Alert on issues**:
|
||
- High 3DS failure rate (>10%)
|
||
- Increased incomplete subscriptions
|
||
- Payment intent confirmation errors
|
||
- Setup Intent creation failures
|
||
|
||
**Suggested logging**:
|
||
```javascript
|
||
// Log 3DS events for analytics
|
||
analytics.track('3DS_Required', { subscriptionId, paymentIntentId });
|
||
analytics.track('3DS_Started', { subscriptionId });
|
||
analytics.track('3DS_Completed', { subscriptionId, success: true });
|
||
analytics.track('3DS_Failed', { subscriptionId, error });
|
||
analytics.track('3DS_Abandoned', { subscriptionId });
|
||
```
|
||
|
||
### 8. Performance Optimizations
|
||
|
||
**Parallel processing where possible**:
|
||
```typescript
|
||
// ❌ Sequential (slower)
|
||
await createPackage();
|
||
await createAddon();
|
||
|
||
// ✅ Parallel when no payment required (trial)
|
||
await Promise.all([
|
||
createPackage({ trial_period_days: 7 }),
|
||
createAddon({ trial_period_days: 7 })
|
||
]);
|
||
```
|
||
|
||
**Cache validated payment methods**:
|
||
```typescript
|
||
// Store Setup Intent result to avoid re-authentication
|
||
localStorage.setItem(`pm_validated_${pmId}`, Date.now().toString());
|
||
|
||
// Check if recently validated (within 1 hour)
|
||
const validated = localStorage.getItem(`pm_validated_${pmId}`);
|
||
if (validated && Date.now() - parseInt(validated) < 3600000) {
|
||
// Skip Setup Intent, card already validated
|
||
}
|
||
```
|
||
|
||
### 9. Security Considerations
|
||
|
||
**Never log sensitive data**:
|
||
```typescript
|
||
// ❌ BAD
|
||
console.log('Payment method:', paymentMethod);
|
||
console.log('Client secret:', clientSecret);
|
||
|
||
// ✅ GOOD
|
||
console.log('Payment method ID:', paymentMethod.id);
|
||
console.log('3DS required:', !!clientSecret);
|
||
```
|
||
|
||
**Validate on backend**:
|
||
- Always verify payment method belongs to customer
|
||
- Check subscription status after 3DS completion
|
||
- Validate payment intent status before finalizing
|
||
|
||
---
|
||
|
||
## 🎯 Quick Reference Summary
|
||
|
||
### 📊 Visual Cheat Sheet
|
||
|
||
```mermaid
|
||
graph LR
|
||
subgraph Scenarios["Common Scenarios"]
|
||
S1["New Subscription<br/>(immediate charge)"]
|
||
S2["Upgrade/Downgrade<br/>(immediate charge)"]
|
||
S3["Package + Addons<br/>(immediate charge)"]
|
||
S4["Reactivate<br/>(no charge)"]
|
||
S5["Add Card in Trial<br/>(no charge)"]
|
||
end
|
||
|
||
subgraph Patterns["Use Pattern"]
|
||
P1[Direct Pattern]
|
||
P2[Setup Intent]
|
||
end
|
||
|
||
subgraph Results["3DS Handling"]
|
||
R1["1 popup per subscription<br/>(if 3DS required)"]
|
||
R2["1 popup total<br/>(if 3DS required)"]
|
||
end
|
||
|
||
S1 --> P1
|
||
S2 --> P1
|
||
S3 --> P1
|
||
S4 --> P2
|
||
S5 --> P2
|
||
|
||
P1 --> R1
|
||
P2 --> R2
|
||
|
||
style P1 fill:#e1f5e1
|
||
style P2 fill:#fff3cd
|
||
style R1 fill:#d4edda
|
||
style R2 fill:#d4edda
|
||
```
|
||
|
||
### Decision Table
|
||
|
||
| Scenario | Pattern | Authentication Timing | Popups |
|
||
|----------|---------|----------------------|--------|
|
||
| New subscription, immediate charge | Direct | During subscription creation | 0-1 |
|
||
| Upgrade/downgrade | Direct | During subscription update | 0-1 |
|
||
| Package + addons, immediate | Direct | Sequential per subscription | 0-2 |
|
||
| Reactivate with new card | Setup Intent | Before updating cancel_at_period_end | 0-1 |
|
||
| Add card during trial | Setup Intent | Before trial ends | 0-1 |
|
||
| Trial period subscription | Setup Intent | After trial, before first charge | 0-1 |
|
||
| Change payment method | Setup Intent | Before next billing cycle | 0-1 |
|
||
|
||
**Note**: "0-1 popups" means 0 for regular cards, 1 for 3DS cards (~5-15% of cards)
|
||
|
||
### Implementation Checklist
|
||
|
||
```mermaid
|
||
graph TD
|
||
Start[Start Implementation] --> Q1{Immediate<br/>Charge?}
|
||
|
||
Q1 -->|YES| Direct[Implement Direct Pattern]
|
||
Q1 -->|NO| Setup[Implement Setup Intent]
|
||
|
||
Direct --> D1[1. Call POST /update]
|
||
D1 --> D2[2. Check response.requiresAction]
|
||
D2 --> D3[3. If true: confirmCardPayment]
|
||
D3 --> D4[4. Wait for auto-activation]
|
||
D4 --> Done1[✓ Done]
|
||
|
||
Setup --> S1[1. Call POST /setupCard]
|
||
S1 --> S2[2. Check response.requiresAction]
|
||
S2 --> S3[3. If true: confirmCardSetup]
|
||
S3 --> S4[4. Call POST /update]
|
||
S4 --> Done2[✓ Done]
|
||
|
||
style Done1 fill:#d4edda
|
||
style Done2 fill:#d4edda
|
||
```
|
||
|
||
### Test Card Quick Reference
|
||
|
||
| Card Number | 3DS Required | Result | Use For |
|
||
|-------------|--------------|--------|---------|
|
||
| 4242424242424242 | ❌ No | Always succeeds | Happy path testing |
|
||
| 4000002500003155 | ✅ Yes | Succeeds after 3DS | 3DS flow testing |
|
||
| 4000000000003220 | ✅ Yes | Succeeds after 3DS | Alternative 3DS test |
|
||
| 4000000000000341 | ❌ No | Always declines | Error handling testing |
|
||
|
||
**Next Steps**:
|
||
1. Test endpoint with Postman or test script
|
||
2. Update frontend components to use Setup Intent
|
||
3. Test with all three card types
|
||
4. Monitor production for 3DS success rates
|
||
|
||
### Key Benefits
|
||
|
||
- ✅ **No Partial Subscriptions**: Either all created or none
|
||
- ✅ **Better UX**: Single authentication step
|
||
- ✅ **SCA Compliant**: Meets European Strong Customer Authentication
|
||
- ✅ **Future-Proof**: Works with upcoming payment regulations
|
||
- ✅ **Reusable**: Can apply to other multi-charge scenarios
|
||
|
||
### Related Documentation
|
||
|
||
- [PAYMENT_FAILURE_HANDLING.md](PAYMENT_FAILURE_HANDLING.md) - Payment failure strategies
|
||
- [SUBSCRIPTION_PROMO_INTEGRATION.md](SUBSCRIPTION_PROMO_INTEGRATION.md) - Promo handling
|
||
- [controllers/subscription.js](../controllers/subscription.js) - Backend implementation
|