diff --git a/src-tauri/src/commands/invoice.rs b/src-tauri/src/commands/invoice.rs index b0fca67..fbe067a 100644 --- a/src-tauri/src/commands/invoice.rs +++ b/src-tauri/src/commands/invoice.rs @@ -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 { 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 { 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 { + 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, id: i64, reason: String) -> Result #[tauri::command] pub fn list_invoices(state: State) -> Result, 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, 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) -> Result, Stri Ok(rows) } +pub fn get_invoice_impl(conn: &Connection, id: i64) -> Result { + fetch_invoice(conn, id).map_err(|e| e.to_string()) +} + #[tauri::command] pub fn get_invoice(state: State, id: i64) -> Result { 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( diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index ad36bfe..dadeb8c 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -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; diff --git a/src-tauri/src/commands/payments.rs b/src-tauri/src/commands/payments.rs new file mode 100644 index 0000000..8f25404 --- /dev/null +++ b/src-tauri/src/commands/payments.rs @@ -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 { + 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 { + 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 { + 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, 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::>>() + .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, input: PaymentInput) -> Result { + 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, invoice_id: i64) -> Result, 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, 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); + } +} diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index c460caf..9358ffb 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -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 = conn diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 26f82b5..ea691d0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index a4ea66b..5c2dc9c 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -265,6 +265,34 @@ pub struct Invoice { pub archived_fingerprint: Option, pub archived_at: Option, pub items: Vec, + /// 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)] diff --git a/src/App.tsx b/src/App.tsx index 283b4e0..27ae6a5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(null); const [view, setView] = useState("new"); + const [detailId, setDetailId] = useState(null); + // A draft made by "Duplicate" that the New invoice form should open once it is shown. + const [openDraftId, setOpenDraftId] = useState(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)} /> + {view === "invoice" && detailId !== null ? ( + setView("invoices")} + onDuplicated={(draftId) => { + setOpenDraftId(draftId); + setView("new"); + }} + /> + ) : null} diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 0899c27..5b0a10b 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -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; 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) => ( { event.preventDefault(); onNavigate(item.id); diff --git a/src/components/RecordPaymentModal.tsx b/src/components/RecordPaymentModal.tsx new file mode 100644 index 0000000..e14f80b --- /dev/null +++ b/src/components/RecordPaymentModal.tsx @@ -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("bank_transfer"); + const [reference, setReference] = useState(""); + const [notes, setNotes] = useState(""); + const [error, setError] = useState(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 ( + void submit()} + > +
+

+ Balance due: {formatPaise(balancePaise)} +

+ {error ? : null} + setPaidOn(e.target.value)} + /> + setAmount(e.target.value)} + /> + setTds(e.target.value)} + /> + + setReference(e.target.value)} /> + setNotes(e.target.value)} /> +
+
+ ); +} diff --git a/src/hooks/useReturnFocus.ts b/src/hooks/useReturnFocus.ts new file mode 100644 index 0000000..525c876 --- /dev/null +++ b/src/hooks/useReturnFocus.ts @@ -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(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]); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index de62982..eb6421a 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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("list_invoices"), getInvoice: (id: number) => invoke("get_invoice", { id }), + recordPayment: (input: PaymentInput) => invoke("record_payment", { input }), + listPayments: (invoiceId: number) => invoke("list_payments", { invoiceId }), + deletePayment: (id: number) => invoke("delete_payment", { id }), + saveDraft: (id: number | null, payload: unknown) => invoke("save_draft", { id, payload }), listDrafts: () => invoke("list_drafts"), diff --git a/src/lib/duplicate.test.ts b/src/lib/duplicate.test.ts new file mode 100644 index 0000000..3690c28 --- /dev/null +++ b/src/lib/duplicate.test.ts @@ -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: "AP/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); + }); +}); diff --git a/src/lib/duplicate.ts b/src/lib/duplicate.ts new file mode 100644 index 0000000..085617c --- /dev/null +++ b/src/lib/duplicate.ts @@ -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, 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, + }; +} diff --git a/src/lib/fiscal.test.ts b/src/lib/fiscal.test.ts new file mode 100644 index 0000000..0de0422 --- /dev/null +++ b/src/lib/fiscal.test.ts @@ -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); + }); +}); diff --git a/src/lib/fiscal.ts b/src/lib/fiscal.ts new file mode 100644 index 0000000..f80d7e6 --- /dev/null +++ b/src/lib/fiscal.ts @@ -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; +} diff --git a/src/lib/historyFilter.test.ts b/src/lib/historyFilter.test.ts new file mode 100644 index 0000000..2e79a45 --- /dev/null +++ b/src/lib/historyFilter.test.ts @@ -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 { + const base: InvoiceSummary = { + id: nextId++, + number: "AP/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: "AP/2025-001", invoiceDate: "2025-04-01", clientName: "Acme", poNumber: "PO-77", paymentStatus: "paid", paidPaise: 90_000, tdsPaise: 10_000, balancePaise: 0 }), + row({ number: "AP/2025-002", invoiceDate: "2025-05-20", clientName: "Beta Labs", totalPaise: 250_050, balancePaise: 250_050, paymentStatus: "overdue" }), + row({ number: "AP/2025-010", invoiceDate: "2026-03-31", clientName: "Acme", paidPaise: 30_000, balancePaise: 70_000, paymentStatus: "partially_paid" }), + row({ number: "AP/2026-001", invoiceDate: "2026-04-02", clientName: "Gamma", status: "cancelled", balancePaise: 0, paymentStatus: "none" }), + row({ number: "AP/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(["AP/2025-002"]); + expect(f({ search: "po-77" })).toEqual(["AP/2025-001"]); + expect(f({ search: " 2026-00 " })).toEqual(["AP/2026-001", "AP/2026-002"]); + expect(f({ search: "nothing" })).toEqual([]); + }); + + it("filters by status and payment status", () => { + expect(f({ status: "cancelled" })).toEqual(["AP/2026-001"]); + expect(f({ status: "issued" })).toHaveLength(4); + expect(f({ status: "paid" })).toEqual(["AP/2025-001"]); + expect(f({ status: "overdue" })).toEqual(["AP/2025-002"]); + expect(f({ status: "partially_paid" })).toEqual(["AP/2025-010"]); + // An overdue invoice with nothing received is also unpaid. + expect(f({ status: "unpaid" })).toEqual(["AP/2025-002", "AP/2026-002"]); + }); + + it("filters by financial year, month and client", () => { + expect(f({ fy: 2025 })).toEqual(["AP/2025-001", "AP/2025-002", "AP/2025-010"]); + expect(f({ fy: 2026 })).toEqual(["AP/2026-001", "AP/2026-002"]); + expect(f({ month: 4 })).toEqual(["AP/2025-001", "AP/2026-001", "AP/2026-002"]); + expect(f({ fy: 2026, month: 4 })).toEqual(["AP/2026-001", "AP/2026-002"]); + expect(f({ client: "Acme", fy: 2025 })).toEqual(["AP/2025-001", "AP/2025-010"]); + }); +}); + +describe("sortInvoices", () => { + const nums = (sort: Parameters[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(["AP/2025-001", "AP/2025-002", "AP/2025-010"]); + expect(nums({ key: "number", dir: "desc" })[0]).toBe("AP/2026-002"); + }); + + it("sorts by date, total and balance", () => { + expect(nums({ key: "date", dir: "desc" })[0]).toBe("AP/2026-002"); + expect(nums({ key: "total", dir: "desc" })[0]).toBe("AP/2025-002"); + expect(nums({ key: "balance", dir: "asc" }).slice(0, 2).sort()).toEqual(["AP/2025-001", "AP/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"])); +}); diff --git a/src/lib/historyFilter.ts b/src/lib/historyFilter.ts new file mode 100644 index 0000000..a8d7a6b --- /dev/null +++ b/src/lib/historyFilter.ts @@ -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 = { 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([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)); +} diff --git a/src/lib/money.test.ts b/src/lib/money.test.ts new file mode 100644 index 0000000..444ea86 --- /dev/null +++ b/src/lib/money.test.ts @@ -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"); + }); +}); diff --git a/src/lib/money.ts b/src/lib/money.ts new file mode 100644 index 0000000..3dab0f9 --- /dev/null +++ b/src/lib/money.ts @@ -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)}`; +} diff --git a/src/lib/payments.ts b/src/lib/payments.ts new file mode 100644 index 0000000..88aa82b --- /dev/null +++ b/src/lib/payments.ts @@ -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; + +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" }; + } +} diff --git a/src/lib/types.ts b/src/lib/types.ts index d20f060..fafdfe2 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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 = { diff --git a/src/styles/carbon.scss b/src/styles/carbon.scss index ee0b26f..a363d32 100644 --- a/src/styles/carbon.scss +++ b/src/styles/carbon.scss @@ -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; +} diff --git a/src/views/InvoiceDetail.tsx b/src/views/InvoiceDetail.tsx new file mode 100644 index 0000000..0f3a7cb --- /dev/null +++ b/src/views/InvoiceDetail.tsx @@ -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 ( +
+
{label}
+
{children || "—"}
+
+ ); +} + +export default function InvoiceDetail({ invoiceId, settings, onBack, onDuplicated }: Props) { + const toast = useToast(); + const [invoice, setInvoice] = useState(null); + const [payments, setPayments] = useState([]); + const [loadError, setLoadError] = useState(null); + const [archive, setArchive] = useState(null); + const [pdf, setPdf] = useState(null); + const [pdfError, setPdfError] = useState(null); + const [archiving, setArchiving] = useState(false); + const [recordOpen, setRecordOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(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 ( +
+ + +
+ ); + } + if (!invoice) { + return ( +
+ +
+ ); + } + + 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 ( +
+ + +
+

{invoice.number}

+ {badge.label} + {invoice.status === "issued" && invoice.paymentStatus === "overdue" && (invoice.paidPaise ?? 0) + (invoice.tdsPaise ?? 0) > 0 ? ( + + Partly paid + + ) : null} + {archive && !archive.archived ? ( + + Not archived + + ) : null} +
+

+ {invoice.clientName || "No client name"} · issued {formatDate(invoice.invoiceDate)} + {invoice.dueDate ? ` · due ${formatDate(invoice.dueDate)}` : ""} +

+ + {invoice.status === "cancelled" ? ( + + ) : null} + +
+ {canPay ? ( + + ) : null} + + + + + onExport("searchable", "rerender")} /> + {invoice.status === "issued" ? ( + 0 ? "Cancel invoice (delete payments first)" : "Cancel invoice"} + hasDivider + isDelete + disabled={payments.length > 0} + onClick={() => { + setReason(""); + setCancelOpen(true); + }} + /> + ) : null} + +
+ + + +
+
+
+ +

Total

+

{formatPaise(total)}

+
+ +

Received

+

{formatPaise(invoice.paidPaise ?? 0)}

+
+ +

TDS deducted

+

{formatPaise(invoice.tdsPaise ?? 0)}

+
+ +

Balance

+

{formatPaise(balance)}

+
+
+ +
+

+ Parties +

+
+
+ {invoice.clientName} + + {invoice.clientAddress} + + {invoice.clientGstin} + {invoice.poNumber} + {invoice.placeOfSupplyStateCode ? stateName(invoice.placeOfSupplyStateCode) : ""} +
+
+ {vendor.vendorName} + + {vendor.vendorAddress} + + {vendor.vendorGstin} + {vendor.vendorPan} +
+
+
+ +
+

+ Items +

+ + + + + Description + HSN/SAC + Amount + + + + {invoice.items.map((item, i) => ( + + + {item.description} + {item.mode === "rate" ? ( +
+ {item.quantity} {item.unit} × ₹{formatAmount(item.rate)} +
+ ) : null} +
+ {item.hsnSac || "—"} + ₹{formatAmount(item.amount)} +
+ ))} +
+
+
+
+
+
Subtotal
+
₹{formatAmount(invoice.subtotal)}
+
+ {invoice.discount > 0 ? ( +
+
Discount
+
−₹{formatAmount(invoice.discount)}
+
+ ) : null} + {invoice.taxType === "cgst_sgst" ? ( + <> +
+
CGST @ {half}%
+
₹{formatAmount(invoice.cgstAmount)}
+
+
+
SGST @ {half}%
+
₹{formatAmount(invoice.sgstAmount)}
+
+ + ) : null} + {invoice.taxType === "igst" ? ( +
+
IGST @ {invoice.taxRate}%
+
₹{formatAmount(invoice.igstAmount)}
+
+ ) : null} +
+
Total
+
₹{formatAmount(invoice.total)}
+
+
+ {invoice.amountInWords ?

{invoice.amountInWords}

: null} + {invoice.notes ?

{invoice.notes}

: null} +
+ +
+

+ Payments +

+ {payments.length === 0 ? ( +

No payments recorded.

+ ) : ( + + + + + Date + Mode + Reference + Received + TDS + + Actions + + + + + {payments.map((p) => ( + + {formatDate(p.paidOn)} + {paymentModeLabel(p.mode)} + + {p.reference || "—"} + {p.notes ?
{p.notes}
: null} +
+ {formatPaise(p.amountPaise)} + {formatPaise(p.tdsPaise)} + +
+
+ )} +
+
+ + +
+ + setRecordOpen(false)} + onRecorded={(p) => { + setRecordOpen(false); + toast.success("Payment recorded", `${formatPaise(p.amountPaise + p.tdsPaise)} on ${formatDate(p.paidOn)}`); + void refresh(); + }} + /> + + setDeleteTarget(null)} + onRequestSubmit={() => void onDeletePayment()} + > +

+ {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.` + : ""} +

