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:
@@ -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<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)?,
|
||||
})
|
||||
}
|
||||
|
||||
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<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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user