Add payments with TDS, invoice detail view and filterable History (Phase E2)

- Migration M8 adds payments (cash, TDS, mode, reference). Payment status is derived, never stored: unpaid, partially paid, paid, or overdue. Overpayment is rejected, TDS counts toward settling, and only issued invoices accept payments. list_invoices and get_invoice carry the paid, TDS and balance summary.
- New invoice detail view: archived-PDF preview, payments list, Record payment modal, Export, Duplicate (as a draft), Re-render and Cancel. An invoice with payments cannot be cancelled until they are deleted.
- History gets search, status, financial-year, month and client filters, sortable columns, pagination, footer totals and an FY summary. Cancelled invoices are excluded from totals. The per-row archive-status calls are gone; archiving is on the detail page.
- Rust tests for payments and the list summary; vitest for fiscal-year, money, filter and duplicate helpers.

PDF output and fingerprint goldens are unchanged. The new UI has not been run in a webview yet.
This commit is contained in:
2026-10-04 17:49:25 +05:30
parent 3510cc4260
commit 1623b879e2
25 changed files with 2220 additions and 175 deletions
+41 -7
View File
@@ -3,8 +3,9 @@ use super::series::{validate_series_format, MAX_NUMBER_LEN};
use super::settings::{map_bank, map_settings, SETTINGS_COLS};
use crate::db::format_number;
use crate::gst::{self, TaxType};
use super::payments::{derive_summary, summary_for, today_ist};
use crate::models::{
BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, Settings,
BankAccount, DraftSummary, Invoice, InvoiceInput, InvoiceItem, InvoiceSummary, PaymentSummary, Settings,
};
use crate::AppState;
use chrono::NaiveDate;
@@ -59,6 +60,8 @@ fn map_invoice(row: &Row) -> rusqlite::Result<Invoice> {
archived_fingerprint: row.get(35)?,
archived_at: row.get(36)?,
items: Vec::new(),
payment: PaymentSummary::default(),
total_paise: 0,
})
}
@@ -90,6 +93,8 @@ fn fetch_invoice(conn: &Connection, id: i64) -> rusqlite::Result<Invoice> {
map_invoice,
)?;
invoice.items = fetch_items(conn, id)?;
invoice.total_paise = gst::rupees_to_paise(invoice.total);
invoice.payment = summary_for(conn, id, &invoice.status, invoice.total, &invoice.due_date)?;
Ok(invoice)
}
@@ -460,6 +465,12 @@ pub fn issue_invoice(
/// Issued invoices are never deleted: cancelling keeps the row and its number.
pub fn cancel_invoice_impl(conn: &mut Connection, id: i64, reason: &str) -> Result<Invoice, String> {
let payments: i64 = conn
.query_row("SELECT COUNT(*) FROM payments WHERE invoice_id = ?1", params![id], |r| r.get(0))
.map_err(|e| e.to_string())?;
if payments > 0 {
return Err("This invoice has payments recorded. Delete them before cancelling it.".into());
}
let now = chrono::Utc::now().to_rfc3339();
let changed = conn
.execute(
@@ -491,23 +502,42 @@ pub fn cancel_invoice(state: State<AppState>, id: i64, reason: String) -> Result
#[tauri::command]
pub fn list_invoices(state: State<AppState>) -> Result<Vec<InvoiceSummary>, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
list_invoices_impl(&conn)
}
/// One query: invoices left-joined to their payment sums. Status derivation happens in Rust
/// (`derive_summary`) so list and detail agree.
pub fn list_invoices_impl(conn: &Connection) -> Result<Vec<InvoiceSummary>, String> {
let today = today_ist();
let mut stmt = conn
.prepare(
"SELECT id, number, invoice_date, due_date, client_name, total, status, created_at
FROM invoices ORDER BY id DESC",
"SELECT i.id, i.number, i.invoice_date, i.due_date, i.client_name, i.total, i.status,
i.created_at, i.po_number, COALESCE(p.paid, 0), COALESCE(p.tds, 0)
FROM invoices i
LEFT JOIN (SELECT invoice_id, SUM(amount_paise) AS paid, SUM(tds_paise) AS tds
FROM payments GROUP BY invoice_id) p ON p.invoice_id = i.id
ORDER BY i.id DESC",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| {
let total: f64 = row.get(5)?;
let status: String = row.get(6)?;
let due_date: String = row.get(3)?;
let total_paise = gst::rupees_to_paise(total);
let payment = derive_summary(&status, total_paise, row.get(9)?, row.get(10)?, &due_date, &today);
Ok(InvoiceSummary {
id: row.get(0)?,
number: row.get(1)?,
invoice_date: row.get(2)?,
due_date: row.get(3)?,
due_date,
client_name: row.get(4)?,
total: row.get(5)?,
status: row.get(6)?,
total,
status,
created_at: row.get(7)?,
po_number: row.get(8)?,
total_paise,
payment,
})
})
.map_err(|e| e.to_string())?
@@ -516,10 +546,14 @@ pub fn list_invoices(state: State<AppState>) -> Result<Vec<InvoiceSummary>, Stri
Ok(rows)
}
pub fn get_invoice_impl(conn: &Connection, id: i64) -> Result<Invoice, String> {
fetch_invoice(conn, id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_invoice(state: State<AppState>, id: i64) -> Result<Invoice, String> {
let conn = state.db.lock().map_err(|e| e.to_string())?;
fetch_invoice(&conn, id).map_err(|e| e.to_string())
get_invoice_impl(&conn, id)
}
pub fn save_draft_impl(