agmission/Development/server/docs/PAYMENT_FAILURE_HANDLING.md

585 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 <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](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) |