agmission/Development/server/docs/FRONTEND_3DS_IMPLEMENTATION.md

58 KiB

Frontend 3D Secure Implementation Guide

📊 Visual Flow Overview

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

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:

// 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 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 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

// 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

// 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

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

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

Request:

{
  "custId": "cus_xxx",
  "pmId": "pm_xxx"
}

Response (No 3DS):

{
  "requiresAction": false,
  "status": "succeeded",
  "setupIntentId": "seti_xxx",
  "message": "Card authenticated successfully"
}

Response (3DS Required):

{
  "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

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

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

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

// 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:

// 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

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

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

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

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>
// 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:

# 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

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?

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 Line ~1734

// 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):

<head>
  <!-- Add Stripe.js -->
  <script src="https://js.stripe.com/v3/"></script>
</head>

2. Create Stripe Service

File: client/app/services/stripe.service.ts

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

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

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

/**
 * @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

router.get('/api/subscription/status/:subscriptionId', 
  requiresAuth, 
  subscriptionController.checkSubscriptionStatus
);

🎨 UI/UX Considerations

Loading States

<!-- 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

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

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?

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)

// 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)

// 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

// 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

// 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

// 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:

{
  "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:

// 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:

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:

// 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:

// 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):

// ✅ 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):

{
  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:

// 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:

// ❌ 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:

// 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:

// ❌ 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

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

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