Skip to content

fix(payments): settle fiat transactions instead of losing them - #111

Merged
manoahLinks merged 1 commit into
mainfrom
fix/fiat-payment-settlement
Aug 1, 2026
Merged

fix(payments): settle fiat transactions instead of losing them#111
manoahLinks merged 1 commit into
mainfrom
fix/fiat-payment-settlement

Conversation

@manoahLinks

Copy link
Copy Markdown
Contributor

The problem

No fiat payment has ever settled. Live transaction counts:

Provider PENDING SUCCESS
PAYSTACK 10 0
MONNIFY 3 0
CRYPTO 6 24

Crypto works because it settles via the Circle webhook (392 audit rows received). Fiat has never settled once. Money leaves the buyer's account and CrowdPass never learns about it — no ticket, no mint, nothing on the organizer dashboard.

Three compounding causes:

1. The post-checkout redirect pointed at localhost in every environment

TicketsService read app.paymentCallbackUrl, but app.config.ts never defined that key. So configService.get() always returned undefined and the hardcoded http://localhost:3000/... fallback always won — production included. No env var could override it, because none was wired anywhere. The route it pointed at didn't exist either (PaymentsController had one route, GET /payments/methods).

2. The provider webhook never arrives

WebhooksService.handleSuccess() is the only code that flips a fiat transaction to SUCCESS and enqueues the mint, and it's only reachable from POST /webhooks/paystack. That endpoint is mapped and deployed, and has received zero requests — while Circle webhooks hit constantly and return 200. Every reported symptom follows from this one fact.

3. Nothing could recover a miss

verifyPayment() is implemented on both providers and exposed on PaymentsService — with zero callers. No cron, no callback route, no admin endpoint. A missed webhook was permanently unrecoverable.

The fix

  • paymentCallbackUrl now exists. Resolves from PAYMENT_CALLBACK_URL, else Render's injected RENDER_EXTERNAL_URL, so a deploy needs no configuration. Deliberately no localhost fallback — and deliberately not falling back to APP_URL, which is the public app origin for email links, not this service's origin.

  • Production refuses to boot when the callback URL is unresolvable, instead of silently shipping a dead redirect again.

  • GET /payments/callback — CrowdPass has no web frontend, so the gateway redirects into the API itself and the mobile WebView watches for this path. It settles the transaction on the redirect, so the ticket mints even if the webhook never arrives, then renders a self-contained result page.

  • GET /payments/verify?reference= — same outcome as JSON, safe for the app to poll (bank transfers can stay pending for minutes).

  • Audit rows for Paystack/Monnify webhooks. Previously only Circle wrote them, which is exactly why "arrived and failed" was indistinguishable from "never arrived".

Both routes funnel into the existing WebhooksService methods rather than reimplementing settlement — those already no-op on any non-PENDING transaction, so a webhook racing a callback is safe.

Mobile contract

The WebView watches for /api/payments/callback, then closes and refreshes. Outcome is machine-readable, so it never needs to scrape visible copy:

<meta name="cp-status"    content="SUCCESS|FAILED|PENDING">
<meta name="cp-settled"   content="true|false">
<meta name="cp-reference" content="HOSTIT_TXN_...">

Deliberate edge-case handling

  • The callback never throws — the buyer may already have been charged, so a 500 is the worst possible outcome. Unknown reference, gateway error, and missing reference all render a coherent page.
  • Refuses to settle an underpayment rather than minting a ticket for less than the order value.
  • Escapes the reference so it can't inject markup into the result page.
  • Duplicate webhooks still process. Circle can bail early because its work is queued and retried independently; these handlers settle synchronously, so dropping a retry would strand a payment whose first delivery failed.
  • Accepts reference, trxref (Paystack) and paymentReference (Monnify) — the providers disagree on the parameter name.

Testing

14 new tests in settlement.controller.spec.ts; full suite green (51 passing), lint and build clean.

Still required outside this PR

  1. Point the Paystack webhook at https://<host>/api/webhooks/paystack — including the /api global prefix. This remains the root cause; the callback route means settlement no longer depends on it.
  2. Upgrade off Render's free plan. It sleeps after ~15 min idle and a cold start can take 30–60s, which breaks webhooks — as render.yaml itself notes.
  3. Organizer subaccounts. Only devmano19@gmail.com has a real one (ACCT_imcjvn2mgjj4bzd, verified live). Others hold DEV_ placeholders that Paystack rejects at checkout.

No fiat payment has ever settled. PAYSTACK and MONNIFY transactions sit
at 10 and 3 PENDING respectively with zero SUCCESS, while CRYPTO — which
settles through the Circle webhook — has 24. Three compounding causes:

1. The post-checkout redirect pointed at localhost in every environment.
   `TicketsService` read `app.paymentCallbackUrl`, but `app.config.ts`
   never defined that key, so `configService.get()` always returned
   undefined and the hardcoded `http://localhost:3000/...` fallback
   always won — production included. No env var could override it
   because none was wired. The route it pointed at didn't exist either.

2. The provider webhook never arrives. `WebhooksService.handleSuccess()`
   is the only code that flips a fiat transaction to SUCCESS and
   enqueues the mint, and it is only reachable from
   `POST /webhooks/paystack`. That endpoint is mapped and deployed but
   has received zero requests. Every symptom follows from this: stuck
   PENDING, no mint, nothing on the organizer dashboard (revenue reads
   `status: SUCCESS`).

3. Nothing could recover a miss. `verifyPayment()` was implemented on
   both providers and exposed on `PaymentsService` with no callers at
   all — no cron, no callback route, no admin endpoint.

Changes:

- Add the `paymentCallbackUrl` key that was being read but never
  existed. It resolves from PAYMENT_CALLBACK_URL, else from Render's
  injected RENDER_EXTERNAL_URL, so a deploy needs no configuration.
  Deliberately no localhost fallback, and no fallback to APP_URL — that
  is the public app origin for email links, not this service's origin.

- `TicketsService` now refuses to boot in production when the callback
  URL is unresolvable, rather than silently shipping a dead redirect.

- Add `GET /payments/callback`. CrowdPass has no web frontend, so the
  gateway redirects into the API itself; the mobile WebView watches for
  this path. It settles the transaction on the redirect, so the ticket
  mints even if the webhook never arrives, and renders a self-contained
  result page exposing the outcome via `cp-status` / `cp-settled` /
  `cp-reference` meta tags. It never throws — the buyer may already have
  been charged, so a 500 is the worst possible outcome — and it refuses
  to settle an underpayment.

- Add `GET /payments/verify?reference=` returning the same outcome as
  JSON for the app to poll.

Both routes funnel into the existing `WebhooksService` methods rather
than reimplementing settlement. Those already no-op on any transaction
that isn't PENDING, so a webhook racing a callback is safe.

- Paystack and Monnify webhooks now write `webhook_events` audit rows;
  previously only Circle did, which is why "arrived and failed" was
  indistinguishable from "never arrived". Unlike the Circle handler a
  duplicate does not short-circuit: Circle's work is queued and retried
  independently, but these settle synchronously, so dropping a retry
  would strand a payment whose first delivery failed.

Note this does not by itself make the webhook arrive — the Paystack
dashboard must point at https://<host>/api/webhooks/paystack, including
the /api global prefix. The callback route means settlement no longer
depends on that being right.
@manoahLinks
manoahLinks merged commit 7a665e9 into main Aug 1, 2026
3 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.

1 participant