fix(payments): settle fiat transactions instead of losing them - #111
Merged
Conversation
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.
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.
The problem
No fiat payment has ever settled. Live transaction counts:
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
TicketsServicereadapp.paymentCallbackUrl, butapp.config.tsnever defined that key. SoconfigService.get()always returnedundefinedand the hardcodedhttp://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 (PaymentsControllerhad one route,GET /payments/methods).2. The provider webhook never arrives
WebhooksService.handleSuccess()is the only code that flips a fiat transaction toSUCCESSand enqueues the mint, and it's only reachable fromPOST /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 onPaymentsService— with zero callers. No cron, no callback route, no admin endpoint. A missed webhook was permanently unrecoverable.The fix
paymentCallbackUrlnow exists. Resolves fromPAYMENT_CALLBACK_URL, else Render's injectedRENDER_EXTERNAL_URL, so a deploy needs no configuration. Deliberately no localhost fallback — and deliberately not falling back toAPP_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
WebhooksServicemethods 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:Deliberate edge-case handling
reference,trxref(Paystack) andpaymentReference(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
https://<host>/api/webhooks/paystack— including the/apiglobal prefix. This remains the root cause; the callback route means settlement no longer depends on it.render.yamlitself notes.devmano19@gmail.comhas a real one (ACCT_imcjvn2mgjj4bzd, verified live). Others holdDEV_placeholders that Paystack rejects at checkout.