/** * Payment status ladder aligned with payments-dashboard ProcessPaymentCsv. * Internal snake_case for DB/API; Title Case display for CSV consumer rows. */ export const PAYMENT_STATUSES = [ "scheduled", "payment_submitted", "issued", "outstanding", "cleared", ] as const; export type PaymentStatus = (typeof PAYMENT_STATUSES)[number]; export const PAYMENT_STATUS_RANK: Readonly> = { scheduled: 1, payment_submitted: 2, issued: 3, outstanding: 4, cleared: 5, }; export const PAYMENT_DISPLAY_STATUSES = [ "Scheduled", "Payment Submitted", "Issued", "Outstanding", "Cleared", ] as const; export type PaymentDisplayStatus = (typeof PAYMENT_DISPLAY_STATUSES)[number]; const INTERNAL_TO_DISPLAY: Readonly> = { scheduled: "Scheduled", payment_submitted: "Payment Submitted", issued: "Issued", outstanding: "Outstanding", cleared: "Cleared", }; const DISPLAY_TO_INTERNAL: Readonly> = Object.fromEntries( Object.entries(INTERNAL_TO_DISPLAY).map(([internal, display]) => [ display.toLowerCase(), internal as PaymentStatus, ]), ); /** Cancel / void vocabulary accepted by the payments-dashboard consumer (case-insensitive). */ export const CANCEL_STATUSES = ["voided", "cancelled", "canceled", "marked as void"] as const; export type CancelStatus = (typeof CANCEL_STATUSES)[number]; export function isPaymentStatus(value: string): value is PaymentStatus { return (PAYMENT_STATUSES as readonly string[]).includes(value); } export function isCancelStatus(value: string | null | undefined): boolean { if (value == null) return false; return (CANCEL_STATUSES as readonly string[]).includes(value.trim().toLowerCase()); } export function paymentStatusRank(status: PaymentStatus): number { return PAYMENT_STATUS_RANK[status]; } /** * Parse a CSV/display or internal status string into PaymentStatus. * Cancel vocabulary returns null (use isCancelStatus separately). */ export function parsePaymentStatus(value: string | null | undefined): PaymentStatus | null { if (value == null) return null; const normalized = value.trim().toLowerCase(); if (isPaymentStatus(normalized)) return normalized; return DISPLAY_TO_INTERNAL[normalized] ?? null; } export function toPaymentDisplayStatus(status: PaymentStatus): PaymentDisplayStatus { return INTERNAL_TO_DISPLAY[status]; } /** * Whether a payment may move from `from` to `to`. * - Non-cancel: rank must not regress; cleared is terminal. * - Cancel: allowed only when current rank < cleared. * - `to` may be a PaymentStatus or a cancel vocabulary string. */ export function canTransitionPaymentStatus( from: PaymentStatus, to: PaymentStatus | string, ): boolean { const fromRank = PAYMENT_STATUS_RANK[from]; if (fromRank === PAYMENT_STATUS_RANK.cleared) { return false; } if (isCancelStatus(to)) { return fromRank < PAYMENT_STATUS_RANK.cleared; } const next = parsePaymentStatus(to); if (next == null) return false; const toRank = PAYMENT_STATUS_RANK[next]; return toRank >= fromRank; }