Payment
Purpose
A gateway-level record of one payment flow — a Booking pay, a Customer pass purchase, a wallet top-up, or a Subscription payment. Polymorphic sourceId/sourceType point to what is being paid for. The row is created when checkout is initiated; status moves only via gateway webhook.
Identity & key fields
- Primary key:
id(uuid, defaultgen_random_uuid()). sourceId(uuid, NOT NULL) — polymorphic id of the entity being paid for; no DB-level FK.sourceType(text, NOT NULL, default'ORDER') — discriminator.paymentSettingsId(uuid, NOT NULL, FK →payments.settings.id).externalId(nullable text) — gateway-side payment id.status(enumpayment_status:pending,succeeded,failed, defaultpending).amount(numeric 10,2, NOT NULL).currency(text, NOT NULL).paymentUrl(nullable text) — gateway-hosted checkout URL.failureReason(nullable text).
The payments.id is what gets sent to LiqPay as order_id — so when the webhook fires back, the system uses its own internal id to look the row up. externalId (the gateway’s id) is recorded only after a successful webhook. status lifecycle is one-shot: from pending to a terminal succeeded / failed, never the other direction.
Invariants
paymentSettingsIdFK is NOT NULL but has no on-delete clause (NO ACTION default) — payment settings cannot be deleted while payments reference them (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/payments.schema.ts).sourceId,sourceType,amount,currency,statusNOT NULL (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/payments.schema.ts).statusdefaults topending;sourceTypedefaults to'ORDER'(enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/payments.schema.ts).
Business invariants:
paymentSettingsIdFK has no on-delete clause — defaults toNO ACTION: payment settings cannot be deleted while any payment references them. Right behaviour for a money-bearing record.sourceId/sourceTypeare a polymorphic reference — no DB FK by design (polymorphic refs cannot be FK-constrained). See ADR cross-schema-references-without-fk.statusis one-shot:pending → succeededORpending → failed. Once terminal, the row is immutable. Retries create a new payment row.amount,currencysnapshot the request at create time.- LiqPay flow uses our own
payments.idasorder_idsent to the gateway — the gateway returns it in the webhook payload (decodedData.order_id), and we use it to find the row.
Lifecycle
Status enum values: pending, succeeded, failed.
created → pending (PaymentsService.createPayment; paymentUrl set for LIQPAY)pending → succeeded (PaymentsService.processWebhook success — line 307; externalId set)pending → failed (PaymentsService.processWebhook fail — line 384; failureReason set)Terminal statuses are immutable. There is no succeeded → refunded transition here — refunds are recorded in Wallet transaction (in-app ledger), not on the gateway Payment row.
Mutation sources (verified against code)
| Source | What it does |
|---|---|
PaymentsService.createPayment | Insert with status='pending'. Generates gateway checkout (LiqPay sign or Mono invoice) and updates paymentUrl. |
PaymentsService.processWebhook | Decodes base64 payload from gateway, looks up payment by decodedData.order_id (our payments.id), transitions pending → succeeded (line 307, sets externalId) or pending → failed (line 384, sets failureReason). |
| Booking / pass / topup / billing paths | Each calls createPayment and then waits for the webhook. |
Files: libs/features/payments/src/lib/services/payments.service.ts.
Relationships
- Payment settings (ENT-036) —
paymentSettingsId→payments.settings.id. N:1. sourceIdis a polymorphic reference — points at bookings, pass purchases, wallet top-ups, subscription payments, etc.; resolved application-side viasourceType. No DB-level FK (polymorphic refs cannot be FK-constrained). See ADR cross-schema-references-without-fk.
API surfaces
| Surface | Exposed | Notes |
|---|---|---|
| client | yes — /payments/{id} (PaymentResponseDto, LiqpayPaymentDto); also referenced from booking pay / pass purchase / wallet top-up | Swagger UI |
| business | inferred yes — managed via libs/features/payments/src/lib/payments-admin.module.ts; no top-level Payment* DTO in audit-time business OpenAPI extract | Swagger UI |
| super-admin | no | — |
Known gotchas / open questions
- Polymorphic
sourceId/sourceTypewith no DB-level FK — readers must trust the discriminator. See ADR cross-schema-references-without-fk. - This table is the gateway-level record; the wallet-level record of the same money movement lives in Wallet transaction.
- Webhook idempotency invariant:
externalIdshould be unique to prevent duplicate processing of a re-delivered webhook. No DB constraint enforces this today. Idempotency relies entirely on the webhook handler. OPEN: add a partial UNIQUE index(external_id) WHERE external_id IS NOT NULLin a future migration as a DB-level safety net. sourceTypeis plain text (default'ORDER') — known values includeBOOKING,ORDER, and likelyTOPUP/PASS_PURCHASE/SUBSCRIPTION_PAYMENT. The full set is implied by call sites, not enforced by an enum.- A pending payment may never resolve. If the gateway webhook is never delivered, the row sits in
pendingforever — no cron currently times these out.
Recommendations
Forward-looking improvements suggested while filling this doc — not currently in place.
- Partial UNIQUE on
(external_id) WHERE external_id IS NOT NULL— repeated webhook delivery is normal; DB-level dedup is the right safety net (already OPEN above). - Promote
sourceTypeto an enum so the legal set of sources is visible at the schema level and impossible to misspell at insert time. - Idempotency-key on the
createPaymentrequest DTO — clients should be able to retry the create with the same key without producing two gateway checkouts for one logical operation. - Timeout cron for stale
pendingpayments — after N minutes / hours without a webhook, transition tofailed(with a distinctfailureReason='webhook_timeout') so downstream systems aren’t blocked forever. - Audit log of status transitions — for financial compliance and dispute resolution.
- Distinguish “system-cancelled” from “gateway-failed” — today
failedcovers both gateway decline and our own timeout. Two different reasons want two different statuses or at least a discriminator infailureReason.