- Settings gets an Integrations tab: connection fields with a write-only API secret, a CA PEM option, Test connection with versions and warnings, live mapping lists loaded after a test, naming mode, toggles and its own Save. - Pushing for a GST-registered vendor is refused until a connection test result is stored. The UI shows the reason and disables Send. - History shows an ERPNext status column from one bulk call, a row Send and Open in ERPNext, and a bulk Send selected with a draft-or-submit confirm step and per-row results that never abort the batch. The detail view gets an ERPNext panel and per-payment send. All of it is hidden or disabled when the integration is not configured. - Optional auto-push after issue runs in the background and never blocks or fails the issue. - Generic export: a deterministic voiced.invoice.v1 JSON (and CSV line items) for selected invoices, documented in docs/voiced-invoice-v1.md with a golden test. Money is integer paise plus a decimal string. The new screens have not been run in a webview yet. The ERPNext Payment Entry deduction fields still need a live check.
459 lines
18 KiB
Rust
459 lines
18 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);
|
|
}
|
|
}
|