feat(mcp): payments on terms, bills due, and the agings' tie-out (P4b S6)

books.receive_payment and books.pay_bill take applications, each
document's cash with an optional discount and reason, and a customer
payment may carry the processor's fee; the result names every document it
settled and what reached the account. books.bills_due lists what to pay
and the running total by due date. The agings take detail and return the
control account's tie-out, and books.export writes ar_aging/ap_aging.

The handlers live in tools/settlements.rs (the EM's split, so S4.4's
documents.rs stays free); documents.rs keeps the documents, the void and
the control check. docs/mcp-interface.md documents every changed tool and
the new one; docs/architecture.md names the group; docs/testing.md
records the tests and P12's mutation targets.

Old names (document_id, cash_account) still work; the output keeps
remaining/status for one document. A fee's processor is named in the
description, not tagged as the entry's vendor: ledger tags are entry-wide
and tagging would move the customer's party totals and 1099-NEC.

The handbook's invoices-and-bills section gains the payment, discount,
fee and bounce-replacement procedures (S4.5 can extend it). Over the wire:
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 and the_aging_export_ties_out.

Terms cannot yet be journaled on a document (S4.3) and a bill line's
account is not an MCP field (S4.4), so bill_on_terms_with_discount,
equipment_bill_payment_is_investing_cash and the discount half of
bills_due are core tests until those land; the unit's As-built block
records this.
This commit is contained in:
swaits committed 2026-10-27 12:59:40 -06:00
1 parent 78a2385440
commit 57f04dbb0c
17 files changed
+1467 -231

No files matched your search

