Make Payment Side Effects Reliable via Webhooks and Unify Stripe Flows #2811
Replies: 3 comments 2 replies
-
|
I have a concern about awaiting tx status update on API side: It's a long-polling, which keeps an HTTP connection open. If it times out, the client still needs to retry, so it can end up similar to short polling on the client side (just with a different retry conditions). However, long polling could be replaced with SSE/Websocket notification then in combination with Postgres LISTEN/NOTIFY (which has a nice interface provided by postgres.js) api won't need to poll the database but instead listen for notification broadcasted from /stripe-webhook and then forward it to corresponding SSE/Websocket connection. This is probably better for scaling but may be harder/longer to implement. Overall, I really like the idea of routing all payments through a single, consistent flow. It is a big win! Great job! |
Beta Was this translation helpful? Give feedback.
-
|
I like the idea of homogenizing the transaction process, well done! I mostly reponsible for all of this, so thanks for initiating this refactor. |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
Context
The current Stripe payment integration has grown organically, resulting in multiple inconsistent patterns for handling payments, coupons, and card validation. The client performs work that should be the backend's responsibility — polling for balance changes, confirming 3DS results, marking payment methods as validated. This RFC proposes unifying all payment flows behind a consistent pattern using webhooks and a single transaction-awaiting endpoint.
Current State
/payment-methods/validate/payment-methods/validate+ retriescreateWalletpaymentIntents.createpaymentIntents.create(capture: "manual")Payment Flow (with optional 3DS)
sequenceDiagram participant Client participant API participant Stripe participant Webhook Client->>API: POST /v1/stripe/transactions/confirm API->>API: Create transaction record (DB) API->>Stripe: paymentIntents.create(confirm: true) Stripe-->>API: PaymentIntent (succeeded | requires_action) alt No 3DS required API-->>Client: { success: true, transactionId } Client->>Client: Start balance polling (2s interval, 30s max) Stripe->>Webhook: payment_intent.succeeded Webhook->>API: Update transaction → "succeeded" Webhook->>API: Top up wallet Client->>Client: Detect balance increase → stop polling else 3DS required API-->>Client: { requiresAction: true, clientSecret, paymentIntentId } Client->>Stripe: stripe.confirmCardPayment(clientSecret) Stripe-->>Client: 3DS authentication result Client->>API: POST /v1/stripe/payment-methods/validate (mark as validated) API-->>Client: { success: true } Client->>Client: Start balance polling (2s interval, 30s max) Stripe->>Webhook: payment_intent.succeeded Webhook->>API: Update transaction → "succeeded" Webhook->>API: Top up wallet Client->>Client: Detect balance increase → stop polling endCoupon Flow
sequenceDiagram participant Client participant API participant Stripe participant Webhook Client->>API: POST /v1/stripe/coupons/apply API->>API: Create transaction record (DB) API->>Stripe: Create invoice with discount Stripe-->>API: Invoice finalized API-->>Client: { coupon, amountAdded, transactionId } Client->>Client: Start balance polling (2s interval, 30s max) Stripe->>Webhook: invoice.payment_succeeded Webhook->>API: Update transaction → "succeeded" Webhook->>API: Top up wallet Client->>Client: Detect balance increase → stop pollingCard Validation / Onboarding Flow (with optional 3DS)
sequenceDiagram participant Client participant API participant Stripe participant Webhook Client->>API: POST /v1/start-trial (createWallet) API->>API: No transaction record created API->>Stripe: paymentIntents.create($1, capture: manual) Stripe-->>API: PaymentIntent (requires_capture | requires_action) alt No 3DS required API->>API: markPaymentMethodAsValidated() API->>API: initializeAndGrantTrialLimits() API-->>Client: { wallet data } else 3DS required API-->>Client: { requires3DS: true, clientSecret, paymentIntentId } Client->>Stripe: stripe.confirmCardPayment(clientSecret) Stripe-->>Client: 3DS authentication result Client->>API: POST /v1/stripe/payment-methods/validate API->>Stripe: Retrieve PaymentIntent, check status API->>API: markPaymentMethodAsValidated() API-->>Client: { success: true } Client->>Client: Refetch payment methods Client->>API: POST /v1/start-trial (retry createWallet) API->>API: initializeAndGrantTrialLimits() API-->>Client: { wallet data } endProblems
1. Three different resolution mechanisms
PaymentPollingProvider(2s × 15 attempts)validatePaymentMethodAfter3DS, then balance pollingPaymentPollingProvidercreateWalletreturns synchronouslyvalidatePaymentMethodAfter3DS, refetch methods, retrycreateWalletThere's no unified way for the client to say "wait for this payment action to complete."
2. Client performs backend responsibilities
PaymentPollingProvider— 80+ lines of React context with refs, timeouts, and effects to poll wallet balance every 2 seconds. This "is the payment done yet?" logic belongs on the backend.validatePaymentMethodAfter3DS— after 3DS, the client explicitly tells the backend to check the PaymentIntent and mark the payment method. Thepayment_intent.succeededwebhook could do this automatically.use3DSecurehook — mixes UI state management (open/close popup) with backend mutation calls. Two concerns in one.3. Card validation has no audit trail
createTestChargecreates a Stripe PaymentIntent but stores no transaction record in the DB. If validation fails or behaves unexpectedly, there's zero visibility. Meanwhile,createPaymentIntent(for real payments) stores a full transaction record. These two methods do nearly the same thing at the Stripe API level — the only real difference iscapture_method: "manual".4. Webhook explicitly skips validation intents
In
stripe-webhook.service.ts:109-115:This forces the client to handle the validation marking via a separate API call. If the webhook processed these intents, the client wouldn't need to.
5. Two nearly-identical PaymentIntent creation methods
createPaymentIntentandcreateTestChargeinstripe.service.tsboth create Stripe PaymentIntents. Differences:createPaymentIntentcreateTestChargecapture_method"manual"markPaymentMethodAsValidated()automatic_payment_methodspayment_method_types: ["card", "link"]These could be a single method with a
captureMethodparameter.Proposed Design
Core idea
transactionId— payment, coupon, and card validation all create transaction recordsGET /v1/stripe/transactions/:id?awaitStatus=succeeded— holds the request until the transaction reaches the requested status or times outUnified Payment Flow (all types)
sequenceDiagram participant Client participant API participant Stripe participant Webhook Client->>API: POST mutation endpoint (payment / coupon / validation) API->>API: Create transaction record (DB) API->>Stripe: Create PaymentIntent / Invoice Stripe-->>API: Result API-->>Client: { transactionId, requires3DS?, clientSecret? } opt 3DS required Client->>Stripe: stripe.confirmCardPayment(clientSecret) Stripe-->>Client: Authentication result end Client->>API: GET /v1/stripe/transactions/:id?awaitStatus=succeeded Note over API: Holds request, polls DB every 500ms (60s timeout) Stripe->>Webhook: payment_intent.succeeded / invoice.payment_succeeded Webhook->>API: Update transaction → "succeeded" Webhook->>API: Side effects (top up wallet / mark payment method) API-->>Client: { status: "succeeded", ... } Client->>Client: Show success, refresh UIWhat changes
Backend:
GET /v1/stripe/transactions/:idendpoint with optional?awaitStatusquery paramcreateTestChargeintocreatePaymentIntent(addcaptureMethodparam, always create transaction)payment_method_validationintents instead of skipping them (marks payment method as validated)validatePaymentMethodAfter3DSmethod and/v1/stripe/payment-methods/validaterouteawaitResolvedfrom confirm/coupon endpoints (superseded by the GET endpoint with?awaitStatus)Client:
PaymentPollingProviderentirely (~230 lines)use3DSecureto pure UI state (remove backend mutation call)validatePaymentMethodAfter3DSfromusePaymentMutationsawaitTransaction(id)call — just a GET requestpayment.tsx,PaymentPopup.tsx,PaymentMethodContainer.tsx) follow the same 3-step patternBenefits
Beta Was this translation helpful? Give feedback.
All reactions