Add payments with TDS, invoice detail view and filterable History (Phase E2)

- Migration M8 adds payments (cash, TDS, mode, reference). Payment status is derived, never stored: unpaid, partially paid, paid, or overdue. Overpayment is rejected, TDS counts toward settling, and only issued invoices accept payments. list_invoices and get_invoice carry the paid, TDS and balance summary.
- New invoice detail view: archived-PDF preview, payments list, Record payment modal, Export, Duplicate (as a draft), Re-render and Cancel. An invoice with payments cannot be cancelled until they are deleted.
- History gets search, status, financial-year, month and client filters, sortable columns, pagination, footer totals and an FY summary. Cancelled invoices are excluded from totals. The per-row archive-status calls are gone; archiving is on the detail page.
- Rust tests for payments and the list summary; vitest for fiscal-year, money, filter and duplicate helpers.

PDF output and fingerprint goldens are unchanged. The new UI has not been run in a webview yet.
This commit is contained in:
2026-10-04 17:49:25 +05:30
parent 708fa7b610
commit 9fdff4f263
25 changed files with 2220 additions and 175 deletions
+46
View File
@@ -0,0 +1,46 @@
import type { InvoiceStatus, PaymentStatus } from "./types";
export type PaymentMode = "bank_transfer" | "upi" | "cash" | "cheque" | "other";
export const PAYMENT_MODES: { id: PaymentMode; label: string }[] = [
{ id: "bank_transfer", label: "Bank transfer" },
{ id: "upi", label: "UPI" },
{ id: "cash", label: "Cash" },
{ id: "cheque", label: "Cheque" },
{ id: "other", label: "Other" },
];
export const paymentModeLabel = (mode: string): string => PAYMENT_MODES.find((m) => m.id === mode)?.label ?? mode;
/** A payment as stored: amounts are integer paise. `amountPaise` is cash; `tdsPaise` is tax the client deducted. */
export interface Payment {
id: number;
invoiceId: number;
paidOn: string;
amountPaise: number;
tdsPaise: number;
mode: PaymentMode;
reference: string;
notes: string;
createdAt: string;
}
export type PaymentInput = Omit<Payment, "id" | "createdAt">;
type TagType = "red" | "green" | "blue" | "gray" | "warm-gray" | "cool-gray" | "purple";
/** What to show in the status column and badges: the cancelled state wins over payment state. */
export function statusBadge(status: InvoiceStatus, payment: PaymentStatus | undefined): { label: string; type: TagType } {
if (status === "cancelled") return { label: "Cancelled", type: "gray" };
if (status === "draft") return { label: "Draft", type: "warm-gray" };
switch (payment) {
case "paid":
return { label: "Paid", type: "green" };
case "partially_paid":
return { label: "Partly paid", type: "blue" };
case "overdue":
return { label: "Overdue", type: "red" };
default:
return { label: "Unpaid", type: "cool-gray" };
}
}