mega-plant/lib/data.ts
David baf0bddde6 Invoices: line-item extraction, booking to plant items, VAT-inclusive income
Adds an Invoices sub-menu under /income with a summary table (one row per
invoice, click to expand the full detail) and the data model behind it.

Data:
- invoices + invoice_lines PocketBase collections, created idempotently by
  scripts/setup-invoice-collections.mjs
- line items transcribed from PHI 166/167/171 with per-invoice arithmetic
  validation in the seed; a bad invoice is refused without blocking the rest
- invoice-*.json in data/source are auto-imported, so adding an invoice is
  one file

Booking:
- manual book-in per line (Booked to), grouped by category
- POST /api/invoice-lines/:id/book moves the income row rather than adding a
  second one, so a booking can be changed freely
- scripts/book-invoice.mjs is idempotent: rows are stamped with
  monthly_income.invoice_line and replaced on re-run
- booked amount follows lib/invoice-booking-policy.json (bookInclusiveVat),
  read by both the app and the CLI so they cannot disagree

Money:
- the R24,205.13 column on these invoices is PLUS VAT, not escalation. The
  seed now rejects VAT that disagrees with the stated rate.
- booked amounts are VAT-inclusive per the client ruling of 2026-10-01; the
  VAT stays visible rather than hidden.

Uploads:
- drag-and-drop PDF attached to invoices.source_pdf
- GET /api/invoices/:id/document proxies it — the collection is rule-locked,
  so a direct PocketBase /api/files link 404s in a browser

Fixes a month-aggregation bug this introduced: buildIncomeRows assigned into
months[i], so a machine with two income rows in one month (one per invoice,
which is normal multi-site use) showed only the last. SS01 September read
R20,700 instead of R35,305 while its own YTD included both. Months now
accumulate, per-month profit is computed once after accumulation, and
lib/rows.test.ts pins it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-01 15:59:30 +02:00

235 lines
6.7 KiB
TypeScript

