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 3510cc4260
commit 1623b879e2
25 changed files with 2220 additions and 175 deletions
+5
View File
@@ -11,6 +11,7 @@ import type {
Settings,
} from "./types";
import type { LogoAsset } from "./logo";
import type { Payment, PaymentInput } from "./payments";
import type { FontInspection, ImportMeta, RemoveOutcome, UserFontRow } from "./fontImport";
export interface ArchiveStatus {
@@ -49,6 +50,10 @@ export const api = {
listInvoices: () => invoke<InvoiceSummary[]>("list_invoices"),
getInvoice: (id: number) => invoke<Invoice>("get_invoice", { id }),
recordPayment: (input: PaymentInput) => invoke<Payment>("record_payment", { input }),
listPayments: (invoiceId: number) => invoke<Payment[]>("list_payments", { invoiceId }),
deletePayment: (id: number) => invoke<void>("delete_payment", { id }),
saveDraft: (id: number | null, payload: unknown) =>
invoke<number>("save_draft", { id, payload }),
listDrafts: () => invoke<DraftSummary[]>("list_drafts"),
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { invoiceToFormState } from "./duplicate";
import { fromDraftPayload, toDraftPayload } from "./draft";
import type { Invoice } from "./types";
const invoice = {
id: 7,
number: "INV/2025-007",
invoiceDate: "2025-04-01",
dueDate: "2025-05-01",
clientId: 3,
clientName: "Acme",
clientAddress: "1 Road",
clientGstin: "",
poNumber: "PO-1",
placeOfSupplyStateCode: "29",
discount: 50,
taxRate: 18,
reverseCharge: false,
bankAccountId: 2,
signaturePath: "assets/sig.png",
notes: "Thanks",
items: [
{ id: 11, description: "Design", mode: "fixed", rate: 0, unit: "unit", quantity: 1, amount: 1000, sortOrder: 4, hsnSac: "998" },
],
} as unknown as Invoice;
describe("invoiceToFormState", () => {
it("copies the content with a fresh date, due date and no item ids", () => {
const s = invoiceToFormState(invoice, { paymentTermsDays: 15 }, "2026-02-20");
expect(s.invoiceDate).toBe("2026-02-20");
expect(s.dueDate).toBe("2026-03-07");
expect(s.clientName).toBe("Acme");
expect(s.placeOfSupply).toBe("29");
expect(s.bankId).toBe(2);
expect(s.saveClient).toBe(false);
expect(s.items).toHaveLength(1);
expect(s.items[0]).not.toHaveProperty("id");
expect(s.items[0].sortOrder).toBe(0);
expect(s.items[0].hsnSac).toBe("998");
});
it("survives a round trip through the draft payload", () => {
const s = invoiceToFormState(invoice, { paymentTermsDays: 30 }, "2026-02-20");
expect(fromDraftPayload(toDraftPayload(s), s)).toEqual(s);
});
});
+32
View File
@@ -0,0 +1,32 @@
import { addDays, todayIso } from "./format";
import type { InvoiceFormState } from "./draft";
import type { Invoice, Settings } from "./types";
/**
* The New invoice form state for "Duplicate": every content field of the issued invoice, with today's date,
* a fresh due date, and no link to the original (no id, no number). The result goes through the normal draft
* mechanism (`toDraftPayload` + `saveDraft`), so the original stays immutable.
*/
export function invoiceToFormState(invoice: Invoice, settings: Pick<Settings, "paymentTermsDays">, today = todayIso()): InvoiceFormState {
return {
invoiceDate: today,
dueDate: addDays(today, settings.paymentTermsDays),
clientId: invoice.clientId,
clientName: invoice.clientName,
clientAddress: invoice.clientAddress,
clientGstin: invoice.clientGstin,
poNumber: invoice.poNumber,
placeOfSupply: invoice.placeOfSupplyStateCode,
saveClient: false,
items: invoice.items.map((item, index) => {
const { id: _id, ...rest } = item;
return { ...rest, sortOrder: index };
}),
discount: invoice.discount,
taxRate: invoice.taxRate,
reverseCharge: invoice.reverseCharge,
bankId: invoice.bankAccountId,
signaturePath: invoice.signaturePath,
notes: invoice.notes,
};
}
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { currentFyStartYear, fyLabel, fyRange, fyStartYear, monthOf, todayIst, FY_MONTHS } from "./fiscal";
describe("financial year", () => {
it("starts on 1 April", () => {
expect(fyStartYear("2026-03-31")).toBe(2025);
expect(fyStartYear("2026-04-01")).toBe(2026);
expect(fyStartYear("2026-12-31")).toBe(2026);
expect(fyStartYear("2027-01-01")).toBe(2026);
});
it("rejects things that are not ISO dates", () => {
expect(fyStartYear("")).toBeNull();
expect(fyStartYear("01/04/2026")).toBeNull();
expect(fyStartYear("2026-13-01")).toBeNull();
expect(monthOf("2026-00-10")).toBeNull();
expect(monthOf("2026-07-10")).toBe(7);
});
it("labels and ranges", () => {
expect(fyLabel(2025)).toBe("FY 2025-26");
expect(fyLabel(2099)).toBe("FY 2099-00");
expect(fyRange(2025)).toEqual({ from: "2025-04-01", to: "2026-03-31" });
});
it("lists months April to March", () => {
expect(FY_MONTHS.map((m) => m.month)).toEqual([4, 5, 6, 7, 8, 9, 10, 11, 12, 1, 2, 3]);
expect(FY_MONTHS[0].label).toBe("April");
});
it("uses IST for now, not the machine time zone", () => {
// 20:00 UTC on 31 March is already 01:30 on 1 April in India: a new financial year.
const now = new Date("2026-03-31T20:00:00Z");
expect(todayIst(now)).toBe("2026-04-01");
expect(currentFyStartYear(now)).toBe(2026);
// 18:29 UTC is still 23:59 on 31 March in India.
expect(currentFyStartYear(new Date("2026-03-31T18:29:00Z"))).toBe(2025);
});
});
+48
View File
@@ -0,0 +1,48 @@
/** Indian financial year (April to March) helpers. Invoice dates are plain YYYY-MM-DD calendar dates, so they
* are read from the string; only "now" needs a time zone, and that is always IST (UTC+05:30). */
const ISO = /^(\d{4})-(\d{2})-(\d{2})$/;
const IST_OFFSET_MS = (5 * 60 + 30) * 60_000;
/** Months in financial-year order, for the month filter. */
export const FY_MONTHS: { month: number; label: string }[] = [4, 5, 6, 7, 8, 9, 10, 11, 12, 1, 2, 3].map((month) => ({
month,
label: new Date(2000, month - 1, 1).toLocaleDateString("en-GB", { month: "long" }),
}));
/** Month 1-12 of an ISO date, or null when it is not one. */
export function monthOf(iso: string): number | null {
const m = ISO.exec(iso);
if (!m) return null;
const month = Number(m[2]);
return month >= 1 && month <= 12 ? month : null;
}
/** The calendar year in which the financial year containing `iso` starts (2026-03-31 -> 2025, 2026-04-01 -> 2026). */
export function fyStartYear(iso: string): number | null {
const m = ISO.exec(iso);
const month = monthOf(iso);
if (!m || month === null) return null;
const year = Number(m[1]);
return month >= 4 ? year : year - 1;
}
/** 2025 -> "FY 2025-26". */
export function fyLabel(startYear: number): string {
return `FY ${startYear}-${String((startYear + 1) % 100).padStart(2, "0")}`;
}
/** First and last day of a financial year. */
export function fyRange(startYear: number): { from: string; to: string } {
return { from: `${startYear}-04-01`, to: `${startYear + 1}-03-31` };
}
/** Today's date in IST as YYYY-MM-DD. */
export function todayIst(now: Date = new Date()): string {
return new Date(now.getTime() + IST_OFFSET_MS).toISOString().slice(0, 10);
}
/** The financial year that `now` falls in, in IST. */
export function currentFyStartYear(now: Date = new Date()): number {
return fyStartYear(todayIst(now)) as number;
}
+123
View File
@@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
import { clientOptions, filterInvoices, fyOptions, fySummary, NO_FILTERS, sortInvoices, totalsOf } from "./historyFilter";
import type { InvoiceSummary } from "./types";
let nextId = 1;
function row(over: Partial<InvoiceSummary>): InvoiceSummary {
const base: InvoiceSummary = {
id: nextId++,
number: "INV/2025-001",
invoiceDate: "2025-06-10",
dueDate: "2025-07-10",
clientName: "Acme",
total: 0,
status: "issued",
createdAt: "",
poNumber: "",
totalPaise: 100_000,
paidPaise: 0,
tdsPaise: 0,
balancePaise: 100_000,
paymentStatus: "unpaid",
};
return { ...base, ...over };
}
const rows: InvoiceSummary[] = [
row({ number: "INV/2025-001", invoiceDate: "2025-04-01", clientName: "Acme", poNumber: "PO-77", paymentStatus: "paid", paidPaise: 90_000, tdsPaise: 10_000, balancePaise: 0 }),
row({ number: "INV/2025-002", invoiceDate: "2025-05-20", clientName: "Beta Labs", totalPaise: 250_050, balancePaise: 250_050, paymentStatus: "overdue" }),
row({ number: "INV/2025-010", invoiceDate: "2026-03-31", clientName: "Acme", paidPaise: 30_000, balancePaise: 70_000, paymentStatus: "partially_paid" }),
row({ number: "INV/2026-001", invoiceDate: "2026-04-02", clientName: "Gamma", status: "cancelled", balancePaise: 0, paymentStatus: "none" }),
row({ number: "INV/2026-002", invoiceDate: "2026-04-05", clientName: "Acme", paymentStatus: "unpaid" }),
];
const f = (over: object) => filterInvoices(rows, { ...NO_FILTERS, ...over }).map((r) => r.number);
describe("filterInvoices", () => {
it("returns everything with no filters", () => expect(f({})).toHaveLength(5));
it("searches number, client and PO case-insensitively", () => {
expect(f({ search: "beta" })).toEqual(["INV/2025-002"]);
expect(f({ search: "po-77" })).toEqual(["INV/2025-001"]);
expect(f({ search: " 2026-00 " })).toEqual(["INV/2026-001", "INV/2026-002"]);
expect(f({ search: "nothing" })).toEqual([]);
});
it("filters by status and payment status", () => {
expect(f({ status: "cancelled" })).toEqual(["INV/2026-001"]);
expect(f({ status: "issued" })).toHaveLength(4);
expect(f({ status: "paid" })).toEqual(["INV/2025-001"]);
expect(f({ status: "overdue" })).toEqual(["INV/2025-002"]);
expect(f({ status: "partially_paid" })).toEqual(["INV/2025-010"]);
// An overdue invoice with nothing received is also unpaid.
expect(f({ status: "unpaid" })).toEqual(["INV/2025-002", "INV/2026-002"]);
});
it("filters by financial year, month and client", () => {
expect(f({ fy: 2025 })).toEqual(["INV/2025-001", "INV/2025-002", "INV/2025-010"]);
expect(f({ fy: 2026 })).toEqual(["INV/2026-001", "INV/2026-002"]);
expect(f({ month: 4 })).toEqual(["INV/2025-001", "INV/2026-001", "INV/2026-002"]);
expect(f({ fy: 2026, month: 4 })).toEqual(["INV/2026-001", "INV/2026-002"]);
expect(f({ client: "Acme", fy: 2025 })).toEqual(["INV/2025-001", "INV/2025-010"]);
});
});
describe("sortInvoices", () => {
const nums = (sort: Parameters<typeof sortInvoices>[1]) => sortInvoices(rows, sort).map((r) => r.number);
it("sorts numbers naturally, so 010 follows 002", () => {
expect(nums({ key: "number", dir: "asc" }).slice(0, 3)).toEqual(["INV/2025-001", "INV/2025-002", "INV/2025-010"]);
expect(nums({ key: "number", dir: "desc" })[0]).toBe("INV/2026-002");
});
it("sorts by date, total and balance", () => {
expect(nums({ key: "date", dir: "desc" })[0]).toBe("INV/2026-002");
expect(nums({ key: "total", dir: "desc" })[0]).toBe("INV/2025-002");
expect(nums({ key: "balance", dir: "asc" }).slice(0, 2).sort()).toEqual(["INV/2025-001", "INV/2026-001"]);
});
it("sorts by status with overdue first and cancelled last, and does not mutate", () => {
const before = rows.map((r) => r.id);
const s = sortInvoices(rows, { key: "status", dir: "asc" });
expect(s[0].paymentStatus).toBe("overdue");
expect(s[s.length - 1].status).toBe("cancelled");
expect(rows.map((r) => r.id)).toEqual(before);
});
it("breaks ties by newest id", () => {
const tied = [row({ clientName: "Same" }), row({ clientName: "Same" })];
expect(sortInvoices(tied, { key: "client", dir: "asc" })[0].id).toBe(tied[1].id);
});
});
describe("totals", () => {
it("adds paise exactly and ignores cancelled invoices", () => {
const t = totalsOf(rows);
expect(t.count).toBe(5);
expect(t.invoicedPaise).toBe(100_000 + 250_050 + 100_000 + 100_000);
expect(t.receivedPaise).toBe(120_000);
expect(t.tdsPaise).toBe(10_000);
expect(t.outstandingPaise).toBe(250_050 + 70_000 + 100_000);
});
it("keeps cents exact where floats would drift", () => {
const many = Array.from({ length: 1000 }, () => row({ totalPaise: 10, balancePaise: 10 }));
expect(totalsOf(many).invoicedPaise).toBe(10_000);
});
it("summarises one financial year including overdue", () => {
const s = fySummary(rows, 2025);
expect(s.count).toBe(3);
expect(s.overdueCount).toBe(1);
expect(s.overduePaise).toBe(250_050);
expect(fySummary(rows, 2030).invoicedPaise).toBe(0);
});
});
describe("options", () => {
it("lists financial years newest first and always the current one", () => {
expect(fyOptions(rows, 2025)).toEqual([2026, 2025]);
expect(fyOptions([], 2027)).toEqual([2027]);
});
it("lists distinct clients", () => expect(clientOptions(rows)).toEqual(["Acme", "Beta Labs", "Gamma"]));
});
+145
View File
@@ -0,0 +1,145 @@
import { fyStartYear, monthOf } from "./fiscal";
import type { InvoiceSummary } from "./types";
export type StatusFilter = "all" | "issued" | "cancelled" | "unpaid" | "partially_paid" | "paid" | "overdue";
export const STATUS_FILTERS: { id: StatusFilter; label: string }[] = [
{ id: "all", label: "All statuses" },
{ id: "issued", label: "Issued" },
{ id: "unpaid", label: "Unpaid" },
{ id: "partially_paid", label: "Partly paid" },
{ id: "paid", label: "Paid" },
{ id: "overdue", label: "Overdue" },
{ id: "cancelled", label: "Cancelled" },
];
export interface HistoryFilters {
search: string;
status: StatusFilter;
/** Financial-year start year, or "all". */
fy: number | "all";
/** Calendar month 1-12, or "all". */
month: number | "all";
client: string | "all";
}
export const NO_FILTERS: HistoryFilters = { search: "", status: "all", fy: "all", month: "all", client: "all" };
export type SortKey = "number" | "date" | "client" | "total" | "balance" | "status";
export interface SortSpec {
key: SortKey;
dir: "asc" | "desc";
}
/** All amounts are integer paise. Cancelled invoices count for nothing. */
export interface Totals {
count: number;
invoicedPaise: number;
receivedPaise: number;
tdsPaise: number;
outstandingPaise: number;
}
const settled = (r: InvoiceSummary) => r.paidPaise + r.tdsPaise;
function matchesStatus(r: InvoiceSummary, f: StatusFilter): boolean {
switch (f) {
case "all":
return true;
case "issued":
return r.status === "issued";
case "cancelled":
return r.status === "cancelled";
case "unpaid":
return r.status === "issued" && settled(r) === 0;
case "partially_paid":
return r.status === "issued" && settled(r) > 0 && r.balancePaise > 0;
case "paid":
return r.status === "issued" && r.paymentStatus === "paid";
case "overdue":
return r.status === "issued" && r.paymentStatus === "overdue";
}
}
/** Rows that satisfy every filter. The search is a case-insensitive substring of number, client or PO number. */
export function filterInvoices(rows: InvoiceSummary[], f: HistoryFilters): InvoiceSummary[] {
const q = f.search.trim().toLowerCase();
return rows.filter((r) => {
if (q && !`${r.number}\n${r.clientName}\n${r.poNumber}`.toLowerCase().includes(q)) return false;
if (!matchesStatus(r, f.status)) return false;
if (f.fy !== "all" && fyStartYear(r.invoiceDate) !== f.fy) return false;
if (f.month !== "all" && monthOf(r.invoiceDate) !== f.month) return false;
if (f.client !== "all" && r.clientName !== f.client) return false;
return true;
});
}
const STATUS_RANK: Record<string, number> = { overdue: 0, unpaid: 1, partially_paid: 2, paid: 3, none: 4 };
const statusRank = (r: InvoiceSummary) => (r.status === "cancelled" ? 5 : (STATUS_RANK[r.paymentStatus] ?? 4));
const numeric = new Intl.Collator("en", { numeric: true, sensitivity: "base" });
function compare(a: InvoiceSummary, b: InvoiceSummary, key: SortKey): number {
switch (key) {
case "number":
return numeric.compare(a.number, b.number);
case "date":
return a.invoiceDate < b.invoiceDate ? -1 : a.invoiceDate > b.invoiceDate ? 1 : 0;
case "client":
return numeric.compare(a.clientName, b.clientName);
case "total":
return a.totalPaise - b.totalPaise;
case "balance":
return a.balancePaise - b.balancePaise;
case "status":
return statusRank(a) - statusRank(b);
}
}
/** A new, stably sorted array; ties fall back to the newest invoice first so the order never jitters. */
export function sortInvoices(rows: InvoiceSummary[], sort: SortSpec): InvoiceSummary[] {
const sign = sort.dir === "asc" ? 1 : -1;
return [...rows].sort((a, b) => sign * compare(a, b, sort.key) || b.id - a.id);
}
export function totalsOf(rows: InvoiceSummary[]): Totals {
const t: Totals = { count: rows.length, invoicedPaise: 0, receivedPaise: 0, tdsPaise: 0, outstandingPaise: 0 };
for (const r of rows) {
if (r.status === "cancelled") continue;
t.invoicedPaise += r.totalPaise;
t.receivedPaise += r.paidPaise;
t.tdsPaise += r.tdsPaise;
t.outstandingPaise += r.balancePaise;
}
return t;
}
export interface FySummary extends Totals {
overdueCount: number;
overduePaise: number;
}
/** The tiles above the table: everything dated in one financial year, regardless of the other filters. */
export function fySummary(rows: InvoiceSummary[], fy: number): FySummary {
const inYear = rows.filter((r) => fyStartYear(r.invoiceDate) === fy);
const overdue = inYear.filter((r) => r.status === "issued" && r.paymentStatus === "overdue");
return {
...totalsOf(inYear),
overdueCount: overdue.length,
overduePaise: overdue.reduce((sum, r) => sum + r.balancePaise, 0),
};
}
/** Financial years that have invoices, newest first, always including `current`. */
export function fyOptions(rows: InvoiceSummary[], current: number): number[] {
const years = new Set<number>([current]);
for (const r of rows) {
const y = fyStartYear(r.invoiceDate);
if (y !== null) years.add(y);
}
return [...years].sort((a, b) => b - a);
}
/** Distinct client names, alphabetical, blanks left out. */
export function clientOptions(rows: InvoiceSummary[]): string[] {
return [...new Set(rows.map((r) => r.clientName.trim()).filter(Boolean))].sort((a, b) => numeric.compare(a, b));
}
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { formatPaise, paiseToInput, parseRupeesToPaise } from "./money";
describe("money", () => {
it("parses typed amounts to exact paise", () => {
expect(parseRupeesToPaise("1250.5")).toBe(125050);
expect(parseRupeesToPaise("1,25,000")).toBe(12500000);
expect(parseRupeesToPaise("₹ 99.99")).toBe(9999);
expect(parseRupeesToPaise("0.29")).toBe(29);
expect(parseRupeesToPaise("19.9")).toBe(1990);
expect(parseRupeesToPaise("5.")).toBe(500);
expect(parseRupeesToPaise("")).toBe(0);
});
it("rejects non-amounts", () => {
for (const bad of ["abc", "-5", "1.234", "1e3", "1.2.3", ".5"]) expect(parseRupeesToPaise(bad)).toBeNull();
expect(parseRupeesToPaise("99999999999999999999")).toBeNull();
});
it("formats", () => {
expect(paiseToInput(123450)).toBe("1234.50");
expect(paiseToInput(5)).toBe("0.05");
expect(formatPaise(12500000)).toBe("₹1,25,000.00");
});
});
+27
View File
@@ -0,0 +1,27 @@
import { formatAmount } from "./format";
/**
* Parses what a person types into an amount field ("1,25,000", "1250.5", "₹ 99.99") into integer paise
* without going through floating point. Returns null for anything that is not a plain amount with at
* most two decimals; an empty string is 0.
*/
export function parseRupeesToPaise(text: string): number | null {
const cleaned = text.replace(/[₹,\s]/g, "");
if (cleaned === "") return 0;
const m = /^(\d+)(?:\.(\d{0,2}))?$/.exec(cleaned);
if (!m) return null;
const whole = Number(m[1]);
const paise = whole * 100 + Number((m[2] ?? "").padEnd(2, "0"));
return Number.isSafeInteger(paise) ? paise : null;
}
/** 123450 -> "1234.50", for filling an amount field. */
export function paiseToInput(paise: number): string {
const p = Math.max(0, Math.round(paise));
return `${Math.floor(p / 100)}.${String(p % 100).padStart(2, "0")}`;
}
/** 123450 -> "₹1,234.50" */
export function formatPaise(paise: number): string {
return `₹${formatAmount(paise / 100)}`;
}
+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" };
}
}
+15
View File
@@ -171,6 +171,9 @@ export interface InvoiceInput {
export type InvoiceStatus = "draft" | "issued" | "cancelled";
/** Derived by the backend from the payments and the due date; "none" for invoices that take no payments. */
export type PaymentStatus = "none" | "unpaid" | "partially_paid" | "paid" | "overdue";
/** An invoice as stored: all amounts are derived on the Rust side. */
export interface Invoice {
id: number;
@@ -214,6 +217,12 @@ export interface Invoice {
archivedFingerprint?: string | null;
archivedAt?: string | null;
items: InvoiceItem[];
/** Payment summary added by the backend (integer paise). Optional so PDF fixtures built by hand need not carry it. */
totalPaise?: number;
paidPaise?: number;
tdsPaise?: number;
balancePaise?: number;
paymentStatus?: PaymentStatus;
}
export interface DraftSummary {
@@ -232,6 +241,12 @@ export interface InvoiceSummary {
total: number;
status: InvoiceStatus;
createdAt: string;
poNumber: string;
totalPaise: number;
paidPaise: number;
tdsPaise: number;
balancePaise: number;
paymentStatus: PaymentStatus;
}
export const EMPTY_ITEM: InvoiceItem = {