merge: P4b S6, paying on terms (branch S6, 6c4480cd)
The third of the paused branches: Ledger::settle with discounts and the processor's fee (kernel::settle, Kani settle_is_exact, Lean settle_posts_a_document_step), books.receive_payment and books.pay_bill with applications, books.bills_due, the agings' detail and tie-out, the aging exports and the CPA package's aging CSVs, the handlers in tools/settlements.rs. Twelve files conflicted with the S4.4 and S5 merges, resolved as plan 8.0.2 says: both routers; both test appends (the shared match arm in arap_standing.rs spliced into both tests; the shared `.unwrap()` in store.rs); the scope table at 124; one invoices-and-bills handbook (S5's send step 1 with S6's steps 2, 3, 5 and procedures) registered once; docs/mcp-interface.md with S4.4's create docs and S6's payment docs; S5's law renumbered 35 and S6's 36 (S1's is 34); the parity rows and sections of both. S6's wire tests create their bills as S4.4's contract now requires (confirmed, the vendor's number, a line account, the vendor's invoice uploaded) and live in their own module beside S4.4's tests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
commit
be58059830
45 files changed
+3949
-520
No files matched your search
@@ -555,8 +555,8 @@ const SPEC: Spec = &[
|
|||||||
(
|
(
|
||||||
42,
|
42,
|
||||||
Area::Arap,
|
Area::Arap,
|
||||||
"Settlements and the fee (allocated, not yet stated)",
|
"A settlement is exact, one party, and names its lines; the gross discount and the gross fee",
|
||||||
None,
|
Some(Status::Convention),
|
||||||
&[],
|
&[],
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ what its statement rests on and the examples that demonstrate it.
|
|||||||
A rule whose only source is a human choice carries rules.md's **CHOICE**, bookspec's status of the same name:
|
A rule whose only source is a human choice carries rules.md's **CHOICE**, bookspec's status of the same name:
|
||||||
the specification names the options, an engine declares its pick
|
the specification names the options, an engine declares its pick
|
||||||
(`claims.choices`, `bookspec_checker::Claims`). Rules B-39 to B-45 are
|
(`claims.choices`, `bookspec_checker::Claims`). Rules B-39 to B-45 are
|
||||||
allocated in `plan/served.md` 8.0.3 and not yet stated: their
|
allocated in `plan/served.md` 8.0.3; those not yet stated keep only
|
||||||
identifier and area are fixed here so a later unit fills them in
|
their identifier and area here, so a later unit fills them in without
|
||||||
without moving anyone else's number.
|
moving anyone else's number.
|
||||||
|
|
||||||
| B | bookspec id | area | status | rule | examples |
|
| B | bookspec id | area | status | rule | examples |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
@@ -54,7 +54,7 @@ without moving anyone else's number.
|
|||||||
| B-39 | `BS-arap-3` | arap | — | Terms: when a document is due (allocated, not yet stated) | — |
|
| B-39 | `BS-arap-3` | arap | — | Terms: when a document is due (allocated, not yet stated) | — |
|
||||||
| B-40 | `BS-arap-4` | arap | — | Numbers and the issued copy (allocated, not yet stated) | — |
|
| B-40 | `BS-arap-4` | arap | — | Numbers and the issued copy (allocated, not yet stated) | — |
|
||||||
| B-41 | `BS-arap-5` | arap | — | Write-offs (allocated, not yet stated) | — |
|
| B-41 | `BS-arap-5` | arap | — | Write-offs (allocated, not yet stated) | — |
|
||||||
| B-42 | `BS-arap-6` | arap | — | Settlements and the fee (allocated, not yet stated) | — |
|
| B-42 | `BS-arap-6` | arap | CONVENTION | A settlement is exact, one party, and names its lines; the gross discount and the gross fee | — |
|
||||||
| B-43 | `BS-entry-5` | entry | — | Drafts and evidence (allocated, not yet stated) | — |
|
| B-43 | `BS-entry-5` | entry | — | Drafts and evidence (allocated, not yet stated) | — |
|
||||||
| B-44 | `BS-ledger-12` | ledger | — | Extraction and dedup (allocated, not yet stated) | — |
|
| B-44 | `BS-ledger-12` | ledger | — | Extraction and dedup (allocated, not yet stated) | — |
|
||||||
| B-45 | `BS-ledger-13` | ledger | — | Audits and attestations (allocated, not yet stated) | — |
|
| B-45 | `BS-ledger-13` | ledger | — | Audits and attestations (allocated, not yet stated) | — |
|
||||||
@@ -795,3 +795,420 @@ fn mark_sent_records_who_and_how() {
|
|||||||
"a send changes no figure"
|
"a send changes no figure"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P4b S6: paying on terms (merged from branch S6), in its own module so its
|
||||||
|
// helpers keep their names beside S4.4's.
|
||||||
|
mod paying_on_terms {
|
||||||
|
use super::common::answer_of;
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Call a tool and unwrap its answer.
|
||||||
|
fn ok(w: &mut Wire, tool: &str, args: Value) -> Value {
|
||||||
|
answer_of(&w.call(tool, args)).unwrap_or_else(|e| panic!("{tool}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The refusal's text.
|
||||||
|
fn err(w: &mut Wire, tool: &str, args: Value) -> String {
|
||||||
|
answer_of(&w.call(tool, args)).expect_err(&format!("{tool} was accepted"))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Setup {
|
||||||
|
_books: Books,
|
||||||
|
wire: Wire,
|
||||||
|
customer: u64,
|
||||||
|
vendor: u64,
|
||||||
|
bills: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bank 1000, card 2050, capital 3000, a customer and a vendor, then a
|
||||||
|
/// session.
|
||||||
|
fn setup(tag: &str) -> Setup {
|
||||||
|
let books = Books::new(tag);
|
||||||
|
let mut wire = books.session("documents");
|
||||||
|
ok(
|
||||||
|
&mut wire,
|
||||||
|
"books.add_account",
|
||||||
|
json!({"code": "1000", "name": "Bank", "account_type": "asset"}),
|
||||||
|
);
|
||||||
|
ok(
|
||||||
|
&mut wire,
|
||||||
|
"books.add_account",
|
||||||
|
json!({"code": "2050", "name": "Card", "account_type": "liability"}),
|
||||||
|
);
|
||||||
|
ok(
|
||||||
|
&mut wire,
|
||||||
|
"books.add_account",
|
||||||
|
json!({"code": "3000", "name": "Capital", "account_type": "equity"}),
|
||||||
|
);
|
||||||
|
ok(
|
||||||
|
&mut wire,
|
||||||
|
"books.add_account",
|
||||||
|
json!({"code": "6999", "name": "General Expenses", "account_type": "expense"}),
|
||||||
|
);
|
||||||
|
let customer = ok(
|
||||||
|
&mut wire,
|
||||||
|
"books.add_customer",
|
||||||
|
json!({"name": "Fernhill Labs"}),
|
||||||
|
)["id"]
|
||||||
|
.as_u64()
|
||||||
|
.unwrap();
|
||||||
|
let vendor = ok(
|
||||||
|
&mut wire,
|
||||||
|
"books.add_vendor",
|
||||||
|
json!({"name": "Tobin Harlow Supply"}),
|
||||||
|
)["id"]
|
||||||
|
.as_u64()
|
||||||
|
.unwrap();
|
||||||
|
Setup {
|
||||||
|
_books: books,
|
||||||
|
wire,
|
||||||
|
customer,
|
||||||
|
vendor,
|
||||||
|
bills: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Setup {
|
||||||
|
fn invoice(&mut self, date: &str, due: &str, amount: &str) -> u64 {
|
||||||
|
ok(
|
||||||
|
&mut self.wire,
|
||||||
|
"books.create_invoice",
|
||||||
|
json!({"party_id": self.customer, "date": date, "due_date": due,
|
||||||
|
"lines": [{"description": "Consulting", "amount": amount}],
|
||||||
|
"confirmed": true}),
|
||||||
|
)["id"]
|
||||||
|
.as_u64()
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bill(&mut self, date: &str, due: &str, amount: &str) -> u64 {
|
||||||
|
// Under documents_v2 (P4b S4.4) a bill names the vendor's invoice
|
||||||
|
// number, each line's account, and carries the vendor's invoice.
|
||||||
|
self.bills += 1;
|
||||||
|
let file = self.wire.upload(
|
||||||
|
format!("vendor invoice TH-{}", self.bills).as_bytes(),
|
||||||
|
"bill.pdf",
|
||||||
|
"application/pdf",
|
||||||
|
"receipt",
|
||||||
|
);
|
||||||
|
ok(
|
||||||
|
&mut self.wire,
|
||||||
|
"books.create_bill",
|
||||||
|
json!({"party_id": self.vendor, "date": date, "due_date": due,
|
||||||
|
"vendor_ref": format!("TH-{}", self.bills),
|
||||||
|
"lines": [{"description": "Paper", "amount": amount, "account": "6999"}],
|
||||||
|
"upload_id": file, "confirmed": true}),
|
||||||
|
)["id"]
|
||||||
|
.as_u64()
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn line(&mut self, entry: u64, code: &str) -> (String, String, String) {
|
||||||
|
let e = ok(
|
||||||
|
&mut self.wire,
|
||||||
|
"books.get_entry",
|
||||||
|
json!({"entry_id": entry}),
|
||||||
|
);
|
||||||
|
for l in e["lines"].as_array().unwrap() {
|
||||||
|
if l["code"] == json!(code) {
|
||||||
|
return (
|
||||||
|
l["side"].as_str().unwrap().to_string(),
|
||||||
|
l["amount"].as_str().unwrap().to_string(),
|
||||||
|
code.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic!("{code} not in entry {entry}: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One deposit settles two invoices of one customer: one entry, one
|
||||||
|
/// receivable line per invoice, each tagged with its own invoice, and the
|
||||||
|
/// output names both documents (B-42 INVARIANTS 1 and 3).
|
||||||
|
#[test]
|
||||||
|
fn one_payment_settles_two_invoices() {
|
||||||
|
let mut s = setup("settle-two");
|
||||||
|
let first = s.invoice("2026-02-01", "2026-03-01", "500.00");
|
||||||
|
let second = s.invoice("2026-02-05", "2026-04-30", "80.00");
|
||||||
|
let out = ok(
|
||||||
|
&mut s.wire,
|
||||||
|
"books.receive_payment",
|
||||||
|
json!({"date": "2026-02-10", "deposit_to": "1000",
|
||||||
|
"reference": "one deposit",
|
||||||
|
"applications": [
|
||||||
|
{"invoice": first, "amount": "300.00"},
|
||||||
|
{"invoice": second, "amount": "80.00"}
|
||||||
|
]}),
|
||||||
|
);
|
||||||
|
assert_eq!(out["deposited"], json!("380.00"));
|
||||||
|
let refs: Vec<&str> = out["documents"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|d| d["ref"].as_str().unwrap())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
refs,
|
||||||
|
[format!("invoice:{first}"), format!("invoice:{second}")]
|
||||||
|
);
|
||||||
|
assert_eq!(out["documents"][0]["outstanding"], json!("200.00"));
|
||||||
|
assert_eq!(out["documents"][1]["status"], json!("paid"));
|
||||||
|
// The entry: one deposit line, two receivable lines.
|
||||||
|
let entry = out["entry_id"].as_u64().unwrap();
|
||||||
|
let e = ok(&mut s.wire, "books.get_entry", json!({"entry_id": entry}));
|
||||||
|
let lines = e["lines"].as_array().unwrap();
|
||||||
|
assert_eq!(lines.len(), 3);
|
||||||
|
assert_eq!(lines[0]["side"], json!("credit"));
|
||||||
|
assert_eq!(lines[0]["amount"], json!("300.00"));
|
||||||
|
assert_eq!(lines[1]["side"], json!("credit"));
|
||||||
|
assert_eq!(lines[1]["amount"], json!("80.00"));
|
||||||
|
// Each invoice's timeline shows its own payment from this entry.
|
||||||
|
for (id, paid) in [(first, "300.00"), (second, "80.00")] {
|
||||||
|
let d = ok(
|
||||||
|
&mut s.wire,
|
||||||
|
"books.document",
|
||||||
|
json!({"ref": format!("invoice:{id}"), "as_of": "2026-12-31"}),
|
||||||
|
);
|
||||||
|
let payment = d["timeline"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|t| t["kind"] == json!("payment"))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(payment["entry_id"].as_u64().unwrap(), entry);
|
||||||
|
assert_eq!(payment["amount"], json!(format!("-{paid}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A discount outside the document's terms needs a reason; with one the
|
||||||
|
/// payment is taken and recorded (B-42 INVARIANT 2; the terms are S4.3's
|
||||||
|
/// to journal, so this bill states none).
|
||||||
|
#[test]
|
||||||
|
fn late_discount_needs_a_reason() {
|
||||||
|
let mut s = setup("late-discount");
|
||||||
|
let bill = s.bill("2026-02-01", "2026-03-03", "100.00");
|
||||||
|
let applications = |reason: Option<&str>| {
|
||||||
|
let mut a = json!([{"bill": bill, "amount": "98.00", "discount": "2.00"}]);
|
||||||
|
if let Some(reason) = reason {
|
||||||
|
a[0]["discount_reason"] = json!(reason);
|
||||||
|
}
|
||||||
|
a
|
||||||
|
};
|
||||||
|
let why = err(
|
||||||
|
&mut s.wire,
|
||||||
|
"books.pay_bill",
|
||||||
|
json!({"date": "2026-02-05", "paid_from": "1000", "applications": applications(None)}),
|
||||||
|
);
|
||||||
|
assert!(why.contains("needs a reason"), "{why}");
|
||||||
|
assert!(why.contains("discount_reason"), "{why}");
|
||||||
|
ok(
|
||||||
|
&mut s.wire,
|
||||||
|
"books.pay_bill",
|
||||||
|
json!({"date": "2026-02-05", "paid_from": "1000",
|
||||||
|
"applications": applications(Some("the vendor honoured it late"))}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fee posts gross (A-4): the deposit gets the net, the fee its expense,
|
||||||
|
/// and A/R is credited the gross (B-42 INVARIANT 1).
|
||||||
|
#[test]
|
||||||
|
fn a_fee_posts_gross_to_receivables_and_net_to_the_bank() {
|
||||||
|
let mut s = setup("fee-gross");
|
||||||
|
let invoice = s.invoice("2026-02-01", "2026-03-01", "100.00");
|
||||||
|
let out = ok(
|
||||||
|
&mut s.wire,
|
||||||
|
"books.receive_payment",
|
||||||
|
json!({"date": "2026-02-05", "deposit_to": "1000",
|
||||||
|
"invoice": invoice, "amount": "100.00",
|
||||||
|
"fee": {"amount": "3.00"}}),
|
||||||
|
);
|
||||||
|
assert_eq!(out["deposited"], json!("97.00"));
|
||||||
|
assert_eq!(out["status"], json!("paid"));
|
||||||
|
let entry = out["entry_id"].as_u64().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
s.line(entry, "1200"),
|
||||||
|
("credit".into(), "100.00".into(), "1200".into())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
s.line(entry, "6060"),
|
||||||
|
("debit".into(), "3.00".into(), "6060".into())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
s.line(entry, "1000"),
|
||||||
|
("debit".into(), "97.00".into(), "1000".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A bill paid by card is excluded from 1099-NEC (D-40): the worksheet
|
||||||
|
/// counts it as card, not as paid.
|
||||||
|
#[test]
|
||||||
|
fn card_paid_bill_is_not_1099_nec() {
|
||||||
|
let mut s = setup("card-1099");
|
||||||
|
ok(
|
||||||
|
&mut s.wire,
|
||||||
|
"books.add_instrument",
|
||||||
|
json!({"kind": "business_card", "label": "Visa", "last4": "4242", "account": "2050"}),
|
||||||
|
);
|
||||||
|
let bill = s.bill("2026-02-01", "2026-03-03", "100.00");
|
||||||
|
ok(
|
||||||
|
&mut s.wire,
|
||||||
|
"books.pay_bill",
|
||||||
|
json!({"date": "2026-02-05", "paid_from": "2050", "bill": bill, "amount": "100.00",
|
||||||
|
"instrument": "Visa"}),
|
||||||
|
);
|
||||||
|
let worksheet = ok(
|
||||||
|
&mut s.wire,
|
||||||
|
"books.vendor_1099",
|
||||||
|
json!({"year": 2026, "cash_accounts": ["1000"]}),
|
||||||
|
);
|
||||||
|
let line = worksheet["vendors"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|v| v["vendor_id"] == json!(s.vendor))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(line["paid"], json!("0.00"), "{worksheet}");
|
||||||
|
assert_eq!(line["excluded_card"], json!("100.00"), "{worksheet}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two bills paid in one entry count once toward 1099s and the party's
|
||||||
|
/// totals (B-42; 8.3.16's multi-document risk).
|
||||||
|
#[test]
|
||||||
|
fn multi_document_payment_counts_once_for_1099_and_party_totals() {
|
||||||
|
let mut s = setup("multi-1099");
|
||||||
|
let first = s.bill("2026-02-01", "2026-03-03", "100.00");
|
||||||
|
let second = s.bill("2026-02-02", "2026-03-04", "200.00");
|
||||||
|
ok(
|
||||||
|
&mut s.wire,
|
||||||
|
"books.pay_bill",
|
||||||
|
json!({"date": "2026-02-05", "paid_from": "1000",
|
||||||
|
"applications": [
|
||||||
|
{"bill": first, "amount": "100.00"},
|
||||||
|
{"bill": second, "amount": "200.00"}
|
||||||
|
]}),
|
||||||
|
);
|
||||||
|
let worksheet = ok(
|
||||||
|
&mut s.wire,
|
||||||
|
"books.vendor_1099",
|
||||||
|
json!({"year": 2026, "cash_accounts": ["1000"]}),
|
||||||
|
);
|
||||||
|
let line = worksheet["vendors"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|v| v["vendor_id"] == json!(s.vendor))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(line["paid"], json!("300.00"), "{worksheet}");
|
||||||
|
let parties = ok(&mut s.wire, "books.parties", json!({"year": 2026}));
|
||||||
|
let party = parties["parties"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|p| p["name"] == json!("Tobin Harlow Supply"))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(party["paid"], json!("300.00"), "{parties}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An overpayment is refused showing what the document owes and the
|
||||||
|
/// remedy for the excess (A-12, D-49).
|
||||||
|
#[test]
|
||||||
|
fn overpayment_refused_with_the_excess() {
|
||||||
|
let mut s = setup("overpayment");
|
||||||
|
let invoice = s.invoice("2026-02-01", "2026-03-01", "100.00");
|
||||||
|
let why = err(
|
||||||
|
&mut s.wire,
|
||||||
|
"books.receive_payment",
|
||||||
|
json!({"date": "2026-02-05", "deposit_to": "1000", "invoice": invoice, "amount": "150.00"}),
|
||||||
|
);
|
||||||
|
assert!(why.contains("150.00"), "{why}");
|
||||||
|
assert!(why.contains("100.00"), "{why}");
|
||||||
|
assert!(why.contains("excess"), "{why}");
|
||||||
|
assert!(why.contains("2300"), "{why}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bills-due list orders by due date and gives the running total
|
||||||
|
/// through each date; the agings' detail rows and their tie-out agree with
|
||||||
|
/// the control check (8.3.10; B-42).
|
||||||
|
#[test]
|
||||||
|
fn bills_due_orders_by_due_and_discount() {
|
||||||
|
let mut s = setup("bills-due");
|
||||||
|
s.bill("2026-02-01", "2026-02-20", "100.00");
|
||||||
|
s.bill("2026-02-02", "2026-02-10", "250.00");
|
||||||
|
s.bill("2026-02-03", "2026-03-10", "400.00");
|
||||||
|
let due = ok(
|
||||||
|
&mut s.wire,
|
||||||
|
"books.bills_due",
|
||||||
|
json!({"through": "2026-02-28", "as_of": "2026-02-05"}),
|
||||||
|
);
|
||||||
|
let dates: Vec<&str> = due["bills"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|b| b["due_date"].as_str().unwrap())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(dates, ["2026-02-10", "2026-02-20"]);
|
||||||
|
assert_eq!(
|
||||||
|
due["by_date"],
|
||||||
|
json!([
|
||||||
|
{"date": "2026-02-10", "amount": "250.00"},
|
||||||
|
{"date": "2026-02-20", "amount": "350.00"}
|
||||||
|
])
|
||||||
|
);
|
||||||
|
// The discount fields exist only when the bill's terms give one, which
|
||||||
|
// S4.3 journals: without them they are absent, not zero.
|
||||||
|
assert!(due["bills"][0].get("discount_by").is_none());
|
||||||
|
let aging = ok(
|
||||||
|
&mut s.wire,
|
||||||
|
"books.ap_aging",
|
||||||
|
json!({"as_of": "2026-02-05", "detail": true}),
|
||||||
|
);
|
||||||
|
assert_eq!(aging["control"]["difference"], json!("0.00"), "{aging}");
|
||||||
|
assert_eq!(aging["control"]["documents_total"], json!("750.00"));
|
||||||
|
let rows = aging["documents"].as_array().unwrap();
|
||||||
|
assert_eq!(rows.len(), 3);
|
||||||
|
assert_eq!(rows[0]["ref"], json!(format!("bill:{}", 2)));
|
||||||
|
assert_eq!(rows[0]["bucket"], json!("current"));
|
||||||
|
// A/R is empty and still ties.
|
||||||
|
let ar = ok(
|
||||||
|
&mut s.wire,
|
||||||
|
"books.ar_aging",
|
||||||
|
json!({"as_of": "2026-02-05", "detail": true}),
|
||||||
|
);
|
||||||
|
assert_eq!(ar["control"]["balance"], json!("0.00"));
|
||||||
|
assert_eq!(ar["control"]["difference"], json!("0.00"));
|
||||||
|
assert!(ar.get("documents").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The aging exports are the same CSV the CPA package carries: one row per
|
||||||
|
/// document and a tie-out line, and the CPA package's CSVs tie to the
|
||||||
|
/// control account (8.3.10; ledger-package's test repeats the tie-out).
|
||||||
|
#[test]
|
||||||
|
fn the_aging_export_ties_out() {
|
||||||
|
let mut s = setup("aging-export");
|
||||||
|
let invoice = s.invoice("2026-02-01", "2026-03-01", "500.00");
|
||||||
|
ok(
|
||||||
|
&mut s.wire,
|
||||||
|
"books.receive_payment",
|
||||||
|
json!({"date": "2026-02-05", "deposit_to": "1000", "invoice": invoice, "amount": "200.00"}),
|
||||||
|
);
|
||||||
|
let out = ok(
|
||||||
|
&mut s.wire,
|
||||||
|
"books.export",
|
||||||
|
json!({"format": "csv", "report": "ar_aging", "as_of": "2026-02-28"}),
|
||||||
|
);
|
||||||
|
let csv = s.wire.fetch_text(&out["file"]);
|
||||||
|
let mut lines = csv.lines();
|
||||||
|
assert_eq!(
|
||||||
|
lines.next(),
|
||||||
|
Some(
|
||||||
|
"number,party,date,due_date,terms,total,paid,discounted,outstanding,bucket,days_overdue"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert!(lines.next().unwrap().contains("500.00"));
|
||||||
|
let total = lines.next().unwrap();
|
||||||
|
assert!(total.starts_with("TOTAL,,,,,,,,"), "{total}");
|
||||||
|
assert!(total.contains("300.00"), "{total}");
|
||||||
|
assert!(total.contains("GL 1200 300.00"), "{total}");
|
||||||
|
assert!(total.ends_with("difference 0.00"), "{total}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1222,6 +1222,8 @@ fn mcp_statement_pdfs_and_cpa_package() {
|
|||||||
[
|
[
|
||||||
"1099-worksheet-2026.csv",
|
"1099-worksheet-2026.csv",
|
||||||
"README.txt",
|
"README.txt",
|
||||||
|
"ap_aging.csv",
|
||||||
|
"ar_aging.csv",
|
||||||
"balance-sheet.pdf",
|
"balance-sheet.pdf",
|
||||||
"balance-sheet.xlsx",
|
"balance-sheet.xlsx",
|
||||||
"cash-flow.pdf",
|
"cash-flow.pdf",
|
||||||
|
|||||||
@@ -887,6 +887,7 @@ fn happy_path(name: &str) -> Mcp {
|
|||||||
);
|
);
|
||||||
m.ok("books.ar_aging", json!({"as_of": "2026-06-30"}));
|
m.ok("books.ar_aging", json!({"as_of": "2026-06-30"}));
|
||||||
m.ok("books.ap_aging", json!({"as_of": "2026-06-30"}));
|
m.ok("books.ap_aging", json!({"as_of": "2026-06-30"}));
|
||||||
|
m.ok("books.bills_due", json!({"through": "2026-06-30"}));
|
||||||
// P4b S1: a document voided, and the control accounts' tie-out.
|
// P4b S1: a document voided, and the control accounts' tie-out.
|
||||||
let dup = m.ok(
|
let dup = m.ok(
|
||||||
"books.create_invoice",
|
"books.create_invoice",
|
||||||
@@ -1193,10 +1194,14 @@ fn happy_path(name: &str) -> Mcp {
|
|||||||
("csv", "general_ledger"),
|
("csv", "general_ledger"),
|
||||||
("json", "members_equity"),
|
("json", "members_equity"),
|
||||||
("csv", "tax_summary"),
|
("csv", "tax_summary"),
|
||||||
|
("csv", "ar_aging"),
|
||||||
|
("xlsx", "ar_aging"),
|
||||||
|
("pdf", "ap_aging"),
|
||||||
] {
|
] {
|
||||||
m.ok(
|
m.ok(
|
||||||
"books.export",
|
"books.export",
|
||||||
json!({"format": format, "report": report, "start": "2026-01-01", "end": "2026-12-31"}),
|
json!({"format": format, "report": report, "start": "2026-01-01", "end": "2026-12-31",
|
||||||
|
"as_of": "2026-12-31"}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// The IIF takes as_of (every posted entry from the first through it),
|
// The IIF takes as_of (every posted entry from the first through it),
|
||||||
|
|||||||
+112
-13
@@ -425,6 +425,64 @@ impl PaymentDetails {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One document a settlement applies to (plan P4b S6; rules.md B-42): the
|
||||||
|
/// cash paid on it, the early-payment discount taken off it, and why a
|
||||||
|
/// discount outside its terms was taken anyway.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Application {
|
||||||
|
/// The invoice or bill it applies to.
|
||||||
|
pub doc: DocRef,
|
||||||
|
/// The cash paid against it, positive.
|
||||||
|
pub cash: Decimal,
|
||||||
|
/// The early-payment discount taken, zero when none.
|
||||||
|
pub discount: Decimal,
|
||||||
|
/// Why a discount outside the document's terms was taken (a vendor who
|
||||||
|
/// honours a late discount): one line, journaled with the payment.
|
||||||
|
pub discount_reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A processor's fee withheld from a customer payment (plan P4b S6; A-4):
|
||||||
|
/// receivables are credited the gross, the fee goes to its expense
|
||||||
|
/// account, and the deposit account gets the net.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Fee {
|
||||||
|
/// The fee, positive.
|
||||||
|
pub amount: Decimal,
|
||||||
|
/// The expense account it goes to; `None` is the
|
||||||
|
/// `payment_processing_fees` role's account.
|
||||||
|
pub account: Option<crate::AccountId>,
|
||||||
|
/// The processor, when the books know it as a vendor.
|
||||||
|
pub vendor: Option<VendorId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything one settlement needs (plan P4b S6; rules.md B-42): its date,
|
||||||
|
/// description, deposit account, the party every document belongs to, the
|
||||||
|
/// applications, and the processor's fee when one was withheld.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct SettleParams {
|
||||||
|
/// The settlement's date.
|
||||||
|
pub date: NaiveDate,
|
||||||
|
/// The entry's description.
|
||||||
|
pub description: String,
|
||||||
|
/// The account the cash reaches (or leaves): a bank, a card, a payable
|
||||||
|
/// holding no role.
|
||||||
|
pub cash_account: crate::AccountId,
|
||||||
|
/// The customer or vendor every application names.
|
||||||
|
pub party: u64,
|
||||||
|
/// One or more documents, one party, one kind.
|
||||||
|
pub applications: Vec<Application>,
|
||||||
|
/// The processor's fee, on a customer payment only.
|
||||||
|
pub fee: Option<Fee>,
|
||||||
|
/// The payment method, when known (D-40).
|
||||||
|
pub instrument: Option<crate::InstrumentId>,
|
||||||
|
/// The processor's or bank's reference, when there is one.
|
||||||
|
pub reference: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One posted line a settlement's plan asks for, with the document it tags
|
||||||
|
/// when it is a control line ([`Application`]'s payment or discount).
|
||||||
|
pub type SettlementRow = (crate::AccountId, crate::Side, Decimal, Option<DocTag>);
|
||||||
|
|
||||||
/// How a document's payment terms count their days (rules.md B-39; plan
|
/// How a document's payment terms count their days (rules.md B-39; plan
|
||||||
/// P4b S4.1): from the document's date, from the end of its month, or a
|
/// P4b S4.1): from the document's date, from the end of its month, or a
|
||||||
/// date the parties agreed.
|
/// date the parties agreed.
|
||||||
@@ -1264,6 +1322,35 @@ impl AgingRow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One document in an aging's detail: its figures and the bucket it falls
|
||||||
|
/// in (plan P4b S6, 8.3.10). Every figure is derived from its tagged lines
|
||||||
|
/// ([`crate::Ledger::doc_state`]).
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct AgingDocument {
|
||||||
|
/// The invoice or bill.
|
||||||
|
pub doc: DocRef,
|
||||||
|
/// The customer or vendor.
|
||||||
|
pub party: String,
|
||||||
|
/// Its own date.
|
||||||
|
pub date: NaiveDate,
|
||||||
|
/// When payment is due.
|
||||||
|
pub due_date: NaiveDate,
|
||||||
|
/// Its terms, as they print ("2/10 net 30"), when it has them.
|
||||||
|
pub terms: Option<String>,
|
||||||
|
/// The sum of its lines.
|
||||||
|
pub total: Decimal,
|
||||||
|
/// Cash paid that stands.
|
||||||
|
pub paid: Decimal,
|
||||||
|
/// Discounts taken that stand.
|
||||||
|
pub discounted: Decimal,
|
||||||
|
/// What it still owes as of the day.
|
||||||
|
pub outstanding: Decimal,
|
||||||
|
/// `current`, `days_30`, `days_60`, `days_90` or `days_90_plus`.
|
||||||
|
pub bucket: &'static str,
|
||||||
|
/// Days past its due date while something is owed; 0 otherwise.
|
||||||
|
pub days_overdue: i64,
|
||||||
|
}
|
||||||
|
|
||||||
/// Put an open balance in its aging bucket.
|
/// Put an open balance in its aging bucket.
|
||||||
///
|
///
|
||||||
/// ```text
|
/// ```text
|
||||||
@@ -1296,23 +1383,35 @@ pub fn aging_bucket(due_date: NaiveDate, as_of: NaiveDate, balance: Decimal) ->
|
|||||||
days_90: Decimal::ZERO,
|
days_90: Decimal::ZERO,
|
||||||
days_90_plus: Decimal::ZERO,
|
days_90_plus: Decimal::ZERO,
|
||||||
};
|
};
|
||||||
if due_date >= as_of {
|
match bucket_key(due_date, as_of) {
|
||||||
row.current = balance;
|
"current" => row.current = balance,
|
||||||
} else {
|
"days_30" => row.days_30 = balance,
|
||||||
let days = (as_of - due_date).num_days();
|
"days_60" => row.days_60 = balance,
|
||||||
if days <= 30 {
|
"days_90" => row.days_90 = balance,
|
||||||
row.days_30 = balance;
|
_ => row.days_90_plus = balance,
|
||||||
} else if days <= 60 {
|
|
||||||
row.days_60 = balance;
|
|
||||||
} else if days <= 90 {
|
|
||||||
row.days_90 = balance;
|
|
||||||
} else {
|
|
||||||
row.days_90_plus = balance;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
row
|
row
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The aging bucket a document due `due_date` falls in as of `as_of`, by
|
||||||
|
/// the edges [`aging_bucket`] uses: `current` (due today is current),
|
||||||
|
/// `days_30`, `days_60`, `days_90` or `days_90_plus`.
|
||||||
|
pub fn bucket_key(due_date: NaiveDate, as_of: NaiveDate) -> &'static str {
|
||||||
|
if due_date >= as_of {
|
||||||
|
return "current";
|
||||||
|
}
|
||||||
|
let days = (as_of - due_date).num_days();
|
||||||
|
if days <= 30 {
|
||||||
|
"days_30"
|
||||||
|
} else if days <= 60 {
|
||||||
|
"days_60"
|
||||||
|
} else if days <= 90 {
|
||||||
|
"days_90"
|
||||||
|
} else {
|
||||||
|
"days_90_plus"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -676,9 +676,80 @@ pub enum LedgerError {
|
|||||||
InvalidPaymentAmount(Decimal),
|
InvalidPaymentAmount(Decimal),
|
||||||
|
|
||||||
/// Payment exceeds the remaining balance.
|
/// Payment exceeds the remaining balance.
|
||||||
#[error("payment of {0} exceeds remaining balance of {1}")]
|
#[error(
|
||||||
|
"payment of {0} exceeds remaining balance of {1}; the difference is the excess: record it as a plain entry to a customer-deposits liability (2300) and apply it later"
|
||||||
|
)]
|
||||||
Overpayment(Decimal, Decimal),
|
Overpayment(Decimal, Decimal),
|
||||||
|
|
||||||
|
/// A settlement named no documents.
|
||||||
|
#[error("a settlement needs at least one invoice or bill")]
|
||||||
|
SettlementEmpty,
|
||||||
|
|
||||||
|
/// A settlement's documents aren't all invoices or all bills.
|
||||||
|
#[error("a settlement cannot mix invoices and bills: {0} is not the same kind as the others")]
|
||||||
|
SettlementMixedKinds(String),
|
||||||
|
|
||||||
|
/// A settlement's documents don't all belong to the one party it
|
||||||
|
/// names (rules.md B-42 INVARIANT 1).
|
||||||
|
#[error("{0} is not {1}'s: a settlement covers one party only")]
|
||||||
|
SettlementPartiesDiffer(String, u64),
|
||||||
|
|
||||||
|
/// A discount needs a reason: none is stated in the document's terms,
|
||||||
|
/// or it is larger than its terms allow, or it is taken after the
|
||||||
|
/// discount date (rules.md B-42 INVARIANT 2).
|
||||||
|
#[error(
|
||||||
|
"{doc}: the discount of {discount} needs a reason ({because}); pass discount_reason with the payment"
|
||||||
|
)]
|
||||||
|
DiscountNeedsReason {
|
||||||
|
/// The document.
|
||||||
|
doc: String,
|
||||||
|
/// The discount taken.
|
||||||
|
discount: Decimal,
|
||||||
|
/// Why the terms don't allow it as a matter of course.
|
||||||
|
because: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// A discount on a bill whose lines include an asset or prepaid
|
||||||
|
/// account is refused: apportioning it across those lines is deferred
|
||||||
|
/// (D-49).
|
||||||
|
#[error(
|
||||||
|
"{doc} has a line on {code} \"{name}\" (an asset or prepaid account), so a discount on it cannot be apportioned (D-49): record the bill net of the discount, or reclassify by an entry Dr 6995 Purchase Discounts / Cr {code}"
|
||||||
|
)]
|
||||||
|
DiscountOnAssetBill {
|
||||||
|
/// The bill.
|
||||||
|
doc: String,
|
||||||
|
/// The asset line's account code.
|
||||||
|
code: String,
|
||||||
|
/// Its name.
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// A settlement names the same document twice: each document takes one
|
||||||
|
/// application (its cash and its discount), so its bound is one bound.
|
||||||
|
#[error("{0} appears twice in one settlement: name it once, with all its cash and discount")]
|
||||||
|
SettlementDuplicateDocument(String),
|
||||||
|
|
||||||
|
/// A settlement's discount reason is not one line of at most
|
||||||
|
/// [`crate::arap::MAX_REFERENCE_CHARS`] characters.
|
||||||
|
#[error("a discount's reason is one line of at most {0} characters")]
|
||||||
|
InvalidDiscountReason(usize),
|
||||||
|
|
||||||
|
/// A processor's fee is a customer payment's only: a bill is paid from
|
||||||
|
/// the company's own money, with no processor taking a cut.
|
||||||
|
#[error("a processor's fee belongs on a customer payment, not on paying a bill")]
|
||||||
|
FeeOnBill,
|
||||||
|
|
||||||
|
/// A fee's amount or account the books can't take: the message says
|
||||||
|
/// why.
|
||||||
|
#[error("{0}")]
|
||||||
|
InvalidFee(String),
|
||||||
|
|
||||||
|
/// A settlement's figures can't be exact: the cash and discounts
|
||||||
|
/// don't cover the applications, or the fee leaves a negative deposit
|
||||||
|
/// (rules.md B-42 INVARIANT 1).
|
||||||
|
#[error("{0}")]
|
||||||
|
InvalidSettlement(String),
|
||||||
|
|
||||||
/// A payment's details were refused (its reference): the message says
|
/// A payment's details were refused (its reference): the message says
|
||||||
/// why.
|
/// why.
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
|
|||||||
@@ -269,6 +269,63 @@ pub fn discount_max<T: Amount>(total: T, percent_bp: u32) -> Option<T> {
|
|||||||
total.checked_mul(bp)?.checked_div_exact(ten_thousand)
|
total.checked_mul(bp)?.checked_div_exact(ten_thousand)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What a settlement of one or more documents comes to: the applications'
|
||||||
|
/// total (cash plus discounts taken) and what reaches the deposit account
|
||||||
|
/// (the cash less the processor's fee).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Settlement<T> {
|
||||||
|
/// Σ cash + Σ discounts: what the settlement applies to the documents.
|
||||||
|
pub applications_total: T,
|
||||||
|
/// Σ cash − fee: what the deposit account receives (never negative).
|
||||||
|
pub deposit: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The arithmetic of a settlement (rules.md B-42; plan P4b S6): each
|
||||||
|
/// document's cash and discount, and the processor's fee withheld from the
|
||||||
|
/// cash that reaches the bank.
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// GOAL: a settlement is exact: what the documents are settled by is the
|
||||||
|
/// cash plus the discounts, and the deposit is the cash less the fee.
|
||||||
|
/// SOURCE: docs/spec/rules.md B-42; plan/served.md 8.3.6, 8.3.13 (B-42);
|
||||||
|
/// CONVENTION; DECISION: the gross method (A-8) and the fee's
|
||||||
|
/// gross presentation (A-4).
|
||||||
|
/// INVARIANTS:
|
||||||
|
/// 1. Some(s): s.applications_total = Σ cash + Σ discounts and
|
||||||
|
/// s.deposit = Σ cash − fee, exactly, with s.deposit >= 0.
|
||||||
|
/// 2. None on overflow, a negative amount, a negative fee, a fee above
|
||||||
|
/// the cash, or arrays of different lengths: never a wrapped or
|
||||||
|
/// rounded figure.
|
||||||
|
/// ```
|
||||||
|
pub fn settle<T: Amount>(cash: &[T], discounts: &[T], fee: T) -> Option<Settlement<T>> {
|
||||||
|
if cash.len() != discounts.len() || fee < T::ZERO {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut total_cash = T::ZERO;
|
||||||
|
for &amount in cash {
|
||||||
|
if amount < T::ZERO {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
total_cash = total_cash.checked_add(amount)?;
|
||||||
|
}
|
||||||
|
let mut total_discounts = T::ZERO;
|
||||||
|
for &amount in discounts {
|
||||||
|
if amount < T::ZERO {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
total_discounts = total_discounts.checked_add(amount)?;
|
||||||
|
}
|
||||||
|
let applications_total = total_cash.checked_add(total_discounts)?;
|
||||||
|
let deposit = total_cash.checked_sub(fee)?;
|
||||||
|
if deposit < T::ZERO {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Settlement {
|
||||||
|
applications_total,
|
||||||
|
deposit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// A document line's amount from its quantity and unit price: the exact
|
/// A document line's amount from its quantity and unit price: the exact
|
||||||
/// product, never rounded.
|
/// product, never rounded.
|
||||||
///
|
///
|
||||||
|
|||||||
+648
-117
@@ -91,6 +91,34 @@ pub const GENERAL_EXPENSES: ControlAccount = control(
|
|||||||
"bill expense",
|
"bill expense",
|
||||||
AccountType::Expense,
|
AccountType::Expense,
|
||||||
);
|
);
|
||||||
|
/// A control account: discounts given to customers (plan P4b S6, A-8; plan
|
||||||
|
/// 8.3.4): a debit-balance revenue account, the gross method's allowance
|
||||||
|
/// when a customer takes an early-payment discount.
|
||||||
|
pub const SALES_ALLOWANCES: ControlAccount = control(
|
||||||
|
"sales_allowances",
|
||||||
|
"4100",
|
||||||
|
"Refunds and Allowances",
|
||||||
|
"sales allowances",
|
||||||
|
AccountType::Revenue,
|
||||||
|
);
|
||||||
|
/// A control account: discounts taken from vendors (plan P4b S6, A-8): a
|
||||||
|
/// credit-balance expense account, the gross method's purchase discount.
|
||||||
|
pub const PURCHASE_DISCOUNTS: ControlAccount = control(
|
||||||
|
"purchase_discounts",
|
||||||
|
"6995",
|
||||||
|
"Purchase Discounts",
|
||||||
|
"purchase discounts",
|
||||||
|
AccountType::Expense,
|
||||||
|
);
|
||||||
|
/// A control account: the processor's fee on a payment received (plan P4b
|
||||||
|
/// S6, A-4): the gross presentation's fee expense.
|
||||||
|
pub const PAYMENT_PROCESSING_FEES: ControlAccount = control(
|
||||||
|
"payment_processing_fees",
|
||||||
|
"6060",
|
||||||
|
"Platform and Payment Processing Fees",
|
||||||
|
"payment processing fees",
|
||||||
|
AccountType::Expense,
|
||||||
|
);
|
||||||
/// A control account; see [`ControlAccount`].
|
/// A control account; see [`ControlAccount`].
|
||||||
pub const RETAINED_EARNINGS: ControlAccount = control(
|
pub const RETAINED_EARNINGS: ControlAccount = control(
|
||||||
"retained_earnings",
|
"retained_earnings",
|
||||||
@@ -6223,6 +6251,415 @@ impl Ledger {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Settle one or more invoices or bills in one entry (plan P4b S6;
|
||||||
|
/// rules.md B-42): cash against each, an early-payment discount where
|
||||||
|
/// the document's terms allow it (or a reason says why), and the
|
||||||
|
/// processor's fee on a customer payment, posted gross.
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// GOAL: record a payment that settles several documents at once, with
|
||||||
|
/// the early-payment discounts and the processor's fee, exactly.
|
||||||
|
/// SOURCE: docs/spec/rules.md B-42; plan/served.md 8.3.6, 8.3.13;
|
||||||
|
/// CONVENTION; DECISION: the gross method (A-8) and the fee's
|
||||||
|
/// gross presentation (A-4).
|
||||||
|
/// INVARIANTS:
|
||||||
|
/// 1. Σ cash + Σ discounts = Σ applications and deposit = Σ cash −
|
||||||
|
/// fee (kernel::settle); one party per settlement.
|
||||||
|
/// 2. Each application's cash + discount ≤ what the document owes on
|
||||||
|
/// every day from the payment's date on.
|
||||||
|
/// 3. Posting rows: payment lines in application order, then the
|
||||||
|
/// discount lines, then the fee line, then the cash line, so a
|
||||||
|
/// journaled event's line indices are stable.
|
||||||
|
/// 4. A discount is at most kernel::discount_max(total, percent_bp)
|
||||||
|
/// and dated on or before terms.discount_by(date), else a reason
|
||||||
|
/// is required; a discount on a bill with an asset line is
|
||||||
|
/// refused (D-49); a fee is refused on a bill.
|
||||||
|
/// 5. A refusal MUST leave the books unchanged.
|
||||||
|
/// ```
|
||||||
|
pub fn settle(&mut self, params: arap::SettleParams) -> Result<EntryId, LedgerError> {
|
||||||
|
self.atomically(|l| l.settle_steps(params))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The lines [`Self::settle`] would post, after every check, in its
|
||||||
|
/// order (payment lines, discount lines, the fee line, the cash line).
|
||||||
|
/// A store journals exactly these before it mutates; it must have
|
||||||
|
/// created the role accounts [`Self::settle`] would create first.
|
||||||
|
pub fn settlement_lines(
|
||||||
|
&self,
|
||||||
|
params: &arap::SettleParams,
|
||||||
|
) -> Result<Vec<arap::SettlementRow>, LedgerError> {
|
||||||
|
use arap::{DocRef, DocTag, Movement};
|
||||||
|
|
||||||
|
if params.applications.is_empty() {
|
||||||
|
return Err(LedgerError::SettlementEmpty);
|
||||||
|
}
|
||||||
|
let kind = match params.applications[0].doc {
|
||||||
|
DocRef::Invoice(_) => arap::DocumentKind::Invoice,
|
||||||
|
DocRef::Bill(_) => arap::DocumentKind::Bill,
|
||||||
|
};
|
||||||
|
let control = match kind {
|
||||||
|
arap::DocumentKind::Invoice => RECEIVABLES,
|
||||||
|
arap::DocumentKind::Bill => PAYABLES,
|
||||||
|
};
|
||||||
|
let discount_role = match kind {
|
||||||
|
arap::DocumentKind::Invoice => SALES_ALLOWANCES,
|
||||||
|
arap::DocumentKind::Bill => PURCHASE_DISCOUNTS,
|
||||||
|
};
|
||||||
|
for app in ¶ms.applications {
|
||||||
|
let same = matches!(
|
||||||
|
(kind, app.doc),
|
||||||
|
(arap::DocumentKind::Invoice, DocRef::Invoice(_))
|
||||||
|
| (arap::DocumentKind::Bill, DocRef::Bill(_))
|
||||||
|
);
|
||||||
|
if !same {
|
||||||
|
return Err(LedgerError::SettlementMixedKinds(app.doc.to_string()));
|
||||||
|
}
|
||||||
|
let party = match app.doc {
|
||||||
|
DocRef::Invoice(id) => {
|
||||||
|
self.invoices
|
||||||
|
.get(&id)
|
||||||
|
.ok_or(LedgerError::UnknownInvoice(id))?
|
||||||
|
.customer_id
|
||||||
|
}
|
||||||
|
DocRef::Bill(id) => {
|
||||||
|
self.bills
|
||||||
|
.get(&id)
|
||||||
|
.ok_or(LedgerError::UnknownBill(id))?
|
||||||
|
.vendor_id
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if party != params.party {
|
||||||
|
return Err(LedgerError::SettlementPartiesDiffer(
|
||||||
|
app.doc.to_string(),
|
||||||
|
params.party,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
crate::calendar::check(params.date)?;
|
||||||
|
if let Some(pid) = self.closed_period_containing(params.date) {
|
||||||
|
return Err(LedgerError::ClosedPeriod(pid, params.date.to_string()));
|
||||||
|
}
|
||||||
|
let details = arap::PaymentDetails {
|
||||||
|
instrument: params.instrument,
|
||||||
|
reference: params.reference.clone(),
|
||||||
|
};
|
||||||
|
details.check()?;
|
||||||
|
if let Some(i) = params.instrument
|
||||||
|
&& !self.instruments.contains_key(&i)
|
||||||
|
{
|
||||||
|
return Err(LedgerError::UnknownInstrument(i));
|
||||||
|
}
|
||||||
|
self.check_controls(&[control])?;
|
||||||
|
self.check_payment_account(params.cash_account)?;
|
||||||
|
let control_account = self.role_account(&control).ok_or_else(|| {
|
||||||
|
LedgerError::InvalidSettlement(format!(
|
||||||
|
"{} has no account in these books yet",
|
||||||
|
control.role
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut cash = Vec::with_capacity(params.applications.len());
|
||||||
|
let mut discounts = Vec::with_capacity(params.applications.len());
|
||||||
|
for (i, app) in params.applications.iter().enumerate() {
|
||||||
|
if params.applications[..i]
|
||||||
|
.iter()
|
||||||
|
.any(|earlier| earlier.doc == app.doc)
|
||||||
|
{
|
||||||
|
return Err(LedgerError::SettlementDuplicateDocument(
|
||||||
|
app.doc.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if app.cash <= Decimal::ZERO {
|
||||||
|
return Err(LedgerError::InvalidPaymentAmount(app.cash));
|
||||||
|
}
|
||||||
|
if app.discount < Decimal::ZERO {
|
||||||
|
return Err(LedgerError::InvalidSettlement(format!(
|
||||||
|
"{}: a discount cannot be negative",
|
||||||
|
app.doc
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let (doc_date, _, _, total) = self.doc_header(app.doc)?;
|
||||||
|
if params.date < doc_date {
|
||||||
|
return Err(LedgerError::PaymentBeforeDocument {
|
||||||
|
date: params.date,
|
||||||
|
document_date: doc_date,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let room = self.payment_room(app.doc, params.date)?;
|
||||||
|
let applied = app.cash.checked_add(app.discount).ok_or_else(|| {
|
||||||
|
LedgerError::TooLargeToCompute(format!("{}'s settlement", app.doc))
|
||||||
|
})?;
|
||||||
|
if applied > room {
|
||||||
|
return Err(LedgerError::Overpayment(applied, room));
|
||||||
|
}
|
||||||
|
if let Some(reason) = &app.discount_reason
|
||||||
|
&& (reason.contains(['\n', '\r'])
|
||||||
|
|| reason.chars().count() > arap::MAX_REFERENCE_CHARS)
|
||||||
|
{
|
||||||
|
return Err(LedgerError::InvalidDiscountReason(
|
||||||
|
arap::MAX_REFERENCE_CHARS,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if app.discount > Decimal::ZERO {
|
||||||
|
self.check_discount(app, kind, total, doc_date, params.date)?;
|
||||||
|
}
|
||||||
|
cash.push(app.cash);
|
||||||
|
discounts.push(app.discount);
|
||||||
|
}
|
||||||
|
let fee_amount = params.fee.as_ref().map_or(Decimal::ZERO, |f| f.amount);
|
||||||
|
let settlement = kernel::settle(&cash, &discounts, fee_amount).ok_or_else(|| {
|
||||||
|
LedgerError::InvalidSettlement(
|
||||||
|
"the cash and discounts don't cover the applications exactly, or the fee is above the cash"
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if settlement.deposit <= Decimal::ZERO {
|
||||||
|
return Err(LedgerError::InvalidSettlement(
|
||||||
|
"the processor's fee can't take the whole payment: the deposit would be nothing"
|
||||||
|
.into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let any_discount = discounts.iter().any(|d| *d > Decimal::ZERO);
|
||||||
|
let mut total_discount = Decimal::ZERO;
|
||||||
|
for app in ¶ms.applications {
|
||||||
|
if app.discount > Decimal::ZERO {
|
||||||
|
total_discount = total_discount
|
||||||
|
.checked_add(app.discount)
|
||||||
|
.ok_or_else(|| LedgerError::TooLargeToCompute("the discounts".into()))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let discount_account = if any_discount {
|
||||||
|
Some(self.role_account(&discount_role).ok_or_else(|| {
|
||||||
|
LedgerError::InvalidSettlement(format!(
|
||||||
|
"{} has no account in these books yet",
|
||||||
|
discount_role.role
|
||||||
|
))
|
||||||
|
})?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let fee_account = match ¶ms.fee {
|
||||||
|
None => None,
|
||||||
|
Some(fee) => Some(match fee.account {
|
||||||
|
Some(a) => {
|
||||||
|
self.check_line_account(a)?;
|
||||||
|
let account = self.chart.get(a).expect("check_line_account passed");
|
||||||
|
if account.account_type != AccountType::Expense || account.placeholder {
|
||||||
|
return Err(LedgerError::InvalidFee(format!(
|
||||||
|
"{} {} is not an expense account: the fee goes to an expense",
|
||||||
|
account.code, account.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
a
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
self.check_controls(&[PAYMENT_PROCESSING_FEES])?;
|
||||||
|
self.role_account(&PAYMENT_PROCESSING_FEES).ok_or_else(|| {
|
||||||
|
LedgerError::InvalidSettlement(
|
||||||
|
"payment_processing_fees has no account in these books yet".into(),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (control_side, discount_side, cash_side) = match kind {
|
||||||
|
arap::DocumentKind::Invoice => (Side::Credit, Side::Debit, Side::Debit),
|
||||||
|
arap::DocumentKind::Bill => (Side::Debit, Side::Credit, Side::Credit),
|
||||||
|
};
|
||||||
|
let mut rows: Vec<arap::SettlementRow> =
|
||||||
|
Vec::with_capacity(2 * params.applications.len() + 3);
|
||||||
|
for (app, cash) in params.applications.iter().zip(cash.iter()) {
|
||||||
|
rows.push((
|
||||||
|
control_account,
|
||||||
|
control_side,
|
||||||
|
*cash,
|
||||||
|
Some(DocTag {
|
||||||
|
doc: app.doc,
|
||||||
|
movement: Movement::Payment,
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for app in ¶ms.applications {
|
||||||
|
if app.discount > Decimal::ZERO {
|
||||||
|
rows.push((
|
||||||
|
control_account,
|
||||||
|
control_side,
|
||||||
|
app.discount,
|
||||||
|
Some(DocTag {
|
||||||
|
doc: app.doc,
|
||||||
|
movement: Movement::Discount,
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(fee) = ¶ms.fee {
|
||||||
|
rows.push((
|
||||||
|
fee_account.expect("a fee has its account"),
|
||||||
|
Side::Debit,
|
||||||
|
fee.amount,
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(account) = discount_account {
|
||||||
|
rows.push((account, discount_side, total_discount, None));
|
||||||
|
}
|
||||||
|
rows.push((params.cash_account, cash_side, settlement.deposit, None));
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refuse a discount the document's terms don't allow without a reason
|
||||||
|
/// ([`Self::settlement_lines`] SCENARIO 4), and a discount on a bill
|
||||||
|
/// with an asset or prepaid line (D-49).
|
||||||
|
fn check_discount(
|
||||||
|
&self,
|
||||||
|
app: &arap::Application,
|
||||||
|
kind: arap::DocumentKind,
|
||||||
|
total: Decimal,
|
||||||
|
doc_date: NaiveDate,
|
||||||
|
date: NaiveDate,
|
||||||
|
) -> Result<(), LedgerError> {
|
||||||
|
if kind == arap::DocumentKind::Bill
|
||||||
|
&& let arap::DocRef::Bill(id) = app.doc
|
||||||
|
&& let Some(bill) = self.bills.get(&id)
|
||||||
|
{
|
||||||
|
for line in &bill.lines {
|
||||||
|
let account = match line.account {
|
||||||
|
Some(a) => a,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
if self
|
||||||
|
.chart
|
||||||
|
.get(account)
|
||||||
|
.is_some_and(|a| a.account_type == AccountType::Asset)
|
||||||
|
{
|
||||||
|
let a = self.chart.get(account).expect("checked above");
|
||||||
|
return Err(LedgerError::DiscountOnAssetBill {
|
||||||
|
doc: app.doc.to_string(),
|
||||||
|
code: a.code.clone(),
|
||||||
|
name: a.name.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let terms = match app.doc {
|
||||||
|
arap::DocRef::Invoice(id) => self.invoices.get(&id).and_then(|i| i.terms.clone()),
|
||||||
|
arap::DocRef::Bill(id) => self.bills.get(&id).and_then(|b| b.terms.clone()),
|
||||||
|
};
|
||||||
|
let (max, by) = match terms {
|
||||||
|
Some(t) => {
|
||||||
|
let max = t
|
||||||
|
.discount()
|
||||||
|
.and_then(|d| kernel::discount_max(total, d.percent_bp));
|
||||||
|
(max, t.discount_by(doc_date))
|
||||||
|
}
|
||||||
|
None => (None, None),
|
||||||
|
};
|
||||||
|
let within = max.is_some_and(|m| app.discount <= m) && by.is_some_and(|b| date <= b);
|
||||||
|
let has_reason = app
|
||||||
|
.discount_reason
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|r| !r.trim().is_empty());
|
||||||
|
if !within && !has_reason {
|
||||||
|
let because = match (max, by) {
|
||||||
|
(None, _) => {
|
||||||
|
"its terms state no early-payment discount this version can bound".to_string()
|
||||||
|
}
|
||||||
|
(Some(m), _) if app.discount > m => {
|
||||||
|
format!("it is more than the terms' {m}")
|
||||||
|
}
|
||||||
|
(Some(_), None) => "its terms state no discount date".to_string(),
|
||||||
|
(Some(_), Some(b)) => format!("it is after the discount date {b}"),
|
||||||
|
};
|
||||||
|
return Err(LedgerError::DiscountNeedsReason {
|
||||||
|
doc: app.doc.to_string(),
|
||||||
|
discount: app.discount,
|
||||||
|
because,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create the accounts a settlement posts to: the control role, the
|
||||||
|
/// discount role when a discount is taken, and the fee's default role.
|
||||||
|
fn ensure_settlement_accounts(
|
||||||
|
&mut self,
|
||||||
|
params: &arap::SettleParams,
|
||||||
|
) -> Result<(), LedgerError> {
|
||||||
|
let kind = match params.applications.first().map(|a| a.doc) {
|
||||||
|
Some(arap::DocRef::Bill(_)) => arap::DocumentKind::Bill,
|
||||||
|
_ => arap::DocumentKind::Invoice,
|
||||||
|
};
|
||||||
|
let control = match kind {
|
||||||
|
arap::DocumentKind::Invoice => RECEIVABLES,
|
||||||
|
arap::DocumentKind::Bill => PAYABLES,
|
||||||
|
};
|
||||||
|
self.check_controls(&[control])?;
|
||||||
|
self.ensure_control(&control);
|
||||||
|
if params
|
||||||
|
.applications
|
||||||
|
.iter()
|
||||||
|
.any(|a| a.discount > Decimal::ZERO)
|
||||||
|
{
|
||||||
|
let discount_role = match kind {
|
||||||
|
arap::DocumentKind::Invoice => SALES_ALLOWANCES,
|
||||||
|
arap::DocumentKind::Bill => PURCHASE_DISCOUNTS,
|
||||||
|
};
|
||||||
|
self.check_controls(&[discount_role])?;
|
||||||
|
self.ensure_control(&discount_role);
|
||||||
|
}
|
||||||
|
if let Some(fee) = ¶ms.fee
|
||||||
|
&& fee.account.is_none()
|
||||||
|
{
|
||||||
|
self.check_controls(&[PAYMENT_PROCESSING_FEES])?;
|
||||||
|
self.ensure_control(&PAYMENT_PROCESSING_FEES);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Self::settle`]'s steps, run inside [`Self::atomically`].
|
||||||
|
fn settle_steps(&mut self, params: arap::SettleParams) -> Result<EntryId, LedgerError> {
|
||||||
|
self.ensure_settlement_accounts(¶ms)?;
|
||||||
|
let rows = self.settlement_lines(¶ms)?;
|
||||||
|
let description = if params.description.trim().is_empty() {
|
||||||
|
format!(
|
||||||
|
"Settlement of {} document{}",
|
||||||
|
params.applications.len(),
|
||||||
|
if params.applications.len() == 1 {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
"s"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
params.description.clone()
|
||||||
|
};
|
||||||
|
let entry = self.create_entry(params.date, &description);
|
||||||
|
for (account, side, amount, tag) in &rows {
|
||||||
|
let money = Money::new(*amount, self.base_currency);
|
||||||
|
match tag {
|
||||||
|
Some(tag) => self.add_tagged_line(entry, *account, *side, money, *tag)?,
|
||||||
|
None => self.add_line(entry, *account, *side, money)?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.post_entry(entry)?;
|
||||||
|
match params.applications[0].doc {
|
||||||
|
arap::DocRef::Invoice(_) => self.tag_customer(entry, params.party),
|
||||||
|
arap::DocRef::Bill(_) => self.tag_vendor(entry, params.party),
|
||||||
|
}
|
||||||
|
if params.instrument.is_some() {
|
||||||
|
self.set_entry_instrument(entry, params.instrument)?;
|
||||||
|
}
|
||||||
|
for app in ¶ms.applications {
|
||||||
|
self.payment_events
|
||||||
|
.entry(app.doc)
|
||||||
|
.or_default()
|
||||||
|
.push(app.cash);
|
||||||
|
self.refresh_doc(app.doc);
|
||||||
|
}
|
||||||
|
Ok(entry)
|
||||||
|
}
|
||||||
|
|
||||||
/// Record a customer payment against an invoice. Posts: Dr Cash,
|
/// Record a customer payment against an invoice. Posts: Dr Cash,
|
||||||
/// Cr A/R ([`Self::receive_payment_with`], with no payment method or
|
/// Cr A/R ([`Self::receive_payment_with`], with no payment method or
|
||||||
/// reference).
|
/// reference).
|
||||||
@@ -6276,14 +6713,25 @@ impl Ledger {
|
|||||||
cash_account: AccountId,
|
cash_account: AccountId,
|
||||||
details: &arap::PaymentDetails,
|
details: &arap::PaymentDetails,
|
||||||
) -> Result<EntryId, LedgerError> {
|
) -> Result<EntryId, LedgerError> {
|
||||||
self.atomically(|l| {
|
let customer = self
|
||||||
l.payment_steps(
|
.invoices
|
||||||
arap::DocRef::Invoice(invoice_id),
|
.get(&invoice_id)
|
||||||
date,
|
.ok_or(LedgerError::UnknownInvoice(invoice_id))?
|
||||||
amount,
|
.customer_id;
|
||||||
cash_account,
|
self.settle(arap::SettleParams {
|
||||||
details,
|
date,
|
||||||
)
|
description: details.description(&format!("Payment on invoice {invoice_id}")),
|
||||||
|
cash_account,
|
||||||
|
party: customer,
|
||||||
|
applications: vec![arap::Application {
|
||||||
|
doc: arap::DocRef::Invoice(invoice_id),
|
||||||
|
cash: amount,
|
||||||
|
discount: Decimal::ZERO,
|
||||||
|
discount_reason: None,
|
||||||
|
}],
|
||||||
|
fee: None,
|
||||||
|
instrument: details.instrument,
|
||||||
|
reference: details.reference.clone(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6335,100 +6783,28 @@ impl Ledger {
|
|||||||
cash_account: AccountId,
|
cash_account: AccountId,
|
||||||
details: &arap::PaymentDetails,
|
details: &arap::PaymentDetails,
|
||||||
) -> Result<EntryId, LedgerError> {
|
) -> Result<EntryId, LedgerError> {
|
||||||
self.atomically(|l| {
|
let vendor = self
|
||||||
l.payment_steps(
|
.bills
|
||||||
arap::DocRef::Bill(bill_id),
|
.get(&bill_id)
|
||||||
date,
|
.ok_or(LedgerError::UnknownBill(bill_id))?
|
||||||
amount,
|
.vendor_id;
|
||||||
cash_account,
|
self.settle(arap::SettleParams {
|
||||||
details,
|
date,
|
||||||
)
|
description: details.description(&format!("Payment on bill {bill_id}")),
|
||||||
|
cash_account,
|
||||||
|
party: vendor,
|
||||||
|
applications: vec![arap::Application {
|
||||||
|
doc: arap::DocRef::Bill(bill_id),
|
||||||
|
cash: amount,
|
||||||
|
discount: Decimal::ZERO,
|
||||||
|
discount_reason: None,
|
||||||
|
}],
|
||||||
|
fee: None,
|
||||||
|
instrument: details.instrument,
|
||||||
|
reference: details.reference.clone(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The steps of a payment ([`Self::receive_payment_with`],
|
|
||||||
/// [`Self::pay_bill_with`]), run inside [`Self::atomically`].
|
|
||||||
fn payment_steps(
|
|
||||||
&mut self,
|
|
||||||
doc: arap::DocRef,
|
|
||||||
date: NaiveDate,
|
|
||||||
amount: Decimal,
|
|
||||||
account: AccountId,
|
|
||||||
details: &arap::PaymentDetails,
|
|
||||||
) -> Result<EntryId, LedgerError> {
|
|
||||||
if amount <= Decimal::ZERO {
|
|
||||||
return Err(LedgerError::InvalidPaymentAmount(amount));
|
|
||||||
}
|
|
||||||
let (doc_date, ..) = self.doc_header(doc)?;
|
|
||||||
let room = self.payment_room(doc, date)?;
|
|
||||||
if amount > room {
|
|
||||||
return Err(LedgerError::Overpayment(amount, room));
|
|
||||||
}
|
|
||||||
if let Some(pid) = self.closed_period_containing(date) {
|
|
||||||
return Err(LedgerError::ClosedPeriod(pid, date.to_string()));
|
|
||||||
}
|
|
||||||
if date < doc_date {
|
|
||||||
return Err(LedgerError::PaymentBeforeDocument {
|
|
||||||
date,
|
|
||||||
document_date: doc_date,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
details.check()?;
|
|
||||||
if let Some(i) = details.instrument
|
|
||||||
&& !self.instruments.contains_key(&i)
|
|
||||||
{
|
|
||||||
return Err(LedgerError::UnknownInstrument(i));
|
|
||||||
}
|
|
||||||
let role = match doc {
|
|
||||||
arap::DocRef::Invoice(_) => RECEIVABLES,
|
|
||||||
arap::DocRef::Bill(_) => PAYABLES,
|
|
||||||
};
|
|
||||||
self.check_controls(&[role])?;
|
|
||||||
// Every check before the entry exists: a refusal leaves no draft.
|
|
||||||
self.check_payment_account(account)?;
|
|
||||||
let control = self.ensure_control(&role);
|
|
||||||
let money = Money::new(amount, self.base_currency);
|
|
||||||
let tag = arap::DocTag {
|
|
||||||
doc,
|
|
||||||
movement: arap::Movement::Payment,
|
|
||||||
};
|
|
||||||
let entry = match doc {
|
|
||||||
arap::DocRef::Invoice(id) => {
|
|
||||||
let entry = self.create_entry(
|
|
||||||
date,
|
|
||||||
&details.description(&format!("Payment on invoice {id}")),
|
|
||||||
);
|
|
||||||
self.add_line(entry, account, Side::Debit, money)?;
|
|
||||||
self.add_tagged_line(entry, control, Side::Credit, money, tag)?;
|
|
||||||
entry
|
|
||||||
}
|
|
||||||
arap::DocRef::Bill(id) => {
|
|
||||||
let entry =
|
|
||||||
self.create_entry(date, &details.description(&format!("Payment on bill {id}")));
|
|
||||||
self.add_tagged_line(entry, control, Side::Debit, money, tag)?;
|
|
||||||
self.add_line(entry, account, Side::Credit, money)?;
|
|
||||||
entry
|
|
||||||
}
|
|
||||||
};
|
|
||||||
self.post_entry(entry)?;
|
|
||||||
match doc {
|
|
||||||
arap::DocRef::Invoice(id) => {
|
|
||||||
let customer = self.invoices[&id].customer_id;
|
|
||||||
self.tag_customer(entry, customer);
|
|
||||||
}
|
|
||||||
arap::DocRef::Bill(id) => {
|
|
||||||
let vendor = self.bills[&id].vendor_id;
|
|
||||||
self.tag_vendor(entry, vendor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if details.instrument.is_some() {
|
|
||||||
self.set_entry_instrument(entry, details.instrument)?;
|
|
||||||
}
|
|
||||||
self.payment_events.entry(doc).or_default().push(amount);
|
|
||||||
self.refresh_doc(doc);
|
|
||||||
Ok(entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Void a document: reverse its own entry (B-9), described "Void
|
/// Void a document: reverse its own entry (B-9), described "Void
|
||||||
/// invoice 7: reason". It keeps its id (and, later, its number); its
|
/// invoice 7: reason". It keeps its id (and, later, its number); its
|
||||||
/// status as of the void's date on is Void. Plain
|
/// status as of the void's date on is Void. Plain
|
||||||
@@ -6741,6 +7117,35 @@ impl Ledger {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tag the line a new payment event names (plan P4b S6; rules.md B-42
|
||||||
|
/// INVARIANT 3): line `index` of `entry`, when it is on the role's
|
||||||
|
/// account, on the side the movement posts, untagged, and for `amount`.
|
||||||
|
/// Otherwise it stays untagged and the control check lists it; replay
|
||||||
|
/// never refuses (B-28).
|
||||||
|
fn tag_replayed_at(
|
||||||
|
&mut self,
|
||||||
|
entry: EntryId,
|
||||||
|
role: &ControlAccount,
|
||||||
|
index: usize,
|
||||||
|
amount: Decimal,
|
||||||
|
tag: arap::DocTag,
|
||||||
|
) {
|
||||||
|
let (Some(account), Some(e)) = (self.role_account(role), self.entries.get(&entry)) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let side = match tag.movement {
|
||||||
|
arap::Movement::Origin => tag.doc.owed_side(),
|
||||||
|
_ => kernel::flip(tag.doc.owed_side()),
|
||||||
|
};
|
||||||
|
let matches = e
|
||||||
|
.lines()
|
||||||
|
.get(index)
|
||||||
|
.is_some_and(|l| l.account == account && l.side == side && l.amount.amount == amount);
|
||||||
|
if matches && !self.line_docs.contains_key(&(entry, index)) {
|
||||||
|
self.tag_line(entry, index, tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Record an invoice without posting: replay support for journaling
|
/// Record an invoice without posting: replay support for journaling
|
||||||
/// stores. The invoice's entry was already rebuilt from its own
|
/// stores. The invoice's entry was already rebuilt from its own
|
||||||
/// `entry.created` / `entry.line_added` / `entry.posted` events; this
|
/// `entry.created` / `entry.line_added` / `entry.posted` events; this
|
||||||
@@ -6917,6 +7322,24 @@ impl Ledger {
|
|||||||
invoice_id: InvoiceId,
|
invoice_id: InvoiceId,
|
||||||
amount: Decimal,
|
amount: Decimal,
|
||||||
entry_id: EntryId,
|
entry_id: EntryId,
|
||||||
|
) -> Result<(), LedgerError> {
|
||||||
|
self.replay_invoice_payment_at(invoice_id, amount, entry_id, None, Decimal::ZERO, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Self::replay_invoice_payment`] with the line a new event names and
|
||||||
|
/// the discount it took (plan P4b S6; rules.md B-42 INVARIANT 3). A
|
||||||
|
/// line the event names but that can't be matched (no such line, the
|
||||||
|
/// wrong account or side, or already tagged) stays untagged, and the
|
||||||
|
/// control check lists it: replay never refuses (B-28).
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn replay_invoice_payment_at(
|
||||||
|
&mut self,
|
||||||
|
invoice_id: InvoiceId,
|
||||||
|
amount: Decimal,
|
||||||
|
entry_id: EntryId,
|
||||||
|
line: Option<usize>,
|
||||||
|
discount: Decimal,
|
||||||
|
discount_line: Option<usize>,
|
||||||
) -> Result<(), LedgerError> {
|
) -> Result<(), LedgerError> {
|
||||||
let customer = self
|
let customer = self
|
||||||
.invoices
|
.invoices
|
||||||
@@ -6924,15 +7347,30 @@ impl Ledger {
|
|||||||
.ok_or(LedgerError::UnknownInvoice(invoice_id))?
|
.ok_or(LedgerError::UnknownInvoice(invoice_id))?
|
||||||
.customer_id;
|
.customer_id;
|
||||||
let doc = arap::DocRef::Invoice(invoice_id);
|
let doc = arap::DocRef::Invoice(invoice_id);
|
||||||
self.tag_replayed(
|
let payment = arap::DocTag {
|
||||||
entry_id,
|
doc,
|
||||||
&RECEIVABLES,
|
movement: arap::Movement::Payment,
|
||||||
Some(amount),
|
};
|
||||||
arap::DocTag {
|
match line {
|
||||||
doc,
|
Some(line) => {
|
||||||
movement: arap::Movement::Payment,
|
self.tag_replayed_at(entry_id, &RECEIVABLES, line, amount, payment);
|
||||||
},
|
}
|
||||||
);
|
None => self.tag_replayed(entry_id, &RECEIVABLES, Some(amount), payment),
|
||||||
|
}
|
||||||
|
if discount > Decimal::ZERO
|
||||||
|
&& let Some(line) = discount_line
|
||||||
|
{
|
||||||
|
self.tag_replayed_at(
|
||||||
|
entry_id,
|
||||||
|
&RECEIVABLES,
|
||||||
|
line,
|
||||||
|
discount,
|
||||||
|
arap::DocTag {
|
||||||
|
doc,
|
||||||
|
movement: arap::Movement::Discount,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
self.tag_customer(entry_id, customer);
|
self.tag_customer(entry_id, customer);
|
||||||
self.payment_events.entry(doc).or_default().push(amount);
|
self.payment_events.entry(doc).or_default().push(amount);
|
||||||
self.refresh_doc(doc);
|
self.refresh_doc(doc);
|
||||||
@@ -6947,6 +7385,21 @@ impl Ledger {
|
|||||||
bill_id: BillId,
|
bill_id: BillId,
|
||||||
amount: Decimal,
|
amount: Decimal,
|
||||||
entry_id: EntryId,
|
entry_id: EntryId,
|
||||||
|
) -> Result<(), LedgerError> {
|
||||||
|
self.replay_bill_payment_at(bill_id, amount, entry_id, None, Decimal::ZERO, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Self::replay_bill_payment`] with the line a new event names and
|
||||||
|
/// the discount it took; as [`Self::replay_invoice_payment_at`].
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn replay_bill_payment_at(
|
||||||
|
&mut self,
|
||||||
|
bill_id: BillId,
|
||||||
|
amount: Decimal,
|
||||||
|
entry_id: EntryId,
|
||||||
|
line: Option<usize>,
|
||||||
|
discount: Decimal,
|
||||||
|
discount_line: Option<usize>,
|
||||||
) -> Result<(), LedgerError> {
|
) -> Result<(), LedgerError> {
|
||||||
let vendor = self
|
let vendor = self
|
||||||
.bills
|
.bills
|
||||||
@@ -6954,15 +7407,30 @@ impl Ledger {
|
|||||||
.ok_or(LedgerError::UnknownBill(bill_id))?
|
.ok_or(LedgerError::UnknownBill(bill_id))?
|
||||||
.vendor_id;
|
.vendor_id;
|
||||||
let doc = arap::DocRef::Bill(bill_id);
|
let doc = arap::DocRef::Bill(bill_id);
|
||||||
self.tag_replayed(
|
let payment = arap::DocTag {
|
||||||
entry_id,
|
doc,
|
||||||
&PAYABLES,
|
movement: arap::Movement::Payment,
|
||||||
Some(amount),
|
};
|
||||||
arap::DocTag {
|
match line {
|
||||||
doc,
|
Some(line) => {
|
||||||
movement: arap::Movement::Payment,
|
self.tag_replayed_at(entry_id, &PAYABLES, line, amount, payment);
|
||||||
},
|
}
|
||||||
);
|
None => self.tag_replayed(entry_id, &PAYABLES, Some(amount), payment),
|
||||||
|
}
|
||||||
|
if discount > Decimal::ZERO
|
||||||
|
&& let Some(line) = discount_line
|
||||||
|
{
|
||||||
|
self.tag_replayed_at(
|
||||||
|
entry_id,
|
||||||
|
&PAYABLES,
|
||||||
|
line,
|
||||||
|
discount,
|
||||||
|
arap::DocTag {
|
||||||
|
doc,
|
||||||
|
movement: arap::Movement::Discount,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
self.tag_vendor(entry_id, vendor);
|
self.tag_vendor(entry_id, vendor);
|
||||||
self.payment_events.entry(doc).or_default().push(amount);
|
self.payment_events.entry(doc).or_default().push(amount);
|
||||||
self.refresh_doc(doc);
|
self.refresh_doc(doc);
|
||||||
@@ -7044,6 +7512,69 @@ impl Ledger {
|
|||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One row per open document in an aging, as of `as_of` (plan P4b S6,
|
||||||
|
/// 8.3.10): the same figures the party rows sum, with the document's
|
||||||
|
/// own number, dates, terms and bucket. Invoices (`invoices` true) or
|
||||||
|
/// bills; due date first.
|
||||||
|
pub fn aging_documents(
|
||||||
|
&self,
|
||||||
|
as_of: NaiveDate,
|
||||||
|
invoices: bool,
|
||||||
|
) -> Result<Vec<arap::AgingDocument>, LedgerError> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for doc in self.documents() {
|
||||||
|
let (name, date, due_date, terms) = match doc {
|
||||||
|
arap::DocRef::Invoice(id) if invoices => {
|
||||||
|
let i = &self.invoices[&id];
|
||||||
|
(
|
||||||
|
self.customers.get(&i.customer_id).map(|c| c.name.clone()),
|
||||||
|
i.date,
|
||||||
|
i.due_date,
|
||||||
|
i.terms.as_ref().map(|t| t.describe()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
arap::DocRef::Bill(id) if !invoices => {
|
||||||
|
let b = &self.bills[&id];
|
||||||
|
(
|
||||||
|
self.vendors.get(&b.vendor_id).map(|v| v.name.clone()),
|
||||||
|
b.date,
|
||||||
|
b.due_date,
|
||||||
|
b.terms.as_ref().map(|t| t.describe()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
let Some(st) = self.doc_state(doc, as_of)? else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if st.outstanding <= Decimal::ZERO {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(arap::AgingDocument {
|
||||||
|
doc,
|
||||||
|
party: name.unwrap_or_default(),
|
||||||
|
date,
|
||||||
|
due_date,
|
||||||
|
terms,
|
||||||
|
total: st.total,
|
||||||
|
paid: st.paid,
|
||||||
|
discounted: st.discounted,
|
||||||
|
outstanding: st.outstanding,
|
||||||
|
bucket: arap::bucket_key(due_date, as_of),
|
||||||
|
days_overdue: st.overdue_days,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out.sort_by_key(|d| {
|
||||||
|
(
|
||||||
|
d.due_date,
|
||||||
|
match d.doc {
|
||||||
|
arap::DocRef::Invoice(id) | arap::DocRef::Bill(id) => id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
});
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Bank reconciliation
|
// Bank reconciliation
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -48,10 +48,11 @@ pub mod tax_capital;
|
|||||||
|
|
||||||
pub use account::{Account, AccountId, AccountType, ChartOfAccounts, Side};
|
pub use account::{Account, AccountId, AccountType, ChartOfAccounts, Side};
|
||||||
pub use arap::{
|
pub use arap::{
|
||||||
AgingRow, Bill, BillFields, BillId, ControlCheck, Customer, CustomerId, DocLine, DocRef,
|
AgingDocument, AgingRow, Application, Bill, BillFields, BillId, ControlCheck, Customer,
|
||||||
DocState, DocStatus, DocTag, DocumentKind, DocumentLine, DocumentPartKey, DocumentStatus,
|
CustomerId, DocLine, DocRef, DocState, DocStatus, DocTag, DocumentKind, DocumentLine,
|
||||||
EarlyDiscount, Invoice, InvoiceFields, InvoiceId, Movement, PartySnapshot, PaymentDetails,
|
DocumentPartKey, DocumentStatus, EarlyDiscount, Fee, Invoice, InvoiceFields, InvoiceId,
|
||||||
SendKind, Sent, SentVia, Terms, TermsKind, UnattachedLine, Vendor, VendorId,
|
Movement, PartySnapshot, PaymentDetails, SendKind, Sent, SentVia, SettleParams, SettlementRow,
|
||||||
|
Terms, TermsKind, UnattachedLine, Vendor, VendorId,
|
||||||
};
|
};
|
||||||
pub use assets::{
|
pub use assets::{
|
||||||
Asset, AssetBasis, AssetCorrection, AssetId, AssetStatus, BasisRule, DepreciationMethod,
|
Asset, AssetBasis, AssetCorrection, AssetId, AssetStatus, BasisRule, DepreciationMethod,
|
||||||
@@ -76,8 +77,9 @@ pub use instruments::{Instrument, InstrumentId, InstrumentKind};
|
|||||||
pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
|
pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
|
||||||
pub use kernel::{EntryFlows, Flow};
|
pub use kernel::{EntryFlows, Flow};
|
||||||
pub use ledger::{
|
pub use ledger::{
|
||||||
ControlAccount, GENERAL_EXPENSES, Ledger, PAYABLES, PAYROLL, RECEIVABLES, RETAINED_EARNINGS,
|
ControlAccount, GENERAL_EXPENSES, Ledger, PAYABLES, PAYMENT_PROCESSING_FEES, PAYROLL,
|
||||||
REVENUE, ReconciliationReport, UnclearedLine, control_account, control_accounts,
|
PURCHASE_DISCOUNTS, RECEIVABLES, RETAINED_EARNINGS, REVENUE, ReconciliationReport,
|
||||||
|
SALES_ALLOWANCES, UnclearedLine, control_account, control_accounts,
|
||||||
};
|
};
|
||||||
pub use money::{Currency, Money};
|
pub use money::{Currency, Money};
|
||||||
pub use parties::{Party, PartyField, PartyId, Role, TinFacts, TinType, W4Facts, W9Facts};
|
pub use parties::{Party, PartyField, PartyId, Role, TinFacts, TinType, W4Facts, W9Facts};
|
||||||
|
|||||||
@@ -1928,6 +1928,38 @@ fn discount_max_is_bounded() {
|
|||||||
kani::cover!(got.is_none(), "an exact bound finer than a whole cent");
|
kani::cover!(got.is_none(), "an exact bound finer than a whole cent");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `kernel::settle` is exact: the applications' total is the cash plus the
|
||||||
|
/// discounts, the deposit is the cash less the fee, and a fee above the
|
||||||
|
/// cash is refused rather than wrapped.
|
||||||
|
#[kani::proof]
|
||||||
|
fn settle_is_exact() {
|
||||||
|
const N: usize = 3;
|
||||||
|
let cash: [i32; N] = kani::any();
|
||||||
|
let discounts: [i32; N] = kani::any();
|
||||||
|
let fee: i32 = kani::any();
|
||||||
|
kani::assume(fee >= 0 && fee <= 3_000_000);
|
||||||
|
for i in 0..N {
|
||||||
|
kani::assume(cash[i] >= 0 && cash[i] <= 1_000_000);
|
||||||
|
kani::assume(discounts[i] >= 0 && discounts[i] <= 1_000_000);
|
||||||
|
}
|
||||||
|
let total_cash = cash[0] + cash[1] + cash[2];
|
||||||
|
let total_discounts = discounts[0] + discounts[1] + discounts[2];
|
||||||
|
let got = kernel::settle(&cash, &discounts, fee);
|
||||||
|
if fee <= total_cash {
|
||||||
|
let settlement = got.expect("no overflow under these bounds");
|
||||||
|
assert_eq!(settlement.applications_total, total_cash + total_discounts);
|
||||||
|
assert_eq!(settlement.deposit, total_cash - fee);
|
||||||
|
assert!(settlement.deposit >= 0);
|
||||||
|
} else {
|
||||||
|
assert!(got.is_none());
|
||||||
|
}
|
||||||
|
kani::cover!(
|
||||||
|
got.is_some() && discounts.iter().any(|d| *d > 0) && fee > 0 && fee < total_cash,
|
||||||
|
"a discount and a fee inside the cash"
|
||||||
|
);
|
||||||
|
kani::cover!(got.is_none(), "a fee above the cash");
|
||||||
|
}
|
||||||
|
|
||||||
/// `kernel::line_amount` is the exact product of a quantity and a unit
|
/// `kernel::line_amount` is the exact product of a quantity and a unit
|
||||||
/// price: never a rounded amount, and None rather than an overflow.
|
/// price: never a rounded amount, and None rather than an overflow.
|
||||||
#[kani::proof]
|
#[kani::proof]
|
||||||
|
|||||||
@@ -521,6 +521,27 @@ proptest! {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// kernel::settle at i64 (what Kani's settle_is_exact proved) and at
|
||||||
|
/// Decimal agree: the same cash, discounts and fee give the same
|
||||||
|
/// applications total and deposit, and both refuse the same cases.
|
||||||
|
#[test]
|
||||||
|
fn settle_agrees(
|
||||||
|
cash in prop::collection::vec(0..=MAX_CENTS, 0..4),
|
||||||
|
discounts in prop::collection::vec(0..=MAX_CENTS, 0..4),
|
||||||
|
fee in 0..=MAX_CENTS,
|
||||||
|
) {
|
||||||
|
let narrow = kernel::settle(&cash, &discounts, fee);
|
||||||
|
let wide = kernel::settle(
|
||||||
|
&cash.iter().map(|&c| dec(c)).collect::<Vec<_>>(),
|
||||||
|
&discounts.iter().map(|&c| dec(c)).collect::<Vec<_>>(),
|
||||||
|
dec(fee),
|
||||||
|
);
|
||||||
|
prop_assert_eq!(
|
||||||
|
narrow.map(|s| (dec(s.applications_total), dec(s.deposit))),
|
||||||
|
wide.map(|s| (s.applications_total, s.deposit))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// kernel::line_amount at i64 (what Kani's line_amount_is_exact
|
/// kernel::line_amount at i64 (what Kani's line_amount_is_exact
|
||||||
/// proved) and at Decimal agree: cents x cents at i64 is the same
|
/// proved) and at Decimal agree: cents x cents at i64 is the same
|
||||||
/// product as dollars x dollars at Decimal, to four places.
|
/// product as dollars x dollars at Decimal, to four places.
|
||||||
|
|||||||
@@ -3662,3 +3662,41 @@ fn a_government_selling_services_is_a_supplier() {
|
|||||||
let line = cf.operating.iter().find(|l| l.entry == stamps).unwrap();
|
let line = cf.operating.iter().find(|l| l.entry == stamps).unwrap();
|
||||||
assert_eq!(line.class, Some(OperatingClass::OtherPayments));
|
assert_eq!(line.class, Some(OperatingClass::OtherPayments));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A bill whose line is equipment, paid later: the payment's cash is
|
||||||
|
/// investing, through the bill's split (D-35; rules.md B-14), not
|
||||||
|
/// operating. Plan P4b S6 keeps this true when the bill is paid as part
|
||||||
|
/// of a settlement.
|
||||||
|
#[test]
|
||||||
|
fn equipment_bill_payment_is_investing_cash() {
|
||||||
|
use ledger_core::arap::{BillFields, DocumentLine};
|
||||||
|
let mut b = Books::new();
|
||||||
|
b.acct("1000", "Bank", AccountType::Asset);
|
||||||
|
b.acct("1500", "Equipment", AccountType::Asset);
|
||||||
|
b.acct("2000", "Accounts Payable", AccountType::Liability);
|
||||||
|
let bank = b.id("1000");
|
||||||
|
let equipment = b.id("1500");
|
||||||
|
let vendor = b.l.add_vendor("Tobin Harlow Supply").unwrap();
|
||||||
|
let bill =
|
||||||
|
b.l.create_bill_with(
|
||||||
|
vendor,
|
||||||
|
d(2026, 1, 10),
|
||||||
|
d(2026, 2, 9),
|
||||||
|
vec![DocumentLine {
|
||||||
|
description: "Laptop".into(),
|
||||||
|
amount: x("500.00"),
|
||||||
|
class_id: None,
|
||||||
|
account: Some(equipment),
|
||||||
|
quantity: None,
|
||||||
|
unit_price: None,
|
||||||
|
}],
|
||||||
|
BillFields::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
b.l.pay_bill(bill, d(2026, 1, 20), x("500.00"), bank)
|
||||||
|
.unwrap();
|
||||||
|
let cf = b.cf(d(2026, 1, 1), d(2026, 1, 31), &["1000"], &[], &["1500"]);
|
||||||
|
assert_eq!(cf.cash_from_investing, -x("500.00"));
|
||||||
|
assert_eq!(cf.cash_from_operations, Decimal::ZERO);
|
||||||
|
assert_eq!(cf.net_change_in_cash, -x("500.00"));
|
||||||
|
}
|
||||||
@@ -1922,3 +1922,380 @@ fn duplicate_vendor_ref_refused_unless_a_reason_is_given() {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// P4b S6: paying on terms (rules.md B-42)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A bill on "2/10 net 30", paid within the discount window: the gross
|
||||||
|
/// method's entry is Dr A/P the full amount, Cr purchase discounts what
|
||||||
|
/// was taken, Cr the bank the rest; the bill reads paid and discounted.
|
||||||
|
/// A discount larger than the terms allow needs a reason (B-42).
|
||||||
|
#[test]
|
||||||
|
fn bill_on_terms_with_discount() {
|
||||||
|
use ledger_core::{Application, DocRef, SettleParams};
|
||||||
|
let mut b = books();
|
||||||
|
let terms = Terms::parse("2/10 net 30").unwrap();
|
||||||
|
let on = d(2026, 4, 1);
|
||||||
|
let bill =
|
||||||
|
b.l.create_bill_with(
|
||||||
|
b.vendor,
|
||||||
|
on,
|
||||||
|
terms.due_date(on).unwrap(),
|
||||||
|
line("Paper", 10_000),
|
||||||
|
BillFields {
|
||||||
|
terms: Some(terms),
|
||||||
|
..BillFields::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let entry =
|
||||||
|
b.l.settle(SettleParams {
|
||||||
|
date: d(2026, 4, 5),
|
||||||
|
description: "Bill 1, early".into(),
|
||||||
|
cash_account: b.bank,
|
||||||
|
party: b.vendor,
|
||||||
|
applications: vec![Application {
|
||||||
|
doc: DocRef::Bill(bill),
|
||||||
|
cash: cents(9_800),
|
||||||
|
discount: cents(200),
|
||||||
|
discount_reason: None,
|
||||||
|
}],
|
||||||
|
fee: None,
|
||||||
|
instrument: None,
|
||||||
|
reference: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let lines = b.l.get_entry(entry).unwrap().lines();
|
||||||
|
assert_eq!(lines.len(), 4);
|
||||||
|
assert_eq!(
|
||||||
|
(lines[0].account, lines[0].side),
|
||||||
|
(b.account("2000"), Side::Debit)
|
||||||
|
);
|
||||||
|
assert_eq!(lines[0].amount.amount, cents(9_800));
|
||||||
|
assert_eq!(
|
||||||
|
(lines[1].account, lines[1].side),
|
||||||
|
(b.account("2000"), Side::Debit)
|
||||||
|
);
|
||||||
|
assert_eq!(lines[1].amount.amount, cents(200));
|
||||||
|
assert_eq!(
|
||||||
|
(lines[2].account, lines[2].side),
|
||||||
|
(b.account("6995"), Side::Credit)
|
||||||
|
);
|
||||||
|
assert_eq!(lines[2].amount.amount, cents(200));
|
||||||
|
assert_eq!((lines[3].account, lines[3].side), (b.bank, Side::Credit));
|
||||||
|
assert_eq!(lines[3].amount.amount, cents(9_800));
|
||||||
|
let st = b.state(DocRef::Bill(bill), d(2026, 4, 5));
|
||||||
|
assert_eq!(
|
||||||
|
(st.status, st.paid, st.discounted, st.outstanding),
|
||||||
|
(DocStatus::Paid, cents(9_800), cents(200), Decimal::ZERO)
|
||||||
|
);
|
||||||
|
assert_eq!(b.l.line_doc(entry, 0).unwrap().movement, Movement::Payment);
|
||||||
|
assert_eq!(b.l.line_doc(entry, 1).unwrap().movement, Movement::Discount);
|
||||||
|
b.ties(d(2026, 4, 5));
|
||||||
|
|
||||||
|
// 3% off is more than 2/10 allows: refused without a reason, taken
|
||||||
|
// with one (a vendor honouring it anyway).
|
||||||
|
let bill2 =
|
||||||
|
b.l.create_bill_with(
|
||||||
|
b.vendor,
|
||||||
|
on,
|
||||||
|
Terms::parse("2/10 net 30").unwrap().due_date(on).unwrap(),
|
||||||
|
line("Paper", 10_000),
|
||||||
|
BillFields {
|
||||||
|
terms: Some(Terms::parse("2/10 net 30").unwrap()),
|
||||||
|
..BillFields::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let params = |reason: Option<String>| SettleParams {
|
||||||
|
date: d(2026, 4, 6),
|
||||||
|
description: "Bill 2, late".into(),
|
||||||
|
cash_account: b.bank,
|
||||||
|
party: b.vendor,
|
||||||
|
applications: vec![Application {
|
||||||
|
doc: DocRef::Bill(bill2),
|
||||||
|
cash: cents(9_700),
|
||||||
|
discount: cents(300),
|
||||||
|
discount_reason: reason,
|
||||||
|
}],
|
||||||
|
fee: None,
|
||||||
|
instrument: None,
|
||||||
|
reference: None,
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
b.l.settle(params(None)),
|
||||||
|
Err(LedgerError::DiscountNeedsReason { .. })
|
||||||
|
));
|
||||||
|
b.l.settle(params(Some("the vendor honoured 3%".into())))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A discount taken after the discount date needs a reason (B-42
|
||||||
|
/// INVARIANT 2); a bill with an asset or prepaid line refuses one with the
|
||||||
|
/// D-49 remedy, and none of the refusals changes the books.
|
||||||
|
#[test]
|
||||||
|
fn late_discount_needs_a_reason() {
|
||||||
|
use ledger_core::{Application, DocRef, SettleParams};
|
||||||
|
let mut b = books();
|
||||||
|
let equipment =
|
||||||
|
b.l.add_account("1500", "Equipment", AccountType::Asset, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let terms = Terms::parse("2/10 net 30").unwrap();
|
||||||
|
let on = d(2026, 4, 1);
|
||||||
|
let bill =
|
||||||
|
b.l.create_bill_with(
|
||||||
|
b.vendor,
|
||||||
|
on,
|
||||||
|
terms.due_date(on).unwrap(),
|
||||||
|
line("Paper", 10_000),
|
||||||
|
BillFields {
|
||||||
|
terms: Some(terms.clone()),
|
||||||
|
..BillFields::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let late = |reason: Option<String>| SettleParams {
|
||||||
|
date: d(2026, 4, 20),
|
||||||
|
description: "Bill 1, late discount".into(),
|
||||||
|
cash_account: b.bank,
|
||||||
|
party: b.vendor,
|
||||||
|
applications: vec![Application {
|
||||||
|
doc: DocRef::Bill(bill),
|
||||||
|
cash: cents(9_800),
|
||||||
|
discount: cents(200),
|
||||||
|
discount_reason: reason,
|
||||||
|
}],
|
||||||
|
fee: None,
|
||||||
|
instrument: None,
|
||||||
|
reference: None,
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
b.l.settle(late(None)),
|
||||||
|
Err(LedgerError::DiscountNeedsReason { .. })
|
||||||
|
));
|
||||||
|
b.l.settle(late(Some("the vendor honoured it late".into())))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// A bill whose line is equipment: no apportioning (D-49).
|
||||||
|
let asset_bill =
|
||||||
|
b.l.create_bill_with(
|
||||||
|
b.vendor,
|
||||||
|
on,
|
||||||
|
terms.due_date(on).unwrap(),
|
||||||
|
vec![DocumentLine {
|
||||||
|
description: "Laptop".into(),
|
||||||
|
amount: cents(10_000),
|
||||||
|
class_id: None,
|
||||||
|
account: Some(equipment),
|
||||||
|
quantity: None,
|
||||||
|
unit_price: None,
|
||||||
|
}],
|
||||||
|
BillFields {
|
||||||
|
terms: Some(terms),
|
||||||
|
..BillFields::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let before = b.l.doc_state(DocRef::Bill(asset_bill), d(2026, 4, 5));
|
||||||
|
let refused = b.l.settle(SettleParams {
|
||||||
|
date: d(2026, 4, 5),
|
||||||
|
description: "Laptop, discounted".into(),
|
||||||
|
cash_account: b.bank,
|
||||||
|
party: b.vendor,
|
||||||
|
applications: vec![Application {
|
||||||
|
doc: DocRef::Bill(asset_bill),
|
||||||
|
cash: cents(9_800),
|
||||||
|
discount: cents(200),
|
||||||
|
discount_reason: Some("vendor honoured it".into()),
|
||||||
|
}],
|
||||||
|
fee: None,
|
||||||
|
instrument: None,
|
||||||
|
reference: None,
|
||||||
|
});
|
||||||
|
match refused {
|
||||||
|
Err(LedgerError::DiscountOnAssetBill { code, .. }) => assert_eq!(code, "1500"),
|
||||||
|
other => panic!("expected DiscountOnAssetBill, got {other:?}"),
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
b.l.doc_state(DocRef::Bill(asset_bill), d(2026, 4, 5)),
|
||||||
|
before
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
b.state(DocRef::Bill(asset_bill), d(2026, 4, 5)).outstanding,
|
||||||
|
cents(10_000)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The processor's fee posts gross (A-4): Dr the bank the net, Dr the
|
||||||
|
/// processing-fee expense the fee, Cr A/R the gross; the invoice reads
|
||||||
|
/// paid and the deposit account has only the net.
|
||||||
|
#[test]
|
||||||
|
fn a_fee_posts_gross_to_receivables_and_net_to_the_bank() {
|
||||||
|
use ledger_core::{Application, DocRef, Fee, SettleParams};
|
||||||
|
let mut b = books();
|
||||||
|
let invoice = b.invoice(d(2026, 4, 1), 10_000);
|
||||||
|
let entry =
|
||||||
|
b.l.settle(SettleParams {
|
||||||
|
date: d(2026, 4, 3),
|
||||||
|
description: "Card payment, fee withheld".into(),
|
||||||
|
cash_account: b.bank,
|
||||||
|
party: b.customer,
|
||||||
|
applications: vec![Application {
|
||||||
|
doc: DocRef::Invoice(invoice),
|
||||||
|
cash: cents(10_000),
|
||||||
|
discount: Decimal::ZERO,
|
||||||
|
discount_reason: None,
|
||||||
|
}],
|
||||||
|
fee: Some(Fee {
|
||||||
|
amount: cents(300),
|
||||||
|
account: None,
|
||||||
|
vendor: None,
|
||||||
|
}),
|
||||||
|
instrument: None,
|
||||||
|
reference: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let lines = b.l.get_entry(entry).unwrap().lines();
|
||||||
|
assert_eq!(lines.len(), 3);
|
||||||
|
assert_eq!(
|
||||||
|
(lines[0].account, lines[0].side),
|
||||||
|
(b.account("1200"), Side::Credit)
|
||||||
|
);
|
||||||
|
assert_eq!(lines[0].amount.amount, cents(10_000));
|
||||||
|
assert_eq!(
|
||||||
|
(lines[1].account, lines[1].side),
|
||||||
|
(b.account("6060"), Side::Debit)
|
||||||
|
);
|
||||||
|
assert_eq!(lines[1].amount.amount, cents(300));
|
||||||
|
assert_eq!((lines[2].account, lines[2].side), (b.bank, Side::Debit));
|
||||||
|
assert_eq!(lines[2].amount.amount, cents(9_700));
|
||||||
|
assert_eq!(
|
||||||
|
b.state(DocRef::Invoice(invoice), d(2026, 4, 3)).status,
|
||||||
|
DocStatus::Paid
|
||||||
|
);
|
||||||
|
assert_eq!(b.l.account_balance(b.bank).unwrap(), cents(9_700));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A payment that settles more than one document names its lines in
|
||||||
|
/// application order, and two applications on one document are refused:
|
||||||
|
/// each document's bound is one bound (B-42 INVARIANTS 1 and 2).
|
||||||
|
#[test]
|
||||||
|
fn a_settlement_names_its_lines_and_refuses_a_document_twice() {
|
||||||
|
use ledger_core::{Application, DocRef, SettleParams};
|
||||||
|
let mut b = books();
|
||||||
|
let first = b.invoice(d(2026, 4, 1), 10_000);
|
||||||
|
let second = b.invoice(d(2026, 4, 2), 5_000);
|
||||||
|
let entry =
|
||||||
|
b.l.settle(SettleParams {
|
||||||
|
date: d(2026, 4, 5),
|
||||||
|
description: "Two invoices, one deposit".into(),
|
||||||
|
cash_account: b.bank,
|
||||||
|
party: b.customer,
|
||||||
|
applications: vec![
|
||||||
|
Application {
|
||||||
|
doc: DocRef::Invoice(first),
|
||||||
|
cash: cents(10_000),
|
||||||
|
discount: Decimal::ZERO,
|
||||||
|
discount_reason: None,
|
||||||
|
},
|
||||||
|
Application {
|
||||||
|
doc: DocRef::Invoice(second),
|
||||||
|
cash: cents(5_000),
|
||||||
|
discount: Decimal::ZERO,
|
||||||
|
discount_reason: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
fee: None,
|
||||||
|
instrument: None,
|
||||||
|
reference: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let lines = b.l.get_entry(entry).unwrap().lines();
|
||||||
|
assert_eq!(lines.len(), 3);
|
||||||
|
assert_eq!(b.l.line_doc(entry, 0).unwrap().doc, DocRef::Invoice(first));
|
||||||
|
assert_eq!(b.l.line_doc(entry, 1).unwrap().doc, DocRef::Invoice(second));
|
||||||
|
assert!(b.l.line_doc(entry, 2).is_none());
|
||||||
|
assert_eq!(b.l.account_balance(b.bank).unwrap(), cents(15_000));
|
||||||
|
|
||||||
|
let thrice = b.invoice(d(2026, 4, 3), 10_000);
|
||||||
|
let before = b.l.account_balance(b.bank).unwrap();
|
||||||
|
let refused = b.l.settle(SettleParams {
|
||||||
|
date: d(2026, 4, 5),
|
||||||
|
description: "Twice".into(),
|
||||||
|
cash_account: b.bank,
|
||||||
|
party: b.customer,
|
||||||
|
applications: vec![
|
||||||
|
Application {
|
||||||
|
doc: DocRef::Invoice(thrice),
|
||||||
|
cash: cents(5_000),
|
||||||
|
discount: Decimal::ZERO,
|
||||||
|
discount_reason: None,
|
||||||
|
},
|
||||||
|
Application {
|
||||||
|
doc: DocRef::Invoice(thrice),
|
||||||
|
cash: cents(5_000),
|
||||||
|
discount: Decimal::ZERO,
|
||||||
|
discount_reason: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
fee: None,
|
||||||
|
instrument: None,
|
||||||
|
reference: None,
|
||||||
|
});
|
||||||
|
assert!(matches!(
|
||||||
|
refused,
|
||||||
|
Err(LedgerError::SettlementDuplicateDocument(_))
|
||||||
|
));
|
||||||
|
assert_eq!(b.l.account_balance(b.bank).unwrap(), before);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A discount on a bill with an asset line is refused, never apportioned
|
||||||
|
/// (D-49): the refusal names the asset account and both remedies (record
|
||||||
|
/// the bill net, or reclassify Dr 6995 / Cr the asset).
|
||||||
|
#[test]
|
||||||
|
fn discount_on_an_asset_bill_is_refused_with_the_remedy() {
|
||||||
|
use ledger_core::{Application, DocRef, SettleParams};
|
||||||
|
let mut b = books();
|
||||||
|
let equipment =
|
||||||
|
b.l.add_account("1500", "Equipment", AccountType::Asset, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let bill =
|
||||||
|
b.l.create_bill(
|
||||||
|
b.vendor,
|
||||||
|
d(2026, 4, 1),
|
||||||
|
d(2026, 5, 1),
|
||||||
|
vec![DocumentLine {
|
||||||
|
description: "Laptop".into(),
|
||||||
|
amount: cents(10_000),
|
||||||
|
class_id: None,
|
||||||
|
account: Some(equipment),
|
||||||
|
quantity: None,
|
||||||
|
unit_price: None,
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
match b.l.settle(SettleParams {
|
||||||
|
date: d(2026, 4, 5),
|
||||||
|
description: "Laptop, discounted".into(),
|
||||||
|
cash_account: b.bank,
|
||||||
|
party: b.vendor,
|
||||||
|
applications: vec![Application {
|
||||||
|
doc: DocRef::Bill(bill),
|
||||||
|
cash: cents(9_800),
|
||||||
|
discount: cents(200),
|
||||||
|
discount_reason: Some("the vendor honoured it".into()),
|
||||||
|
}],
|
||||||
|
fee: None,
|
||||||
|
instrument: None,
|
||||||
|
reference: None,
|
||||||
|
}) {
|
||||||
|
Err(LedgerError::DiscountOnAssetBill { code, .. }) => assert_eq!(code, "1500"),
|
||||||
|
other => panic!("expected DiscountOnAssetBill, got {other:?}"),
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
b.state(DocRef::Bill(bill), d(2026, 4, 5)).outstanding,
|
||||||
|
cents(10_000)
|
||||||
|
);
|
||||||
|
b.ties(d(2026, 4, 5));
|
||||||
|
}
|
||||||
@@ -55,6 +55,7 @@ struct Item {
|
|||||||
party: String,
|
party: String,
|
||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
due: NaiveDate,
|
due: NaiveDate,
|
||||||
|
terms: Option<ledger_core::Terms>,
|
||||||
state: Result<Option<DocState>, ledger_core::LedgerError>,
|
state: Result<Option<DocState>, ledger_core::LedgerError>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +75,7 @@ fn items(b: &Books, bills: bool) -> Vec<Item> {
|
|||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
date: d.date,
|
date: d.date,
|
||||||
due: d.due_date,
|
due: d.due_date,
|
||||||
|
terms: d.terms.clone(),
|
||||||
state: b.doc_today(DocRef::Bill(d.id)),
|
state: b.doc_today(DocRef::Bill(d.id)),
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -89,6 +91,7 @@ fn items(b: &Books, bills: bool) -> Vec<Item> {
|
|||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
date: d.date,
|
date: d.date,
|
||||||
due: d.due_date,
|
due: d.due_date,
|
||||||
|
terms: d.terms.clone(),
|
||||||
state: b.doc_today(DocRef::Invoice(d.id)),
|
state: b.doc_today(DocRef::Invoice(d.id)),
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -282,26 +285,23 @@ pub fn show_list(ui: &mut Ui, b: &Books, bills: bool, shell: &mut Shell<'_>) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let amt = widgets::AMOUNT_MIN + 16.0;
|
let amt = widgets::AMOUNT_MIN + 16.0;
|
||||||
let fixed = 110.0 + 104.0 * 2.0 + 120.0 + amt * 2.0;
|
let fixed = 110.0 + 104.0 * 2.0 + 120.0 + amt * 2.0 + if bills { 104.0 } else { 0.0 };
|
||||||
let w = ui.available_width().max(fixed + 160.0);
|
let w = ui.available_width().max(fixed + 160.0);
|
||||||
widgets::min_width(ui, "invoice_list", w, |ui| {
|
widgets::min_width(ui, "invoice_list", w, |ui| {
|
||||||
let head = |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted);
|
let head = |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted);
|
||||||
row(
|
let mut header = vec![
|
||||||
ui,
|
head("Document", 110.0, false),
|
||||||
w,
|
head(if bills { "Vendor" } else { "Customer" }, 0.0, false),
|
||||||
&[
|
head("Date", 104.0, false),
|
||||||
head("Document", 110.0, false),
|
head("Due", 104.0, false),
|
||||||
head(if bills { "Vendor" } else { "Customer" }, 0.0, false),
|
head("Status", 120.0, false),
|
||||||
head("Date", 104.0, false),
|
head("Total", amt, true),
|
||||||
head("Due", 104.0, false),
|
head("Still owed", amt, true),
|
||||||
head("Status", 120.0, false),
|
];
|
||||||
head("Total", amt, true),
|
if bills {
|
||||||
head("Still owed", amt, true),
|
header.push(head("Discount by", 104.0, false));
|
||||||
],
|
}
|
||||||
None,
|
row(ui, w, &header, None, true, false);
|
||||||
true,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
widgets::rule(ui);
|
widgets::rule(ui);
|
||||||
for it in rows {
|
for it in rows {
|
||||||
let (status, total, owed, late) = match &it.state {
|
let (status, total, owed, late) = match &it.state {
|
||||||
@@ -314,28 +314,49 @@ pub fn show_list(ui: &mut Ui, b: &Books, bills: bool, shell: &mut Shell<'_>) {
|
|||||||
Ok(None) => ("Unknown".into(), String::new(), String::new(), false),
|
Ok(None) => ("Unknown".into(), String::new(), String::new(), false),
|
||||||
Err(_) => ("Can't compute".into(), String::new(), String::new(), false),
|
Err(_) => ("Can't compute".into(), String::new(), String::new(), false),
|
||||||
};
|
};
|
||||||
|
let discount_by = it.terms.as_ref().and_then(|t| t.discount_by(it.date));
|
||||||
|
let discount_open = bills && discount_by.is_some_and(|d| d >= b.today);
|
||||||
|
let discount_soon = discount_open
|
||||||
|
&& discount_by.is_some_and(|d| d <= b.today + chrono::Days::new(3));
|
||||||
|
let due_soon = bills
|
||||||
|
&& matches!(&it.state, Ok(Some(s))
|
||||||
|
if !s.outstanding.is_zero() && s.status != DocStatus::Void)
|
||||||
|
&& it.due <= b.today + chrono::Days::new(7);
|
||||||
let name = format!("{}, {}, {}, owes {}", it.name, it.party, status, owed);
|
let name = format!("{}, {}, {}, owes {}", it.name, it.party, status, owed);
|
||||||
let resp = row(
|
let mut cells = vec![
|
||||||
ui,
|
(it.name.clone(), 110.0, false, p.text),
|
||||||
w,
|
(it.party.clone(), 0.0, false, p.text),
|
||||||
&[
|
(fmt::date(it.date), 104.0, false, p.text),
|
||||||
(it.name.clone(), 110.0, false, p.text),
|
(
|
||||||
(it.party.clone(), 0.0, false, p.text),
|
fmt::date(it.due),
|
||||||
(fmt::date(it.date), 104.0, false, p.text),
|
104.0,
|
||||||
(
|
false,
|
||||||
fmt::date(it.due),
|
if late || due_soon { p.warning } else { p.text },
|
||||||
104.0,
|
),
|
||||||
false,
|
(
|
||||||
if late { p.warning } else { p.text },
|
status.clone(),
|
||||||
),
|
120.0,
|
||||||
(status, 120.0, false, if late { p.warning } else { p.muted }),
|
false,
|
||||||
(total, amt, true, p.text),
|
if late { p.warning } else { p.muted },
|
||||||
(owed, amt, true, p.text),
|
),
|
||||||
],
|
(total, amt, true, p.text),
|
||||||
None,
|
(owed, amt, true, p.text),
|
||||||
false,
|
];
|
||||||
true,
|
if bills {
|
||||||
);
|
cells.push((
|
||||||
|
discount_by.map(fmt::date).unwrap_or_default(),
|
||||||
|
104.0,
|
||||||
|
false,
|
||||||
|
if discount_soon {
|
||||||
|
p.warning
|
||||||
|
} else if discount_open {
|
||||||
|
p.text
|
||||||
|
} else {
|
||||||
|
p.muted
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let resp = row(ui, w, &cells, None, false, true);
|
||||||
// Named in full, for assistive tech (and the tests).
|
// Named in full, for assistive tech (and the tests).
|
||||||
resp.widget_info(|| {
|
resp.widget_info(|| {
|
||||||
egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name)
|
egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name)
|
||||||
|
|||||||
@@ -157,6 +157,8 @@ fn the_list_shows_the_ledgers_figures() {
|
|||||||
);
|
);
|
||||||
let mut h = open(&dir, Route::Payables);
|
let mut h = open(&dir, Route::Payables);
|
||||||
shot(&mut h, "payables");
|
shot(&mut h, "payables");
|
||||||
|
// The Money out list carries the discount-deadline column (blank
|
||||||
|
// until terms are journaled, S4.3).
|
||||||
assert!(
|
assert!(
|
||||||
h.query_by_label_contains("Bill 1, Clay Co, Open, owes 300.00")
|
h.query_by_label_contains("Bill 1, Clay Co, Open, owes 300.00")
|
||||||
.is_some()
|
.is_some()
|
||||||
|
|||||||
@@ -307,6 +307,12 @@ pub fn chart_template(entity: EntityType, owners: &[String]) -> Result<ChartTemp
|
|||||||
"expense",
|
"expense",
|
||||||
Some("nondeductible"),
|
Some("nondeductible"),
|
||||||
),
|
),
|
||||||
|
acct(
|
||||||
|
"6995",
|
||||||
|
"Purchase Discounts",
|
||||||
|
"expense",
|
||||||
|
Some("other_deductions"),
|
||||||
|
),
|
||||||
acct(
|
acct(
|
||||||
"7100",
|
"7100",
|
||||||
"Employee Benefits",
|
"Employee Benefits",
|
||||||
|
|||||||
@@ -6,3 +6,4 @@
|
|||||||
- Only the owner can agree to the upgrade, with the owner passphrase: in the app, which asks each time it opens the books, or with `wanderbooks books upgrade ID` in a terminal. No tool can, and you never ask for, take or type the passphrase. Tell the human, in plain words: the books wait for their go-ahead, what was refused, and that saying no keeps the books working as they are.
|
- Only the owner can agree to the upgrade, with the owner passphrase: in the app, which asks each time it opens the books, or with `wanderbooks books upgrade ID` in a terminal. No tool can, and you never ask for, take or type the passphrase. Tell the human, in plain words: the books wait for their go-ahead, what was refused, and that saying no keeps the books working as they are.
|
||||||
- Before it upgrades, the server makes a backup of the books, their vault and keys included, and proves it whole; a backup that can't be made or proven upgrades nothing. Then the books move to the new format in one step, on the record.
|
- Before it upgrades, the server makes a backup of the books, their vault and keys included, and proves it whole; a backup that can't be made or proven upgrades nothing. Then the books move to the new format in one step, on the record.
|
||||||
- After the upgrade an older version of wanderbooks can't open these books; the backup made first keeps them as they were. Going back is the owner's choice, never yours.
|
- After the upgrade an older version of wanderbooks can't open these books; the backup made first keeps them as they were. Going back is the owner's choice, never yours.
|
||||||
|
- This upgrade is `documents_v2`: invoice numbers, terms and line accounts (S4), sent records (S5), payments that settle several documents with early-payment discounts and a processor's fee (S6), and write-offs (S7). Until it is given, those writes are refused and everything else works as before.
|
||||||
@@ -7,7 +7,7 @@ own figures are read from the ledger lines tagged with it, never kept on
|
|||||||
the document: a reversed payment, a void or a write-off comes out right
|
the document: a reversed payment, a void or a write-off comes out right
|
||||||
without another copy to correct (B-38).
|
without another copy to correct (B-38).
|
||||||
|
|
||||||
## Sending an invoice
|
## 1. Send an invoice
|
||||||
|
|
||||||
- The books never send mail, and never say they did. To send an invoice,
|
- The books never send mail, and never say they did. To send an invoice,
|
||||||
create and post it (`books.create_invoice`), hand its PDF to the human
|
create and post it (`books.create_invoice`), hand its PDF to the human
|
||||||
@@ -26,3 +26,60 @@ without another copy to correct (B-38).
|
|||||||
- A void invoice can't be marked sent: it should never have reached the
|
- A void invoice can't be marked sent: it should never have reached the
|
||||||
customer. An invoice already open, partly paid, paid or written off can
|
customer. An invoice already open, partly paid, paid or written off can
|
||||||
(a copy for the customer's records, a reminder).
|
(a copy for the customer's records, a reminder).
|
||||||
|
|
||||||
|
## 2. Record a customer payment
|
||||||
|
|
||||||
|
- Ask for the date the money arrived, where it landed, and what it paid.
|
||||||
|
One deposit may settle several invoices of the same customer: pass
|
||||||
|
`applications` with each invoice and its cash amount.
|
||||||
|
- Record how it moved (`instrument`) and the processor's or bank's
|
||||||
|
reference. The books record payments; they never collect them.
|
||||||
|
- If the customer paid more than the invoices owe, the payment is
|
||||||
|
refused: record the excess as a plain entry to 2300 Customer Deposits
|
||||||
|
(no role) and apply it when the work is invoiced (D-49, A-12).
|
||||||
|
- A check or note marked "paid in full" of a disputed amount earns the
|
||||||
|
UCC §3-311 warning. Record only the payment; a lawyer or the payer's
|
||||||
|
agreement settles the rest.
|
||||||
|
|
||||||
|
## 3. Pay a vendor bill
|
||||||
|
|
||||||
|
- Ask for the date, the account it left, and which bills it pays. One
|
||||||
|
payment may settle several bills of one vendor (`applications`).
|
||||||
|
- Record the method and the reference. A card payment is tagged as card
|
||||||
|
and stays out of 1099-NEC (D-40).
|
||||||
|
- Use `books.bills_due` for what is due and when, with each vendor's
|
||||||
|
early-payment discount window.
|
||||||
|
|
||||||
|
### Taking an early-payment discount
|
||||||
|
|
||||||
|
- A bill on "2/10 net 30" takes 2% off if paid within ten days. Pass the
|
||||||
|
discount beside the cash (`discount`); the books post the gross method:
|
||||||
|
the bill is credited in full, the discount goes to Purchase Discounts
|
||||||
|
(6995), and the bank gets the rest.
|
||||||
|
- A discount at most the terms allow and dated on or before their
|
||||||
|
discount date needs no reason. A larger or later one needs
|
||||||
|
`discount_reason` (a vendor who honours it anyway); it is journaled.
|
||||||
|
- A bill with an asset or prepaid line refuses a discount: record the
|
||||||
|
bill net of the discount, or reclassify with an entry Dr 6995 Purchase
|
||||||
|
Discounts / Cr the asset account (D-49). Never split the discount across
|
||||||
|
the asset lines.
|
||||||
|
|
||||||
|
## 5. A processor's fee
|
||||||
|
|
||||||
|
- The books never charge or collect; a processor does, and keeps a fee.
|
||||||
|
Pass `fee` on `books.receive_payment` (amount, and optionally the
|
||||||
|
expense account and the processor's vendor).
|
||||||
|
- The entry is gross: Dr the deposit account the net, Dr the
|
||||||
|
processing-fee expense the fee, Cr Accounts Receivable the gross (A-4).
|
||||||
|
Only cash movements count toward a 1099, so the fee is not a payment to
|
||||||
|
the processor.
|
||||||
|
|
||||||
|
## A replacement payment arrives before a bounce is known
|
||||||
|
|
||||||
|
1. Park it: a plain entry Dr the bank / Cr 2300 Customer Deposits (no
|
||||||
|
role), described with the customer and what it is for.
|
||||||
|
2. When the first payment bounces, reverse it (`books.reverse_entry`)
|
||||||
|
dated the bounce day; it un-pays its invoices.
|
||||||
|
3. Apply the parked money with `books.receive_payment`,
|
||||||
|
`deposit_to: "2300"`, dated on or after the bounce, against the
|
||||||
|
invoices it pays. The 2300 balance returns to zero.
|
||||||
@@ -79,6 +79,10 @@ pub const SECTIONS: &[(&str, &str)] = &[
|
|||||||
include_str!("../handbook/parties-and-tins.md"),
|
include_str!("../handbook/parties-and-tins.md"),
|
||||||
),
|
),
|
||||||
("1099", include_str!("../handbook/1099.md")),
|
("1099", include_str!("../handbook/1099.md")),
|
||||||
|
(
|
||||||
|
"invoices-and-bills",
|
||||||
|
include_str!("../handbook/invoices-and-bills.md"),
|
||||||
|
),
|
||||||
("cpa-handoff", include_str!("../handbook/cpa-handoff.md")),
|
("cpa-handoff", include_str!("../handbook/cpa-handoff.md")),
|
||||||
(
|
(
|
||||||
"settings-and-conventions",
|
"settings-and-conventions",
|
||||||
@@ -103,10 +107,6 @@ pub const SECTIONS: &[(&str, &str)] = &[
|
|||||||
"tracing-figures",
|
"tracing-figures",
|
||||||
include_str!("../handbook/tracing-figures.md"),
|
include_str!("../handbook/tracing-figures.md"),
|
||||||
),
|
),
|
||||||
(
|
|
||||||
"invoices-and-bills",
|
|
||||||
include_str!("../handbook/invoices-and-bills.md"),
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/// The resource URI of the handbook's index, and the prefix of a section's.
|
/// The resource URI of the handbook's index, and the prefix of a section's.
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ const C: Needs = PostWhenConfirmed;
|
|||||||
const K: Needs = PostForApprovalRules;
|
const K: Needs = PostForApprovalRules;
|
||||||
|
|
||||||
/// Every tool and what it needs (INVARIANT 1).
|
/// Every tool and what it needs (INVARIANT 1).
|
||||||
pub const TABLE: [(&str, Needs); 123] = [
|
pub const TABLE: [(&str, Needs); 124] = [
|
||||||
("books.handbook", R),
|
("books.handbook", R),
|
||||||
("books.start", R),
|
("books.start", R),
|
||||||
// Settings are committed at once: the settings commit of the plan.
|
// Settings are committed at once: the settings commit of the plan.
|
||||||
@@ -173,6 +173,7 @@ pub const TABLE: [(&str, Needs); 123] = [
|
|||||||
("books.document", R),
|
("books.document", R),
|
||||||
("books.ar_aging", R),
|
("books.ar_aging", R),
|
||||||
("books.ap_aging", R),
|
("books.ap_aging", R),
|
||||||
|
("books.bills_due", R),
|
||||||
("books.set_fx_rate", W),
|
("books.set_fx_rate", W),
|
||||||
("books.run_payroll", P),
|
("books.run_payroll", P),
|
||||||
("books.remit_payroll_taxes", P),
|
("books.remit_payroll_taxes", P),
|
||||||
|
|||||||
@@ -567,6 +567,7 @@ impl BooksServer {
|
|||||||
+ Self::projects_router()
|
+ Self::projects_router()
|
||||||
+ Self::documents_router()
|
+ Self::documents_router()
|
||||||
+ Self::sent_router()
|
+ Self::sent_router()
|
||||||
|
+ Self::settlements_router()
|
||||||
+ Self::entries_router()
|
+ Self::entries_router()
|
||||||
+ Self::reports_router()
|
+ Self::reports_router()
|
||||||
+ Self::assets_router()
|
+ Self::assets_router()
|
||||||
@@ -1425,6 +1426,8 @@ mod schedules;
|
|||||||
mod sent;
|
mod sent;
|
||||||
#[path = "tools/settings.rs"]
|
#[path = "tools/settings.rs"]
|
||||||
mod settings_tools;
|
mod settings_tools;
|
||||||
|
#[path = "tools/settlements.rs"]
|
||||||
|
mod settlements;
|
||||||
#[path = "tools/tax.rs"]
|
#[path = "tools/tax.rs"]
|
||||||
mod tax;
|
mod tax;
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ fn annotations_complete() {
|
|||||||
const D: Hints = (Some(false), Some(true), None);
|
const D: Hints = (Some(false), Some(true), None);
|
||||||
const WI: Hints = (Some(false), Some(false), Some(true));
|
const WI: Hints = (Some(false), Some(false), Some(true));
|
||||||
const DI: Hints = (Some(false), Some(true), Some(true));
|
const DI: Hints = (Some(false), Some(true), Some(true));
|
||||||
let table: [(&str, Hints); 123] = [
|
let table: [(&str, Hints); 124] = [
|
||||||
("books.handbook", R),
|
("books.handbook", R),
|
||||||
("books.start", R),
|
("books.start", R),
|
||||||
("books.set_setting", DI),
|
("books.set_setting", DI),
|
||||||
@@ -151,6 +151,7 @@ fn annotations_complete() {
|
|||||||
("books.document", R),
|
("books.document", R),
|
||||||
("books.ar_aging", R),
|
("books.ar_aging", R),
|
||||||
("books.ap_aging", R),
|
("books.ap_aging", R),
|
||||||
|
("books.bills_due", R),
|
||||||
("books.set_fx_rate", W),
|
("books.set_fx_rate", W),
|
||||||
("books.run_payroll", D),
|
("books.run_payroll", D),
|
||||||
("books.remit_payroll_taxes", D),
|
("books.remit_payroll_taxes", D),
|
||||||
|
|||||||
@@ -1,48 +1,13 @@
|
|||||||
//! `books.documents`' group: the A/R and A/P documents (invoices and
|
//! `books.documents`' group: the A/R and A/P documents (invoices and
|
||||||
//! bills), the payments and voids against them, the read tools
|
//! bills), their voids, the read tools (`books.documents`,
|
||||||
//! (`books.documents`, `books.document`), the control check and the
|
//! `books.document`) and the control check. Split whole out of
|
||||||
//! agings. Split whole out of `server.rs` (plan/served.md 8.2 U0a): the
|
//! `server.rs` (plan/served.md 8.2 U0a); the payments and the agings live
|
||||||
//! tools, their names, descriptions and rules are unchanged
|
//! in `tools/settlements.rs` (P4b S6).
|
||||||
//! (`docs/mcp-interface.md`; rules.md B-38).
|
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use ledger_core::Terms;
|
use ledger_core::Terms;
|
||||||
use ledger_core::arap::{self, PartySnapshot};
|
use ledger_core::arap::{self, PartySnapshot};
|
||||||
|
|
||||||
/// A payment's method and reference from its parameters (the method by
|
|
||||||
/// id or label, books.instruments).
|
|
||||||
fn payment_details(
|
|
||||||
store: &Store,
|
|
||||||
p: &PaymentParams,
|
|
||||||
) -> Result<ledger_core::PaymentDetails, String> {
|
|
||||||
Ok(ledger_core::PaymentDetails {
|
|
||||||
instrument: p
|
|
||||||
.instrument
|
|
||||||
.as_deref()
|
|
||||||
.map(|r| resolve_instrument(store, r))
|
|
||||||
.transpose()?,
|
|
||||||
reference: p.reference.clone(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What a payment tool answers: the document's figures after it, derived
|
|
||||||
/// from its lines that stand (rules.md B-38), as of the end of the books.
|
|
||||||
fn payment_out(
|
|
||||||
ledger: &Ledger,
|
|
||||||
doc: ledger_core::DocRef,
|
|
||||||
entry_id: u64,
|
|
||||||
) -> Result<Json<PaymentOut>, String> {
|
|
||||||
let st = ledger
|
|
||||||
.doc_state(doc, chrono::NaiveDate::MAX)
|
|
||||||
.map_err(|e| e.to_string())?
|
|
||||||
.ok_or_else(|| format!("{} not found", doc_label(doc)))?;
|
|
||||||
Ok(Json(PaymentOut {
|
|
||||||
entry_id,
|
|
||||||
remaining: money(st.outstanding),
|
|
||||||
status: st.status.key().to_string(),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A document's id.
|
/// A document's id.
|
||||||
fn doc_id(doc: ledger_core::DocRef) -> u64 {
|
fn doc_id(doc: ledger_core::DocRef) -> u64 {
|
||||||
match doc {
|
match doc {
|
||||||
@@ -71,14 +36,6 @@ fn movement_key(m: ledger_core::Movement) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A document's kind and id for people ("invoice 7").
|
|
||||||
fn doc_label(doc: ledger_core::DocRef) -> String {
|
|
||||||
match doc {
|
|
||||||
ledger_core::DocRef::Invoice(id) => format!("invoice {id}"),
|
|
||||||
ledger_core::DocRef::Bill(id) => format!("bill {id}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An invoice's or bill's lines as the tool takes them: each amount
|
/// An invoice's or bill's lines as the tool takes them: each amount
|
||||||
/// parsed, each project and account resolved ([`resolve_class`],
|
/// parsed, each project and account resolved ([`resolve_class`],
|
||||||
/// [`resolve_account`]), and quantity and unit price together, before
|
/// [`resolve_account`]), and quantity and unit price together, before
|
||||||
@@ -847,74 +804,6 @@ impl BooksServer {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tool(
|
|
||||||
name = "books.receive_payment",
|
|
||||||
title = "Receive an invoice payment",
|
|
||||||
description = "Record a customer payment against an invoice. Posts Dr Cash / Cr A/R, the receivable line tagged with the invoice (its figures are derived from its lines that stand: a reversed payment no longer counts). Name instrument (how the money moved, books.instruments) and the processor's or bank's reference when known. The books record payments; they never collect them (D-45). Refused above what it owes, before its date, or into an account that isn't cash.",
|
|
||||||
annotations(
|
|
||||||
read_only_hint = false,
|
|
||||||
destructive_hint = true,
|
|
||||||
open_world_hint = false
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
async fn receive_payment(
|
|
||||||
&self,
|
|
||||||
params: Parameters<PaymentParams>,
|
|
||||||
) -> Result<Json<PaymentOut>, String> {
|
|
||||||
let p = params.0;
|
|
||||||
let mut store = self.lock()?;
|
|
||||||
let cash = resolve_account(&store, &p.cash_account)?;
|
|
||||||
let details = payment_details(&store, &p)?;
|
|
||||||
let entry_id = store
|
|
||||||
.receive_payment_with(
|
|
||||||
p.document_id,
|
|
||||||
parse_date(&p.date)?,
|
|
||||||
parse_amount(&p.amount)?,
|
|
||||||
cash,
|
|
||||||
&details,
|
|
||||||
)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
payment_out(
|
|
||||||
store.ledger(),
|
|
||||||
ledger_core::DocRef::Invoice(p.document_id),
|
|
||||||
entry_id,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tool(
|
|
||||||
name = "books.pay_bill",
|
|
||||||
title = "Pay a bill",
|
|
||||||
description = "Record a payment of a vendor bill. Posts Dr A/P / Cr Cash, the payable line tagged with the bill (its figures are derived from its lines that stand: a reversed payment no longer counts). Name instrument (how the money moved, books.instruments: a card payment is left out of 1099-NEC) and the payment's reference when known. The books record payments; they never make them (D-45). Refused above what it owes, before its date, or from an account that isn't cash.",
|
|
||||||
annotations(
|
|
||||||
read_only_hint = false,
|
|
||||||
destructive_hint = true,
|
|
||||||
open_world_hint = false
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
async fn pay_bill(
|
|
||||||
&self,
|
|
||||||
params: Parameters<PaymentParams>,
|
|
||||||
) -> Result<Json<PaymentOut>, String> {
|
|
||||||
let p = params.0;
|
|
||||||
let mut store = self.lock()?;
|
|
||||||
let cash = resolve_account(&store, &p.cash_account)?;
|
|
||||||
let details = payment_details(&store, &p)?;
|
|
||||||
let entry_id = store
|
|
||||||
.pay_bill_with(
|
|
||||||
p.document_id,
|
|
||||||
parse_date(&p.date)?,
|
|
||||||
parse_amount(&p.amount)?,
|
|
||||||
cash,
|
|
||||||
&details,
|
|
||||||
)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
payment_out(
|
|
||||||
store.ledger(),
|
|
||||||
ledger_core::DocRef::Bill(p.document_id),
|
|
||||||
entry_id,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tool(
|
#[tool(
|
||||||
name = "books.void_document",
|
name = "books.void_document",
|
||||||
title = "Void an invoice or bill",
|
title = "Void an invoice or bill",
|
||||||
@@ -1229,78 +1118,4 @@ impl BooksServer {
|
|||||||
let checks = ledger.control_check(as_of).map_err(|e| e.to_string())?;
|
let checks = ledger.control_check(as_of).map_err(|e| e.to_string())?;
|
||||||
Ok(Json(control_check_out(ledger, as_of, &checks)))
|
Ok(Json(control_check_out(ledger, as_of, &checks)))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tool(
|
|
||||||
name = "books.ar_aging",
|
|
||||||
title = "Receivables aging",
|
|
||||||
description = described(
|
|
||||||
"A/R aging report as of a date: outstanding balances per customer, bucketed by days past due (current, 30, 60, 90, 90+).",
|
|
||||||
ledger_core::calendar::PeriodKind::AsOf
|
|
||||||
),
|
|
||||||
annotations(read_only_hint = true, open_world_hint = false)
|
|
||||||
)]
|
|
||||||
async fn ar_aging(&self, params: Parameters<AgingParams>) -> Result<Json<AgingOut>, String> {
|
|
||||||
let p = params.0;
|
|
||||||
let as_of = self.as_of(p.as_of.as_deref())?;
|
|
||||||
let store = self.lock()?;
|
|
||||||
let rows = store
|
|
||||||
.ledger()
|
|
||||||
.ar_aging(as_of)
|
|
||||||
.map_err(|e| e.to_string())?
|
|
||||||
.into_iter()
|
|
||||||
.map(|r| {
|
|
||||||
let total = r.total().map_err(|e| e.to_string())?.to_string();
|
|
||||||
Ok(AgingRowOut {
|
|
||||||
name: r.name,
|
|
||||||
current: money(r.current),
|
|
||||||
days_30: money(r.days_30),
|
|
||||||
days_60: money(r.days_60),
|
|
||||||
days_90: money(r.days_90),
|
|
||||||
days_90_plus: money(r.days_90_plus),
|
|
||||||
total,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect::<Result<_, String>>()?;
|
|
||||||
Ok(Json(AgingOut {
|
|
||||||
as_of: as_of.to_string(),
|
|
||||||
rows,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tool(
|
|
||||||
name = "books.ap_aging",
|
|
||||||
title = "Payables aging",
|
|
||||||
description = described(
|
|
||||||
"A/P aging report as of a date: outstanding balances per vendor, bucketed by days past due (current, 30, 60, 90, 90+).",
|
|
||||||
ledger_core::calendar::PeriodKind::AsOf
|
|
||||||
),
|
|
||||||
annotations(read_only_hint = true, open_world_hint = false)
|
|
||||||
)]
|
|
||||||
async fn ap_aging(&self, params: Parameters<AgingParams>) -> Result<Json<AgingOut>, String> {
|
|
||||||
let p = params.0;
|
|
||||||
let as_of = self.as_of(p.as_of.as_deref())?;
|
|
||||||
let store = self.lock()?;
|
|
||||||
let rows = store
|
|
||||||
.ledger()
|
|
||||||
.ap_aging(as_of)
|
|
||||||
.map_err(|e| e.to_string())?
|
|
||||||
.into_iter()
|
|
||||||
.map(|r| {
|
|
||||||
let total = r.total().map_err(|e| e.to_string())?.to_string();
|
|
||||||
Ok(AgingRowOut {
|
|
||||||
name: r.name,
|
|
||||||
current: money(r.current),
|
|
||||||
days_30: money(r.days_30),
|
|
||||||
days_60: money(r.days_60),
|
|
||||||
days_90: money(r.days_90),
|
|
||||||
days_90_plus: money(r.days_90_plus),
|
|
||||||
total,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect::<Result<_, String>>()?;
|
|
||||||
Ok(Json(AgingOut {
|
|
||||||
as_of: as_of.to_string(),
|
|
||||||
rows,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -848,7 +848,7 @@ impl BooksServer {
|
|||||||
#[tool(
|
#[tool(
|
||||||
name = "books.export",
|
name = "books.export",
|
||||||
title = "Export a report",
|
title = "Export a report",
|
||||||
description = "Export a report. Reports: profit_and_loss, balance_sheet, cash_flow (direct method, with its reconciliation and noncash schedules; needs cash_accounts), trial_balance (as_of), members_equity (multi-member LLC; members from the settings), tax_summary, general_ledger (by account: running balances, splits), general_journal (each entry posted in the period; alias journal), fixed_asset_register (as_of: every asset with tag, serial, cost, depreciation, book value, reconciled to the books). The file is handed over (run its download to save it; never its bytes). Formats: pdf and xlsx for the statements, tax_summary and fixed_asset_register; xlsx, csv, json for every report; iif for general_journal or journal (QuickBooks Desktop: plain ASCII text, CR LF; every posted entry from the first through as_of (start and end are refused), period closes left out, DOCNUM the entry id; names, numbers and memos fit QuickBooks' limits; warnings says what is written differently from the books, memos grouped, to tell the human). Dates: start/end for period reports (omitted: the fiscal year to date), as_of for balance_sheet, trial_balance, fixed_asset_register and iif (omitted: today).",
|
description = "Export a report. Reports: profit_and_loss, balance_sheet, cash_flow (direct method, with its reconciliation and noncash schedules; needs cash_accounts), trial_balance (as_of), members_equity (multi-member LLC; members from the settings), tax_summary, general_ledger (by account: running balances, splits), general_journal (each entry posted in the period; alias journal), fixed_asset_register (as_of: every asset with tag, serial, cost, depreciation, book value, reconciled to the books), ar_aging and ap_aging (as_of: one row per open invoice or bill, with a tie-out line to its control account). The file is handed over (run its download to save it; never its bytes). Formats: pdf and xlsx for the statements, tax_summary, fixed_asset_register and the agings; xlsx, csv, json for every report; iif for general_journal or journal (QuickBooks Desktop: plain ASCII text, CR LF; every posted entry from the first through as_of (start and end are refused), period closes left out, DOCNUM the entry id; names, numbers and memos fit QuickBooks' limits; warnings says what is written differently from the books, memos grouped, to tell the human). Dates: start/end for period reports (omitted: the fiscal year to date), as_of for balance_sheet, trial_balance, fixed_asset_register, ar_aging, ap_aging and iif (omitted: today).",
|
||||||
annotations(read_only_hint = true, open_world_hint = false)
|
annotations(read_only_hint = true, open_world_hint = false)
|
||||||
)]
|
)]
|
||||||
async fn export(&self, params: Parameters<ExportParams>) -> Result<Json<ExportOut>, String> {
|
async fn export(&self, params: Parameters<ExportParams>) -> Result<Json<ExportOut>, String> {
|
||||||
@@ -911,9 +911,11 @@ impl BooksServer {
|
|||||||
"fixed_asset_register" => {
|
"fixed_asset_register" => {
|
||||||
self.export_fixed_asset_register(&format, p.as_of.as_deref())?
|
self.export_fixed_asset_register(&format, p.as_of.as_deref())?
|
||||||
}
|
}
|
||||||
|
"ar_aging" => self.export_aging(&format, true, p.as_of.as_deref())?,
|
||||||
|
"ap_aging" => self.export_aging(&format, false, p.as_of.as_deref())?,
|
||||||
_ => {
|
_ => {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"bad report {:?}: expected general_journal, general_ledger, trial_balance, profit_and_loss, balance_sheet, cash_flow, members_equity, tax_summary, or fixed_asset_register",
|
"bad report {:?}: expected general_journal, general_ledger, trial_balance, profit_and_loss, balance_sheet, cash_flow, members_equity, tax_summary, fixed_asset_register, ar_aging, or ap_aging",
|
||||||
p.report
|
p.report
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -1205,14 +1207,64 @@ impl BooksServer {
|
|||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
report::members_equity(&me)
|
report::members_equity(&me)
|
||||||
}
|
}
|
||||||
|
"ar_aging" => {
|
||||||
|
ledger_package::aging_statement(ledger, true, self.as_of(p.as_of.as_deref())?)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
}
|
||||||
|
"ap_aging" => {
|
||||||
|
ledger_package::aging_statement(ledger, false, self.as_of(p.as_of.as_deref())?)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"bad report {report:?}: expected profit_and_loss, balance_sheet, cash_flow, trial_balance, members_equity, or tax_summary"
|
"bad report {report:?}: expected profit_and_loss, balance_sheet, cash_flow, trial_balance, members_equity, tax_summary, ar_aging, or ap_aging"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The receivables or payables aging as CSV or JSON (plan P4b S6,
|
||||||
|
/// 8.3.10): one row per open document, then the tie-out line; the
|
||||||
|
/// same CSV the CPA package carries.
|
||||||
|
fn export_aging(
|
||||||
|
&self,
|
||||||
|
format: &str,
|
||||||
|
invoices: bool,
|
||||||
|
as_of: Option<&str>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let as_of = self.as_of(as_of)?;
|
||||||
|
let store = self.lock()?;
|
||||||
|
if format == "json" {
|
||||||
|
let docs = store
|
||||||
|
.ledger()
|
||||||
|
.aging_documents(as_of, invoices)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let rows: Vec<serde_json::Value> = docs
|
||||||
|
.iter()
|
||||||
|
.map(|d| {
|
||||||
|
serde_json::json!({
|
||||||
|
"number": match d.doc {
|
||||||
|
ledger_core::DocRef::Invoice(id) => store.ledger().get_invoice(id).and_then(|i| i.number.clone()),
|
||||||
|
ledger_core::DocRef::Bill(id) => store.ledger().get_bill(id).and_then(|b| b.vendor_ref.clone()),
|
||||||
|
},
|
||||||
|
"party": d.party,
|
||||||
|
"date": d.date.to_string(),
|
||||||
|
"due_date": d.due_date.to_string(),
|
||||||
|
"terms": d.terms,
|
||||||
|
"total": money(d.total),
|
||||||
|
"paid": money(d.paid),
|
||||||
|
"discounted": money(d.discounted),
|
||||||
|
"outstanding": money(d.outstanding),
|
||||||
|
"bucket": d.bucket,
|
||||||
|
"days_overdue": d.days_overdue,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
return serde_json::to_string_pretty(&rows).map_err(|e| e.to_string());
|
||||||
|
}
|
||||||
|
ledger_package::aging_csv(store.ledger(), invoices, as_of)
|
||||||
|
}
|
||||||
|
|
||||||
/// Render a report to XLSX bytes via ledger-import.
|
/// Render a report to XLSX bytes via ledger-import.
|
||||||
fn export_xlsx(&self, report: &str, p: &ExportParams) -> Result<Vec<u8>, String> {
|
fn export_xlsx(&self, report: &str, p: &ExportParams) -> Result<Vec<u8>, String> {
|
||||||
use ledger_import::{XlsxReport, report_to_xlsx};
|
use ledger_import::{XlsxReport, report_to_xlsx};
|
||||||
|
|||||||
@@ -0,0 +1,557 @@
|
|||||||
|
//! `books.receive_payment`, `books.pay_bill`, `books.bills_due` and the
|
||||||
|
//! agings: paying on terms (plan P4b S6; rules.md B-42). A payment settles
|
||||||
|
//! one or several invoices (or bills) of one party, with the early-payment
|
||||||
|
//! discounts their terms allow and the processor's fee on a customer
|
||||||
|
//! payment posted gross. Split from `tools/documents.rs` so S4.4's work
|
||||||
|
//! there and this slice's do not collide (plan/served.md 8.2 P4b S6).
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A document as the payment tools and the agings name it ("invoice:7",
|
||||||
|
/// "bill:12").
|
||||||
|
fn doc_ref_text(doc: ledger_core::DocRef) -> String {
|
||||||
|
match doc {
|
||||||
|
ledger_core::DocRef::Invoice(id) => format!("invoice:{id}"),
|
||||||
|
ledger_core::DocRef::Bill(id) => format!("bill:{id}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A document's number, when the books have one: an invoice's issued
|
||||||
|
/// number, or a bill's vendor invoice number.
|
||||||
|
fn doc_number(ledger: &Ledger, doc: ledger_core::DocRef) -> Option<String> {
|
||||||
|
match doc {
|
||||||
|
ledger_core::DocRef::Invoice(id) => ledger.get_invoice(id).and_then(|i| i.number.clone()),
|
||||||
|
ledger_core::DocRef::Bill(id) => ledger.get_bill(id).and_then(|b| b.vendor_ref.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A document's party (the customer of an invoice, the vendor of a bill).
|
||||||
|
fn doc_party(ledger: &Ledger, doc: ledger_core::DocRef) -> Result<u64, String> {
|
||||||
|
match doc {
|
||||||
|
ledger_core::DocRef::Invoice(id) => ledger
|
||||||
|
.get_invoice(id)
|
||||||
|
.map(|i| i.customer_id)
|
||||||
|
.ok_or_else(|| format!("no invoice {id}: see books.documents")),
|
||||||
|
ledger_core::DocRef::Bill(id) => ledger
|
||||||
|
.get_bill(id)
|
||||||
|
.map(|b| b.vendor_id)
|
||||||
|
.ok_or_else(|| format!("no bill {id}: see books.documents")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one id a payment tool's single form names: `invoice`/`bill`, or
|
||||||
|
/// the older `document_id`. Both at once is refused unless they agree.
|
||||||
|
fn single_document(primary: Option<u64>, alias: Option<u64>, what: &str) -> Result<u64, String> {
|
||||||
|
match (primary, alias) {
|
||||||
|
(Some(a), Some(b)) if a != b => Err(format!(
|
||||||
|
"pass {what} or document_id, not both: {} and {b} differ",
|
||||||
|
a
|
||||||
|
)),
|
||||||
|
(Some(id), _) | (None, Some(id)) => Ok(id),
|
||||||
|
(None, None) => Err(format!(
|
||||||
|
"pass {what} (or an applications list): see books.documents"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one account a payment tool's single form names: `deposit_to`/
|
||||||
|
/// `paid_from`, or the older `cash_account`.
|
||||||
|
fn single_account(
|
||||||
|
primary: &Option<String>,
|
||||||
|
alias: &Option<String>,
|
||||||
|
what: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
match (primary, alias) {
|
||||||
|
(Some(a), Some(b)) if a != b => Err(format!(
|
||||||
|
"pass {what} or cash_account, not both: {a:?} and {b:?} differ"
|
||||||
|
)),
|
||||||
|
(Some(a), _) | (None, Some(a)) => Ok(a.clone()),
|
||||||
|
(None, None) => Err(format!("pass {what} (or cash_account)")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every document a payment tool's parameters name, as the core takes
|
||||||
|
/// them: the single form (`invoice`/`bill` with `amount`) or the
|
||||||
|
/// applications list, never both.
|
||||||
|
fn applications(
|
||||||
|
store: &Store,
|
||||||
|
p: &PaymentParams,
|
||||||
|
invoices: bool,
|
||||||
|
) -> Result<Vec<ledger_core::Application>, String> {
|
||||||
|
if !p.applications.is_empty() {
|
||||||
|
if p.amount.is_some() {
|
||||||
|
return Err(
|
||||||
|
"pass amount with invoice or bill, or an applications list, not both".into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
.applications
|
||||||
|
.iter()
|
||||||
|
.map(|a| {
|
||||||
|
let (id, other) = if invoices {
|
||||||
|
(a.invoice, a.bill)
|
||||||
|
} else {
|
||||||
|
(a.bill, a.invoice)
|
||||||
|
};
|
||||||
|
if other.is_some() {
|
||||||
|
return Err(format!(
|
||||||
|
"an application names {} only, not {}",
|
||||||
|
if invoices { "invoice" } else { "bill" },
|
||||||
|
if invoices { "bill" } else { "invoice" }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let id = id.ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"an application needs its {}",
|
||||||
|
if invoices { "invoice" } else { "bill" }
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let doc = if invoices {
|
||||||
|
ledger_core::DocRef::Invoice(id)
|
||||||
|
} else {
|
||||||
|
ledger_core::DocRef::Bill(id)
|
||||||
|
};
|
||||||
|
// Refuse an unknown document here, before anything is
|
||||||
|
// journaled, with the list tool named.
|
||||||
|
doc_party(store.ledger(), doc)?;
|
||||||
|
Ok(ledger_core::Application {
|
||||||
|
doc,
|
||||||
|
cash: parse_amount(&a.amount)?,
|
||||||
|
discount: match &a.discount {
|
||||||
|
Some(d) => parse_amount(d)?,
|
||||||
|
None => Decimal::ZERO,
|
||||||
|
},
|
||||||
|
discount_reason: a.discount_reason.clone(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
let id = single_document(
|
||||||
|
if invoices { p.invoice } else { p.bill },
|
||||||
|
p.document_id,
|
||||||
|
if invoices { "invoice" } else { "bill" },
|
||||||
|
)?;
|
||||||
|
let amount = p
|
||||||
|
.amount
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| "pass amount (or an applications list)".to_string())?;
|
||||||
|
let doc = if invoices {
|
||||||
|
ledger_core::DocRef::Invoice(id)
|
||||||
|
} else {
|
||||||
|
ledger_core::DocRef::Bill(id)
|
||||||
|
};
|
||||||
|
doc_party(store.ledger(), doc)?;
|
||||||
|
Ok(vec![ledger_core::Application {
|
||||||
|
doc,
|
||||||
|
cash: parse_amount(amount)?,
|
||||||
|
discount: Decimal::ZERO,
|
||||||
|
discount_reason: None,
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The settlement one payment tool describes, and the warnings its
|
||||||
|
/// parameters add.
|
||||||
|
fn settlement(
|
||||||
|
store: &Store,
|
||||||
|
p: &PaymentParams,
|
||||||
|
invoices: bool,
|
||||||
|
) -> Result<(ledger_core::SettleParams, Vec<String>), String> {
|
||||||
|
if p.applications.is_empty() {
|
||||||
|
if invoices && p.bill.is_some() {
|
||||||
|
return Err("books.receive_payment takes invoice, not bill".into());
|
||||||
|
}
|
||||||
|
if !invoices && p.invoice.is_some() {
|
||||||
|
return Err("books.pay_bill takes bill, not invoice".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let apps = applications(store, p, invoices)?;
|
||||||
|
let first = apps
|
||||||
|
.first()
|
||||||
|
.ok_or_else(|| "a payment needs at least one invoice or bill".to_string())?
|
||||||
|
.doc;
|
||||||
|
let party = doc_party(store.ledger(), first)?;
|
||||||
|
let cash_account = resolve_account(
|
||||||
|
store,
|
||||||
|
&single_account(
|
||||||
|
if invoices {
|
||||||
|
&p.deposit_to
|
||||||
|
} else {
|
||||||
|
&p.paid_from
|
||||||
|
},
|
||||||
|
&p.cash_account,
|
||||||
|
if invoices { "deposit_to" } else { "paid_from" },
|
||||||
|
)?,
|
||||||
|
)?;
|
||||||
|
let details = ledger_core::PaymentDetails {
|
||||||
|
instrument: p
|
||||||
|
.instrument
|
||||||
|
.as_deref()
|
||||||
|
.map(|r| resolve_instrument(store, r))
|
||||||
|
.transpose()?,
|
||||||
|
reference: p.reference.clone(),
|
||||||
|
};
|
||||||
|
let count = apps.len();
|
||||||
|
let base = if invoices {
|
||||||
|
if count == 1 {
|
||||||
|
format!(
|
||||||
|
"Payment on invoice {}",
|
||||||
|
match first {
|
||||||
|
ledger_core::DocRef::Invoice(id) => id,
|
||||||
|
ledger_core::DocRef::Bill(_) => 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!("Payment on {count} invoices")
|
||||||
|
}
|
||||||
|
} else if count == 1 {
|
||||||
|
format!(
|
||||||
|
"Payment on bill {}",
|
||||||
|
match first {
|
||||||
|
ledger_core::DocRef::Bill(id) => id,
|
||||||
|
ledger_core::DocRef::Invoice(_) => 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!("Payment on {count} bills")
|
||||||
|
};
|
||||||
|
let mut description = details.description(&base);
|
||||||
|
if !invoices && p.fee.is_some() {
|
||||||
|
return Err("a processor's fee belongs on a customer payment, not on paying a bill".into());
|
||||||
|
}
|
||||||
|
let fee = match &p.fee {
|
||||||
|
None => None,
|
||||||
|
Some(fee) => {
|
||||||
|
let fee = ledger_core::Fee {
|
||||||
|
amount: parse_amount(&fee.amount)?,
|
||||||
|
account: fee
|
||||||
|
.account
|
||||||
|
.as_deref()
|
||||||
|
.map(|a| resolve_account(store, a))
|
||||||
|
.transpose()?,
|
||||||
|
vendor: fee
|
||||||
|
.vendor
|
||||||
|
.as_deref()
|
||||||
|
.map(|v| resolve_vendor(store, v))
|
||||||
|
.transpose()?,
|
||||||
|
};
|
||||||
|
if let Some(vendor) = fee.vendor
|
||||||
|
&& let Some(v) = store.ledger().get_vendor(vendor)
|
||||||
|
{
|
||||||
|
description = format!("{description} (processing fee to {})", v.name);
|
||||||
|
}
|
||||||
|
Some(fee)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut warnings = Vec::new();
|
||||||
|
if p.paid_in_full_marking {
|
||||||
|
warnings.push(
|
||||||
|
"The check or note is marked paid in full of a disputed amount. Cashing it may settle \
|
||||||
|
the whole claim (UCC §3-311): the books record only this payment; a lawyer or the \
|
||||||
|
payer's agreement settles the rest."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok((
|
||||||
|
ledger_core::SettleParams {
|
||||||
|
date: parse_date(&p.date)?,
|
||||||
|
description,
|
||||||
|
cash_account,
|
||||||
|
party,
|
||||||
|
applications: apps,
|
||||||
|
fee,
|
||||||
|
instrument: details.instrument,
|
||||||
|
reference: details.reference,
|
||||||
|
},
|
||||||
|
warnings,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a payment settled: each document's figures after it (derived from
|
||||||
|
/// its lines that stand, rules.md B-38) and what reached or left the
|
||||||
|
/// account.
|
||||||
|
fn payment_out(
|
||||||
|
ledger: &Ledger,
|
||||||
|
entry_id: u64,
|
||||||
|
params: &ledger_core::SettleParams,
|
||||||
|
) -> Result<Json<PaymentOut>, String> {
|
||||||
|
let mut documents = Vec::with_capacity(params.applications.len());
|
||||||
|
let mut deposited = Decimal::ZERO;
|
||||||
|
for app in ¶ms.applications {
|
||||||
|
deposited += app.cash;
|
||||||
|
let st = ledger
|
||||||
|
.doc_state(app.doc, chrono::NaiveDate::MAX)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.ok_or_else(|| format!("{} not found", doc_ref_text(app.doc)))?;
|
||||||
|
documents.push(PaidDocumentOut {
|
||||||
|
r#ref: doc_ref_text(app.doc),
|
||||||
|
number: doc_number(ledger, app.doc),
|
||||||
|
outstanding: money(st.outstanding),
|
||||||
|
status: st.status.key().to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let fee = params.fee.as_ref().map_or(Decimal::ZERO, |f| f.amount);
|
||||||
|
deposited -= fee;
|
||||||
|
let (remaining, status) = match documents.as_slice() {
|
||||||
|
[one] => (Some(one.outstanding.clone()), Some(one.status.clone())),
|
||||||
|
_ => (None, None),
|
||||||
|
};
|
||||||
|
Ok(Json(PaymentOut {
|
||||||
|
entry_id,
|
||||||
|
documents,
|
||||||
|
deposited: money(deposited),
|
||||||
|
remaining,
|
||||||
|
status,
|
||||||
|
warnings: Vec::new(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tool_router(router = settlements_router, vis = "pub(super)")]
|
||||||
|
impl BooksServer {
|
||||||
|
#[tool(
|
||||||
|
name = "books.receive_payment",
|
||||||
|
title = "Receive a payment",
|
||||||
|
description = "Record a customer payment against one or several invoices of one customer. Posts Dr the deposit account (the cash less any processor fee), Dr payment-processing fees the fee, Dr sales allowances the discounts, Cr A/R for each invoice's cash and discount, each receivable line tagged with its invoice. An early-payment discount may be at most the invoice's terms allow and dated by their discount date; a later or larger one needs discount_reason. Name instrument (books.instruments) and the processor's reference. Refused above what an invoice owes, before its date, or into an account that is not cash. The books record payments; they never collect them (D-45).",
|
||||||
|
annotations(
|
||||||
|
read_only_hint = false,
|
||||||
|
destructive_hint = true,
|
||||||
|
open_world_hint = false
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn receive_payment(
|
||||||
|
&self,
|
||||||
|
params: Parameters<PaymentParams>,
|
||||||
|
) -> Result<Json<PaymentOut>, String> {
|
||||||
|
let p = params.0;
|
||||||
|
let mut store = self.lock()?;
|
||||||
|
let (settle, warnings) = settlement(&store, &p, true)?;
|
||||||
|
let entry_id = store.settle(settle.clone()).map_err(|e| e.to_string())?;
|
||||||
|
let mut out = payment_out(store.ledger(), entry_id, &settle)?.0;
|
||||||
|
out.warnings = warnings;
|
||||||
|
Ok(Json(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tool(
|
||||||
|
name = "books.pay_bill",
|
||||||
|
title = "Pay a bill",
|
||||||
|
description = "Record paying one or several vendor bills of one vendor. Posts Dr A/P for each bill's cash and discount, each payable line tagged with its bill; Cr the payer account the cash and purchase discounts the discounts. An early-payment discount may be at most the bill's terms allow and dated by their discount date; a later or larger one needs discount_reason, and a bill with an asset or prepaid line refuses one (record it net, or reclassify Dr 6995 / Cr the asset). Name instrument (books.instruments: a card payment is left out of 1099-NEC) and the reference. Refused above what a bill owes, before its date, or from an account that is not cash. The books record payments; they never make them (D-45).",
|
||||||
|
annotations(
|
||||||
|
read_only_hint = false,
|
||||||
|
destructive_hint = true,
|
||||||
|
open_world_hint = false
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn pay_bill(
|
||||||
|
&self,
|
||||||
|
params: Parameters<PaymentParams>,
|
||||||
|
) -> Result<Json<PaymentOut>, String> {
|
||||||
|
let p = params.0;
|
||||||
|
let mut store = self.lock()?;
|
||||||
|
let (settle, _) = settlement(&store, &p, false)?;
|
||||||
|
let entry_id = store.settle(settle.clone()).map_err(|e| e.to_string())?;
|
||||||
|
payment_out(store.ledger(), entry_id, &settle)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tool(
|
||||||
|
name = "books.bills_due",
|
||||||
|
title = "Bills due",
|
||||||
|
description = described(
|
||||||
|
"What to pay and when: every open bill due on or before a day (through; omitted: today), due date first, with its vendor, the vendor's invoice number and what it still owes, and the early-payment discount each vendor's terms give (the last day it is open, the most off, and what to pay to take it). by_date is the running total of what falls due through each date, for planning. The figures are read from the bills' ledger lines that stand (B-38).",
|
||||||
|
ledger_core::calendar::PeriodKind::AsOf
|
||||||
|
),
|
||||||
|
annotations(read_only_hint = true, open_world_hint = false)
|
||||||
|
)]
|
||||||
|
async fn bills_due(
|
||||||
|
&self,
|
||||||
|
params: Parameters<BillsDueParams>,
|
||||||
|
) -> Result<Json<BillsDueOut>, String> {
|
||||||
|
let p = params.0;
|
||||||
|
let as_of = self.as_of(p.as_of.as_deref())?;
|
||||||
|
let through = p
|
||||||
|
.through
|
||||||
|
.as_deref()
|
||||||
|
.map(parse_date)
|
||||||
|
.transpose()?
|
||||||
|
.unwrap_or_else(today);
|
||||||
|
let store = self.lock()?;
|
||||||
|
let ledger = store.ledger();
|
||||||
|
let mut bills: Vec<(DueBillOut, Decimal)> = Vec::new();
|
||||||
|
for bill in ledger.bills() {
|
||||||
|
let doc = ledger_core::DocRef::Bill(bill.id);
|
||||||
|
let Some(st) = ledger.doc_state(doc, as_of).map_err(|e| e.to_string())? else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if st.outstanding <= Decimal::ZERO || bill.due_date > through {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let discounts = bill.terms.as_ref().and_then(|terms| {
|
||||||
|
let early = terms.discount()?;
|
||||||
|
let by = terms.discount_by(bill.date)?;
|
||||||
|
let max = ledger_core::kernel::discount_max(bill.total().ok()?, early.percent_bp)?;
|
||||||
|
Some((by, max))
|
||||||
|
});
|
||||||
|
let (discount_by, max_discount, pay) = match discounts {
|
||||||
|
Some((by, max)) if by >= as_of => (
|
||||||
|
Some(by.to_string()),
|
||||||
|
Some(money(max)),
|
||||||
|
Some(money(st.outstanding - max)),
|
||||||
|
),
|
||||||
|
_ => (None, None, None),
|
||||||
|
};
|
||||||
|
bills.push((
|
||||||
|
DueBillOut {
|
||||||
|
r#ref: doc_ref_text(doc),
|
||||||
|
vendor: ledger
|
||||||
|
.get_vendor(bill.vendor_id)
|
||||||
|
.map(|v| v.name.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
vendor_ref: bill.vendor_ref.clone(),
|
||||||
|
due_date: bill.due_date.to_string(),
|
||||||
|
outstanding: money(st.outstanding),
|
||||||
|
discount_by,
|
||||||
|
max_discount,
|
||||||
|
pay_by_discount_date_amount: pay,
|
||||||
|
},
|
||||||
|
st.outstanding,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
bills.sort_by_key(|(b, _)| (b.due_date.clone(), b.r#ref.clone()));
|
||||||
|
let mut by_date = Vec::new();
|
||||||
|
let mut running = Decimal::ZERO;
|
||||||
|
for (bill, owed) in &bills {
|
||||||
|
running += owed;
|
||||||
|
match by_date.last_mut() {
|
||||||
|
Some(DueDateTotalOut { date, amount }) if *date == bill.due_date => {
|
||||||
|
*amount = money(running);
|
||||||
|
}
|
||||||
|
_ => by_date.push(DueDateTotalOut {
|
||||||
|
date: bill.due_date.clone(),
|
||||||
|
amount: money(running),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Json(BillsDueOut {
|
||||||
|
as_of: as_of.to_string(),
|
||||||
|
through: through.to_string(),
|
||||||
|
bills: bills.into_iter().map(|(b, _)| b).collect(),
|
||||||
|
by_date,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tool(
|
||||||
|
name = "books.ar_aging",
|
||||||
|
title = "Receivables aging",
|
||||||
|
description = described(
|
||||||
|
"A/R aging report as of a date: outstanding balances per customer, bucketed by days past due (current, 30, 60, 90, 90+), with the receivables account's tie-out (its balance against what its invoices owe, its lines no invoice's, the difference: always 0.00). detail: true adds one row per invoice, with its number, dates and terms.",
|
||||||
|
ledger_core::calendar::PeriodKind::AsOf
|
||||||
|
),
|
||||||
|
annotations(read_only_hint = true, open_world_hint = false)
|
||||||
|
)]
|
||||||
|
async fn ar_aging(&self, params: Parameters<AgingParams>) -> Result<Json<AgingOut>, String> {
|
||||||
|
aging(self, params.0, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tool(
|
||||||
|
name = "books.ap_aging",
|
||||||
|
title = "Payables aging",
|
||||||
|
description = described(
|
||||||
|
"A/P aging report as of a date: outstanding balances per vendor, bucketed by days past due (current, 30, 60, 90, 90+), with the payables account's tie-out (its balance against what its bills owe, its lines no bill's, the difference: always 0.00). detail: true adds one row per bill, with its number, dates and terms.",
|
||||||
|
ledger_core::calendar::PeriodKind::AsOf
|
||||||
|
),
|
||||||
|
annotations(read_only_hint = true, open_world_hint = false)
|
||||||
|
)]
|
||||||
|
async fn ap_aging(&self, params: Parameters<AgingParams>) -> Result<Json<AgingOut>, String> {
|
||||||
|
aging(self, params.0, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An aging of invoices (`invoices` true) or bills, with its tie-out.
|
||||||
|
fn aging(server: &BooksServer, p: AgingParams, invoices: bool) -> Result<Json<AgingOut>, String> {
|
||||||
|
let as_of = server.as_of(p.as_of.as_deref())?;
|
||||||
|
let store = server.lock()?;
|
||||||
|
let ledger = store.ledger();
|
||||||
|
let rows = if invoices {
|
||||||
|
ledger.ar_aging(as_of)
|
||||||
|
} else {
|
||||||
|
ledger.ap_aging(as_of)
|
||||||
|
}
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| {
|
||||||
|
let total = r.total().map_err(|e| e.to_string())?.to_string();
|
||||||
|
Ok(AgingRowOut {
|
||||||
|
name: r.name,
|
||||||
|
current: money(r.current),
|
||||||
|
days_30: money(r.days_30),
|
||||||
|
days_60: money(r.days_60),
|
||||||
|
days_90: money(r.days_90),
|
||||||
|
days_90_plus: money(r.days_90_plus),
|
||||||
|
total,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, String>>()?;
|
||||||
|
let documents = if p.detail {
|
||||||
|
ledger
|
||||||
|
.aging_documents(as_of, invoices)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.into_iter()
|
||||||
|
.map(|d| {
|
||||||
|
Ok(AgingDocumentOut {
|
||||||
|
r#ref: doc_ref_text(d.doc),
|
||||||
|
number: doc_number(ledger, d.doc),
|
||||||
|
party: d.party,
|
||||||
|
date: d.date.to_string(),
|
||||||
|
due_date: d.due_date.to_string(),
|
||||||
|
terms: d.terms,
|
||||||
|
total: money(d.total),
|
||||||
|
paid: money(d.paid),
|
||||||
|
discounted: money(d.discounted),
|
||||||
|
outstanding: money(d.outstanding),
|
||||||
|
bucket: d.bucket.to_string(),
|
||||||
|
days_overdue: d.days_overdue,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, String>>()?
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
let checks = ledger.control_check(as_of).map_err(|e| e.to_string())?;
|
||||||
|
let role = if invoices { "receivables" } else { "payables" };
|
||||||
|
let control = match checks.iter().find(|c| c.role == role) {
|
||||||
|
Some(check) => {
|
||||||
|
let account = ledger.chart().get(check.account);
|
||||||
|
AgingControlOut {
|
||||||
|
account: match account {
|
||||||
|
Some(a) => format!("{} {}", a.code, a.name),
|
||||||
|
None => String::new(),
|
||||||
|
},
|
||||||
|
balance: money(check.balance),
|
||||||
|
documents_total: money(check.documents_total),
|
||||||
|
unattached: check
|
||||||
|
.unattached
|
||||||
|
.iter()
|
||||||
|
.map(|u| UnattachedLineOut {
|
||||||
|
entry_id: u.entry,
|
||||||
|
line: u.line,
|
||||||
|
date: u.date.to_string(),
|
||||||
|
amount: money(u.amount),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
difference: money(check.difference),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No document of this kind has posted yet: nothing to tie out.
|
||||||
|
None => AgingControlOut {
|
||||||
|
account: String::new(),
|
||||||
|
balance: money(Decimal::ZERO),
|
||||||
|
documents_total: money(Decimal::ZERO),
|
||||||
|
unattached: Vec::new(),
|
||||||
|
difference: money(Decimal::ZERO),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
Ok(Json(AgingOut {
|
||||||
|
as_of: as_of.to_string(),
|
||||||
|
rows,
|
||||||
|
documents,
|
||||||
|
control,
|
||||||
|
}))
|
||||||
|
}
|
||||||
+222
-12
@@ -3170,17 +3170,73 @@ pub struct CreateBillParams {
|
|||||||
pub confirmed: Option<bool>,
|
pub confirmed: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One invoice or bill a payment applies to (plan P4b S6; B-42).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct PaymentApplicationParam {
|
||||||
|
/// The invoice (books.receive_payment) or bill (books.pay_bill), by id.
|
||||||
|
#[serde(default)]
|
||||||
|
pub invoice: Option<u64>,
|
||||||
|
/// The bill (books.pay_bill), by id.
|
||||||
|
#[serde(default)]
|
||||||
|
pub bill: Option<u64>,
|
||||||
|
/// The cash paid against it, decimal string, positive.
|
||||||
|
pub amount: String,
|
||||||
|
/// An early-payment discount taken off it, decimal string.
|
||||||
|
#[serde(default)]
|
||||||
|
pub discount: Option<String>,
|
||||||
|
/// Why a discount outside the document's terms was taken (a late
|
||||||
|
/// discount the vendor honours): one line, recorded with the payment.
|
||||||
|
#[serde(default)]
|
||||||
|
pub discount_reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A processor's fee withheld from a customer payment (plan P4b S6; A-4).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct FeeParam {
|
||||||
|
/// The fee, decimal string, positive.
|
||||||
|
pub amount: String,
|
||||||
|
/// The expense account it goes to (id or code); omitted: the
|
||||||
|
/// payment-processing-fees role's account.
|
||||||
|
#[serde(default)]
|
||||||
|
pub account: Option<String>,
|
||||||
|
/// The processor, when the books know it as a vendor (id or name).
|
||||||
|
#[serde(default)]
|
||||||
|
pub vendor: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Parameters for `books.receive_payment` / `books.pay_bill`.
|
/// Parameters for `books.receive_payment` / `books.pay_bill`.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
pub struct PaymentParams {
|
pub struct PaymentParams {
|
||||||
/// Invoice id (receive_payment) or bill id (pay_bill).
|
/// Invoice id (receive_payment) or bill id (pay_bill); the older name
|
||||||
pub document_id: u64,
|
/// of `invoice` / `bill`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub document_id: Option<u64>,
|
||||||
|
/// The invoice, by id (books.receive_payment).
|
||||||
|
#[serde(default)]
|
||||||
|
pub invoice: Option<u64>,
|
||||||
|
/// The bill, by id (books.pay_bill).
|
||||||
|
#[serde(default)]
|
||||||
|
pub bill: Option<u64>,
|
||||||
/// Payment date, YYYY-MM-DD.
|
/// Payment date, YYYY-MM-DD.
|
||||||
pub date: String,
|
pub date: String,
|
||||||
/// Payment amount, decimal string.
|
/// Payment amount, decimal string; with `invoice` or `bill` only. The
|
||||||
pub amount: String,
|
/// applications' amounts are the cash; a discount is beside it.
|
||||||
/// Cash account id or code.
|
#[serde(default)]
|
||||||
pub cash_account: String,
|
pub amount: Option<String>,
|
||||||
|
/// Cash account id or code; the older name of `deposit_to` /
|
||||||
|
/// `paid_from`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub cash_account: Option<String>,
|
||||||
|
/// The account the cash reaches (receive_payment): a bank or card that
|
||||||
|
/// holds no role.
|
||||||
|
#[serde(default)]
|
||||||
|
pub deposit_to: Option<String>,
|
||||||
|
/// The account the cash leaves (pay_bill): a bank, card or payable
|
||||||
|
/// that holds no role.
|
||||||
|
#[serde(default)]
|
||||||
|
pub paid_from: Option<String>,
|
||||||
/// How the money moved: a payment method's id or label
|
/// How the money moved: a payment method's id or label
|
||||||
/// (`books.instruments`), tagged on the payment's entry in the same
|
/// (`books.instruments`), tagged on the payment's entry in the same
|
||||||
/// call (D-40: a card payment is left out of 1099-NEC).
|
/// call (D-40: a card payment is left out of 1099-NEC).
|
||||||
@@ -3191,6 +3247,34 @@ pub struct PaymentParams {
|
|||||||
/// carried in the entry's description (D-45).
|
/// carried in the entry's description (D-45).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub reference: Option<String>,
|
pub reference: Option<String>,
|
||||||
|
/// Several invoices (receive_payment) or bills (pay_bill) settled by
|
||||||
|
/// one payment, one party.
|
||||||
|
#[serde(default)]
|
||||||
|
pub applications: Vec<PaymentApplicationParam>,
|
||||||
|
/// The processor's fee withheld from a customer payment (never on a
|
||||||
|
/// bill): receivables are credited the gross, the fee goes to expense,
|
||||||
|
/// the deposit account gets the net.
|
||||||
|
#[serde(default)]
|
||||||
|
pub fee: Option<FeeParam>,
|
||||||
|
/// A check or note marked "paid in full" for a disputed amount: adds a
|
||||||
|
/// warning under UCC §3-311 and records nothing else.
|
||||||
|
#[serde(default)]
|
||||||
|
pub paid_in_full_marking: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One document a payment settled, after it.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct PaidDocumentOut {
|
||||||
|
/// "invoice:7" or "bill:12".
|
||||||
|
pub r#ref: String,
|
||||||
|
/// Its number, when it has one ("INV-0007").
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub number: Option<String>,
|
||||||
|
/// What it still owes after the payment, decimal string.
|
||||||
|
pub outstanding: String,
|
||||||
|
/// Its status after it: open, partial, paid, written_off, void,
|
||||||
|
/// overpaid, or unattached.
|
||||||
|
pub status: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Output of `books.receive_payment` / `books.pay_bill`.
|
/// Output of `books.receive_payment` / `books.pay_bill`.
|
||||||
@@ -3198,12 +3282,22 @@ pub struct PaymentParams {
|
|||||||
pub struct PaymentOut {
|
pub struct PaymentOut {
|
||||||
/// The payment journal entry's id.
|
/// The payment journal entry's id.
|
||||||
pub entry_id: u64,
|
pub entry_id: u64,
|
||||||
/// What the document still owes after it, decimal string (derived
|
/// Every document it settled, in application order.
|
||||||
/// from its lines that stand).
|
pub documents: Vec<PaidDocumentOut>,
|
||||||
pub remaining: String,
|
/// What reached (receive_payment) or left (pay_bill) the account: the
|
||||||
/// The document's status after it: open, partial, paid, written_off,
|
/// cash less a processor's fee, decimal string.
|
||||||
/// void, overpaid, or unattached.
|
pub deposited: String,
|
||||||
pub status: String,
|
/// What the single document still owes (the older shape; present when
|
||||||
|
/// one document was settled).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub remaining: Option<String>,
|
||||||
|
/// The single document's status (the older shape).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub status: Option<String>,
|
||||||
|
/// What to tell the human (a "paid in full" marking's UCC §3-311
|
||||||
|
/// warning).
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub warnings: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parameters for `books.documents`.
|
/// Parameters for `books.documents`.
|
||||||
@@ -3449,6 +3543,10 @@ pub struct AgingParams {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
#[schemars(description = AS_OF_DOC)]
|
#[schemars(description = AS_OF_DOC)]
|
||||||
pub as_of: Option<String>,
|
pub as_of: Option<String>,
|
||||||
|
/// Also one row per document, with its number, dates, terms and
|
||||||
|
/// bucket.
|
||||||
|
#[serde(default)]
|
||||||
|
pub detail: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One row of an aging report.
|
/// One row of an aging report.
|
||||||
@@ -3470,6 +3568,53 @@ pub struct AgingRowOut {
|
|||||||
pub total: String,
|
pub total: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One document in an aging's detail.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct AgingDocumentOut {
|
||||||
|
/// "invoice:7" or "bill:12".
|
||||||
|
pub r#ref: String,
|
||||||
|
/// Its number, when it has one.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub number: Option<String>,
|
||||||
|
/// The customer or vendor.
|
||||||
|
pub party: String,
|
||||||
|
/// Its own date.
|
||||||
|
pub date: String,
|
||||||
|
/// When payment is due.
|
||||||
|
pub due_date: String,
|
||||||
|
/// Its terms, as they print ("2/10 net 30"), when it has them.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub terms: Option<String>,
|
||||||
|
/// The sum of its lines.
|
||||||
|
pub total: String,
|
||||||
|
/// Cash paid that stands.
|
||||||
|
pub paid: String,
|
||||||
|
/// Discounts taken that stand.
|
||||||
|
pub discounted: String,
|
||||||
|
/// What it still owes as of the day.
|
||||||
|
pub outstanding: String,
|
||||||
|
/// current, days_30, days_60, days_90 or days_90_plus.
|
||||||
|
pub bucket: String,
|
||||||
|
/// Days past its due date while something is owed; 0 otherwise.
|
||||||
|
pub days_overdue: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The aging's account against its documents (the control check, B-23,
|
||||||
|
/// B-38 INVARIANT 1).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct AgingControlOut {
|
||||||
|
/// "1200 Accounts Receivable" or "2000 Accounts Payable".
|
||||||
|
pub account: String,
|
||||||
|
/// Its ledger balance as of the day.
|
||||||
|
pub balance: String,
|
||||||
|
/// What its documents owe on it, together.
|
||||||
|
pub documents_total: String,
|
||||||
|
/// The lines on it that are no document's.
|
||||||
|
pub unattached: Vec<UnattachedLineOut>,
|
||||||
|
/// balance − documents_total − unattached: always 0.00.
|
||||||
|
pub difference: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// Output of `books.ar_aging` / `books.ap_aging`.
|
/// Output of `books.ar_aging` / `books.ap_aging`.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
pub struct AgingOut {
|
pub struct AgingOut {
|
||||||
@@ -3477,6 +3622,71 @@ pub struct AgingOut {
|
|||||||
pub as_of: String,
|
pub as_of: String,
|
||||||
/// Rows, one per party with an outstanding balance.
|
/// Rows, one per party with an outstanding balance.
|
||||||
pub rows: Vec<AgingRowOut>,
|
pub rows: Vec<AgingRowOut>,
|
||||||
|
/// One row per open document, when `detail` was asked for.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub documents: Vec<AgingDocumentOut>,
|
||||||
|
/// The account's tie-out.
|
||||||
|
pub control: AgingControlOut,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parameters for `books.bills_due`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct BillsDueParams {
|
||||||
|
/// Show bills due on or before this day, YYYY-MM-DD (omitted: today).
|
||||||
|
#[serde(default)]
|
||||||
|
pub through: Option<String>,
|
||||||
|
/// As of this day for what each bill owes ([`AS_OF_DOC`]).
|
||||||
|
#[serde(default)]
|
||||||
|
#[schemars(description = AS_OF_DOC)]
|
||||||
|
pub as_of: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One bill to pay, with its discount window when its terms give one.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct DueBillOut {
|
||||||
|
/// "bill:12".
|
||||||
|
pub r#ref: String,
|
||||||
|
/// The vendor.
|
||||||
|
pub vendor: String,
|
||||||
|
/// The vendor's own invoice number, when recorded.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub vendor_ref: Option<String>,
|
||||||
|
/// When payment is due.
|
||||||
|
pub due_date: String,
|
||||||
|
/// What it still owes as of the day.
|
||||||
|
pub outstanding: String,
|
||||||
|
/// The last day its early-payment discount is open, when it has one.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub discount_by: Option<String>,
|
||||||
|
/// The most its terms allow off, decimal string.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub max_discount: Option<String>,
|
||||||
|
/// What to pay to take the discount: the outstanding less the
|
||||||
|
/// discount, decimal string.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub pay_by_discount_date_amount: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One date's running total of what falls due through it.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct DueDateTotalOut {
|
||||||
|
/// The due date.
|
||||||
|
pub date: String,
|
||||||
|
/// What falls due on or before it, together.
|
||||||
|
pub amount: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Output of `books.bills_due`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct BillsDueOut {
|
||||||
|
/// The day it is as of.
|
||||||
|
pub as_of: String,
|
||||||
|
/// The last day a bill is included for.
|
||||||
|
pub through: String,
|
||||||
|
/// Bills due by `through`, due date first.
|
||||||
|
pub bills: Vec<DueBillOut>,
|
||||||
|
/// The running total, one row per due date.
|
||||||
|
pub by_date: Vec<DueDateTotalOut>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
//! The receivables and payables agings as files (plan P4b S6, 8.3.10;
|
||||||
|
//! rules.md B-18, B-23): one row per open document, and a tie-out line
|
||||||
|
//! against the control account. The server's `books.export` and the CPA
|
||||||
|
//! package build the same CSV from this, and its XLSX/PDF from
|
||||||
|
//! [`aging_statement`], so a CPA's two copies can't differ.
|
||||||
|
|
||||||
|
use ledger_core::{DocRef, Ledger};
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
|
||||||
|
use crate::text::{csv_escape, money};
|
||||||
|
|
||||||
|
/// The CSV's header: one row per open document (8.3.10).
|
||||||
|
pub const AGING_CSV_HEADER: &str =
|
||||||
|
"number,party,date,due_date,terms,total,paid,discounted,outstanding,bucket,days_overdue";
|
||||||
|
|
||||||
|
/// One aging's rows and tie-out as CSV: the header, one row per open
|
||||||
|
/// invoice (`invoices` true) or bill, then `TOTAL,,,,,,,,<Σ
|
||||||
|
/// outstanding>,GL <account> <balance>,difference <difference>`.
|
||||||
|
pub fn aging_csv(
|
||||||
|
ledger: &Ledger,
|
||||||
|
invoices: bool,
|
||||||
|
as_of: chrono::NaiveDate,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let docs = ledger
|
||||||
|
.aging_documents(as_of, invoices)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let mut out = String::from(AGING_CSV_HEADER);
|
||||||
|
out.push('\n');
|
||||||
|
let mut total = Decimal::ZERO;
|
||||||
|
for d in &docs {
|
||||||
|
total += d.outstanding;
|
||||||
|
out.push_str(&format!(
|
||||||
|
"{},{},{},{},{},{},{},{},{},{},{}\n",
|
||||||
|
csv_escape(&number(ledger, d.doc)),
|
||||||
|
csv_escape(&d.party),
|
||||||
|
d.date,
|
||||||
|
d.due_date,
|
||||||
|
csv_escape(d.terms.as_deref().unwrap_or("")),
|
||||||
|
money(d.total),
|
||||||
|
money(d.paid),
|
||||||
|
money(d.discounted),
|
||||||
|
money(d.outstanding),
|
||||||
|
d.bucket,
|
||||||
|
d.days_overdue,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let (code, balance, difference) = tie_out(ledger, invoices, as_of)?;
|
||||||
|
let account = if code.is_empty() {
|
||||||
|
"GL —".to_string()
|
||||||
|
} else {
|
||||||
|
format!("GL {code}")
|
||||||
|
};
|
||||||
|
out.push_str(&format!(
|
||||||
|
"TOTAL,,,,,,,,{total},{account} {balance},difference {difference}\n",
|
||||||
|
total = money(total),
|
||||||
|
balance = money(balance),
|
||||||
|
difference = money(difference),
|
||||||
|
));
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same aging as a laid-out statement for XLSX and PDF: one line per
|
||||||
|
/// party with its six bucket amounts, the totals, and the tie-out.
|
||||||
|
pub fn aging_statement(
|
||||||
|
ledger: &Ledger,
|
||||||
|
invoices: bool,
|
||||||
|
as_of: chrono::NaiveDate,
|
||||||
|
) -> Result<ledger_import::Statement, String> {
|
||||||
|
use ledger_import::report::{Row, RowKind, Statement};
|
||||||
|
let party_rows = if invoices {
|
||||||
|
ledger.ar_aging(as_of)
|
||||||
|
} else {
|
||||||
|
ledger.ap_aging(as_of)
|
||||||
|
}
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let (code, balance, difference) = tie_out(ledger, invoices, as_of)?;
|
||||||
|
let columns: Vec<String> = ["Current", "1-30", "31-60", "61-90", "90+", "Total"]
|
||||||
|
.into_iter()
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect();
|
||||||
|
let mut out_rows: Vec<Row> = vec![Row {
|
||||||
|
kind: RowKind::Section,
|
||||||
|
label: if invoices {
|
||||||
|
"Receivables".into()
|
||||||
|
} else {
|
||||||
|
"Payables".into()
|
||||||
|
},
|
||||||
|
amounts: vec![],
|
||||||
|
currency: false,
|
||||||
|
rule_above: false,
|
||||||
|
}];
|
||||||
|
let (mut current, mut d30, mut d60, mut d90, mut d90p) = (
|
||||||
|
Decimal::ZERO,
|
||||||
|
Decimal::ZERO,
|
||||||
|
Decimal::ZERO,
|
||||||
|
Decimal::ZERO,
|
||||||
|
Decimal::ZERO,
|
||||||
|
);
|
||||||
|
for r in &party_rows {
|
||||||
|
let total = r.total().map_err(|e| e.to_string())?;
|
||||||
|
current += r.current;
|
||||||
|
d30 += r.days_30;
|
||||||
|
d60 += r.days_60;
|
||||||
|
d90 += r.days_90;
|
||||||
|
d90p += r.days_90_plus;
|
||||||
|
out_rows.push(Row {
|
||||||
|
kind: RowKind::Line,
|
||||||
|
label: r.name.clone(),
|
||||||
|
amounts: vec![
|
||||||
|
Some(r.current),
|
||||||
|
Some(r.days_30),
|
||||||
|
Some(r.days_60),
|
||||||
|
Some(r.days_90),
|
||||||
|
Some(r.days_90_plus),
|
||||||
|
Some(total),
|
||||||
|
],
|
||||||
|
currency: false,
|
||||||
|
rule_above: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let total = current + d30 + d60 + d90 + d90p;
|
||||||
|
out_rows.push(Row {
|
||||||
|
kind: RowKind::Total,
|
||||||
|
label: "Total".into(),
|
||||||
|
amounts: vec![
|
||||||
|
Some(current),
|
||||||
|
Some(d30),
|
||||||
|
Some(d60),
|
||||||
|
Some(d90),
|
||||||
|
Some(d90p),
|
||||||
|
Some(total),
|
||||||
|
],
|
||||||
|
currency: true,
|
||||||
|
rule_above: true,
|
||||||
|
});
|
||||||
|
out_rows.push(Row {
|
||||||
|
kind: RowKind::Spacer,
|
||||||
|
label: String::new(),
|
||||||
|
amounts: vec![],
|
||||||
|
currency: false,
|
||||||
|
rule_above: false,
|
||||||
|
});
|
||||||
|
out_rows.push(Row {
|
||||||
|
kind: RowKind::Section,
|
||||||
|
label: "Tie-out".into(),
|
||||||
|
amounts: vec![],
|
||||||
|
currency: false,
|
||||||
|
rule_above: false,
|
||||||
|
});
|
||||||
|
out_rows.push(Row {
|
||||||
|
kind: RowKind::Line,
|
||||||
|
label: if code.is_empty() {
|
||||||
|
"GL balance".to_string()
|
||||||
|
} else {
|
||||||
|
format!("GL {code} balance")
|
||||||
|
},
|
||||||
|
amounts: vec![None, None, None, None, None, Some(balance)],
|
||||||
|
currency: false,
|
||||||
|
rule_above: false,
|
||||||
|
});
|
||||||
|
out_rows.push(Row {
|
||||||
|
kind: RowKind::Total,
|
||||||
|
label: "Difference".into(),
|
||||||
|
amounts: vec![None, None, None, None, None, Some(difference)],
|
||||||
|
currency: true,
|
||||||
|
rule_above: true,
|
||||||
|
});
|
||||||
|
Ok(Statement {
|
||||||
|
// Excel sheet names take no slash; the statement's title is the
|
||||||
|
// one both XLSX and PDF draw.
|
||||||
|
title: if invoices {
|
||||||
|
"AR Aging".into()
|
||||||
|
} else {
|
||||||
|
"AP Aging".into()
|
||||||
|
},
|
||||||
|
period: format!("As of {}", as_of.format("%B %-d, %Y")),
|
||||||
|
columns,
|
||||||
|
rows: out_rows,
|
||||||
|
percent_from: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The document's number for the aging's first column: an invoice's issued
|
||||||
|
/// number, a bill's vendor number, else the document's own name.
|
||||||
|
fn number(ledger: &Ledger, doc: DocRef) -> String {
|
||||||
|
match doc {
|
||||||
|
DocRef::Invoice(id) => ledger
|
||||||
|
.get_invoice(id)
|
||||||
|
.and_then(|i| i.number.clone())
|
||||||
|
.unwrap_or_else(|| format!("Invoice {id}")),
|
||||||
|
DocRef::Bill(id) => ledger
|
||||||
|
.get_bill(id)
|
||||||
|
.and_then(|b| b.vendor_ref.clone())
|
||||||
|
.unwrap_or_else(|| format!("Bill {id}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The control account's code, balance and difference as of the day
|
||||||
|
/// ([`Ledger::control_check`]): the aging's tie-out.
|
||||||
|
fn tie_out(
|
||||||
|
ledger: &Ledger,
|
||||||
|
invoices: bool,
|
||||||
|
as_of: chrono::NaiveDate,
|
||||||
|
) -> Result<(String, Decimal, Decimal), String> {
|
||||||
|
let role = if invoices { "receivables" } else { "payables" };
|
||||||
|
let checks = ledger.control_check(as_of).map_err(|e| e.to_string())?;
|
||||||
|
let Some(check) = checks.iter().find(|c| c.role == role) else {
|
||||||
|
return Ok((String::new(), Decimal::ZERO, Decimal::ZERO));
|
||||||
|
};
|
||||||
|
let code = ledger
|
||||||
|
.chart()
|
||||||
|
.get(check.account)
|
||||||
|
.map(|a| a.code.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
Ok((code, check.balance, check.difference))
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
//! whichever [`PackageBooks`] they are read through.
|
//! whichever [`PackageBooks`] they are read through.
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
|
mod aging;
|
||||||
mod attention;
|
mod attention;
|
||||||
mod books;
|
mod books;
|
||||||
mod iif;
|
mod iif;
|
||||||
@@ -34,6 +35,7 @@ mod text;
|
|||||||
mod tins;
|
mod tins;
|
||||||
mod vendor_1099;
|
mod vendor_1099;
|
||||||
|
|
||||||
|
pub use aging::{AGING_CSV_HEADER, aging_csv, aging_statement};
|
||||||
pub use attention::{
|
pub use attention::{
|
||||||
Attention, Integrity, Report, Severity, Target, files_1099, find, find_with, reconcile_prompt,
|
Attention, Integrity, Report, Severity, Target, files_1099, find, find_with, reconcile_prompt,
|
||||||
w9_missing, w9_prompt,
|
w9_missing, w9_prompt,
|
||||||
|
|||||||
@@ -372,6 +372,17 @@ pub fn build_package<B: PackageBooks + ?Sized>(
|
|||||||
files.push((format!("{name}.pdf"), pdf(st)?));
|
files.push((format!("{name}.pdf"), pdf(st)?));
|
||||||
files.push((format!("{name}.xlsx"), xlsx(XlsxReport::Statement(st))?));
|
files.push((format!("{name}.xlsx"), xlsx(XlsxReport::Statement(st))?));
|
||||||
}
|
}
|
||||||
|
// The receivables and payables agings, each with its tie-out line to
|
||||||
|
// the control account (B-23, 8.3.10): the same CSV books.export and
|
||||||
|
// the viewer make.
|
||||||
|
files.push((
|
||||||
|
"ar_aging.csv".into(),
|
||||||
|
crate::aging::aging_csv(ledger, true, as_of)?.into_bytes(),
|
||||||
|
));
|
||||||
|
files.push((
|
||||||
|
"ap_aging.csv".into(),
|
||||||
|
crate::aging::aging_csv(ledger, false, as_of)?.into_bytes(),
|
||||||
|
));
|
||||||
// The fixed asset register, when an asset was acquired by as_of (one
|
// The fixed asset register, when an asset was acquired by as_of (one
|
||||||
// acquired later is not in it, so neither is an empty register).
|
// acquired later is not in it, so neither is an empty register).
|
||||||
if ledger
|
if ledger
|
||||||
@@ -566,7 +577,7 @@ pub fn build_package<B: PackageBooks + ?Sized>(
|
|||||||
let year_end = NaiveDate::from_ymd_opt(year, 12, 31) == Some(as_of);
|
let year_end = NaiveDate::from_ymd_opt(year, 12, 31) == Some(as_of);
|
||||||
let include_tins = tins.is_some();
|
let include_tins = tins.is_some();
|
||||||
let readme = format!(
|
let readme = format!(
|
||||||
"{name} — books package for the CPA\n\nFiscal year to date: {fy_start} through {as_of_s}. {basis} basis.\nKept in wanderbooks: double-entry books from an append-only, hash-chained journal ({seq} events, verified).\n\nStatements (PDF and XLSX): profit and loss, balance sheet, cash flow, {members}tax summary, trial balance.\nLedgers (XLSX): general ledger by account; general journal entry by entry.\n{iif}{worksheet}{k1}{receipts}{tins}\nThe tax summary maps accounts to return categories; it's a worksheet, not a filing. Questions go to the owners, who can ask their bookkeeping agent to trace any number to its entries and receipts.\n",
|
"{name} — books package for the CPA\n\nFiscal year to date: {fy_start} through {as_of_s}. {basis} basis.\nKept in wanderbooks: double-entry books from an append-only, hash-chained journal ({seq} events, verified).\n\nStatements (PDF and XLSX): profit and loss, balance sheet, cash flow, {members}tax summary, trial balance.\nLedgers (XLSX): general ledger by account; general journal entry by entry.\nAgings (CSV): ar_aging.csv and ap_aging.csv, one row per open invoice or bill, each ending with a TOTAL line that ties to its control account (B-23). Early-payment discounts are taken by the gross method (A-8); bad debts are written off directly (A-5).\n{iif}{worksheet}{k1}{receipts}{tins}\nThe tax summary maps accounts to return categories; it's a worksheet, not a filing. Questions go to the owners, who can ask their bookkeeping agent to trace any number to its entries and receipts.\n",
|
||||||
name = company.name,
|
name = company.name,
|
||||||
basis = company.basis,
|
basis = company.basis,
|
||||||
members = if company.members.is_empty() {
|
members = if company.members.is_empty() {
|
||||||
|
|||||||
@@ -338,6 +338,8 @@ fn a_single_member_package_holds_the_documented_files_in_order() {
|
|||||||
"tax-summary.xlsx".into(),
|
"tax-summary.xlsx".into(),
|
||||||
"trial-balance.pdf".into(),
|
"trial-balance.pdf".into(),
|
||||||
"trial-balance.xlsx".into(),
|
"trial-balance.xlsx".into(),
|
||||||
|
"ar_aging.csv".into(),
|
||||||
|
"ap_aging.csv".into(),
|
||||||
"general-ledger.xlsx".into(),
|
"general-ledger.xlsx".into(),
|
||||||
"general-journal.xlsx".into(),
|
"general-journal.xlsx".into(),
|
||||||
"1099-worksheet-2026.csv".into(),
|
"1099-worksheet-2026.csv".into(),
|
||||||
@@ -2446,3 +2448,82 @@ fn the_k1_worksheet_reports_tax_basis_capital() {
|
|||||||
// The book worksheet is the same as without assets' bases.
|
// The book worksheet is the same as without assets' bases.
|
||||||
assert!(text(&p, "k1-members.csv").contains(",1685.00\n"));
|
assert!(text(&p, "k1-members.csv").contains(",1685.00\n"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The CPA package's aging CSVs are the agings, one row per open document
|
||||||
|
/// and a tie-out line that equals the control account (plan P4b S6,
|
||||||
|
/// 8.3.10; rules.md B-23, B-38 INVARIANT 1).
|
||||||
|
#[test]
|
||||||
|
fn the_aging_csvs_tie_to_the_control_check() {
|
||||||
|
let dir = tempfile::Builder::new()
|
||||||
|
.prefix("wb-package-aging-")
|
||||||
|
.tempdir()
|
||||||
|
.unwrap();
|
||||||
|
let mut s = Store::create(&dir.path().join("books-data"), Currency::USD).unwrap();
|
||||||
|
for (code, name, ty) in [
|
||||||
|
("1000", "Checking", AccountType::Asset),
|
||||||
|
("3000", "Capital", AccountType::Equity),
|
||||||
|
] {
|
||||||
|
s.add_account(code, name, ty, None, false).unwrap();
|
||||||
|
}
|
||||||
|
let bank = id_of(&s, "1000");
|
||||||
|
let customer = s.add_customer("Fernhill Labs").unwrap();
|
||||||
|
let vendor = s.add_vendor("Tobin Harlow Supply").unwrap();
|
||||||
|
let line = |what: &str, cents: i64| DocumentLine {
|
||||||
|
description: what.into(),
|
||||||
|
amount: Decimal::new(cents, 2),
|
||||||
|
class_id: None,
|
||||||
|
account: None,
|
||||||
|
quantity: None,
|
||||||
|
unit_price: None,
|
||||||
|
};
|
||||||
|
let invoice = s
|
||||||
|
.create_invoice(
|
||||||
|
customer,
|
||||||
|
d("2026-02-01"),
|
||||||
|
d("2026-03-01"),
|
||||||
|
vec![line("Consulting", 50_000)],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let bill = s
|
||||||
|
.create_bill(
|
||||||
|
vendor,
|
||||||
|
d("2026-02-02"),
|
||||||
|
d("2026-03-04"),
|
||||||
|
vec![line("Paper", 20_000)],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
s.receive_payment(invoice, d("2026-02-10"), Decimal::new(20_000, 2), bank)
|
||||||
|
.unwrap();
|
||||||
|
s.pay_bill(bill, d("2026-02-11"), Decimal::new(10_000, 2), bank)
|
||||||
|
.unwrap();
|
||||||
|
let p = build_package(
|
||||||
|
&s,
|
||||||
|
&settings(&company(false), &cash()),
|
||||||
|
&request(false, None),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
for (name, role, code, balance) in [
|
||||||
|
("ar_aging.csv", "receivables", "1200", "300.00"),
|
||||||
|
("ap_aging.csv", "payables", "2000", "100.00"),
|
||||||
|
] {
|
||||||
|
let csv = text(&p, name);
|
||||||
|
let total = csv.lines().last().unwrap();
|
||||||
|
assert!(
|
||||||
|
total.starts_with("TOTAL,,,,,,,,300.00") || total.starts_with("TOTAL,,,,,,,,100.00")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
total.contains(&format!("GL {code} {balance}")),
|
||||||
|
"{name}: {total}"
|
||||||
|
);
|
||||||
|
assert!(total.ends_with("difference 0.00"), "{name}: {total}");
|
||||||
|
let check = s
|
||||||
|
.ledger()
|
||||||
|
.control_check(d("2026-12-31"))
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.find(|c| c.role == role)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(check.balance.to_string(), balance, "{name}");
|
||||||
|
assert_eq!(check.difference, Decimal::ZERO, "{name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
+212
-101
@@ -2914,26 +2914,6 @@ impl Store {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Journal an auto-posted two-line entry (debit one account, credit
|
|
||||||
/// another for the same amount): [`Self::journal_auto_lines`].
|
|
||||||
fn journal_auto_entry(
|
|
||||||
&mut self,
|
|
||||||
date: NaiveDate,
|
|
||||||
description: &str,
|
|
||||||
debit_account: AccountId,
|
|
||||||
credit_account: AccountId,
|
|
||||||
amount: Decimal,
|
|
||||||
) -> Result<(EntryId, Vec<JournalLine>), StoreError> {
|
|
||||||
self.journal_auto_lines(
|
|
||||||
date,
|
|
||||||
description,
|
|
||||||
&[
|
|
||||||
(debit_account, Side::Debit, amount, None),
|
|
||||||
(credit_account, Side::Credit, amount, None),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Journal an auto-posted entry of `lines` (account, side, amount,
|
/// Journal an auto-posted entry of `lines` (account, side, amount,
|
||||||
/// project) through the standard entry.created / entry.line_added /
|
/// project) through the standard entry.created / entry.line_added /
|
||||||
/// entry.posted flow, a tagged line's `entry.line_class_set` right
|
/// entry.posted flow, a tagged line's `entry.line_class_set` right
|
||||||
@@ -3606,42 +3586,26 @@ impl Store {
|
|||||||
cash_account: AccountId,
|
cash_account: AccountId,
|
||||||
details: &ledger_core::PaymentDetails,
|
details: &ledger_core::PaymentDetails,
|
||||||
) -> Result<EntryId, StoreError> {
|
) -> Result<EntryId, StoreError> {
|
||||||
self.rehearse(|l| l.receive_payment_with(invoice_id, date, amount, cash_account, details))?;
|
let customer = self
|
||||||
self.approval_gate(
|
.ledger
|
||||||
|l| l.receive_payment_with(invoice_id, date, amount, cash_account, details),
|
.get_invoice(invoice_id)
|
||||||
false,
|
.ok_or(ledger_core::LedgerError::UnknownInvoice(invoice_id))?
|
||||||
)?;
|
.customer_id;
|
||||||
// The rehearsal above is the whole check: hand-written mirrors of
|
self.settle(ledger_core::SettleParams {
|
||||||
// the engine's refusals drift from it (and were never reached).
|
date,
|
||||||
self.ensure_payment_features(ledger_core::DocRef::Invoice(invoice_id), amount, details)?;
|
description: details.description(&format!("Payment on invoice {invoice_id}")),
|
||||||
let ar = self.ensure_control_account(&ledger_core::RECEIVABLES)?;
|
cash_account,
|
||||||
let description = details.description(&format!("Payment on invoice {invoice_id}"));
|
party: customer,
|
||||||
let (entry_id, mut journaled) =
|
applications: vec![ledger_core::Application {
|
||||||
self.journal_auto_entry(date, &description, cash_account, ar, amount)?;
|
doc: ledger_core::DocRef::Invoice(invoice_id),
|
||||||
journaled.push(self.append_journal(
|
cash: amount,
|
||||||
"invoice.payment_received",
|
discount: Decimal::ZERO,
|
||||||
json!({
|
discount_reason: None,
|
||||||
"invoice_id": invoice_id,
|
}],
|
||||||
"date": date.to_string(),
|
fee: None,
|
||||||
"amount": amount.to_string(),
|
instrument: details.instrument,
|
||||||
"cash_account": cash_account,
|
reference: details.reference.clone(),
|
||||||
"entry_id": entry_id,
|
})
|
||||||
}),
|
|
||||||
)?);
|
|
||||||
if details.instrument.is_some() {
|
|
||||||
journaled.push(self.append_journal(
|
|
||||||
"entry.instrument_set",
|
|
||||||
crate::form1099::entry_instrument_set(entry_id, details.instrument),
|
|
||||||
)?);
|
|
||||||
}
|
|
||||||
let live =
|
|
||||||
self.ledger
|
|
||||||
.receive_payment_with(invoice_id, date, amount, cash_account, details)?;
|
|
||||||
debug_assert_eq!(live, entry_id, "id allocator diverged from journal");
|
|
||||||
for line in &journaled {
|
|
||||||
apply_line_to_sqlite(&self.conn, line)?;
|
|
||||||
}
|
|
||||||
Ok(live)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pay a vendor bill ([`Self::pay_bill_with`], with no payment method
|
/// Pay a vendor bill ([`Self::pay_bill_with`], with no payment method
|
||||||
@@ -3675,35 +3639,140 @@ impl Store {
|
|||||||
cash_account: AccountId,
|
cash_account: AccountId,
|
||||||
details: &ledger_core::PaymentDetails,
|
details: &ledger_core::PaymentDetails,
|
||||||
) -> Result<EntryId, StoreError> {
|
) -> Result<EntryId, StoreError> {
|
||||||
self.rehearse(|l| l.pay_bill_with(bill_id, date, amount, cash_account, details))?;
|
let vendor = self
|
||||||
self.approval_gate(
|
.ledger
|
||||||
|l| l.pay_bill_with(bill_id, date, amount, cash_account, details),
|
.get_bill(bill_id)
|
||||||
false,
|
.ok_or(ledger_core::LedgerError::UnknownBill(bill_id))?
|
||||||
)?;
|
.vendor_id;
|
||||||
self.ensure_payment_features(ledger_core::DocRef::Bill(bill_id), amount, details)?;
|
self.settle(ledger_core::SettleParams {
|
||||||
let ap = self.ensure_control_account(&ledger_core::PAYABLES)?;
|
date,
|
||||||
let description = details.description(&format!("Payment on bill {bill_id}"));
|
description: details.description(&format!("Payment on bill {bill_id}")),
|
||||||
|
cash_account,
|
||||||
|
party: vendor,
|
||||||
|
applications: vec![ledger_core::Application {
|
||||||
|
doc: ledger_core::DocRef::Bill(bill_id),
|
||||||
|
cash: amount,
|
||||||
|
discount: Decimal::ZERO,
|
||||||
|
discount_reason: None,
|
||||||
|
}],
|
||||||
|
fee: None,
|
||||||
|
instrument: details.instrument,
|
||||||
|
reference: details.reference.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Settle one or more invoices or bills in one entry (plan P4b S6;
|
||||||
|
/// rules.md B-42): the core posts it ([`ledger_core::Ledger::settle`]);
|
||||||
|
/// this journals its lines once and one `invoice.payment_received` or
|
||||||
|
/// `bill.paid` per application, each naming the control line it tags,
|
||||||
|
/// then the payment method. A single legacy-shaped payment is
|
||||||
|
/// journaled exactly as before; more than one application, a discount
|
||||||
|
/// or a fee needs `documents_v2`, and a payment accepted only because
|
||||||
|
/// an earlier one was reversed needs `document_standing`.
|
||||||
|
pub fn settle(&mut self, params: ledger_core::SettleParams) -> Result<EntryId, StoreError> {
|
||||||
|
self.rehearse(|l| l.settle(params.clone()))?;
|
||||||
|
self.approval_gate(|l| l.settle(params.clone()), false)?;
|
||||||
|
self.ensure_payment_features(¶ms)?;
|
||||||
|
let kind = match params.applications.first().map(|a| a.doc) {
|
||||||
|
Some(ledger_core::DocRef::Bill(_)) => ledger_core::DocumentKind::Bill,
|
||||||
|
_ => ledger_core::DocumentKind::Invoice,
|
||||||
|
};
|
||||||
|
let control_role = match kind {
|
||||||
|
ledger_core::DocumentKind::Invoice => ledger_core::RECEIVABLES,
|
||||||
|
ledger_core::DocumentKind::Bill => ledger_core::PAYABLES,
|
||||||
|
};
|
||||||
|
self.ensure_control_account(&control_role)?;
|
||||||
|
if params
|
||||||
|
.applications
|
||||||
|
.iter()
|
||||||
|
.any(|a| a.discount > Decimal::ZERO)
|
||||||
|
{
|
||||||
|
let role = match kind {
|
||||||
|
ledger_core::DocumentKind::Invoice => ledger_core::SALES_ALLOWANCES,
|
||||||
|
ledger_core::DocumentKind::Bill => ledger_core::PURCHASE_DISCOUNTS,
|
||||||
|
};
|
||||||
|
self.ensure_control_account(&role)?;
|
||||||
|
}
|
||||||
|
if params.fee.as_ref().is_some_and(|f| f.account.is_none()) {
|
||||||
|
self.ensure_control_account(&ledger_core::PAYMENT_PROCESSING_FEES)?;
|
||||||
|
}
|
||||||
|
let rows = self.ledger.settlement_lines(¶ms)?;
|
||||||
|
let simple: Vec<(AccountId, Side, Decimal, Option<ClassId>)> = rows
|
||||||
|
.iter()
|
||||||
|
.map(|&(account, side, amount, _)| (account, side, amount, None))
|
||||||
|
.collect();
|
||||||
|
let description = if params.description.trim().is_empty() {
|
||||||
|
format!(
|
||||||
|
"Settlement of {} document{}",
|
||||||
|
params.applications.len(),
|
||||||
|
if params.applications.len() == 1 {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
"s"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
params.description.clone()
|
||||||
|
};
|
||||||
let (entry_id, mut journaled) =
|
let (entry_id, mut journaled) =
|
||||||
self.journal_auto_entry(date, &description, ap, cash_account, amount)?;
|
self.journal_auto_lines(params.date, &description, &simple)?;
|
||||||
journaled.push(self.append_journal(
|
let documents_v2 = params.applications.len() > 1
|
||||||
"bill.paid",
|
|| params
|
||||||
json!({
|
.applications
|
||||||
"bill_id": bill_id,
|
.iter()
|
||||||
"date": date.to_string(),
|
.any(|a| a.discount > Decimal::ZERO)
|
||||||
"amount": amount.to_string(),
|
|| params.fee.is_some();
|
||||||
"cash_account": cash_account,
|
let discounts = params
|
||||||
|
.applications
|
||||||
|
.iter()
|
||||||
|
.filter(|a| a.discount > Decimal::ZERO)
|
||||||
|
.count();
|
||||||
|
let fee_line = params.applications.len() + discounts;
|
||||||
|
let (event, id_key) = match kind {
|
||||||
|
ledger_core::DocumentKind::Invoice => ("invoice.payment_received", "invoice_id"),
|
||||||
|
ledger_core::DocumentKind::Bill => ("bill.paid", "bill_id"),
|
||||||
|
};
|
||||||
|
for (i, app) in params.applications.iter().enumerate() {
|
||||||
|
let doc_id = match app.doc {
|
||||||
|
ledger_core::DocRef::Invoice(id) | ledger_core::DocRef::Bill(id) => id,
|
||||||
|
};
|
||||||
|
let mut payload = json!({
|
||||||
|
"date": params.date.to_string(),
|
||||||
|
"amount": app.cash.to_string(),
|
||||||
|
"cash_account": params.cash_account,
|
||||||
"entry_id": entry_id,
|
"entry_id": entry_id,
|
||||||
}),
|
});
|
||||||
)?);
|
payload[id_key] = json!(doc_id);
|
||||||
if details.instrument.is_some() {
|
if documents_v2 {
|
||||||
|
payload["line"] = json!(i);
|
||||||
|
if app.discount > Decimal::ZERO {
|
||||||
|
let line = params.applications.len()
|
||||||
|
+ params.applications[..i]
|
||||||
|
.iter()
|
||||||
|
.filter(|a| a.discount > Decimal::ZERO)
|
||||||
|
.count();
|
||||||
|
payload["discount"] = json!(app.discount.to_string());
|
||||||
|
payload["discount_line"] = json!(line);
|
||||||
|
if let Some(reason) = &app.discount_reason {
|
||||||
|
payload["discount_reason"] = json!(reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(fee) = ¶ms.fee
|
||||||
|
&& i == 0
|
||||||
|
{
|
||||||
|
payload["fee"] = json!(fee.amount.to_string());
|
||||||
|
payload["fee_line"] = json!(fee_line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
journaled.push(self.append_journal(event, payload)?);
|
||||||
|
}
|
||||||
|
if params.instrument.is_some() {
|
||||||
journaled.push(self.append_journal(
|
journaled.push(self.append_journal(
|
||||||
"entry.instrument_set",
|
"entry.instrument_set",
|
||||||
crate::form1099::entry_instrument_set(entry_id, details.instrument),
|
crate::form1099::entry_instrument_set(entry_id, params.instrument),
|
||||||
)?);
|
)?);
|
||||||
}
|
}
|
||||||
let live = self
|
let live = self.ledger.settle(params)?;
|
||||||
.ledger
|
|
||||||
.pay_bill_with(bill_id, date, amount, cash_account, details)?;
|
|
||||||
debug_assert_eq!(live, entry_id, "id allocator diverged from journal");
|
debug_assert_eq!(live, entry_id, "id allocator diverged from journal");
|
||||||
for line in &journaled {
|
for line in &journaled {
|
||||||
apply_line_to_sqlite(&self.conn, line)?;
|
apply_line_to_sqlite(&self.conn, line)?;
|
||||||
@@ -3711,29 +3780,39 @@ impl Store {
|
|||||||
Ok(live)
|
Ok(live)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The journal features a payment of `amount` on `doc` needs
|
/// The journal features a settlement needs (rules.md B-27, B-42):
|
||||||
/// ([`Self::receive_payment_with`] SCENARIOS 2 and 3): document_standing
|
/// `documents_v2` for more than one application, a discount or a fee
|
||||||
/// when more than an older version thinks is still owed (the
|
/// (an older version would drop them, and the balance would be wrong),
|
||||||
/// document's total less every payment event it recorded, reversed
|
/// `document_standing` when an application pays more than an older
|
||||||
/// or not, its line tagged or not: `Ledger::payments_recorded`),
|
/// version thinks is still owed (the document's total less every
|
||||||
/// payment_instruments when it names a payment method.
|
/// payment event it recorded, reversed or not, its line tagged or
|
||||||
|
/// not), and `payment_instruments` when it names a payment method.
|
||||||
fn ensure_payment_features(
|
fn ensure_payment_features(
|
||||||
&self,
|
&self,
|
||||||
doc: ledger_core::DocRef,
|
params: &ledger_core::SettleParams,
|
||||||
amount: Decimal,
|
|
||||||
details: &ledger_core::PaymentDetails,
|
|
||||||
) -> Result<(), StoreError> {
|
) -> Result<(), StoreError> {
|
||||||
let total = match doc {
|
if params.applications.len() > 1
|
||||||
ledger_core::DocRef::Invoice(id) => self.ledger.get_invoice(id).map(|i| i.total()),
|
|| params
|
||||||
ledger_core::DocRef::Bill(id) => self.ledger.get_bill(id).map(|b| b.total()),
|
.applications
|
||||||
};
|
.iter()
|
||||||
if let Some(total) = total {
|
.any(|a| a.discount > Decimal::ZERO)
|
||||||
let older_owed = total?.checked_sub(self.ledger.payments_recorded(doc)?);
|
|| params.fee.is_some()
|
||||||
if older_owed.is_none_or(|owed| amount > owed) {
|
{
|
||||||
self.ensure_feature(crate::format::DOCUMENT_STANDING)?;
|
self.ensure_feature(crate::format::DOCUMENTS_V2)?;
|
||||||
|
}
|
||||||
|
for app in ¶ms.applications {
|
||||||
|
let total = match app.doc {
|
||||||
|
ledger_core::DocRef::Invoice(id) => self.ledger.get_invoice(id).map(|i| i.total()),
|
||||||
|
ledger_core::DocRef::Bill(id) => self.ledger.get_bill(id).map(|b| b.total()),
|
||||||
|
};
|
||||||
|
if let Some(total) = total {
|
||||||
|
let older_owed = total?.checked_sub(self.ledger.payments_recorded(app.doc)?);
|
||||||
|
if older_owed.is_none_or(|owed| app.cash > owed) {
|
||||||
|
self.ensure_feature(crate::format::DOCUMENT_STANDING)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if details.instrument.is_some() {
|
if params.instrument.is_some() {
|
||||||
self.ensure_feature(crate::format::PAYMENT_INSTRUMENTS)?;
|
self.ensure_feature(crate::format::PAYMENT_INSTRUMENTS)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -6489,6 +6568,31 @@ fn get_str<'a>(p: &'a Value, key: &str) -> Result<&'a str, StoreError> {
|
|||||||
.ok_or_else(|| StoreError::JournalCorrupt(format!("missing/invalid '{key}'")))
|
.ok_or_else(|| StoreError::JournalCorrupt(format!("missing/invalid '{key}'")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An optional integer a newer payment event carries (its `line` or
|
||||||
|
/// `discount_line`): absent or null is none; anything else must be an
|
||||||
|
/// integer.
|
||||||
|
fn get_opt_usize(p: &Value, key: &str) -> Result<Option<usize>, StoreError> {
|
||||||
|
match p.get(key) {
|
||||||
|
None | Some(Value::Null) => Ok(None),
|
||||||
|
Some(v) => v
|
||||||
|
.as_u64()
|
||||||
|
.map(|n| Some(n as usize))
|
||||||
|
.ok_or_else(|| StoreError::JournalCorrupt(format!("invalid '{key}'"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An optional decimal string a newer payment event carries (its
|
||||||
|
/// `discount`): absent or null is zero.
|
||||||
|
fn get_opt_decimal(p: &Value, key: &str) -> Result<Decimal, StoreError> {
|
||||||
|
match p.get(key) {
|
||||||
|
None | Some(Value::Null) => Ok(Decimal::ZERO),
|
||||||
|
Some(v) => v
|
||||||
|
.as_str()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.ok_or_else(|| StoreError::JournalCorrupt(format!("invalid '{key}'"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Replay one journal line into a ledger, asserting recorded ids. Used by
|
/// Replay one journal line into a ledger, asserting recorded ids. Used by
|
||||||
/// hydration, `verify`, and `rebuild`: the journal is the script, the engine
|
/// hydration, `verify`, and `rebuild`: the journal is the script, the engine
|
||||||
/// is the actor.
|
/// is the actor.
|
||||||
@@ -7073,23 +7177,30 @@ fn replay_line(
|
|||||||
ledger.replay_invoice_sent(invoice_id, sent)?;
|
ledger.replay_invoice_sent(invoice_id, sent)?;
|
||||||
}
|
}
|
||||||
"invoice.payment_received" => {
|
"invoice.payment_received" => {
|
||||||
// The payment entry was already rebuilt; this only moves the
|
// The payment entry was already rebuilt; this only tags its
|
||||||
// invoice's paid amount and status.
|
// receivables lines (the line the event names, or the first
|
||||||
ledger.replay_invoice_payment(
|
// untagged one for a legacy event) and moves the invoice.
|
||||||
|
ledger.replay_invoice_payment_at(
|
||||||
get_u64(p, "invoice_id")?,
|
get_u64(p, "invoice_id")?,
|
||||||
get_str(p, "amount")?
|
get_str(p, "amount")?
|
||||||
.parse()
|
.parse()
|
||||||
.map_err(|_| StoreError::JournalCorrupt("bad amount".into()))?,
|
.map_err(|_| StoreError::JournalCorrupt("bad amount".into()))?,
|
||||||
get_u64(p, "entry_id")?,
|
get_u64(p, "entry_id")?,
|
||||||
|
get_opt_usize(p, "line")?,
|
||||||
|
get_opt_decimal(p, "discount")?,
|
||||||
|
get_opt_usize(p, "discount_line")?,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
"bill.paid" => {
|
"bill.paid" => {
|
||||||
ledger.replay_bill_payment(
|
ledger.replay_bill_payment_at(
|
||||||
get_u64(p, "bill_id")?,
|
get_u64(p, "bill_id")?,
|
||||||
get_str(p, "amount")?
|
get_str(p, "amount")?
|
||||||
.parse()
|
.parse()
|
||||||
.map_err(|_| StoreError::JournalCorrupt("bad amount".into()))?,
|
.map_err(|_| StoreError::JournalCorrupt("bad amount".into()))?,
|
||||||
get_u64(p, "entry_id")?,
|
get_u64(p, "entry_id")?,
|
||||||
|
get_opt_usize(p, "line")?,
|
||||||
|
get_opt_decimal(p, "discount")?,
|
||||||
|
get_opt_usize(p, "discount_line")?,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
"fx.rate_set" => {
|
"fx.rate_set" => {
|
||||||
|
|||||||
@@ -5,3 +5,4 @@
|
|||||||
# It is recommended to check this file in to source control so that
|
# It is recommended to check this file in to source control so that
|
||||||
# everyone who runs the test benefits from these saved cases.
|
# everyone who runs the test benefits from these saved cases.
|
||||||
cc 704ab49c9bea84359475c3afa56c3354275a353670fc71156fba151f846f4f73 # shrinks to first = [Create(false, 0, 1), Create(false, 0, 1)], then = [Close(1), Hand(false, 1)]
|
cc 704ab49c9bea84359475c3afa56c3354275a353670fc71156fba151f846f4f73 # shrinks to first = [Create(false, 0, 1), Create(false, 0, 1)], then = [Close(1), Hand(false, 1)]
|
||||||
|
cc f678814ec34db3abef40116e17e562fe5abe652c09f12600e19841beaf9d6fe6 # shrinks to first = [Create(false, 0, 13902), Settle { a: 0, b: 0, n: 0, cash: 6676, discount: 551, fee: 0 }], then = []
|
||||||
@@ -71,6 +71,16 @@ enum Op {
|
|||||||
Close(u8),
|
Close(u8),
|
||||||
/// A hand line on receivables (true) or payables: always refused.
|
/// A hand line on receivables (true) or payables: always refused.
|
||||||
Hand(bool, i64),
|
Hand(bool, i64),
|
||||||
|
/// One payment settling two documents of one party, the first with a
|
||||||
|
/// discount and (on invoices) a processor's fee.
|
||||||
|
Settle {
|
||||||
|
a: usize,
|
||||||
|
b: usize,
|
||||||
|
n: u8,
|
||||||
|
cash: i64,
|
||||||
|
discount: i64,
|
||||||
|
fee: i64,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
fn arb_op() -> impl Strategy<Value = Op> {
|
fn arb_op() -> impl Strategy<Value = Op> {
|
||||||
@@ -83,6 +93,15 @@ fn arb_op() -> impl Strategy<Value = Op> {
|
|||||||
2 => (0usize..8, 0u8..90).prop_map(|(k, n)| Op::MarkSent(k, n)),
|
2 => (0usize..8, 0u8..90).prop_map(|(k, n)| Op::MarkSent(k, n)),
|
||||||
1 => (1u8..4).prop_map(Op::Close),
|
1 => (1u8..4).prop_map(Op::Close),
|
||||||
1 => (any::<bool>(), 1i64..10_000).prop_map(|(r, c)| Op::Hand(r, c)),
|
1 => (any::<bool>(), 1i64..10_000).prop_map(|(r, c)| Op::Hand(r, c)),
|
||||||
|
2 => (0usize..8, 0usize..8, 0u8..90, 1i64..20_000, 0i64..2_000, 0i64..500)
|
||||||
|
.prop_map(|(a, b, n, cash, discount, fee)| Op::Settle {
|
||||||
|
a,
|
||||||
|
b,
|
||||||
|
n,
|
||||||
|
cash,
|
||||||
|
discount,
|
||||||
|
fee,
|
||||||
|
}),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,6 +239,67 @@ fn run(s: &mut Store, k: &Setup, ops: &[Op], docs: &mut Vec<DocRef>) -> Result<(
|
|||||||
})
|
})
|
||||||
.map(drop)
|
.map(drop)
|
||||||
}
|
}
|
||||||
|
Op::Settle { .. } if docs.is_empty() => Ok(()),
|
||||||
|
Op::Settle {
|
||||||
|
a,
|
||||||
|
b,
|
||||||
|
n,
|
||||||
|
cash,
|
||||||
|
discount,
|
||||||
|
fee,
|
||||||
|
} => {
|
||||||
|
let first = docs[a % docs.len()];
|
||||||
|
let mut second = docs[b % docs.len()];
|
||||||
|
if second == first && docs.len() > 1 {
|
||||||
|
second = docs[(b + 1) % docs.len()];
|
||||||
|
}
|
||||||
|
let same_kind = matches!(
|
||||||
|
(first, second),
|
||||||
|
(DocRef::Invoice(_), DocRef::Invoice(_)) | (DocRef::Bill(_), DocRef::Bill(_))
|
||||||
|
);
|
||||||
|
if !same_kind {
|
||||||
|
// One party and one kind per settlement: not this pair.
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
let party = match first {
|
||||||
|
DocRef::Invoice(id) => s.ledger().get_invoice(id).map(|i| i.customer_id),
|
||||||
|
DocRef::Bill(id) => s.ledger().get_bill(id).map(|v| v.vendor_id),
|
||||||
|
}
|
||||||
|
.unwrap();
|
||||||
|
let invoices = matches!(first, DocRef::Invoice(_));
|
||||||
|
let money = cents(cash);
|
||||||
|
let apps = vec![
|
||||||
|
ledger_core::Application {
|
||||||
|
doc: first,
|
||||||
|
cash: money,
|
||||||
|
discount: cents(discount),
|
||||||
|
discount_reason: (discount > 0)
|
||||||
|
.then(|| format!("a late discount, n {n}")),
|
||||||
|
},
|
||||||
|
ledger_core::Application {
|
||||||
|
doc: second,
|
||||||
|
cash: money,
|
||||||
|
discount: Decimal::ZERO,
|
||||||
|
discount_reason: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
s.settle(ledger_core::SettleParams {
|
||||||
|
date: day(n),
|
||||||
|
description: "two documents, one payment".into(),
|
||||||
|
cash_account: k.bank,
|
||||||
|
party,
|
||||||
|
applications: apps,
|
||||||
|
fee: (invoices && fee > 0).then_some(ledger_core::Fee {
|
||||||
|
amount: cents(fee),
|
||||||
|
account: None,
|
||||||
|
vendor: None,
|
||||||
|
}),
|
||||||
|
instrument: None,
|
||||||
|
reference: None,
|
||||||
|
})
|
||||||
|
.map(drop)
|
||||||
|
}
|
||||||
|
}
|
||||||
Op::Hand(receivables, c) => {
|
Op::Hand(receivables, c) => {
|
||||||
let code = if receivables { "1200" } else { "2000" };
|
let code = if receivables { "1200" } else { "2000" };
|
||||||
match s.ledger().chart().find_by_code(code).map(|a| a.id) {
|
match s.ledger().chart().find_by_code(code).map(|a| a.id) {
|
||||||
|
|||||||
@@ -1241,3 +1241,217 @@ fn mark_sent_on_void_refused() {
|
|||||||
assert_eq!(s.ledger().get_invoice(inv).unwrap().sends.len(), 1);
|
assert_eq!(s.ledger().get_invoice(inv).unwrap().sends.len(), 1);
|
||||||
s.verify().unwrap();
|
s.verify().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A settlement of two invoices, a discount and a processor's fee: one
|
||||||
|
/// entry whose events name the lines they tag, every figure derived, and
|
||||||
|
/// the books replaying to the same lines (plan P4b S6; rules.md B-42
|
||||||
|
/// INVARIANTS 1 and 3).
|
||||||
|
#[test]
|
||||||
|
fn a_settlement_names_its_lines_and_replays() {
|
||||||
|
use ledger_core::{Application, DocRef, Fee, SettleParams};
|
||||||
|
let dir = test_dir("settlement-lines");
|
||||||
|
let mut s = Store::create(&dir.0, Currency::USD).unwrap();
|
||||||
|
let bank = s
|
||||||
|
.add_account("1000", "Bank", AccountType::Asset, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let c = s.add_customer("Fernhill Labs").unwrap();
|
||||||
|
let first = s
|
||||||
|
.create_invoice(c, d(2026, 4, 1), d(2026, 5, 1), vec![line("Design", 7_000)])
|
||||||
|
.unwrap();
|
||||||
|
let second = s
|
||||||
|
.create_invoice(
|
||||||
|
c,
|
||||||
|
d(2026, 4, 2),
|
||||||
|
d(2026, 5, 2),
|
||||||
|
vec![line("Support", 2_000)],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let from = journal_len(&dir.0);
|
||||||
|
let entry = s
|
||||||
|
.settle(SettleParams {
|
||||||
|
date: d(2026, 4, 10),
|
||||||
|
description: "One deposit, two invoices".into(),
|
||||||
|
cash_account: bank,
|
||||||
|
party: c,
|
||||||
|
applications: vec![
|
||||||
|
Application {
|
||||||
|
doc: DocRef::Invoice(first),
|
||||||
|
cash: cents(6_000),
|
||||||
|
discount: cents(1_000),
|
||||||
|
discount_reason: Some("vendor honoured the early discount late".into()),
|
||||||
|
},
|
||||||
|
Application {
|
||||||
|
doc: DocRef::Invoice(second),
|
||||||
|
cash: cents(2_000),
|
||||||
|
discount: Decimal::ZERO,
|
||||||
|
discount_reason: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
fee: Some(Fee {
|
||||||
|
amount: cents(500),
|
||||||
|
account: None,
|
||||||
|
vendor: None,
|
||||||
|
}),
|
||||||
|
instrument: None,
|
||||||
|
reference: Some("ch_42".into()),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let got = since(&dir.0, from);
|
||||||
|
// The two role accounts created on demand, then the entry, then one
|
||||||
|
// event per application.
|
||||||
|
assert_eq!(
|
||||||
|
got.iter().filter(|(t, _)| t == "account.added").count(),
|
||||||
|
2,
|
||||||
|
"{got:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
got.iter()
|
||||||
|
.filter(|(t, _)| t == "invoice.payment_received")
|
||||||
|
.count(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
got.iter().filter(|(t, _)| t == "entry.line_added").count(),
|
||||||
|
6
|
||||||
|
);
|
||||||
|
// One credit per invoice's cash, one for the discount, the fee, the
|
||||||
|
// discount expense and the net deposit: the rows the event lines name.
|
||||||
|
let events: Vec<&Value> = got
|
||||||
|
.iter()
|
||||||
|
.filter(|(t, _)| t == "invoice.payment_received")
|
||||||
|
.map(|(_, p)| p)
|
||||||
|
.collect();
|
||||||
|
assert_eq!(events.len(), 2);
|
||||||
|
assert_eq!(events[0]["line"], json!(0));
|
||||||
|
assert_eq!(events[0]["discount"], json!("10.00"));
|
||||||
|
assert_eq!(events[0]["discount_line"], json!(2));
|
||||||
|
assert_eq!(events[0]["fee"], json!("5.00"));
|
||||||
|
assert_eq!(events[0]["fee_line"], json!(3));
|
||||||
|
assert_eq!(events[1]["line"], json!(1));
|
||||||
|
assert!(events[1].get("discount").is_none());
|
||||||
|
assert!(events[1].get("fee").is_none());
|
||||||
|
let live = s.ledger().clone();
|
||||||
|
let ar = live.control_check(d(2026, 4, 10)).unwrap();
|
||||||
|
let receivables = ar.iter().find(|c| c.role == "receivables").unwrap();
|
||||||
|
assert_eq!(receivables.balance, cents(0));
|
||||||
|
assert_eq!(receivables.documents_total, cents(0));
|
||||||
|
assert_eq!(receivables.difference, Decimal::ZERO);
|
||||||
|
for (id, paid, discounted) in [(first, 6_000, 1_000), (second, 2_000, 0)] {
|
||||||
|
let st = live.invoice_state(id, d(2026, 4, 10)).unwrap().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(st.paid, st.discounted, st.outstanding),
|
||||||
|
(cents(paid), cents(discounted), Decimal::ZERO)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(s.ledger().get_entry(entry).unwrap().lines().len(), 6);
|
||||||
|
drop(s);
|
||||||
|
let s = Store::open(&dir.0).unwrap();
|
||||||
|
assert!(
|
||||||
|
live.differs_from(s.ledger()).is_empty(),
|
||||||
|
"{:?}",
|
||||||
|
live.differs_from(s.ledger())
|
||||||
|
);
|
||||||
|
s.verify().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A settlement of more than one document, a discount or a fee needs
|
||||||
|
/// `documents_v2`: refused, journaling nothing, in books without it, and
|
||||||
|
/// accepted after the owner's upgrade, replaying to the same books (plan
|
||||||
|
/// P4b S6; rules.md B-27, B-42).
|
||||||
|
#[test]
|
||||||
|
fn settlement_waits_for_documents_v2() {
|
||||||
|
use ledger_core::{Application, DocRef, SettleParams};
|
||||||
|
let dir = fixture("settlement-v2");
|
||||||
|
let mut s = Store::open(&dir.0).unwrap();
|
||||||
|
assert!(
|
||||||
|
!s.journal_format()
|
||||||
|
.has_feature(ledger_store::format::DOCUMENTS_V2)
|
||||||
|
);
|
||||||
|
let bank = s.ledger().chart().find_by_code("1000").unwrap().id;
|
||||||
|
let head = s.head();
|
||||||
|
let params = |discount: Decimal| SettleParams {
|
||||||
|
date: d(2026, 2, 15),
|
||||||
|
description: "Two bills, one payment".into(),
|
||||||
|
cash_account: bank,
|
||||||
|
party: 1,
|
||||||
|
applications: vec![
|
||||||
|
Application {
|
||||||
|
doc: DocRef::Bill(1),
|
||||||
|
cash: cents(7_000),
|
||||||
|
discount,
|
||||||
|
discount_reason: (!discount.is_zero()).then_some("late discount".into()),
|
||||||
|
},
|
||||||
|
Application {
|
||||||
|
doc: DocRef::Bill(2),
|
||||||
|
cash: cents(4_000),
|
||||||
|
discount: Decimal::ZERO,
|
||||||
|
discount_reason: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
fee: None,
|
||||||
|
instrument: None,
|
||||||
|
reference: None,
|
||||||
|
};
|
||||||
|
match s.settle(params(Decimal::ZERO)) {
|
||||||
|
Err(StoreError::FormatUpgradePending { need }) => {
|
||||||
|
assert_eq!(need, "the journal feature documents_v2")
|
||||||
|
}
|
||||||
|
other => panic!("expected FormatUpgradePending, got {other:?}"),
|
||||||
|
}
|
||||||
|
assert_eq!(s.head(), head, "nothing journaled");
|
||||||
|
s.upgrade_format().unwrap();
|
||||||
|
// The discount is outside the invoice's terms (it has none), so it
|
||||||
|
// needs its reason; the payment is also accepted only because the
|
||||||
|
// earlier one was reversed, which needs document_standing too.
|
||||||
|
s.settle(params(cents(1_000))).unwrap();
|
||||||
|
s.verify().unwrap();
|
||||||
|
let live = s.ledger().clone();
|
||||||
|
drop(s);
|
||||||
|
let s = Store::open(&dir.0).unwrap();
|
||||||
|
assert!(
|
||||||
|
live.differs_from(s.ledger()).is_empty(),
|
||||||
|
"{:?}",
|
||||||
|
live.differs_from(s.ledger())
|
||||||
|
);
|
||||||
|
let st = s.ledger().bill_state(1, d(2026, 12, 31)).unwrap().unwrap();
|
||||||
|
assert_eq!(st.status, ledger_core::DocStatus::Paid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy payment events carry no `line`, so replay keeps the positional
|
||||||
|
/// rule: the first untagged control line for the payment's amount, in the
|
||||||
|
/// order the events come (plan P4b S6; rules.md B-42 INVARIANT 3).
|
||||||
|
#[test]
|
||||||
|
fn legacy_payment_events_keep_the_positional_rule() {
|
||||||
|
use ledger_core::{DocRef, Movement};
|
||||||
|
let dir = fixture("legacy-positional");
|
||||||
|
let s = Store::open(&dir.0).unwrap();
|
||||||
|
let payments: Vec<Value> = std::fs::read_to_string(dir.0.join(JOURNAL_FILE))
|
||||||
|
.unwrap()
|
||||||
|
.lines()
|
||||||
|
.map(|l| serde_json::from_str::<Value>(l).unwrap())
|
||||||
|
.filter(|v| v["type"] == json!("invoice.payment_received"))
|
||||||
|
.map(|v| v["payload"].clone())
|
||||||
|
.collect();
|
||||||
|
assert!(!payments.is_empty());
|
||||||
|
for p in &payments {
|
||||||
|
assert!(p.get("line").is_none(), "{p}");
|
||||||
|
}
|
||||||
|
let lines = s.ledger().doc_lines(DocRef::Invoice(3));
|
||||||
|
assert_eq!(
|
||||||
|
lines
|
||||||
|
.iter()
|
||||||
|
.map(|l| (l.movement, l.amount))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
[
|
||||||
|
(Movement::Origin, cents(30_000)),
|
||||||
|
(Movement::Payment, cents(-12_000))
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
s.ledger()
|
||||||
|
.line_doc(lines[1].entry, lines[1].line)
|
||||||
|
.unwrap()
|
||||||
|
.doc,
|
||||||
|
DocRef::Invoice(3)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1875,6 +1875,8 @@ fn books_written_under_older_rules_still_open() {
|
|||||||
(4, "4000", "Revenue", "Revenue"),
|
(4, "4000", "Revenue", "Revenue"),
|
||||||
(5, "2000", "Accounts Payable", "Liability"),
|
(5, "2000", "Accounts Payable", "Liability"),
|
||||||
(6, "6500", "Supplies", "Expense"),
|
(6, "6500", "Supplies", "Expense"),
|
||||||
|
(7, "4100", "Refunds and Allowances", "Revenue"),
|
||||||
|
(8, "1020", "Bank", "Asset"),
|
||||||
] {
|
] {
|
||||||
w.append(
|
w.append(
|
||||||
"account.added",
|
"account.added",
|
||||||
@@ -1966,13 +1968,55 @@ fn books_written_under_older_rules_still_open() {
|
|||||||
"to": ["billing@fernhill.example"], "kind": "original", "reference": "msg-1"}),
|
"to": ["billing@fernhill.example"], "kind": "original", "reference": "msg-1"}),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
// P4b S6's new shape, hand-written as this version writes it: a
|
||||||
|
// payment event naming its line and the discount line it also tags.
|
||||||
|
// This reader tags both; the version before it would drop the discount
|
||||||
|
// and read the invoice paid above its total.
|
||||||
|
w.append(
|
||||||
|
"entry.created",
|
||||||
|
json!({"id": 4, "date": "2026-04-05", "description": "Payment on invoice 1"}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
for (account, side, amount) in [
|
||||||
|
(3, "credit", "98.99"),
|
||||||
|
(3, "credit", "1.00"),
|
||||||
|
(7, "debit", "1.00"),
|
||||||
|
(8, "debit", "98.99"),
|
||||||
|
] {
|
||||||
|
w.append(
|
||||||
|
"entry.line_added",
|
||||||
|
json!({"id": 4, "account": account, "side": side, "amount": amount,
|
||||||
|
"currency": "USD"}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
w.append(
|
||||||
|
"entry.posted",
|
||||||
|
json!({"id": 4, "date": "2026-04-05", "description": "Payment on invoice 1",
|
||||||
|
"reversal_of": null,
|
||||||
|
"lines": [
|
||||||
|
{"account": 3, "side": "credit", "amount": "98.99", "currency": "USD"},
|
||||||
|
{"account": 3, "side": "credit", "amount": "1.00", "currency": "USD"},
|
||||||
|
{"account": 7, "side": "debit", "amount": "1.00", "currency": "USD"},
|
||||||
|
{"account": 8, "side": "debit", "amount": "98.99", "currency": "USD"}
|
||||||
|
]}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
w.append(
|
||||||
|
"invoice.payment_received",
|
||||||
|
json!({"invoice_id": 1, "date": "2026-04-05", "amount": "98.99",
|
||||||
|
"cash_account": 8, "entry_id": 4, "line": 0,
|
||||||
|
"discount": "1.00", "discount_line": 1,
|
||||||
|
"discount_reason": "the customer paid the rest late"}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
drop(w);
|
drop(w);
|
||||||
// Opening rebuilds from the journal; it must accept all of it.
|
// Opening rebuilds from the journal; it must accept all of it.
|
||||||
let _ = std::fs::remove_file(dir.join(ledger_store::db_file()));
|
let _ = std::fs::remove_file(dir.join(ledger_store::db_file()));
|
||||||
let store = Store::open(&dir).expect("older books still open");
|
let store = Store::open(&dir).expect("older books still open");
|
||||||
store.verify().unwrap();
|
store.verify().unwrap();
|
||||||
assert_eq!(store.journal_format().format, 1);
|
assert_eq!(store.journal_format().format, 1);
|
||||||
assert_eq!(store.ledger().chart().len(), 6);
|
assert_eq!(store.ledger().chart().len(), 8);
|
||||||
assert_eq!(store.ledger().list_periods().len(), 2);
|
assert_eq!(store.ledger().list_periods().len(), 2);
|
||||||
let lines = store.ledger().get_entry(1).unwrap().lines();
|
let lines = store.ledger().get_entry(1).unwrap().lines();
|
||||||
assert_eq!(lines.len(), 2);
|
assert_eq!(lines.len(), 2);
|
||||||
@@ -2040,6 +2084,30 @@ fn books_written_under_older_rules_still_open() {
|
|||||||
assert_eq!(sends[0].to, ["billing@fernhill.example"]);
|
assert_eq!(sends[0].to, ["billing@fernhill.example"]);
|
||||||
assert_eq!(sends[0].kind, ledger_core::arap::SendKind::Original);
|
assert_eq!(sends[0].kind, ledger_core::arap::SendKind::Original);
|
||||||
assert_eq!(sends[0].reference.as_deref(), Some("msg-1"));
|
assert_eq!(sends[0].reference.as_deref(), Some("msg-1"));
|
||||||
|
// The S6 shape: the invoice reads paid and discounted, and replay
|
||||||
|
// tagged the lines the event names.
|
||||||
|
let st = store
|
||||||
|
.ledger()
|
||||||
|
.invoice_state(1, NaiveDate::from_ymd_opt(2026, 12, 31).unwrap())
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(st.status, st.paid, st.discounted, st.outstanding),
|
||||||
|
(
|
||||||
|
ledger_core::DocStatus::Paid,
|
||||||
|
"98.99".parse::<Decimal>().unwrap(),
|
||||||
|
"1.00".parse::<Decimal>().unwrap(),
|
||||||
|
Decimal::ZERO
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store.ledger().line_doc(4, 0).unwrap().movement,
|
||||||
|
ledger_core::Movement::Payment
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store.ledger().line_doc(4, 1).unwrap().movement,
|
||||||
|
ledger_core::Movement::Discount
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The books an earlier wanderbooks wrote, one directory each under
|
/// The books an earlier wanderbooks wrote, one directory each under
|
||||||
|
|||||||
@@ -167,9 +167,12 @@ each declared with `#[path]`, opening with `use super::*`, and holding a
|
|||||||
BooksServer`; `server.rs`'s `tool_router` adds every group's router. The
|
BooksServer`; `server.rs`'s `tool_router` adds every group's router. The
|
||||||
groups are:
|
groups are:
|
||||||
|
|
||||||
- `documents` — invoices, bills, payments, voids, `books.documents`,
|
- `documents` — invoices, bills, voids, `books.documents`,
|
||||||
`books.document`, the control check and the agings;
|
`books.document` and the control check;
|
||||||
- `sent` — `books.mark_sent`, the record that an invoice went out (P4b S5);
|
- `sent` — `books.mark_sent`, the record that an invoice went out (P4b S5);
|
||||||
|
- `settlements` — payments that settle one or several documents
|
||||||
|
(`books.receive_payment`, `books.pay_bill`), `books.bills_due`, and the
|
||||||
|
agings (`books.ar_aging`, `books.ap_aging`) with their tie-out (P4b S6);
|
||||||
- `entries` — `books.draft_entry`, `books.add_line`, `books.post_entry`,
|
- `entries` — `books.draft_entry`, `books.add_line`, `books.post_entry`,
|
||||||
`books.reverse_entry`, `books.get_entry`, `books.search_entries`, the
|
`books.reverse_entry`, `books.get_entry`, `books.search_entries`, the
|
||||||
bank CSV import;
|
bank CSV import;
|
||||||
|
|||||||
+52
-19
@@ -1688,7 +1688,11 @@ posting.
|
|||||||
`journal`: each entry posted in the period, line by line; `iif`:
|
`journal`: each entry posted in the period, line by line; `iif`:
|
||||||
every posted entry from the first through `as_of`, never a period),
|
every posted entry from the first through `as_of`, never a period),
|
||||||
`tax_summary` (see "Tools — tax categories"), `fixed_asset_register`
|
`tax_summary` (see "Tools — tax categories"), `fixed_asset_register`
|
||||||
(as of a day; see "Tools — fixed assets").
|
(as of a day; see "Tools — fixed assets"), `ar_aging`, `ap_aging`
|
||||||
|
(as of a day: columns `number,party,date,due_date,terms,total,paid,
|
||||||
|
discounted,outstanding,bucket,days_overdue`, one row per open
|
||||||
|
document, then a tie-out line `TOTAL,,,,,,,,<Σ outstanding>,GL
|
||||||
|
<account> <balance>,difference 0.00`; B-23, 8.3.10).
|
||||||
- Formats: `pdf` and `xlsx` for `profit_and_loss`,
|
- Formats: `pdf` and `xlsx` for `profit_and_loss`,
|
||||||
`balance_sheet`, `cash_flow`, `trial_balance`, `members_equity`,
|
`balance_sheet`, `cash_flow`, `trial_balance`, `members_equity`,
|
||||||
`tax_summary` and `fixed_asset_register`; `xlsx`, `csv`, `json` for
|
`tax_summary` and `fixed_asset_register`; `xlsx`, `csv`, `json` for
|
||||||
@@ -2086,21 +2090,37 @@ snapshots; see plan 8.2 S4.
|
|||||||
- `warnings` names an expense account it created with no tax category;
|
- `warnings` names an expense account it created with no tax category;
|
||||||
`notes` one per finished or abandoned project a line names (none for
|
`notes` one per finished or abandoned project a line names (none for
|
||||||
on-spec: a cost of on-spec work is an expense like any other).
|
on-spec: a cost of on-spec work is an expense like any other).
|
||||||
- `books.receive_payment { document_id, date, amount, cash_account,
|
- `books.receive_payment { date, deposit_to | cash_account, instrument?,
|
||||||
instrument?, reference? }` → `{ entry_id, remaining, status }`. Posts
|
reference?, invoice | document_id, amount? | applications: [{ invoice,
|
||||||
Dr Cash / Cr A/R, the receivable line tagged with the invoice (B-38),
|
amount, discount?, discount_reason? }], fee?: { amount, account?,
|
||||||
neither line tagged with a project (balance-sheet only: the invoice's
|
vendor? }, paid_in_full_marking? }` → `{ entry_id, documents: [{ ref,
|
||||||
lines carry its projects). `instrument` (id or label) is tagged on the
|
number?, outstanding, status }], deposited, remaining?, status?,
|
||||||
entry in the same call; `reference` (one line, at most 200 characters:
|
warnings? }`. Settles one or several invoices of one customer in one
|
||||||
a charge id, a trace number) goes into its description. `remaining`
|
entry (B-42): Dr the deposit account the cash less the fee, Dr the
|
||||||
and `status` (open, partial, paid, written_off, void, overpaid,
|
payment-processing-fees account the fee, Dr sales allowances the
|
||||||
unattached) are the invoice's derived figures after it. The books
|
discounts, Cr A/R each invoice's cash and discount, every receivable
|
||||||
record payments; they never collect them (D-45).
|
line tagged with its invoice (payment lines in application order, then
|
||||||
- `books.pay_bill { document_id, date, amount, cash_account, instrument?,
|
the discount lines). The `amount`s are cash only; a discount goes beside
|
||||||
reference? }` → `{ entry_id, remaining, status }`. Posts Dr A/P / Cr
|
one. A discount at most the invoice's terms allow and dated by their
|
||||||
Cash, the payable line tagged with the bill, neither line tagged with a
|
discount date needs no reason; a larger or later one needs
|
||||||
project; `instrument` and `reference` as for `receive_payment` (a card
|
`discount_reason`. `fee.vendor` (a processor the books know as a vendor)
|
||||||
payment is left out of 1099-NEC, D-40).
|
is named in the entry's description; only cash movements count toward a
|
||||||
|
1099, so the fee is not a payment to the processor. `deposited` is the
|
||||||
|
cash less the fee (A-4's gross presentation); `remaining` and `status`
|
||||||
|
repeat the single document's figures. An overpayment is refused with the
|
||||||
|
excess and the remedy (a plain entry to 2300 Customer Deposits).
|
||||||
|
`paid_in_full_marking: true` adds a UCC §3-311 warning and records
|
||||||
|
nothing else. `document_id` and `cash_account` are the older names of
|
||||||
|
`invoice`/`bill` and `deposit_to`/`paid_from`. The books record
|
||||||
|
payments; they never collect them (D-45).
|
||||||
|
- `books.pay_bill { date, paid_from | cash_account, instrument?,
|
||||||
|
reference?, bill | document_id, amount? | applications: [{ bill, amount,
|
||||||
|
discount?, discount_reason? }] }` → as `receive_payment`, per bill and
|
||||||
|
one vendor (B-42): Dr A/P each bill's cash and discount, Cr the payer
|
||||||
|
account the cash, Cr purchase discounts the discounts. A discount on a
|
||||||
|
bill with an asset or prepaid line is refused with its remedy (record
|
||||||
|
the bill net, or reclassify Dr 6995 / Cr the asset, D-49); a processor's
|
||||||
|
fee is refused on a bill.
|
||||||
- `books.void_document { kind, id, date, reason }` → `{ entry_id, status }`.
|
- `books.void_document { kind, id, date, reason }` → `{ entry_id, status }`.
|
||||||
Voids an invoice or bill (`kind` `invoice` or `bill`) that should never
|
Voids an invoice or bill (`kind` `invoice` or `bill`) that should never
|
||||||
have existed by reversing its own entry with the reason; it keeps its
|
have existed by reversing its own entry with the reason; it keeps its
|
||||||
@@ -2156,9 +2176,22 @@ snapshots; see plan 8.2 S4.
|
|||||||
there is no unattached line and nothing needing attention.
|
there is no unattached line and nothing needing attention.
|
||||||
`books.verify` checks the same identity and `books.digest` lists its
|
`books.verify` checks the same identity and `books.digest` lists its
|
||||||
problems (`control_problems`).
|
problems (`control_problems`).
|
||||||
- `books.ar_aging { as_of? }` → `{ as_of, rows: [{ name, current, days_30,
|
- `books.ar_aging { as_of?, detail? }` → `{ as_of, rows: [{ name,
|
||||||
days_60, days_90, days_90_plus, total }] }` (`as_of` omitted: today).
|
current, days_30, days_60, days_90, days_90_plus, total }], documents?:
|
||||||
- `books.ap_aging { as_of? }` → same, per vendor (`as_of` omitted: today).
|
[{ ref, number?, party, date, due_date, terms?, total, paid,
|
||||||
|
discounted, outstanding, bucket, days_overdue }], control: { account,
|
||||||
|
balance, documents_total, unattached, difference } }` (`as_of` omitted:
|
||||||
|
today). `detail: true` adds one row per open invoice; `control` is the
|
||||||
|
receivables account's tie-out (B-23, B-38 INVARIANT 1), `difference`
|
||||||
|
always `0.00`.
|
||||||
|
- `books.ap_aging { as_of?, detail? }` → same, per vendor and bill.
|
||||||
|
- `books.bills_due { through?, as_of? }` → `{ as_of, through, bills:
|
||||||
|
[{ ref, vendor, vendor_ref?, due_date, outstanding, discount_by?,
|
||||||
|
max_discount?, pay_by_discount_date_amount? }], by_date: [{ date,
|
||||||
|
amount }] }`. Every open bill due on or before `through` (omitted:
|
||||||
|
today), due date first, with its terms' early-payment discount when it
|
||||||
|
is still open (`discount_by`, the most off, what to pay to take it);
|
||||||
|
`by_date` is the running total of what falls due through each date.
|
||||||
|
|
||||||
## Tools — foreign exchange
|
## Tools — foreign exchange
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ No other statuses.
|
|||||||
| Prepaid expense + deferred revenue amortization | Amortization schedules (Xero/QBO add-ons) | partial | hand-verified fixture, to the cent; no golden file yet |
|
| Prepaid expense + deferred revenue amortization | Amortization schedules (Xero/QBO add-ons) | partial | hand-verified fixture, to the cent; no golden file yet |
|
||||||
| Automated cross-implementation diff harness (GnuCash first) | — (`just oracle`) | partial | live: `tests/oracle_gnucash.rs` runs 200 random books through GnuCash 5.10 in a pinned container, 15,800 exact comparisons, weekly in CI (docs/oracle.md Layer 5); open: fixture books diffed as `snapshot()` output, and the machine-readable catalog with CI enforcing this floor |
|
| Automated cross-implementation diff harness (GnuCash first) | — (`just oracle`) | partial | live: `tests/oracle_gnucash.rs` runs 200 random books through GnuCash 5.10 in a pinned container, 15,800 exact comparisons, weekly in CI (docs/oracle.md Layer 5); open: fixture books diffed as `snapshot()` output, and the machine-readable catalog with CI enforcing this floor |
|
||||||
| Invoice sent tracking (who, how, to whom, when, original or reminder, the channel's reference) | Business → Invoices (Xero's "Sent"; QBO's email) | done | P4b S5; records, never sends (A-4); a send changes no figure; `invoice.sent` under `documents_v2` (D-52) |
|
| Invoice sent tracking (who, how, to whom, when, original or reminder, the channel's reference) | Business → Invoices (Xero's "Sent"; QBO's email) | done | P4b S5; records, never sends (A-4); a send changes no figure; `invoice.sent` under `documents_v2` (D-52) |
|
||||||
|
| Multi-document settlements with early-payment discounts (gross method) | Business → Invoices/Bills; Receive payment | done | P4b S6: one entry, one party, one application per document (`kernel::settle`, B-42); a discount beyond the terms needs a reason; a bill with an asset line is refused (D-49); the bill and its payment tested, replay equal to live |
|
||||||
|
| A processor's fee on a payment received (gross presentation) | Xero/QBO Stripe & Shopify deposits | done | P4b S6: Dr the deposit the net, Dr processing fees the fee, Cr A/R the gross (A-4); `a_fee_posts_gross_to_receivables_and_net_to_the_bank` |
|
||||||
|
| Bills due, aging detail and tie-out, aging exports | Business → Bills; Reports → A/R and A/P Aging | done | P4b S6: `books.bills_due`, `detail`, `control`, `ar_aging.csv`/`ap_aging.csv` in `books.export` and the CPA package, each with its `TOTAL` tie-out (`the_aging_csvs_tie_to_the_control_check`) |
|
||||||
|
|
||||||
Workflow/UI parity (QuickBooks/Xero reference) gets its own catalog when the
|
Workflow/UI parity (QuickBooks/Xero reference) gets its own catalog when the
|
||||||
egui phase starts; semantics come first.
|
egui phase starts; semantics come first.
|
||||||
@@ -151,3 +154,12 @@ tracking also feeds Needs attention: an issued invoice nobody marked sent
|
|||||||
(past `invoicing.unsent_days`) and an overdue invoice not reminded within
|
(past `invoicing.unsent_days`) and an overdue invoice not reminded within
|
||||||
`invoicing.reminder_days` are one list in the viewer and in `books.start`,
|
`invoicing.reminder_days` are one list in the viewer and in `books.start`,
|
||||||
each offering `books.mark_sent` (step 4; U0b's one engine).
|
each offering `books.mark_sent` (step 4; U0b's one engine).
|
||||||
|
|
||||||
|
### P4b S6 paying on terms (2026-10-27)
|
||||||
|
|
||||||
|
S6 adds the three rows above: multi-document settlements with
|
||||||
|
early-payment discounts (the gross method, A-8), the processor's fee
|
||||||
|
posted gross (A-4), and the bills-due list with the agings' detail,
|
||||||
|
tie-out and exports. The pre-S6 fixture's figures still hold (no figure
|
||||||
|
moves), and every new write needs the journal feature `documents_v2`,
|
||||||
|
which the owner's books declare at their next upgrade (D-52).
|
||||||
@@ -3119,3 +3119,98 @@ starts from empty books (legacy lines are covered by the identity
|
|||||||
coding of `DocRef`), and an account becoming a control account by its
|
coding of `DocRef`), and an account becoming a control account by its
|
||||||
first document, holding lines from before, is outside it (those lines are
|
first document, holding lines from before, is outside it (those lines are
|
||||||
listed as unattached).
|
listed as unattached).
|
||||||
|
|
||||||
|
## B-42 A settlement is exact and names its lines
|
||||||
|
|
||||||
|
**bookspec:** `BS-arap-6`
|
||||||
|
|
||||||
|
**Status:** CONVENTION (a payment that settles several documents at once is
|
||||||
|
still one balanced entry, and each control line carries its document; the
|
||||||
|
subledger tie-out is B-38's); CHOICE (the gross method for an
|
||||||
|
early-payment discount, A-8; the processor's fee posted gross, A-4; D-49's
|
||||||
|
refusal to apportion a discount across a bill's asset lines). ASC
|
||||||
|
606-10-32-5 to 32-9 are the strict treatment of an expected discount;
|
||||||
|
this company's size makes the gross method immaterial (A-8). Sources are
|
||||||
|
cited and not yet vendored, so this rule keeps the CONVENTION label (the
|
||||||
|
Open list in `README.md`).
|
||||||
|
|
||||||
|
1. **One party, one kind.** A settlement applies to one or more invoices
|
||||||
|
of one customer, or one or more bills of one vendor, never a mix, and
|
||||||
|
never the same document twice. Deposits: Σ cash + Σ discounts is what
|
||||||
|
it applies to the documents; Σ cash − fee is what reaches the deposit
|
||||||
|
account (`kernel::settle`). The arithmetic is exact: no epsilon, no
|
||||||
|
rounding, and a fee above the cash is refused.
|
||||||
|
2. **Bounds.** Each application's cash + discount is at most what its
|
||||||
|
document owes on every day from the settlement's date on
|
||||||
|
(`payment_room`), so no day is overpaid. A payment is never dated
|
||||||
|
before its document, and never in a closed period.
|
||||||
|
3. **Discounts.** A discount is at most `kernel::discount_max(total,
|
||||||
|
percent_bp)` for the document's terms and is dated on or before
|
||||||
|
`terms.discount_by(date)`; outside that it needs a reason, journaled
|
||||||
|
with the payment (a vendor honouring a late discount). Terms that
|
||||||
|
state no discount are the same: any discount needs a reason. A discount
|
||||||
|
on a bill whose lines include an asset or prepaid account is refused
|
||||||
|
with D-49's remedy: record the bill net of the discount, or reclassify
|
||||||
|
Dr 6995 Purchase Discounts / Cr the asset.
|
||||||
|
4. **The fee.** A processor's fee is a customer payment's only. It posts
|
||||||
|
gross: Dr the processing-fee expense, Dr the deposit account the net,
|
||||||
|
Cr accounts receivable the gross. Only cash movements count toward a
|
||||||
|
1099, so the fee is not a payment to the processor.
|
||||||
|
5. **Lines and journal.** The entry's rows are the payment lines in
|
||||||
|
application order, then the discount lines, then the fee line, then
|
||||||
|
the cash line; a new payment event names the line it tags (`line`) and,
|
||||||
|
when it took one, its discount and discount line (`discount`,
|
||||||
|
`discount_line`, `discount_reason`), and the fee's on the first
|
||||||
|
application's event (`fee`, `fee_line`). Replay tags exactly those
|
||||||
|
lines; a legacy event with no `line` takes the first untagged control
|
||||||
|
line for its amount (B-38 item 1). A line replay cannot match stays
|
||||||
|
untagged and the control check lists it: replay never refuses (B-28).
|
||||||
|
6. **Exports.** An aging as of a day lists one row per open document with
|
||||||
|
its number, party, dates, terms, total, paid, discounted, outstanding,
|
||||||
|
bucket and days overdue, then a tie-out line against its control
|
||||||
|
account (B-23, B-38 INVARIANT 1). The CPA package and `books.export`
|
||||||
|
write the same CSV.
|
||||||
|
|
||||||
|
```text
|
||||||
|
GOAL: a settlement is exact and names its lines.
|
||||||
|
SOURCE: CONVENTION; ASC 606-10-32-5 to 32-9 noted; CHOICE: the gross
|
||||||
|
method (A-8), the fee's gross presentation (A-4).
|
||||||
|
INVARIANTS:
|
||||||
|
1. Σ cash + Σ discounts = Σ applications; deposit = Σ cash − fee
|
||||||
|
(kernel::settle); one party per settlement; one application per
|
||||||
|
document.
|
||||||
|
2. Each application's cash + discount ≤ what the document owes on every
|
||||||
|
day from the payment's date on.
|
||||||
|
3. Each new payment event names the line it tags; replay tags that line;
|
||||||
|
a legacy event without one takes the first untagged control line for
|
||||||
|
its amount.
|
||||||
|
4. A discount on a bill with an asset or prepaid line is refused.
|
||||||
|
SCENARIOS:
|
||||||
|
1. Two invoices of one customer, 300 and 80, paid 380 by one deposit:
|
||||||
|
two payment lines, each tagged, one cash line.
|
||||||
|
2. "2/10 net 30" bill 100, paid 98 within ten days: Dr A/P 98 (payment)
|
||||||
|
and 2 (discount), Cr purchase discounts 2, Cr bank 98.
|
||||||
|
3. The same discount after the tenth day without a reason: refused,
|
||||||
|
naming discount_reason; with one: taken and journaled.
|
||||||
|
4. An invoice 100 paid through a processor keeping 3: Dr bank 97, Dr
|
||||||
|
processing fees 3, Cr A/R 100.
|
||||||
|
5. A discount on a bill whose line is equipment: refused with the
|
||||||
|
record-net or reclassify remedy (D-49).
|
||||||
|
```
|
||||||
|
|
||||||
|
**Enforced:** `kernel::settle`, `Ledger::{settle, settlement_lines,
|
||||||
|
check_discount}`, `arap::{Application, Fee, SettleParams, SettlementRow}`,
|
||||||
|
`Ledger::{replay_invoice_payment_at, replay_bill_payment_at}`,
|
||||||
|
`Store::settle`, the aging builders (`Ledger::aging_documents`,
|
||||||
|
`ledger_package::{aging_csv, aging_statement}`). **Checked:** `ledger-core` `tests/spec_documents.rs`
|
||||||
|
(`bill_on_terms_with_discount`, `late_discount_needs_a_reason`,
|
||||||
|
`discount_on_an_asset_bill_is_refused_with_the_remedy`,
|
||||||
|
`a_fee_posts_gross_to_receivables_and_net_to_the_bank`,
|
||||||
|
`a_settlement_names_its_lines_and_refuses_a_document_twice`),
|
||||||
|
`tests/kernel_bridge.rs::settle_agrees`; Kani `settle_is_exact`;
|
||||||
|
`ledger-store` `tests/arap_standing.rs`
|
||||||
|
(`a_settlement_names_its_lines_and_replays`, `settlement_waits_for_documents_v2`,
|
||||||
|
`legacy_payment_events_keep_the_positional_rule`), `tests/arap_props.rs`;
|
||||||
|
`ledger-cli` `tests/documents.rs`; `ledger-package`'s aging tie-out test.
|
||||||
|
The Lean step is `Documents.Step.document` with every control line tagged
|
||||||
|
(`Documents.settle_unattached`); no new `Step` constructor.
|
||||||
@@ -3114,3 +3114,80 @@ predicates in `ledger-package::attention`: `invoice.number.is_some()`
|
|||||||
`checked_add_days` `unsent_days` comparison (`>`), `overdue_days > 0`, the
|
`checked_add_days` `unsent_days` comparison (`>`), `overdue_days > 0`, the
|
||||||
`sends.max()` last send, and the `reminder_days` comparison (`>`), plus
|
`sends.max()` last send, and the `reminder_days` comparison (`>`), plus
|
||||||
each item's target (`Target::Invoice`) and `books.mark_sent` step args.
|
each item's target (`Target::Invoice`) and `books.mark_sent` step args.
|
||||||
|
|
||||||
|
## P4b S6: paying on terms (2026-10-27)
|
||||||
|
|
||||||
|
rules.md B-42; plan 8.2 P4b S6. One payment settles several documents of
|
||||||
|
one party, with the early-payment discounts their terms allow, the
|
||||||
|
processor's fee posted gross, a bills-due list, the agings' detail and
|
||||||
|
tie-out, and the aging exports.
|
||||||
|
|
||||||
|
- **Core (`crates/ledger-core/tests/spec_documents.rs`):**
|
||||||
|
`bill_on_terms_with_discount` (the gross-method entry for a bill on
|
||||||
|
"2/10 net 30", and a 3% discount refused without a reason, taken with
|
||||||
|
one), `late_discount_needs_a_reason` (after the discount date, and the
|
||||||
|
asset-line refusal's unchanged books),
|
||||||
|
`discount_on_an_asset_bill_is_refused_with_the_remedy` (D-49: the code
|
||||||
|
and both remedies), `a_fee_posts_gross_to_receivables_and_net_to_the_bank`
|
||||||
|
(Dr bank 97, Dr 6060 3, Cr A/R 100),
|
||||||
|
`a_settlement_names_its_lines_and_refuses_a_document_twice` (control
|
||||||
|
lines in application order; one document twice refused).
|
||||||
|
`spec_cash_flow.rs::equipment_bill_payment_is_investing_cash` keeps
|
||||||
|
D-35 through a settlement.
|
||||||
|
- **Kernel:** `kernel_bridge.rs::settle_agrees`; Kani
|
||||||
|
`settle_is_exact` (a nonzero discount and a nonzero fee covered).
|
||||||
|
- **Store (`crates/ledger-store/tests/arap_standing.rs`):**
|
||||||
|
`a_settlement_names_its_lines_and_replays` (one entry, two invoices, a
|
||||||
|
discount and a fee; each event's `line`, `discount_line` and `fee_line`
|
||||||
|
point at the rows the plan posts; replay equals live);
|
||||||
|
`settlement_waits_for_documents_v2` (refused with the feature remedy and
|
||||||
|
no journal line, then accepted after the upgrade);
|
||||||
|
`legacy_payment_events_keep_the_positional_rule` (the old fixture's
|
||||||
|
events carry no `line`, and replay still tags in order).
|
||||||
|
`arap_props.rs`'s history gains `Settle` (two documents, a discount and,
|
||||||
|
on invoices, a fee), so `subledger_equals_control`, the bounds and
|
||||||
|
replay-equals-live run over settlements.
|
||||||
|
- **Store's older shapes:** `tests/store.rs::books_written_under_older_rules_still_open`
|
||||||
|
writes a payment event of S6's shape by hand through `JournalWriter`
|
||||||
|
and opens the books, the invoice reading paid and discounted.
|
||||||
|
- **Over the wire (`crates/ledger-cli/tests/documents.rs`):**
|
||||||
|
`one_payment_settles_two_invoices`, `late_discount_needs_a_reason`,
|
||||||
|
`a_fee_posts_gross_to_receivables_and_net_to_the_bank`,
|
||||||
|
`card_paid_bill_is_not_1099_nec`,
|
||||||
|
`multi_document_payment_counts_once_for_1099_and_party_totals`,
|
||||||
|
`overpayment_refused_with_the_excess`,
|
||||||
|
`bills_due_orders_by_due_and_discount` (the list and its running total;
|
||||||
|
no terms, so no discount fields), `the_aging_export_ties_out` (the CSV
|
||||||
|
header, row and `TOTAL` tie-out).
|
||||||
|
- **CPA package (`crates/ledger-package/tests/package.rs`):**
|
||||||
|
`the_aging_csvs_tie_to_the_control_check`.
|
||||||
|
- **Viewer (`crates/ledger-gui/tests/invoices.rs`):**
|
||||||
|
`the_list_shows_the_ledgers_figures` re-renders the Money out list's
|
||||||
|
screenshot with the new "Discount by" column (blank until S4.3
|
||||||
|
journals terms).
|
||||||
|
- **Not yet possible through the MCP (depends on S4.3/S4.4):**
|
||||||
|
`bill_on_terms_with_discount` and `bills_due_orders_by_due_and_discount`'s
|
||||||
|
discount half need a journaled `terms` field (S4.3) and
|
||||||
|
`equipment_bill_payment_is_investing_cash` needs a bill line's account
|
||||||
|
in `books.create_bill` (S4.4). The core tests named for them stand in
|
||||||
|
until those land; see the unit's report.
|
||||||
|
- **Older books (8.0.6):** the pre-S1 fixture
|
||||||
|
`tests/fixtures/p4-arap-before` is also pre-S6 and holds payments; its
|
||||||
|
golden figures still hold (`legacy_reversal_now_counts`), and S6 moves
|
||||||
|
no figure. Its journal has no declared `documents_v2`, so the store test
|
||||||
|
above proves the refusal and the upgrade.
|
||||||
|
|
||||||
|
**For P12 (mutation targets):** `kernel::settle`'s length check, each
|
||||||
|
negative check, the fee-above-cash test and both checked adds;
|
||||||
|
`Ledger::settlement_lines`'s kind/party checks, the duplicate-document
|
||||||
|
check, the per-application `cash + discount` bound, the discount window
|
||||||
|
(`<= max`, `<= by`, "has a reason"), the asset-bill loop, the fee-on-bill
|
||||||
|
refusal and the row order (payment lines, discounts, fee, cash);
|
||||||
|
`check_discount`'s `within` conjunction and its `because` arms;
|
||||||
|
`replay_*_payment_at`'s line/amount/account/side match and the discount
|
||||||
|
line's tag; the store's `documents_v2` condition (more than one
|
||||||
|
application, any discount, a fee) and the `line`/`discount_line`/`fee_line`
|
||||||
|
arithmetic; `bills_due`'s `through` filter, the discount-window test and
|
||||||
|
the running-total grouping; `aging_documents`'s outstanding filter and
|
||||||
|
bucket; `aging_csv`'s tie-out columns and `aging_statement`'s totals; the
|
||||||
|
viewer's `discount_by` and `days_overdue` flags.
|
||||||
+24
-2
@@ -544,7 +544,7 @@ come from the kernel:
|
|||||||
|
|
||||||
### Sent tracking (P4b S5)
|
### Sent tracking (P4b S5)
|
||||||
|
|
||||||
34. **A send changes no figure.** `Ledger::mark_sent` is a command, so it
|
35. **A send changes no figure.** `Ledger::mark_sent` is a command, so it
|
||||||
is a `Step` in the model (AGENTS.md's formal-methods bar; the unit's
|
is a `Step` in the model (AGENTS.md's formal-methods bar; the unit's
|
||||||
"no Lean change" phrase was overruled by it, and says so in its As
|
"no Lean change" phrase was overruled by it, and says so in its As
|
||||||
built block). `Documents.lean`'s `Books` gains `sends`, and
|
built block). `Documents.lean`'s `Books` gains `sends`, and
|
||||||
@@ -560,6 +560,25 @@ come from the kernel:
|
|||||||
`a_send_changes_no_figure`, `mark_sent_on_void_refused`,
|
`a_send_changes_no_figure`, `mark_sent_on_void_refused`,
|
||||||
`mark_sent_waits_for_documents_v2`
|
`mark_sent_waits_for_documents_v2`
|
||||||
|
|
||||||
|
### Settlements (P4b S6)
|
||||||
|
|
||||||
|
36. **A settlement is exact.** The applications' total is Σ cash + Σ
|
||||||
|
discounts and the deposit is Σ cash − fee, with exact arithmetic; a
|
||||||
|
fee above the cash, a negative amount, or arrays of different lengths
|
||||||
|
is refused (None), never wrapped or rounded. The core's posting is a
|
||||||
|
document step: every control line a settlement posts carries its
|
||||||
|
document, so `Documents.control_agrees` covers it and no new `Step`
|
||||||
|
constructor is needed.
|
||||||
|
- Kani `settle_is_exact` (at i32, fixed arrays of three applications,
|
||||||
|
a nonzero discount and a nonzero fee covered) · bridge
|
||||||
|
`settle_agrees` · tests `bill_on_terms_with_discount`,
|
||||||
|
`late_discount_needs_a_reason`,
|
||||||
|
`discount_on_an_asset_bill_is_refused_with_the_remedy`,
|
||||||
|
`a_fee_posts_gross_to_receivables_and_net_to_the_bank`,
|
||||||
|
`a_settlement_names_its_lines_and_refuses_a_document_twice` · Lean
|
||||||
|
`Documents.settle_posts_a_document_step`,
|
||||||
|
`Documents.settle_unattached`.
|
||||||
|
|
||||||
## What the proofs found
|
## What the proofs found
|
||||||
|
|
||||||
The engine already had 190+ tests, property tests, mutation testing,
|
The engine already had 190+ tests, property tests, mutation testing,
|
||||||
@@ -757,7 +776,9 @@ headline theorems) and `just prove-slow` 467 s (8 harnesses, the slowest
|
|||||||
|
|
||||||
P4b S4.1's two harnesses were measured on 2026-10-26 (a busy machine);
|
P4b S4.1's two harnesses were measured on 2026-10-26 (a busy machine);
|
||||||
the rest are the 2026-10-22 run. `discount_max_is_bounded`'s 163 s is
|
the rest are the 2026-10-22 run. `discount_max_is_bounded`'s 163 s is
|
||||||
Kani's verification time (it runs in `prove-slow`).
|
Kani's verification time (it runs in `prove-slow`). P4b S6 adds
|
||||||
|
`settle_is_exact` to `prove` and two Lean theorems to the audit
|
||||||
|
(`Documents.settle_posts_a_document_step`, `Documents.settle_unattached`).
|
||||||
|
|
||||||
Every harness, the law it proves, the recipe that runs it, and its time
|
Every harness, the law it proves, the recipe that runs it, and its time
|
||||||
in that quiet run (`prove`: each harness's verification time; `prove-slow`:
|
in that quiet run (`prove`: each harness's verification time; `prove-slow`:
|
||||||
@@ -824,4 +845,5 @@ each harness's whole run, its compile of about 50 s included):
|
|||||||
| `line_amount_is_exact` | 33 | prove | 6 |
|
| `line_amount_is_exact` | 33 | prove | 6 |
|
||||||
| `bounds_check_matches_definition` | 34 | prove | (not in the 2026-10-22 run; P4b S1, 2026-10-24) |
|
| `bounds_check_matches_definition` | 34 | prove | (not in the 2026-10-22 run; P4b S1, 2026-10-24) |
|
||||||
| `bounds_kept_is_the_definition_on_books_in_bounds` | 34 | prove | (as above) |
|
| `bounds_kept_is_the_definition_on_books_in_bounds` | 34 | prove | (as above) |
|
||||||
|
| `settle_is_exact` | 36 | prove | 1 |
|
||||||
| `kani_symbolic_copy_canary` | (the checker: "Trusting the checker") | prove | 3 |
|
| `kani_symbolic_copy_canary` | (the checker: "Trusting the checker") | prove | 3 |
|
||||||
+9
-1
@@ -1383,7 +1383,15 @@ Each unit follows the 8.0.4 template. "A-n" is an owner answer numbered in 8.3.1
|
|||||||
5. **Files:** owns `Ledger::settle` and its tests, the payment events' new fields and arms, `books.bills_due`, the aging exports; touches the 8.0.3 files, `crates/ledger-mcp/src/tools/documents.rs` (after S4.4; coordinate with the S4 engineer on `tools/documents.rs`: S6 adds its handlers in a second file `tools/settlements.rs` if S4.4 is still open).
|
5. **Files:** owns `Ledger::settle` and its tests, the payment events' new fields and arms, `books.bills_due`, the aging exports; touches the 8.0.3 files, `crates/ledger-mcp/src/tools/documents.rs` (after S4.4; coordinate with the S4 engineer on `tools/documents.rs`: S6 adds its handlers in a second file `tools/settlements.rs` if S4.4 is still open).
|
||||||
6. **Tests and proofs:** named above; Kani `settle_is_exact`; Lean: none new (the posting step is `Step.document` with more lines; `control_agrees` covers it because every control line is tagged).
|
6. **Tests and proofs:** named above; Kani `settle_is_exact`; Lean: none new (the posting step is `Step.document` with more lines; `control_agrees` covers it because every control line is tagged).
|
||||||
7. **Done when:** (1) the tests pass; (2) `several_payment_events_on_one_entry_tag_its_lines_in_order` still passes (the legacy rule); (3) the 1099 worksheet and party totals count a two-invoice payment once (`multi_document_payment_counts_once_for_1099_and_party_totals`); (4) the CPA package's aging CSVs tie to `control_check` on the demo books; (5) 8.0.6 in the PR, no golden figure moved; (6) `just check`, `just prove` green.
|
7. **Done when:** (1) the tests pass; (2) `several_payment_events_on_one_entry_tag_its_lines_in_order` still passes (the legacy rule); (3) the 1099 worksheet and party totals count a two-invoice payment once (`multi_document_payment_counts_once_for_1099_and_party_totals`); (4) the CPA package's aging CSVs tie to `control_check` on the demo books; (5) 8.0.6 in the PR, no golden figure moved; (6) `just check`, `just prove` green.
|
||||||
8. **As built:** none yet.
|
8. **As built** (2026-10-27, `S6`): the steps above are built except the departures below.
|
||||||
|
- **The handlers live in `crates/ledger-mcp/src/tools/settlements.rs`**, not `tools/documents.rs` (the EM approved this group, because S4.4 works in `documents.rs`). `books.receive_payment`, `books.pay_bill`, `books.ar_aging` and `books.ap_aging` moved there; `books.bills_due` is new. `docs/architecture.md` names the group.
|
||||||
|
- **`Ledger::settle` is a public command**, as Build 2 asks, so `Store::settle` calls it; its posting is still `Step.document` (every control line carries its document), and Lean gained `Documents.settle_posts_a_document_step` and `Documents.settle_unattached` instead of a new `Step` constructor (plan 8.2 S6, Tests and proofs: "the posting step is `Step.document` with more lines").
|
||||||
|
- **Terms cannot yet be journaled on a document** (S4.3 owns `invoice.created`/`bill.created`'s `terms`). `Ledger::settle` reads `doc.terms` wherever it is set, and the core tests (`bill_on_terms_with_discount`, `late_discount_needs_a_reason`) build documents through `create_invoice_with`/`create_bill_with`; the over-the-wire `bill_on_terms_with_discount` and the discount half of `bills_due_orders_by_due_and_discount` wait for S4.3. A bill line's account is likewise S4.4's MCP field, so `equipment_bill_payment_is_investing_cash` is a core test (`spec_cash_flow.rs`) until then.
|
||||||
|
- **A fee's processor is recorded in the entry's description, not as the entry's vendor tag.** Ledger party/vendor tags are entry-wide, not line-level; tagging the entry with the processor would move the customer's party total and muddy 1099-NEC. `fee.vendor` is resolved and named in the description, and only cash movements count toward a 1099.
|
||||||
|
- **`confirmed` is not added**: P4 owns preview/confirm for every mutating tool, and adding a preview now would duplicate that sweep. The older `document_id`/`cash_account` names still work beside `invoice`/`bill` and `deposit_to`/`paid_from`.
|
||||||
|
- **No schema change**: discounts and fees are tagged ledger lines and payment-event fields, so the projection needs no column and schema 18 is not taken.
|
||||||
|
- **No bookspec examples yet**: `payment-terms-early-discount` needs S4.3's journaled terms; `arap-settlement-exact` and `processor-fee-gross-presentation` are recorded here as the next step for the bookspec unit, not written.
|
||||||
|
- **8.0.6:** item 1 is in `books_written_under_older_rules_still_open`; item 2 is the pre-S1 (therefore pre-S6) fixture `tests/fixtures/p4-arap-before`, whose figures still hold; item 3 is `settlement_waits_for_documents_v2`; item 4 is `tests/format.rs` unchanged (no new event type) plus `format_upgrade.rs` unchanged (the `documents_v2` setting is S4.2's); item 5 is the same fixture's `figures.json` and `legacy_reversal_now_counts` (no figure moved); item 6 is in the PR/report.
|
||||||
|
|
||||||
#### P4b S7: bad debts (`write_off_invoice`)
|
#### P4b S7: bad debts (`write_off_invoice`)
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ open Wanderbooks
|
|||||||
#print axioms Documents.control_agrees
|
#print axioms Documents.control_agrees
|
||||||
#print axioms Documents.control_agrees_through
|
#print axioms Documents.control_agrees_through
|
||||||
#print axioms Documents.Step.document_unattached
|
#print axioms Documents.Step.document_unattached
|
||||||
|
#print axioms Documents.settle_posts_a_document_step
|
||||||
|
#print axioms Documents.settle_unattached
|
||||||
#print axioms Documents.Step.reverse_moves
|
#print axioms Documents.Step.reverse_moves
|
||||||
#print axioms Documents.reverse_twice
|
#print axioms Documents.reverse_twice
|
||||||
#print axioms Documents.Step.markSent_changes_no_figure
|
#print axioms Documents.Step.markSent_changes_no_figure
|
||||||
|
|||||||
@@ -221,6 +221,21 @@ theorem Step.document_unattached (B : Books) (e : List TLine) (h : Tagged B.cont
|
|||||||
simp [hn, onAcct, hacc]
|
simp [hn, onAcct, hacc]
|
||||||
· simp [hn]
|
· simp [hn]
|
||||||
|
|
||||||
|
/-- **A settlement is a document step**: its entry's every control line
|
||||||
|
carries its document (a payment or a discount), so the books' rules for
|
||||||
|
any entry whose control lines are tagged cover it; no new `Step`
|
||||||
|
constructor is needed (plan P4b S6; rules.md B-42). -/
|
||||||
|
theorem settle_posts_a_document_step (B : Books) (e : List TLine)
|
||||||
|
(h : Tagged B.control e) : Step B { B with lines := B.lines ++ e } :=
|
||||||
|
Step.document B e h
|
||||||
|
|
||||||
|
/-- **A settlement never moves what is unattached** on a control account:
|
||||||
|
every control line it posts is its document's (B-42, from
|
||||||
|
`Step.document_unattached`). -/
|
||||||
|
theorem settle_unattached (B : Books) (e : List TLine) (h : Tagged B.control e) (a : Nat)
|
||||||
|
(ha : a ∈ B.control) : unattached a (B.lines ++ e) = unattached a B.lines :=
|
||||||
|
Step.document_unattached B e h a ha
|
||||||
|
|
||||||
/-- **A reversal moves each document by exactly the opposite of its lines
|
/-- **A reversal moves each document by exactly the opposite of its lines
|
||||||
it mirrors** (B-38 INVARIANT 3), and what is unattached likewise: only
|
it mirrors** (B-38 INVARIANT 3), and what is unattached likewise: only
|
||||||
reversing a legacy hand line changes it. -/
|
reversing a legacy hand line changes it. -/
|
||||||
|
|||||||
Reference in new issue
Block a user