+
+ + setCancelOpen(false)} + onRequestSubmit={() => void onCancelInvoice()} + > +

The invoice stays in the list as cancelled and its number is not reused.

+ setReason(e.target.value)} /> +
+
+ ); +} diff --git a/src/views/InvoiceHistory.tsx b/src/views/InvoiceHistory.tsx index a00d95b..0fc7175 100644 --- a/src/views/InvoiceHistory.tsx +++ b/src/views/InvoiceHistory.tsx @@ -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([]); const [loading, setLoading] = useState(true); const [busyId, setBusyId] = useState(null); - const [cancelTarget, setCancelTarget] = useState(null); - const [reason, setReason] = useState(""); - // Whether each invoice has an archived PDF; unknown (absent) until its status was read. - const [archived, setArchived] = useState>({}); - const exp = useInvoiceExport({ - settings, - bank: null, - onInvoiceUpdated: (inv) => setArchived((a) => ({ ...a, [inv.id]: Boolean(inv.archivedPdfSha256) })), - }); + const [filters, setFilters] = useState(NO_FILTERS); + const [sort, setSort] = useState({ 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; - }; - }, [rows]); + const patch = (change: Partial) => { + setFilters((f) => ({ ...f, ...change })); + setPage(1); + }; + + 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 (

Invoices

