Skip to content

feat(payments): real-time confirmation pipeline & app/provider state boundary - #1578

Merged
yusuftomilola merged 1 commit into
DistinctCodes:mainfrom
soundsng:feature/1571-realtime-confirmation-pipeline
Aug 22, 2026
Merged

feat(payments): real-time confirmation pipeline & app/provider state boundary#1578
yusuftomilola merged 1 commit into
DistinctCodes:mainfrom
soundsng:feature/1571-realtime-confirmation-pipeline

Conversation

@soundsng

Copy link
Copy Markdown
Contributor

Summary

Issue #1570 gives 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, 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/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.
  • A duplicate delivery of the same outcome against an already-terminal payment is a clean no-op.
  • A later, disagreeing event against an already-terminal payment is flagged 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-providerReference events, 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/sandbox

Verifies the HMAC signature against the exact raw bytes (main.ts now boots with rawBody: true so 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 /payments

JWT-authenticated handshake (same verification the HTTP JwtAuthGuard uses); a client explicitly subscribes to one payment's room only after re-checking the same owner-or-admin 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 (covered explicitly by tests). The webhook remains authoritative and can still supersede this later, through the same idempotent apply path.

PaymentRailAdapter

Gained verifyWebhookSignature/parseWebhookPayload/verifyByReference. SandboxRailAdapter implements all three — HMAC via PAYMENT_WEBHOOK_SECRET; verifyByReference is 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-client isn'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 (/payments namespace, payment:update event) is ready for a page to consume once one exists.

Test plan

  • npm run build — passes
  • npm run lint — passes
  • npm run test102 passing (all new + all pre-existing), covering:
    • webhook confirmed/failed/pending outcomes, duplicate replay (clean no-op), out-of-order/conflicting replay (flagged, not applied), unknown providerReference (logged anomaly)
    • forged/invalid webhook signature rejected and logged, before reaching any state logic
    • malformed-but-signed payload rejected and logged
    • verify-on-return: already-terminal short-circuits without calling the provider; missing providerReference; provider timeout; provider rejection; pending response — all assert verified:false and the payment status is unchanged; a confirmed response transitions and emits the real-time update
    • WS gateway: connection rejected with no/invalid token; subscribe rejected when unauthenticated; subscribe joins the room only when PaymentsService.findOne's access check passes, otherwise emits subscribe:error; emitPaymentUpdate targets the correct room

Closes #1571

…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
@vercel

vercel Bot commented Aug 21, 2026

Copy link
Copy Markdown

@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 yusuftomilola left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.apply is 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_event rather than either silently applied or silently dropped. That last case is the one most implementations get wrong.
  • ConfirmationEvent as 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 findOne before 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-return only ever moving status on an authoritative confirmed/failed response — timeout, provider error, and pending all leave the payment untouched and return verified: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-client not 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.

@yusuftomilola
yusuftomilola merged commit f8e5754 into DistinctCodes:main Aug 22, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Payments 2/7: Real-Time Confirmation Pipeline & the App/Provider State Boundary

2 participants