Period close, bank rec, AR/AP, multi-currency (41 MCP tools)

This commit is contained in:
swaits committed 2026-10-11 08:15:18 +00:00
1 parent dc852d5f7e
commit 26f15eb867
1 file changed
+399
+399
View File
@@ -0,0 +1,399 @@
//! Accounts receivable and payable: customers, vendors, invoices, bills.
//!
//! Invoices and bills are business documents that auto-post journal entries:
//!
//! * `create_invoice`: Dr `1200` Accounts Receivable, Cr `4000` Revenue.
//! * `create_bill`: Dr `6999` General Expenses, Cr `2000` Accounts Payable.
//! * `receive_payment`: Dr Cash, Cr A/R. Updates the invoice's paid amount.
//! * `pay_bill`: Dr A/P, Cr Cash. Updates the bill's paid amount.
//!
//! The control accounts (`1200`, `2000`) and the default revenue/expense
//! accounts (`4000`, `6999`) are created on demand if missing. Invoices and
//! bills respect closed periods: they cannot be created with dates in a
//! closed period.
use crate::LedgerError;
use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
/// Opaque customer handle.
pub type CustomerId = u64;
/// Opaque vendor handle.
pub type VendorId = u64;
/// Opaque invoice handle.
pub type InvoiceId = u64;
/// Opaque bill handle.
pub type BillId = u64;
/// A customer: someone we invoice.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Customer {
/// Opaque handle.
pub id: CustomerId,
/// Display name.
pub name: String,
}
/// A vendor: someone who bills us.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Vendor {
/// Opaque handle.
pub id: VendorId,
/// Display name.
pub name: String,
}
/// Payment status of an invoice or bill.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
pub enum DocumentStatus {
/// Nothing paid yet.
Open,
/// Partially paid.
Partial,
/// Fully paid.
Paid,
}
/// One line on an invoice or bill.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DocumentLine {
/// Description of the goods/services.
pub description: String,
/// Line amount (positive).
pub amount: Decimal,
}
/// A sales invoice: we billed a customer.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Invoice {
/// Opaque handle.
pub id: InvoiceId,
/// The customer.
pub customer_id: CustomerId,
/// Invoice date.
pub date: NaiveDate,
/// When payment is due.
pub due_date: NaiveDate,
/// Line items.
pub lines: Vec<DocumentLine>,
/// How much has been paid.
pub amount_paid: Decimal,
/// Derived from amount_paid vs total.
pub status: DocumentStatus,
/// The auto-posted journal entry.
pub entry_id: u64,
}
impl Invoice {
/// Total of all lines.
pub fn total(&self) -> Decimal {
self.lines.iter().map(|l| l.amount).sum()
}
/// Amount still owed.
pub fn balance(&self) -> Decimal {
self.total() - self.amount_paid
}
}
/// A vendor bill: we owe a vendor.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Bill {
/// Opaque handle.
pub id: BillId,
/// The vendor.
pub vendor_id: VendorId,
/// Bill date.
pub date: NaiveDate,
/// When payment is due.
pub due_date: NaiveDate,
/// Line items.
pub lines: Vec<DocumentLine>,
/// How much has been paid.
pub amount_paid: Decimal,
/// Derived from amount_paid vs total.
pub status: DocumentStatus,
/// The auto-posted journal entry.
pub entry_id: u64,
}
impl Bill {
/// Total of all lines.
pub fn total(&self) -> Decimal {
self.lines.iter().map(|l| l.amount).sum()
}
/// Amount still owed.
pub fn balance(&self) -> Decimal {
self.total() - self.amount_paid
}
}
/// Validate document lines: at least one, all positive amounts.
pub fn validate_lines(lines: &[DocumentLine]) -> Result<(), LedgerError> {
if lines.is_empty() {
return Err(LedgerError::EmptyDocumentLines);
}
for line in lines {
if line.amount <= Decimal::ZERO {
return Err(LedgerError::InvalidDocumentLineAmount(line.amount));
}
}
Ok(())
}
/// Validate that due date is not before the document date.
pub fn validate_due_date(date: NaiveDate, due_date: NaiveDate) -> Result<(), LedgerError> {
if due_date < date {
return Err(LedgerError::DueBeforeDate(due_date.to_string()));
}
Ok(())
}
/// Derive status from paid vs total.
pub fn derive_status(paid: Decimal, total: Decimal) -> DocumentStatus {
if paid.is_zero() {
DocumentStatus::Open
} else if paid >= total {
DocumentStatus::Paid
} else {
DocumentStatus::Partial
}
}
/// One row of an aging report.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AgingRow {
/// Customer or vendor name.
pub name: String,
/// Not yet due.
pub current: Decimal,
/// 1-30 days past due.
pub days_30: Decimal,
/// 31-60 days past due.
pub days_60: Decimal,
/// 61-90 days past due.
pub days_90: Decimal,
/// Over 90 days past due.
pub days_90_plus: Decimal,
}
impl AgingRow {
/// Total outstanding.
pub fn total(&self) -> Decimal {
self.current + self.days_30 + self.days_60 + self.days_90 + self.days_90_plus
}
}
/// Bucket a past-due balance by days overdue.
pub fn aging_bucket(due_date: NaiveDate, as_of: NaiveDate, balance: Decimal) -> AgingRow {
let mut row = AgingRow {
name: String::new(),
current: Decimal::ZERO,
days_30: Decimal::ZERO,
days_60: Decimal::ZERO,
days_90: Decimal::ZERO,
days_90_plus: Decimal::ZERO,
};
if due_date > as_of {
row.current = balance;
} else {
let days = (as_of - due_date).num_days();
if days <= 30 {
row.days_30 = balance;
} else if days <= 60 {
row.days_60 = balance;
} else if days <= 90 {
row.days_90 = balance;
} else {
row.days_90_plus = balance;
}
}
row
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{AccountType, Currency, Ledger, Money, Side};
fn arap_ledger() -> Ledger {
let mut l = Ledger::new(Currency::USD);
l.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
l
}
#[test]
fn invoice_lifecycle() {
let mut l = arap_ledger();
let cust = l.add_customer("Acme Corp").unwrap();
let inv = l
.create_invoice(
cust,
NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(),
NaiveDate::from_ymd_opt(2026, 10, 31).unwrap(),
vec![DocumentLine {
description: "Widgets".to_string(),
amount: "1000.00".parse().unwrap(),
}],
)
.unwrap();
let invoice = l.get_invoice(inv).unwrap();
assert_eq!(invoice.total(), "1000.00".parse().unwrap());
assert_eq!(invoice.status, DocumentStatus::Open);
// A/R has a debit balance of 1000.
let ar = l.chart().find_by_code("1200").unwrap().id;
assert_eq!(l.account_balance(ar).unwrap(), "1000".parse().unwrap());
// Partial payment.
let cash = l.chart().find_by_code("1000").unwrap().id;
l.receive_payment(
inv,
NaiveDate::from_ymd_opt(2026, 10, 15).unwrap(),
"400.00".parse().unwrap(),
cash,
)
.unwrap();
let invoice = l.get_invoice(inv).unwrap();
assert_eq!(invoice.status, DocumentStatus::Partial);
assert_eq!(invoice.balance(), "600.00".parse().unwrap());
// Full payment.
l.receive_payment(
inv,
NaiveDate::from_ymd_opt(2026, 10, 20).unwrap(),
"600.00".parse().unwrap(),
cash,
)
.unwrap();
let invoice = l.get_invoice(inv).unwrap();
assert_eq!(invoice.status, DocumentStatus::Paid);
assert_eq!(l.account_balance(ar).unwrap(), Decimal::ZERO);
}
#[test]
fn bill_lifecycle() {
let mut l = arap_ledger();
let vend = l.add_vendor("Supply Co").unwrap();
let bill = l
.create_bill(
vend,
NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(),
NaiveDate::from_ymd_opt(2026, 10, 31).unwrap(),
vec![DocumentLine {
description: "Paper".to_string(),
amount: "250.00".parse().unwrap(),
}],
)
.unwrap();
let ap = l.chart().find_by_code("2000").unwrap().id;
// A/P is credit-normal; balance should be 250 (credit).
assert_eq!(l.account_balance(ap).unwrap(), "250".parse().unwrap());
let cash = l.chart().find_by_code("1000").unwrap().id;
// Seed cash so the payment doesn't go negative (not required, but tidy).
let e = l.create_entry(NaiveDate::from_ymd_opt(2026, 9, 1).unwrap(), "seed");
let m = Money::new("1000.00".parse().unwrap(), Currency::USD);
l.add_line(e, cash, Side::Debit, m).unwrap();
let eq = l
.add_account("3000", "Equity", AccountType::Equity, None, false)
.unwrap();
l.add_line(e, eq, Side::Credit, m).unwrap();
l.post_entry(e).unwrap();
l.pay_bill(
bill,
NaiveDate::from_ymd_opt(2026, 10, 15).unwrap(),
"250.00".parse().unwrap(),
cash,
)
.unwrap();
assert_eq!(l.get_bill(bill).unwrap().status, DocumentStatus::Paid);
assert_eq!(l.account_balance(ap).unwrap(), Decimal::ZERO);
}
#[test]
fn overpayment_rejected() {
let mut l = arap_ledger();
let cust = l.add_customer("Acme").unwrap();
let inv = l
.create_invoice(
cust,
NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(),
NaiveDate::from_ymd_opt(2026, 10, 31).unwrap(),
vec![DocumentLine {
description: "X".to_string(),
amount: "100.00".parse().unwrap(),
}],
)
.unwrap();
let cash = l.chart().find_by_code("1000").unwrap().id;
let err = l.receive_payment(
inv,
NaiveDate::from_ymd_opt(2026, 10, 15).unwrap(),
"150.00".parse().unwrap(),
cash,
);
assert!(matches!(err, Err(LedgerError::Overpayment(_, _))));
}
#[test]
fn ar_aging_buckets_correctly() {
let mut l = arap_ledger();
let cust = l.add_customer("Acme").unwrap();
// Due in the future: current.
l.create_invoice(
cust,
NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(),
NaiveDate::from_ymd_opt(2026, 11, 15).unwrap(),
vec![DocumentLine {
description: "A".to_string(),
amount: "100.00".parse().unwrap(),
}],
)
.unwrap();
// 45 days past due: 60 bucket.
l.create_invoice(
cust,
NaiveDate::from_ymd_opt(2026, 7, 1).unwrap(),
NaiveDate::from_ymd_opt(2026, 8, 17).unwrap(),
vec![DocumentLine {
description: "B".to_string(),
amount: "200.00".parse().unwrap(),
}],
)
.unwrap();
let as_of = NaiveDate::from_ymd_opt(2026, 10, 1).unwrap();
let aging = l.ar_aging(as_of);
assert_eq!(aging.len(), 1);
assert_eq!(aging[0].current, "100".parse().unwrap());
assert_eq!(aging[0].days_60, "200".parse().unwrap());
}
#[test]
fn invoice_in_closed_period_rejected() {
let mut l = arap_ledger();
let cust = l.add_customer("Acme").unwrap();
let pid = l
.create_period(
"2026-10",
NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(),
NaiveDate::from_ymd_opt(2026, 10, 31).unwrap(),
)
.unwrap();
l.close_period(pid).unwrap();
let err = l.create_invoice(
cust,
NaiveDate::from_ymd_opt(2026, 10, 15).unwrap(),
NaiveDate::from_ymd_opt(2026, 11, 15).unwrap(),
vec![DocumentLine {
description: "X".to_string(),
amount: "100.00".parse().unwrap(),
}],
);
assert!(matches!(err, Err(LedgerError::ClosedPeriod(_, _))));
}
}