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
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_secretto 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:
- Update to
cancel_at_period_end=false(reactivate) - 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 success4000000000003220- 3DS required, tests authentication flow4000000000000341- 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
incompletestatus ✓ - 3DS cards return
requires_actionstatus (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
draftstatus - No payment attempt is made automatically
- Subscription becomes
activewithout payment - The
payment_behaviorparameter 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:
- Backend returns:
{ (subscription info: id,customer,status,items,current_period_start,current_period_end..), requires_action: true, client_secret: "pi_xxx_secret_xxx" } - Frontend uses Stripe.js:
stripe.confirmCardPayment(client_secret)(Note: Use confirmCardPayment, NOT handleCardAction) - Customer completes 3DS authentication in popup
- CRITICAL: After 3DS completes, subscription is STILL 'incomplete'
- Frontend MUST poll
GET /api/subscription/status/:subscriptionIdto detect activation - Stripe auto-charges in background (1-5 seconds)
- Subscription becomes
activeafter successful charge - 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_secretif status isrequires_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 testtest_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_confirmationis 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_actionbut customer can't complete auth - This causes subscriptions to be created with
past_dueorincompletestatus - Solution: Return
client_secretto frontend for 3DS handling
🎯 Success Criteria
- ❌ Card
4000000000000341with 50% coupon → Payment fails ✅ WORKING - ✅ Card
4242424242424242with 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_actionwhich we allow - Subscription created but payment incomplete →
past_duestatus - Workaround: Use cards without 3DS for testing, or implement client-side 3DS flow
- Proper Fix: Detect
requires_actionand returnclient_secretto 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) |