Audit fixes: party money, reconciliations, require policy, K-1 property
An independent recompute of the real books found no numeric disagreements; review found 15 defects, all fixed with spec-first regression tests: - k1: a prepaid credited straight to capital is property; a member paying a vendor through 2100 is cash. Docs and B-13 match. - parties: a reversal of a reversal counts on its root's side; liability-to-liability payments (member pays the card) count as paid. - viewer: party money uses the server's cash accounts and full calendar year; per-party "What for" scales multi-party entries. - reconciliations: refuse a withdrawn statement before journaling; new books.void_statement; reconciled lines can't be un-cleared; one shared reconcilable-accounts rule for digest and viewer. - CPA package includes receipts linked to entries; a file can't be both filed on and linked to one entry. - parties = "require" covers payroll, tax remittance (agency) and disposal (buyer); no-party marks follow reversal chains and clear when a vendor is tagged. - derived member shares are frozen to exact amounts when posted, so a settings change never re-splits history. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
1 parent
e1b2509c22
commit
8498e6cfab
22 files changed
+1802
-343
No files matched your search
+224
-77
@@ -54,7 +54,7 @@ use rust_decimal::Decimal;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// The agent operating contract, delivered via initialize.
|
||||
const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- Receipt corrections: receipts can change, but nothing is erased. To replace one, attach the new receipt, then books.unlink_receipt the old (with a reason); retire unwanted receipts or documents with books.withdraw_attachment (with a reason). The journal keeps every step.\n- Attachments: file a file once and link it wherever it is evidence (books.link_attachment, or link_to on books.attach_receipt and books.file_document): an entry (a file linked to an entry counts as its receipt), an account, an asset, a party, an invoice, a bill, a period, a payroll run, a schedule, a reconciliation, or the company. books.attachments lists a subject's files; books.unlink_attachment takes a link off with a reason. File documents under their kind (statement, contract, photo, warranty, import_source, formation, legal, tax, accounting, other). File a bank export before importing it and pass source_attachment_id to books.import_bank_csv.\n- Reconciliations: every month, for each bank, cash, and card account, file the statement (books.file_document category statement), record what it says (books.record_statement), mark the lines it shows (books.mark_cleared), preview books.reconcile with statement_attachment, and confirm with confirmed=true once the difference is exactly zero: that records the reconciliation with its statement. Never confirm by forcing the numbers; find the difference. books.void_reconciliation (with a reason) undoes a mistake. books.digest lists unreconciled_accounts: accounts not reconciled through the end of last month.\n- After posting, check books.digest to review what changed, so a human can review too.\n- Amounts in every response are decimal strings. Treat them as exact.\n- Fixed assets: register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). Depreciation is straight-line, book basis only - tax depreciation stays with the CPA. Every asset gets a tag for the label on the thing itself (books.register_asset generates the next one, like FL-0001, unless the human has their own); record the manufacturer's serial number too, change either with books.label_asset, and print label sheets with books.asset_labels (save the PDF; print at 100% on Avery 5160/8160). Asset tools take the tag in place of the id. Record every asset's federal tax basis beside its book value (books.set_asset_tax_basis, or tax_basis_rule on books.register_asset): cost for a purchase; for a member's contribution, the member's adjusted basis (what they paid, less depreciation they took) with rule personal_use_conversion (their personal property: tax basis is the lesser of value and their basis) or carryover (their business property). Book value stays fair market value. books.export fixed_asset_register is the register for the CPA; books.show asset opens one in the viewer.\n- Prepaid expenses and deferred revenue: record the original payment (with its receipt), then books.create_schedule; books.run_schedules posts the monthly amortization (preview first; exempt from the receipt policy like depreciation). Recurring entries (books.create_recurring, books.run_recurring) only ever create drafts: attach each actual receipt and post through the normal preview.\n- Platform payouts (app stores, payment processors, marketplaces): never book the net deposit as revenue. books.record_payout grosses it up (gross revenue, fees, adjustments) into a draft and refuses unless it reconciles to the cent; attach the payout statement, then preview and post.\n- The viewer: the human usually has the read-only viewer open beside you (`just gui`). When they ask to see something, and after you post, call books.show so it's on their screen.\n- Parties: keep the directory current. Every counterparty (vendor, contractor, landlord, customer, payout platfo
Line truncated
|
||||
const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- Receipt corrections: receipts can change, but nothing is erased. To replace one, attach the new receipt, then books.unlink_receipt the old (with a reason); retire unwanted receipts or documents with books.withdraw_attachment (with a reason). The journal keeps every step.\n- Attachments: file a file once and link it wherever it is evidence (books.link_attachment, or link_to on books.attach_receipt and books.file_document): an entry (a file linked to an entry counts as its receipt), an account, an asset, a party, an invoice, a bill, a period, a payroll run, a schedule, a reconciliation, or the company. books.attachments lists a subject's files; books.unlink_attachment takes a link off with a reason. File documents under their kind (statement, contract, photo, warranty, import_source, formation, legal, tax, accounting, other). File a bank export before importing it and pass source_attachment_id to books.import_bank_csv.\n- Reconciliations: every month, for each bank, cash, and card account, file the statement (books.file_document category statement), record what it says (books.record_statement), mark the lines it shows (books.mark_cleared), preview books.reconcile with statement_attachment, and confirm with confirmed=true once the difference is exactly zero: that records the reconciliation with its statement. Never confirm by forcing the numbers; find the difference. books.void_reconciliation (with a reason) undoes a mistake; books.void_statement (with a reason) a mis-recorded statement, to record it again. books.digest lists unreconciled_accounts: accounts not reconciled through the end of last month.\n- After posting, check books.digest to review what changed, so a human can review too.\n- Amounts in every response are decimal strings. Treat them as exact.\n- Fixed assets: register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). Depreciation is straight-line, book basis only - tax depreciation stays with the CPA. Every asset gets a tag for the label on the thing itself (books.register_asset generates the next one, like FL-0001, unless the human has their own); record the manufacturer's serial number too, change either with books.label_asset, and print label sheets with books.asset_labels (save the PDF; print at 100% on Avery 5160/8160). Asset tools take the tag in place of the id. Record every asset's federal tax basis beside its book value (books.set_asset_tax_basis, or tax_basis_rule on books.register_asset): cost for a purchase; for a member's contribution, the member's adjusted basis (what they paid, less depreciation they took) with rule personal_use_conversion (their personal property: tax basis is the lesser of value and their basis) or carryover (their business property). Book value stays fair market value. books.export fixed_asset_register is the register for the CPA; books.show asset opens one in the viewer.\n- Prepaid expenses and deferred revenue: record the original payment (with its receipt), then books.create_schedule; books.run_schedules posts the monthly amortization (preview first; exempt from the receipt policy like depreciation). Recurring entries (books.create_recurring, books.run_recurring) only ever create drafts: attach each actual receipt and post through the normal preview.\n- Platform payouts (app stores, payment processors, marketplaces): never book the net deposit as revenue. books.record_payout grosses it up (gross revenue, fees, adjustments) into a draft and refuses unless it reconciles to the cent; attach the payout statement, then preview and post.\n- The viewer: the human usually has the read-only viewer open beside you (`just gui`). When they ask to see something, and after you post, call books.show so it's on their screen.\n- Parties: keep the directory
Line truncated
|
||||
|
||||
/// Max receipt/document upload: 25 MB.
|
||||
const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024;
|
||||
@@ -461,13 +461,17 @@ impl BooksServer {
|
||||
})
|
||||
}
|
||||
|
||||
/// Cash and card accounts due for reconciliation: the `[books]
|
||||
/// cash_accounts` and every liability with a recorded statement, with
|
||||
/// posted activity through the end of last month, not reconciled
|
||||
/// through it.
|
||||
/// Cash and card accounts due for reconciliation
|
||||
/// (ledger_store::reconcilable, the viewer's Needs-attention rule):
|
||||
/// the cash accounts (`[books] cash_accounts`, else assets named for
|
||||
/// cash), the card accounts (liabilities named "card", before their
|
||||
/// first statement), and every other liability with a recorded
|
||||
/// statement, with posted activity through the end of last month, not
|
||||
/// reconciled through it.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: the monthly close's reconciliation step can't be forgotten.
|
||||
/// GOAL: the monthly close's reconciliation step can't be forgotten,
|
||||
/// and the digest and the viewer agree on what is due.
|
||||
/// SOURCE: docs/parties-and-attachments.md "Reconciliations" (Needs
|
||||
/// attention: an account with activity not reconciled through
|
||||
/// last month's end).
|
||||
@@ -477,40 +481,17 @@ impl BooksServer {
|
||||
store: &Store,
|
||||
today: NaiveDate,
|
||||
) -> Result<Vec<UnreconciledAccountOut>, String> {
|
||||
use ledger_store::reconcilable;
|
||||
let ledger = store.ledger();
|
||||
let due = chrono::Datelike::with_day(&today, 1)
|
||||
.and_then(|d| d.pred_opt())
|
||||
.expect("a month before today");
|
||||
let mut accounts: Vec<AccountId> = self
|
||||
.settings()
|
||||
.roles
|
||||
.cash
|
||||
.iter()
|
||||
.filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id))
|
||||
.collect();
|
||||
for st in store.statements(None).map_err(|e| e.to_string())? {
|
||||
if ledger
|
||||
.chart()
|
||||
.get(st.account_id)
|
||||
.is_some_and(|a| a.account_type == AccountType::Liability)
|
||||
{
|
||||
accounts.push(st.account_id);
|
||||
}
|
||||
}
|
||||
accounts.sort_unstable();
|
||||
accounts.dedup();
|
||||
let cash = reconcilable::cash_accounts(ledger, &self.settings().roles.cash);
|
||||
let statements = store.statements(None).map_err(|e| e.to_string())?;
|
||||
let reconciliations = store.reconciliations(None).map_err(|e| e.to_string())?;
|
||||
let mut out = Vec::new();
|
||||
for id in accounts {
|
||||
let active = ledger
|
||||
.posted_entries()
|
||||
.any(|e| e.date() <= due && e.lines().iter().any(|l| l.account == id));
|
||||
if !active {
|
||||
for id in reconcilable::reconcilable_accounts(ledger, &cash, &statements) {
|
||||
let Some(due) = reconcilable::reconciliation_due(ledger, &reconciliations, id, today)
|
||||
else {
|
||||
continue;
|
||||
}
|
||||
let through = reconciled_through(store, id)?;
|
||||
if through.is_some_and(|t| t >= due) {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let (code, name) = code_name(store, id);
|
||||
out.push(UnreconciledAccountOut {
|
||||
next_step: format!(
|
||||
@@ -518,7 +499,8 @@ impl BooksServer {
|
||||
),
|
||||
code,
|
||||
name,
|
||||
reconciled_through: through.map(|d| d.to_string()),
|
||||
reconciled_through: reconcilable::reconciled_through(&reconciliations, id)
|
||||
.map(|d| d.to_string()),
|
||||
due_through: due.to_string(),
|
||||
});
|
||||
}
|
||||
@@ -1565,6 +1547,19 @@ fn party_activity(
|
||||
})
|
||||
}
|
||||
|
||||
/// The `party_required` refusal for an engine-posted entry (payroll, a
|
||||
/// remittance, a disposal) that names no counterparty under `[books]
|
||||
/// parties = "require"`: `what` says what to pass.
|
||||
fn party_required(what: &str) -> String {
|
||||
serde_json::json!({
|
||||
"code": "party_required",
|
||||
"message": format!(
|
||||
"these books require a counterparty on every entry ([books] parties = \"require\"): {what} (add the party with books.add_party first if it's new)"
|
||||
),
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// The chart's ids for these account codes (codes it lacks are skipped).
|
||||
fn cash_ids(ledger: &Ledger, codes: &[String]) -> Vec<AccountId> {
|
||||
codes
|
||||
@@ -1650,6 +1645,58 @@ fn check_shares_resolve(
|
||||
ledger_core::shares::resolve(entry, shares, &money, &members).map(|_| ())
|
||||
}
|
||||
|
||||
/// Fix a posted entry's derived split among several parties: journal the
|
||||
/// exact amounts they resolve to now, so a later change to
|
||||
/// `[[company.members]]` (a new income split, a member archived) never
|
||||
/// re-splits posted history.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: posted is forever, the split between its parties included.
|
||||
/// SOURCE: AGENTS.md ("Posted is forever"); docs/parties-and-attachments.md
|
||||
/// "Shares"; ledger_core::shares INVARIANT 10 (derived several:
|
||||
/// the members' capital lines, else their income shares, both read
|
||||
/// from the settings).
|
||||
/// INVARIANTS:
|
||||
/// 1. Only a posted entry naming two or more parties with derived shares
|
||||
/// changes: its shares become Amount(|part|) for each party, in
|
||||
/// order, journaled as entry.parties_set (the reversal chain too), so
|
||||
/// each party's resolved part is exactly what it was.
|
||||
/// 2. Nothing changes when the shares don't resolve, or a part is zero
|
||||
/// (an amount must be positive): the entry keeps its derived shares.
|
||||
/// ```
|
||||
fn fix_derived_shares(store: &mut Store, settings: &Settings, entry_id: u64) -> Result<(), String> {
|
||||
let ledger = store.ledger();
|
||||
let Some(entry) = ledger.get_entry(entry_id) else {
|
||||
return Ok(());
|
||||
};
|
||||
let shares = ledger.entry_parties(entry_id);
|
||||
if entry.status() != ledger_core::EntryStatus::Posted
|
||||
|| shares.len() < 2
|
||||
|| !shares
|
||||
.iter()
|
||||
.any(|s| matches!(s.share, ledger_core::Share::Derived))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let (money, members) = share_context(ledger, settings);
|
||||
let Ok(parts) = ledger_core::shares::resolve(entry, shares, &money, &members) else {
|
||||
return Ok(());
|
||||
};
|
||||
if parts.iter().any(|(_, v)| v.is_zero()) {
|
||||
return Ok(());
|
||||
}
|
||||
let fixed: Vec<ledger_core::PartyShare> = parts
|
||||
.into_iter()
|
||||
.map(|(party, v)| ledger_core::PartyShare {
|
||||
party,
|
||||
share: ledger_core::Share::Amount(v.abs()),
|
||||
})
|
||||
.collect();
|
||||
store
|
||||
.set_entry_parties(entry_id, &fixed)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// An entry's parties for output, each share resolved, and why they don't
|
||||
/// resolve when they don't.
|
||||
fn entry_parties_out(
|
||||
@@ -2482,6 +2529,7 @@ impl BooksServer {
|
||||
.to_tool_error());
|
||||
}
|
||||
store.post_entry(p.entry_id).map_err(|e| e.to_string())?;
|
||||
fix_derived_shares(&mut store, &self.settings(), p.entry_id)?;
|
||||
let mut posted = out;
|
||||
posted.status = "posted".to_string();
|
||||
Ok(Json(posted))
|
||||
@@ -2957,6 +3005,10 @@ impl BooksServer {
|
||||
.map(|p| p.label().to_string()),
|
||||
parties,
|
||||
parties_error,
|
||||
no_party_reason: store
|
||||
.ledger()
|
||||
.entry_no_party(p.entry_id)
|
||||
.map(str::to_string),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -4971,7 +5023,7 @@ impl BooksServer {
|
||||
|
||||
#[tool(
|
||||
name = "books.dispose_asset",
|
||||
description = "Dispose of a fixed asset (sell or retire). Without confirmed=true returns a preview — book value, gain/loss, the auto-posted final depreciation, and the disposal entry's lines — and posts nothing. With confirmed=true posts the disposal entry (plus any missing final-period depreciation) after enforcing the receipt policy: file the bill of sale with books.file_document first and pass its attachment ids as receipt_attachment_ids."
|
||||
description = "Dispose of a fixed asset (sell or retire). Without confirmed=true returns a preview — book value, gain/loss, the auto-posted final depreciation, and the disposal entry's lines — and posts nothing. With confirmed=true posts the disposal entry (plus any missing final-period depreciation) after enforcing the receipt policy: file the bill of sale with books.file_document first and pass its attachment ids as receipt_attachment_ids. Name the buyer (buyer: a party id or name): it is the entry's party, and [books] parties = \"require\" refuses a sale without one; a retirement with no proceeds and no buyer is marked as having no counterparty."
|
||||
)]
|
||||
async fn dispose_asset(
|
||||
&self,
|
||||
@@ -4985,6 +5037,11 @@ impl BooksServer {
|
||||
let proceeds_account = resolve_account(&store, &p.proceeds_account)?;
|
||||
let gain_loss_account = resolve_account(&store, &p.gain_loss_account)?;
|
||||
let asset_id = resolve_asset(&store, p.asset_id, p.tag.as_deref())?;
|
||||
let buyer = p
|
||||
.buyer
|
||||
.as_deref()
|
||||
.map(|r| resolve_party(&store, r))
|
||||
.transpose()?;
|
||||
let asset = store
|
||||
.ledger()
|
||||
.get_asset(asset_id)
|
||||
@@ -5076,6 +5133,14 @@ impl BooksServer {
|
||||
for aid in &attachment_ids {
|
||||
store.check_linkable(*aid).map_err(|e| e.to_string())?;
|
||||
}
|
||||
if buyer.is_none()
|
||||
&& !proceeds.amount.is_zero()
|
||||
&& self.settings().parties == ledger_config::PartyPolicy::Require
|
||||
{
|
||||
return Err(party_required(
|
||||
"pass buyer: the party the asset was sold to",
|
||||
));
|
||||
}
|
||||
let entry_id = store
|
||||
.dispose_asset(
|
||||
asset_id,
|
||||
@@ -5090,6 +5155,14 @@ impl BooksServer {
|
||||
.link_receipt(*aid, entry_id)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
match buyer {
|
||||
Some(b) => store.set_entry_party(entry_id, Some(b)),
|
||||
None if proceeds.amount.is_zero() => {
|
||||
store.set_entry_no_party(entry_id, "asset retired with no proceeds")
|
||||
}
|
||||
None => Ok(()),
|
||||
}
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut posted = out;
|
||||
posted.status = "posted".to_string();
|
||||
posted.entry_id = entry_id;
|
||||
@@ -5235,7 +5308,8 @@ impl BooksServer {
|
||||
/// SCENARIOS:
|
||||
/// 1. IF statement_attachment is given: the account, date, and
|
||||
/// balance are the statement's; any given that disagree: SHOULD
|
||||
/// refuse.
|
||||
/// refuse. IF its attachment is withdrawn (the statement retired)
|
||||
/// or its statement voided: SHOULD refuse, preview or not.
|
||||
/// 2. IF it isn't: account, statement_date, and statement_balance are
|
||||
/// required, and confirmed=true SHOULD refuse.
|
||||
/// 3. IF confirmed=true and problems is not empty: SHOULD refuse,
|
||||
@@ -5265,7 +5339,11 @@ impl BooksServer {
|
||||
.transpose()?;
|
||||
let (account, date, balance) = match p.statement_attachment {
|
||||
Some(att) => {
|
||||
find_attachment(&store, att)?;
|
||||
if find_attachment(&store, att)?.withdrawn_at.is_some() {
|
||||
return Err(format!(
|
||||
"attachment {att} is withdrawn, so its statement is retired: file the statement again and record it (books.record_statement)"
|
||||
));
|
||||
}
|
||||
let st = store
|
||||
.statement(att)
|
||||
.map_err(|e| e.to_string())?
|
||||
@@ -5481,6 +5559,38 @@ impl BooksServer {
|
||||
Ok(Json(ReconciliationsOut { reconciliations }))
|
||||
}
|
||||
|
||||
/// ```text
|
||||
/// GOAL: correct a mis-recorded statement on the record.
|
||||
/// SOURCE: Store::void_statement.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the attachment records no statement, a reconciliation not
|
||||
/// voided rests on it, or the reason is blank: SHOULD refuse,
|
||||
/// journaling nothing.
|
||||
/// 2. Otherwise: journaled as `statement.voided`; books.statements no
|
||||
/// longer lists it, and the same attachment can record the
|
||||
/// statement again (books.record_statement).
|
||||
/// ```
|
||||
#[tool(
|
||||
name = "books.void_statement",
|
||||
description = "Void a recorded statement that was recorded wrong (a mistyped balance or period), with the reason recorded in the journal, so the same filed statement can be recorded again with books.record_statement. Refused while a reconciliation not voided rests on it (books.void_reconciliation first). Nothing is erased: the journal keeps what it said. Journaled as statement.voided. Returns the statement as it was recorded."
|
||||
)]
|
||||
async fn void_statement(
|
||||
&self,
|
||||
params: Parameters<VoidStatementParams>,
|
||||
) -> Result<Json<StatementOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let st = store
|
||||
.statement(p.attachment_id)
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("attachment {} records no statement", p.attachment_id))?;
|
||||
let out = statement_out(&store, &st)?;
|
||||
store
|
||||
.void_statement(p.attachment_id, &p.reason)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Json(out))
|
||||
}
|
||||
|
||||
/// ```text
|
||||
/// GOAL: undo a mistaken reconciliation on the record, to redo it.
|
||||
/// SOURCE: Store::void_reconciliation.
|
||||
@@ -6356,7 +6466,7 @@ impl BooksServer {
|
||||
|
||||
#[tool(
|
||||
name = "books.set_entry_party",
|
||||
description = "Tag an entry (draft or posted) with the party it was with (id or name), or pass party null (or omit it) to clear the tag. A party with a vendor record also tags the entry's vendor, so a payment to a contractor or landlord counts in books.vendor_1099; a party without one, or clearing, clears the vendor tag. For an entry with several counterparties pass parties instead: [{party, amount?|percent?}], each share an exact amount (cents), a percent (summing to 100; leftover cents to the first party), or neither (derived: a sole party gets all; members named together get what their capital or distributions lines say, else their [[company.members]] share). Name both members on a joint member transaction (a reimbursement on 2100, a joint contribution) and let the shares derive; give amounts when a split is uneven. Refused: a party twice, mixed kinds of share, two parties with vendor records, and, once the entry has balanced lines, shares that don't add up to what it moved. Metadata only: amounts never change. An entry and its reversal carry the same parties: tagging either tags both, so each party's part nets to zero. Journaled as entry.party_set (one party) or entry.parties_set (parties). Returns the entry (as books.get_entry), each party's share resolved."
|
||||
description = "Tag an entry (draft or posted) with the party it was with (id or name), or pass party null (or omit it) to clear the tag. A party with a vendor record also tags the entry's vendor, so a payment to a contractor or landlord counts in books.vendor_1099; a party without one, or clearing, clears the vendor tag. For an entry with several counterparties pass parties instead: [{party, amount?|percent?}], each share an exact amount (cents), a percent (summing to 100; leftover cents to the first party), or neither (derived: a sole party gets all; members named together get what their capital or distributions lines say, else their [[company.members]] share; once the entry is posted, a derived split among several parties is recorded as the exact amounts it resolves to, so a later change to [[company.members]] never re-splits posted history). Name both members on a joint member transaction (a reimbursement on 2100, a joint contribution) and let the shares derive; give amounts when a split is uneven. Refused: a party twice, mixed kinds of share, two parties with vendor records, and, once the entry has balanced lines, shares that don't add up to what it moved. Metadata only: amounts never change. An entry and its reversal carry the same parties: tagging either tags both, so each party's part nets to zero. Journaled as entry.party_set (one party) or entry.parties_set (parties). Returns the entry (as books.get_entry), each party's share resolved."
|
||||
)]
|
||||
async fn set_entry_party(
|
||||
&self,
|
||||
@@ -6418,6 +6528,7 @@ impl BooksServer {
|
||||
store
|
||||
.set_entry_parties(p.entry_id, &sh)
|
||||
.map_err(|e| e.to_string())?;
|
||||
fix_derived_shares(&mut store, &settings, p.entry_id)?;
|
||||
}
|
||||
(party, None, None) => {
|
||||
store
|
||||
@@ -6705,6 +6816,15 @@ impl BooksServer {
|
||||
})
|
||||
})
|
||||
.collect::<Result<_, String>>()?;
|
||||
if self.settings().parties == ledger_config::PartyPolicy::Require
|
||||
&& lines
|
||||
.iter()
|
||||
.any(|l| l.employee.is_none() && !l.net_pay().is_zero())
|
||||
{
|
||||
return Err(party_required(
|
||||
"name each payroll line's employee party (employee), so the entry names who was paid",
|
||||
));
|
||||
}
|
||||
let run_id = store
|
||||
.run_payroll(parse_date(&p.date)?, lines, cash)
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -6724,7 +6844,7 @@ impl BooksServer {
|
||||
|
||||
#[tool(
|
||||
name = "books.remit_payroll_taxes",
|
||||
description = "Remit all outstanding payroll tax payables (federal/state withholding, SS, Medicare, other deductions) to cash in one balanced entry."
|
||||
description = "Remit all outstanding payroll tax payables (federal/state withholding, SS, Medicare, other deductions) to cash in one balanced entry. Name the tax agency paid (agency: a party id or name, e.g. the IRS): it is the entry's party, and [books] parties = \"require\" refuses without it."
|
||||
)]
|
||||
async fn remit_payroll_taxes(
|
||||
&self,
|
||||
@@ -6733,6 +6853,14 @@ impl BooksServer {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let cash = resolve_account(&store, &p.cash_account)?;
|
||||
let agency = p
|
||||
.agency
|
||||
.as_deref()
|
||||
.map(|r| resolve_party(&store, r))
|
||||
.transpose()?;
|
||||
if agency.is_none() && self.settings().parties == ledger_config::PartyPolicy::Require {
|
||||
return Err(party_required("pass agency: the tax agency paid"));
|
||||
}
|
||||
// Total before remitting, for the response.
|
||||
let total: Decimal = store
|
||||
.ledger()
|
||||
@@ -6743,6 +6871,11 @@ impl BooksServer {
|
||||
let entry_id = store
|
||||
.remit_payroll_taxes(parse_date(&p.date)?, cash)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if let Some(a) = agency {
|
||||
store
|
||||
.set_entry_party(entry_id, Some(a))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(Json(RemitPayrollTaxesOut {
|
||||
entry_id,
|
||||
total: money(total),
|
||||
@@ -6937,7 +7070,7 @@ impl BooksServer {
|
||||
/// ```
|
||||
#[tool(
|
||||
name = "books.export_cpa_package",
|
||||
description = "Build the CPA handoff package as a zip: the profit and loss, balance sheet, cash flow, statement of changes in members' equity (multi-member LLCs), tax summary, and trial balance, each as PDF and XLSX; the fixed asset register as PDF and XLSX when there are fixed assets; the general ledger (by account) and general journal (entry by entry) as XLSX; the journal as IIF (imports into QuickBooks Desktop); the 1099 worksheet as CSV; with members, the K-1 worksheet (k1-members.csv: per member their party, share, capital account, TIN, and beginning capital, contributions in cash and in property, share of income, distributions, and ending capital for the fiscal year to date); every receipt behind the year's entries (receipts/, with an index.csv tying each file to its entry) and the company's documents (documents/), unless include_receipts is false; and a README with the journal's verified hash. By default (include_tins) the K-1 and 1099 worksheets carry every member's and payee's full TIN from the TIN vault, so the package is saved, never returned: save_to (a new .zip path or an existing folder, outside the books dir) is required, and the output gives only its path; tell the human where it is and never open, attach, or copy it. A locked vault or a missing TIN doesn't stop the package: tin_gaps lists each one (collect them with books.set_tin or books.set_w9 and export again). include_tins: false builds it with last fours only, returned as base64 unless save_to is given. cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.",
|
||||
description = "Build the CPA handoff package as a zip: the profit and loss, balance sheet, cash flow, statement of changes in members' equity (multi-member LLCs), tax summary, and trial balance, each as PDF and XLSX; the fixed asset register as PDF and XLSX when there are fixed assets; the general ledger (by account) and general journal (entry by entry) as XLSX; the journal as IIF (imports into QuickBooks Desktop); the 1099 worksheet as CSV; with members, the K-1 worksheet (k1-members.csv: per member their party, share, capital account, TIN, and beginning capital, contributions in cash and in property, share of income, distributions, and ending capital for the fiscal year to date); every receipt behind the year's entries, filed on them or linked to them (receipts/, with an index.csv tying each file to its entry) and the company's documents (documents/), unless include_receipts is false; and a README with the journal's verified hash. By default (include_tins) the K-1 and 1099 worksheets carry every member's and payee's full TIN from the TIN vault, so the package is saved, never returned: save_to (a new .zip path or an existing folder, outside the books dir) is required, and the output gives only its path; tell the human where it is and never open, attach, or copy it. A locked vault or a missing TIN doesn't stop the package: tin_gaps lists each one (collect them with books.set_tin or books.set_w9 and export again). include_tins: false builds it with last fours only, returned as base64 unless save_to is given. cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.",
|
||||
annotations(read_only_hint = true)
|
||||
)]
|
||||
async fn export_cpa_package(
|
||||
@@ -7252,8 +7385,11 @@ impl BooksServer {
|
||||
format!("{e}\n").into_bytes(),
|
||||
)),
|
||||
}
|
||||
// The evidence: every current receipt on the year's posted entries,
|
||||
// and the company's documents, with an index tying them together.
|
||||
// The evidence: every current file behind the year's posted
|
||||
// entries, filed on them or linked to them (a file linked to an
|
||||
// entry counts as its receipt), one copy per entry with a row in
|
||||
// the index; and the company's documents, the files behind no
|
||||
// entry.
|
||||
let mut receipt_count = 0;
|
||||
if p.include_receipts {
|
||||
let clean = |s: &str| -> String {
|
||||
@@ -7272,44 +7408,55 @@ impl BooksServer {
|
||||
if a.withdrawn_at.is_some() {
|
||||
continue;
|
||||
}
|
||||
let bytes = store.attachment_bytes(a.id).map_err(|e| e.to_string())?;
|
||||
match a.entry_id.and_then(|e| ledger.get_entry(e)) {
|
||||
Some(e)
|
||||
if e.status() == ledger_core::EntryStatus::Posted
|
||||
&& e.date() >= fy
|
||||
&& e.date() <= as_of =>
|
||||
let mut behind: Vec<u64> = a.entry_id.into_iter().collect();
|
||||
for sub in store.attachment_links(a.id).map_err(|e| e.to_string())? {
|
||||
if let ledger_store::Subject::Entry(e) = sub
|
||||
&& !behind.contains(&e)
|
||||
{
|
||||
let name = format!(
|
||||
"receipts/{}-entry-{}-{}",
|
||||
e.date(),
|
||||
e.id(),
|
||||
clean(&a.file_name)
|
||||
);
|
||||
let amount: Decimal = e
|
||||
.lines()
|
||||
.iter()
|
||||
.filter(|l| l.side == Side::Debit)
|
||||
.map(|l| l.amount.amount)
|
||||
.sum();
|
||||
index.push_str(&format!(
|
||||
"{},{},\"{}\",{},{},{}\n",
|
||||
e.id(),
|
||||
e.date(),
|
||||
e.description().replace('"', "\"\""),
|
||||
money(amount),
|
||||
name,
|
||||
a.blob_sha
|
||||
));
|
||||
files.push((name, bytes));
|
||||
receipt_count += 1;
|
||||
behind.push(e);
|
||||
}
|
||||
None if a.entry_id.is_none() && a.category != "receipts" => {
|
||||
}
|
||||
behind.sort_unstable();
|
||||
let bytes = store.attachment_bytes(a.id).map_err(|e| e.to_string())?;
|
||||
if behind.is_empty() {
|
||||
if a.category != "receipts" {
|
||||
files.push((
|
||||
format!("documents/{}/{}", clean(&a.category), clean(&a.file_name)),
|
||||
bytes,
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
continue;
|
||||
}
|
||||
for e in behind.iter().filter_map(|&e| ledger.get_entry(e)) {
|
||||
if e.status() != ledger_core::EntryStatus::Posted
|
||||
|| e.date() < fy
|
||||
|| e.date() > as_of
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let name = format!(
|
||||
"receipts/{}-entry-{}-{}",
|
||||
e.date(),
|
||||
e.id(),
|
||||
clean(&a.file_name)
|
||||
);
|
||||
let amount: Decimal = e
|
||||
.lines()
|
||||
.iter()
|
||||
.filter(|l| l.side == Side::Debit)
|
||||
.map(|l| l.amount.amount)
|
||||
.sum();
|
||||
index.push_str(&format!(
|
||||
"{},{},\"{}\",{},{},{}\n",
|
||||
e.id(),
|
||||
e.date(),
|
||||
e.description().replace('"', "\"\""),
|
||||
money(amount),
|
||||
name,
|
||||
a.blob_sha
|
||||
));
|
||||
files.push((name, bytes.clone()));
|
||||
receipt_count += 1;
|
||||
}
|
||||
}
|
||||
files.push(("receipts/index.csv".into(), index.into_bytes()));
|
||||
|
||||
@@ -1463,6 +1463,9 @@ pub struct GetEntryOut {
|
||||
pub parties: Vec<EntryPartyOut>,
|
||||
/// Why the shares don't resolve, when they don't.
|
||||
pub parties_error: Option<String>,
|
||||
/// Why it has no counterparty, when it is marked so (a reversal
|
||||
/// carries its original's mark).
|
||||
pub no_party_reason: Option<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1989,6 +1992,12 @@ pub struct DisposeAssetParams {
|
||||
/// sale) to link to the disposal entry on posting. Required when the
|
||||
/// receipt policy applies.
|
||||
pub receipt_attachment_ids: Option<Vec<u64>>,
|
||||
/// The buyer (party id or name), named on the disposal entry. Under
|
||||
/// `[books] parties = "require"` a sale with proceeds needs one; a
|
||||
/// retirement with no proceeds and no buyer is marked as having no
|
||||
/// counterparty.
|
||||
#[serde(default)]
|
||||
pub buyer: Option<String>,
|
||||
/// Confirm the disposal. Without confirmed=true returns a preview and
|
||||
/// posts nothing.
|
||||
pub confirmed: Option<bool>,
|
||||
@@ -2416,6 +2425,10 @@ pub struct RemitPayrollTaxesParams {
|
||||
pub date: String,
|
||||
/// Cash account id or code the remittance comes from.
|
||||
pub cash_account: String,
|
||||
/// The tax agency paid (party id or name, e.g. the IRS), named on the
|
||||
/// remittance entry. Required under `[books] parties = "require"`.
|
||||
#[serde(default)]
|
||||
pub agency: Option<String>,
|
||||
}
|
||||
|
||||
/// Output of `books.remit_payroll_taxes`.
|
||||
@@ -2530,8 +2543,10 @@ pub struct ExportCpaPackageParams {
|
||||
/// `[books] investing_accounts`.
|
||||
#[serde(default)]
|
||||
pub investing_accounts: Vec<String>,
|
||||
/// Include every receipt for the year's entries and the company's
|
||||
/// documents (default true).
|
||||
/// Include every receipt for the year's entries (every current file
|
||||
/// filed on or linked to each posted entry, one copy per entry, each
|
||||
/// with a row in receipts/index.csv) and the company's documents (the
|
||||
/// files behind no entry) (default true).
|
||||
#[serde(default = "default_true")]
|
||||
pub include_receipts: bool,
|
||||
/// Carry every member's and 1099 payee's full TIN from the TIN vault
|
||||
@@ -2986,10 +3001,12 @@ pub struct ListPartiesOut {
|
||||
/// liability (a card, a member paying for it, a loan; not payables or
|
||||
/// payroll withholdings), with cash that pays a liability down counting
|
||||
/// once, as paid to that liability's creditor (a reimbursement to the
|
||||
/// members, a card payment to the issuer), and cash raised by a liability
|
||||
/// counting as received from the lender. Positive is paid to the party,
|
||||
/// negative received from it; a reversal counts against its original's
|
||||
/// side. A transfer between the company's own cash accounts nets to zero.
|
||||
/// members, a card payment to the issuer), a liability paid down by
|
||||
/// another counting the same way (a member paying the card from their own
|
||||
/// funds, a loan paying it off), and cash raised by a liability counting
|
||||
/// as received from the lender. Positive is paid to the party, negative
|
||||
/// received from it; a reversal counts against its root original's side
|
||||
/// (reversing the reversal counts on that side again). A transfer between the company's own cash accounts nets to zero.
|
||||
/// A bill is not paid until its payment is.
|
||||
/// An entry naming several parties counts each one's share of that money
|
||||
/// (its amount, its percent, or its derived share: see
|
||||
@@ -3271,6 +3288,15 @@ pub struct ReconciliationOut {
|
||||
pub voided_reason: Option<String>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.void_statement`.
|
||||
#[derive(Debug, Clone, Deserialize, JsonSchema)]
|
||||
pub struct VoidStatementParams {
|
||||
/// The attachment whose recorded statement to void.
|
||||
pub attachment_id: u64,
|
||||
/// Why (what was recorded wrong), recorded in the journal.
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// Parameters for `books.void_reconciliation`.
|
||||
#[derive(Debug, Clone, Deserialize, JsonSchema)]
|
||||
pub struct VoidReconciliationParams {
|
||||
|
||||
@@ -147,7 +147,7 @@ fn mcp_end_to_end_over_stdio() {
|
||||
// Tool listing: the narrow tools; chart is read-only.
|
||||
let list = mcp.request("tools/list", json!({}));
|
||||
let tools = list["result"]["tools"].as_array().unwrap();
|
||||
assert_eq!(tools.len(), 92, "expected 92 tools, got {}", tools.len());
|
||||
assert_eq!(tools.len(), 93, "expected 93 tools, got {}", tools.len());
|
||||
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
|
||||
assert_eq!(chart["annotations"]["readOnlyHint"], true);
|
||||
assert!(
|
||||
|
||||
@@ -1148,6 +1148,155 @@ fn void_reconciliation_lets_it_be_redone() {
|
||||
assert_ne!(redo["reconciliation"]["reconciliation_id"], json!(rid));
|
||||
}
|
||||
|
||||
/// books.reconcile SCENARIO 1: a statement whose file was withdrawn is
|
||||
/// retired; the preview and the confirmation are refused, and nothing is
|
||||
/// journaled (it used to return an error after journaling
|
||||
/// reconciliation.completed). books.statements no longer lists it.
|
||||
#[test]
|
||||
fn a_withdrawn_statement_is_retired_and_reconciles_nothing() {
|
||||
let mut m = books("withdrawn-statement");
|
||||
let (dep, pay) = january(&mut m);
|
||||
m.clear(dep, 0);
|
||||
m.clear(pay, 1);
|
||||
let st = m.statement("1000", "2026-01-01", "2026-01-31", "800.00");
|
||||
m.ok(
|
||||
"books.unlink_attachment",
|
||||
json!({"attachment_id": st, "subject": {"kind": "account", "id": 1}, "reason": "wrong file"}),
|
||||
);
|
||||
m.ok(
|
||||
"books.withdraw_attachment",
|
||||
json!({"attachment_id": st, "reason": "wrong file"}),
|
||||
);
|
||||
let why = m.refuse("books.reconcile", json!({"statement_attachment": st}));
|
||||
assert!(why.contains("withdrawn"), "{why}");
|
||||
m.refuse(
|
||||
"books.reconcile",
|
||||
json!({"statement_attachment": st, "confirmed": true}),
|
||||
);
|
||||
assert_eq!(m.count("reconciliation.completed"), 0);
|
||||
assert!(
|
||||
m.read_only("books.reconciliations", json!({}))["reconciliations"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
m.read_only("books.statements", json!({}))["statements"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(m.ok("books.verify", json!({}))["status"], "ok");
|
||||
}
|
||||
|
||||
/// books.void_statement SCENARIOS 1–2: a statement recorded with the wrong
|
||||
/// closing balance is voided and recorded again on the same file, then
|
||||
/// reconciles; voiding it while that reconciliation stands is refused.
|
||||
#[test]
|
||||
fn a_mis_recorded_statement_is_voided_and_recorded_again() {
|
||||
let mut m = books("void-statement");
|
||||
let (dep, pay) = january(&mut m);
|
||||
m.clear(dep, 0);
|
||||
m.clear(pay, 1);
|
||||
let st = m.statement("1000", "2026-01-01", "2026-01-31", "880.00");
|
||||
m.refuse(
|
||||
"books.void_statement",
|
||||
json!({"attachment_id": st, "reason": " "}),
|
||||
);
|
||||
let v = m.ok(
|
||||
"books.void_statement",
|
||||
json!({"attachment_id": st, "reason": "closing balance mistyped"}),
|
||||
);
|
||||
assert_eq!(v["closing_balance"], "880.00");
|
||||
assert_eq!(m.count("statement.voided"), 1);
|
||||
m.refuse(
|
||||
"books.void_statement",
|
||||
json!({"attachment_id": st, "reason": "again"}),
|
||||
);
|
||||
assert!(
|
||||
m.read_only("books.statements", json!({}))["statements"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
m.ok(
|
||||
"books.record_statement",
|
||||
json!({"attachment_id": st, "account": "1000", "period_start": "2026-01-01",
|
||||
"period_end": "2026-01-31", "opening_balance": "0.00", "closing_balance": "800.00"}),
|
||||
);
|
||||
m.ok(
|
||||
"books.reconcile",
|
||||
json!({"statement_attachment": st, "confirmed": true}),
|
||||
);
|
||||
m.refuse(
|
||||
"books.void_statement",
|
||||
json!({"attachment_id": st, "reason": "why"}),
|
||||
);
|
||||
// A line the reconciliation cleared stays cleared while it stands.
|
||||
m.refuse(
|
||||
"books.mark_cleared",
|
||||
json!({"entry_id": pay, "line_index": 1, "cleared": false}),
|
||||
);
|
||||
assert_eq!(m.ok("books.verify", json!({}))["status"], "ok");
|
||||
}
|
||||
|
||||
/// books.export_cpa_package `include_receipts`: receipts/ holds every
|
||||
/// current file behind each posted entry, filed on it or linked to it (a
|
||||
/// linked file counts as the entry's receipt, so the receipt policy let
|
||||
/// the entry post), one copy per entry with an index row; documents/ holds
|
||||
/// the files behind no entry. A linked-only receipt used to be left out
|
||||
/// of the package entirely.
|
||||
#[test]
|
||||
fn the_cpa_package_carries_receipts_linked_to_entries() {
|
||||
use base64::Engine as _;
|
||||
let mut m = books_at("cpa-linked", "0.00", "");
|
||||
let receipt = m.receipt(None, b"%PDF linked receipt");
|
||||
let e = m.draft(
|
||||
"2026-03-01",
|
||||
json!({}),
|
||||
&[("6999", "debit", "40.00"), ("1000", "credit", "40.00")],
|
||||
);
|
||||
m.link(receipt, json!({"kind": "entry", "id": e}));
|
||||
// A contract linked to the same entry is evidence for it too.
|
||||
m.doc(
|
||||
"contract",
|
||||
b"%PDF contract",
|
||||
json!([{"kind": "entry", "id": e}]),
|
||||
);
|
||||
m.ok(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": e, "confirmed": true}),
|
||||
);
|
||||
let pkg = m.ok(
|
||||
"books.export_cpa_package",
|
||||
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], "include_tins": false}),
|
||||
);
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(pkg["content_base64"].as_str().unwrap())
|
||||
.unwrap();
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let names: Vec<String> = (0..zip.len())
|
||||
.map(|i| zip.by_index(i).unwrap().name().unwrap().to_string())
|
||||
.collect();
|
||||
let mut index = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("receipts/index.csv").unwrap(), &mut index)
|
||||
.unwrap();
|
||||
let rows: Vec<&str> = index.lines().skip(1).collect();
|
||||
let for_e: Vec<&&str> = rows
|
||||
.iter()
|
||||
.filter(|r| r.starts_with(&format!("{e},")))
|
||||
.collect();
|
||||
assert_eq!(for_e.len(), 2, "the linked receipt and contract: {index}");
|
||||
for r in for_e {
|
||||
let file = r.split(',').nth(4).unwrap();
|
||||
assert!(names.iter().any(|n| n == file), "{file} in {names:?}");
|
||||
}
|
||||
assert!(
|
||||
!names.iter().any(|n| n.starts_with("documents/contract")),
|
||||
"the contract is behind an entry, filed with it: {names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------- import sources
|
||||
|
||||
#[test]
|
||||
@@ -1243,9 +1392,12 @@ fn unreconciled(m: &mut Mcp) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Cash accounts (`[books] cash_accounts`) and card accounts with
|
||||
/// statements are listed until reconciled through the end of last month;
|
||||
/// accounts without activity by then aren't.
|
||||
/// Cash accounts (`[books] cash_accounts`), card accounts (liabilities
|
||||
/// named "card", before their first statement, so the first
|
||||
/// reconciliation is asked for), and any other liability once it has a
|
||||
/// statement are listed until reconciled through the end of last month;
|
||||
/// accounts without activity by then aren't (ledger_store::reconcilable,
|
||||
/// the viewer's rule too).
|
||||
#[test]
|
||||
fn the_digest_lists_accounts_not_reconciled_through_last_month() {
|
||||
let mut m = books("digest");
|
||||
@@ -1263,10 +1415,15 @@ fn the_digest_lists_accounts_not_reconciled_through_last_month() {
|
||||
json!({}),
|
||||
&[("6999", "debit", "40.00"), ("2050", "credit", "40.00")],
|
||||
);
|
||||
let owed = m.post(
|
||||
&day,
|
||||
json!({}),
|
||||
&[("6999", "debit", "15.00"), ("2100", "credit", "15.00")],
|
||||
);
|
||||
assert_eq!(
|
||||
unreconciled(&mut m),
|
||||
["1000"],
|
||||
"the card has no statement yet"
|
||||
["1000", "2050"],
|
||||
"the card is due before its first statement; 2100 isn't a card"
|
||||
);
|
||||
let d = m.read_only("books.digest", json!({"since": "2020-01-01"}));
|
||||
let row = &d["unreconciled_accounts"][0];
|
||||
@@ -1278,15 +1435,21 @@ fn the_digest_lists_accounts_not_reconciled_through_last_month() {
|
||||
.unwrap()
|
||||
.contains("books.record_statement")
|
||||
);
|
||||
// The card's statement makes it a card account to reconcile.
|
||||
let card_st = m.statement("2050", &start, &day, "40.00");
|
||||
assert_eq!(unreconciled(&mut m), ["1000", "2050"]);
|
||||
m.clear(card, 1);
|
||||
m.ok(
|
||||
"books.reconcile",
|
||||
json!({"statement_attachment": card_st, "confirmed": true}),
|
||||
);
|
||||
assert_eq!(unreconciled(&mut m), ["1000"]);
|
||||
// A liability not named for a card joins with its first statement.
|
||||
let owed_st = m.statement("2100", &start, &day, "15.00");
|
||||
assert_eq!(unreconciled(&mut m), ["1000", "2100"]);
|
||||
m.clear(owed, 1);
|
||||
m.ok(
|
||||
"books.reconcile",
|
||||
json!({"statement_attachment": owed_st, "confirmed": true}),
|
||||
);
|
||||
m.clear(dep, 0);
|
||||
let st = m.statement("1000", &start, &day, "100.00");
|
||||
m.ok(
|
||||
|
||||
@@ -997,6 +997,11 @@ fn happy_path(name: &str) -> Mcp {
|
||||
"books.void_reconciliation",
|
||||
json!({"reconciliation_id": rec, "reason": "redo after the March import"}),
|
||||
);
|
||||
// Voided, its statement can be voided too, to record it again.
|
||||
m.ok(
|
||||
"books.void_statement",
|
||||
json!({"attachment_id": stmt, "reason": "closing balance mistyped"}),
|
||||
);
|
||||
|
||||
// Periods.
|
||||
let p = m.ok(
|
||||
|
||||
@@ -1383,7 +1383,8 @@ fn a_joint_reimbursement_names_both_members_and_splits_by_share() {
|
||||
"books.post_entry",
|
||||
json!({"entry_id": id, "confirmed": true}),
|
||||
);
|
||||
assert_eq!(m.count("entry.parties_set"), 1);
|
||||
// Named derived on the draft, then fixed at the amounts it posted at.
|
||||
assert_eq!(m.count("entry.parties_set"), 2);
|
||||
for who in ["Tobin Harlow", "Morgan Ellis"] {
|
||||
let t = m.party(who, 2026)["totals"].clone();
|
||||
assert_eq!(t["paid"], "50.00", "{who}'s half");
|
||||
@@ -1563,3 +1564,155 @@ fn under_require_several_parties_satisfy_the_policy() {
|
||||
);
|
||||
m.read_only("books.verify", json!({}));
|
||||
}
|
||||
|
||||
/// docs/parties-and-attachments.md "Counterparties" (require): the
|
||||
/// engine's own posting paths name their counterparty or are refused
|
||||
/// before anything posts. books.run_payroll names the employee party on
|
||||
/// the entry (so their books.party counts the net pay); a line by name
|
||||
/// only is refused. books.remit_payroll_taxes needs `agency`;
|
||||
/// books.dispose_asset needs `buyer` for a sale, and marks a retirement
|
||||
/// with no proceeds as having none.
|
||||
#[test]
|
||||
fn under_require_engine_postings_name_their_counterparty() {
|
||||
let mut m = books_with("require-engine", "parties = \"require\"\n");
|
||||
m.accounts(&[
|
||||
("1500", "Equipment", "asset"),
|
||||
("1510", "Accumulated Depreciation", "asset"),
|
||||
("6500", "Depreciation", "expense"),
|
||||
("7100", "Gain or Loss on Disposal", "revenue"),
|
||||
]);
|
||||
let pat = m.add_party("Pat Doe", &["employee"]);
|
||||
let irs = m.add_party("IRS", &["tax_agency"]);
|
||||
let buyer = m.add_party("Buyer Co", &[]);
|
||||
let mut by_name = pat_line("Pat Doe");
|
||||
by_name.as_object_mut().unwrap().remove("employee");
|
||||
by_name["employee_name"] = json!("Pat Doe");
|
||||
let e = m.refuse(
|
||||
"books.run_payroll",
|
||||
json!({"date": "2026-02-15", "cash_account": "1000", "lines": [by_name]}),
|
||||
);
|
||||
assert!(e.contains("party_required"), "{e}");
|
||||
let run = m.ok(
|
||||
"books.run_payroll",
|
||||
json!({"date": "2026-02-15", "cash_account": "1000", "lines": [pat_line("Pat Doe")]}),
|
||||
);
|
||||
let entry = m.read_only("books.get_entry", json!({"entry_id": run["entry_id"]}));
|
||||
assert_eq!(entry["parties"][0]["party_id"], json!(pat), "{entry}");
|
||||
assert_eq!(m.party("Pat Doe", 2026)["totals"]["paid"], "773.50");
|
||||
let e = m.refuse(
|
||||
"books.remit_payroll_taxes",
|
||||
json!({"date": "2026-03-01", "cash_account": "1000"}),
|
||||
);
|
||||
assert!(e.contains("party_required"), "{e}");
|
||||
let r = m.ok(
|
||||
"books.remit_payroll_taxes",
|
||||
json!({"date": "2026-03-01", "cash_account": "1000", "agency": "IRS"}),
|
||||
);
|
||||
let entry = m.read_only("books.get_entry", json!({"entry_id": r["entry_id"]}));
|
||||
assert_eq!(entry["parties"][0]["party_id"], json!(irs), "{entry}");
|
||||
// Assets: one sold, one retired.
|
||||
let reg = |m: &mut Mcp, tag: &str| {
|
||||
m.ok(
|
||||
"books.register_asset",
|
||||
json!({"description": "Laptop", "purchase_date": "2026-01-10", "cost": "1200.00",
|
||||
"salvage_value": "0.00", "useful_life_months": 36, "cost_account": "1500",
|
||||
"accum_depr_account": "1510", "expense_account": "6500", "tag": tag,
|
||||
"tax_basis_rule": "cost"}),
|
||||
)["asset_id"]
|
||||
.as_u64()
|
||||
.unwrap()
|
||||
};
|
||||
let (sold, retired) = (reg(&mut m, "LAP-1"), reg(&mut m, "LAP-2"));
|
||||
let sale = json!({"asset_id": sold, "date": "2026-06-30", "proceeds": "500.00",
|
||||
"proceeds_account": "1000", "gain_loss_account": "7100",
|
||||
"confirmed": true});
|
||||
let before = m.journal();
|
||||
let e = m.refuse("books.dispose_asset", sale.clone());
|
||||
assert!(e.contains("party_required"), "{e}");
|
||||
assert!(m.journal() == before, "refused, nothing journaled");
|
||||
let mut with_buyer = sale;
|
||||
with_buyer["buyer"] = json!("Buyer Co");
|
||||
let d = m.ok("books.dispose_asset", with_buyer);
|
||||
let entry = m.read_only("books.get_entry", json!({"entry_id": d["entry_id"]}));
|
||||
assert_eq!(entry["parties"][0]["party_id"], json!(buyer), "{entry}");
|
||||
let d = m.ok(
|
||||
"books.dispose_asset",
|
||||
json!({"asset_id": retired, "date": "2026-06-30", "proceeds": "0.00",
|
||||
"proceeds_account": "1000", "gain_loss_account": "7100", "confirmed": true}),
|
||||
);
|
||||
let entry = m.read_only("books.get_entry", json!({"entry_id": d["entry_id"]}));
|
||||
assert!(entry["no_party_reason"].is_string(), "{entry}");
|
||||
assert_eq!(m.ok("books.verify", json!({}))["status"], "ok");
|
||||
}
|
||||
|
||||
/// fix_derived_shares INVARIANT 1 (docs/parties-and-attachments.md
|
||||
/// "Shares": posted is forever, the split included): a joint
|
||||
/// reimbursement posted with derived shares at 50/50 keeps 50.00 each when
|
||||
/// the members' shares later become 60/40, and when a member is archived;
|
||||
/// a draft still follows the settings until it posts.
|
||||
#[test]
|
||||
fn a_posted_derived_split_survives_settings_changes_and_archiving() {
|
||||
let mut m = member_books("shares-fixed", "");
|
||||
let posted = m.post(
|
||||
"2026-03-01",
|
||||
both(),
|
||||
&[("2100", "debit", "100.00"), ("1000", "credit", "100.00")],
|
||||
);
|
||||
let e = m.read_only("books.get_entry", json!({"entry_id": posted}));
|
||||
assert_eq!(
|
||||
resolved(&e),
|
||||
[
|
||||
("Tobin Harlow".to_string(), "50.00".to_string()),
|
||||
("Morgan Ellis".to_string(), "50.00".to_string())
|
||||
]
|
||||
);
|
||||
assert_eq!(e["parties"][0]["share"], "amount", "fixed when posted: {e}");
|
||||
assert_eq!(e["parties"][0]["amount"], "50.00", "{e}");
|
||||
let draft = m.draft(
|
||||
"2026-03-02",
|
||||
both(),
|
||||
&[("2100", "debit", "100.00"), ("1000", "credit", "100.00")],
|
||||
);
|
||||
// The members' split changes to 60/40.
|
||||
let toml = m.data.parent().unwrap().join("wanderbooks.toml");
|
||||
let text = std::fs::read_to_string(&toml).unwrap();
|
||||
let changed = text
|
||||
.replacen("share = \"50%\"", "share = \"60%\"", 1)
|
||||
.replacen("share = \"50%\"", "share = \"40%\"", 1);
|
||||
std::fs::write(&toml, changed).unwrap();
|
||||
// The server reloads it on change: wait until the draft reads 60/40.
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
let d = loop {
|
||||
let d = m.ok("books.post_entry", json!({"entry_id": draft}));
|
||||
if resolved(&d)[0].1 == "60.00" || std::time::Instant::now() > deadline {
|
||||
break d;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
};
|
||||
assert_eq!(
|
||||
resolved(&d),
|
||||
[
|
||||
("Tobin Harlow".to_string(), "60.00".to_string()),
|
||||
("Morgan Ellis".to_string(), "40.00".to_string())
|
||||
],
|
||||
"a draft follows the settings"
|
||||
);
|
||||
let e = m.read_only("books.get_entry", json!({"entry_id": posted}));
|
||||
assert_eq!(
|
||||
resolved(&e),
|
||||
[
|
||||
("Tobin Harlow".to_string(), "50.00".to_string()),
|
||||
("Morgan Ellis".to_string(), "50.00".to_string())
|
||||
],
|
||||
"posted history keeps its split"
|
||||
);
|
||||
// Morgan archived: her history stays, and Tobin's.
|
||||
let mo = e["parties"][1]["party_id"].as_u64().unwrap();
|
||||
m.ok(
|
||||
"books.update_party",
|
||||
json!({"party": mo.to_string(), "archived": true}),
|
||||
);
|
||||
assert_eq!(m.party("Tobin Harlow", 2026)["totals"]["paid"], "50.00");
|
||||
assert_eq!(m.party(&mo.to_string(), 2026)["totals"]["paid"], "50.00");
|
||||
assert_eq!(m.ok("books.verify", json!({}))["status"], "ok");
|
||||
}
|
||||
Reference in new issue
Block a user