seahaven-ap/src/mocks/invoices.ts

708 lines
18 KiB
TypeScript

import { syncMockApprovalsForInvoice } from "@/mocks/approvals";
import { areMocksEnabled } from "@/mocks/env";
export type MockInvoiceStatus = "pending_approval" | "approved" | "scheduled" | "paid" | "rejected";
export type MockInvoiceLine = {
id: string;
description: string;
amount: number;
glAccountId: string;
departmentId: string;
};
export type MockInvoiceComment = {
id: string;
authorUserId: string;
authorName: string;
body: string;
createdAt: string;
};
export type MockInvoiceActivity = {
id: string;
actorUserId: string;
actorName: string;
message: string;
createdAt: string;
};
export type MockInvoice = {
id: string;
invoiceNumber: string;
invoiceNumbers: string;
vendorId: string;
vendorName: string;
amountDue: number;
amount: number;
dueDate: string;
payDate: string | null;
sendPaymentOn: string | null;
status: MockInvoiceStatus;
paymentMethod: "check" | "ach";
documentUrl: string;
memo: string;
lines: MockInvoiceLine[];
comments: MockInvoiceComment[];
activity: MockInvoiceActivity[];
hasBolt: boolean;
hasBell: boolean;
hasEye: boolean;
actionLabel: string;
lastUpdate: string;
lastUpdateMessage: string;
lastUpdateActorName: string;
lastUpdateAt: string;
invoiceDate: string;
processingBegan: string;
tagging: string;
fs: string;
description: string;
invoiceType: string;
location: string;
paymentAccount: string;
relatedInvoice: string | null;
rowCount: number;
};
export type MockInvoiceHeaderPatch = Partial<
Pick<
MockInvoice,
| "vendorName"
| "amountDue"
| "amount"
| "dueDate"
| "payDate"
| "sendPaymentOn"
| "paymentMethod"
| "memo"
>
>;
const SAMPLE_DOCUMENT_URL = "/fixtures/sample-invoice.pdf";
type MockInvoiceCore = Omit<
MockInvoice,
| "hasBolt"
| "hasBell"
| "hasEye"
| "actionLabel"
| "lastUpdate"
| "lastUpdateMessage"
| "lastUpdateActorName"
| "lastUpdateAt"
| "invoiceDate"
| "processingBegan"
| "tagging"
| "fs"
| "description"
| "invoiceType"
| "location"
| "paymentAccount"
| "relatedInvoice"
| "rowCount"
>;
const ACTION_LABEL_BY_STATUS: Record<MockInvoiceStatus, string> = {
pending_approval: "Approve invoice",
approved: "Approved",
scheduled: "Scheduled",
paid: "Paid",
rejected: "Rejected",
};
function actionLabelForStatus(status: MockInvoiceStatus): string {
return ACTION_LABEL_BY_STATUS[status];
}
function derivedActivityFields(invoice: Pick<MockInvoice, "activity" | "comments" | "dueDate">) {
const events = [
...invoice.activity.map((entry) => ({
at: entry.createdAt,
message: entry.message,
actor: entry.actorName,
})),
...invoice.comments.map((comment) => ({
at: comment.createdAt,
message: comment.body,
actor: comment.authorName,
})),
];
const latest = events.reduce<(typeof events)[number] | undefined>((current, event) => {
if (!current || event.at > current.at) {
return event;
}
return current;
}, undefined);
const lastUpdateAt = latest?.at ?? `${invoice.dueDate}T12:00:00.000Z`;
return {
lastUpdate: lastUpdateAt.slice(0, 10),
lastUpdateMessage: latest?.message ?? "Invoice updated",
lastUpdateActorName: latest?.actor ?? "System",
lastUpdateAt,
};
}
function paymentAccountFor(paymentMethod: MockInvoice["paymentMethod"]): string {
return paymentMethod === "ach" ? "Operating ACH" : "Operating Check";
}
/** Invoice date is independent of the due date. Offset varies per seed. */
function invoiceDateBeforeDue(dueDate: string, index: number): string {
const [year, month, day] = dueDate.split("-").map(Number);
const date = new Date(Date.UTC(year, month - 1, day));
date.setUTCDate(date.getUTCDate() - (14 + index));
return date.toISOString().slice(0, 10);
}
/** Keep catalog-derived fields in sync after mock mutations. */
function withRefreshedCatalogFields(invoice: MockInvoice): MockInvoice {
return {
...invoice,
actionLabel: actionLabelForStatus(invoice.status),
...derivedActivityFields(invoice),
description: invoice.memo,
paymentAccount: paymentAccountFor(invoice.paymentMethod),
rowCount: invoice.lines.length,
};
}
function enrichInvoice(core: MockInvoiceCore, index: number): MockInvoice {
return withRefreshedCatalogFields(
{
...core,
hasBolt: index % 3 === 0,
hasBell: index % 2 === 0,
hasEye: true,
actionLabel: actionLabelForStatus(core.status),
lastUpdate: "",
lastUpdateMessage: "",
lastUpdateActorName: "",
lastUpdateAt: "",
invoiceDate: invoiceDateBeforeDue(core.dueDate, index),
processingBegan: core.activity[0]?.createdAt?.slice(0, 10) ?? core.dueDate,
tagging: index % 2 === 0 ? "Ops" : "",
fs: index % 2 === 0 ? "synced" : "",
description: core.memo,
invoiceType: "Standard",
location: "Ronkonkoma",
paymentAccount: paymentAccountFor(core.paymentMethod),
relatedInvoice: null,
rowCount: core.lines.length,
},
);
}
function cloneInvoice(invoice: MockInvoice): MockInvoice {
return {
...invoice,
lines: invoice.lines.map((line) => ({ ...line })),
comments: invoice.comments.map((comment) => ({ ...comment })),
activity: invoice.activity.map((entry) => ({ ...entry })),
};
}
const seedInvoiceCores: MockInvoiceCore[] = [
{
id: "inv-1",
invoiceNumber: "INV-1001",
invoiceNumbers: "INV-1001",
vendorId: "vendor-1",
vendorName: "Acme Supplies Co",
amountDue: 1250.5,
amount: 1250.5,
dueDate: "2026-08-20",
payDate: "2026-08-15",
sendPaymentOn: "2026-08-15",
status: "approved",
paymentMethod: "check",
documentUrl: SAMPLE_DOCUMENT_URL,
memo: "Office supplies restock",
lines: [
{
id: "inv-1-line-1",
description: "Printer paper (case)",
amount: 450.5,
glAccountId: "gl-1",
departmentId: "dept-4",
},
{
id: "inv-1-line-2",
description: "Toner cartridges",
amount: 800,
glAccountId: "gl-1",
departmentId: "dept-4",
},
],
comments: [
{
id: "inv-1-c1",
authorUserId: "user-1",
authorName: "Alex Rivera",
body: "Coded to Administration office supplies.",
createdAt: "2026-08-05T14:20:00.000Z",
},
],
activity: [
{
id: "inv-1-a1",
actorUserId: "user-1",
actorName: "Alex Rivera",
message: "Submitted for approval",
createdAt: "2026-08-05T15:00:00.000Z",
},
{
id: "inv-1-a2",
actorUserId: "user-2",
actorName: "Jordan Lee",
message: "Approved",
createdAt: "2026-08-06T10:12:00.000Z",
},
],
},
{
id: "inv-2",
invoiceNumber: "INV-1002",
invoiceNumbers: "INV-1002",
vendorId: "vendor-2",
vendorName: "Harbor Maintenance LLC",
amountDue: 4800,
amount: 4800,
dueDate: "2026-08-25",
payDate: null,
sendPaymentOn: "2026-08-20",
status: "pending_approval",
paymentMethod: "ach",
documentUrl: SAMPLE_DOCUMENT_URL,
memo: "Dock repair materials",
lines: [
{
id: "inv-2-line-1",
description: "Dock board replacement",
amount: 3200,
glAccountId: "gl-2",
departmentId: "dept-1",
},
{
id: "inv-2-line-2",
description: "Labor",
amount: 1600,
glAccountId: "gl-4",
departmentId: "dept-1",
},
],
comments: [
{
id: "inv-2-c1",
authorUserId: "user-1",
authorName: "Alex Rivera",
body: "Please confirm GL split before approve.",
createdAt: "2026-08-08T11:05:00.000Z",
},
],
activity: [
{
id: "inv-2-a1",
actorUserId: "user-1",
actorName: "Alex Rivera",
message: "Submitted for approval",
createdAt: "2026-08-08T11:10:00.000Z",
},
],
},
{
id: "inv-3",
invoiceNumber: "INV-1003",
invoiceNumbers: "INV-1003",
vendorId: "vendor-3",
vendorName: "Northwind Utilities",
amountDue: 312.75,
amount: 312.75,
dueDate: "2026-08-12",
payDate: "2026-08-10",
sendPaymentOn: "2026-08-10",
status: "approved",
paymentMethod: "check",
documentUrl: SAMPLE_DOCUMENT_URL,
memo: "July utilities",
lines: [
{
id: "inv-3-line-1",
description: "Electric service",
amount: 312.75,
glAccountId: "gl-3",
departmentId: "dept-1",
},
],
comments: [],
activity: [
{
id: "inv-3-a1",
actorUserId: "user-1",
actorName: "Alex Rivera",
message: "Submitted for approval",
createdAt: "2026-08-01T09:00:00.000Z",
},
{
id: "inv-3-a2",
actorUserId: "user-3",
actorName: "Sam Patel",
message: "Approved",
createdAt: "2026-08-02T16:40:00.000Z",
},
],
},
{
id: "inv-4",
invoiceNumber: "INV-1004",
invoiceNumbers: "INV-1004",
vendorId: "vendor-4",
vendorName: "Coastal Office Supply",
amountDue: 890.25,
amount: 900,
dueDate: "2026-09-01",
payDate: null,
sendPaymentOn: "2026-08-27",
status: "pending_approval",
paymentMethod: "ach",
documentUrl: SAMPLE_DOCUMENT_URL,
memo: "Desk chairs",
lines: [
{
id: "inv-4-line-1",
description: "Ergonomic chairs (3)",
amount: 900,
glAccountId: "gl-1",
departmentId: "dept-3",
},
],
comments: [],
activity: [
{
id: "inv-4-a1",
actorUserId: "user-1",
actorName: "Alex Rivera",
message: "Submitted for approval",
createdAt: "2026-08-09T13:22:00.000Z",
},
],
},
{
id: "inv-5",
invoiceNumber: "INV-1005",
invoiceNumbers: "INV-1005",
vendorId: "vendor-1",
vendorName: "Acme Supplies Co",
amountDue: 2100,
amount: 2100,
dueDate: "2026-07-30",
payDate: "2026-07-25",
sendPaymentOn: "2026-07-25",
status: "scheduled",
paymentMethod: "check",
documentUrl: SAMPLE_DOCUMENT_URL,
memo: "Quarterly stock",
lines: [
{
id: "inv-5-line-1",
description: "Bulk supplies",
amount: 2100,
glAccountId: "gl-1",
departmentId: "dept-2",
},
],
comments: [],
activity: [
{
id: "inv-5-a1",
actorUserId: "user-2",
actorName: "Jordan Lee",
message: "Approved",
createdAt: "2026-07-20T12:00:00.000Z",
},
{
id: "inv-5-a2",
actorUserId: "user-1",
actorName: "Alex Rivera",
message: "Scheduled for payment",
createdAt: "2026-07-22T09:30:00.000Z",
},
],
},
{
id: "inv-6",
invoiceNumber: "INV-1006",
invoiceNumbers: "INV-1006",
vendorId: "vendor-2",
vendorName: "Harbor Maintenance LLC",
amountDue: 0,
amount: 1550,
dueDate: "2026-07-15",
payDate: "2026-07-10",
sendPaymentOn: "2026-07-10",
status: "paid",
paymentMethod: "ach",
documentUrl: SAMPLE_DOCUMENT_URL,
memo: "Emergency pump repair",
lines: [
{
id: "inv-6-line-1",
description: "Pump repair",
amount: 1550,
glAccountId: "gl-2",
departmentId: "dept-1",
},
],
comments: [],
activity: [
{
id: "inv-6-a1",
actorUserId: "user-1",
actorName: "Alex Rivera",
message: "Marked paid",
createdAt: "2026-07-11T08:00:00.000Z",
},
],
},
{
id: "inv-7",
invoiceNumber: "INV-1007",
invoiceNumbers: "INV-1007",
vendorId: "vendor-3",
vendorName: "Northwind Utilities",
amountDue: 645.1,
amount: 645.1,
dueDate: "2026-08-28",
payDate: "2026-08-22",
sendPaymentOn: "2026-08-22",
status: "approved",
paymentMethod: "ach",
documentUrl: SAMPLE_DOCUMENT_URL,
memo: "August utilities",
lines: [
{
id: "inv-7-line-1",
description: "Water and sewer",
amount: 645.1,
glAccountId: "gl-3",
departmentId: "dept-1",
},
],
comments: [],
activity: [
{
id: "inv-7-a1",
actorUserId: "user-3",
actorName: "Sam Patel",
message: "Approved",
createdAt: "2026-08-18T17:05:00.000Z",
},
],
},
{
id: "inv-8",
invoiceNumber: "INV-1008",
invoiceNumbers: "INV-1008",
vendorId: "vendor-1",
vendorName: "Acme Supplies Co",
amountDue: 275.5,
amount: 275.5,
dueDate: "2026-08-30",
payDate: null,
sendPaymentOn: "2026-08-28",
status: "pending_approval",
paymentMethod: "check",
documentUrl: SAMPLE_DOCUMENT_URL,
memo: "Misc small order",
lines: [
{
id: "inv-8-line-1",
description: "Labels and binders",
amount: 275.5,
glAccountId: "gl-1",
departmentId: "dept-2",
},
],
comments: [
{
id: "inv-8-c1",
authorUserId: "user-1",
authorName: "Alex Rivera",
body: "Under threshold policy.",
createdAt: "2026-08-07T10:00:00.000Z",
},
],
activity: [
{
id: "inv-8-a1",
actorUserId: "user-1",
actorName: "Alex Rivera",
message: "Submitted for approval",
createdAt: "2026-08-07T10:05:00.000Z",
},
],
},
];
const seedInvoices: MockInvoice[] = seedInvoiceCores.map((core, index) =>
enrichInvoice(core, index),
);
/** Mutable in-memory invoice store for mock POC interactions. */
let invoiceStore: MockInvoice[] = seedInvoices.map((invoice) => cloneInvoice(invoice));
function replaceInvoice(index: number, next: MockInvoice): MockInvoice {
invoiceStore = [...invoiceStore.slice(0, index), next, ...invoiceStore.slice(index + 1)];
return cloneInvoice(next);
}
export function getMockInvoices(): MockInvoice[] {
if (!areMocksEnabled()) {
return [];
}
return invoiceStore.map((invoice) => cloneInvoice(invoice));
}
export function getMockInvoice(id: string): MockInvoice | undefined {
if (!areMocksEnabled()) {
return undefined;
}
const found = invoiceStore.find((invoice) => invoice.id === id);
return found ? cloneInvoice(found) : undefined;
}
export function updateMockInvoicePayDate(id: string, payDate: string | null): MockInvoice {
const index = invoiceStore.findIndex((invoice) => invoice.id === id);
if (index === -1) {
throw new Error(`Mock invoice not found: ${id}`);
}
const current = invoiceStore[index];
return replaceInvoice(
index,
withRefreshedCatalogFields(
{
...current,
payDate,
activity: [
...current.activity,
{
id: `activity-pay-date-${Date.now()}`,
actorUserId: "user-1",
actorName: "System",
message: "Pay date updated",
createdAt: new Date().toISOString(),
},
],
},
),
);
}
export function updateMockInvoiceHeader(id: string, patch: MockInvoiceHeaderPatch): MockInvoice {
const index = invoiceStore.findIndex((invoice) => invoice.id === id);
if (index === -1) {
throw new Error(`Mock invoice not found: ${id}`);
}
return replaceInvoice(
index,
withRefreshedCatalogFields({ ...invoiceStore[index], ...patch }),
);
}
export function updateMockInvoiceLines(id: string, lines: MockInvoiceLine[]): MockInvoice {
const index = invoiceStore.findIndex((invoice) => invoice.id === id);
if (index === -1) {
throw new Error(`Mock invoice not found: ${id}`);
}
return replaceInvoice(
index,
withRefreshedCatalogFields(
{
...invoiceStore[index],
lines: lines.map((line) => ({ ...line })),
},
),
);
}
export function addMockInvoiceComment(
id: string,
comment: Omit<MockInvoiceComment, "id" | "createdAt"> & {
id?: string;
createdAt?: string;
},
): MockInvoice {
const index = invoiceStore.findIndex((invoice) => invoice.id === id);
if (index === -1) {
throw new Error(`Mock invoice not found: ${id}`);
}
const current = invoiceStore[index];
const nextComment: MockInvoiceComment = {
id: comment.id ?? `comment-${Date.now()}`,
authorUserId: comment.authorUserId,
authorName: comment.authorName,
body: comment.body,
createdAt: comment.createdAt ?? new Date().toISOString(),
};
return replaceInvoice(
index,
withRefreshedCatalogFields(
{
...current,
comments: [...current.comments, nextComment],
},
),
);
}
export function transitionMockInvoiceStatus(
id: string,
status: MockInvoiceStatus,
activity: Omit<MockInvoiceActivity, "id" | "createdAt"> & {
id?: string;
createdAt?: string;
},
): MockInvoice {
const index = invoiceStore.findIndex((invoice) => invoice.id === id);
if (index === -1) {
throw new Error(`Mock invoice not found: ${id}`);
}
const current = invoiceStore[index];
const nextActivity: MockInvoiceActivity = {
id: activity.id ?? `activity-${Date.now()}`,
actorUserId: activity.actorUserId,
actorName: activity.actorName,
message: activity.message,
createdAt: activity.createdAt ?? new Date().toISOString(),
};
const updated = replaceInvoice(
index,
withRefreshedCatalogFields(
{
...current,
status,
activity: [...current.activity, nextActivity],
},
),
);
if (status === "approved" || status === "rejected") {
syncMockApprovalsForInvoice(id, status);
} else if (status === "pending_approval") {
syncMockApprovalsForInvoice(id, "pending");
}
return updated;
}
/** Test helper to restore seed data between unit tests. */
export function resetMockInvoices(): void {
invoiceStore = seedInvoices.map((invoice) => cloneInvoice(invoice));
}