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:
1 parent
78a2385440
commit
57f04dbb0c
17 files changed
+1467
-231
No files matched your search
@@ -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.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -79,6 +79,10 @@ pub const SECTIONS: &[(&str, &str)] = &[
|
||||
include_str!("../handbook/parties-and-tins.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")),
|
||||
(
|
||||
"settings-and-conventions",
|
||||
|
||||
@@ -56,7 +56,7 @@ const C: Needs = PostWhenConfirmed;
|
||||
const K: Needs = PostForApprovalRules;
|
||||
|
||||
/// Every tool and what it needs (INVARIANT 1).
|
||||
pub const TABLE: [(&str, Needs); 122] = [
|
||||
pub const TABLE: [(&str, Needs); 123] = [
|
||||
("books.handbook", R),
|
||||
("books.start", R),
|
||||
// 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.ar_aging", R),
|
||||
("books.ap_aging", R),
|
||||
("books.bills_due", R),
|
||||
("books.set_fx_rate", W),
|
||||
("books.run_payroll", P),
|
||||
("books.remit_payroll_taxes", P),
|
||||
|
||||
@@ -566,6 +566,7 @@ impl BooksServer {
|
||||
+ Self::instruments_router()
|
||||
+ Self::projects_router()
|
||||
+ Self::documents_router()
|
||||
+ Self::settlements_router()
|
||||
+ Self::entries_router()
|
||||
+ Self::reports_router()
|
||||
+ Self::assets_router()
|
||||
@@ -1422,6 +1423,8 @@ mod reports_tools;
|
||||
mod schedules;
|
||||
#[path = "tools/settings.rs"]
|
||||
mod settings_tools;
|
||||
#[path = "tools/settlements.rs"]
|
||||
mod settlements;
|
||||
#[path = "tools/tax.rs"]
|
||||
mod tax;
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ fn annotations_complete() {
|
||||
const D: Hints = (Some(false), Some(true), None);
|
||||
const WI: Hints = (Some(false), Some(false), 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.start", R),
|
||||
("books.set_setting", DI),
|
||||
@@ -150,6 +150,7 @@ fn annotations_complete() {
|
||||
("books.document", R),
|
||||
("books.ar_aging", R),
|
||||
("books.ap_aging", R),
|
||||
("books.bills_due", R),
|
||||
("books.set_fx_rate", W),
|
||||
("books.run_payroll", D),
|
||||
("books.remit_payroll_taxes", D),
|
||||
|
||||
@@ -1,46 +1,11 @@
|
||||
//! `books.documents`' group: the A/R and A/P documents (invoices and
|
||||
//! bills), the payments and voids against them, the read tools
|
||||
//! (`books.documents`, `books.document`), the control check and the
|
||||
//! agings. Split whole out of `server.rs` (plan/served.md 8.2 U0a): the
|
||||
//! tools, their names, descriptions and rules are unchanged
|
||||
//! (`docs/mcp-interface.md`; rules.md B-38).
|
||||
//! bills), their voids, the read tools (`books.documents`,
|
||||
//! `books.document`) and the control check. Split whole out of
|
||||
//! `server.rs` (plan/served.md 8.2 U0a); the payments and the agings live
|
||||
//! in `tools/settlements.rs` (P4b S6).
|
||||
|
||||
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.
|
||||
fn doc_id(doc: ledger_core::DocRef) -> u64 {
|
||||
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
|
||||
/// parsed and each project resolved ([`resolve_class`]) before anything is
|
||||
/// 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(
|
||||
name = "books.void_document",
|
||||
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())?;
|
||||
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(
|
||||
name = "books.export",
|
||||
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)
|
||||
)]
|
||||
async fn export(&self, params: Parameters<ExportParams>) -> Result<Json<ExportOut>, String> {
|
||||
@@ -911,9 +911,11 @@ impl BooksServer {
|
||||
"fixed_asset_register" => {
|
||||
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!(
|
||||
"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
|
||||
));
|
||||
}
|
||||
@@ -1205,14 +1207,64 @@ impl BooksServer {
|
||||
.map_err(|e| e.to_string())?;
|
||||
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!(
|
||||
"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.
|
||||
fn export_xlsx(&self, report: &str, p: &ExportParams) -> Result<Vec<u8>, String> {
|
||||
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,
|
||||
}))
|
||||
}
|
||||
+223
-13
@@ -3091,17 +3091,73 @@ pub struct CreateDocumentOut {
|
||||
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`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct PaymentParams {
|
||||
/// Invoice id (receive_payment) or bill id (pay_bill).
|
||||
pub document_id: u64,
|
||||
/// Invoice id (receive_payment) or bill id (pay_bill); the older name
|
||||
/// 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.
|
||||
pub date: String,
|
||||
/// Payment amount, decimal string.
|
||||
pub amount: String,
|
||||
/// Cash account id or code.
|
||||
pub cash_account: String,
|
||||
/// Payment amount, decimal string; with `invoice` or `bill` only. The
|
||||
/// applications' amounts are the cash; a discount is beside it.
|
||||
#[serde(default)]
|
||||
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
|
||||
/// (`books.instruments`), tagged on the payment's entry in the same
|
||||
/// 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).
|
||||
#[serde(default)]
|
||||
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`.
|
||||
@@ -3119,12 +3203,22 @@ pub struct PaymentParams {
|
||||
pub struct PaymentOut {
|
||||
/// The payment journal entry's id.
|
||||
pub entry_id: u64,
|
||||
/// What the document still owes after it, decimal string (derived
|
||||
/// from its lines that stand).
|
||||
pub remaining: String,
|
||||
/// The document's status after it: open, partial, paid, written_off,
|
||||
/// void, overpaid, or unattached.
|
||||
pub status: String,
|
||||
/// Every document it settled, in application order.
|
||||
pub documents: Vec<PaidDocumentOut>,
|
||||
/// What reached (receive_payment) or left (pay_bill) the account: the
|
||||
/// cash less a processor's fee, decimal string.
|
||||
pub deposited: 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`.
|
||||
@@ -3361,6 +3455,10 @@ pub struct AgingParams {
|
||||
#[serde(default)]
|
||||
#[schemars(description = AS_OF_DOC)]
|
||||
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.
|
||||
@@ -3382,6 +3480,53 @@ pub struct AgingRowOut {
|
||||
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`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct AgingOut {
|
||||
@@ -3389,6 +3534,71 @@ pub struct AgingOut {
|
||||
pub as_of: String,
|
||||
/// Rows, one per party with an outstanding balance.
|
||||
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`.
|
||||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
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)]
|
||||
pub section: Option<String>,
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user