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
+41 -7
View File
@@ -3,8 +3,9 @@ use super::series::{validate_series_format, MAX_NUMBER_LEN};
use super::settings::{map_bank, map_settings, SETTINGS_COLS};
use crate::db::format_number;
use crate::gst::{self, TaxType};
use super::payments::{derive_summary, summary_for, today_ist};
use crate::models::{
BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, Settings,
BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, PaymentSummary, Settings,
};
use crate::AppState;
use chrono::NaiveDate;
@@ -59,6 +60,8 @@ fn map_invoice(row: &Row) -> rusqlite::Result<Invoice> {
archived_fingerprint: row.get(35)?,
archived_at: row.get(36)?,
items: Vec::new(),
payment: PaymentSummary::default(),
total_paise: 0,
})
}
@@ -90,6 +93,8 @@ fn fetch_invoice(conn: &Connection, id: i64) -> rusqlite::Result<Invoice> {
map_invoice,
)?;
invoice.items = fetch_items(conn, id)?;
invoice.total_paise = gst::rupees_to_paise(invoice.total);
invoice.payment = summary_for(conn, id, &invoice.status, invoice.total, &invoice.due_date)?;
Ok(invoice)
}
@@ -460,6 +465,12 @@ pub fn issue_invoice(
/// Issued invoices are never deleted: cancelling keeps the row and its number.
pub fn cancel_invoice_impl(conn: &mut Connection, id: i64, reason: &str) -> Result<Invoice, String> {
let payments: i64 = conn
.query_row("SELECT COUNT(*) FROM payments WHERE invoice_id = ?1", params![id], |r| r.get(0))
.map_err(|e| e.to_string())?;
if payments > 0 {
return Err("This invoice has payments recorded. Delete them before cancelling it.".into());
}
let now = chrono::Utc::now().to_rfc3339();
let changed = conn
.execute(
@@ -491,23 +502,42 @@ pub fn cancel_invoice(state: State<AppState>, id: i64, reason: String) -> Result
#[tauri::command]
pub fn list_invoices(state: State<AppState>) -> Result<Vec<InvoiceSummary>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
list_invoices_impl(&conn)
}
/// One query: invoices left-joined to their payment sums. Status derivation happens in Rust
/// (`derive_summary`) so list and detail agree.
pub fn list_invoices_impl(conn: &Connection) -> Result<Vec<InvoiceSummary>, String> {
let today = today_ist();
let mut stmt = conn
.prepare(
"SELECT id, number, invoice_date, due_date, client_name, total, status, created_at
FROM invoices ORDER BY id DESC",
"SELECT i.id, i.number, i.invoice_date, i.due_date, i.client_name, i.total, i.status,
i.created_at, i.po_number, COALESCE(p.paid, 0), COALESCE(p.tds, 0)
FROM invoices i
LEFT JOIN (SELECT invoice_id, SUM(amount_paise) AS paid, SUM(tds_paise) AS tds
FROM payments GROUP BY invoice_id) p ON p.invoice_id = i.id
ORDER BY i.id DESC",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| {
let total: f64 = row.get(5)?;
let status: String = row.get(6)?;
let due_date: String = row.get(3)?;
let total_paise = gst::rupees_to_paise(total);
let payment = derive_summary(&status, total_paise, row.get(9)?, row.get(10)?, &due_date, &today);
Ok(InvoiceSummary {
id: row.get(0)?,
number: row.get(1)?,
invoice_date: row.get(2)?,
due_date: row.get(3)?,
due_date,
client_name: row.get(4)?,
total: row.get(5)?,
status: row.get(6)?,
total,
status,
created_at: row.get(7)?,
po_number: row.get(8)?,
total_paise,
payment,
})
})
.map_err(|e| e.to_string())?
@@ -516,10 +546,14 @@ pub fn list_invoices(state: State<AppState>) -> Result<Vec<InvoiceSummary>, Stri
Ok(rows)
}
pub fn get_invoice_impl(conn: &Connection, id: i64) -> Result<Invoice, String> {
fetch_invoice(conn, id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_invoice(state: State<AppState>, id: i64) -> Result<Invoice, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
fetch_invoice(&conn, id).map_err(|e| e.to_string())
get_invoice_impl(&conn, id)
}
pub fn save_draft_impl(
+1
View File
@@ -5,6 +5,7 @@ pub mod files;
pub mod fonts;
pub mod invoice;
pub mod logo;
pub mod payments;
pub mod presets;
pub mod raw;
pub mod series;
+454
View File
@@ -0,0 +1,454 @@
//! Payments received against issued invoices.
//!
//! Money is integer paise throughout. Payment status is never stored: it is derived from the
//! payment rows, the invoice total and the due date, so deleting a payment reverts it for free.
//! The issued invoice row is not touched by any of this.
use crate::gst::rupees_to_paise;
use crate::models::PaymentSummary;
use crate::AppState;
use chrono::{FixedOffset, NaiveDate, Utc};
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use tauri::State;
const MODES: &[&str] = &["bank_transfer", "upi", "cash", "cheque", "other"];
/// Upper bound on one payment (paise); keeps sums far from i64 overflow.
const MAX_PAISE: i64 = 100_000_000_000;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Payment {
pub id: i64,
pub invoice_id: i64,
pub paid_on: String,
/// Cash received.
pub amount_paise: i64,
/// Tax deducted at source by the client; settles the invoice like cash does.
pub tds_paise: i64,
pub mode: String,
pub reference: String,
pub notes: String,
pub created_at: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PaymentInput {
pub invoice_id: i64,
pub paid_on: String,
pub amount_paise: i64,
#[serde(default)]
pub tds_paise: i64,
pub mode: String,
#[serde(default)]
pub reference: String,
#[serde(default)]
pub notes: String,
}
/// Today's date in India (IST, UTC+05:30), as YYYY-MM-DD. Due dates are plain calendar dates.
pub fn today_ist() -> String {
let ist = FixedOffset::east_opt(5 * 3600 + 1800).expect("valid offset");
Utc::now().with_timezone(&ist).date_naive().format("%Y-%m-%d").to_string()
}
/// Derives the payment summary. Only an issued invoice takes payments; anything else is `none`
/// with a zero balance (a cancelled invoice is owed nothing). `settled` is cash plus TDS.
/// Overdue means a due date strictly before `today` while a balance remains; it wins over
/// `partially_paid`, and the paid amounts still show the partial payment.
pub fn derive_summary(
status: &str,
total_paise: i64,
paid_paise: i64,
tds_paise: i64,
due_date: &str,
today: &str,
) -> PaymentSummary {
if status != "issued" {
return PaymentSummary {
paid_paise,
tds_paise,
balance_paise: 0,
payment_status: "none".to_string(),
};
}
let settled = paid_paise + tds_paise;
let balance = (total_paise - settled).max(0);
let overdue = balance > 0
&& NaiveDate::parse_from_str(due_date.trim(), "%Y-%m-%d").is_ok()
&& due_date.trim() < today;
let payment_status = if balance == 0 && total_paise > 0 {
"paid"
} else if overdue {
"overdue"
} else if settled > 0 {
"partially_paid"
} else {
"unpaid"
};
PaymentSummary {
paid_paise,
tds_paise,
balance_paise: balance,
payment_status: payment_status.to_string(),
}
}
/// Summary for one invoice read from the payments table.
pub fn summary_for(
conn: &Connection,
invoice_id: i64,
status: &str,
total: f64,
due_date: &str,
) -> rusqlite::Result<PaymentSummary> {
let (paid, tds): (i64, i64) = conn.query_row(
"SELECT COALESCE(SUM(amount_paise), 0), COALESCE(SUM(tds_paise), 0)
FROM payments WHERE invoice_id = ?1",
params![invoice_id],
|r| Ok((r.get(0)?, r.get(1)?)),
)?;
Ok(derive_summary(status, rupees_to_paise(total), paid, tds, due_date, &today_ist()))
}
fn rupees(paise: i64) -> String {
format!("₹{}.{:02}", paise / 100, paise % 100)
}
fn map_payment(r: &rusqlite::Row) -> rusqlite::Result<Payment> {
Ok(Payment {
id: r.get(0)?,
invoice_id: r.get(1)?,
paid_on: r.get(2)?,
amount_paise: r.get(3)?,
tds_paise: r.get(4)?,
mode: r.get(5)?,
reference: r.get(6)?,
notes: r.get(7)?,
created_at: r.get(8)?,
})
}
const COLS: &str = "id, invoice_id, paid_on, amount_paise, tds_paise, mode, reference, notes, created_at";
pub fn record_payment_impl(conn: &mut Connection, input: PaymentInput) -> Result<Payment, String> {
let db = |e: rusqlite::Error| e.to_string();
NaiveDate::parse_from_str(input.paid_on.trim(), "%Y-%m-%d")
.map_err(|_| "Payment date must be a date like 2026-04-01".to_string())?;
if !MODES.contains(&input.mode.as_str()) {
return Err(format!("Unknown payment mode \"{}\"", input.mode));
}
if input.amount_paise < 0 || input.tds_paise < 0 {
return Err("Amounts cannot be negative".into());
}
if input.amount_paise > MAX_PAISE || input.tds_paise > MAX_PAISE {
return Err("That amount is too large".into());
}
if input.amount_paise + input.tds_paise == 0 {
return Err("Enter an amount received or TDS deducted".into());
}
let tx = conn.transaction().map_err(db)?;
let (status, total): (String, f64) = tx
.query_row(
"SELECT status, total FROM invoices WHERE id = ?1",
params![input.invoice_id],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()
.map_err(db)?
.ok_or_else(|| "Invoice not found".to_string())?;
if status != "issued" {
return Err(format!("Payments can only be recorded on an issued invoice (this one is {status})"));
}
let total_paise = rupees_to_paise(total);
let settled: i64 = tx
.query_row(
"SELECT COALESCE(SUM(amount_paise + tds_paise), 0) FROM payments WHERE invoice_id = ?1",
params![input.invoice_id],
|r| r.get(0),
)
.map_err(db)?;
let balance = total_paise - settled;
let incoming = input.amount_paise + input.tds_paise;
if incoming > balance {
return Err(format!(
"This payment of {} (cash plus TDS) is more than the outstanding balance of {}",
rupees(incoming),
rupees(balance.max(0))
));
}
let now = chrono::Utc::now().to_rfc3339();
tx.execute(
"INSERT INTO payments (invoice_id, paid_on, amount_paise, tds_paise, mode, reference, notes, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
input.invoice_id,
input.paid_on.trim(),
input.amount_paise,
input.tds_paise,
input.mode,
input.reference.trim(),
input.notes.trim(),
now
],
)
.map_err(db)?;
let id = tx.last_insert_rowid();
let payment = tx
.query_row(&format!("SELECT {COLS} FROM payments WHERE id = ?1"), params![id], map_payment)
.map_err(db)?;
tx.commit().map_err(db)?;
Ok(payment)
}
pub fn list_payments_impl(conn: &Connection, invoice_id: i64) -> Result<Vec<Payment>, String> {
let mut stmt = conn
.prepare(&format!(
"SELECT {COLS} FROM payments WHERE invoice_id = ?1 ORDER BY paid_on ASC, id ASC"
))
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map(params![invoice_id], map_payment)
.map_err(|e| e.to_string())?
.collect::<rusqlite::Result<Vec<_>>>()
.map_err(|e| e.to_string())?;
Ok(rows)
}
pub fn delete_payment_impl(conn: &Connection, id: i64) -> Result<(), String> {
let changed = conn
.execute("DELETE FROM payments WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
if changed == 0 {
return Err("Payment not found".into());
}
Ok(())
}
#[tauri::command]
pub fn record_payment(state: State<AppState>, input: PaymentInput) -> Result<Payment, String> {
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
record_payment_impl(&mut conn, input)
}
#[tauri::command]
pub fn list_payments(state: State<AppState>, invoice_id: i64) -> Result<Vec<Payment>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
list_payments_impl(&conn, invoice_id)
}
#[tauri::command]
pub fn delete_payment(state: State<AppState>, id: i64) -> Result<(), String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
delete_payment_impl(&conn, id)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::invoice::{cancel_invoice_impl, issue_invoice_impl};
use crate::models::InvoiceInput;
use serde_json::json;
use std::path::Path;
/// An unregistered vendor issues without tax, so the invoice total is exactly the line amount.
fn issued(conn: &mut Connection, amount: f64, due: &str) -> i64 {
let input: InvoiceInput = serde_json::from_value(json!({
"invoiceDate": "2026-04-01",
"dueDate": due,
"clientName": "Client Ltd",
"taxType": "none",
"taxRate": 0.0,
"items": [{"description": "Design", "mode": "fixed", "amount": amount}],
}))
.unwrap();
issue_invoice_impl(conn, Path::new("/nonexistent"), input, &json!({})).unwrap().id
}
fn pay(invoice_id: i64, amount: i64, tds: i64) -> PaymentInput {
PaymentInput {
invoice_id,
paid_on: "2026-04-10".into(),
amount_paise: amount,
tds_paise: tds,
mode: "bank_transfer".into(),
reference: "UTR1".into(),
notes: String::new(),
}
}
fn summary(conn: &Connection, id: i64) -> PaymentSummary {
let (status, total, due): (String, f64, String) = conn
.query_row("SELECT status, total, due_date FROM invoices WHERE id = ?1", params![id], |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?))
})
.unwrap();
summary_for(conn, id, &status, total, &due).unwrap()
}
#[test]
fn partial_then_full_payment_settles() {
let mut conn = crate::db::open_in_memory().unwrap();
let id = issued(&mut conn, 1000.0, "2999-01-01");
assert_eq!(summary(&conn, id).payment_status, "unpaid");
assert_eq!(summary(&conn, id).balance_paise, 100_000);
record_payment_impl(&mut conn, pay(id, 40_000, 0)).unwrap();
let s = summary(&conn, id);
assert_eq!((s.payment_status.as_str(), s.paid_paise, s.balance_paise), ("partially_paid", 40_000, 60_000));
record_payment_impl(&mut conn, pay(id, 60_000, 0)).unwrap();
let s = summary(&conn, id);
assert_eq!((s.payment_status.as_str(), s.balance_paise), ("paid", 0));
}
#[test]
fn tds_counts_towards_settling() {
let mut conn = crate::db::open_in_memory().unwrap();
let id = issued(&mut conn, 1000.0, "2999-01-01");
// 10% TDS: the client pays 900.00 in cash and deducts 100.00.
record_payment_impl(&mut conn, pay(id, 90_000, 10_000)).unwrap();
let s = summary(&conn, id);
assert_eq!((s.payment_status.as_str(), s.paid_paise, s.tds_paise, s.balance_paise), ("paid", 90_000, 10_000, 0));
// TDS alone is a valid payment too.
let id2 = issued(&mut conn, 500.0, "2999-01-01");
record_payment_impl(&mut conn, pay(id2, 0, 5_000)).unwrap();
assert_eq!(summary(&conn, id2).payment_status, "partially_paid");
}
#[test]
fn overpayment_is_rejected_including_tds() {
let mut conn = crate::db::open_in_memory().unwrap();
let id = issued(&mut conn, 1000.0, "2999-01-01");
let err = record_payment_impl(&mut conn, pay(id, 100_001, 0)).unwrap_err();
assert!(err.contains("more than the outstanding balance of ₹1000.00"), "{err}");
// Cash plus TDS is what counts.
assert!(record_payment_impl(&mut conn, pay(id, 95_000, 6_000)).is_err());
record_payment_impl(&mut conn, pay(id, 50_000, 0)).unwrap();
let err = record_payment_impl(&mut conn, pay(id, 50_001, 0)).unwrap_err();
assert!(err.contains("₹500.00"), "{err}");
let n: i64 = conn.query_row("SELECT COUNT(*) FROM payments", [], |r| r.get(0)).unwrap();
assert_eq!(n, 1);
}
#[test]
fn invalid_input_is_rejected() {
let mut conn = crate::db::open_in_memory().unwrap();
let id = issued(&mut conn, 1000.0, "2999-01-01");
assert!(record_payment_impl(&mut conn, pay(id, 0, 0)).is_err());
assert!(record_payment_impl(&mut conn, pay(id, -1, 0)).is_err());
assert!(record_payment_impl(&mut conn, pay(id, 100, -5)).is_err());
let mut bad = pay(id, 100, 0);
bad.mode = "barter".into();
assert!(record_payment_impl(&mut conn, bad).is_err());
let mut bad = pay(id, 100, 0);
bad.paid_on = "10/04/2026".into();
assert!(record_payment_impl(&mut conn, bad).is_err());
assert!(record_payment_impl(&mut conn, pay(9999, 100, 0)).is_err());
// The table CHECKs hold even if a caller bypasses the command validation.
assert!(conn
.execute(
"INSERT INTO payments (invoice_id, paid_on, amount_paise, tds_paise, mode, created_at)
VALUES (?1, '2026-04-10', 0, 0, 'cash', 'now')",
params![id]
)
.is_err());
}
#[test]
fn draft_and_cancelled_invoices_take_no_payments() {
let mut conn = crate::db::open_in_memory().unwrap();
let id = issued(&mut conn, 1000.0, "2999-01-01");
cancel_invoice_impl(&mut conn, id, "mistake").unwrap();
let err = record_payment_impl(&mut conn, pay(id, 100, 0)).unwrap_err();
assert!(err.contains("issued invoice"), "{err}");
let s = summary(&conn, id);
assert_eq!((s.payment_status.as_str(), s.balance_paise), ("none", 0));
let id2 = issued(&mut conn, 1000.0, "2999-01-01");
conn.execute("UPDATE invoices SET status = 'draft' WHERE id = ?1", params![id2]).unwrap();
assert!(record_payment_impl(&mut conn, pay(id2, 100, 0)).is_err());
}
#[test]
fn an_invoice_with_payments_cannot_be_cancelled() {
let mut conn = crate::db::open_in_memory().unwrap();
let id = issued(&mut conn, 1000.0, "2999-01-01");
let p = record_payment_impl(&mut conn, pay(id, 100, 0)).unwrap();
let err = cancel_invoice_impl(&mut conn, id, "").unwrap_err();
assert!(err.contains("payments"), "{err}");
delete_payment_impl(&conn, p.id).unwrap();
cancel_invoice_impl(&mut conn, id, "").unwrap();
}
#[test]
fn deleting_a_payment_reverts_the_status() {
let mut conn = crate::db::open_in_memory().unwrap();
let id = issued(&mut conn, 1000.0, "2999-01-01");
let full = record_payment_impl(&mut conn, pay(id, 100_000, 0)).unwrap();
assert_eq!(summary(&conn, id).payment_status, "paid");
delete_payment_impl(&conn, full.id).unwrap();
let s = summary(&conn, id);
assert_eq!((s.payment_status.as_str(), s.balance_paise), ("unpaid", 100_000));
assert!(list_payments_impl(&conn, id).unwrap().is_empty());
assert!(delete_payment_impl(&conn, full.id).is_err());
}
#[test]
fn overdue_is_derived_from_due_date_and_balance() {
let d = |due: &str, paid: i64, tds: i64| derive_summary("issued", 100_000, paid, tds, due, "2026-05-10");
// Due today is not overdue yet; the day after is.
assert_eq!(d("2026-05-10", 0, 0).payment_status, "unpaid");
assert_eq!(d("2026-05-09", 0, 0).payment_status, "overdue");
// Overdue wins over partially paid; the paid amount is still reported.
let s = d("2026-05-09", 10_000, 0);
assert_eq!((s.payment_status.as_str(), s.paid_paise), ("overdue", 10_000));
// A settled invoice is never overdue.
assert_eq!(d("2026-05-09", 90_000, 10_000).payment_status, "paid");
// No usable due date means never overdue.
assert_eq!(d("", 0, 0).payment_status, "unpaid");
assert_eq!(d("not a date", 0, 0).payment_status, "unpaid");
// Cancelled invoices are not owed.
assert_eq!(derive_summary("cancelled", 100_000, 0, 0, "2020-01-01", "2026-05-10").balance_paise, 0);
}
#[test]
fn totals_are_integer_paise_without_float_drift() {
let mut conn = crate::db::open_in_memory().unwrap();
// 0.1 + 0.2 style amounts: 3 x 33.33 = 99.99.
let id = issued(&mut conn, 99.99, "2999-01-01");
record_payment_impl(&mut conn, pay(id, 3333, 0)).unwrap();
record_payment_impl(&mut conn, pay(id, 3333, 0)).unwrap();
record_payment_impl(&mut conn, pay(id, 3333, 0)).unwrap();
let s = summary(&conn, id);
assert_eq!((s.paid_paise, s.balance_paise, s.payment_status.as_str()), (9999, 0, "paid"));
let inv = super::super::invoice::get_invoice_impl(&conn, id).unwrap();
assert_eq!(inv.total_paise, 9999);
}
#[test]
fn list_invoices_joins_the_payment_sums() {
let mut conn = crate::db::open_in_memory().unwrap();
let a = issued(&mut conn, 1000.0, "2020-01-01");
let b = issued(&mut conn, 200.0, "2999-01-01");
record_payment_impl(&mut conn, pay(a, 30_000, 5_000)).unwrap();
record_payment_impl(&mut conn, pay(a, 10_000, 0)).unwrap();
record_payment_impl(&mut conn, pay(b, 20_000, 0)).unwrap();
let rows = super::super::invoice::list_invoices_impl(&conn).unwrap();
let get = |id: i64| rows.iter().find(|r| r.id == id).unwrap();
let ra = get(a);
assert_eq!((ra.payment.paid_paise, ra.payment.tds_paise, ra.payment.balance_paise), (40_000, 5_000, 55_000));
assert_eq!(ra.payment.payment_status, "overdue");
assert_eq!(ra.total_paise, 100_000);
assert_eq!(get(b).payment.payment_status, "paid");
// The JSON keeps the existing fields and adds the flattened summary.
let json = serde_json::to_value(ra).unwrap();
assert_eq!(json["balancePaise"], 55_000);
assert_eq!(json["paymentStatus"], "overdue");
assert_eq!(json["total"], 1000.0);
}
}
+31 -3
View File
@@ -10,7 +10,7 @@ const MAX_BACKUPS: usize = 10;
const BACKUP_PREFIX: &str = "voiced-pre-v";
/// Highest schema version, i.e. the number of entries in `migrations()`.
const LATEST_VERSION: i64 = 7;
const LATEST_VERSION: i64 = 8;
#[derive(Debug, thiserror::Error)]
pub enum DbError {
@@ -245,8 +245,36 @@ CREATE TABLE item_presets (
);
"#;
/// Version 8: payments received against issued invoices. Amounts are integer paise. `amount_paise` is the cash
/// received and `tds_paise` the tax the client deducted at source; both count towards settling the invoice.
/// Payment status is derived from these rows and the due date, never stored, and the issued invoice row is untouched.
const M8: &str = r#"
CREATE TABLE payments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
invoice_id INTEGER NOT NULL REFERENCES invoices(id),
paid_on TEXT NOT NULL,
amount_paise INTEGER NOT NULL DEFAULT 0 CHECK (amount_paise >= 0),
tds_paise INTEGER NOT NULL DEFAULT 0 CHECK (tds_paise >= 0),
mode TEXT NOT NULL CHECK (mode IN ('bank_transfer', 'upi', 'cash', 'cheque', 'other')),
reference TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
CHECK (amount_paise + tds_paise > 0)
);
CREATE INDEX idx_payments_invoice ON payments(invoice_id);
"#;
fn migrations() -> Migrations<'static> {
Migrations::new(vec![M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4), M::up(M5), M::up(M6), M::up(M7)])
Migrations::new(vec![
M::up(SCHEMA),
M::up(M2),
M::up(M3),
M::up(M4),
M::up(M5),
M::up(M6),
M::up(M7),
M::up(M8),
])
}
/// Open (creating if needed) the database at `path` and bring it to the latest schema.
@@ -725,7 +753,7 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC);
.unwrap();
migrations().to_latest(&mut conn).unwrap();
assert_eq!(user_version(&conn), 7);
assert_eq!(user_version(&conn), LATEST_VERSION);
type Row = (String, String, String, String, String, String, String);
let rows: Vec<Row> = conn
+3
View File
@@ -133,6 +133,9 @@ pub fn run() {
commands::invoice::list_drafts,
commands::invoice::get_draft,
commands::invoice::delete_draft,
commands::payments::record_payment,
commands::payments::list_payments,
commands::payments::delete_payment,
commands::assets::import_asset,
commands::assets::save_asset_bytes,
commands::assets::read_asset_data_uri,
+32
View File
@@ -265,6 +265,34 @@ pub struct Invoice {
pub archived_fingerprint: Option<String>,
pub archived_at: Option<String>,
pub items: Vec<InvoiceItem>,
/// Derived from the payments table (never stored on the invoice): see commands::payments.
#[serde(flatten)]
pub payment: PaymentSummary,
/// `total` in integer paise.
#[serde(default)]
pub total_paise: i64,
}
/// What has been received against an invoice. All amounts are integer paise; `payment_status` is
/// unpaid | partially_paid | paid | overdue, or `none` for an invoice that takes no payments.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PaymentSummary {
pub paid_paise: i64,
pub tds_paise: i64,
pub balance_paise: i64,
pub payment_status: String,
}
impl Default for PaymentSummary {
fn default() -> Self {
PaymentSummary {
paid_paise: 0,
tds_paise: 0,
balance_paise: 0,
payment_status: "none".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -278,6 +306,10 @@ pub struct InvoiceSummary {
pub total: f64,
pub status: String,
pub created_at: String,
pub po_number: String,
pub total_paise: i64,
#[serde(flatten)]
pub payment: PaymentSummary,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
+26 -1
View File
@@ -6,6 +6,7 @@ import { AppShell, type AppView } from "./components/AppShell";
import Onboarding from "./views/Onboarding";
import NewInvoice from "./views/NewInvoice";
import InvoiceHistory from "./views/InvoiceHistory";
import InvoiceDetail from "./views/InvoiceDetail";
import Clients from "./views/Clients";
import { ToastProvider, useToast } from "./components/ToastProvider";
import AppSettings from "./views/AppSettings";
@@ -24,6 +25,9 @@ function AppInner() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [view, setView] = useState<AppView>("new");
const [detailId, setDetailId] = useState<number | null>(null);
// A draft made by "Duplicate" that the New invoice form should open once it is shown.
const [openDraftId, setOpenDraftId] = useState<number | null>(null);
const refreshSettings = useCallback(async () => {
try {
@@ -104,10 +108,31 @@ function AppInner() {
onSettingsChange={refreshSettings}
active={view === "new"}
onActivate={() => setView("new")}
openDraftId={openDraftId}
onDraftOpened={() => setOpenDraftId(null)}
/>
<div hidden={view !== "invoices"}>
<InvoiceHistory settings={settings} active={view === "invoices"} />
<InvoiceHistory
settings={settings}
active={view === "invoices"}
onOpen={(id) => {
setDetailId(id);
setView("invoice");
}}
/>
</div>
{view === "invoice" && detailId !== null ? (
<InvoiceDetail
key={detailId}
invoiceId={detailId}
settings={settings}
onBack={() => setView("invoices")}
onDuplicated={(draftId) => {
setOpenDraftId(draftId);
setView("new");
}}
/>
) : null}
<div hidden={view !== "clients"}>
<Clients active={view === "clients"} />
</div>
+4 -3
View File
@@ -11,9 +11,10 @@ import {
import { Asleep, Light } from "@carbon/icons-react";
import type { ReactNode } from "react";
export type AppView = "new" | "invoices" | "clients" | "settings";
/** "invoice" is the detail page of one invoice; it is reached from "invoices" and highlights that tab. */
export type AppView = "new" | "invoices" | "invoice" | "clients" | "settings";
const NAV: { id: AppView; label: string }[] = [
const NAV: { id: Exclude<AppView, "invoice">; label: string }[] = [
{ id: "new", label: "New invoice" },
{ id: "invoices", label: "Invoices" },
{ id: "clients", label: "Clients" },
@@ -49,7 +50,7 @@ export function AppShell({ view, onNavigate, theme, onToggleTheme, children }: A
{NAV.map((item) => (
<HeaderMenuItem
key={item.id}
isCurrentPage={view === item.id}
isCurrentPage={view === item.id || (view === "invoice" && item.id === "invoices")}
onClick={(event) => {
event.preventDefault();
onNavigate(item.id);
+127
View File
@@ -0,0 +1,127 @@
import { useEffect, useState } from "react";
import { InlineNotification, Modal, Select, SelectItem, TextInput } from "@carbon/react";
import { api } from "../lib/api";
import { todayIst } from "../lib/fiscal";
import { formatPaise, paiseToInput, parseRupeesToPaise } from "../lib/money";
import { PAYMENT_MODES, type Payment, type PaymentMode } from "../lib/payments";
import { useReturnFocus } from "../hooks/useReturnFocus";
interface Props {
open: boolean;
invoiceId: number;
invoiceNumber: string;
/** What is still owed, in paise. */
balancePaise: number;
onClose: () => void;
onRecorded: (payment: Payment) => void;
}
/** "Record payment": cash received plus TDS deducted, validated against the balance before it is sent. */
export default function RecordPaymentModal({ open, invoiceId, invoiceNumber, balancePaise, onClose, onRecorded }: Props) {
const [paidOn, setPaidOn] = useState(todayIst());
const [amount, setAmount] = useState("");
const [tds, setTds] = useState("");
const [mode, setMode] = useState<PaymentMode>("bank_transfer");
const [reference, setReference] = useState("");
const [notes, setNotes] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
useReturnFocus(open);
// Each opening starts from the balance, so the common case (paid in full) is one click.
useEffect(() => {
if (!open) return;
setPaidOn(todayIst());
setAmount(paiseToInput(balancePaise));
setTds("");
setMode("bank_transfer");
setReference("");
setNotes("");
setError(null);
setBusy(false);
}, [open, balancePaise]);
const amountPaise = parseRupeesToPaise(amount);
const tdsPaise = parseRupeesToPaise(tds);
const amountInvalid = amountPaise === null;
const tdsInvalid = tdsPaise === null;
const total = (amountPaise ?? 0) + (tdsPaise ?? 0);
const overBalance = !amountInvalid && !tdsInvalid && total > balancePaise;
const empty = !amountInvalid && !tdsInvalid && total === 0;
const dateInvalid = !/^\d{4}-\d{2}-\d{2}$/.test(paidOn);
const canSubmit = !busy && !amountInvalid && !tdsInvalid && !overBalance && !empty && !dateInvalid;
const submit = async () => {
if (!canSubmit || amountPaise === null || tdsPaise === null) return;
setBusy(true);
setError(null);
try {
const payment = await api.recordPayment({ invoiceId, paidOn, amountPaise, tdsPaise, mode, reference, notes });
onRecorded(payment);
} catch (e) {
setError(String(e));
setBusy(false);
}
};
return (
<Modal
open={open}
modalHeading={`Record payment for ${invoiceNumber}`}
primaryButtonText={busy ? "Saving…" : "Record payment"}
secondaryButtonText="Cancel"
primaryButtonDisabled={!canSubmit}
onRequestClose={onClose}
onRequestSubmit={() => void submit()}
>
<div className="voiced-stack">
<p>
Balance due: <strong>{formatPaise(balancePaise)}</strong>
</p>
{error ? <InlineNotification kind="error" lowContrast hideCloseButton title="Could not record the payment" subtitle={error} /> : null}
<TextInput
id="payDate"
type="date"
labelText="Date received"
value={paidOn}
invalid={dateInvalid}
invalidText="Enter a date"
onChange={(e) => setPaidOn(e.target.value)}
/>
<TextInput
id="payAmount"
inputMode="decimal"
labelText="Amount received (₹)"
helperText="Cash that reached your account"
value={amount}
invalid={amountInvalid || overBalance || empty}
invalidText={
amountInvalid
? "Enter an amount like 1250.50"
: overBalance
? `Amount plus TDS is more than the balance of ${formatPaise(balancePaise)}`
: "Enter an amount received or TDS deducted"
}
onChange={(e) => setAmount(e.target.value)}
/>
<TextInput
id="payTds"
inputMode="decimal"
labelText="TDS deducted by client (₹)"
helperText="Counts towards settling the invoice; leave empty if none"
value={tds}
invalid={tdsInvalid}
invalidText="Enter an amount like 125.00"
onChange={(e) => setTds(e.target.value)}
/>
<Select id="payMode" labelText="Mode" value={mode} onChange={(e) => setMode(e.target.value as PaymentMode)}>
{PAYMENT_MODES.map((m) => (
<SelectItem key={m.id} value={m.id} text={m.label} />
))}
</Select>
<TextInput id="payRef" labelText="UTR / cheque number (optional)" value={reference} onChange={(e) => setReference(e.target.value)} />
<TextInput id="payNotes" labelText="Notes (optional)" value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
</Modal>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { useEffect, useRef } from "react";
/**
* Gives focus back to whatever had it when a modal opened, once the modal closes. Carbon's Modal only
* does this with a launcher ref, which does not suit modals opened from table rows or menus.
*/
export function useReturnFocus(open: boolean): void {
const origin = useRef<HTMLElement | null>(null);
const wasOpen = useRef(false);
useEffect(() => {
if (open && !wasOpen.current) {
origin.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
} else if (!open && wasOpen.current) {
const el = origin.current;
origin.current = null;
// After the modal has unmounted its focus trap.
if (el && el.isConnected) setTimeout(() => el.focus(), 0);
}
wasOpen.current = open;
}, [open]);
}
+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 = {
+130
View File
@@ -540,3 +540,133 @@ body {
margin-top: 0.5rem;
font-size: 0.75rem;
}
/* ---------- Invoice history and detail ---------- */
.voiced-num {
text-align: right;
}
.voiced-history__fy {
font-size: 1.25rem;
font-weight: 400;
margin-bottom: 0.5rem;
}
.voiced-history__tiles,
.voiced-detail__tiles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.voiced-history__amount,
.voiced-detail__amount {
font-size: 1.75rem;
font-weight: 400;
}
.voiced-history__tile--overdue {
border-left: 4px solid var(--cds-support-error);
}
.voiced-history__filters {
display: grid;
grid-template-columns: minmax(14rem, 2fr) repeat(4, minmax(9rem, 1fr)) auto;
gap: 1rem;
align-items: end;
margin-bottom: 1rem;
}
.voiced-history__row {
cursor: pointer;
}
.voiced-history__totals {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 2rem;
padding: 0.75rem 1rem;
background: var(--cds-layer-accent);
border-top: 1px solid var(--cds-border-subtle);
}
.voiced-detail__head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin-top: 1rem;
}
.voiced-detail__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin: 1rem 0;
}
.voiced-detail__grid {
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
gap: 2rem;
align-items: start;
}
@media (max-width: 1100px) {
.voiced-detail__grid,
.voiced-history__filters {
grid-template-columns: 1fr;
}
}
.voiced-detail__h2 {
font-size: 1.25rem;
font-weight: 400;
margin-bottom: 0.75rem;
}
.voiced-detail__parties {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
}
.voiced-detail__field {
margin-bottom: 0.75rem;
}
.voiced-detail__pre {
white-space: pre-line;
}
.voiced-detail__totals {
margin: 1rem 0 0.5rem auto;
max-width: 22rem;
}
.voiced-detail__totals > div {
display: flex;
justify-content: space-between;
padding: 0.25rem 0;
}
.voiced-detail__grand {
border-top: 1px solid var(--cds-border-strong);
font-weight: 600;
}
.voiced-detail__preview {
position: sticky;
top: 4rem;
}
.voiced-detail__unarchived {
padding: 1.5rem;
background: var(--cds-layer-accent);
}
.voiced-detail__unarchived p {
margin-bottom: 1rem;
}
+529
View File
@@ -0,0 +1,529 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
Button,
InlineLoading,
InlineNotification,
Modal,
OverflowMenu,
OverflowMenuItem,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
Tag,
TextInput,
Tile,
} from "@carbon/react";
import { ArrowLeft, TrashCan } from "@carbon/icons-react";
import { api, type ArchiveStatus } from "../lib/api";
import { formatAmount, formatDate } from "../lib/format";
import { formatPaise } from "../lib/money";
import { paymentModeLabel, statusBadge, type Payment } from "../lib/payments";
import { createExportDeps } from "../lib/exportDeps";
import { renderAndArchive, type ExportSource } from "../lib/exportFlow";
import { toDraftPayload } from "../lib/draft";
import { invoiceToFormState } from "../lib/duplicate";
import type { ExportMode } from "../lib/filename";
import { useInvoiceExport } from "../hooks/useInvoiceExport";
import { useReturnFocus } from "../hooks/useReturnFocus";
import ExportFeedback from "../components/ExportFeedback";
import PdfPreview from "../components/PdfPreview";
import RecordPaymentModal from "../components/RecordPaymentModal";
import { useToast } from "../components/ToastProvider";
import { INDIAN_STATES, type Invoice, type Settings } from "../lib/types";
interface Props {
invoiceId: number;
settings: Settings;
onBack: () => void;
/** A draft was created from this invoice; the shell opens it in the New invoice form. */
onDuplicated: (draftId: number) => void;
}
interface Frozen {
vendorName?: string;
vendorAddress?: string;
vendorGstin?: string;
vendorPan?: string;
vendorEmail?: string;
vendorPhone?: string;
}
function parseSnapshot(text: string): Frozen {
try {
return text ? (JSON.parse(text) as Frozen) : {};
} catch {
return {};
}
}
const stateName = (code: string) => {
const s = INDIAN_STATES.find((x) => x.code === code);
return s ? `${code} - ${s.name}` : code;
};
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="voiced-detail__field">
<dt className="voiced-muted">{label}</dt>
<dd>{children || "—"}</dd>
</div>
);
}
export default function InvoiceDetail({ invoiceId, settings, onBack, onDuplicated }: Props) {
const toast = useToast();
const [invoice, setInvoice] = useState<Invoice | null>(null);
const [payments, setPayments] = useState<Payment[]>([]);
const [loadError, setLoadError] = useState<string | null>(null);
const [archive, setArchive] = useState<ArchiveStatus | null>(null);
const [pdf, setPdf] = useState<Uint8Array | null>(null);
const [pdfError, setPdfError] = useState<string | null>(null);
const [archiving, setArchiving] = useState(false);
const [recordOpen, setRecordOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Payment | null>(null);
const [cancelOpen, setCancelOpen] = useState(false);
const [reason, setReason] = useState("");
const [duplicating, setDuplicating] = useState(false);
useReturnFocus(deleteTarget !== null);
useReturnFocus(cancelOpen);
// Guards against a slow load for a previous id landing after the id changed.
const generation = useRef(0);
const loadPdf = useCallback(async (id: number, gen: number) => {
setPdfError(null);
try {
const status = await api.archiveStatus(id);
if (gen !== generation.current) return;
setArchive(status);
if (status.archived) {
const bytes = await api.readArchive(id);
if (gen === generation.current) setPdf(bytes);
} else {
setPdf(null);
}
} catch (e) {
if (gen === generation.current) setPdfError(String(e));
}
}, []);
const loadData = useCallback(async (id: number, gen: number) => {
try {
const [inv, pays] = await Promise.all([api.getInvoice(id), api.listPayments(id)]);
if (gen !== generation.current) return;
setInvoice(inv);
setPayments(pays);
setLoadError(null);
} catch (e) {
if (gen === generation.current) setLoadError(String(e));
}
}, []);
useEffect(() => {
const gen = ++generation.current;
setInvoice(null);
setPdf(null);
setArchive(null);
void loadData(invoiceId, gen);
void loadPdf(invoiceId, gen);
}, [invoiceId, loadData, loadPdf]);
const refresh = () => loadData(invoiceId, generation.current);
const exp = useInvoiceExport({
settings,
bank: null,
// An export that had to archive the original first: show it in the preview.
onInvoiceUpdated: () => void loadPdf(invoiceId, generation.current),
});
const busy = exp.busy || archiving || duplicating;
const onExport = (mode: ExportMode, source: ExportSource) => {
if (!invoice) return;
void exp.start(invoice, mode, source).catch((e) => toast.error("Export failed", String(e)));
};
const onArchive = async () => {
if (!invoice) return;
setArchiving(true);
try {
const outcome = await renderAndArchive(invoice, createExportDeps({ settings, bank: null }));
if (outcome.archived) {
toast.success(`Invoice ${invoice.number} is archived`);
await Promise.all([loadPdf(invoiceId, generation.current), refresh()]);
} else {
toast.error("Could not archive the PDF", outcome.error ?? "The PDF could not be archived.");
}
} catch (e) {
toast.error("Could not archive the PDF", String(e));
} finally {
setArchiving(false);
}
};
const onDuplicate = async () => {
if (!invoice) return;
setDuplicating(true);
try {
const id = await api.saveDraft(null, toDraftPayload(invoiceToFormState(invoice, settings)));
toast.success("Draft created from this invoice", "Review it, then issue it as a new invoice.");
onDuplicated(id);
} catch (e) {
toast.error("Could not duplicate the invoice", String(e));
} finally {
setDuplicating(false);
}
};
const onCancelInvoice = async () => {
if (!invoice) return;
try {
await api.cancelInvoice(invoice.id, reason);
toast.success(`Invoice ${invoice.number} cancelled`);
setCancelOpen(false);
setReason("");
await refresh();
} catch (e) {
setCancelOpen(false);
toast.error("Could not cancel the invoice", String(e));
}
};
const onDeletePayment = async () => {
if (!deleteTarget) return;
const target = deleteTarget;
setDeleteTarget(null);
try {
await api.deletePayment(target.id);
toast.success("Payment deleted", `${formatPaise(target.amountPaise + target.tdsPaise)} on ${formatDate(target.paidOn)}`);
await refresh();
} catch (e) {
toast.error("Could not delete the payment", String(e));
}
};
if (loadError) {
return (
<div className="voiced-page">
<Button kind="ghost" size="sm" renderIcon={ArrowLeft} onClick={onBack}>
Back to invoices
</Button>
<InlineNotification kind="error" lowContrast hideCloseButton title="Could not load the invoice" subtitle={loadError} />
</div>
);
}
if (!invoice) {
return (
<div className="voiced-page">
<InlineLoading description="Loading invoice" />
</div>
);
}
const badge = statusBadge(invoice.status, invoice.paymentStatus);
const vendor = parseSnapshot(invoice.vendorSnapshot);
const total = invoice.totalPaise ?? Math.round(invoice.total * 100);
const balance = invoice.balancePaise ?? 0;
const canPay = invoice.status === "issued" && balance > 0;
const half = invoice.taxRate / 2;
return (
<div className="voiced-page voiced-detail">
<Button kind="ghost" size="sm" renderIcon={ArrowLeft} onClick={onBack}>
Back to invoices
</Button>
<div className="voiced-detail__head">
<h1 className="voiced-page__title">{invoice.number}</h1>
<Tag type={badge.type}>{badge.label}</Tag>
{invoice.status === "issued" && invoice.paymentStatus === "overdue" && (invoice.paidPaise ?? 0) + (invoice.tdsPaise ?? 0) > 0 ? (
<Tag type="blue" size="sm">
Partly paid
</Tag>
) : null}
{archive && !archive.archived ? (
<Tag type="warm-gray" size="sm">
Not archived
</Tag>
) : null}
</div>
<p className="voiced-page__subtitle">
{invoice.clientName || "No client name"} · issued {formatDate(invoice.invoiceDate)}
{invoice.dueDate ? ` · due ${formatDate(invoice.dueDate)}` : ""}
</p>
{invoice.status === "cancelled" ? (
<InlineNotification
kind="warning"
lowContrast
hideCloseButton
title="This invoice is cancelled"
subtitle={invoice.cancelReason ? `Reason: ${invoice.cancelReason}` : "Its number is not reused."}
/>
) : null}
<div className="voiced-detail__actions" role="toolbar" aria-label="Invoice actions">
{canPay ? (
<Button size="md" onClick={() => setRecordOpen(true)} disabled={busy}>
Record payment
</Button>
) : null}
<Button kind="tertiary" size="md" onClick={() => onExport("searchable", "original")} disabled={busy}>
Export PDF
</Button>
<Button kind="tertiary" size="md" onClick={() => onExport("flattened", "original")} disabled={busy}>
Export flattened
</Button>
<Button kind="tertiary" size="md" onClick={() => void onDuplicate()} disabled={busy}>
{duplicating ? "Duplicating…" : "Duplicate"}
</Button>
<OverflowMenu aria-label="More actions" iconDescription="More actions" flipped size="md" disabled={busy}>
<OverflowMenuItem itemText="Re-render with current template" onClick={() => onExport("searchable", "rerender")} />
{invoice.status === "issued" ? (
<OverflowMenuItem
itemText={payments.length > 0 ? "Cancel invoice (delete payments first)" : "Cancel invoice"}
hasDivider
isDelete
disabled={payments.length > 0}
onClick={() => {
setReason("");
setCancelOpen(true);
}}
/>
) : null}
</OverflowMenu>
</div>
<ExportFeedback exp={exp} />
<div className="voiced-detail__grid">
<div className="voiced-detail__main voiced-stack">
<div className="voiced-detail__tiles">
<Tile>
<p className="voiced-muted">Total</p>
<p className="voiced-detail__amount">{formatPaise(total)}</p>
</Tile>
<Tile>
<p className="voiced-muted">Received</p>
<p className="voiced-detail__amount">{formatPaise(invoice.paidPaise ?? 0)}</p>
</Tile>
<Tile>
<p className="voiced-muted">TDS deducted</p>
<p className="voiced-detail__amount">{formatPaise(invoice.tdsPaise ?? 0)}</p>
</Tile>
<Tile>
<p className="voiced-muted">Balance</p>
<p className="voiced-detail__amount">{formatPaise(balance)}</p>
</Tile>
</div>
<section aria-labelledby="detail-parties">
<h2 id="detail-parties" className="voiced-detail__h2">
Parties
</h2>
<div className="voiced-detail__parties">
<dl>
<Field label="Billed to">{invoice.clientName}</Field>
<Field label="Address">
<span className="voiced-detail__pre">{invoice.clientAddress}</span>
</Field>
<Field label="GSTIN">{invoice.clientGstin}</Field>
<Field label="PO number">{invoice.poNumber}</Field>
<Field label="Place of supply">{invoice.placeOfSupplyStateCode ? stateName(invoice.placeOfSupplyStateCode) : ""}</Field>
</dl>
<dl>
<Field label="Issued by">{vendor.vendorName}</Field>
<Field label="Address">
<span className="voiced-detail__pre">{vendor.vendorAddress}</span>
</Field>
<Field label="GSTIN">{vendor.vendorGstin}</Field>
<Field label="PAN">{vendor.vendorPan}</Field>
</dl>
</div>
</section>
<section aria-labelledby="detail-items">
<h2 id="detail-items" className="voiced-detail__h2">
Items
</h2>
<TableContainer>
<Table aria-label="Invoice items" size="sm">
<TableHead>
<TableRow>
<TableHeader>Description</TableHeader>
<TableHeader>HSN/SAC</TableHeader>
<TableHeader className="voiced-num">Amount</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{invoice.items.map((item, i) => (
<TableRow key={item.id ?? i}>
<TableCell>
<span className="voiced-detail__pre">{item.description}</span>
{item.mode === "rate" ? (
<div className="voiced-muted">
{item.quantity} {item.unit} × ₹{formatAmount(item.rate)}
</div>
) : null}
</TableCell>
<TableCell>{item.hsnSac || "—"}</TableCell>
<TableCell className="voiced-num">₹{formatAmount(item.amount)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<dl className="voiced-detail__totals">
<div>
<dt>Subtotal</dt>
<dd>₹{formatAmount(invoice.subtotal)}</dd>
</div>
{invoice.discount > 0 ? (
<div>
<dt>Discount</dt>
<dd>−₹{formatAmount(invoice.discount)}</dd>
</div>
) : null}
{invoice.taxType === "cgst_sgst" ? (
<>
<div>
<dt>CGST @ {half}%</dt>
<dd>₹{formatAmount(invoice.cgstAmount)}</dd>
</div>
<div>
<dt>SGST @ {half}%</dt>
<dd>₹{formatAmount(invoice.sgstAmount)}</dd>
</div>
</>
) : null}
{invoice.taxType === "igst" ? (
<div>
<dt>IGST @ {invoice.taxRate}%</dt>
<dd>₹{formatAmount(invoice.igstAmount)}</dd>
</div>
) : null}
<div className="voiced-detail__grand">
<dt>Total</dt>
<dd>₹{formatAmount(invoice.total)}</dd>
</div>
</dl>
{invoice.amountInWords ? <p className="voiced-muted">{invoice.amountInWords}</p> : null}
{invoice.notes ? <p className="voiced-detail__pre voiced-muted">{invoice.notes}</p> : null}
</section>
<section aria-labelledby="detail-payments">
<h2 id="detail-payments" className="voiced-detail__h2">
Payments
</h2>
{payments.length === 0 ? (
<p className="voiced-muted">No payments recorded.</p>
) : (
<TableContainer>
<Table aria-label="Payments" size="sm">
<TableHead>
<TableRow>
<TableHeader>Date</TableHeader>
<TableHeader>Mode</TableHeader>
<TableHeader>Reference</TableHeader>
<TableHeader className="voiced-num">Received</TableHeader>
<TableHeader className="voiced-num">TDS</TableHeader>
<TableHeader>
<span className="cds--assistive-text">Actions</span>
</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{payments.map((p) => (
<TableRow key={p.id}>
<TableCell>{formatDate(p.paidOn)}</TableCell>
<TableCell>{paymentModeLabel(p.mode)}</TableCell>
<TableCell>
{p.reference || "—"}
{p.notes ? <div className="voiced-muted">{p.notes}</div> : null}
</TableCell>
<TableCell className="voiced-num">{formatPaise(p.amountPaise)}</TableCell>
<TableCell className="voiced-num">{formatPaise(p.tdsPaise)}</TableCell>
<TableCell>
<Button
kind="ghost"
size="sm"
hasIconOnly
renderIcon={TrashCan}
iconDescription={`Delete payment of ${formatPaise(p.amountPaise + p.tdsPaise)} on ${formatDate(p.paidOn)}`}
tooltipPosition="left"
onClick={() => setDeleteTarget(p)}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</section>
</div>
<aside className="voiced-detail__preview" aria-label="Archived PDF">
<h2 className="voiced-detail__h2">PDF as issued</h2>
{archive && !archive.archived ? (
<div className="voiced-detail__unarchived">
<p>Issued – not archived. The original PDF was never saved, so there is nothing to preview yet.</p>
<Button kind="primary" size="sm" onClick={() => void onArchive()} disabled={busy}>
{archiving ? "Working…" : "Render & archive"}
</Button>
</div>
) : (
<PdfPreview bytes={pdf} pages={0} issues={[]} rendering={archive === null && !pdfError} mode="worker" error={pdfError} />
)}
</aside>
</div>
<RecordPaymentModal
open={recordOpen}
invoiceId={invoice.id}
invoiceNumber={invoice.number}
balancePaise={balance}
onClose={() => setRecordOpen(false)}
onRecorded={(p) => {
setRecordOpen(false);
toast.success("Payment recorded", `${formatPaise(p.amountPaise + p.tdsPaise)} on ${formatDate(p.paidOn)}`);
void refresh();
}}
/>
<Modal
open={deleteTarget !== null}
danger
modalHeading="Delete this payment?"
primaryButtonText="Delete payment"
secondaryButtonText="Keep payment"
onRequestClose={() => setDeleteTarget(null)}
onRequestSubmit={() => void onDeletePayment()}
>
<p>
{deleteTarget
? `${formatPaise(deleteTarget.amountPaise + deleteTarget.tdsPaise)} (cash ${formatPaise(deleteTarget.amountPaise)}, TDS ${formatPaise(deleteTarget.tdsPaise)}) received on ${formatDate(deleteTarget.paidOn)}. The invoice balance goes back up by that amount.`
: ""}
</p>
</Modal>
<Modal
open={cancelOpen}
danger
modalHeading={`Cancel invoice ${invoice.number}?`}
primaryButtonText="Cancel invoice"
secondaryButtonText="Keep invoice"
onRequestClose={() => setCancelOpen(false)}
onRequestSubmit={() => void onCancelInvoice()}
>
<p style={{ marginBottom: "1rem" }}>The invoice stays in the list as cancelled and its number is not reused.</p>
<TextInput id="detailCancelReason" labelText="Reason (optional)" value={reason} onChange={(e) => setReason(e.target.value)} />
</Modal>
</div>
);
}
+227 -130
View File
@@ -1,10 +1,14 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import {
Button,
InlineLoading,
Modal,
Link,
OverflowMenu,
OverflowMenuItem,
Pagination,
Search,
Select,
SelectItem,
Table,
TableBody,
TableCell,
@@ -13,32 +17,62 @@ import {
TableHeader,
TableRow,
Tag,
TextInput,
Tile,
} from "@carbon/react";
import { api } from "../lib/api";
import { formatAmount, formatDate } from "../lib/format";
import { createExportDeps } from "../lib/exportDeps";
import { renderAndArchive, type ExportSource } from "../lib/exportFlow";
import { formatDate } from "../lib/format";
import { formatPaise } from "../lib/money";
import { statusBadge } from "../lib/payments";
import { currentFyStartYear, FY_MONTHS, fyLabel } from "../lib/fiscal";
import {
clientOptions,
filterInvoices,
fyOptions,
fySummary,
NO_FILTERS,
sortInvoices,
STATUS_FILTERS,
totalsOf,
type HistoryFilters,
type SortKey,
type SortSpec,
type StatusFilter,
} from "../lib/historyFilter";
import type { ExportMode } from "../lib/filename";
import type { ExportSource } from "../lib/exportFlow";
import { useInvoiceExport } from "../hooks/useInvoiceExport";
import ExportFeedback from "../components/ExportFeedback";
import type { InvoiceSummary, Settings } from "../lib/types";
import { useToast } from "../components/ToastProvider";
import type { InvoiceSummary, Settings } from "../lib/types";
export default function InvoiceHistory({ settings, active }: { settings: Settings; active: boolean }) {
interface Props {
settings: Settings;
active: boolean;
/** Opens the detail page of an invoice. */
onOpen: (id: number) => void;
}
const PAGE_SIZES = [25, 50, 100];
const COLUMNS: { key: SortKey; label: string; numeric?: boolean }[] = [
{ key: "number", label: "Number" },
{ key: "date", label: "Date" },
{ key: "client", label: "Client" },
{ key: "total", label: "Total", numeric: true },
{ key: "balance", label: "Balance", numeric: true },
{ key: "status", label: "Status" },
];
export default function InvoiceHistory({ settings, active, onOpen }: Props) {
const toast = useToast();
const [rows, setRows] = useState<InvoiceSummary[]>([]);
const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState<number | null>(null);
const [cancelTarget, setCancelTarget] = useState<InvoiceSummary | null>(null);
const [reason, setReason] = useState("");
// Whether each invoice has an archived PDF; unknown (absent) until its status was read.
const [archived, setArchived] = useState<Record<number, boolean>>({});
const exp = useInvoiceExport({
settings,
bank: null,
onInvoiceUpdated: (inv) => setArchived((a) => ({ ...a, [inv.id]: Boolean(inv.archivedPdfSha256) })),
});
const [filters, setFilters] = useState<HistoryFilters>(NO_FILTERS);
const [sort, setSort] = useState<SortSpec>({ key: "date", dir: "desc" });
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(PAGE_SIZES[0]);
const exp = useInvoiceExport({ settings, bank: null });
const load = async () => {
try {
@@ -50,29 +84,31 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
}
};
// The view stays mounted, so refetch every time it is shown again.
// The view stays mounted, so refetch every time it is shown again (a payment may have been recorded).
useEffect(() => {
if (active) void load();
}, [active]);
// Read each invoice's archive status in the background; it is a local call per row.
useEffect(() => {
let stop = false;
void (async () => {
for (const row of rows) {
if (stop) return;
try {
const status = await api.archiveStatus(row.id);
if (!stop) setArchived((a) => ({ ...a, [row.id]: status.archived }));
} catch {
// Unknown stays unknown: the row just shows no tag.
}
}
})();
return () => {
stop = true;
const patch = (change: Partial<HistoryFilters>) => {
setFilters((f) => ({ ...f, ...change }));
setPage(1);
};
}, [rows]);
const currentFy = currentFyStartYear();
const tileFy = filters.fy === "all" ? currentFy : filters.fy;
const filtered = useMemo(() => filterInvoices(rows, filters), [rows, filters]);
const sorted = useMemo(() => sortInvoices(filtered, sort), [filtered, sort]);
const totals = useMemo(() => totalsOf(filtered), [filtered]);
const summary = useMemo(() => fySummary(rows, tileFy), [rows, tileFy]);
const years = useMemo(() => fyOptions(rows, currentFy), [rows, currentFy]);
const clients = useMemo(() => clientOptions(rows), [rows]);
const filtersActive = JSON.stringify(filters) !== JSON.stringify(NO_FILTERS);
const lastPage = Math.max(1, Math.ceil(sorted.length / pageSize));
const shown = sorted.slice((Math.min(page, lastPage) - 1) * pageSize, Math.min(page, lastPage) * pageSize);
const onSort = (key: SortKey) =>
setSort((s) => (s.key === key ? { key, dir: s.dir === "asc" ? "desc" : "asc" } : { key, dir: key === "date" || key === "total" || key === "balance" ? "desc" : "asc" }));
const onExport = async (id: number, mode: ExportMode, source: ExportSource) => {
setBusyId(id);
@@ -87,42 +123,10 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
}
};
const onArchive = async (id: number) => {
setBusyId(id);
try {
const invoice = await api.getInvoice(id);
const outcome = await renderAndArchive(invoice, createExportDeps({ settings, bank: null }));
if (outcome.archived) {
setArchived((a) => ({ ...a, [id]: true }));
toast.success(`Invoice ${invoice.number} is archived`);
} else {
toast.error("Could not archive the PDF", `Invoice ${invoice.number}: ${outcome.error ?? "the PDF could not be archived."}`);
}
} catch (e) {
toast.error("Could not archive the PDF", String(e));
} finally {
setBusyId(null);
}
};
const onCancel = async () => {
if (!cancelTarget) return;
try {
await api.cancelInvoice(cancelTarget.id, reason);
toast.success(`Invoice ${cancelTarget.number} cancelled`);
setCancelTarget(null);
setReason("");
await load();
} catch (e) {
setCancelTarget(null);
toast.error("Could not cancel the invoice", String(e));
}
};
return (
<div className="voiced-page">
<h1 className="voiced-page__title">Invoices</h1>
<p className="voiced-page__subtitle">Every invoice you have issued, newest first.</p>
<p className="voiced-page__subtitle">Every invoice you have issued. Open one to see its PDF, record payments or export it.</p>
<ExportFeedback exp={exp} />
@@ -130,49 +134,144 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
<InlineLoading description="Loading invoices" />
) : rows.length === 0 ? (
<div className="voiced-empty">No invoices yet. Create one from “New invoice”.</div>
) : (
<>
<section aria-label={`Summary for ${fyLabel(tileFy)}`}>
<h2 className="voiced-history__fy">{fyLabel(tileFy)}</h2>
<div className="voiced-history__tiles">
<Tile>
<p className="voiced-muted">Invoiced</p>
<p className="voiced-history__amount">{formatPaise(summary.invoicedPaise)}</p>
<p className="voiced-muted">{summary.count} invoices</p>
</Tile>
<Tile>
<p className="voiced-muted">Received</p>
<p className="voiced-history__amount">{formatPaise(summary.receivedPaise)}</p>
</Tile>
<Tile>
<p className="voiced-muted">TDS deducted</p>
<p className="voiced-history__amount">{formatPaise(summary.tdsPaise)}</p>
</Tile>
<Tile>
<p className="voiced-muted">Outstanding</p>
<p className="voiced-history__amount">{formatPaise(summary.outstandingPaise)}</p>
</Tile>
<Tile className={summary.overdueCount > 0 ? "voiced-history__tile--overdue" : undefined}>
<p className="voiced-muted">Overdue</p>
<p className="voiced-history__amount">{formatPaise(summary.overduePaise)}</p>
<p className="voiced-muted">{summary.overdueCount} invoices</p>
</Tile>
</div>
</section>
<div className="voiced-history__filters" role="search">
<Search
id="historySearch"
size="md"
labelText="Search invoices"
placeholder="Number, client or PO number"
value={filters.search}
onChange={(e) => patch({ search: e.target.value })}
onClear={() => patch({ search: "" })}
/>
<Select id="historyStatus" size="md" labelText="Status" value={filters.status} onChange={(e) => patch({ status: e.target.value as StatusFilter })}>
{STATUS_FILTERS.map((s) => (
<SelectItem key={s.id} value={s.id} text={s.label} />
))}
</Select>
<Select
id="historyFy"
size="md"
labelText="Financial year"
value={String(filters.fy)}
onChange={(e) => patch({ fy: e.target.value === "all" ? "all" : Number(e.target.value) })}
>
<SelectItem value="all" text="All years" />
{years.map((y) => (
<SelectItem key={y} value={String(y)} text={fyLabel(y)} />
))}
</Select>
<Select
id="historyMonth"
size="md"
labelText="Month"
value={String(filters.month)}
onChange={(e) => patch({ month: e.target.value === "all" ? "all" : Number(e.target.value) })}
>
<SelectItem value="all" text="All months" />
{FY_MONTHS.map((m) => (
<SelectItem key={m.month} value={String(m.month)} text={m.label} />
))}
</Select>
<Select id="historyClient" size="md" labelText="Client" value={filters.client} onChange={(e) => patch({ client: e.target.value })}>
<SelectItem value="all" text="All clients" />
{clients.map((c) => (
<SelectItem key={c} value={c} text={c} />
))}
</Select>
{filtersActive ? (
<Button kind="ghost" size="md" onClick={() => patch({ ...NO_FILTERS })}>
Clear filters
</Button>
) : null}
</div>
{sorted.length === 0 ? (
<div className="voiced-empty">No invoices match these filters.</div>
) : (
<TableContainer>
<Table aria-label="Invoices">
<TableHead>
<TableRow>
<TableHeader>Number</TableHeader>
<TableHeader>Date</TableHeader>
<TableHeader>Client</TableHeader>
<TableHeader>Total</TableHeader>
<TableHeader>Status</TableHeader>
<TableHeader>Actions</TableHeader>
{COLUMNS.map((c) => (
<TableHeader
key={c.key}
isSortable
isSortHeader={sort.key === c.key}
sortDirection={sort.key === c.key ? (sort.dir === "asc" ? "ASC" : "DESC") : "NONE"}
onClick={() => onSort(c.key)}
className={c.numeric ? "voiced-num" : undefined}
>
{c.label}
</TableHeader>
))}
<TableHeader>
<span className="cds--assistive-text">Actions</span>
</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell>{row.number}</TableCell>
{shown.map((row) => {
const badge = statusBadge(row.status, row.paymentStatus);
return (
<TableRow key={row.id} className="voiced-history__row" onClick={() => onOpen(row.id)}>
<TableCell>
<Link
href="#"
onClick={(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onOpen(row.id);
}}
>
{row.number}
</Link>
</TableCell>
<TableCell>{formatDate(row.invoiceDate)}</TableCell>
<TableCell>{row.clientName || "—"}</TableCell>
<TableCell>₹{formatAmount(row.total)}</TableCell>
<TableCell className="voiced-num">{formatPaise(row.totalPaise)}</TableCell>
<TableCell className="voiced-num">{row.status === "cancelled" ? "—" : formatPaise(row.balancePaise)}</TableCell>
<TableCell>
<div className="voiced-header-action">
{row.status === "cancelled" ? (
<Tag type="red" size="sm">
Cancelled
<Tag type={badge.type} size="sm">
{badge.label}
</Tag>
) : (
row.status
)}
{archived[row.id] === false ? (
<Tag type="warm-gray" size="sm">
Not archived
{row.status === "issued" && row.paymentStatus === "overdue" && row.paidPaise + row.tdsPaise > 0 ? (
<Tag type="blue" size="sm">
Partly paid
</Tag>
) : null}
</div>
</TableCell>
<TableCell>
<div className="voiced-header-action">
{archived[row.id] === false ? (
<Button kind="ghost" size="sm" onClick={() => void onArchive(row.id)} disabled={busyId !== null || exp.busy}>
{busyId === row.id ? "Working…" : "Render & archive"}
</Button>
) : null}
<TableCell onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<OverflowMenu
aria-label={`Actions for invoice ${row.number}`}
iconDescription="Actions"
@@ -180,49 +279,47 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting
size="sm"
disabled={busyId !== null || exp.busy}
>
<OverflowMenuItem itemText="Open" onClick={() => onOpen(row.id)} />
<OverflowMenuItem itemText="Export PDF (original as issued)" onClick={() => void onExport(row.id, "searchable", "original")} />
<OverflowMenuItem itemText="Export flattened PDF" onClick={() => void onExport(row.id, "flattened", "original")} />
<OverflowMenuItem itemText="Re-render with current template" onClick={() => void onExport(row.id, "searchable", "rerender")} />
{row.status === "issued" ? (
<OverflowMenuItem
itemText="Cancel invoice"
hasDivider
isDelete
onClick={() => {
setReason("");
setCancelTarget(row);
}}
/>
) : null}
</OverflowMenu>
</div>
</TableCell>
</TableRow>
))}
);
})}
</TableBody>
</Table>
<div className="voiced-history__totals" role="status" aria-label="Totals for the filtered invoices">
<span className="voiced-muted">
{filtersActive ? "Filtered" : "All"}: {totals.count} invoices
</span>
<span>
Invoiced <strong>{formatPaise(totals.invoicedPaise)}</strong>
</span>
<span>
Received <strong>{formatPaise(totals.receivedPaise)}</strong>
</span>
<span>
TDS <strong>{formatPaise(totals.tdsPaise)}</strong>
</span>
<span>
Outstanding <strong>{formatPaise(totals.outstandingPaise)}</strong>
</span>
</div>
<Pagination
page={Math.min(page, lastPage)}
pageSize={pageSize}
pageSizes={PAGE_SIZES}
totalItems={sorted.length}
onChange={({ page: p, pageSize: s }: { page: number; pageSize: number }) => {
setPage(s !== pageSize ? 1 : p);
setPageSize(s);
}}
/>
</TableContainer>
)}
<Modal
open={cancelTarget !== null}
danger
modalHeading={`Cancel invoice ${cancelTarget?.number ?? ""}?`}
primaryButtonText="Cancel invoice"
secondaryButtonText="Keep invoice"
onRequestClose={() => setCancelTarget(null)}
onRequestSubmit={onCancel}
>
<p style={{ marginBottom: "1rem" }}>
The invoice stays in the list as cancelled and its number is not reused.
</p>
<TextInput
id="cancelReason"
labelText="Reason (optional)"
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
</Modal>
</>
)}
</div>
);
}
+12 -1
View File
@@ -84,6 +84,9 @@ interface NewInvoiceProps {
active: boolean;
/** Ask the shell to show this view (used by Ctrl+N from another tab). */
onActivate: () => void;
/** A draft to open as soon as the form is shown (set by "Duplicate" on the invoice page). */
openDraftId?: number | null;
onDraftOpened?: () => void;
}
const AUTOSAVE_MS = 1000;
@@ -94,7 +97,7 @@ const formatStamp = (iso: string) => {
return Number.isNaN(d.getTime()) ? iso : `${d.toLocaleDateString("en-GB")} ${formatTime(d)}`;
};
export default function NewInvoice({ settings, onSettingsChange, active, onActivate }: NewInvoiceProps) {
export default function NewInvoice({ settings, onSettingsChange, active, onActivate, openDraftId = null, onDraftOpened }: NewInvoiceProps) {
const toast = useToast();
const newItem = (): InvoiceItem => ({ ...EMPTY_ITEM, hsnSac: settings.defaultHsnSac });
const blankState = (): InvoiceFormState => {
@@ -562,6 +565,14 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
}
};
useEffect(() => {
if (openDraftId === null || !active) return;
onDraftOpened?.();
void resumeDraft(openDraftId);
// resumeDraft closes over fresh state each render; only a new request should trigger it.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openDraftId, active]);
const removeDraft = async (id: number) => {
try {
await api.deleteDraft(id);