agmission/Development/server/docs/PAYMENT_FAILURE_HANDLING.md

20 KiB

Payment Failure Handling - Complete Documentation

🎯 Critical Issues

Issue 1: Failed Cards Creating Active Subscriptions

Failed payment cards (4000000000000341) were creating active subscriptions with partial discount coupons (50% off), causing revenue loss.

Issue 2: Multiple Subscriptions with 3DS Cards

When creating package + addon subscriptions with same 3DS card:

  • Each subscription requires its own 3DS authentication
  • This is expected Stripe behavior - each PaymentIntent is independent
  • User sees multiple 3DS popups (one per subscription)
  • Frequency: <2% of checkouts (most cards don't require 3DS)

Updated Recommendation (January 16, 2026): Accept multiple popups - don't over-engineer for rare edge case.


📋 Solutions Overview

Handle 3DS authentication during subscription creation:

  • payment_behavior: 'default_incomplete' - Allows 3DS authentication flow
  • Manual invoice finalization and payment confirmation
  • 3DS detection via handleSubscriptionPayment() helper
  • Return client_secret to frontend when 3DS required
  • Subscription cleanup on payment failure
  • Accept multiple 3DS popups for multiple subscriptions (rare scenario)

Status: Completed (Updated January 16, 2026 for 3DS support)

When to Use:

  • Creating subscriptions with immediate charge (no trial)
  • Upgrading/downgrading existing subscription
  • Package + addon creation with immediate billing
  • Any scenario where first payment happens NOW

Solution 2: Setup Intent Pattern (For Future Charges Only)

Pre-authenticate payment methods for future off-session payments using Stripe SetupIntent API.

Status: Completed - See Setup Intent Pattern section

When to Use:

  • Reactivating subscription with new card (cancel_at_period_end=false)
  • Adding payment method during trial period (no immediate charge)
  • Changing payment method on active subscription (next charge is future)
  • Pre-validating card for scheduled billing

⚠️ DO NOT Use For:

  • Creating subscription with immediate charge (causes double authentication)
  • Multiple subscriptions with immediate billing (not a solution for multiple 3DS)

🔐 Setup Intent Pattern

Purpose

SetupIntent is Stripe's API for authenticating payment methods for future off-session payments WITHOUT charging them NOW.

Use Cases

Scenario 1: Reactivating Subscription with New Card

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

Challenge: Subscription is already active (no immediate charge), but new card needs authentication for FUTURE recurring charges.

Solution: Use Setup Intent to pre-authenticate without charging.

Setup Intent Flow:
┌─────────────────────────────────────────┐
│ 1. Setup Card (Pre-authenticate)       │
│    └─> Trigger 3DS popup (if needed)   │
│ 2. Update subscription settings        │
│    └─> cancel_at_period_end = false    │
│ 3. Next billing cycle                  │
│    └─> Auto-charge (no popup)          │
└─────────────────────────────────────────┘
Result: Card authenticated for future use ✅

Scenario 2: Trial Period with New Card

User starts subscription with trial period:

  • No immediate charge (trial active)
  • Card needs validation for future billing

Solution: Use Setup Intent to validate card without charging.

NOT For: Multiple Subscriptions with Immediate Charge

Previous Misconception: Use Setup Intent to avoid multiple 3DS popups when creating package + addons.

Reality (Confirmed January 16, 2026):

  • Setup Intent authenticates for future off-session payments
  • First payment is on-session → still requires 3DS
  • Results in double authentication: Setup Intent 3DS + Payment 3DS
  • Worse UX than accepting multiple popups

Correct Approach: Accept that each subscription requires its own 3DS (rare scenario <2%).

Backend Implementation

New Endpoint: POST /api/subscription/setupCard

Location: controllers/subscription.js

Code:

async function setupCardAuthentication_post(req, res) {
  const { custId, pmId } = req.body;

  // Validate parameters
  if (!custId || !pmId) {
    throw new AppParamError(Errors.INVALID_PARAM, 'custId and pmId are required');
  }

  // Create SetupIntent to pre-authenticate card
  const setupIntent = await stripe.setupIntents.create({
    customer: custId,
    payment_method: pmId,
    usage: 'off_session', // For future charges without customer present
    confirm: true,
    return_url: `${env.SITE_URL}/subscription/setup-complete`
  });

  // If 3DS required, return client_secret for frontend
  if (setupIntent.status === 'requires_action') {
    return res.json({
      requiresAction: true,
      clientSecret: setupIntent.client_secret,
      setupIntentId: setupIntent.id,
      status: setupIntent.status,
      message: 'Card authentication required'
    });
  }

  // Card authenticated successfully
  return res.json({
    requiresAction: false,
    status: setupIntent.status,
    setupIntentId: setupIntent.id,
    message: 'Card authenticated successfully'
  });
}

Route: Added to routes/subscription.js

router.post('/setupCard', memberCtl.setupCardAuthentication_post);

Frontend Implementation

Example: Reactivating Subscription with New Card

Step 1: Pre-authenticate new card

// User wants to reactivate subscription with new card
const setupResult = await api.setupCard(custId, newPmId);

// Handle 3DS if required
if (setupResult.requiresAction) {
  const setupIntent = await stripe.confirmCardSetup(setupResult.clientSecret);
  if (setupIntent.status !== 'succeeded') {
    throw new Error('Authentication failed');
  }
}

Step 2: Update subscription settings

// Card is authenticated for future billing
// Now reactivate subscription (no immediate charge)
const result = await api.updateSubscriptionSettings({
  cancel_at_period_end: false,
  default_payment_method: newPmId // Already authenticated for future
});

Complete Flow:

async reactivateWithNewCard(newPmId: string) {
  try {
    // 1. Authenticate card for FUTURE charges (no charge now)
    this.loadingMessage = 'Verifying payment method...';
    const setup = await this.subscriptionService.setupCard(this.custId, newPmId).toPromise();
    
    // 2. Handle 3DS if needed
    if (setup.requiresAction) {
      this.loadingMessage = 'Please complete authentication...';
      const result = await this.stripeService.confirmCardSetup(setup.clientSecret);
      if (result.status !== 'succeeded') {
        throw new Error('Authentication failed');
      }
    }
    
    // 3. Reactivate subscription (no charge, no additional 3DS)
    this.loadingMessage = 'Reactivating subscription...';
    await this.subscriptionService.updateSettings({
      cancel_at_period_end: false,
      default_payment_method: newPmId
    }).toPromise();
    
    // Success!
    this.showSuccess('Subscription reactivated! Next billing will use new card.');
    
  } catch (error) {
    this.handleError(error);
  }
}

Note: This is for reactivation ONLY. For new subscriptions with immediate charge, use Direct Subscription Pattern (see FRONTEND_3DS_IMPLEMENTATION.md).

Benefits

Pre-validates card: Checks card before billing cycle
Future-proof: Authenticated for recurring payments without popup
SCA Compliant: Meets Strong Customer Authentication requirements
No immediate charge: Perfect for trials and reactivations

When to Use Setup Intent

Use Setup Intent Pattern when:

  • Reactivating subscription with new card (cancel_at_period_end=false)
  • Adding payment method during trial (no immediate charge)
  • Changing default payment method (future billing)
  • Pre-validating card for scheduled payments

DO NOT use Setup Intent when:

  • Creating subscription with immediate charge
  • Upgrading/downgrading (immediate payment)
  • Any scenario requiring payment NOW

Comparison Table

Aspect Direct Subscription Setup Intent Pattern
Charge Timing Immediate (NOW) Future (off-session)
3DS Timing During subscription creation Before subscription creation
Best For Immediate billing Trial periods, reactivation
Prevents Multiple 3DS? No (and not needed) No (not designed for this)
Complexity Lower Higher
Use Case Frequency Common Rare
User Experience Direct Clear intent

Testing

# Test Setup Intent with 3DS card
curl -X POST http://localhost:4100/api/subscription/setupCard \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <token>" \
  -d '{"custId":"cus_xxx","pmId":"pm_card_threeDSecure"}'

# Expected: requiresAction: true, clientSecret present

Test Cards:

  • 4242424242424242 - No 3DS, immediate success
  • 4000000000003220 - 3DS required, tests authentication flow
  • 4000000000000341 - Always fails, tests error handling

Documentation

Complete implementation guide: FRONTEND_3DS_IMPLEMENTATION.md


🔍 Payment Verification (Original Issue)

Root Cause Analysis

This bug required three separate fixes to fully resolve:

Fix 1: Payment Behavior Parameter

Direct subscriptions needed proper payment_behavior setting to handle both payment failures AND 3DS authentication.

Initial Solution (Jan 8, 2026): Used payment_behavior: 'error_if_incomplete' to force incomplete status on payment failure.

Updated Solution (Jan 16, 2026): Changed to payment_behavior: 'default_incomplete' to support 3DS authentication:

  • Failed payments still result in incomplete status ✓
  • 3DS cards return requires_action status (not an error) ✓
  • Frontend can handle 3DS authentication ✓
  • After 3DS completion, Stripe automatically charges and activates subscription ✓

Fix 2: SubscriptionSchedules Create Draft Invoices

When subscriptions are created via SubscriptionSchedule (for promotional coupons with expiry dates):

  • Invoices are created in draft status
  • No payment attempt is made automatically
  • Subscription becomes active without payment
  • The payment_behavior parameter is ignored by SubscriptionSchedules

Solution: Added invoice finalization and payment verification logic.

Fix 3: Payment Intent Never Confirmed (CRITICAL - Final Fix)

The root cause: After finalizing the invoice, the payment intent status was requires_confirmation. Without calling stripe.paymentIntents.confirm(), the charge was NEVER attempted and failed cards stayed in limbo.

Stripe PaymentIntent Workflow:

draft invoice → finalize → PaymentIntent (requires_confirmation)
                                    ↓
                              [CONFIRM] ← THIS WAS MISSING!
                                    ↓
                    Valid card → succeeded
                    Failed card → requires_payment_method (declined)

The Solution

Code Changes

File: controllers/subscription.js

Location 1: Line ~2088 - Direct Subscription Payment Behavior (Updated for 3DS)

let params = {
  metadata: { type: type },
  cancel_at_period_end: true,
  expand: ['latest_invoice.payment_intent'],
  // CRITICAL: Support both payment failures AND 3DS authentication
  payment_behavior: 'default_incomplete'  // Changed from 'error_if_incomplete'
};

// After subscription creation, check if 3DS required
const handledSub = await handleSubscriptionPayment(subscription);
// If 3DS needed, returns { requiresAction: true, client_secret, ... }
// Frontend handles 3DS, Stripe auto-charges after authentication

Location 2: Line ~1480 - Payment Intent Confirmation After Invoice Finalization

// After finalizing invoice, retrieve and confirm 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}`);

// CRITICAL FIX: Confirm payment intent to trigger actual charge attempt
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}`);
  } catch (confirmErr) {
    debug(`Payment intent confirmation failed: ${confirmErr.message}`);
    piStatus = 'requires_payment_method';
  }
}

// Only fail on actual payment failures
if (piStatus === 'requires_payment_method') {
  debug(`Payment failed for subscription ${subscription.id}, canceling`);
  
  // Void invoice and delete subscription
  try {
    if (finalizedInvoice.status === 'open') {
      await stripe.invoices.voidInvoice(finalizedInvoice.id);
    }
  } catch (voidErr) {
    debug(`Failed to void invoice: ${voidErr.message}`);
  }
  
  try {
    await stripe.subscriptions.del(subscription.id);
  } catch (delErr) {
    if (delErr.code !== 'resource_missing') {
      debug(`Failed to delete subscription: ${delErr.message}`);
    }
  }
  
  throw new AppMembershipError(Errors.PAYMENT_FAILED,
    'Payment failed. Please update your payment method and try again.');
}

Location 3: Line ~1542 - Same Fix for Open Invoices

else if (invoice.status === 'open' && invoice.amount_due > 0) {
  let pi = invoice.payment_intent
    ? (typeof invoice.payment_intent === 'string'
      ? await stripe.paymentIntents.retrieve(invoice.payment_intent)
      : invoice.payment_intent)
    : null;

  let piStatus = pi?.status;
  
  // CRITICAL FIX: Confirm payment intent
  if (piStatus === 'requires_confirmation' && pi) {
    try {
      pi = await stripe.paymentIntents.confirm(pi.id);
      piStatus = pi.status;
    } catch (confirmErr) {
      piStatus = 'requires_payment_method';
    }
  }

  // Only fail if payment explicitly failed
  if (piStatus === 'requires_payment_method') {
    await stripe.subscriptions.del(subscription.id);
    throw new AppMembershipError(Errors.PAYMENT_FAILED,
      'Payment failed. Please update your payment method and try again.');
  }
}

🧪 Test Results

Test 1: Failed Card (4000000000000341)

✅ Customer created
✅ Schedule created with 50% coupon
📃 Invoice status: draft
⚙️  Finalizing invoice...
📊 Payment Intent: requires_confirmation
⚙️  Confirming payment intent...
❌ Payment confirmation failed: Your card was declined
   Updated Status: requires_payment_method
✅ CORRECT: Payment failed, subscription deleted

Test 2: Valid Card (4242424242424242)

✅ Customer created
✅ Schedule created with 50% coupon
📃 Invoice status: draft
⚙️  Finalizing invoice...
📊 Payment Intent: requires_confirmation
⚙️  Confirming payment intent...
✅ Payment confirmed: succeeded
✅ CORRECT: Subscription remains active

Test 3: 3D Secure Card (4000000000003220)

Updated Behavior (After Fix):

✅ Customer created
✅ Schedule created with 50% coupon
📃 Invoice status: draft
⚙️  Finalizing invoice...
📊 Payment Intent: requires_confirmation
⚙️  Confirming payment intent with return_url...
📊 Payment requires action (3DS)
📤 Returning client_secret to frontend
✅ CORRECT: Frontend receives requires_action flag + client_secret

Frontend Flow:

  1. Backend returns: { (subscription info: id,customer,status,items,current_period_start,current_period_end..), requires_action: true, client_secret: "pi_xxx_secret_xxx" }
  2. Frontend uses Stripe.js: stripe.confirmCardPayment(client_secret) (Note: Use confirmCardPayment, NOT handleCardAction)
  3. Customer completes 3DS authentication in popup
  4. CRITICAL: After 3DS completes, subscription is STILL 'incomplete'
  5. Frontend MUST poll GET /api/subscription/status/:subscriptionId to detect activation
  6. Stripe auto-charges in background (1-5 seconds)
  7. Subscription becomes active after successful charge
  8. Show success message to user

⚠️ Common Mistake: Assuming confirmCardPayment() success means subscription is active. It doesn't! Must poll.

Error Handling: If server-side confirmation throws an error for 3DS cards, the code now:

  • Catches authentication-related errors
  • Re-fetches payment intent to get actual status
  • Returns client_secret if status is requires_action
  • Only treats as failure if truly declined (not 3DS)

Key Fix: Added return_url parameter to stripe.paymentIntents.confirm() to prevent Stripe from throwing errors on 3DS cards. Also added error handling to catch authentication errors and return client_secret to frontend.


📊 Payment Intent Status Reference

Status Meaning Action
requires_confirmation Needs confirm() call Confirm it
requires_payment_method Payment failed Delete + error
requires_action 3D Secure needed Allow
processing Payment processing Allow
succeeded Payment successful Allow

📁 Files Modified

  • controllers/subscription.js (3 locations)
  • test_payment_confirmation.js - Failed card test
  • test_valid_card_confirmation.js - Valid card test

🚀 Deployment Checklist

  • Code implemented
  • Tests passing
  • Documentation complete
  • User testing via frontend
  • Production deployment
  • Monitor error logs

🔑 Key Learning

Stripe PaymentIntent requires explicit confirmation:

  • requires_confirmation is NOT a final state
  • Must call .confirm() to trigger charge for regular cards
  • Failed cards won't automatically become requires_payment_method

3D Secure (3DS) Cards:

  • ⚠️ Should NOT be confirmed server-side
  • Require client-side authentication flow with Stripe.js
  • Server-side confirmation will return requires_action but customer can't complete auth
  • This causes subscriptions to be created with past_due or incomplete status
  • Solution: Return client_secret to frontend for 3DS handling

🎯 Success Criteria

  • Card 4000000000000341 with 50% coupon → Payment fails WORKING
  • Card 4242424242424242 with 50% coupon → Subscription created WORKING
  • ⚠️ Card 4000000000003220 (3DS) → Needs client-side handling ⚠️ LIMITATION
  • No false positives (valid cards rejected) WORKING

🐛 Troubleshooting

Valid Cards Failing

  • Check server logs for payment intent status
  • Verify Stripe API key
  • Check webhook interference

Failed Cards Creating Subscriptions

  • Verify server restarted with new code
  • Check debug logs for "Confirming payment intent"
  • Enable debug: DEBUG=agm:*

3DS Cards Creating Subscriptions with past_due Status

  • This is expected with current server-side confirmation
  • 3DS requires customer authentication on client-side
  • Server-side confirmation returns requires_action which we allow
  • Subscription created but payment incomplete → past_due status
  • Workaround: Use cards without 3DS for testing, or implement client-side 3DS flow
  • Proper Fix: Detect requires_action and return client_secret to frontend

📚 Additional Resources


📝 Revision History

Date Version Changes
2024-02-08 v1.0 Initial payment_behavior fix
2024-02-08 v2.0 Added invoice finalization
2024-02-09 v3.0 Added payment intent confirmation (final fix)