What must NOT break (existing flows)
The DB status check (invoice.status === "paid") is the key — it’s what makes this safe for both flows. Checkout session always writes paid before payment_intent.succeeded arrives, so no duplicate processing. Direct PaymentIntent flow has no checkout session, so invoice is still unpaid when we get there.
Update
For situations when both checkout.session completed and payment intent succeededs
Whichever arrives first wins; the second one sees paid and bails. No duplicate processing in any scenario.
Full Flow Audit Report
All Payment Scenarios Traced Through Webhook Handler
SCENARIO 1: Catalog One-Time Order (client buys from catalog page)
Creation: Backend creates PaymentIntent via createStripePaymentIntent with confirmPayment: false
Metadata format: projectInvoiceId: '["inv1","inv2"]' (JSON array), projectId: '["proj1"]'
Stripe events: charge.succeeded → payment_intent.succeeded
Duplicate risk: None — only one handler processes it
Gap: None ✅
SCENARIO 2: Checkout Session — One-Time Invoice Payment (admin creates invoice, client pays via checkout)
Creation: createStripeConnectCheckoutSession with mode: "payment", sets payment_intent_data.metadata.projectInvoiceId (plain ID)
Stripe events: checkout.session.completed + payment_intent.succeeded
Race scenario (PI arrives first):
Gap: None ✅
SCENARIO 3: Direct PaymentIntent — Invoice Payment (the NEW flow, no checkout session)
Creation: Frontend confirms PaymentIntent directly (created with projectInvoiceId plain ID in metadata)
Stripe events: charge.succeeded → payment_intent.succeeded (NO checkout.session.completed)
Duplicate risk: None — checkout.session.completed never fires
Gap: None ✅
SCENARIO 4: Checkout Session — Subscription Invoice Payment
Creation: createStripeConnectCheckoutSession with mode: "subscription", metadata on subscription_data (NOT payment_intent_data)
Stripe events: checkout.session.completed + invoice.paid + payment_intent.succeeded
Duplicate risk: None
Gap: None ✅
SCENARIO 5: Subscription Renewal (recurring billing cycle)
Trigger: Stripe auto-charges the subscription
Stripe events: invoice.paid + payment_intent.succeeded
Duplicate risk: None
Gap: None ✅
SCENARIO 6: Subscription Payment Failed
Stripe events: invoice.payment_failed
Gap: None ✅
SCENARIO 7: Subscription Cancelled
Stripe events: customer.subscription.deleted
Gap: None ✅
SCENARIO 8: 3DS / Bank Verification Required
Stripe events: payment_intent.requires_action
Gap: None ✅
SCENARIO 9: Setup Intent (catalog with future payment)
Stripe events: setup_intent.succeeded
Gap: None ✅
SCENARIO 10: AutoPay (reusable default payment method exists)
Trigger: createStripeConnectCheckoutSession detects existing default PM → calls stripeAutoPay → backend charges directly
Stripe events: Depends on backend auto-pay implementation (likely creates a PaymentIntent or Stripe Invoice)
This is a backend-driven charge. If it creates a PaymentIntent with projectInvoiceId in metadata, payment_intent.succeeded would process it through our new handler. If it uses Stripe Invoicing, invoice.paid would handle it. Either way, our status === "paid" guard prevents duplicates.
Gap: None ✅
Summary
Critical Fix Caught During Audit
The catalog flow sets projectInvoiceId as a JSON array string ('["id1"]') while the checkout/invoice flow uses a plain ObjectId. Our initial implementation would have intercepted catalog payments and failed the DB lookup (looking for '["id1"]' as an _id), silently preventing catalog orders from being processed. Fixed by detecting the JSON array format (startsWith("[")) and routing those to NEW_ORDER_URL as before.
Changes Made (final)
Integration repo — stripe.service.ts:
checkout.session.completed: added invoice.status === "paid" early return guard payment_intent.succeeded: replaced blanket skip with smart routing: Subscription renewal (no projectInvoiceId) → skip Catalog (JSON array metadata) → NEW_ORDER_URL Invoice (plain ID) + already paid → skip Invoice (plain ID) + not paid → process + handle-single-payment Backend repo — projectInvoice.service.js:
Added updateProject import Added project.isLive = true in processSingleInvoicePayment