import { serverAuth } from "./pb";
import type { RecordModel } from "pocketbase";
export type Asset = {
id: string;
code: string;
name: string;
category: string;
type: string;
gl_account: string;
supplier: string;
monthly_excl: number;
monthly_incl: number;
purchase_value: number;
is_provisional: boolean;
is_active: boolean;
} & RecordModel;
export type CostLine = {
id: string;
asset: string;
year: number;
month: number;
kind: "instalment" | "insurance" | "tracking" | "operator" | "running";
amount: number;
} & RecordModel;
export type MonthlyIncome = {
id: string;
asset: string;
year: number;
month: number;
income: number;
fixed_expenses: number;
running_cost: number;
} & RecordModel;
export type MonthlyRunningCost = {
id: string;
asset: string;
year: number;
month: number;
amount: number;
} & RecordModel;
export type Overhead = {
id: string;
section: string;
description: string;
gl_account: string;
supplier: string;
qty: number;
unit_price: number;
amount_excl: number;
vat: number;
amount_incl: number;
} & RecordModel;
// Invoice header. `booked_month`/`booked_year` record the period the invoice was
// posted into monthly_income; `is_booked` flips once every line has a target asset.
export type Invoice = {
id: string;
invoice_no: string;
invoice_date: string;
order_no: string;
debtor: string;
debtor_no: string;
period_from: string;
period_to: string;
description: string;
site: string;
section: string;
category: string;
currency: string;
vat_reg_no: string;
source_file: string;
subtotal: number;
escalation: number;
vat: number;
vat_pct: number;
total_excl: number;
total_incl: number;
source_pdf: string;
booked_month: number;
booked_year: number;
is_booked: boolean;
} & RecordModel;
// Invoice line. `machine_code` is what the invoice prints; `asset_code` is the
// register match; `booked_asset` is the machine the income is actually booked to.
// These can differ — an invoice line may legitimately be charged to another machine.
export type InvoiceLine = {
id: string;
invoice: string;
line_no: number;
machine_code: string;
asset_code: string;
description: string;
qty: number;
unit: string;
rate: number;
rate_type: string;
amount_excl: number;
vat: number;
total_incl: number;
booked_asset: string;
booked_month: number;
booked_year: number;
} & RecordModel;
export async function getAssets(): Promise<Asset[]> {
const pb = await serverAuth();
return pb.collection("assets").getFullList<Asset>({ sort: "category,name" });
}
export async function getAssetsByCategory(category: string): Promise<Asset[]> {
const all = await getAssets();
return all.filter((a) => a.category === category);
}
export async function getMonthlyIncome(year = 2026): Promise<MonthlyIncome[]> {
const pb = await serverAuth();
return pb.collection("monthly_income").getFullList<MonthlyIncome>({
filter: `year = ${year}`,
sort: "month",
});
}
// Part 1.2: YTD aggregations operate on actuals only by default.
// `is_forecast` is a boolean field on monthly_income; rows where it's true are excluded.
export async function getActualMonthlyIncome(year = 2026): Promise<MonthlyIncome[]> {
const all = await getMonthlyIncome(year);
return all.filter((r) => !(r as any).is_forecast);
}
// Returns the last month that has any actual income — used to label forecast ranges.
export function lastActualMonth(rows: MonthlyIncome[]): number {
let m = 0;
for (const r of rows) if (r.income > 0 && r.month > m) m = r.month;
return m;
}
export async function getRunningCosts(year = 2026): Promise<MonthlyRunningCost[]> {
const pb = await serverAuth();
return pb.collection("monthly_running_costs").getFullList<MonthlyRunningCost>({
filter: `year = ${year}`,
sort: "month",
});
}
export async function getOverheads(): Promise<Overhead[]> {
const pb = await serverAuth();
return pb.collection("overheads").getFullList<Overhead>({ sort: "section,description" });
}
export async function getOverheadsBySection(section: string): Promise<Overhead[]> {
const all = await getOverheads();
return all.filter((o) => o.section === section);
}
export async function getInvoices(): Promise<Invoice[]> {
const pb = await serverAuth();
return pb.collection("invoices").getFullList<Invoice>({ sort: "-invoice_date,-invoice_no" });
}
export async function getInvoiceLines(invoiceId?: string): Promise<InvoiceLine[]> {
const pb = await serverAuth();
return pb.collection("invoice_lines").getFullList<InvoiceLine>({
filter: invoiceId ? `invoice = "${invoiceId}"` : "",
sort: "line_no",
});
}
// An invoice line joined to the asset it is booked against. Kept as a plain derived
// type so the page never has to deal with PocketBase expand payloads.
export type BookedLine = InvoiceLine & {
bookedCode: string;
bookedName: string;
isBooked: boolean;
};
export function joinBookedLines(lines: InvoiceLine[], assets: Asset[]): BookedLine[] {
const byId = new Map(assets.map((a) => [a.id, a]));
return lines.map((l) => {
const a = l.booked_asset ? byId.get(l.booked_asset) : undefined;
return {
...l,
bookedCode: a?.code || "",
bookedName: a?.name || "",
isBooked: Boolean(a),
};
});
}
// One machine can legitimately be hired to several sites in the same month — that is
// multi-site use, not a duplicate. These are flagged so the reader can see the pattern,
// NOT as a data error, so nothing here is styled as a problem.
//
// Returns a map of line id → the other (invoice, site) pairs this same machine code was
// billed on over an overlapping period.
export function findDuplicateMachineCodes(
invoices: Invoice[],
lines: BookedLine[],
): Map<string, string[]> {
const byId = new Map(invoices.map((i) => [i.id, i]));
const groups = new Map<string, BookedLine[]>();
for (const l of lines) {
if (!l.machine_code) continue;
if (!groups.has(l.machine_code)) groups.set(l.machine_code, []);
groups.get(l.machine_code)!.push(l);
}
const out = new Map<string, string[]>();
for (const [, group] of groups) {
if (group.length < 2) continue;
for (const line of group) {
const inv = byId.get(line.invoice);
if (!inv) continue;
const others = new Set<string>();
for (const other of group) {
if (other.id === line.id) continue;
const oInv = byId.get(other.invoice);
if (!oInv || oInv.id === inv.id) continue;
const overlaps = inv.period_from <= oInv.period_to && oInv.period_from <= inv.period_to;
if (overlaps) others.add(oInv.invoice_no);
}
if (others.size) out.set(line.id, [...others]);
}
}
return out;
}
export const MONTH_NAMES = [
"Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec",
];