diff --git a/src/app/dashboard/financial/page.test.tsx b/src/app/dashboard/financial/page.test.tsx
new file mode 100644
index 0000000..0d40b4a
--- /dev/null
+++ b/src/app/dashboard/financial/page.test.tsx
@@ -0,0 +1,110 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+
+const { getFullList, getList, getScope } = vi.hoisted(() => ({
+ getFullList: vi.fn(),
+ getList: vi.fn(),
+ getScope: vi.fn(),
+}));
+vi.mock("@/lib/pb", () => ({
+ pbAdmin: async () => ({
+ collection: (name: string) => ({
+ getFullList: (options: unknown) => getFullList(name, options),
+ getList: (page: number, perPage: number, options: unknown) => getList(name, options),
+ }),
+ }),
+}));
+vi.mock("@/lib/site-scope-server", () => ({ getSiteScope: getScope }));
+vi.mock("@/components/layout/sidebar", () => ({ Topbar: () => }));
+vi.mock("@/lib/dn-page-token", () => ({ signDnPageProxyToken: () => "tok" }));
+vi.mock("@/lib/dn-pipeline/missing-purchase-orders", () => ({ missingPurchaseOrders: () => [] }));
+vi.mock("./missing-po-attention", () => ({ MissingPoAttention: () => null }));
+
+import FinancialLanding from "./page";
+
+const site = { id: "site1", name: "The Hills", project_code: "HILLS" };
+
+/** Tile value text for a KPI label, with Intl's non-breaking spaces normalised. */
+function tileValue(html: string, label: string): string {
+ const match = html.match(new RegExp(`${label}[\\s\\S]*?
([^<]*)`));
+ return (match?.[1] ?? "").replace(/\u00a0/g, " ");
+}
+
+type Fixture = {
+ pos?: Record[];
+ items?: Record[];
+ dnItems?: Record[];
+ notes?: Record[];
+};
+
+function setup(fixture: Fixture) {
+ getFullList.mockImplementation(async (name: string) => {
+ if (name === "purchase_orders") return fixture.pos ?? [];
+ if (name === "purchase_order_items") return fixture.items ?? [];
+ if (name === "delivery_note_items") return fixture.dnItems ?? [];
+ if (name === "delivery_notes") return fixture.notes ?? [];
+ if (name === "dn_batch_notes") return [];
+ return [];
+ });
+ getList.mockResolvedValue({ totalItems: 0 });
+}
+
+async function renderPage() {
+ return renderToStaticMarkup(
+ await FinancialLanding({ searchParams: Promise.resolve({ site: site.id }) }),
+ );
+}
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ // SAST month key is 2026-10 for this instant.
+ vi.setSystemTime(new Date("2026-10-04T10:00:00Z"));
+ getScope.mockResolvedValue({ allowed: [site], active: [site], mode: "single", unrestricted: false });
+ setup({});
+});
+afterEach(() => vi.useRealTimers());
+
+it("shows what is still open on an order, not everything ever ordered", async () => {
+ setup({
+ // R1 000 ordered, R400 delivered -> R600 still owed.
+ pos: [{ id: "po1", site: "site1", supplier_ref: "s1", processing_status: "processed", total_value: 1000, date_uploaded: "2026-10-01 00:00:00.000Z" }],
+ items: [{ id: "i1", purchase_order: "po1", qty: 10, rate: 100 }],
+ dnItems: [{ id: "d1", purchase_order_item: "i1", qty_received: 4, delivery_note: "dn1" }],
+ notes: [{ id: "dn1", document_type: "delivery_note", date_received: "2026-10-02 00:00:00.000Z" }],
+ });
+ const html = await renderPage();
+ expect(tileValue(html, "Open PO value")).toBe("R 600");
+ expect(tileSub(html, "Open PO value")).toBe("1 orders still open");
+});
+
+it("does not let an over-delivered order cancel another order's open balance", async () => {
+ setup({
+ pos: [
+ { id: "po1", site: "site1", supplier_ref: "s1", processing_status: "processed", total_value: 1000, date_uploaded: "2026-10-01 00:00:00.000Z" },
+ { id: "po2", site: "site1", supplier_ref: "s2", processing_status: "processed", total_value: 1000, date_uploaded: "2026-10-01 00:00:00.000Z" },
+ ],
+ items: [
+ { id: "i1", purchase_order: "po1", qty: 10, rate: 100 },
+ { id: "i2", purchase_order: "po2", qty: 10, rate: 100 },
+ ],
+ dnItems: [
+ { id: "d1", purchase_order_item: "i1", qty_received: 4, delivery_note: "dn1" },
+ // R1 500 against a R1 000 order: -R500 outstanding here.
+ { id: "d2", purchase_order_item: "i2", qty_received: 15, delivery_note: "dn2" },
+ ],
+ notes: [
+ { id: "dn1", document_type: "delivery_note", date_received: "2026-10-02 00:00:00.000Z" },
+ { id: "dn2", document_type: "delivery_note", date_received: "2026-10-02 00:00:00.000Z" },
+ ],
+ });
+ const html = await renderPage();
+ // po1's R600 stands; po2 clamps to 0 rather than netting po1 down to R100.
+ expect(tileValue(html, "Open PO value")).toBe("R 600");
+ expect(tileSub(html, "Open PO value")).toBe("1 orders still open");
+});
diff --git a/src/app/dashboard/financial/page.tsx b/src/app/dashboard/financial/page.tsx
index 76e813f..1c183f0 100644
--- a/src/app/dashboard/financial/page.tsx
+++ b/src/app/dashboard/financial/page.tsx
@@ -13,21 +13,27 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { composeFilter, filterOpt, siteFilter, type SearchParams } from "@/lib/site-scope";
import { getSiteScope } from "@/lib/site-scope-server";
import { formatCurrency, formatNumber } from "@/lib/utils";
+import { computeSupplierLedger, type SupplierPoItemLike } from "@/lib/supplier-ledger";
+import type { DocumentKind } from "@/lib/document-kind";
+
+const round2 = (n: number) => Math.round(n * 100) / 100;
export const dynamic = "force-dynamic";
/**
* Landing for the Financial section. Per the sidebar restructure spec
* §4.3:
- * - open PO value = Σ purchase_orders.total_value where processing_status
- * is 'processed' or unset (excludes pending/processing placeholders and
- * failed/dead rows that never became real orders)
+ * - open PO value = what is still owed on real orders: ordered minus
+ * delivered, from computeSupplierLedger over the items of real POs and
+ * the delivery lines belonging to them. Clamped per PO, so an
+ * over-delivered order cannot cancel another order's open balance.
+ * "Real" = processing_status 'processed' or unset, which excludes
+ * pending/processing placeholders and failed/dead rows that never
+ * became real orders
* - DNs needing review = count where processing_status='needs-review'
- * - this month's PO spend = Σ total_value where date_uploaded falls in
- * the current calendar month
*
* Kept inline here because this is the only surface that renders this
- * exact trio; the per-section pages render their own KPI tiles.
+ * exact set of tiles; the per-section pages render their own KPI tiles.
*/
async function loadFinancialSummary(scope: import("@/lib/site-scope").SiteScope) {
const pb = await pbAdmin();
@@ -37,7 +43,10 @@ async function loadFinancialSummary(scope: import("@/lib/site-scope").SiteScope)
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1),
).toISOString();
- const [pos, waitingNotes, needsReviewDns] = await Promise.all([
+ const [pos, waitingNotes, needsReviewDns, poItems, dnItems, deliveryNotes] = await Promise.all([
+ // No `fields` restriction: missingPurchaseOrders below reads site and
+ // purchase_order_number, and the month spend needs created as well as
+ // date_uploaded (MCP-raised orders carry no upload date).
pb.collection("purchase_orders").getFullList({
expand: "site",
...filterOpt(siteF),
@@ -51,28 +60,65 @@ async function loadFinancialSummary(scope: import("@/lib/site-scope").SiteScope)
filter: composeFilter(siteF, "processing_status='needs-review'"),
$autoCancel: false,
}),
+ // Ledger inputs. Not site-scoped themselves: they are joined to the
+ // site-scoped POs above, exactly as the Suppliers page does it.
+ pb.collection("purchase_order_items").getFullList({
+ fields: "id,purchase_order,qty,rate",
+ $autoCancel: false,
+ }),
+ pb.collection("delivery_note_items").getFullList({
+ fields: "id,purchase_order_item,booking,qty_received,delivery_note",
+ $autoCancel: false,
+ }),
+ // document_type and date_received live on delivery_notes, not on
+ // delivery_note_items (see src/lib/document-kind.ts).
+ pb.collection("delivery_notes").getFullList({
+ fields: "id,document_type,date_received",
+ $autoCancel: false,
+ }),
]);
const REAL_STATUSES = new Set(["processed", ""]);
- let openPoValue = 0;
+ const kindByDnId = new Map(
+ deliveryNotes.map((d) => [String(d.id), (d.document_type ?? "") as DocumentKind]),
+ );
+ const realPos = pos.filter((p) => REAL_STATUSES.has(p.processing_status ?? ""));
+ const realPoIds = new Set(realPos.map((p) => String(p.id)));
+ const realItems = poItems.filter((item) => realPoIds.has(String(item.purchase_order)));
+ const realItemIds = new Set(realItems.map((item) => String(item.id)));
+ // A delivery line belongs to a real PO by booking snapshot, or -- for an
+ // older unbooked receipt -- by its parent PO item.
+ const realDeliveries = dnItems.filter((line) => {
+ const booking = (line as { booking?: { purchaseOrderId: string } }).booking;
+ if (booking) return realPoIds.has(booking.purchaseOrderId);
+ return realItemIds.has(String((line as { purchase_order_item?: string }).purchase_order_item ?? ""));
+ });
+
+ const ledger = computeSupplierLedger(
+ realItems as unknown as SupplierPoItemLike[],
+ realDeliveries.map((line) => ({
+ booking: (line as { booking?: { purchaseOrderId: string; net: number } }).booking,
+ purchase_order_item: String((line as { purchase_order_item?: string }).purchase_order_item ?? ""),
+ qty_received: Number((line as { qty_received?: number }).qty_received ?? 0),
+ document_type:
+ kindByDnId.get(String((line as { delivery_note?: string }).delivery_note ?? "")) ?? "",
+ })),
+ );
+
+ // Clamp per PO: one over-delivered order must not net off another's open
+ // balance, which would understate what is still owed overall.
+ const openPoValue = round2(
+ ledger.lines.reduce((s, line) => s + Math.max(0, line.outstanding), 0),
+ );
+
+ // Month spend: unchanged for now (see the Task 5 commit).
let monthSpend = 0;
for (const p of pos) {
- const total = p.total_value ?? 0;
- const status = p.processing_status ?? "";
- if (REAL_STATUSES.has(status)) {
- openPoValue += total;
- }
- const dateUploaded = String(p.date_uploaded ?? "");
- if (dateUploaded >= monthStart) {
- monthSpend += total;
- }
+ if (String(p.date_uploaded ?? "") >= monthStart) monthSpend += p.total_value ?? 0;
}
return {
- openPoCount: pos.filter((p) => {
- const s = p.processing_status ?? "";
- return REAL_STATUSES.has(s);
- }).length,
+ openPoCount: ledger.lines.filter((line) => line.outstanding > 0.005).length,
openPoValue,
missingPos: missingPurchaseOrders(waitingNotes, pos).map(row=>({...row,documents:row.noteIds.map(id=>{
const note=waitingNotes.find(n=>n.id===id)!;
@@ -106,7 +152,7 @@ export default async function FinancialLanding({