P3: party totals by what moved the money, once (defects 14, 11, 24)
Each party's part of an entry is signed as its own account would show it, with a direction and its cash and in-kind amounts; a reversal is its original negated. Totals on a cash or all basis: for a member, all equals the members' equity statement and cash the K-1's cash. Money is cash, cards and the financing accounts, and a liability credit never pays its own creditor (creditor_of), so accruals and declared distributions count once. tin_last4 from the TIN or W-9 facts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
52 files changed
+7535
-560
No files matched your search
@@ -4,8 +4,12 @@
|
||||
- Tag every entry with its party (books.draft_entry party, or books.set_entry_party; a party with a vendor record tags the vendor for 1099s too).
|
||||
- An entry with several counterparties names them all with parties ([{party, amount?|percent?}] on books.draft_entry or books.set_entry_party): there is no "joint" party. A joint member transaction (paid from or reimbursed to the members' joint funds through a payable to members, a contribution from joint funds, a distribution to both) names both members with no shares, and their shares derive (from their capital or distribution lines on the entry, else their member shares in the settings); when the split is uneven, or a party isn't a member, give each party an exact amount (or a percent).
|
||||
- The post_entry preview lists each party's resolved share (parties) and refuses to post (party_shares, parties_error) until the shares add up to what the entry moved; show the split to the human before confirming.
|
||||
- A share's resolved amount is signed as the party's own account would show it: positive, the company paid it or gave it value; negative, received from it. Each part says its direction (paid, received, or neither: a bill, an invoice, a fee on its own card, a distribution still owed) and what of it was money (cash) and what property (in_kind); a reversal is exactly the opposite of what it reverses. A members' entry that moves their capital counts what moved it: a property contribution is received, in kind.
|
||||
- Party totals (books.party, books.parties) take a basis: cash, the default, counts money; all adds property contributed or distributed in kind (for a member, the statement of members' equity). Say which basis you report. It is not the books' accounting method.
|
||||
- Money is the cash accounts, the cards (a card account's name says "card"; a gift card the company sold is deferred revenue, not a card) and the financing accounts setting ([books] financing_accounts: what the members advanced, loans): list the members' payable and every loan there, and any card whose account name doesn't say "card". Any other liability (an accrual, deferred revenue, distributions payable) counts once, when it is settled.
|
||||
- Name whose each card, loan and advance is: books.update_party creditor_of, the issuer for its card, the lender for its loan, each member for what the members advanced. Then a fee on the card, interest on the loan, or a distribution credited to what the members are owed never reads as a payment to them; it counts when paid. A distribution declared to distributions payable counts when it is paid, too. Record a reimbursement and a distribution as two entries, and a member's cash either as a loan (through the members' financing, tagged with the payee it paid) or as capital, not both.
|
||||
- Every post_entry preview says where the entry stands (party_policy, party_missing, party_suggestions): under the `[books] parties` setting "suggest" (the default), when party_missing, name the likely party to the human and tag it (or add it) before confirming; under "require", posting is refused (party_required) until the entry names a party or is marked as having none (books.set_entry_party with party null and no_party_reason, or draft_entry no_party_reason: only for an entry with truly no counterparty, like a transfer between the company's own accounts); under "off", don't raise it.
|
||||
- Record W-9 facts (books.set_w9) for contractors and landlords before paying them, and W-4 facts (books.set_w4) for employees before their first payroll; name the employee party on every books.run_payroll line.
|
||||
- Never put a full TIN or SSN anywhere in the books, only the last four digits: the engine refuses any nine-digit number in a party field. The full TIN goes only to the encrypted TIN vault, and every member's and every 1099 payee's belongs there: books.set_tin (party, tin, tin_type: ssn, ein, itin; any role, so each member's SSN for their K-1) and books.set_w9 with tin and tin_type for a payee's W-9, and the signed form with w9_upload_id (books.begin_upload purpose w9), or w9_content_base64 with w9_sha256 for one of at most 256 KiB (never books.file_document or books.attach_receipt: the form shows the TIN).
|
||||
- A full TIN lives in the vault: keep it out of descriptions, notes, file names and version-history messages. books.get_w9 hands the signed form over (run its download command to save it for the human); books.purge_tin (preview, then confirmed, with a reason) deletes a TIN no longer needed.
|
||||
- books.parties lists the directory, books.party shows one party with the year's totals, and books.show party opens it in the viewer. Archive (books.update_party archived) parties no longer dealt with; never delete history.
|
||||
- books.parties lists the directory (each party's TIN last four from its TIN or W-9 facts), books.party shows one party with the year's totals, and books.show party opens it in the viewer. Archive (books.update_party archived) parties no longer dealt with; never delete history.
|
||||
+103
-49
@@ -2026,7 +2026,7 @@ fn party_roles(p: &ledger_core::Party) -> Vec<String> {
|
||||
}
|
||||
|
||||
/// A party's facts for a tool's output.
|
||||
fn party_out(p: &ledger_core::Party) -> PartyOut {
|
||||
fn party_out(ledger: &Ledger, p: &ledger_core::Party) -> PartyOut {
|
||||
PartyOut {
|
||||
party_id: p.id,
|
||||
name: p.name.clone(),
|
||||
@@ -2059,6 +2059,11 @@ fn party_out(p: &ledger_core::Party) -> PartyOut {
|
||||
in_vault: t.vault_record.is_some(),
|
||||
purged: t.purged.clone(),
|
||||
}),
|
||||
creditor_of: p
|
||||
.creditor_of
|
||||
.iter()
|
||||
.filter_map(|a| ledger.chart().get(*a).map(|a| a.code.clone()))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2122,34 +2127,45 @@ fn checked_sum(
|
||||
struct PartyActivity {
|
||||
/// The party's entries, drafts included.
|
||||
entries: std::collections::BTreeSet<u64>,
|
||||
/// Paid to it in the year.
|
||||
/// Paid to it in the year, on the basis asked.
|
||||
paid: Decimal,
|
||||
/// Received from it in the year.
|
||||
/// Received from it in the year, on the basis asked.
|
||||
received: Decimal,
|
||||
}
|
||||
|
||||
/// The party's entries and the year's money paid and received
|
||||
/// A party-totals `basis` from a tool: "cash" (the default) or "all"
|
||||
/// (rules.md B-35 item 4); any other word is refused.
|
||||
fn parse_basis(basis: Option<&str>) -> Result<ledger_core::parties::Basis, String> {
|
||||
match basis {
|
||||
None => Ok(ledger_core::parties::Basis::default()),
|
||||
Some(b) => ledger_core::parties::Basis::parse(b)
|
||||
.ok_or_else(|| format!("basis is \"cash\" or \"all\", not {b:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The party's entries and the year's paid and received on `basis`
|
||||
/// (`PartyTotalsOut`): ledger_core::parties::activity_with over the
|
||||
/// calendar year, with the `[books] cash_accounts` and the members (so an
|
||||
/// entry naming several parties counts this one's share).
|
||||
/// calendar year, with the money accounts of [`share_context`] and the
|
||||
/// members (so an entry naming several parties counts this one's part).
|
||||
fn party_activity(
|
||||
store: &Store,
|
||||
party: &ledger_core::Party,
|
||||
settings: &Settings,
|
||||
year: i32,
|
||||
basis: ledger_core::parties::Basis,
|
||||
) -> Result<PartyActivity, String> {
|
||||
let ledger = store.ledger();
|
||||
let cash = cash_ids(ledger, &settings.roles.cash);
|
||||
let span = (
|
||||
NaiveDate::from_ymd_opt(year, 1, 1).ok_or("year out of range")?,
|
||||
NaiveDate::from_ymd_opt(year, 12, 31).ok_or("year out of range")?,
|
||||
);
|
||||
let members = settings.company.party_members(ledger);
|
||||
let a = ledger_core::parties::activity_with(ledger, party.id, span, &cash, &members);
|
||||
let (money, members) = share_context(ledger, settings);
|
||||
let a = ledger_core::parties::activity_with(ledger, party.id, span, &money, &members);
|
||||
let (paid, received) = a.totals(basis);
|
||||
Ok(PartyActivity {
|
||||
entries: a.entries,
|
||||
paid: a.paid,
|
||||
received: a.received,
|
||||
paid,
|
||||
received,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2175,7 +2191,8 @@ fn cash_ids(ledger: &Ledger, codes: &[String]) -> Vec<AccountId> {
|
||||
}
|
||||
|
||||
/// What party shares resolve with ([`ledger_core::shares::resolve`]): the
|
||||
/// money accounts over the `[books] cash_accounts`, and the members.
|
||||
/// money accounts over the `[books] cash_accounts` and
|
||||
/// `financing_accounts` (with the cards), and the members.
|
||||
fn share_context(
|
||||
ledger: &Ledger,
|
||||
settings: &Settings,
|
||||
@@ -2184,7 +2201,11 @@ fn share_context(
|
||||
Vec<ledger_core::PartyMember>,
|
||||
) {
|
||||
(
|
||||
ledger_core::parties::money_accounts(ledger, &cash_ids(ledger, &settings.roles.cash)),
|
||||
ledger_core::parties::money_accounts(
|
||||
ledger,
|
||||
&cash_ids(ledger, &settings.roles.cash),
|
||||
&cash_ids(ledger, &settings.roles.financing),
|
||||
),
|
||||
settings.company.party_members(ledger),
|
||||
)
|
||||
}
|
||||
@@ -2252,7 +2273,7 @@ fn check_shares_resolve(
|
||||
return Ok(());
|
||||
}
|
||||
let (money, members) = share_context(ledger, settings);
|
||||
ledger_core::shares::resolve(entry, shares, &money, &members).map(|_| ())
|
||||
ledger_core::shares::resolve(ledger, entry_id, shares, &money, &members).map(|_| ())
|
||||
}
|
||||
|
||||
/// Fix a posted entry's derived split among several parties: journal the
|
||||
@@ -2289,17 +2310,19 @@ fn fix_derived_shares(store: &mut Store, settings: &Settings, entry_id: u64) ->
|
||||
return Ok(());
|
||||
}
|
||||
let (money, members) = share_context(ledger, settings);
|
||||
let Ok(parts) = ledger_core::shares::resolve(entry, shares, &money, &members) else {
|
||||
let Ok(resolved) = ledger_core::shares::resolve(ledger, entry_id, shares, &money, &members)
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
if parts.iter().any(|(_, v)| v.is_zero()) {
|
||||
if resolved.parts.iter().any(|p| p.amount.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()),
|
||||
let fixed: Vec<ledger_core::PartyShare> = resolved
|
||||
.parts
|
||||
.iter()
|
||||
.map(|p| ledger_core::PartyShare {
|
||||
party: p.party,
|
||||
share: ledger_core::Share::Amount(p.amount.abs()),
|
||||
})
|
||||
.collect();
|
||||
store
|
||||
@@ -2317,10 +2340,11 @@ fn entry_parties_out(
|
||||
let ledger = store.ledger();
|
||||
let shares = ledger.entry_parties(entry.id());
|
||||
let (accounts, members) = share_context(ledger, settings);
|
||||
let (resolved, error) = match ledger_core::shares::resolve(entry, shares, &accounts, &members) {
|
||||
Ok(r) => (Some(r), None),
|
||||
Err(e) => (None, Some(e)),
|
||||
};
|
||||
let (resolved, error) =
|
||||
match ledger_core::shares::resolve(ledger, entry.id(), shares, &accounts, &members) {
|
||||
Ok(r) => (Some(r), None),
|
||||
Err(e) => (None, Some(e)),
|
||||
};
|
||||
let out = shares
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -2341,7 +2365,12 @@ fn entry_parties_out(
|
||||
share: kind.to_string(),
|
||||
amount,
|
||||
percent,
|
||||
resolved: resolved.as_ref().map(|r| money(r[i].1)),
|
||||
resolved: resolved.as_ref().map(|r| money(r.parts[i].amount)),
|
||||
direction: resolved
|
||||
.as_ref()
|
||||
.map(|r| r.target.direction.key().to_string()),
|
||||
cash: resolved.as_ref().map(|r| money(r.parts[i].cash)),
|
||||
in_kind: resolved.as_ref().map(|r| money(r.parts[i].in_kind)),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -6906,13 +6935,13 @@ impl BooksServer {
|
||||
.add_party(&p.name, &roles)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let party = store.ledger().get_party(id).expect("just added");
|
||||
Ok(Json(party_out(party)))
|
||||
Ok(Json(party_out(store.ledger(), party)))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.update_party",
|
||||
title = "Update a party",
|
||||
description = "Change a party's facts: name, display_name, website, address, notes, archived. Give only the fields to change; an empty string clears display_name, website, address, or notes. Every field is checked before any is written, so a bad one (a blank or taken name, a full TIN, over 500 characters) changes nothing; each is journaled as its own party.updated. Archive a party you no longer deal with (its history stays; it then goes by id). Returns the party.",
|
||||
description = "Change a party's facts: name, display_name, website, address, notes, archived, creditor_of. Give only the fields to change; \"\" clears display_name, website, address or notes. creditor_of replaces the list of liabilities the company owes the party itself (its card, its loan, what the members advanced): a credit to one never counts as paying it. Every field is checked before any is written, so a bad one (a blank or taken name, a full TIN, over 500 characters, not a liability) changes nothing; each is journaled as party.updated or party.creditor_set. Archive a party you no longer deal with (its history stays; it then goes by id). Returns the party.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
@@ -6946,24 +6975,46 @@ impl BooksServer {
|
||||
if let Some(a) = p.archived {
|
||||
fields.push(F::Archived(a));
|
||||
}
|
||||
if fields.is_empty() {
|
||||
// creditor_of replaces the list: each account added or dropped is
|
||||
// one change.
|
||||
let mut creditor: Vec<(AccountId, bool)> = Vec::new();
|
||||
if let Some(codes) = &p.creditor_of {
|
||||
let want: std::collections::BTreeSet<AccountId> = codes
|
||||
.iter()
|
||||
.map(|c| resolve_account(&store, c.trim()))
|
||||
.collect::<Result<_, _>>()?;
|
||||
let has = &store.ledger().get_party(id).expect("resolved").creditor_of;
|
||||
creditor.extend(want.difference(has).map(|a| (*a, true)));
|
||||
creditor.extend(has.difference(&want).map(|a| (*a, false)));
|
||||
}
|
||||
if fields.is_empty() && p.creditor_of.is_none() {
|
||||
return Err(
|
||||
"give at least one field to change: name, display_name, website, address, notes, archived"
|
||||
"give at least one field to change: name, display_name, website, address, notes, archived, creditor_of"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
// Every field, in order, before any is written (INVARIANT 2).
|
||||
// Every change, in order, before any is written (INVARIANT 2).
|
||||
let mut rehearsal = store.ledger().clone();
|
||||
for f in &fields {
|
||||
rehearsal
|
||||
.update_party(id, f.clone())
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
for (a, on) in &creditor {
|
||||
rehearsal
|
||||
.set_party_creditor(id, *a, *on)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
for f in fields {
|
||||
store.update_party(id, f).map_err(|e| e.to_string())?;
|
||||
}
|
||||
for (a, on) in creditor {
|
||||
store
|
||||
.set_party_creditor(id, a, on)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
let party = store.ledger().get_party(id).expect("resolved");
|
||||
Ok(Json(party_out(party)))
|
||||
Ok(Json(party_out(store.ledger(), party)))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
@@ -7030,7 +7081,7 @@ impl BooksServer {
|
||||
store.set_party_role(id, r, on).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let party = store.ledger().get_party(id).expect("resolved");
|
||||
Ok(Json(party_out(party)))
|
||||
Ok(Json(party_out(store.ledger(), party)))
|
||||
}
|
||||
|
||||
/// ```text
|
||||
@@ -7272,7 +7323,7 @@ impl BooksServer {
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
let party = store.ledger().get_party(id).expect("resolved");
|
||||
Ok(Json(party_out(party)))
|
||||
Ok(Json(party_out(store.ledger(), party)))
|
||||
}
|
||||
|
||||
/// ```text
|
||||
@@ -7359,7 +7410,7 @@ impl BooksServer {
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
let party = store.ledger().get_party(id).expect("resolved");
|
||||
Ok(Json(party_out(party)))
|
||||
Ok(Json(party_out(store.ledger(), party)))
|
||||
}
|
||||
|
||||
/// ```text
|
||||
@@ -7494,11 +7545,7 @@ impl BooksServer {
|
||||
name: party.name.clone(),
|
||||
tin_in_vault: held_tin,
|
||||
w9_file_in_vault: held_w9,
|
||||
tin_last4: party
|
||||
.w9
|
||||
.as_ref()
|
||||
.map(|f| f.tin_last4.clone())
|
||||
.or_else(|| party.tin.as_ref().map(|t| t.last4.clone())),
|
||||
tin_last4: party.tin_last4().map(str::to_string),
|
||||
journaled: false,
|
||||
note: format!(
|
||||
"Confirming deletes {}{}; backups made before keep their copies. Make sure the CPA has what they need first.",
|
||||
@@ -7578,14 +7625,14 @@ impl BooksServer {
|
||||
let id = resolve_party(&store, &p.party)?;
|
||||
store.set_party_w4(id, facts).map_err(|e| e.to_string())?;
|
||||
let party = store.ledger().get_party(id).expect("resolved");
|
||||
Ok(Json(party_out(party)))
|
||||
Ok(Json(party_out(store.ledger(), party)))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.parties",
|
||||
title = "List parties",
|
||||
description = described(
|
||||
"The party directory, by id: each party's name, label, roles (vendor and customer included), vendor and customer record ids, whether W-9 facts (with the TIN's last four) and W-4 facts are on file, archived, and what was paid to and received from it in the year (defined as in books.party). Filter by role; archived parties only with include_archived.",
|
||||
"The party directory, by id: each party's name, label, roles (vendor and customer included), vendor and customer record ids, whether W-9 and W-4 facts are on file, the TIN's last four when the books know it (TIN or W-9 facts), archived, and what was paid to and received from it in the year on the basis asked: cash by default (money), or all (adds property contributed or distributed in kind); defined as in books.party, the basis echoed. Filter by role; archived parties only with include_archived.",
|
||||
ledger_core::calendar::PeriodKind::CalendarYear
|
||||
),
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
@@ -7601,6 +7648,7 @@ impl BooksServer {
|
||||
.map(|r| parse_roles(&[r.to_string()]).map(|v| v[0]))
|
||||
.transpose()?;
|
||||
let year = p.year.unwrap_or_else(|| self.periods().calendar_year());
|
||||
let basis = parse_basis(p.basis.as_deref())?;
|
||||
let settings = self.settings();
|
||||
let store = self.lock()?;
|
||||
let mut parties = Vec::new();
|
||||
@@ -7611,7 +7659,7 @@ impl BooksServer {
|
||||
if role.is_some_and(|r| !party.all_roles().contains(&r)) {
|
||||
continue;
|
||||
}
|
||||
let act = party_activity(&store, party, &settings, year)?;
|
||||
let act = party_activity(&store, party, &settings, year, basis)?;
|
||||
parties.push(PartySummaryOut {
|
||||
party_id: party.id,
|
||||
name: party.name.clone(),
|
||||
@@ -7620,21 +7668,25 @@ impl BooksServer {
|
||||
vendor_id: party.vendor,
|
||||
customer_id: party.customer,
|
||||
w9_on_file: party.w9.is_some(),
|
||||
tin_last4: party.w9.as_ref().map(|w| w.tin_last4.clone()),
|
||||
tin_last4: party.tin_last4().map(str::to_string),
|
||||
w4_on_file: party.w4.is_some(),
|
||||
archived: party.archived,
|
||||
paid: money(act.paid),
|
||||
received: money(act.received),
|
||||
});
|
||||
}
|
||||
Ok(Json(ListPartiesOut { year, parties }))
|
||||
Ok(Json(ListPartiesOut {
|
||||
year,
|
||||
basis: basis.key().to_string(),
|
||||
parties,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.party",
|
||||
title = "Get a party",
|
||||
description = described(
|
||||
"One party, by id or name: its facts and roles, vendor and customer record ids, the year's totals (paid to it, received from it, invoiced, billed, the 1099 amount for a vendor, payroll gross and net), what it is owed and owes now (open bills and invoices), every entry it is on (tagged with it or its vendor record, its invoices and their payments, and their reversals), its invoices and bills, and the payroll lines naming it. Totals come from the journal: the output's schema says exactly how.",
|
||||
"One party, by id or name: its facts and roles, vendor and customer record ids, the year's totals (paid to it and received from it on the basis asked, cash by default or all with property in kind, the basis echoed; invoiced, billed, the 1099 amount for a vendor, payroll gross and net), what it is owed and owes now (open bills and invoices), every entry it is on (tagged with it or its vendor record, its invoices and their payments, and their reversals), its invoices and bills, and the payroll lines naming it. Totals come from the journal: the output's schema says exactly how.",
|
||||
ledger_core::calendar::PeriodKind::CalendarYear
|
||||
),
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
@@ -7642,12 +7694,13 @@ impl BooksServer {
|
||||
async fn party(&self, params: Parameters<PartyParams>) -> Result<Json<PartyDetailOut>, String> {
|
||||
let p = params.0;
|
||||
let year = p.year.unwrap_or_else(|| self.periods().calendar_year());
|
||||
let basis = parse_basis(p.basis.as_deref())?;
|
||||
let settings = self.settings();
|
||||
let store = self.lock()?;
|
||||
let ledger = store.ledger();
|
||||
let id = resolve_party(&store, &p.party)?;
|
||||
let party = ledger.get_party(id).expect("resolved");
|
||||
let act = party_activity(&store, party, &settings, year)?;
|
||||
let act = party_activity(&store, party, &settings, year, basis)?;
|
||||
let in_year = |d: NaiveDate| chrono::Datelike::year(&d) == year;
|
||||
let mut entries: Vec<PartyEntryOut> = act
|
||||
.entries
|
||||
@@ -7704,6 +7757,7 @@ impl BooksServer {
|
||||
}
|
||||
}
|
||||
let totals = PartyTotalsOut {
|
||||
basis: basis.key().to_string(),
|
||||
paid: money(act.paid),
|
||||
received: money(act.received),
|
||||
invoiced: money(checked_sum(
|
||||
@@ -7734,7 +7788,7 @@ impl BooksServer {
|
||||
net_pay: money(net),
|
||||
};
|
||||
Ok(Json(PartyDetailOut {
|
||||
party: party_out(party),
|
||||
party: party_out(ledger, party),
|
||||
year,
|
||||
totals,
|
||||
entries,
|
||||
@@ -7769,7 +7823,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.set_entry_party",
|
||||
title = "Tag an entry's parties",
|
||||
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.",
|
||||
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, with its direction and what of it was money or property in kind.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
|
||||
+103
-27
@@ -1256,11 +1256,33 @@ pub struct EntryPartyOut {
|
||||
pub amount: Option<String>,
|
||||
/// The percent given, for a percent share, decimal string.
|
||||
pub percent: Option<String>,
|
||||
/// Its exact part of what the entry moved (positive: paid to it;
|
||||
/// negative: received from it; for an entry moving no money, its part
|
||||
/// of the total), decimal string. Null when the shares don't resolve
|
||||
/// (see `parties_error`).
|
||||
/// Its exact part of what the entry moved, signed as a line on the
|
||||
/// party's own account would be (debit-positive): positive, the
|
||||
/// company paid it or gave it value; negative, received from it. With
|
||||
/// direction "neither", its part of the entry's movement of its own
|
||||
/// accounts (a bill is negative, owed to it; an invoice positive), else
|
||||
/// of the entry's total. A reversal's is exactly its original's
|
||||
/// negated. A member's entry is what moved their capital (a property
|
||||
/// contribution is negative, received, in kind). Decimal string. Null
|
||||
/// when the shares don't resolve (see `parties_error`).
|
||||
pub resolved: Option<String>,
|
||||
/// "paid", "received" or "neither": neither is a promise not yet
|
||||
/// settled (a bill, an invoice, a fee charged on its own card, a
|
||||
/// distribution declared and not yet paid) or an entry that moved
|
||||
/// nothing with anyone (a close), counted in no party total. Null
|
||||
/// exactly when `resolved` is.
|
||||
pub direction: Option<String>,
|
||||
/// What of `resolved` moved as money (cash, a card, the members'
|
||||
/// financing, a loan; on a member's contribution, what the K-1
|
||||
/// worksheet counts as cash; of a distribution, the money it paid):
|
||||
/// what the party's totals count on basis "cash". "0.00" for direction
|
||||
/// neither. Decimal string, null when `resolved` is.
|
||||
pub cash: Option<String>,
|
||||
/// What of `resolved` moved as property (contributed or distributed in
|
||||
/// kind): basis "all" adds it. resolved − cash − in_kind is a promise
|
||||
/// not yet paid, counted on neither basis. Decimal string, null when
|
||||
/// `resolved` is.
|
||||
pub in_kind: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of `books.draft_entry`.
|
||||
@@ -3000,6 +3022,14 @@ pub struct UpdatePartyParams {
|
||||
/// resolves by name, and its name is free for another party). False:
|
||||
/// dealt with again (refused while another party has its name).
|
||||
pub archived: Option<bool>,
|
||||
/// The liability accounts (codes or ids) the company owes this party
|
||||
/// itself on: the card it issued, the loan it made, what the members
|
||||
/// advanced (name each member). Replaces its list; [] clears it. A
|
||||
/// credit to one never counts as paying the party (a fee on its card,
|
||||
/// interest on its loan, a distribution still owed: a promise, counted
|
||||
/// when paid). Liabilities only, not placeholders, payables or payroll
|
||||
/// withholdings.
|
||||
pub creditor_of: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.set_party_roles`. Give at least one role to add
|
||||
@@ -3243,6 +3273,10 @@ pub struct ListPartiesParams {
|
||||
/// so year to date).
|
||||
#[schemars(description = YEAR_DOC)]
|
||||
pub year: Option<i32>,
|
||||
/// What `paid` and `received` count: "cash" (the default: money) or
|
||||
/// "all" (money and property contributed or distributed in kind). Not
|
||||
/// the books' accounting method. See `books.party` totals.
|
||||
pub basis: Option<String>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.party`.
|
||||
@@ -3254,6 +3288,10 @@ pub struct PartyParams {
|
||||
/// date).
|
||||
#[schemars(description = YEAR_DOC)]
|
||||
pub year: Option<i32>,
|
||||
/// What `paid` and `received` count: "cash" (the default: money) or
|
||||
/// "all" (money and property contributed or distributed in kind). Not
|
||||
/// the books' accounting method. See the totals' schema.
|
||||
pub basis: Option<String>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.set_entry_party`.
|
||||
@@ -3345,6 +3383,10 @@ pub struct PartyOut {
|
||||
pub w4: Option<W4Out>,
|
||||
/// Taxpayer-ID facts (any role), if on file: type and last four.
|
||||
pub tin: Option<TinOut>,
|
||||
/// The liability accounts the company owes this party itself on (its
|
||||
/// card, its loan, what the members advanced), by code: a credit to
|
||||
/// one never counts as paying it.
|
||||
pub creditor_of: Vec<String>,
|
||||
}
|
||||
|
||||
/// One party in `books.parties`.
|
||||
@@ -3364,16 +3406,19 @@ pub struct PartySummaryOut {
|
||||
pub customer_id: Option<u64>,
|
||||
/// W-9 facts are on file.
|
||||
pub w9_on_file: bool,
|
||||
/// The W-9's TIN last four digits, when on file.
|
||||
/// The TIN's last four digits when the books know it: from its TIN
|
||||
/// facts (`books.set_tin`; a member's SSN for the K-1) or its W-9
|
||||
/// facts, which never disagree. Null when neither is on file. Whether
|
||||
/// the vault still holds the full number: `books.party` `tin.in_vault`.
|
||||
pub tin_last4: Option<String>,
|
||||
/// W-4 facts are on file.
|
||||
pub w4_on_file: bool,
|
||||
/// No longer dealt with.
|
||||
pub archived: bool,
|
||||
/// Paid to the party in the year, decimal string (as `books.party`
|
||||
/// totals).
|
||||
/// Paid to the party in the year on `basis`, decimal string (as
|
||||
/// `books.party` totals).
|
||||
pub paid: String,
|
||||
/// Received from the party in the year, decimal string (as
|
||||
/// Received from the party in the year on `basis`, decimal string (as
|
||||
/// `books.party` totals).
|
||||
pub received: String,
|
||||
}
|
||||
@@ -3383,6 +3428,8 @@ pub struct PartySummaryOut {
|
||||
pub struct ListPartiesOut {
|
||||
/// The year `paid` and `received` cover.
|
||||
pub year: i32,
|
||||
/// What `paid` and `received` count: "cash" or "all".
|
||||
pub basis: String,
|
||||
/// Parties, by id.
|
||||
pub parties: Vec<PartySummaryOut>,
|
||||
}
|
||||
@@ -3394,30 +3441,59 @@ pub struct ListPartiesOut {
|
||||
/// the payments received on them, and reversals of any of these. Only
|
||||
/// posted entries dated in the calendar year count.
|
||||
///
|
||||
/// Each entry's money is `ledger_core::parties::entry_flow`: what left or
|
||||
/// reached the `[books] cash_accounts` (every asset account but
|
||||
/// receivables when none are set), plus what the company paid through a
|
||||
/// 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), 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
|
||||
/// `books.set_entry_party` `parties`); an entry whose shares don't resolve
|
||||
/// counts for none of them.
|
||||
/// `paid` and `received` add up each entry's part for the party, as
|
||||
/// `books.get_entry` shows it (`resolved`, `direction`, `cash`,
|
||||
/// `in_kind`): on `basis` "cash" (the default) what moved as money, on
|
||||
/// "all" that plus property contributed or distributed in kind. A
|
||||
/// reversal counts on its original's side and nets it to zero. (Not the
|
||||
/// books' accounting method.)
|
||||
///
|
||||
/// Money is what left or reached the `[books] cash_accounts` (every asset
|
||||
/// account but receivables when none are set), the cards (liabilities
|
||||
/// whose names say "card", not "gift card"), and the `[books]
|
||||
/// financing_accounts` (what the members
|
||||
/// advanced, loans): a card charge or a member paying for the company is
|
||||
/// paid to the payee then; cash that pays one of these down is paid to
|
||||
/// its creditor (a reimbursement to the members, a card payment to the
|
||||
/// issuer); cash it raises is received from the lender. Any other
|
||||
/// liability is a promise (a bill, an accrual, deferred revenue): counted
|
||||
/// when it is settled, never also when it is booked. A credit to what the
|
||||
/// company owes a party itself (`books.party` `creditor_of`: a fee on its
|
||||
/// own card, interest on its own loan, a distribution still owed to a
|
||||
/// member) never counts as paying it.
|
||||
///
|
||||
/// An entry naming only members that moves equity counts what moved their
|
||||
/// capital: a contribution received, the property in it in kind (asset
|
||||
/// accounts that are neither cash nor receivables) and the rest cash, as
|
||||
/// on the K-1 worksheet; a distribution paid, the money it paid as cash
|
||||
/// and the property it gave in kind, the rest (declared to distributions
|
||||
/// payable, or credited to what the members advanced once they are named
|
||||
/// its creditors) still owed, counted when it is paid. So for a member,
|
||||
/// basis "all" over those entries and those payments is the statement of
|
||||
/// members' equity's contributions and distributions, and basis "cash"
|
||||
/// its cash contributions as the K-1 worksheet has them, but for a
|
||||
/// distribution declared and not yet paid, which is in the statement only. Known limits (record them apart): an entry
|
||||
/// that both repays a member and distributes to them counts the
|
||||
/// distribution only; a member's money that goes through the members'
|
||||
/// financing account both ways under their own name (lent, then converted
|
||||
/// to capital) counts both times, as does a distribution declared through
|
||||
/// it before the members are named its creditors; an expense a member paid
|
||||
/// booked straight to capital and tagged with the vendor isn't paid to the
|
||||
/// vendor; capital moved between members counts for neither.
|
||||
///
|
||||
/// An entry naming several parties counts each one's share (its amount,
|
||||
/// its percent, or its derived share: see `books.set_entry_party`
|
||||
/// `parties`); an entry whose shares don't resolve counts for none of
|
||||
/// them. A transfer between the company's own cash accounts nets to zero.
|
||||
/// (`ledger_core::parties::activity_with`, with the `[[company.members]]`,
|
||||
/// computes it; the viewer shows the same numbers.)
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
pub struct PartyTotalsOut {
|
||||
/// Paid to the party, decimal string.
|
||||
/// What `paid` and `received` count: "cash" or "all".
|
||||
pub basis: String,
|
||||
/// Paid to the party on `basis`, decimal string.
|
||||
pub paid: String,
|
||||
/// Received from the party, decimal string.
|
||||
/// Received from the party on `basis`, decimal string.
|
||||
pub received: String,
|
||||
/// Its customer record's invoices dated in the year, total, decimal
|
||||
/// string.
|
||||
|
||||
@@ -1027,14 +1027,31 @@ fn party_totals_for_a_customer_a_member_and_a_prepaid() {
|
||||
assert_eq!(t["received"], "2000.00", "a contribution");
|
||||
assert_eq!(t["paid"], "500.00", "a distribution");
|
||||
|
||||
// A prepaid bought with a member's card: an asset, paid by a liability.
|
||||
m.add_party("Insurer", &[]);
|
||||
m.post(
|
||||
"2026-04-01",
|
||||
json!({"party": "Insurer"}),
|
||||
&[("1300", "debit", "1200.00"), ("2100", "credit", "1200.00")],
|
||||
// A prepaid bought with a member's card: an asset, paid through what
|
||||
// the members advanced. That is money when it is a financing account
|
||||
// (rules.md B-35 item 5, P3 defect 24); unlisted, Due to Members is a
|
||||
// promise like any other liability, counted when the member is paid
|
||||
// back.
|
||||
let prepaid = |m: &mut Mcp| {
|
||||
m.add_party("Insurer", &[]);
|
||||
m.post(
|
||||
"2026-04-01",
|
||||
json!({"party": "Insurer"}),
|
||||
&[("1300", "debit", "1200.00"), ("2100", "credit", "1200.00")],
|
||||
);
|
||||
m.party("Insurer", 2026)["totals"].clone()
|
||||
};
|
||||
let t = prepaid(&mut m);
|
||||
assert_eq!(
|
||||
(t["paid"].clone(), t["received"].clone()),
|
||||
(json!("0.00"), json!("0.00"))
|
||||
);
|
||||
let t = m.party("Insurer", 2026)["totals"].clone();
|
||||
let dir = test_dir("totals-financing");
|
||||
let toml = books_toml(&dir, "Spec Co", "financing_accounts = [\"2100\"]\n");
|
||||
let mut f = Mcp::start(&dir, &toml);
|
||||
f.accounts(CHART);
|
||||
f.books_dir = Some(dir);
|
||||
let t = prepaid(&mut f);
|
||||
assert_eq!(t["paid"], "1200.00");
|
||||
assert_eq!(t["received"], "0.00");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,713 @@
|
||||
//! P3 defects 14 and 24 through the MCP, over stdio against the real
|
||||
//! binary: each party's part of an entry (`books.get_entry` `resolved`,
|
||||
//! `direction`, `cash`, `in_kind`), party totals on a basis
|
||||
//! (`books.party`, `books.parties` `basis`), and a party named the
|
||||
//! creditor of a liability (`books.update_party` `creditor_of`).
|
||||
//!
|
||||
//! Expected behavior comes only from the tool descriptions and parameter /
|
||||
//! result doc comments in `src/server.rs` and `src/types.rs` (as
|
||||
//! `tools/list` serves them) and rules.md B-35. The harness is the minimal
|
||||
//! subset of `tests/spec_mcp.rs` (copied from `tests/spec_parties.rs`):
|
||||
//! every success validates against the tool's `outputSchema`, and every
|
||||
//! refusal leaves `journal.jsonl` byte-identical.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(180);
|
||||
|
||||
// ---------------------------------------------------------------- harness
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Out {
|
||||
Ok(Value),
|
||||
ToolErr(String),
|
||||
RpcErr(Value),
|
||||
}
|
||||
|
||||
impl Out {
|
||||
fn refused(&self) -> bool {
|
||||
!matches!(self, Out::Ok(_))
|
||||
}
|
||||
fn err_text(&self) -> String {
|
||||
match self {
|
||||
Out::Ok(v) => panic!("expected a refusal, got {v}"),
|
||||
Out::ToolErr(t) => t.clone(),
|
||||
Out::RpcErr(e) => e.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Mcp {
|
||||
stdin: ChildStdin,
|
||||
rx: Receiver<String>,
|
||||
child: Child,
|
||||
next_id: u64,
|
||||
data: PathBuf,
|
||||
validators: HashMap<String, jsonschema::Validator>,
|
||||
/// The books dir when the server owns it: removed after the server
|
||||
/// stops (fields drop after `Drop::drop`).
|
||||
books_dir: Option<TestDir>,
|
||||
}
|
||||
|
||||
impl Drop for Mcp {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
static DIR_SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// A test's dir, removed when the test ends, pass or panic.
|
||||
struct TestDir(PathBuf);
|
||||
|
||||
impl std::ops::Deref for TestDir {
|
||||
type Target = std::path::Path;
|
||||
fn deref(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<std::path::Path> for TestDir {
|
||||
fn as_ref(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn test_dir(name: &str) -> TestDir {
|
||||
let n = DIR_SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"ledger-mcp-spec-party-totals-{}-{name}-{n}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
TestDir(dir)
|
||||
}
|
||||
|
||||
/// `wanderbooks.toml` with a company name and extra `[books]` TOML. The
|
||||
/// receipt threshold is high so ordinary test entries need no receipt.
|
||||
fn books_toml(dir: &Path, company: &str, books_extra: &str) -> PathBuf {
|
||||
let path = dir.join("wanderbooks.toml");
|
||||
std::fs::write(
|
||||
&path,
|
||||
format!(
|
||||
"[company]\nname = \"{company}\"\nfiscal_year_start = \"01-01\"\n\
|
||||
[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n{books_extra}"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
impl Mcp {
|
||||
fn start(dir: &Path, toml: &Path) -> Self {
|
||||
let run = dir.join("run");
|
||||
std::fs::create_dir_all(&run).unwrap();
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp"))
|
||||
.arg("--data-dir")
|
||||
.arg(dir.join("books-data"))
|
||||
.arg("--books-toml")
|
||||
.arg(toml)
|
||||
.env("XDG_RUNTIME_DIR", &run)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("spawn ledger-mcp");
|
||||
let stdin = child.stdin.take().unwrap();
|
||||
let stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
let (tx, rx) = channel();
|
||||
std::thread::spawn(move || {
|
||||
for line in stdout.lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if tx.send(line).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut mcp = Self {
|
||||
books_dir: None,
|
||||
stdin,
|
||||
rx,
|
||||
child,
|
||||
next_id: 1,
|
||||
data: dir.join("books-data"),
|
||||
validators: HashMap::new(),
|
||||
};
|
||||
let init = mcp.rpc(
|
||||
"initialize",
|
||||
json!({
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "spec-party-totals", "version": "0.1.0"},
|
||||
}),
|
||||
);
|
||||
assert!(init.get("result").is_some(), "initialize failed: {init}");
|
||||
mcp.send_line(
|
||||
&json!({"jsonrpc": "2.0", "method": "notifications/initialized"}).to_string(),
|
||||
);
|
||||
let list = mcp.rpc("tools/list", json!({}));
|
||||
for tool in list["result"]["tools"].as_array().expect("tools") {
|
||||
let name = tool["name"].as_str().unwrap().to_string();
|
||||
let validator = jsonschema::validator_for(&tool["outputSchema"])
|
||||
.unwrap_or_else(|e| panic!("{name}'s outputSchema is not valid: {e}"));
|
||||
mcp.validators.insert(name, validator);
|
||||
}
|
||||
mcp
|
||||
}
|
||||
|
||||
fn journal(&self) -> Vec<u8> {
|
||||
std::fs::read(self.data.join("journal.jsonl")).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The journal's line types, in order.
|
||||
fn journal_types(&self) -> Vec<String> {
|
||||
String::from_utf8(self.journal())
|
||||
.unwrap()
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.map(|l| {
|
||||
let v: Value = serde_json::from_str(l).unwrap();
|
||||
v["type"]
|
||||
.as_str()
|
||||
.or_else(|| v["line_type"].as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn send_line(&mut self, line: &str) {
|
||||
writeln!(self.stdin, "{line}").unwrap();
|
||||
self.stdin.flush().unwrap();
|
||||
}
|
||||
|
||||
fn recv(&mut self) -> Option<Value> {
|
||||
loop {
|
||||
match self.rx.recv_timeout(TIMEOUT) {
|
||||
Ok(line) => {
|
||||
let v: Value = serde_json::from_str(&line)
|
||||
.unwrap_or_else(|e| panic!("server wrote non-JSON {line:?}: {e}"));
|
||||
if v.get("id").is_none() && v.get("method").is_some() {
|
||||
continue;
|
||||
}
|
||||
return Some(v);
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rpc(&mut self, method: &str, params: Value) -> Value {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params});
|
||||
self.send_line(&msg.to_string());
|
||||
let resp = self
|
||||
.recv()
|
||||
.unwrap_or_else(|| panic!("no response to {method} (id {id})"));
|
||||
assert_eq!(resp["id"], json!(id), "response id mismatch: {resp}");
|
||||
resp
|
||||
}
|
||||
|
||||
/// Call a tool; a refusal must leave the journal unchanged, a success
|
||||
/// must match the outputSchema.
|
||||
fn tool(&mut self, name: &str, args: Value) -> Out {
|
||||
let before = self.journal();
|
||||
let resp = self.rpc("tools/call", json!({"name": name, "arguments": args}));
|
||||
let out = if let Some(err) = resp.get("error") {
|
||||
Out::RpcErr(err.clone())
|
||||
} else {
|
||||
let result = &resp["result"];
|
||||
if result.get("isError") == Some(&json!(true)) {
|
||||
Out::ToolErr(
|
||||
result["content"]
|
||||
.as_array()
|
||||
.and_then(|c| c.iter().find_map(|b| b["text"].as_str()))
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
)
|
||||
} else {
|
||||
let sc = result["structuredContent"].clone();
|
||||
let v = self
|
||||
.validators
|
||||
.get(name)
|
||||
.unwrap_or_else(|| panic!("{name} is not in tools/list"));
|
||||
let errors: Vec<String> = v.iter_errors(&sc).map(|e| e.to_string()).collect();
|
||||
assert!(
|
||||
errors.is_empty(),
|
||||
"{name} violates its outputSchema: {errors:?}\n{sc}"
|
||||
);
|
||||
Out::Ok(sc)
|
||||
}
|
||||
};
|
||||
if out.refused() {
|
||||
assert!(
|
||||
self.journal() == before,
|
||||
"refused {name} {args} changed the journal ({out:?})"
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn ok(&mut self, name: &str, args: Value) -> Value {
|
||||
let a = args.to_string();
|
||||
match self.tool(name, args) {
|
||||
Out::Ok(v) => v,
|
||||
other => panic!("{name} {a}: expected success, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `tool`, expecting a refusal that says why.
|
||||
fn refuse(&mut self, name: &str, args: Value) -> String {
|
||||
let a = args.to_string();
|
||||
let out = self.tool(name, args);
|
||||
assert!(out.refused(), "{name} {a} should be refused, got {out:?}");
|
||||
let why = out.err_text();
|
||||
assert!(!why.trim().is_empty(), "{name} {a}: a refusal says why");
|
||||
why
|
||||
}
|
||||
|
||||
/// `ok`, and the journal must not change (read-only tools).
|
||||
fn read_only(&mut self, name: &str, args: Value) -> Value {
|
||||
let before = self.journal();
|
||||
let v = self.ok(name, args);
|
||||
assert!(
|
||||
self.journal() == before,
|
||||
"read-only {name} changed the journal"
|
||||
);
|
||||
v
|
||||
}
|
||||
|
||||
fn accounts(&mut self, accts: &[(&str, &str, &str)]) {
|
||||
for (code, name, ty) in accts {
|
||||
self.ok(
|
||||
"books.add_account",
|
||||
json!({"code": code, "name": name, "account_type": ty}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draft an entry with `args` extra (party, vendor) and lines; the id.
|
||||
fn draft(&mut self, date: &str, extra: Value, lines: &[(&str, &str, &str)]) -> u64 {
|
||||
let mut args = json!({"date": date, "description": format!("entry {date}")});
|
||||
for (k, v) in extra.as_object().unwrap() {
|
||||
args[k] = v.clone();
|
||||
}
|
||||
let id = self.ok("books.draft_entry", args)["entry_id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
for (account, side, amount) in lines {
|
||||
self.ok(
|
||||
"books.add_line",
|
||||
json!({"entry_id": id, "account": account, "side": side, "amount": amount}),
|
||||
);
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
/// `draft`, then post it (high receipt threshold: no receipt needed).
|
||||
fn post(&mut self, date: &str, extra: Value, lines: &[(&str, &str, &str)]) -> u64 {
|
||||
let id = self.draft(date, extra, lines);
|
||||
self.ok(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": id, "confirmed": true}),
|
||||
);
|
||||
id
|
||||
}
|
||||
|
||||
fn add_party(&mut self, name: &str, roles: &[&str]) -> u64 {
|
||||
self.ok("books.add_party", json!({"name": name, "roles": roles}))["party_id"]
|
||||
.as_u64()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// How many journal lines have this type.
|
||||
fn count(&self, line_type: &str) -> usize {
|
||||
self.journal_types()
|
||||
.iter()
|
||||
.filter(|t| t.as_str() == line_type)
|
||||
.count()
|
||||
}
|
||||
}
|
||||
|
||||
const CHART: &[(&str, &str, &str)] = &[
|
||||
("1000", "Checking", "asset"),
|
||||
("1300", "Prepaid Expenses", "asset"),
|
||||
("1500", "Equipment", "asset"),
|
||||
("2050", "Business Card", "liability"),
|
||||
("2100", "Due to Members", "liability"),
|
||||
("3000", "Capital — Tobin Harlow", "equity"),
|
||||
("3001", "Capital — Morgan Ellis", "equity"),
|
||||
("6050", "Domain & Hosting", "expense"),
|
||||
("6400", "Bank Fees", "expense"),
|
||||
];
|
||||
|
||||
/// Fernhill Labs LLC: two members 50/50, cash 1000, the members'
|
||||
/// financing 2100; the chart; both members and the card's issuer in the
|
||||
/// directory.
|
||||
fn books(name: &str) -> Mcp {
|
||||
let dir = test_dir(name);
|
||||
let toml = books_toml(
|
||||
&dir,
|
||||
"Fernhill Labs LLC",
|
||||
"financing_accounts = [\"2100\"]\n\
|
||||
[[company.members]]\nname = \"Tobin Harlow\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\
|
||||
[[company.members]]\nname = \"Morgan Ellis\"\nshare = \"50%\"\ncapital_account = \"3001\"\n",
|
||||
);
|
||||
let mut m = Mcp::start(&dir, &toml);
|
||||
m.accounts(CHART);
|
||||
m.add_party("Tobin Harlow", &["member"]);
|
||||
m.add_party("Morgan Ellis", &["member"]);
|
||||
m.add_party("Harbor Bank", &["financial_institution"]);
|
||||
m.books_dir = Some(dir);
|
||||
m
|
||||
}
|
||||
|
||||
/// A party's part of an entry as books.get_entry gives it: (resolved,
|
||||
/// direction, cash, in_kind).
|
||||
fn part(m: &mut Mcp, entry: u64, party: &str) -> (Value, Value, Value, Value) {
|
||||
let e = m.read_only("books.get_entry", json!({"entry_id": entry}));
|
||||
let p = e["parties"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|p| p["party"] == party)
|
||||
.unwrap_or_else(|| panic!("{party} on entry {entry}: {e}"))
|
||||
.clone();
|
||||
(
|
||||
p["resolved"].clone(),
|
||||
p["direction"].clone(),
|
||||
p["cash"].clone(),
|
||||
p["in_kind"].clone(),
|
||||
)
|
||||
}
|
||||
|
||||
/// (paid, received, basis) from books.party.
|
||||
fn totals(m: &mut Mcp, party: &str, basis: Option<&str>) -> (Value, Value, Value) {
|
||||
let mut args = json!({"party": party, "year": 2026});
|
||||
if let Some(b) = basis {
|
||||
args["basis"] = json!(b);
|
||||
}
|
||||
let p = m.read_only("books.party", args);
|
||||
let t = &p["totals"];
|
||||
(t["paid"].clone(), t["received"].clone(), t["basis"].clone())
|
||||
}
|
||||
|
||||
fn reverse(m: &mut Mcp, entry: u64, date: &str) -> u64 {
|
||||
m.ok(
|
||||
"books.reverse_entry",
|
||||
json!({"entry_id": entry, "date": date, "description": "reversed", "confirmed": true}),
|
||||
)["reversal_id"]
|
||||
.as_u64()
|
||||
.unwrap_or_else(|| panic!("a reversal of {entry}"))
|
||||
}
|
||||
|
||||
/// Defect 14 over the MCP (rules.md B-35): `resolved` is signed as a line
|
||||
/// on the party's own account, with its direction and its money and
|
||||
/// property; a reversal is the exact opposite; party totals take a basis,
|
||||
/// cash by default and echoed, all adding property in kind, so that a
|
||||
/// member's basis-all received is the statement of members' equity's
|
||||
/// contributions; any other basis is refused.
|
||||
#[test]
|
||||
fn defect14_party_totals_take_a_basis() {
|
||||
let mut m = books("defect14");
|
||||
let tobin = json!({"parties": [{"party": "Tobin Harlow"}]});
|
||||
let both = json!({"parties": [{"party": "Tobin Harlow"}, {"party": "Morgan Ellis"}]});
|
||||
// Cash in, 400.00 each.
|
||||
let cash_in = m.post(
|
||||
"2026-10-05",
|
||||
tobin.clone(),
|
||||
&[("1000", "debit", "400.00"), ("3000", "credit", "400.00")],
|
||||
);
|
||||
m.post(
|
||||
"2026-10-05",
|
||||
json!({"parties": [{"party": "Morgan Ellis"}]}),
|
||||
&[("1000", "debit", "400.00"), ("3001", "credit", "400.00")],
|
||||
);
|
||||
// A laptop Tobin contributes, then its reversal.
|
||||
let laptop = m.post(
|
||||
"2026-10-12",
|
||||
tobin.clone(),
|
||||
&[("1500", "debit", "1480.00"), ("3000", "credit", "1480.00")],
|
||||
);
|
||||
let undo = reverse(&mut m, laptop, "2026-10-13");
|
||||
// Devices from both, derived from the capital lines; one with its
|
||||
// service contract (prepaid: property).
|
||||
m.post(
|
||||
"2026-10-14",
|
||||
both.clone(),
|
||||
&[
|
||||
("1500", "debit", "1480.00"),
|
||||
("3000", "credit", "740.00"),
|
||||
("3001", "credit", "740.00"),
|
||||
],
|
||||
);
|
||||
m.post(
|
||||
"2026-10-19",
|
||||
both.clone(),
|
||||
&[
|
||||
("1500", "debit", "1010.00"),
|
||||
("1300", "debit", "30.00"),
|
||||
("3000", "credit", "520.00"),
|
||||
("3001", "credit", "520.00"),
|
||||
],
|
||||
);
|
||||
// A reimbursement to both (money, paid).
|
||||
let reimburse = m.post(
|
||||
"2026-10-20",
|
||||
json!({"parties": [{"party": "Tobin Harlow", "amount": "93.25"}, {"party": "Morgan Ellis", "amount": "93.25"}]}),
|
||||
&[("2100", "debit", "186.50"), ("1000", "credit", "186.50")],
|
||||
);
|
||||
assert_eq!(
|
||||
part(&mut m, cash_in, "Tobin Harlow"),
|
||||
(
|
||||
json!("-400.00"),
|
||||
json!("received"),
|
||||
json!("-400.00"),
|
||||
json!("0.00")
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
part(&mut m, laptop, "Tobin Harlow"),
|
||||
(
|
||||
json!("-1480.00"),
|
||||
json!("received"),
|
||||
json!("0.00"),
|
||||
json!("-1480.00")
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
part(&mut m, undo, "Tobin Harlow"),
|
||||
(
|
||||
json!("1480.00"),
|
||||
json!("paid"),
|
||||
json!("0.00"),
|
||||
json!("1480.00")
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
part(&mut m, reimburse, "Morgan Ellis"),
|
||||
(json!("93.25"), json!("paid"), json!("93.25"), json!("0.00"))
|
||||
);
|
||||
// Totals: cash by default, labelled; all adds 740.00 + 520.00.
|
||||
let cash = (json!("93.25"), json!("400.00"), json!("cash"));
|
||||
assert_eq!(totals(&mut m, "Tobin Harlow", None), cash);
|
||||
assert_eq!(totals(&mut m, "Tobin Harlow", Some("cash")), cash);
|
||||
let all = totals(&mut m, "Tobin Harlow", Some("all"));
|
||||
assert_eq!(all, (json!("93.25"), json!("1660.00"), json!("all")));
|
||||
// The statement of members' equity, the same dates: contributions
|
||||
// 1,660.00 each (the laptop and its reversal cancel), no
|
||||
// distributions; basis all's received is exactly that.
|
||||
let me = m.read_only(
|
||||
"books.export",
|
||||
json!({"report": "members_equity", "format": "json", "start": "2026-01-01", "end": "2026-12-31"}),
|
||||
);
|
||||
let me: Value = serde_json::from_str(me["content"].as_str().unwrap()).unwrap();
|
||||
for who in ["Tobin Harlow", "Morgan Ellis"] {
|
||||
let col = me["members"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|c| c["name"] == who)
|
||||
.unwrap_or_else(|| panic!("{who} in {me}"))
|
||||
.clone();
|
||||
assert_eq!(col["contributions"], totals(&mut m, who, Some("all")).1);
|
||||
assert_eq!(col["contributions"], "1660.00");
|
||||
assert_eq!(col["distributions"], "0.00");
|
||||
}
|
||||
// books.parties echoes the basis.
|
||||
let list = m.read_only("books.parties", json!({"year": 2026, "basis": "all"}));
|
||||
assert_eq!(list["basis"], "all");
|
||||
let row = list["parties"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|p| p["name"] == "Morgan Ellis")
|
||||
.unwrap()
|
||||
.clone();
|
||||
assert_eq!(
|
||||
(row["paid"].clone(), row["received"].clone()),
|
||||
(json!("93.25"), json!("1660.00"))
|
||||
);
|
||||
assert_eq!(m.read_only("books.parties", json!({}))["basis"], "cash");
|
||||
// Any other basis is refused, by both tools.
|
||||
for (tool, args) in [
|
||||
(
|
||||
"books.party",
|
||||
json!({"party": "Tobin Harlow", "basis": "accrual"}),
|
||||
),
|
||||
("books.parties", json!({"basis": "accrual"})),
|
||||
] {
|
||||
let why = m.refuse(tool, args);
|
||||
assert!(why.contains("\"cash\" or \"all\""), "{tool}: {why}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Defect 24 over the MCP: `books.update_party` `creditor_of` names the
|
||||
/// liabilities the company owes a party itself on (journaled as
|
||||
/// party.creditor_set, the journal's feature declared first), replaces
|
||||
/// the list, refuses what isn't a liability with nothing journaled; and a
|
||||
/// fee on the issuer's own card then reads neither, so the issuer is paid
|
||||
/// what the card payment paid, once.
|
||||
#[test]
|
||||
fn defect24_creditor_of_round_trips_and_a_card_fee_is_not_a_payment() {
|
||||
let mut m = books("defect24");
|
||||
let issuer = json!({"parties": [{"party": "Harbor Bank"}]});
|
||||
let fee = m.post(
|
||||
"2026-05-01",
|
||||
issuer.clone(),
|
||||
&[("6400", "debit", "25.00"), ("2050", "credit", "25.00")],
|
||||
);
|
||||
m.post(
|
||||
"2026-05-20",
|
||||
issuer.clone(),
|
||||
&[("2050", "debit", "125.00"), ("1000", "credit", "125.00")],
|
||||
);
|
||||
assert_eq!(totals(&mut m, "Harbor Bank", None).0, json!("150.00"));
|
||||
// Not a liability: refused, nothing journaled (the tool checks).
|
||||
let why = m.refuse(
|
||||
"books.update_party",
|
||||
json!({"party": "Harbor Bank", "creditor_of": ["1000"]}),
|
||||
);
|
||||
assert!(why.contains("liability"), "{why}");
|
||||
assert_eq!(m.count("party.creditor_set"), 0);
|
||||
let before = m.count("journal.format_set");
|
||||
let p = m.ok(
|
||||
"books.update_party",
|
||||
json!({"party": "Harbor Bank", "creditor_of": ["2050"]}),
|
||||
);
|
||||
assert_eq!(p["creditor_of"], json!(["2050"]));
|
||||
assert_eq!(m.count("party.creditor_set"), 1);
|
||||
assert_eq!(
|
||||
m.count("journal.format_set"),
|
||||
before + 1,
|
||||
"the feature declared once"
|
||||
);
|
||||
// The same list again changes nothing.
|
||||
let journal = m.journal();
|
||||
m.ok(
|
||||
"books.update_party",
|
||||
json!({"party": "Harbor Bank", "creditor_of": ["2050"]}),
|
||||
);
|
||||
assert!(m.journal() == journal);
|
||||
assert_eq!(
|
||||
part(&mut m, fee, "Harbor Bank"),
|
||||
(
|
||||
json!("-25.00"),
|
||||
json!("neither"),
|
||||
json!("0.00"),
|
||||
json!("0.00")
|
||||
)
|
||||
);
|
||||
assert_eq!(totals(&mut m, "Harbor Bank", None).0, json!("125.00"));
|
||||
assert_eq!(
|
||||
totals(&mut m, "Harbor Bank", Some("all")).0,
|
||||
json!("125.00")
|
||||
);
|
||||
// Both members are owed what the members advanced; then one isn't.
|
||||
for who in ["Tobin Harlow", "Morgan Ellis"] {
|
||||
let p = m.ok(
|
||||
"books.update_party",
|
||||
json!({"party": who, "creditor_of": ["2100"]}),
|
||||
);
|
||||
assert_eq!(p["creditor_of"], json!(["2100"]));
|
||||
}
|
||||
let p = m.ok(
|
||||
"books.update_party",
|
||||
json!({"party": "Morgan Ellis", "creditor_of": []}),
|
||||
);
|
||||
assert_eq!(p["creditor_of"], json!([]));
|
||||
assert_eq!(m.count("party.creditor_set"), 4);
|
||||
assert_eq!(m.count("journal.format_set"), before + 1);
|
||||
let p = m.read_only("books.party", json!({"party": "Tobin Harlow"}));
|
||||
assert_eq!(p["party"]["creditor_of"], json!(["2100"]));
|
||||
assert_eq!(m.ok("books.verify", json!({}))["status"], "ok");
|
||||
}
|
||||
|
||||
/// What `books.post_entry` previews is what it posts: each party's
|
||||
/// `resolved`, `direction`, `cash` and `in_kind` in the preview are
|
||||
/// `books.get_entry`'s once confirmed (a derived split is then journaled as
|
||||
/// the amounts it resolved to, the same parts), for a joint contribution
|
||||
/// of money and property and for a distribution partly still owed.
|
||||
#[test]
|
||||
fn the_post_preview_shows_the_parts_it_posts() {
|
||||
let mut m = books("preview");
|
||||
m.accounts(&[("2150", "Distributions Payable", "liability")]);
|
||||
let both = json!({"parties": [{"party": "Tobin Harlow"}, {"party": "Morgan Ellis"}]});
|
||||
let tobin = json!({"parties": [{"party": "Tobin Harlow"}]});
|
||||
let parts = |v: &Value| -> Vec<Value> {
|
||||
v["parties"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|p| {
|
||||
json!([
|
||||
p["party"],
|
||||
p["resolved"],
|
||||
p["direction"],
|
||||
p["cash"],
|
||||
p["in_kind"]
|
||||
])
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let joint = m.draft(
|
||||
"2026-03-02",
|
||||
both,
|
||||
&[
|
||||
("1000", "debit", "610.00"),
|
||||
("1500", "debit", "390.00"),
|
||||
("3000", "credit", "500.00"),
|
||||
("3001", "credit", "500.00"),
|
||||
],
|
||||
);
|
||||
let owed = m.draft(
|
||||
"2026-03-03",
|
||||
tobin,
|
||||
&[
|
||||
("3000", "debit", "300.00"),
|
||||
("1000", "credit", "120.00"),
|
||||
("2150", "credit", "180.00"),
|
||||
],
|
||||
);
|
||||
for (id, want) in [
|
||||
(
|
||||
joint,
|
||||
vec![
|
||||
json!(["Tobin Harlow", "-500.00", "received", "-305.00", "-195.00"]),
|
||||
json!(["Morgan Ellis", "-500.00", "received", "-305.00", "-195.00"]),
|
||||
],
|
||||
),
|
||||
(
|
||||
owed,
|
||||
vec![json!(["Tobin Harlow", "300.00", "paid", "120.00", "0.00"])],
|
||||
),
|
||||
] {
|
||||
let preview = m.read_only("books.post_entry", json!({"entry_id": id}));
|
||||
assert_eq!(preview["status"], "preview");
|
||||
assert_eq!(parts(&preview), want, "entry {id}'s preview");
|
||||
m.ok(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": id, "confirmed": true}),
|
||||
);
|
||||
let posted = m.read_only("books.get_entry", json!({"entry_id": id}));
|
||||
assert_eq!(parts(&posted), want, "entry {id} posted");
|
||||
}
|
||||
let (paid, received, _) = totals(&mut m, "Tobin Harlow", None);
|
||||
assert_eq!((paid, received), (json!("120.00"), json!("305.00")));
|
||||
}
|
||||
@@ -2219,3 +2219,61 @@ fn idempotent_tools_change_nothing_when_repeated() {
|
||||
.collect();
|
||||
assert!(wrong.is_empty(), "{wrong:#?}");
|
||||
}
|
||||
|
||||
// ------------------------------------------------- defect 11 (P3)
|
||||
|
||||
/// Defect 11 (audit F28): `books.parties` showed a member's TIN as
|
||||
/// missing (`tin_last4: null`) because it read W-9 facts only, while
|
||||
/// `books.party` showed the TIN facts' last four. The directory's last
|
||||
/// four is the TIN facts', else the W-9 facts' (they never disagree:
|
||||
/// parties INVARIANT 2a), null with neither, and kept after a purge
|
||||
/// (the facts keep the last four).
|
||||
#[test]
|
||||
fn defect11_the_directory_shows_a_members_tin_last4() {
|
||||
let mut m = books("defect11");
|
||||
m.add_party("Mo", &["member"]);
|
||||
m.add_party("Nobody In Particular", &[]);
|
||||
m.ok(
|
||||
"books.set_tin",
|
||||
json!({"party": "Mo", "tin": MEMBER_SSN, "tin_type": "ssn"}),
|
||||
);
|
||||
assert!(
|
||||
!m.w9(
|
||||
"Acme Design",
|
||||
json!({"tin_last4": "6789", "kept_at": "binder"})
|
||||
)
|
||||
.refused()
|
||||
);
|
||||
let row = |m: &mut Mcp, name: &str| -> Value {
|
||||
let list = m.read_only("books.parties", json!({"year": 2026}));
|
||||
list["parties"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|p| p["name"] == name)
|
||||
.unwrap_or_else(|| panic!("{name} in {list}"))
|
||||
.clone()
|
||||
};
|
||||
let mo = row(&mut m, "Mo");
|
||||
assert_eq!(
|
||||
(mo["tin_last4"].clone(), mo["w9_on_file"].clone()),
|
||||
(json!("4915"), json!(false))
|
||||
);
|
||||
assert_eq!(
|
||||
m.party("Mo", 2026)["party"]["tin"]["last4"],
|
||||
mo["tin_last4"]
|
||||
);
|
||||
let acme = row(&mut m, "Acme Design");
|
||||
assert_eq!(
|
||||
(acme["tin_last4"].clone(), acme["w9_on_file"].clone()),
|
||||
(json!("6789"), json!(true))
|
||||
);
|
||||
assert!(row(&mut m, "Nobody In Particular")["tin_last4"].is_null());
|
||||
m.ok(
|
||||
"books.purge_tin",
|
||||
json!({"party": "Mo", "reason": "left the company", "confirmed": true}),
|
||||
);
|
||||
assert_eq!(row(&mut m, "Mo")["tin_last4"], "4915");
|
||||
let list = m.read_only("books.parties", json!({}));
|
||||
assert!(!holds_tin(list.to_string().as_bytes(), MEMBER_SSN_DIGITS));
|
||||
}
|
||||
Reference in new issue
Block a user