Skip to content

Payments 1/7: Payment Domain Model, Initiation Flow & Idempotent Transaction Lifecycle #1570

Description

@yusuftomilola

Problem / Context

ManageHub has no payment implementation at all right now. Bookings and other chargeable actions have no way to collect money, and nothing downstream (confirmation, reconciliation, wallets, on-chain settlement) can be built without a foundational, correctly-shaped domain model. If we skip straight to "integrate a payment provider," idempotency and concurrency safety tend to get bolted on after a duplicate-charge incident instead of designed in from the start — this issue exists to make that impossible by sequencing it first.

This is issue 1 of 7 in the payment track. See the full roadmap context in the issues that follow.

Objective

Establish the core payment domain: a Payment entity with an explicit, guarded state machine, a provider-agnostic initiation flow, and idempotency/duplicate-prevention built in from the first line of code — plus the minimal HTTP surface every later issue plugs into.

Technical approach

  • Payment entity: id, bookingId/order reference, userId, amount (minor units), currency, rail (enum — FIAT, STELLAR_CUSTODIAL, STELLAR_EXTERNAL, extensible), provider, providerReference, status, idempotencyKey, metadata (jsonb), timestamps.
  • Explicit state machine: INITIATED → AWAITING_CONFIRMATION → CONFIRMED | FAILED | EXPIRED, plus CONFIRMED → REFUNDED | PARTIALLY_REFUNDED. All transitions go through one guarded service method — no other code path may write status directly.
  • Idempotency, two layers:
    • Client-supplied Idempotency-Key on POST /payments/initiate, unique per (userId, idempotencyKey), mapped to the resulting Payment — a retried request returns the original result instead of creating a duplicate.
    • DB-level partial unique index enforcing at most one non-terminal Payment per bookingId, independent of the idempotency key, so a race between two different requests (not just retries) still can't double-create.
  • Provider-agnostic PaymentRailAdapter interface. This issue only needs a placeholder/sandbox implementation — the point is the shape, not a working external integration (that's later issues).
  • Endpoints: POST /payments/initiate, GET /payments/:id, GET /payments (own for users, all for admins).

Detailed scope

  • Migration + entity + enums.
  • InitiatePaymentService with idempotency-key lookup-or-create, concurrency-safe via the DB unique constraint plus retry-on-conflict (catch the unique violation, return the existing row rather than erroring).
  • Status-transition guard utility that every later issue's providers must use.
  • Basic RBAC: owner or admin can view; only the owner can initiate for their own booking.
  • Unit + integration tests for the state machine and for the concurrency race (two simultaneous initiate calls for the same booking → exactly one Payment row).

Important edge cases and failure scenarios

  • Two initiate requests fired milliseconds apart for the same booking (double-click, client retry) — must resolve to one Payment.
  • Client retries the same Idempotency-Key after a timeout without knowing if the first request succeeded — must return the original result, never duplicate or error.
  • Same Idempotency-Key reused with a different amount/booking (bug or abuse) — must be rejected with a conflict, never silently processed against the old payload.
  • Booking already has a CONFIRMED payment — initiate must reject.
  • Payment stuck in INITIATED because the user navigated away — this issue defines the expiry field/TTL; a later reconciliation issue implements the sweep that acts on it.

Dependencies

None — this is the foundation everything else in the payment track builds on.

Acceptance criteria

  • Concurrent initiate requests for the same booking never produce more than one non-terminal Payment row (test-verified).
  • Replaying the same Idempotency-Key returns the original Payment (test-verified).
  • Every status transition goes through the guarded method; illegal transitions throw.
  • OpenAPI docs describe the state machine.

Definition of done

Migration merged; endpoints live behind auth; ≥90% branch coverage on the state machine and idempotency paths; the concurrency-race test passes in CI; no direct write to status exists outside the transition guard (checked in review).

Metadata

Metadata

Assignees

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardThird CampaignCampaign: Third CampaignbackendenhancementNew feature or request

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions