feat(payments): Payment domain model, initiation flow & idempotent transaction lifecycle - #1577
Merged
yusuftomilola merged 1 commit intoAug 21, 2026
Conversation
…ecycle Foundation for the payment track (issue 1 of 7): a Payment entity with a guarded state machine, a provider-agnostic initiation flow, and two-layer idempotency built in from the start. - Payment entity: bookingId/userId refs, amount (minor units), currency, rail (FIAT/STELLAR_CUSTODIAL/STELLAR_EXTERNAL), provider, status, idempotencyKey, metadata, expiresAt TTL. - Guarded state machine (payment-state-machine.ts): the only path that may change Payment#status; illegal transitions throw. - Idempotency: unique (userId, idempotencyKey) index for safe retries, plus a partial unique index on bookingId (non-terminal statuses only) so two different concurrent requests for the same booking can't both create a row. PaymentsService catches the resulting unique-violation and returns the winning row instead of erroring. - Provider-agnostic PaymentRailAdapter interface with a sandbox/placeholder implementation; real Paystack/Stellar adapters land in later issues. - POST /payments/initiate, GET /payments/:id, GET /payments with owner-or- admin RBAC. - Migration creating the payments table, enums, and both unique indexes. - Minimal JWT auth guard + roles guard + RBAC scaffolding, since the backend currently has no auth module for this to build on. - Unit tests covering every legal/illegal state transition and the idempotency-key and booking-id concurrency races. Closes DistinctCodes#1570
|
@mftee 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 21, 2026
yusuftomilola
left a comment
Collaborator
There was a problem hiding this comment.
Reviewed the payments foundation PR (1/7 in the track). Solid groundwork:
Paymententity covers the essentials well: minor-unitamount,railabstraction (FIAT/STELLAR_CUSTODIAL/STELLAR_EXTERNAL), provider reference, TTL viaexpiresAt, and jsonbmetadatafor extensibility.- The guarded state machine centralizing all status writes through
transitionStatus()(throwing on illegal transitions) is the right call — prevents payment state corruption from scattered writes. - Idempotency is handled thoughtfully at two levels: the
(userId, idempotencyKey)unique index for safe replay/409-on-mismatch, plus a separate partial unique index onbookingIdfor non-terminal statuses to guard against concurrent double-initiation independent of the idempotency key. Recovering from the unique-violation race ininitiate()by returning the winning row instead of erroring is exactly right for a concurrency-safe API. PaymentRailAdapterinterface with theSandboxRailAdapterplaceholder keeps this PR scoped to the domain model without pulling in real Paystack/Stellar integration prematurely.- RBAC (owner-or-admin view, owner-only initiate) is minimal but correctly scoped for this issue.
- Test coverage is thorough — 67/67 passing, including both concurrency races, idempotency replay/mismatch, and state machine transition edge cases.
- Clear callout on the scope note (bootstrap/auth/DB wiring added only because
maincurrently lacks it) — appreciated the transparency there instead of silently expanding scope.
CI green across Backend, Frontend, and E2E. The Vercel status failure is an unauthorized deployment integration link, unrelated to the code.
Approving — great foundation for PAY-02 through PAY-07.
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
Foremost/foundational issue in the payment track (1 of 7): establishes the core
Paymentdomain so every later payment issue has something to build on.Paymententity:bookingId/userIdrefs,amount(minor units),currency,rail(FIAT/STELLAR_CUSTODIAL/STELLAR_EXTERNAL),provider,providerReference,status,idempotencyKey,metadata(jsonb),expiresAt(TTL), timestamps.payment-state-machine.ts):INITIATED -> AWAITING_CONFIRMATION -> CONFIRMED | FAILED | EXPIRED,CONFIRMED -> REFUNDED | PARTIALLY_REFUNDED.PaymentsService.transitionStatus()is the only sanctioned path that writesstatus; illegal transitions throw.(userId, idempotencyKey)index — replaying the sameIdempotency-Keyheader returns the originalPaymentinstead of creating a duplicate; reusing the key with a different payload is rejected with a 409.bookingId(non-terminal statuses only) so two different concurrent requests for the same booking can't both create a row, independent of idempotency key.PaymentsService.initiate()catches the resulting unique-violation and recovers by returning the winning row rather than erroring, so a concurrency race resolves to exactly onePayment.PaymentRailAdapterinterface with aSandboxRailAdapterplaceholder — real Paystack/Stellar adapters are later issues; this issue only needed the shape.POST /payments/initiate,GET /payments/:id,GET /payments(own for users, all for admins), documented via Swagger/OpenAPI including the state machine description.Note on scope
backend/srconmaincurrently has no bootstrap, auth, or database wiring (recent repo cleanup). This PR adds the minimal scaffolding needed for the above to function and be testable:main.ts/app.module.tsbootstrap, aTypeOrmModuledata source, and a small JWT auth guard + roles guard +UserRoleenum for the owner/admin checks. No Booking/User persistence layer is introduced —bookingId/userIdare plain UUID references, consistent with the issue's scope.Test plan
npm run build(tsc vianest build) — passesnpm run lint— passesnpm run test— 67/67 passing, covering:findOne/findAllCloses #1570