//! 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, /// Name of the Payment Entry on ERPNext once this payment was sent; `None` until then. #[serde(default)] pub erpnext_payment_entry: Option, } #[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)?, erpnext_payment_entry: r .get::<_, Option>(9)? .filter(|e| !e.trim().is_empty()), }) } const COLS: &str = "id, invoice_id, paid_on, amount_paise, tds_paise, mode, reference, notes, created_at, erpnext_payment_entry"; 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); } }