feat(payments): real-time confirmation pipeline & app/provider state boundary - #1578
Merged
yusuftomilola merged 1 commit intoAug 22, 2026
Conversation
…boundary Foundation issue DistinctCodes#1570 gave us a Payment that starts AWAITING_CONFIRMATION, but nothing moved it forward. This adds a provider-agnostic confirmation pipeline that treats the provider's response as the only source of truth, with a real-time push channel so the app finds out immediately instead of polling. - PaymentConfirmationService: the single place that turns a rail's confirmed/failed/pending outcome into a Payment state change (or a deliberate no-op) — shared by both the webhook path and the verify-on-return fast path, so both converge on identical idempotency/ ordering semantics. A terminal Payment status is never overwritten by a later event (duplicate deliveries are clean no-ops; a later event that disagrees with an already-terminal status is flagged conflicting_event, not silently applied or silently dropped). - ConfirmationEvent: append-only audit log of every confirmation event received — webhook or verify-on-return — regardless of whether it changed anything, including events for an unknown providerReference and rejected (invalid-signature/malformed) webhooks. Stores a payload hash, never the raw payload. - PaymentWebhookController (POST /payments/webhooks/sandbox): verifies the HMAC signature against the exact raw bytes (main.ts now boots with rawBody: true) before touching any state or trusting the payload; rejects and logs both invalid-signature and malformed-payload cases without ever reaching the confirmation logic. - PaymentsGateway (WS namespace /payments): JWT-authenticated handshake (same pattern/verification as the HTTP JwtAuthGuard), per-payment rooms joined only after re-checking the same owner-or-admin access rule PaymentsService.findOne already enforces — the real-time channel has no looser access rules than the REST endpoint. Polling (GET /payments/:id) remains the documented fallback for clients that can't hold a socket open. - POST /payments/:id/verify-return: bounded synchronous fast path for the checkout-return leg. A timeout, provider error, or "pending" response always returns verified:false with the payment UNCHANGED — only an authoritative confirmed/failed response ever moves the status. The webhook remains authoritative and can still supersede this later, through the same idempotent PaymentConfirmationService.apply path. - PaymentRailAdapter gained verifyWebhookSignature/parseWebhookPayload/ verifyByReference; SandboxRailAdapter implements all three (HMAC via PAYMENT_WEBHOOK_SECRET; verifyByReference is deterministic on the reference string so every outcome is exercisable in tests without a real provider). - New env vars: PAYMENT_WEBHOOK_SECRET, PAYMENT_VERIFY_TIMEOUT_MS (default 3000ms). Frontend real-time consumer intentionally deferred: there's no existing payment UI/page in this repo to attach it to yet, and socket.io-client isn't installed — building a disconnected hook with nothing to integrate into would be speculative scope, and every acceptance criterion for this issue is backend-verifiable. The WS gateway is ready for a page to consume once one exists. 102 tests passing (all new + all pre-existing). Closes DistinctCodes#1571
|
@soundsng is attempting to deploy a commit to the naijabuz's projects Team on Vercel. A member of the Team first needs to authorize it. |
yusuftomilola
approved these changes
Aug 22, 2026
yusuftomilola
left a comment
Collaborator
There was a problem hiding this comment.
Reviewed the real-time confirmation pipeline PR (closes #1571). Strong follow-up to the PAY-01 foundation:
- Routing both the webhook path and the verify-on-return fast path through the same
PaymentConfirmationService.applyis the key design decision here — it guarantees both paths converge on identical idempotency/ordering semantics instead of drifting into two slightly different state machines over time. - The three-way outcome handling is exactly right for a provider-confirmation boundary: terminal status never overwritten, same-outcome replay is a clean no-op, and a later disagreeing event against an already-terminal payment is flagged
conflicting_eventrather than either silently applied or silently dropped. That last case is the one most implementations get wrong. ConfirmationEventas an append-only audit log capturing unknown-reference events, rejected webhooks, duplicates, and conflicts (with a payload hash, not the raw payload) gives real operational visibility into a system that's otherwise a black box when something goes sideways.- Verifying the HMAC signature against the exact raw bytes (via
rawBody: true) instead of a re-serialized parsed copy is the correct way to do webhook signature verification — re-serialization mismatches are a classic source of false-negative signature checks. - The WS gateway re-checking the same owner-or-admin rule as the REST
findOnebefore allowing a room subscription is the right call — a real-time channel with looser access than the equivalent REST endpoint would be a quiet privilege escalation. verify-returnonly ever moving status on an authoritative confirmed/failed response — timeout, provider error, andpendingall leave the payment untouched and returnverified:false— correctly keeps the checkout-return leg from racing ahead of the webhook.- The frontend scope decision is well-reasoned and transparently documented rather than silently dropped: no existing payment UI to attach to,
socket.io-clientnot installed, so building a disconnected hook would be speculative. The gateway is ready for when a page exists to consume it. - Test coverage matches the risk surface precisely: duplicate/conflicting replay, forged/invalid signatures, malformed-but-signed payloads, and the full verify-on-return matrix (timeout/error/pending/confirmed) are all explicitly asserted.
CI is green across Backend, Frontend, and Frontend E2E. Vercel's FAILURE is the usual unauthorized deployment integration link, unrelated to the code.
Approving — thorough, well-reasoned continuation of the payment track.
10 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Issue #1570 gives us a
Paymentthat startsAWAITING_CONFIRMATION, but nothing moved it forward. This adds a provider-agnostic confirmation pipeline that treats the provider's response as the only source of truth, plus a real-time push channel so the app finds out the instant it happens instead of only on next poll.PaymentConfirmationService
The single place that turns a rail's
confirmed/failed/pendingoutcome into a Payment state change (or a deliberate no-op) — shared by both the webhook path and the verify-on-return fast path, so both converge on identical idempotency/ordering semantics:conflicting_event— logged, not silently applied or silently dropped.ConfirmationEvent (append-only audit log)
Records every confirmation event received — webhook or verify-on-return — regardless of whether it changed anything: unknown-
providerReferenceevents, rejected (invalid-signature / malformed-payload) webhooks, duplicates, and conflicts all show up here. Stores a payload hash, never the raw payload.PaymentWebhookController —
POST /payments/webhooks/sandboxVerifies the HMAC signature against the exact raw bytes (
main.tsnow boots withrawBody: trueso we're not re-serializing a parsed copy) before touching any state or trusting the payload. Both invalid-signature and malformed-(but-authentically-signed)-payload cases are rejected and logged without ever reaching the confirmation logic.PaymentsGateway — WS namespace
/paymentsJWT-authenticated handshake (same verification the HTTP
JwtAuthGuarduses); a client explicitly subscribes to one payment's room only after re-checking the same owner-or-admin rulePaymentsService.findOnealready enforces — the real-time channel has no looser access rules than the REST endpoint. Polling (GET /payments/:id) remains the documented fallback for clients that can't hold a socket open.POST /payments/:id/verify-returnBounded synchronous fast path for the checkout-return leg. A timeout, provider error, or
pendingresponse always returnsverified:falsewith the payment unchanged — only an authoritative confirmed/failed response ever moves the status (covered explicitly by tests). The webhook remains authoritative and can still supersede this later, through the same idempotentapplypath.PaymentRailAdapter
Gained
verifyWebhookSignature/parseWebhookPayload/verifyByReference.SandboxRailAdapterimplements all three — HMAC viaPAYMENT_WEBHOOK_SECRET;verifyByReferenceis deterministic on the reference string (fail/pending/anything-else) so every outcome is exercisable in tests without a real provider.New env vars:
PAYMENT_WEBHOOK_SECRET,PAYMENT_VERIFY_TIMEOUT_MS(default 3000ms).Scope note: frontend
The issue's "Detailed scope" lists a frontend real-time payment-status view. I checked — there's no existing payment UI/page in this repo to attach it to yet, and
socket.io-clientisn't an installed frontend dependency. Building a disconnected hook with nothing to integrate into would be speculative scope, and every acceptance criterion for this issue is backend-verifiable. The WS gateway (/paymentsnamespace,payment:updateevent) is ready for a page to consume once one exists.Test plan
npm run build— passesnpm run lint— passesnpm run test— 102 passing (all new + all pre-existing), covering:providerReference(logged anomaly)providerReference; provider timeout; provider rejection;pendingresponse — all assertverified:falseand the payment status is unchanged; a confirmed response transitions and emits the real-time updatePaymentsService.findOne's access check passes, otherwise emitssubscribe:error;emitPaymentUpdatetargets the correct roomCloses #1571