# 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 ### Solution 1: Direct Subscription Pattern (Recommended) 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](#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](../controllers/subscription.js#L769) **Code**: ```javascript 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](../routes/subscription.js) ```javascript router.post('/setupCard', memberCtl.setupCardAuthentication_post); ``` ### Frontend Implementation **Example: Reactivating Subscription with New Card** **Step 1: Pre-authenticate new card** ```typescript // 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** ```typescript // 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**: ```typescript 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 ```bash # Test Setup Intent with 3DS card curl -X POST http://localhost:4100/api/subscription/setupCard \ -H "Content-Type: application/json" \ -H "Authorization: Bearer " \ -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](FRONTEND_3DS_IMPLEMENTATION.md#setup-intent-pattern) --- ## ๐Ÿ” 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) ```javascript 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 ```javascript // 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 ```javascript 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 - [x] Code implemented - [x] Tests passing - [x] 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 - Stripe PaymentIntents API: https://stripe.com/docs/api/payment_intents - Stripe Test Cards: https://stripe.com/docs/testing#cards - Subscription Schedules: https://stripe.com/docs/billing/subscriptions/subscription-schedules --- ## ๐Ÿ“ 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) |