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>
235 lines
6.7 KiB
TypeScript
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",
|
|
];
|