Skip to content
Share
Explore

Filling in Gap for One-time catalog orders

Gap
Fixed by our change?
Invoice status, amountPaid, amountDue, amountRemain, paidDate, paymentMethod not updated
✅ We update it in integration before calling backend
Project not set to isLive: true
✅ We update it using projectId from metadata
stripeCustomerId not saved on client member
✅ We handle it
Default payment method not set on Stripe customer
✅ We handle it
handle-single-payment not called (no notifications, no activity logs, no client conversion, no voucher tracking, no outbound webhooks)
✅ We call it after the updates
There are no rows in this table

What must NOT break (existing flows)

Flow
Still works?
Checkout session payment
checkout.session.completed fires first → invoice becomes paidpayment_intent.succeeded sees paid → skips
Subscription renewal (invoice.paid)
isInvoicePayment true + no projectInvoiceId → skips early
Catalog purchase (no projectInvoiceId)
✅ Falls through to NEW_ORDER_URL as before
There are no rows in this table
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
Race scenario
Result
checkout.session.completed first → payment_intent.succeeded second
Checkout processes it → PI sees paid → skips ✅
payment_intent.succeeded first → checkout.session.completed second
PI processes it → Checkout sees paid → skips ✅
Only checkout.session.completed (normal checkout flow)
Processes normally ✅
Only payment_intent.succeeded (direct PI flow)
Processes normally ✅
There are no rows in this table
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: falseMetadata format: projectInvoiceId: '["inv1","inv2"]' (JSON array), projectId: '["proj1"]'Stripe events: charge.succeededpayment_intent.succeeded
Event
Handler path
What happens
payment_intent.succeeded
rawProjectInvoiceId = '["inv1"]'isCatalogPayment = true → falls through to NEW_ORDER_URL
stripeOneTimeWebhook on backend: updates projects isLive, updates invoices to paid, sends notifications, activity logs, webhooks ✅
There are no rows in this table
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
Event
Handler path
What happens
checkout.session.completed (arrives first)
Invoice not paid → updates invoice to paid, updates customer, sets default PM → calls handle-single-payment
Invoice updated, notifications sent, project set to isLive (via our backend fix) ✅
payment_intent.succeeded (arrives second)
rawProjectInvoiceId = plain ID → not catalog → checks DB → invoice is "paid"skips
No duplicate ✅
There are no rows in this table
Race scenario (PI arrives first):
Event
Handler path
What happens
payment_intent.succeeded (first)
Invoice not paid → processes it → updates invoice, customer, PM → calls handle-single-payment
checkout.session.completed (second)
Fetches invoice → status === "paid"skips
No duplicate ✅
There are no rows in this table
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.succeededpayment_intent.succeeded (NO checkout.session.completed)
Event
Handler path
What happens
payment_intent.succeeded
rawProjectInvoiceId = plain ID → not catalog → checks DB → invoice NOT paid → processes: updates invoice, customer, default PM → calls handle-single-payment
Invoice updated, project isLive (backend), notifications, activity logs ✅
There are no rows in this table
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
Event
Handler path
What happens
checkout.session.completed
Updates invoice to paid, updates orderSubscription (lastPayment/nextPayment), applies cancel_at on Stripe sub, saves customer → calls handle-single-payment (with stripeSubscriptionId)
invoice.paid
update-from-stripe on backend
billing_reason = subscription_create → finds orderSubscription by sub ID → invoice is already "paid" from checkout handler → the controller checks invoice.status === 'open'skips the update block
payment_intent.succeeded
isInvoicePayment = true (Stripe subscription creates internal invoice) + rawProjectInvoiceId is undefined (no payment_intent_data for subscription mode) → hits early skip
Skips
There are no rows in this table
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
Event
Handler path
What happens
invoice.paid
update-from-stripebilling_reason = subscription_cycle → finds orderSubscription → updates invoice to paid, sets project/folder/sub to isLive, sends notifications, webhooks
payment_intent.succeeded
isInvoicePayment = true, rawProjectInvoiceId = undefined → early skip
Skips
There are no rows in this table
Duplicate risk: None ​Gap: None ✅
SCENARIO 6: Subscription Payment Failed
Stripe events: invoice.payment_failed
Event
Handler path
What happens
invoice.payment_failed
capture-stripe-failed-payment on backend → 3DS detection, activity log, emails to client + agency, in-app notifications
There are no rows in this table
Gap: None ✅
SCENARIO 7: Subscription Cancelled
Stripe events: customer.subscription.deleted
Event
Handler path
What happens
customer.subscription.deleted
sync-cancelled-subscription → sets orderSubscription.isActive = false
There are no rows in this table
Gap: None ✅
SCENARIO 8: 3DS / Bank Verification Required
Stripe events: payment_intent.requires_action
Event
Handler path
What happens
payment_intent.requires_action (microdeposits)
Sends bank verification email via backend
payment_intent.requires_action (use_stripe_sdk)
Returns message for frontend to handle
Updates invoice to "processing" if projectInvoiceId exists
There are no rows in this table
Gap: None ✅
SCENARIO 9: Setup Intent (catalog with future payment)
Stripe events: setup_intent.succeeded
Event
Handler path
What happens
setup_intent.succeeded
Forwards to INIT_ORDER_FROM_CATALOG_URL with metadata
There are no rows in this table
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

#
Scenario
Processed by
Duplicate?
Gap?
1
Catalog one-time
payment_intent.succeededNEW_ORDER_URL
No
No
2
Checkout one-time invoice
checkout.session.completed (or PI if race)
No — paid guard
No
3
Direct PI invoice (NEW)
payment_intent.succeeded → invoice update + handle-single-payment
No
No
4
Checkout subscription
checkout.session.completed
No — PI skips, invoice.paid skips
No
5
Subscription renewal
invoice.paidupdate-from-stripe
No — PI skips
No
6
Payment failed
invoice.payment_failed
N/A
No
7
Sub cancelled
customer.subscription.deleted
N/A
No
8
3DS/bank verify
payment_intent.requires_action
N/A
No
9
Setup intent
setup_intent.succeeded
N/A
No
10
AutoPay
Backend-driven, guarded by paid check
No
No
There are no rows in this table

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 repostripe.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 repoprojectInvoice.service.js:
Added updateProject import
Added project.isLive = true in processSingleInvoicePayment

Want to print your doc?
This is not the way.
Try clicking the ··· in the right corner or using a keyboard shortcut (
CtrlP
) instead.