+401
View File
@@ -0,0 +1,401 @@
//! Paying on terms over the wire (plan P4b S6; rules.md B-42): one payment
//! settling several documents, the early-payment discount, the processor's
//! fee, the bills-due list, the agings' tie-out and the exports. Every
//! call goes through the books server as an agent reaches it (common
//! INVARIANT 1).
mod common;
use common::{Books, Wire, answer_of};
use serde_json::{Value, json};
/// 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,
}
/// 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"}),
);
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,
}
}
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}]}),
)["id"]
.as_u64()
.unwrap()
}
fn bill(&mut self, date: &str, due: &str, amount: &str) -> u64 {
ok(
&mut self.wire,
"books.create_bill",
json!({"party_id": self.vendor, "date": date, "due_date": due,
"lines": [{"description": "Paper", "amount": amount}]}),
)["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}");
}
+3 -1
View File
@@ -125,7 +125,7 @@ fn mcp_end_to_end() {
// Tool listing: the narrow tools; chart is read-only. // Tool listing: the narrow tools; chart is read-only.
let list = mcp.request("tools/list", json!({})); let list = mcp.request("tools/list", json!({}));
let tools = list["result"]["tools"].as_array().unwrap(); let tools = list["result"]["tools"].as_array().unwrap();
assert_eq!(tools.len(), 122, "expected 122 tools, got {}", tools.len()); assert_eq!(tools.len(), 123, "expected 123 tools, got {}", tools.len());
// Files come in as tool input: books.begin_upload, never a local path. // Files come in as tool input: books.begin_upload, never a local path.
assert!(tools.iter().any(|t| t["name"] == "books.begin_upload")); assert!(tools.iter().any(|t| t["name"] == "books.begin_upload"));
assert!(!tools.iter().any(|t| t["name"] == "books.import_local_file")); assert!(!tools.iter().any(|t| t["name"] == "books.import_local_file"));
@@ -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",
+6 -1
View File
@@ -878,6 +878,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",
@@ -1176,10 +1177,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),
@@ -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.
@@ -0,0 +1,63 @@
# Invoices and bills
Invoices are what customers owe the company; bills are what it owes
vendors. Every figure on them is read from the ledger lines tagged with
them, so a reversal, a void or a bounced payment is right by
construction: never edit one.
## 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.
+4
View File
@@ -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",
+2 -1
View File
@@ -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); 122] = [ pub const TABLE: [(&str, Needs); 123] = [
("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.
@@ -171,6 +171,7 @@ pub const TABLE: [(&str, Needs); 122] = [
("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),
+3
View File
@@ -566,6 +566,7 @@ impl BooksServer {
+ Self::instruments_router() + Self::instruments_router()
+ Self::projects_router() + Self::projects_router()
+ Self::documents_router() + Self::documents_router()
+ Self::settlements_router()
+ Self::entries_router() + Self::entries_router()
+ Self::reports_router() + Self::reports_router()
+ Self::assets_router() + Self::assets_router()
@@ -1422,6 +1423,8 @@ mod reports_tools;
mod schedules; mod schedules;
#[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;
+2 -1
View File
@@ -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); 122] = [ let table: [(&str, Hints); 123] = [
("books.handbook", R), ("books.handbook", R),
("books.start", R), ("books.start", R),
("books.set_setting", DI), ("books.set_setting", DI),
@@ -150,6 +150,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),
+4 -189
View File
@@ -1,46 +1,11 @@
//! `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::*;
/// 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 {
@@ -69,14 +34,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 and each project resolved ([`resolve_class`]) before anything is /// parsed and each project resolved ([`resolve_class`]) before anything is
/// written. /// written.
@@ -208,74 +165,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",
@@ -556,78 +445,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,
}))
}
} }
+55 -3
View File
@@ -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};
+557
View File
@@ -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 &params.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,
}))
}
+223 -13
View File
@@ -3091,17 +3091,73 @@ pub struct CreateDocumentOut {
pub notes: Vec<String>, pub notes: Vec<String>,
} }
/// 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).
@@ -3112,6 +3168,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`.
@@ -3119,12 +3203,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`.
@@ -3361,6 +3455,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.
@@ -3382,6 +3480,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 {
@@ -3389,6 +3534,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>,
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -4719,7 +4929,7 @@ pub struct ReconciliationsOut {
/// Parameters for `books.handbook`. /// Parameters for `books.handbook`.
#[derive(Debug, Default, Deserialize, JsonSchema)] #[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct HandbookParams { pub struct HandbookParams {
/// The section to read: contract, entries, drafts-and-reversals, evidence-and-receipts, attachments, files-in-out, reconciliation, fixed-assets, prepaid-and-recurring, revenue-and-payouts, projects, parties-and-tins, 1099, cpa-handoff, settings-and-conventions, viewer, backups, self-host, format-upgrade, rules-that-change, control-roles, tracing-figures, or "all" for the whole handbook in order. Omit for the index. /// The section to read: contract, entries, drafts-and-reversals, evidence-and-receipts, attachments, files-in-out, reconciliation, fixed-assets, prepaid-and-recurring, revenue-and-payouts, projects, parties-and-tins, 1099, invoices-and-bills, cpa-handoff, settings-and-conventions, viewer, backups, self-host, format-upgrade, rules-that-change, control-roles, tracing-figures, or "all" for the whole handbook in order. Omit for the index.
#[serde(default)] #[serde(default)]
pub section: Option<String>, pub section: Option<String>,
} }
+5 -2
View File
@@ -147,8 +147,11 @@ 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;
- `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
View File
@@ -1668,7 +1668,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
@@ -2008,21 +2012,37 @@ S4 changes `books.create_invoice` and `books.create_bill`; see plan 8.2 S4.
never tagged; `notes` as for invoices, but none for an on-spec project never tagged; `notes` as for invoices, but none for an on-spec project
(a cost of on-spec work is an expense like any other); the feature as (a cost of on-spec work is an expense like any other); the feature as
for invoices. for invoices.
- `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
@@ -2060,9 +2080,22 @@ S4 changes `books.create_invoice` and `books.create_bill`; 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
+77
View File
@@ -3050,3 +3050,80 @@ and `BooksServer::new`'s copy (a dropped field must fail
`isolation_label_is_enforced_under_the_unit`); the host's `server_flags` `isolation_label_is_enforced_under_the_unit`); the host's `server_flags`
extension and `parse_args`' passthrough of `--isolation` and its optional extension and `parse_args`' passthrough of `--isolation` and its optional
`--http` value. `--http` value.
## 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.
+9 -1
View File
@@ -1358,7 +1358,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`)