-

Every invoice you have issued, newest first.

+

Every invoice you have issued. Open one to see its PDF, record payments or export it.

@@ -131,98 +135,191 @@ export default function InvoiceHistory({ settings, active }: { settings: Setting ) : rows.length === 0 ? (
No invoices yet. Create one from “New invoice”.
) : ( - - - - - Number - Date - Client - Total - Status - Actions - - - - {rows.map((row) => ( - - {row.number} - {formatDate(row.invoiceDate)} - {row.clientName || "—"} - ₹{formatAmount(row.total)} - -
- {row.status === "cancelled" ? ( - - Cancelled - - ) : ( - row.status - )} - {archived[row.id] === false ? ( - - Not archived - - ) : null} -
-
- -
- {archived[row.id] === false ? ( - - ) : null} - - void onExport(row.id, "searchable", "original")} /> - void onExport(row.id, "flattened", "original")} /> - void onExport(row.id, "searchable", "rerender")} /> - {row.status === "issued" ? ( - { - setReason(""); - setCancelTarget(row); - }} - /> - ) : null} - -
-
-
- ))} -
-
-
- )} + <> +
+

{fyLabel(tileFy)}

+
+ +

Invoiced

+

{formatPaise(summary.invoicedPaise)}

+

{summary.count} invoices

