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).
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
Paymententity 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
Paymententity: id,bookingId/order reference,userId,amount(minor units),currency,rail(enum —FIAT,STELLAR_CUSTODIAL,STELLAR_EXTERNAL, extensible),provider,providerReference,status,idempotencyKey,metadata(jsonb), timestamps.INITIATED → AWAITING_CONFIRMATION → CONFIRMED | FAILED | EXPIRED, plusCONFIRMED → REFUNDED | PARTIALLY_REFUNDED. All transitions go through one guarded service method — no other code path may writestatusdirectly.Idempotency-KeyonPOST /payments/initiate, unique per(userId, idempotencyKey), mapped to the resultingPayment— a retried request returns the original result instead of creating a duplicate.PaymentperbookingId, independent of the idempotency key, so a race between two different requests (not just retries) still can't double-create.PaymentRailAdapterinterface. This issue only needs a placeholder/sandbox implementation — the point is the shape, not a working external integration (that's later issues).POST /payments/initiate,GET /payments/:id,GET /payments(own for users, all for admins).Detailed scope
InitiatePaymentServicewith 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).Paymentrow).Important edge cases and failure scenarios
Payment.Idempotency-Keyafter a timeout without knowing if the first request succeeded — must return the original result, never duplicate or error.Idempotency-Keyreused with a different amount/booking (bug or abuse) — must be rejected with a conflict, never silently processed against the old payload.CONFIRMEDpayment — initiate must reject.INITIATEDbecause 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
Paymentrow (test-verified).Idempotency-Keyreturns the originalPayment(test-verified).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
statusexists outside the transition guard (checked in review).