Files
Voiced/src-tauri/src/commands/payments.rs
T
soubarna b6cf46b883 style(src-tauri): cargo fmt
Pure mechanical rustfmt reformat (default config, no rustfmt.toml).
2026-10-05 17:19:13 +05:30

523 lines
19 KiB
Rust

//! 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<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PaymentInput {
pub invoice_id: i64,
pub paid_on: String,
pub amount_paise: i64,
#[serde(default)]
pub tds_paise: i64,
pub mode: String,
#[serde(default)]
pub reference: String,
#[serde(default)]
pub notes: String,
}
/// Today's date in India (IST, UTC+05:30), as YYYY-MM-DD. Due dates are plain calendar dates.
pub fn today_ist() -> String {
let ist = FixedOffset::east_opt(5 * 3600 + 1800).expect("valid offset");
Utc::now()
.with_timezone(&ist)
.date_naive()
.format("%Y-%m-%d")
.to_string()
}
/// Derives the payment summary. Only an issued invoice takes payments; anything else is `none`
/// with a zero balance (a cancelled invoice is owed nothing). `settled` is cash plus TDS.
/// Overdue means a due date strictly before `today` while a balance remains; it wins over
/// `partially_paid`, and the paid amounts still show the partial payment.
pub fn derive_summary(
status: &str,
total_paise: i64,
paid_paise: i64,
tds_paise: i64,
due_date: &str,
today: &str,
) -> PaymentSummary {
if status != "issued" {
return PaymentSummary {
paid_paise,
tds_paise,
balance_paise: 0,
payment_status: "none".to_string(),
};
}
let settled = paid_paise + tds_paise;
let balance = (total_paise - settled).max(0);
let overdue = balance > 0
&& NaiveDate::parse_from_str(due_date.trim(), "%Y-%m-%d").is_ok()
&& due_date.trim() < today;
let payment_status = if balance == 0 && total_paise > 0 {
"paid"
} else if overdue {
"overdue"
} else if settled > 0 {
"partially_paid"
} else {
"unpaid"
};
PaymentSummary {
paid_paise,
tds_paise,
balance_paise: balance,
payment_status: payment_status.to_string(),
}
}
/// Summary for one invoice read from the payments table.
pub fn summary_for(
conn: &Connection,
invoice_id: i64,
status: &str,
total: f64,
due_date: &str,
) -> rusqlite::Result<PaymentSummary> {
let (paid, tds): (i64, i64) = conn.query_row(
"SELECT COALESCE(SUM(amount_paise), 0), COALESCE(SUM(tds_paise), 0)
FROM payments WHERE invoice_id = ?1",
params![invoice_id],
|r| Ok((r.get(0)?, r.get(1)?)),
)?;
Ok(derive_summary(
status,
rupees_to_paise(total),
paid,
tds,
due_date,
&today_ist(),
))
}
fn rupees(paise: i64) -> String {
format!("₹{}.{:02}", paise / 100, paise % 100)
}
fn map_payment(r: &rusqlite::Row) -> rusqlite::Result<Payment> {
Ok(Payment {
id: r.get(0)?,
invoice_id: r.get(1)?,
paid_on: r.get(2)?,
amount_paise: r.get(3)?,
tds_paise: r.get(4)?,
mode: r.get(5)?,
reference: r.get(6)?,
notes: r.get(7)?,
created_at: r.get(8)?,
erpnext_payment_entry: r
.get::<_, Option<String>>(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<Payment, String> {
let db = |e: rusqlite::Error| e.to_string();
NaiveDate::parse_from_str(input.paid_on.trim(), "%Y-%m-%d")
.map_err(|_| "Payment date must be a date like 2026-04-01".to_string())?;
if !MODES.contains(&input.mode.as_str()) {
return Err(format!("Unknown payment mode \"{}\"", input.mode));
}
if input.amount_paise < 0 || input.tds_paise < 0 {
return Err("Amounts cannot be negative".into());
}
if input.amount_paise > MAX_PAISE || input.tds_paise > MAX_PAISE {
return Err("That amount is too large".into());
}
if input.amount_paise + input.tds_paise == 0 {
return Err("Enter an amount received or TDS deducted".into());
}
let tx = conn.transaction().map_err(db)?;
let (status, total): (String, f64) = tx
.query_row(
"SELECT status, total FROM invoices WHERE id = ?1",
params![input.invoice_id],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()
.map_err(db)?
.ok_or_else(|| "Invoice not found".to_string())?;
if status != "issued" {
return Err(format!(
"Payments can only be recorded on an issued invoice (this one is {status})"
));
}
let total_paise = rupees_to_paise(total);
let settled: i64 = tx
.query_row(
"SELECT COALESCE(SUM(amount_paise + tds_paise), 0) FROM payments WHERE invoice_id = ?1",
params![input.invoice_id],
|r| r.get(0),
)
.map_err(db)?;
let balance = total_paise - settled;
let incoming = input.amount_paise + input.tds_paise;
if incoming > balance {
return Err(format!(
"This payment of {} (cash plus TDS) is more than the outstanding balance of {}",
rupees(incoming),
rupees(balance.max(0))
));
}
let now = chrono::Utc::now().to_rfc3339();
tx.execute(
"INSERT INTO payments (invoice_id, paid_on, amount_paise, tds_paise, mode, reference, notes, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
input.invoice_id,
input.paid_on.trim(),
input.amount_paise,
input.tds_paise,
input.mode,
input.reference.trim(),
input.notes.trim(),
now
],
)
.map_err(db)?;
let id = tx.last_insert_rowid();
let payment = tx
.query_row(
&format!("SELECT {COLS} FROM payments WHERE id = ?1"),
params![id],
map_payment,
)
.map_err(db)?;
tx.commit().map_err(db)?;
Ok(payment)
}
pub fn list_payments_impl(conn: &Connection, invoice_id: i64) -> Result<Vec<Payment>, String> {
let mut stmt = conn
.prepare(&format!(
"SELECT {COLS} FROM payments WHERE invoice_id = ?1 ORDER BY paid_on ASC, id ASC"
))
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map(params![invoice_id], map_payment)
.map_err(|e| e.to_string())?
.collect::<rusqlite::Result<Vec<_>>>()
.map_err(|e| e.to_string())?;
Ok(rows)
}
pub fn delete_payment_impl(conn: &Connection, id: i64) -> Result<(), String> {
let changed = conn
.execute("DELETE FROM payments WHERE id = ?1", params![id])
.map_err(|e| e.to_string())?;
if changed == 0 {
return Err("Payment not found".into());
}
Ok(())
}
#[tauri::command]
pub fn record_payment(state: State<AppState>, input: PaymentInput) -> Result<Payment, String> {
let mut conn = state.db.lock().map_err(|e| e.to_string())?;
record_payment_impl(&mut conn, input)
}
#[tauri::command]
pub fn list_payments(state: State<AppState>, invoice_id: i64) -> Result<Vec<Payment>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
list_payments_impl(&conn, invoice_id)
}
#[tauri::command]
pub fn delete_payment(state: State<AppState>, id: i64) -> Result<(), String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
delete_payment_impl(&conn, id)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::invoice::{cancel_invoice_impl, issue_invoice_impl};
use crate::models::InvoiceInput;
use serde_json::json;
use std::path::Path;
/// An unregistered vendor issues without tax, so the invoice total is exactly the line amount.
fn issued(conn: &mut Connection, amount: f64, due: &str) -> i64 {
let input: InvoiceInput = serde_json::from_value(json!({
"invoiceDate": "2026-04-01",
"dueDate": due,
"clientName": "Client Ltd",
"taxType": "none",
"taxRate": 0.0,
"items": [{"description": "Design", "mode": "fixed", "amount": amount}],
}))
.unwrap();
issue_invoice_impl(conn, Path::new("/nonexistent"), input, &json!({}))
.unwrap()
.id
}
fn pay(invoice_id: i64, amount: i64, tds: i64) -> PaymentInput {
PaymentInput {
invoice_id,
paid_on: "2026-04-10".into(),
amount_paise: amount,
tds_paise: tds,
mode: "bank_transfer".into(),
reference: "UTR1".into(),
notes: String::new(),
}
}
fn summary(conn: &Connection, id: i64) -> PaymentSummary {
let (status, total, due): (String, f64, String) = conn
.query_row(
"SELECT status, total, due_date FROM invoices WHERE id = ?1",
params![id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap();
summary_for(conn, id, &status, total, &due).unwrap()
}
#[test]
fn partial_then_full_payment_settles() {
let mut conn = crate::db::open_in_memory().unwrap();
let id = issued(&mut conn, 1000.0, "2999-01-01");
assert_eq!(summary(&conn, id).payment_status, "unpaid");
assert_eq!(summary(&conn, id).balance_paise, 100_000);
record_payment_impl(&mut conn, pay(id, 40_000, 0)).unwrap();
let s = summary(&conn, id);
assert_eq!(
(s.payment_status.as_str(), s.paid_paise, s.balance_paise),
("partially_paid", 40_000, 60_000)
);
record_payment_impl(&mut conn, pay(id, 60_000, 0)).unwrap();
let s = summary(&conn, id);
assert_eq!((s.payment_status.as_str(), s.balance_paise), ("paid", 0));
}
#[test]
fn tds_counts_towards_settling() {
let mut conn = crate::db::open_in_memory().unwrap();
let id = issued(&mut conn, 1000.0, "2999-01-01");
// 10% TDS: the client pays 900.00 in cash and deducts 100.00.
record_payment_impl(&mut conn, pay(id, 90_000, 10_000)).unwrap();
let s = summary(&conn, id);
assert_eq!(
(
s.payment_status.as_str(),
s.paid_paise,
s.tds_paise,
s.balance_paise
),
("paid", 90_000, 10_000, 0)
);
// TDS alone is a valid payment too.
let id2 = issued(&mut conn, 500.0, "2999-01-01");
record_payment_impl(&mut conn, pay(id2, 0, 5_000)).unwrap();
assert_eq!(summary(&conn, id2).payment_status, "partially_paid");
}
#[test]
fn overpayment_is_rejected_including_tds() {
let mut conn = crate::db::open_in_memory().unwrap();
let id = issued(&mut conn, 1000.0, "2999-01-01");
let err = record_payment_impl(&mut conn, pay(id, 100_001, 0)).unwrap_err();
assert!(
err.contains("more than the outstanding balance of ₹1000.00"),
"{err}"
);
// Cash plus TDS is what counts.
assert!(record_payment_impl(&mut conn, pay(id, 95_000, 6_000)).is_err());
record_payment_impl(&mut conn, pay(id, 50_000, 0)).unwrap();
let err = record_payment_impl(&mut conn, pay(id, 50_001, 0)).unwrap_err();
assert!(err.contains("₹500.00"), "{err}");
let n: i64 = conn
.query_row("SELECT COUNT(*) FROM payments", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 1);
}
#[test]
fn invalid_input_is_rejected() {
let mut conn = crate::db::open_in_memory().unwrap();
let id = issued(&mut conn, 1000.0, "2999-01-01");
assert!(record_payment_impl(&mut conn, pay(id, 0, 0)).is_err());
assert!(record_payment_impl(&mut conn, pay(id, -1, 0)).is_err());
assert!(record_payment_impl(&mut conn, pay(id, 100, -5)).is_err());
let mut bad = pay(id, 100, 0);
bad.mode = "barter".into();
assert!(record_payment_impl(&mut conn, bad).is_err());
let mut bad = pay(id, 100, 0);
bad.paid_on = "10/04/2026".into();
assert!(record_payment_impl(&mut conn, bad).is_err());
assert!(record_payment_impl(&mut conn, pay(9999, 100, 0)).is_err());
// The table CHECKs hold even if a caller bypasses the command validation.
assert!(conn
.execute(
"INSERT INTO payments (invoice_id, paid_on, amount_paise, tds_paise, mode, created_at)
VALUES (?1, '2026-04-10', 0, 0, 'cash', 'now')",
params![id]
)
.is_err());
}
#[test]
fn draft_and_cancelled_invoices_take_no_payments() {
let mut conn = crate::db::open_in_memory().unwrap();
let id = issued(&mut conn, 1000.0, "2999-01-01");
cancel_invoice_impl(&mut conn, id, "mistake").unwrap();
let err = record_payment_impl(&mut conn, pay(id, 100, 0)).unwrap_err();
assert!(err.contains("issued invoice"), "{err}");
let s = summary(&conn, id);
assert_eq!((s.payment_status.as_str(), s.balance_paise), ("none", 0));
let id2 = issued(&mut conn, 1000.0, "2999-01-01");
conn.execute(
"UPDATE invoices SET status = 'draft' WHERE id = ?1",
params![id2],
)
.unwrap();
assert!(record_payment_impl(&mut conn, pay(id2, 100, 0)).is_err());
}
#[test]
fn an_invoice_with_payments_cannot_be_cancelled() {
let mut conn = crate::db::open_in_memory().unwrap();
let id = issued(&mut conn, 1000.0, "2999-01-01");
let p = record_payment_impl(&mut conn, pay(id, 100, 0)).unwrap();
let err = cancel_invoice_impl(&mut conn, id, "").unwrap_err();
assert!(err.contains("payments"), "{err}");
delete_payment_impl(&conn, p.id).unwrap();
cancel_invoice_impl(&mut conn, id, "").unwrap();
}
#[test]
fn deleting_a_payment_reverts_the_status() {
let mut conn = crate::db::open_in_memory().unwrap();
let id = issued(&mut conn, 1000.0, "2999-01-01");
let full = record_payment_impl(&mut conn, pay(id, 100_000, 0)).unwrap();
assert_eq!(summary(&conn, id).payment_status, "paid");
delete_payment_impl(&conn, full.id).unwrap();
let s = summary(&conn, id);
assert_eq!(
(s.payment_status.as_str(), s.balance_paise),
("unpaid", 100_000)
);
assert!(list_payments_impl(&conn, id).unwrap().is_empty());
assert!(delete_payment_impl(&conn, full.id).is_err());
}
#[test]
fn overdue_is_derived_from_due_date_and_balance() {
let d = |due: &str, paid: i64, tds: i64| {
derive_summary("issued", 100_000, paid, tds, due, "2026-05-10")
};
// Due today is not overdue yet; the day after is.
assert_eq!(d("2026-05-10", 0, 0).payment_status, "unpaid");
assert_eq!(d("2026-05-09", 0, 0).payment_status, "overdue");
// Overdue wins over partially paid; the paid amount is still reported.
let s = d("2026-05-09", 10_000, 0);
assert_eq!(
(s.payment_status.as_str(), s.paid_paise),
("overdue", 10_000)
);
// A settled invoice is never overdue.
assert_eq!(d("2026-05-09", 90_000, 10_000).payment_status, "paid");
// No usable due date means never overdue.
assert_eq!(d("", 0, 0).payment_status, "unpaid");
assert_eq!(d("not a date", 0, 0).payment_status, "unpaid");
// Cancelled invoices are not owed.
assert_eq!(
derive_summary("cancelled", 100_000, 0, 0, "2020-01-01", "2026-05-10").balance_paise,
0
);
}
#[test]
fn totals_are_integer_paise_without_float_drift() {
let mut conn = crate::db::open_in_memory().unwrap();
// 0.1 + 0.2 style amounts: 3 x 33.33 = 99.99.
let id = issued(&mut conn, 99.99, "2999-01-01");
record_payment_impl(&mut conn, pay(id, 3333, 0)).unwrap();
record_payment_impl(&mut conn, pay(id, 3333, 0)).unwrap();
record_payment_impl(&mut conn, pay(id, 3333, 0)).unwrap();
let s = summary(&conn, id);
assert_eq!(
(s.paid_paise, s.balance_paise, s.payment_status.as_str()),
(9999, 0, "paid")
);
let inv = super::super::invoice::get_invoice_impl(&conn, id).unwrap();
assert_eq!(inv.total_paise, 9999);
}
#[test]
fn list_invoices_joins_the_payment_sums() {
let mut conn = crate::db::open_in_memory().unwrap();
let a = issued(&mut conn, 1000.0, "2020-01-01");
let b = issued(&mut conn, 200.0, "2999-01-01");
record_payment_impl(&mut conn, pay(a, 30_000, 5_000)).unwrap();
record_payment_impl(&mut conn, pay(a, 10_000, 0)).unwrap();
record_payment_impl(&mut conn, pay(b, 20_000, 0)).unwrap();
let rows = super::super::invoice::list_invoices_impl(&conn).unwrap();
let get = |id: i64| rows.iter().find(|r| r.id == id).unwrap();
let ra = get(a);
assert_eq!(
(
ra.payment.paid_paise,
ra.payment.tds_paise,
ra.payment.balance_paise
),
(40_000, 5_000, 55_000)
);
assert_eq!(ra.payment.payment_status, "overdue");
assert_eq!(ra.total_paise, 100_000);
assert_eq!(get(b).payment.payment_status, "paid");
// The JSON keeps the existing fields and adds the flattened summary.
let json = serde_json::to_value(ra).unwrap();
assert_eq!(json["balancePaise"], 55_000);
assert_eq!(json["paymentStatus"], "overdue");
assert_eq!(json["total"], 1000.0);
}
}