+
+ +

Received

+

{formatPaise(summary.receivedPaise)}

+
+ +

TDS deducted

+

{formatPaise(summary.tdsPaise)}

+
+ +

Outstanding

+

{formatPaise(summary.outstandingPaise)}

+
+ 0 ? "voiced-history__tile--overdue" : undefined}> +

Overdue

+

{formatPaise(summary.overduePaise)}

+

{summary.overdueCount} invoices

+
+
+
- setCancelTarget(null)} - onRequestSubmit={onCancel} - > -

- The invoice stays in the list as cancelled and its number is not reused. -

- setReason(e.target.value)} - /> -
+
+ patch({ search: e.target.value })} + onClear={() => patch({ search: "" })} + /> + + + + + {filtersActive ? ( + + ) : null} +
+ + {sorted.length === 0 ? ( +
No invoices match these filters.
+ ) : ( + + + + + {COLUMNS.map((c) => ( + onSort(c.key)} + className={c.numeric ? "voiced-num" : undefined} + > + {c.label} + + ))} + + Actions + + + + + {shown.map((row) => { + const badge = statusBadge(row.status, row.paymentStatus); + return ( + onOpen(row.id)}> + + { + e.preventDefault(); + e.stopPropagation(); + onOpen(row.id); + }} + > + {row.number} + + + {formatDate(row.invoiceDate)} + {row.clientName || "—"} + {formatPaise(row.totalPaise)} + {row.status === "cancelled" ? "—" : formatPaise(row.balancePaise)} + + + {badge.label} + + {row.status === "issued" && row.paymentStatus === "overdue" && row.paidPaise + row.tdsPaise > 0 ? ( + + Partly paid + + ) : null} + + e.stopPropagation()}> + + onOpen(row.id)} /> + void onExport(row.id, "searchable", "original")} /> + void onExport(row.id, "flattened", "original")} /> + + + + ); + })} + +
+
+ + {filtersActive ? "Filtered" : "All"}: {totals.count} invoices + + + Invoiced {formatPaise(totals.invoicedPaise)} + + + Received {formatPaise(totals.receivedPaise)} + + + TDS {formatPaise(totals.tdsPaise)} + + + Outstanding {formatPaise(totals.outstandingPaise)} + +
+ { + setPage(s !== pageSize ? 1 : p); + setPageSize(s); + }} + /> +
+ )} + + )}
); } diff --git a/src/views/NewInvoice.tsx b/src/views/NewInvoice.tsx index 59c5356..2536c85 100644 --- a/src/views/NewInvoice.tsx +++ b/src/views/NewInvoice.tsx @@ -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);