Skip to content

feat(payments): Payment domain model, initiation flow & idempotent transaction lifecycle - #1577

Merged
yusuftomilola merged 1 commit into
DistinctCodes:mainfrom
mftee:feature/1570-payment-domain-model
Aug 21, 2026
Merged

feat(payments): Payment domain model, initiation flow & idempotent transaction lifecycle#1577
yusuftomilola merged 1 commit into
DistinctCodes:mainfrom
mftee:feature/1570-payment-domain-model

Conversation

@mftee

@mftee mftee commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Foremost/foundational issue in the payment track (1 of 7): establishes the core Payment domain so every later payment issue has something to build on.

  • Payment entity: bookingId/userId refs, amount (minor units), currency, rail (FIAT / STELLAR_CUSTODIAL / STELLAR_EXTERNAL), provider, providerReference, status, idempotencyKey, metadata (jsonb), expiresAt (TTL), timestamps.
  • Guarded state machine (payment-state-machine.ts): INITIATED -> AWAITING_CONFIRMATION -> CONFIRMED | FAILED | EXPIRED, CONFIRMED -> REFUNDED | PARTIALLY_REFUNDED. PaymentsService.transitionStatus() is the only sanctioned path that writes status; illegal transitions throw.
  • Idempotency, two layers:
    • Unique (userId, idempotencyKey) index — replaying the same Idempotency-Key header returns the original Payment instead of creating a duplicate; reusing the key with a different payload is rejected with a 409.
    • Partial unique index on 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 one Payment.
  • Provider-agnostic PaymentRailAdapter interface with a SandboxRailAdapter placeholder — real Paystack/Stellar adapters are later issues; this issue only needed the shape.
  • Endpoints: POST /payments/initiate, GET /payments/:id, GET /payments (own for users, all for admins), documented via Swagger/OpenAPI including the state machine description.
  • Basic RBAC: owner-or-admin can view; only the owner can initiate.

Note on scope

backend/src on main currently 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.ts bootstrap, a TypeOrmModule data source, and a small JWT auth guard + roles guard + UserRole enum for the owner/admin checks. No Booking/User persistence layer is introduced — bookingId/userId are plain UUID references, consistent with the issue's scope.

Test plan

  • npm run build (tsc via nest build) — passes
  • npm run lint — passes
  • npm run test — 67/67 passing, covering:
    • every legal and illegal state-machine transition
    • idempotent replay (same key → same row, no duplicate)
    • rejecting a reused key with a different payload
    • the two DB-level concurrency races (idempotency-key and booking-id), verifying exactly one row wins
    • booking-already-confirmed / already-in-progress rejection
    • owner/admin view RBAC on findOne/findAll

Closes #1570

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

vercel Bot commented Aug 21, 2026

Copy link
Copy Markdown

@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 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 payments foundation PR (1/7 in the track). Solid groundwork:

  • Payment entity covers the essentials well: minor-unit amount, rail abstraction (FIAT/STELLAR_CUSTODIAL/STELLAR_EXTERNAL), provider reference, TTL via expiresAt, and jsonb metadata for 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 on bookingId for non-terminal statuses to guard against concurrent double-initiation independent of the idempotency key. Recovering from the unique-violation race in initiate() by returning the winning row instead of erroring is exactly right for a concurrency-safe API.
  • PaymentRailAdapter interface with the SandboxRailAdapter placeholder 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 main currently 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.

@yusuftomilola
yusuftomilola merged commit 2369f7d into DistinctCodes:main Aug 21, 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 1/7: Payment Domain Model, Initiation Flow & Idempotent Transaction Lifecycle

2 participants