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:
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]));
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user