mirror of
https://github.com/Sea-Haven-Industries/seahaven-ap.git
synced 2026-09-30 05:43:18 +00:00
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.
This commit is contained in:
parent
20d6f56f7e
commit
46b570352a
20 changed files with 549 additions and 8 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -3,6 +3,7 @@ node_modules/
|
|||
|
||||
# Build
|
||||
dist/
|
||||
packages/*/dist/
|
||||
build/
|
||||
coverage/
|
||||
test-results/
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
dist
|
||||
packages/*/dist
|
||||
build
|
||||
node_modules
|
||||
coverage
|
||||
|
|
|
|||
|
|
@ -2,6 +2,14 @@
|
|||
|
||||
Internal accounts payable automation for Sea Haven Industries (`ap.seahaven.com`).
|
||||
|
||||
## Workspace layout
|
||||
|
||||
npm workspaces:
|
||||
|
||||
- `@seahaven-ap/web` — Vite/React SPA (repo root)
|
||||
- `@seahaven-ap/shared` — payment ladder, invoice helpers, pay-date parsers, CSV constants ([`packages/shared`](packages/shared))
|
||||
- `@seahaven-ap/api` — reserved for AP-14 (not present yet)
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -7,11 +7,33 @@ import tseslint from "typescript-eslint";
|
|||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ["dist/**", "build/**", "node_modules/**", "coverage/**", "src/router.ts"],
|
||||
ignores: [
|
||||
"dist/**",
|
||||
"packages/*/dist/**",
|
||||
"build/**",
|
||||
"node_modules/**",
|
||||
"coverage/**",
|
||||
"src/router.ts",
|
||||
],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
eslintConfigPrettier,
|
||||
{
|
||||
files: ["packages/shared/src/**/*.ts"],
|
||||
languageOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
globals: globals.node,
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/**/*.{ts,tsx}", "vite.config.ts", "vitest.config.ts", "playwright.config.ts"],
|
||||
languageOptions: {
|
||||
|
|
|
|||
23
package-lock.json
generated
23
package-lock.json
generated
|
|
@ -1,12 +1,15 @@
|
|||
{
|
||||
"name": "seahaven-ap",
|
||||
"name": "@seahaven-ap/web",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "seahaven-ap",
|
||||
"name": "@seahaven-ap/web",
|
||||
"version": "0.1.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
|
|
@ -16,6 +19,7 @@
|
|||
"@mui/icons-material": "^7.3.2",
|
||||
"@mui/material": "^7.3.2",
|
||||
"@mui/x-data-grid": "^8.11.0",
|
||||
"@seahaven-ap/shared": "workspace:*",
|
||||
"@tanstack/react-query": "^5.85.5",
|
||||
"@tanstack/react-query-devtools": "^5.85.5",
|
||||
"ky": "^1.10.0",
|
||||
|
|
@ -2080,6 +2084,10 @@
|
|||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@seahaven-ap/shared": {
|
||||
"resolved": "packages/shared",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.101.4",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz",
|
||||
|
|
@ -5570,6 +5578,17 @@
|
|||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"packages/shared": {
|
||||
"name": "@seahaven-ap/shared",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.2",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.22.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
package.json
13
package.json
|
|
@ -1,14 +1,18 @@
|
|||
{
|
||||
"name": "seahaven-ap",
|
||||
"name": "@seahaven-ap/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev": "npm run build:shared && vite",
|
||||
"generate:router": "node scripts/generate-router.mjs",
|
||||
"build": "npm run generate:router && tsc -b && vite build",
|
||||
"build:shared": "npm run build -w @seahaven-ap/shared",
|
||||
"build": "npm run build:shared && npm run generate:router && tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test": "npm run test -w @seahaven-ap/shared && vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"lint": "eslint . --max-warnings=0",
|
||||
|
|
@ -30,6 +34,7 @@
|
|||
"@mui/icons-material": "^7.3.2",
|
||||
"@mui/material": "^7.3.2",
|
||||
"@mui/x-data-grid": "^8.11.0",
|
||||
"@seahaven-ap/shared": "workspace:*",
|
||||
"@tanstack/react-query": "^5.85.5",
|
||||
"@tanstack/react-query-devtools": "^5.85.5",
|
||||
"ky": "^1.10.0",
|
||||
|
|
|
|||
31
packages/shared/package.json
Normal file
31
packages/shared/package.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"name": "@seahaven-ap/shared",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Shared AP domain: payment ladder, invoice helpers, pay-date parsers, CSV constants",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc --project tsconfig.build.json",
|
||||
"typecheck": "tsc --noEmit --project tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.2",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.22.1"
|
||||
}
|
||||
}
|
||||
25
packages/shared/src/csv.test.ts
Normal file
25
packages/shared/src/csv.test.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { PAYMENT_CSV_HEADER, PAYMENT_CSV_HEADERS, PAYMENT_CSV_METHODS } from "./csv.js";
|
||||
|
||||
describe("payment CSV constants", () => {
|
||||
it("freezes the consumer header contract and order", () => {
|
||||
expect([...PAYMENT_CSV_HEADERS]).toEqual([
|
||||
"Check Number",
|
||||
"Method",
|
||||
"Status",
|
||||
"Send Payment On",
|
||||
"Amount in USD",
|
||||
"Amount",
|
||||
"Payee",
|
||||
"Invoice Numbers",
|
||||
"Company/Subsidiary",
|
||||
]);
|
||||
expect(PAYMENT_CSV_HEADER.checkNumber).toBe("Check Number");
|
||||
expect(PAYMENT_CSV_HEADER.companySubsidiary).toBe("Company/Subsidiary");
|
||||
});
|
||||
|
||||
it("exports Check and ACH method display values", () => {
|
||||
expect(PAYMENT_CSV_METHODS.check).toBe("Check");
|
||||
expect(PAYMENT_CSV_METHODS.ach).toBe("ACH");
|
||||
});
|
||||
});
|
||||
41
packages/shared/src/csv.ts
Normal file
41
packages/shared/src/csv.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* Fixed CSV header contract for payments-dashboard ProcessPaymentCsv compatibility.
|
||||
* Column names and export order must not drift.
|
||||
*/
|
||||
|
||||
export const PAYMENT_CSV_HEADER = {
|
||||
checkNumber: "Check Number",
|
||||
method: "Method",
|
||||
status: "Status",
|
||||
sendPaymentOn: "Send Payment On",
|
||||
amountInUsd: "Amount in USD",
|
||||
amount: "Amount",
|
||||
payee: "Payee",
|
||||
invoiceNumbers: "Invoice Numbers",
|
||||
companySubsidiary: "Company/Subsidiary",
|
||||
} as const;
|
||||
|
||||
export type PaymentCsvHeaderKey = keyof typeof PAYMENT_CSV_HEADER;
|
||||
|
||||
/** Ordered headers for export / golden-file alignment (AP-17). */
|
||||
export const PAYMENT_CSV_HEADERS = [
|
||||
PAYMENT_CSV_HEADER.checkNumber,
|
||||
PAYMENT_CSV_HEADER.method,
|
||||
PAYMENT_CSV_HEADER.status,
|
||||
PAYMENT_CSV_HEADER.sendPaymentOn,
|
||||
PAYMENT_CSV_HEADER.amountInUsd,
|
||||
PAYMENT_CSV_HEADER.amount,
|
||||
PAYMENT_CSV_HEADER.payee,
|
||||
PAYMENT_CSV_HEADER.invoiceNumbers,
|
||||
PAYMENT_CSV_HEADER.companySubsidiary,
|
||||
] as const;
|
||||
|
||||
export type PaymentCsvHeader = (typeof PAYMENT_CSV_HEADERS)[number];
|
||||
|
||||
/** Method display values the consumer branches on (Check vs ACH). */
|
||||
export const PAYMENT_CSV_METHODS = {
|
||||
check: "Check",
|
||||
ach: "ACH",
|
||||
} as const;
|
||||
|
||||
export type PaymentCsvMethod = (typeof PAYMENT_CSV_METHODS)[keyof typeof PAYMENT_CSV_METHODS];
|
||||
42
packages/shared/src/index.ts
Normal file
42
packages/shared/src/index.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
export {
|
||||
CANCEL_STATUSES,
|
||||
PAYMENT_DISPLAY_STATUSES,
|
||||
PAYMENT_STATUSES,
|
||||
PAYMENT_STATUS_RANK,
|
||||
canTransitionPaymentStatus,
|
||||
isCancelStatus,
|
||||
isPaymentStatus,
|
||||
parsePaymentStatus,
|
||||
paymentStatusRank,
|
||||
toPaymentDisplayStatus,
|
||||
type CancelStatus,
|
||||
type PaymentDisplayStatus,
|
||||
type PaymentStatus,
|
||||
} from "./payment-status.js";
|
||||
|
||||
export {
|
||||
INVOICE_STATUSES,
|
||||
READY_TO_PAY_STATUSES,
|
||||
isInvoiceStatus,
|
||||
isReadyToPay,
|
||||
statusAfterPayRun,
|
||||
statusAfterPaymentCleared,
|
||||
type InvoiceStatus,
|
||||
} from "./invoice-status.js";
|
||||
|
||||
export {
|
||||
isPlausibleSendYear,
|
||||
parseMDY,
|
||||
toCanonicalMDY,
|
||||
toISODate,
|
||||
type ParsedMDY,
|
||||
} from "./pay-date.js";
|
||||
|
||||
export {
|
||||
PAYMENT_CSV_HEADER,
|
||||
PAYMENT_CSV_HEADERS,
|
||||
PAYMENT_CSV_METHODS,
|
||||
type PaymentCsvHeader,
|
||||
type PaymentCsvHeaderKey,
|
||||
type PaymentCsvMethod,
|
||||
} from "./csv.js";
|
||||
18
packages/shared/src/invoice-status.test.ts
Normal file
18
packages/shared/src/invoice-status.test.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { isReadyToPay, statusAfterPayRun, statusAfterPaymentCleared } from "./invoice-status.js";
|
||||
|
||||
describe("invoice status helpers", () => {
|
||||
it("treats approved as Ready-to-Pay", () => {
|
||||
expect(isReadyToPay("approved")).toBe(true);
|
||||
expect(isReadyToPay("pending_approval")).toBe(false);
|
||||
expect(isReadyToPay("scheduled")).toBe(false);
|
||||
expect(isReadyToPay("paid")).toBe(false);
|
||||
expect(isReadyToPay("rejected")).toBe(false);
|
||||
expect(isReadyToPay("void")).toBe(false);
|
||||
});
|
||||
|
||||
it("exposes post pay-run and cleared invoice statuses", () => {
|
||||
expect(statusAfterPayRun()).toBe("scheduled");
|
||||
expect(statusAfterPaymentCleared()).toBe("paid");
|
||||
});
|
||||
});
|
||||
36
packages/shared/src/invoice-status.ts
Normal file
36
packages/shared/src/invoice-status.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* Invoice status helpers for Ready-to-Pay and post pay-run alignment.
|
||||
* Thin surface for AP-17; full pay-run logic stays in the API.
|
||||
*/
|
||||
|
||||
export const INVOICE_STATUSES = [
|
||||
"pending_approval",
|
||||
"approved",
|
||||
"scheduled",
|
||||
"paid",
|
||||
"rejected",
|
||||
"void",
|
||||
] as const;
|
||||
|
||||
export type InvoiceStatus = (typeof INVOICE_STATUSES)[number];
|
||||
|
||||
/** Invoices eligible for Select to Pay / pay-run selection. */
|
||||
export const READY_TO_PAY_STATUSES = ["approved"] as const satisfies readonly InvoiceStatus[];
|
||||
|
||||
export function isInvoiceStatus(value: string): value is InvoiceStatus {
|
||||
return (INVOICE_STATUSES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function isReadyToPay(status: InvoiceStatus | string): boolean {
|
||||
return (READY_TO_PAY_STATUSES as readonly string[]).includes(status);
|
||||
}
|
||||
|
||||
/** Status after a successful pay run groups the invoice into the payments queue. */
|
||||
export function statusAfterPayRun(): InvoiceStatus {
|
||||
return "scheduled";
|
||||
}
|
||||
|
||||
/** Terminal paid status once the payment ladder reaches cleared (AP-17 wiring). */
|
||||
export function statusAfterPaymentCleared(): InvoiceStatus {
|
||||
return "paid";
|
||||
}
|
||||
30
packages/shared/src/pay-date.test.ts
Normal file
30
packages/shared/src/pay-date.test.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { isPlausibleSendYear, parseMDY, toCanonicalMDY, toISODate } from "./pay-date.js";
|
||||
|
||||
describe("pay-date helpers", () => {
|
||||
it("parses M/D/YY and MM/DD/YYYY", () => {
|
||||
expect(parseMDY("7/4/26")).toEqual({ year: 2026, month: 7, day: 4 });
|
||||
expect(parseMDY("07/04/2026")).toEqual({ year: 2026, month: 7, day: 4 });
|
||||
});
|
||||
|
||||
it("rejects invalid calendar dates and garbage", () => {
|
||||
expect(parseMDY("2/30/2026")).toBeNull();
|
||||
expect(parseMDY("2026-07-04")).toBeNull();
|
||||
expect(parseMDY("")).toBeNull();
|
||||
expect(parseMDY(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("formats ISO and canonical MDY", () => {
|
||||
expect(toISODate("7/4/26")).toBe("2026-07-04");
|
||||
expect(toCanonicalMDY("7/4/26")).toBe("07/04/2026");
|
||||
expect(toISODate("bad")).toBeNull();
|
||||
});
|
||||
|
||||
it("enforces the plausibility window relative to nowYear", () => {
|
||||
expect(isPlausibleSendYear("01/01/2019", 2026)).toBe(true);
|
||||
expect(isPlausibleSendYear("01/01/2018", 2026)).toBe(false);
|
||||
expect(isPlausibleSendYear("01/01/2028", 2026)).toBe(true);
|
||||
expect(isPlausibleSendYear("01/01/2029", 2026)).toBe(false);
|
||||
expect(isPlausibleSendYear("not-a-date", 2026)).toBe(false);
|
||||
});
|
||||
});
|
||||
53
packages/shared/src/pay-date.ts
Normal file
53
packages/shared/src/pay-date.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/**
|
||||
* Shared date parsing for Stampli M/D/YY and MM/DD/YYYY formats.
|
||||
* Ported from payments-dashboard/src/dates.js — both formats must parse;
|
||||
* anything else returns null (never pass through garbage).
|
||||
*/
|
||||
|
||||
export type ParsedMDY = {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
};
|
||||
|
||||
/** Parse "M/D/YY" or "MM/DD/YYYY" into { year, month, day }, or null. */
|
||||
export function parseMDY(value: string | null | undefined): ParsedMDY | null {
|
||||
const match = /^(\d{1,2})\/(\d{1,2})\/(\d{2}|\d{4})$/.exec(String(value ?? "").trim());
|
||||
if (!match) return null;
|
||||
const month = Number.parseInt(match[1]!, 10);
|
||||
const day = Number.parseInt(match[2]!, 10);
|
||||
let year = Number.parseInt(match[3]!, 10);
|
||||
if (match[3]!.length === 2) year += 2000;
|
||||
const dt = new Date(year, month - 1, day);
|
||||
if (dt.getFullYear() !== year || dt.getMonth() !== month - 1 || dt.getDate() !== day) {
|
||||
return null;
|
||||
}
|
||||
return { year, month, day };
|
||||
}
|
||||
|
||||
/** "YYYY-MM-DD" or null. */
|
||||
export function toISODate(value: string | null | undefined): string | null {
|
||||
const p = parseMDY(value);
|
||||
if (!p) return null;
|
||||
return `${p.year}-${String(p.month).padStart(2, "0")}-${String(p.day).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** Canonical "MM/DD/YYYY" or null. Stored form for send_payment_on / CSV. */
|
||||
export function toCanonicalMDY(value: string | null | undefined): string | null {
|
||||
const p = parseMDY(value);
|
||||
if (!p) return null;
|
||||
return `${String(p.month).padStart(2, "0")}/${String(p.day).padStart(2, "0")}/${p.year}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plausibility window for ingested send dates.
|
||||
* Relative to the current year (7 years back, 2 years forward).
|
||||
*/
|
||||
export function isPlausibleSendYear(
|
||||
value: string | null | undefined,
|
||||
nowYear: number = new Date().getFullYear(),
|
||||
): boolean {
|
||||
const p = parseMDY(value);
|
||||
if (p === null) return false;
|
||||
return p.year >= nowYear - 7 && p.year <= nowYear + 2;
|
||||
}
|
||||
66
packages/shared/src/payment-status.test.ts
Normal file
66
packages/shared/src/payment-status.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CANCEL_STATUSES,
|
||||
PAYMENT_STATUS_RANK,
|
||||
canTransitionPaymentStatus,
|
||||
isCancelStatus,
|
||||
parsePaymentStatus,
|
||||
toPaymentDisplayStatus,
|
||||
} from "./payment-status.js";
|
||||
|
||||
describe("payment status ladder", () => {
|
||||
it("ranks the ladder in consumer order", () => {
|
||||
expect(PAYMENT_STATUS_RANK.scheduled).toBe(1);
|
||||
expect(PAYMENT_STATUS_RANK.payment_submitted).toBe(2);
|
||||
expect(PAYMENT_STATUS_RANK.issued).toBe(3);
|
||||
expect(PAYMENT_STATUS_RANK.outstanding).toBe(4);
|
||||
expect(PAYMENT_STATUS_RANK.cleared).toBe(5);
|
||||
});
|
||||
|
||||
it("maps internal statuses to CSV display strings", () => {
|
||||
expect(toPaymentDisplayStatus("payment_submitted")).toBe("Payment Submitted");
|
||||
expect(toPaymentDisplayStatus("cleared")).toBe("Cleared");
|
||||
});
|
||||
|
||||
it("parses display and internal status strings", () => {
|
||||
expect(parsePaymentStatus("Payment Submitted")).toBe("payment_submitted");
|
||||
expect(parsePaymentStatus("OUTSTANDING")).toBe("outstanding");
|
||||
expect(parsePaymentStatus("scheduled")).toBe("scheduled");
|
||||
expect(parsePaymentStatus("voided")).toBeNull();
|
||||
});
|
||||
|
||||
it("allows forward transitions and same-rank no-ops", () => {
|
||||
expect(canTransitionPaymentStatus("scheduled", "payment_submitted")).toBe(true);
|
||||
expect(canTransitionPaymentStatus("issued", "Outstanding")).toBe(true);
|
||||
expect(canTransitionPaymentStatus("outstanding", "outstanding")).toBe(true);
|
||||
expect(canTransitionPaymentStatus("outstanding", "cleared")).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks non-cancel rank regression", () => {
|
||||
expect(canTransitionPaymentStatus("issued", "scheduled")).toBe(false);
|
||||
expect(canTransitionPaymentStatus("cleared", "outstanding")).toBe(false);
|
||||
expect(canTransitionPaymentStatus("payment_submitted", "Scheduled")).toBe(false);
|
||||
});
|
||||
|
||||
it("allows void from ranks below cleared", () => {
|
||||
for (const status of ["scheduled", "payment_submitted", "issued", "outstanding"] as const) {
|
||||
expect(canTransitionPaymentStatus(status, "voided")).toBe(true);
|
||||
expect(canTransitionPaymentStatus(status, "Cancelled")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks all transitions once cleared, including void", () => {
|
||||
expect(canTransitionPaymentStatus("cleared", "voided")).toBe(false);
|
||||
expect(canTransitionPaymentStatus("cleared", "cleared")).toBe(false);
|
||||
expect(canTransitionPaymentStatus("cleared", "outstanding")).toBe(false);
|
||||
});
|
||||
|
||||
it("recognizes cancel vocabulary case-insensitively", () => {
|
||||
for (const status of CANCEL_STATUSES) {
|
||||
expect(isCancelStatus(status)).toBe(true);
|
||||
expect(isCancelStatus(status.toUpperCase())).toBe(true);
|
||||
}
|
||||
expect(isCancelStatus("Marked As Void")).toBe(true);
|
||||
expect(isCancelStatus("Cleared")).toBe(false);
|
||||
});
|
||||
});
|
||||
106
packages/shared/src/payment-status.ts
Normal file
106
packages/shared/src/payment-status.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* 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;
|
||||
}
|
||||
7
packages/shared/tsconfig.build.json
Normal file
7
packages/shared/tsconfig.build.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false
|
||||
},
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
21
packages/shared/tsconfig.json
Normal file
21
packages/shared/tsconfig.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"noEmitOnError": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
8
packages/shared/vitest.config.ts
Normal file
8
packages/shared/vitest.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
Typography,
|
||||
} from "@mui/material";
|
||||
import type { GridRowParams, GridRowSelectionModel } from "@mui/x-data-grid";
|
||||
import { isReadyToPay } from "@seahaven-ap/shared";
|
||||
import { CreatePayRunButton } from "@/dashboard/CreatePayRunButton";
|
||||
import { InvoiceDataGrid } from "@/dashboard/InvoiceDataGrid";
|
||||
import { pendingApprovalColumns, readyToPayColumns } from "@/dashboard/columns";
|
||||
|
|
@ -46,7 +47,7 @@ export function PaymentProcessingGrids() {
|
|||
};
|
||||
|
||||
const selectRows = useMemo(
|
||||
() => invoices.filter((invoice) => invoice.status === "approved"),
|
||||
() => invoices.filter((invoice) => isReadyToPay(invoice.status)),
|
||||
[invoices],
|
||||
);
|
||||
const pendingRows = useMemo(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue