seahaven-ap/packages/shared/src/payment-status.ts
Adam Moussa 46b570352a
feat(shared): add payment ladder and CSV domain package (AP-13) (#11)
* feat(shared): add payment ladder and CSV domain package

Introduce @seahaven-ap/shared via npm workspaces so FE and future API share
payments-dashboard-compatible status ladders, pay-date helpers, and CSV headers.

* fix(web): build shared package before vite dev

Ensure fresh npm ci && npm run dev (and Playwright webServer) can resolve
@seahaven-ap/shared dist exports that are gitignored.
2026-08-10 19:28:02 -04:00

106 lines
3.1 KiB
TypeScript

/**
* 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<Record<PaymentStatus, number>> = {
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<Record<PaymentStatus, PaymentDisplayStatus>> = {
scheduled: "Scheduled",
payment_submitted: "Payment Submitted",
issued: "Issued",
outstanding: "Outstanding",
cleared: "Cleared",
};
const DISPLAY_TO_INTERNAL: Readonly<Record<string, PaymentStatus>> = 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;
}