Skip to content

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, default gen_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 (enum payment_status: pending, succeeded, failed, default pending).
  • 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

  • paymentSettingsId FK 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, status NOT NULL (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/payments.schema.ts).
  • status defaults to pending; sourceType defaults to 'ORDER' (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/payments.schema.ts).

Business invariants:

  • paymentSettingsId FK has no on-delete clause — defaults to NO ACTION: payment settings cannot be deleted while any payment references them. Right behaviour for a money-bearing record.
  • sourceId / sourceType are a polymorphic reference — no DB FK by design (polymorphic refs cannot be FK-constrained). See ADR cross-schema-references-without-fk.
  • status is one-shot: pending → succeeded OR pending → failed. Once terminal, the row is immutable. Retries create a new payment row.
  • amount, currency snapshot the request at create time.
  • LiqPay flow uses our own payments.id as order_id sent 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)

SourceWhat it does
PaymentsService.createPaymentInsert with status='pending'. Generates gateway checkout (LiqPay sign or Mono invoice) and updates paymentUrl.
PaymentsService.processWebhookDecodes 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 pathsEach calls createPayment and then waits for the webhook.

Files: libs/features/payments/src/lib/services/payments.service.ts.

Relationships

  • Payment settings (ENT-036) — paymentSettingsIdpayments.settings.id. N:1.
  • sourceId is a polymorphic reference — points at bookings, pass purchases, wallet top-ups, subscription payments, etc.; resolved application-side via sourceType. No DB-level FK (polymorphic refs cannot be FK-constrained). See ADR cross-schema-references-without-fk.

API surfaces

SurfaceExposedNotes
clientyes — /payments/{id} (PaymentResponseDto, LiqpayPaymentDto); also referenced from booking pay / pass purchase / wallet top-upSwagger UI
businessinferred yes — managed via libs/features/payments/src/lib/payments-admin.module.ts; no top-level Payment* DTO in audit-time business OpenAPI extractSwagger UI
super-adminno

Known gotchas / open questions

  • Polymorphic sourceId / sourceType with 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: externalId should 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 NULL in a future migration as a DB-level safety net.
  • sourceType is plain text (default 'ORDER') — known values include BOOKING, ORDER, and likely TOPUP / 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 pending forever — 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 sourceType to 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 createPayment request 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 pending payments — after N minutes / hours without a webhook, transition to failed (with a distinct failureReason='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 failed covers both gateway decline and our own timeout. Two different reasons want two different statuses or at least a discriminator in failureReason.