import { Column, Entity, Index } from "typeorm"; import { BaseEntity } from "@edr/api-common"; import { ClientAction, PaymentReferenceType, PaymentService, ProviderMethod, ProviderPaymentStatus, } from "@edr/types"; /** * One payment attempt for one domain order — the platform-wide source of truth for payment * state. `reference_id` is a soft reference into the owning app's schema (never a FK; see * docs/payment-service/architecture.md §5). * * Enum-valued columns are stored as varchar (values mirror the shared @edr/types enums) so * adding a provider/status never needs an ALTER TYPE migration. */ @Entity({ name: "payment_intent" }) // A domain order may have MANY intents (free method changes; a second real payment is stored as // its own SUCCEEDED row). Plain lookup index — no uniqueness. Confirm-once is enforced by the // owning app confirming the booking only while it is still payable, not by the DB. @Index("idx_payment_intent_reference", ["service", "referenceType", "referenceId"]) @Index("idx_payment_intent_sweep", ["status", "updatedAt"]) @Index("idx_payment_intent_idempotency", ["service", "idempotencyKey"]) export class PaymentIntent extends BaseEntity { /** Owning domain app — routing discriminator for notifications. */ @Column({ name: "service", type: "varchar", length: 16 }) service!: PaymentService; @Column({ name: "reference_type", type: "varchar", length: 32 }) referenceType!: PaymentReferenceType; /** Domain order id (booking/shipment). Soft reference — no cross-schema FK. */ @Column({ name: "reference_id", type: "varchar", length: 64 }) referenceId!: string; /** Provider-facing reference, prefixed PSG-/FRT- so webhooks route before a DB lookup. */ @Column({ name: "merchant_order_id", type: "varchar", length: 64, unique: true, }) merchantOrderId!: string; @Column({ name: "provider", type: "varchar", length: 16 }) provider!: ProviderMethod; /** Provider-side order/session id (prepay id, HPP orderId, …). */ @Column({ name: "provider_order_id", type: "varchar", length: 128, nullable: true, }) providerOrderId?: string | null; /** Final provider transaction id, set on terminal success. */ @Index("idx_payment_intent_provider_txn") @Column({ name: "provider_txn_id", type: "varchar", length: 128, nullable: true, }) providerTxnId?: string | null; /** App-asserted authoritative amount (real/major price; may be fractional, e.g. 10.50). */ @Column({ name: "amount_minor", type: "double precision" }) amountMinor!: number; /** Provider-reported amount; reconciled against amount_minor (e.g. Waafi truncates decimals). */ @Column({ name: "confirmed_amount_minor", type: "double precision", nullable: true, }) confirmedAmountMinor?: number | null; @Column({ name: "currency", type: "varchar", length: 8 }) currency!: string; /** State machine: REQUIRES_ACTION → PROCESSING → SUCCEEDED | FAILED | CANCELLED (absorbing). */ @Column({ name: "status", type: "varchar", length: 24, default: ProviderPaymentStatus.REQUIRES_ACTION, }) status!: ProviderPaymentStatus; /** Redirect/launch payload returned to the app for the user to complete payment. */ @Column({ name: "client_action", type: "jsonb", nullable: true }) clientAction?: ClientAction | null; @Column({ name: "failure_code", type: "varchar", length: 64, nullable: true }) failureCode?: string | null; @Column({ name: "failure_message", type: "text", nullable: true }) failureMessage?: string | null; /** Caller-supplied initiate dedupe key (in addition to the per-reference upsert). */ @Column({ name: "idempotency_key", type: "varchar", length: 128, nullable: true, }) idempotencyKey?: string | null; /** * CBE_BILL only: the short numeric Bill_Id the customer types at a CBE channel * (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §5). Null for every other provider. */ @Column({ name: "bill_reference", type: "varchar", length: 32, nullable: true, unique: true, }) billReference?: string | null; /** Payer full name snapshot — fallback for CBE /cbe/query Full_Name when bill-query is down. */ @Column({ name: "payer_name", type: "varchar", length: 128, nullable: true }) payerName?: string | null; @Column({ name: "expires_at", type: "timestamptz", nullable: true }) expiresAt?: Date | null; @Column({ name: "paid_at", type: "timestamptz", nullable: true }) paidAt?: Date | null; /** Audit copy of the provider initiation request/response (secrets redacted upstream). */ @Column({ name: "raw_initiation", type: "jsonb", nullable: true }) rawInitiation?: Record | null; } /** Statuses that keep the per-reference unique index "active" (block a new intent). */ export const ACTIVE_INTENT_STATUSES = [ ProviderPaymentStatus.REQUIRES_ACTION, ProviderPaymentStatus.PROCESSING, ProviderPaymentStatus.SUCCEEDED, ] as const; export const TERMINAL_INTENT_STATUSES = [ ProviderPaymentStatus.SUCCEEDED, ProviderPaymentStatus.FAILED, ProviderPaymentStatus.CANCELLED, ] as const;