P3: dated 1099 decisions, payment methods and the card exception (defects 23, 12, 18, D-40)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
A vendor's 1099 decision is its own dated fact (files NEC or MISC, not reportable with its reason, or undetermined), applied by each payment's date. Payment methods are recorded; card and network payments are left out and listed; a member's check through financing counts; a bank payment with no method counts (D-40); $2,000 for payments after 2025. One worksheet for the tool, the viewer and the CPA package. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
77 files changed
+11455
-970
No files matched your search
@@ -1,5 +1,7 @@
|
||||
# 1099s
|
||||
|
||||
- Mark contractors and landlords with books.set_vendor_1099 (and whether their W-9 is on file), and tag every payment to them with its vendor or party (books.draft_entry vendor or party, books.set_entry_vendor, or books.set_entry_party; books.pay_bill tags automatically).
|
||||
- books.vendor_1099 is the year-end worksheet for the CPA. With include_tins: true it carries the full TINs from the vault, for the CPA's copy; csv: true also hands it over as a CSV file (run its download command to save it).
|
||||
- Card payments are reported by the card processor (1099-K) and are left out.
|
||||
- Decide every vendor with books.set_vendor_1099 and who decided: nec, misc, or not_reportable with its reason_kind and the facts (a corporation, the United States or a state, a payee selling only merchandise...); undetermined is "not decided yet". "Paid by card" is never a vendor's reason. Never corporation for a payee paid for legal services or for medical and health care: those payments stay reportable even to a corporation (nec or misc). Give effective only when a payee's status changed (an LLC's corporate election, from its Form 8832 date); a payee that incorporates is a new vendor with its own W-9. Ask the human; the CPA confirms.
|
||||
- Record each way the company pays once (books.add_instrument: the bank, the company card, each member's own card, each member's own cash or checks, a payment platform), and tag every payment to a vendor with its vendor or party (books.draft_entry vendor or party, books.set_entry_vendor, books.set_entry_party; books.pay_bill tags automatically).
|
||||
- Tag what members paid for the company with the card or the cash they used (books.set_entry_instrument): their card is excluded, their own check counts. A card that only funded a friends-and-family transfer is their cash. Tag what a payment platform's balance paid the same way: goods and services excluded, friends-and-family counts; untagged, both stay undetermined.
|
||||
- Card and payment-network payments are excluded (the processor's Form 1099-K), whoever's card it was. A bank payment with no method recorded counts: tag a debit-card payment with the card.
|
||||
- books.vendor_1099 is the year-end worksheet for the CPA. undetermined means "tell me how it was paid" or "decide this vendor". With include_tins: true it carries the full TINs from the vault; csv: true also hands it over as a CSV file (run its download command to save it).
|
||||
@@ -6,7 +6,7 @@
|
||||
- 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.
|
||||
- Money is the cash accounts, the cards (the business cards recorded as payment methods, books.add_instrument kind business_card; until one is, a card account's name says "card"; a gift card the company sold is deferred revenue, not a card; a member's own card pays through the members' financing account) and the financing accounts setting ([books] financing_accounts: what the members advanced, loans): list the members' payable and every loan there, and record every company card as a payment method (or, until then, list 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.
|
||||
|
||||
@@ -62,7 +62,7 @@ const C: Needs = PostWhenConfirmed;
|
||||
const K: Needs = PostForApprovalRules;
|
||||
|
||||
/// Every tool and what it needs (INVARIANT 1).
|
||||
pub const TABLE: [(&str, Needs); 112] = [
|
||||
pub const TABLE: [(&str, Needs); 115] = [
|
||||
("books.handbook", R),
|
||||
("books.start", R),
|
||||
// Settings are committed at once: the settings commit of the plan.
|
||||
@@ -116,6 +116,9 @@ pub const TABLE: [(&str, Needs); 112] = [
|
||||
("books.list_vendors", R),
|
||||
("books.set_vendor_1099", W),
|
||||
("books.set_entry_vendor", W),
|
||||
("books.add_instrument", W),
|
||||
("books.instruments", R),
|
||||
("books.set_entry_instrument", W),
|
||||
// Until P7 these can put full TINs out (with include_tins, or a W-9
|
||||
// itself): only an admin token, never a plain one (plan 3.3, "no
|
||||
// scope unwraps the vault", holds for every other scope).
|
||||
|
||||
+177
-62
@@ -557,7 +557,7 @@ fn vendor_1099_report(
|
||||
year: i32,
|
||||
threshold: Option<Decimal>,
|
||||
) -> Result<ledger_core::Vendor1099Report, ledger_core::LedgerError> {
|
||||
ledger_package::vendor_1099_report(ledger, &package_settings(settings), year, None, threshold)
|
||||
ledger_package::vendor_1099_report(ledger, &package_settings(settings), year, threshold, None)
|
||||
}
|
||||
|
||||
impl BooksServer {
|
||||
@@ -750,6 +750,7 @@ impl BooksServer {
|
||||
+ Self::access_router()
|
||||
+ Self::files_router()
|
||||
+ Self::backup_router()
|
||||
+ Self::instruments_router()
|
||||
}
|
||||
|
||||
/// The settings in force for this request (reloaded at its start, in
|
||||
@@ -915,8 +916,10 @@ impl BooksServer {
|
||||
/// 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
|
||||
/// cash), the card accounts (ledger_core::parties::card_accounts: the
|
||||
/// business-card payment methods' liabilities, else liabilities named
|
||||
/// "card", not "gift", 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.
|
||||
///
|
||||
@@ -2729,6 +2732,8 @@ mod access_tools;
|
||||
mod backup_tools;
|
||||
#[path = "tools/files.rs"]
|
||||
mod files;
|
||||
#[path = "tools/instruments.rs"]
|
||||
mod instruments;
|
||||
#[path = "tools/kept.rs"]
|
||||
mod kept;
|
||||
pub use kept::SettingKeyOut;
|
||||
@@ -3025,6 +3030,11 @@ impl BooksServer {
|
||||
.as_deref()
|
||||
.map(|v| resolve_vendor(&store, v))
|
||||
.transpose()?;
|
||||
let instrument = p
|
||||
.instrument
|
||||
.as_deref()
|
||||
.map(|r| instruments::resolve_instrument(&store, r))
|
||||
.transpose()?;
|
||||
let party = p
|
||||
.party
|
||||
.as_deref()
|
||||
@@ -3064,6 +3074,10 @@ impl BooksServer {
|
||||
));
|
||||
}
|
||||
}
|
||||
// A payment method exists only under the payment_instruments
|
||||
// feature (books.add_instrument declared it), so tagging the draft
|
||||
// with one below declares nothing after the draft's first line
|
||||
// (B-27).
|
||||
let id = store
|
||||
.create_entry(date, &p.description)
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -3082,6 +3096,11 @@ impl BooksServer {
|
||||
} else if let Some(r) = no_party {
|
||||
store.set_entry_no_party(id, r).map_err(|e| e.to_string())?;
|
||||
}
|
||||
if instrument.is_some() {
|
||||
store
|
||||
.set_entry_instrument(id, instrument)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(Json(DraftEntryOut {
|
||||
entry_id: id,
|
||||
status: "draft".to_string(),
|
||||
@@ -3901,6 +3920,12 @@ impl BooksServer {
|
||||
.entry_vendor(p.entry_id)
|
||||
.and_then(|v| store.ledger().get_vendor(v))
|
||||
.map(|v| v.name.clone()),
|
||||
instrument: store
|
||||
.ledger()
|
||||
.entry_instrument(p.entry_id)
|
||||
.and_then(|i| store.ledger().get_instrument(i))
|
||||
.map(|i| i.label.clone()),
|
||||
instrument_id: store.ledger().entry_instrument(p.entry_id),
|
||||
party_id: store.ledger().entry_party(p.entry_id),
|
||||
party: store
|
||||
.ledger()
|
||||
@@ -4869,7 +4894,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.list_vendors",
|
||||
title = "List vendors",
|
||||
description = "Vendors, with which 1099 each gets, whether its W-9 is on file, and the party each belongs to (books.party).",
|
||||
description = "Vendors, each with its 1099 decision in force today (nec, misc, not_reportable with its reason, or undetermined; who decided and when) and every dated decision, whether its W-9 is on file, and the party it belongs to (books.party). undetermined with no decided_by: nobody has decided yet (books.set_vendor_1099).",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn list_vendors(&self) -> Result<Json<ListVendorsOut>, String> {
|
||||
@@ -4879,51 +4904,59 @@ impl BooksServer {
|
||||
.ledger()
|
||||
.list_vendors()
|
||||
.into_iter()
|
||||
.map(|v| VendorOut {
|
||||
id: v.id,
|
||||
name: v.name.clone(),
|
||||
form_1099: form_1099_label(v.form_1099),
|
||||
tin_on_file: v.tin_on_file,
|
||||
party_id: store.ledger().party_of_vendor(v.id),
|
||||
})
|
||||
.map(|v| instruments::vendor_out(&store, v))
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.set_vendor_1099",
|
||||
title = "Set a vendor's 1099 status",
|
||||
description = "Mark which information return a vendor gets: \"nec\" (1099-NEC: contractors, freelancers, attorneys), \"misc\" (1099-MISC: rent, royalties, prizes), or \"none\" (corporations generally, merchants, anyone paid only by card). Record whether a W-9 is on file; never store the TIN itself. Ask the human when unsure; the CPA confirms.",
|
||||
title = "Record a vendor's 1099 decision",
|
||||
description = "Record whether a vendor gets a 1099, with who decided (decided_by): nec (1099-NEC: contractors, attorneys), misc (1099-MISC: rent, royalties), not_reportable with reason_kind (corporation, tax_exempt, united_states, state, foreign_government, foreign_person, merchandise, employee, other with a citation) and reason in words, or undetermined. A corporation paid for legal or medical services stays nec/misc. Paid by card is never a reason (cards are excluded per payment). effective: only when the payee's status changed (an LLC's corporate election, from its date); a payee that incorporates is a new vendor. tin_on_file: a W-9 is on file (alone, it keeps the decision). Ask the human; CPA confirms.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
idempotent_hint = true,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn set_vendor_1099(
|
||||
&self,
|
||||
params: Parameters<SetVendor1099Params>,
|
||||
) -> Result<Json<VendorOut>, String> {
|
||||
) -> Result<Json<SetVendor1099Out>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let id = resolve_vendor(&store, &p.vendor)?;
|
||||
let form = match p.form_1099.trim().to_lowercase().as_str() {
|
||||
"none" | "" => None,
|
||||
other => Some(
|
||||
ledger_core::Form1099::parse(other)
|
||||
.ok_or_else(|| format!("form_1099 {other:?}: expected nec, misc, or none"))?,
|
||||
),
|
||||
let decision = instruments::decision_in(&p)?;
|
||||
let (changed, warnings) = match decision {
|
||||
None => {
|
||||
let flag = p.tin_on_file.expect("decision_in: one of the two");
|
||||
store
|
||||
.set_vendor_w9_flag(id, flag)
|
||||
.map_err(|e| e.to_string())?;
|
||||
(ledger_core::form1099::Applied::Unchanged, Vec::new())
|
||||
}
|
||||
Some(d) => {
|
||||
let reached = ledger_core::form1099::decision_changes(store.ledger(), id, &d)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let applied = store
|
||||
.set_vendor_1099(id, &d, p.tin_on_file)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let name = &store.ledger().get_vendor(id).expect("resolved").name;
|
||||
let warnings = instruments::filed_year_warnings(
|
||||
store.ledger(),
|
||||
&format!("{name}'s 1099 decision"),
|
||||
&reached,
|
||||
today(),
|
||||
);
|
||||
(applied, warnings)
|
||||
}
|
||||
};
|
||||
store
|
||||
.set_vendor_1099(id, form, p.tin_on_file)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let v = store.ledger().get_vendor(id).expect("exists");
|
||||
Ok(Json(VendorOut {
|
||||
id,
|
||||
name: v.name.clone(),
|
||||
form_1099: form_1099_label(v.form_1099),
|
||||
tin_on_file: v.tin_on_file,
|
||||
party_id: store.ledger().party_of_vendor(id),
|
||||
Ok(Json(SetVendor1099Out {
|
||||
vendor: instruments::vendor_out(&store, v),
|
||||
changed: instruments::applied_key(changed).to_string(),
|
||||
warnings,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -4962,7 +4995,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.vendor_1099",
|
||||
title = "1099 worksheet",
|
||||
description = "1099 worksheet for a calendar year: cash paid to each vendor (credits minus debits to the [books] cash and financing accounts on entries tagged with the vendor, so card payments, which the card processor reports on 1099-K, are excluded; on an entry naming several parties, only the vendor's resolved share of that payment), which vendors are reportable (marked NEC/MISC and paid at least the threshold: $600 through 2025, $2,000 in 2026, indexed for inflation after that), which are missing a W-9, and each payee's TIN last four. include_tins: true adds each payee's full TIN from the TIN vault (or why it can't), for the CPA's worksheet; csv: true also hands it over as a CSV file (run its download command to save it). For a year whose threshold wanderbooks doesn't know yet it refuses rather than guess: get the figure from the IRS or the human, pass it as `threshold`, and tell the human, who can report it upstream so the rule is updated for everyone. For the CPA to confirm.",
|
||||
description = "The 1099 worksheet for a calendar year. Per vendor: what counts toward its 1099 (paid out of the cash accounts, or through a financing account with the method recorded, while not decided not_reportable; an entry with several parties counts only the vendor's share), what was paid by card or payment network (excluded: Form 1099-K's), what is undetermined (a member paid, method not recorded: books.set_entry_instrument), its 1099 decision, and its status (reportable at the threshold: $600 through 2025, $2,000 in 2026, indexed after; under_threshold; not_reportable; undetermined). Lists those payments, W-9s missing, each payee's TIN last four. include_tins adds full TINs from the vault; csv hands it over. cash_accounts and financing_accounts default to the settings' (as books.export_cpa_package's do); through stops the year at a day: given a package's accounts and its as_of, the CSV is the package's 1099 worksheet. A year whose threshold isn't known is refused: pass threshold from the IRS. For the CPA to confirm.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
@@ -4975,21 +5008,44 @@ impl BooksServer {
|
||||
) -> Result<Json<Vendor1099Out>, String> {
|
||||
let p = params.0;
|
||||
let threshold = p.threshold.as_deref().map(parse_amount).transpose()?;
|
||||
let through = p.through.as_deref().map(parse_date).transpose()?;
|
||||
if let Some(t) = through
|
||||
&& chrono::Datelike::year(&t) != p.year
|
||||
{
|
||||
return Err(format!(
|
||||
"through ({t}) is a day of {}: the worksheet counts payments made in {} from January 1 through it",
|
||||
p.year, p.year
|
||||
));
|
||||
}
|
||||
let store = self.lock()?;
|
||||
let ledger = store.ledger();
|
||||
for code in p.cash_accounts.iter().chain(&p.financing_accounts) {
|
||||
if ledger.chart().find_by_code(code).is_none() {
|
||||
return Err(format!(
|
||||
"no account {code} in the chart (books.chart lists them)"
|
||||
));
|
||||
}
|
||||
}
|
||||
// With include_tins, the vault is opened once (never creating a
|
||||
// key: a locked vault becomes a note on each line).
|
||||
let vault = p.include_tins.then(|| self.open_vault(&store, false));
|
||||
let tins = vault
|
||||
.as_ref()
|
||||
.map(|vault| move |id| vault_tin(ledger, vault, id));
|
||||
// The accounts given, else the settings': the CPA package's rule
|
||||
// (ledger_package::Settings::with_accounts), so its 1099 worksheet
|
||||
// is this one for the same accounts and `through` its as_of.
|
||||
let settings = self.settings();
|
||||
let accounts =
|
||||
package_settings(&settings).with_accounts(&p.cash_accounts, &p.financing_accounts);
|
||||
// A year past what wanderbooks knows is refused with guidance
|
||||
// (research or ask, pass `threshold`, then update the rule).
|
||||
let w = ledger_package::vendor_1099_worksheet(
|
||||
ledger,
|
||||
&package_settings(&self.settings()),
|
||||
&accounts,
|
||||
p.year,
|
||||
threshold,
|
||||
through,
|
||||
tins.as_ref().map(|f| f as ledger_package::TinSource<'_>),
|
||||
)?;
|
||||
let warning = p.include_tins.then(|| {
|
||||
@@ -5006,6 +5062,27 @@ impl BooksServer {
|
||||
Some(self.hand_over(&book_name(&store), &name, "text/csv", csv.into_bytes())?)
|
||||
}
|
||||
};
|
||||
let payments = |list: &[ledger_core::Payment1099]| -> Vec<Payment1099Out> {
|
||||
list.iter()
|
||||
.map(|p| Payment1099Out {
|
||||
entry_id: p.entry_id,
|
||||
date: p.date.to_string(),
|
||||
vendor_id: p.vendor_id,
|
||||
vendor: ledger
|
||||
.get_vendor(p.vendor_id)
|
||||
.map(|v| v.name.clone())
|
||||
.unwrap_or_default(),
|
||||
amount: money(p.amount),
|
||||
instrument: p
|
||||
.instrument
|
||||
.and_then(|i| ledger.get_instrument(i))
|
||||
.map(|i| i.label.clone()),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let excluded_card_payments = payments(&w.excluded_card_payments);
|
||||
let undetermined_payments = payments(&w.undetermined_payments);
|
||||
let other_receipts = payments(&w.other_receipts);
|
||||
let vendors = w
|
||||
.lines
|
||||
.into_iter()
|
||||
@@ -5013,8 +5090,23 @@ impl BooksServer {
|
||||
vendor_id: l.vendor_id,
|
||||
name: l.name,
|
||||
form_1099: l.form_1099,
|
||||
decision_1099: instruments::decision_out(Some(&ledger_core::Dated1099 {
|
||||
effective: l.effective,
|
||||
decision: l.decision.clone(),
|
||||
source: l.decided.clone(),
|
||||
})),
|
||||
decisions_in_year: l
|
||||
.decisions
|
||||
.iter()
|
||||
.map(|d| instruments::decision_out(Some(d)))
|
||||
.collect(),
|
||||
tin_on_file: l.tin_on_file,
|
||||
paid: money(l.paid),
|
||||
excluded_card: money(l.excluded_card),
|
||||
undetermined: money(l.undetermined),
|
||||
not_reportable: money(l.not_reportable),
|
||||
status: l.status.key().to_string(),
|
||||
decision_needed: l.decision_needed,
|
||||
reportable: l.reportable,
|
||||
party_id: l.party_id,
|
||||
tin_last4: l.tin_last4,
|
||||
@@ -5028,6 +5120,11 @@ impl BooksServer {
|
||||
threshold: money(w.threshold),
|
||||
vendors,
|
||||
missing_w9: w.missing_w9,
|
||||
excluded_card_payments,
|
||||
undetermined_payments,
|
||||
other_receipts,
|
||||
untagged_entries: w.untagged_payments,
|
||||
unresolved: w.unresolved,
|
||||
notes: w.notes,
|
||||
warning,
|
||||
csv: csv_file,
|
||||
@@ -7005,6 +7102,15 @@ impl BooksServer {
|
||||
.set_party_creditor(id, *a, *on)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
// The creditor lines' journal feature, declared before this
|
||||
// command's first line, never between its lines (rules.md B-27: a
|
||||
// refused format upgrade must not leave the name changed and the
|
||||
// creditors not).
|
||||
if !creditor.is_empty() {
|
||||
store
|
||||
.ensure_feature(ledger_store::format::PARTY_CREDITORS)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
for f in fields {
|
||||
store.update_party(id, f).map_err(|e| e.to_string())?;
|
||||
}
|
||||
@@ -7299,6 +7405,19 @@ impl BooksServer {
|
||||
}
|
||||
};
|
||||
let on_file = facts.is_some();
|
||||
// The vendor record's W-9 flag follows (below): its feature is
|
||||
// declared before this command's first line, never after it
|
||||
// (B-27: a refusal to upgrade the format must not leave the W-9
|
||||
// recorded and the flag not).
|
||||
let vendor = store.ledger().get_party(id).and_then(|p| p.vendor);
|
||||
if vendor
|
||||
.and_then(|v| store.ledger().get_vendor(v))
|
||||
.is_some_and(|v| v.tin_on_file != on_file)
|
||||
{
|
||||
store
|
||||
.ensure_feature(ledger_store::format::VENDOR_1099_DECISIONS)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
if replace_tin {
|
||||
store.set_party_tin(id, None).map_err(|e| e.to_string())?;
|
||||
}
|
||||
@@ -7308,18 +7427,11 @@ impl BooksServer {
|
||||
.set_party_tin(id, tin_facts)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
// Keep the vendor record's W-9 flag (the 1099 worksheet's) in step.
|
||||
let vendor = store
|
||||
.ledger()
|
||||
.get_party(id)
|
||||
.and_then(|p| p.vendor)
|
||||
.and_then(|v| store.ledger().get_vendor(v))
|
||||
.map(|v| (v.id, v.form_1099, v.tin_on_file));
|
||||
if let Some((v, form, tin_on_file)) = vendor
|
||||
&& tin_on_file != on_file
|
||||
{
|
||||
// Keep the vendor record's W-9 flag (the 1099 worksheet's) in
|
||||
// step, its 1099 decision and who made it kept (P3 review R8).
|
||||
if let Some(v) = vendor {
|
||||
store
|
||||
.set_vendor_1099(v, form, on_file)
|
||||
.set_vendor_w9_flag(v, on_file)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
let party = store.ledger().get_party(id).expect("resolved");
|
||||
@@ -7722,20 +7834,22 @@ impl BooksServer {
|
||||
entries.sort_by(|a, b| b.date.cmp(&a.date).then(b.entry_id.cmp(&a.entry_id)));
|
||||
let invoices = party_invoices(ledger, party);
|
||||
let bills = party_bills(ledger, party);
|
||||
let amount_1099 = match party.vendor {
|
||||
Some(v) => {
|
||||
let r = vendor_1099_report(ledger, &self.settings(), year, Some(Decimal::ZERO))
|
||||
.map_err(|e| e.to_string())?;
|
||||
Some(money(
|
||||
r.vendors
|
||||
.iter()
|
||||
.find(|l| l.vendor_id == v)
|
||||
.map(|l| l.paid)
|
||||
.unwrap_or_default(),
|
||||
))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
// The worksheet's figure; none when it can't be made (no cash
|
||||
// accounts named), never a failed answer.
|
||||
let amount_1099 = party.vendor.and_then(|v| {
|
||||
let r = vendor_1099_report(ledger, &self.settings(), year, Some(Decimal::ZERO)).ok()?;
|
||||
Some(money(
|
||||
r.vendors
|
||||
.iter()
|
||||
.find(|l| l.vendor_id == v)
|
||||
.map(|l| l.paid)
|
||||
.unwrap_or_default(),
|
||||
))
|
||||
});
|
||||
let decided_now = party
|
||||
.vendor
|
||||
.and_then(|v| ledger.get_vendor(v))
|
||||
.map(|v| v.decision_on(today()).cloned().unwrap_or_default());
|
||||
// Documents and runs are restored as journaled (rules.md B-28): a
|
||||
// total past exact arithmetic refuses the answer, never a panic.
|
||||
let mut payroll = Vec::new();
|
||||
@@ -7780,10 +7894,8 @@ impl BooksServer {
|
||||
"what is receivable",
|
||||
)?),
|
||||
amount_1099,
|
||||
form_1099: party
|
||||
.vendor
|
||||
.and_then(|v| ledger.get_vendor(v))
|
||||
.and_then(|v| form_1099_label(v.form_1099)),
|
||||
form_1099: form_1099_label(decided_now.as_ref().and_then(|d| d.form())),
|
||||
decision_1099: decided_now.as_ref().map(|d| d.key().to_string()),
|
||||
gross_pay: money(gross),
|
||||
net_pay: money(net),
|
||||
};
|
||||
@@ -10063,7 +10175,7 @@ mod tests {
|
||||
const D: Hints = (Some(false), Some(true), None);
|
||||
const WI: Hints = (Some(false), Some(false), Some(true));
|
||||
const DI: Hints = (Some(false), Some(true), Some(true));
|
||||
let table: [(&str, Hints); 112] = [
|
||||
let table: [(&str, Hints); 115] = [
|
||||
("books.handbook", R),
|
||||
("books.start", R),
|
||||
("books.set_setting", DI),
|
||||
@@ -10113,9 +10225,12 @@ mod tests {
|
||||
("books.run_recurring", DI),
|
||||
("books.record_payout", W),
|
||||
("books.list_vendors", R),
|
||||
("books.set_vendor_1099", W),
|
||||
("books.set_vendor_1099", WI),
|
||||
("books.set_entry_vendor", W),
|
||||
("books.vendor_1099", W),
|
||||
("books.add_instrument", W),
|
||||
("books.instruments", R),
|
||||
("books.set_entry_instrument", WI),
|
||||
("books.chart_template", R),
|
||||
("books.tax_categories", R),
|
||||
("books.renumber_account", D),
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
//! 1099 tracking through the server: payment methods
|
||||
//! (`books.add_instrument`, `books.instruments`,
|
||||
//! `books.set_entry_instrument`), and the 1099 decision's shape in and out
|
||||
//! (`books.set_vendor_1099`, `books.list_vendors`, `books.vendor_1099`;
|
||||
//! plan P3, defects 12, 18 and 23; D-40).
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: the agent records how each payment was made and why each vendor
|
||||
//! does or doesn't get a 1099, and the worksheet never guesses.
|
||||
//! SOURCE: docs/spec/rules.md B-19 (Treas. Reg. §1.6041-1(a)(1)(iv),
|
||||
//! §1.6041-3; sources/irs-1099-payment-method.md); plan/served.md
|
||||
//! 4.9; DECISION D-40 (owner, 2026-10-20).
|
||||
//! INVARIANTS:
|
||||
//! 1. A 1099 decision names who made it (decided_by; decided_on
|
||||
//! defaults to today in the company's time zone). "none" is refused:
|
||||
//! no 1099 is not_reportable with its reason, or undetermined.
|
||||
//! 2. not_reportable takes a reason_kind and a reason, and other takes
|
||||
//! a citation too; no other decision takes either.
|
||||
//! 3. tin_on_file omitted keeps the W-9 flag; form_1099 omitted changes
|
||||
//! only it, keeping the decision and who made it (P3 review R8).
|
||||
//! 4. A change that reaches payments in a closed period, or in a year
|
||||
//! whose 1099s were due (January 31 of the next year), warns.
|
||||
//! 5. A payment method is named by id or label; a number that is one
|
||||
//! method's id and another's label is refused.
|
||||
//! ```
|
||||
|
||||
use super::*;
|
||||
use ledger_core::form1099::{Applied, Dated1099, Decision1099, ExemptReason, Form1099};
|
||||
use ledger_core::{InstrumentId, InstrumentKind};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Parameters and results
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
/// Parameters for `books.add_instrument`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct AddInstrumentParams {
|
||||
/// bank (the company's checks, ACH, wires, bill-pay), business_card (a
|
||||
/// company card the payee charged), member_card (a member's personal
|
||||
/// card the payee charged), member_cash_or_check (a member's own cash,
|
||||
/// check, ACH, Zelle, or a friends-and-family transfer), or platform (a
|
||||
/// payment the payee received as a seller through PayPal or Venmo goods
|
||||
/// and services).
|
||||
pub kind: String,
|
||||
/// Its name ("Harbor Bank checking", "Riley's Visa"), unique.
|
||||
pub label: String,
|
||||
/// The last four digits of its card or account number (never more).
|
||||
pub last4: Option<String>,
|
||||
/// Whose it is (a member's card): party id or name. Informational.
|
||||
pub owner_party: Option<String>,
|
||||
/// The account it pays through (id or code): the bank account, the
|
||||
/// card's liability, or the members' financing account (Due to
|
||||
/// Members) for a member's own card or cash.
|
||||
pub account: String,
|
||||
}
|
||||
|
||||
/// A payment method.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct InstrumentOut {
|
||||
/// Its id.
|
||||
pub id: u64,
|
||||
/// Its kind (as books.add_instrument takes it).
|
||||
pub kind: String,
|
||||
/// Whether a payment made with it is a card or payment-network
|
||||
/// payment: excluded from the company's 1099s (Form 1099-K's).
|
||||
pub card_or_network: bool,
|
||||
/// Its name.
|
||||
pub label: String,
|
||||
/// Its last four digits, if recorded.
|
||||
pub last4: Option<String>,
|
||||
/// Whose it is: party id, if recorded.
|
||||
pub owner_party: Option<u64>,
|
||||
/// The account it pays through: id.
|
||||
pub account_id: u64,
|
||||
/// That account's code.
|
||||
pub account: String,
|
||||
}
|
||||
|
||||
/// Result of `books.add_instrument`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct AddInstrumentOut {
|
||||
/// The payment method.
|
||||
#[serde(flatten)]
|
||||
pub instrument: InstrumentOut,
|
||||
/// Things to check, if any.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.instruments`.
|
||||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
pub struct InstrumentsParams {}
|
||||
|
||||
/// Result of `books.instruments`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct InstrumentsOut {
|
||||
/// Every payment method, by id.
|
||||
pub instruments: Vec<InstrumentOut>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.set_entry_instrument`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct SetEntryInstrumentParams {
|
||||
/// Entry id (draft or posted).
|
||||
pub entry_id: u64,
|
||||
/// The payment method (id or label, books.instruments); omit to clear.
|
||||
pub instrument: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of `books.set_entry_instrument`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SetEntryInstrumentOut {
|
||||
/// The entry, as books.get_entry shows it.
|
||||
#[serde(flatten)]
|
||||
pub entry: GetEntryOut,
|
||||
/// Whether anything changed (false: the entry and its reversals were
|
||||
/// tagged so already).
|
||||
pub changed: bool,
|
||||
/// Payments the change reaches in a closed period, or in a year whose
|
||||
/// 1099s were due: tell the human.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Shapes
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
/// A dated decision as the server shows it (none: never decided).
|
||||
pub(super) fn decision_out(d: Option<&Dated1099>) -> Decision1099Out {
|
||||
let undecided = Decision1099::Undetermined;
|
||||
let decision = d.map_or(&undecided, |d| &d.decision);
|
||||
let (reason_kind, reason, authority) = match decision {
|
||||
Decision1099::NotReportable { kind, reason } => (
|
||||
Some(kind.key().to_string()),
|
||||
Some(reason.clone()),
|
||||
kind.citation().map(str::to_string),
|
||||
),
|
||||
_ => (None, None, None),
|
||||
};
|
||||
let source = d.and_then(|d| d.source.as_ref());
|
||||
Decision1099Out {
|
||||
value: decision.key().to_string(),
|
||||
reason_kind,
|
||||
reason,
|
||||
authority,
|
||||
effective: d.and_then(|d| d.effective).map(|e| e.to_string()),
|
||||
decided_by: source.map(|s| s.decided_by.clone()),
|
||||
decided_on: source.map(|s| s.decided_on.to_string()),
|
||||
citation: source.and_then(|s| s.citation.clone()),
|
||||
note: source.and_then(|s| s.note.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A vendor as the server shows it: its decision in force today, and every
|
||||
/// one recorded.
|
||||
pub(super) fn vendor_out(store: &Store, v: &ledger_core::Vendor) -> VendorOut {
|
||||
let now = ledger_core::form1099::in_force(&v.decisions_1099, today());
|
||||
VendorOut {
|
||||
id: v.id,
|
||||
name: v.name.clone(),
|
||||
form_1099: form_1099_label(now.and_then(|d| d.decision.form())),
|
||||
decision_1099: decision_out(now),
|
||||
decisions_1099: v
|
||||
.decisions_1099
|
||||
.iter()
|
||||
.map(|d| decision_out(Some(d)))
|
||||
.collect(),
|
||||
tin_on_file: v.tin_on_file,
|
||||
party_id: store.ledger().party_of_vendor(v.id),
|
||||
}
|
||||
}
|
||||
|
||||
fn instrument_out(store: &Store, i: &ledger_core::Instrument) -> InstrumentOut {
|
||||
InstrumentOut {
|
||||
id: i.id,
|
||||
kind: i.kind.key().to_string(),
|
||||
card_or_network: i.kind.is_card_or_network(),
|
||||
label: i.label.clone(),
|
||||
last4: i.last4.clone(),
|
||||
owner_party: i.owner_party,
|
||||
account_id: i.account,
|
||||
account: store
|
||||
.ledger()
|
||||
.chart()
|
||||
.get(i.account)
|
||||
.map(|a| a.code.clone())
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a payment method by id or label (INVARIANT 5).
|
||||
pub(super) fn resolve_instrument(store: &Store, r: &str) -> Result<InstrumentId, String> {
|
||||
let ledger = store.ledger();
|
||||
let by_id = r
|
||||
.trim()
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.filter(|id| ledger.get_instrument(*id).is_some());
|
||||
let by_label = ledger.instrument_by_label(r).map(|i| i.id);
|
||||
match (by_id, by_label) {
|
||||
(Some(i), Some(l)) if i != l => Err(format!(
|
||||
"payment method {r:?} is ambiguous: it is method {i}'s id and method {l}'s label; pass the id"
|
||||
)),
|
||||
(Some(i), _) | (None, Some(i)) => Ok(i),
|
||||
(None, None) => Err(format!(
|
||||
"unknown payment method {r:?}: use an id or label from books.instruments (books.add_instrument adds one)"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The decision `books.set_vendor_1099` was given, or none when it
|
||||
/// changes only the W-9 flag (INVARIANTS 1-3).
|
||||
pub(super) fn decision_in(p: &SetVendor1099Params) -> Result<Option<Dated1099>, String> {
|
||||
let Some(value) = p.form_1099.as_deref() else {
|
||||
if p.reason_kind.is_some() || p.reason.is_some() || p.effective.is_some() {
|
||||
return Err(
|
||||
"reason_kind, reason and effective go with form_1099 (the decision they describe)"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if p.tin_on_file.is_none() {
|
||||
return Err(
|
||||
"nothing to change: pass form_1099 (the decision, with decided_by) or tin_on_file"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
return Ok(None);
|
||||
};
|
||||
let value = value.trim().to_ascii_lowercase();
|
||||
let decision = match value.as_str() {
|
||||
"not_reportable" => {
|
||||
let kind = p.reason_kind.as_deref().ok_or(
|
||||
"not_reportable needs reason_kind: corporation, tax_exempt, united_states, state, foreign_government, foreign_person, merchandise, employee, or other",
|
||||
)?;
|
||||
let kind = ExemptReason::parse(kind).ok_or_else(|| {
|
||||
format!(
|
||||
"reason_kind {kind:?}: expected corporation, tax_exempt, united_states, state, foreign_government, foreign_person, merchandise, employee, or other (paid by card is not a reason: card payments are excluded payment by payment)"
|
||||
)
|
||||
})?;
|
||||
let reason = p.reason.as_deref().filter(|r| !r.trim().is_empty()).ok_or(
|
||||
"not_reportable needs reason: the facts, in words (\"a corporation by name\")",
|
||||
)?;
|
||||
if kind == ExemptReason::Other
|
||||
&& p.citation.as_deref().is_none_or(|c| c.trim().is_empty())
|
||||
{
|
||||
return Err(
|
||||
"reason_kind other needs a citation: the rule or document that says no 1099 is due"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
Decision1099::not_reportable(kind, reason)?
|
||||
}
|
||||
"none" | "" | "no" | "no_1099" => {
|
||||
return Err(format!(
|
||||
"form_1099 {value:?}: say not_reportable with its reason_kind and reason (a corporation, the United States or a state, a payee selling only merchandise, ...), or undetermined. Paid by card is not a reason: card payments are excluded payment by payment"
|
||||
));
|
||||
}
|
||||
"undetermined" => Decision1099::Undetermined,
|
||||
other => Decision1099::Files(Form1099::parse(other).ok_or_else(|| {
|
||||
format!("form_1099 {other:?}: expected nec, misc, not_reportable, or undetermined")
|
||||
})?),
|
||||
};
|
||||
if !matches!(decision, Decision1099::NotReportable { .. })
|
||||
&& (p.reason_kind.is_some() || p.reason.is_some())
|
||||
{
|
||||
return Err(format!(
|
||||
"reason_kind and reason go with not_reportable, not {value}"
|
||||
));
|
||||
}
|
||||
let decided_by = p
|
||||
.decided_by
|
||||
.as_deref()
|
||||
.ok_or("decided_by is required with form_1099: who decided (a person's name)")?;
|
||||
let source = ledger_core::settings::Source {
|
||||
decided_by: decided_by.trim().to_string(),
|
||||
decided_on: match &p.decided_on {
|
||||
Some(d) => parse_date(d)?,
|
||||
None => today(),
|
||||
},
|
||||
// A blank citation or note is none.
|
||||
citation: p
|
||||
.citation
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|c| !c.is_empty())
|
||||
.map(str::to_string),
|
||||
note: p
|
||||
.note
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|n| !n.is_empty())
|
||||
.map(str::to_string),
|
||||
};
|
||||
Ok(Some(Dated1099 {
|
||||
effective: p.effective.as_deref().map(parse_date).transpose()?,
|
||||
decision,
|
||||
source: Some(source),
|
||||
}))
|
||||
}
|
||||
|
||||
/// What a change reaching `payments` (entry, date) warns of (INVARIANT 4):
|
||||
/// one line per calendar year with a payment in a closed period or whose
|
||||
/// 1099s were due by `today`.
|
||||
pub(super) fn filed_year_warnings(
|
||||
ledger: &Ledger,
|
||||
what: &str,
|
||||
payments: &[(ledger_core::EntryId, NaiveDate)],
|
||||
today: NaiveDate,
|
||||
) -> Vec<String> {
|
||||
let mut years: std::collections::BTreeMap<i32, (Vec<ledger_core::EntryId>, bool)> =
|
||||
Default::default();
|
||||
for &(e, d) in payments {
|
||||
let year = chrono::Datelike::year(&d);
|
||||
let closed = ledger.closed_period_containing(d).is_some();
|
||||
let due = NaiveDate::from_ymd_opt(year + 1, 1, 31).is_none_or(|due| today > due);
|
||||
if closed || due {
|
||||
let y = years.entry(year).or_default();
|
||||
y.0.push(e);
|
||||
y.1 |= closed;
|
||||
}
|
||||
}
|
||||
years
|
||||
.into_iter()
|
||||
.map(|(year, (mut ids, closed))| {
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
let ids = ids.iter().map(|i| i.to_string()).collect::<Vec<_>>().join(", ");
|
||||
let when = if closed {
|
||||
", in a closed period".to_string()
|
||||
} else {
|
||||
format!(", a year whose 1099s were due January 31, {}", year + 1)
|
||||
};
|
||||
format!(
|
||||
"This changes {what} for {year} (entries {ids}){when}: if a {year} 1099 was filed, a corrected one may be needed. Tell the human and the CPA."
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `books.set_vendor_1099`'s "changed".
|
||||
pub(super) fn applied_key(a: Applied) -> &'static str {
|
||||
match a {
|
||||
Applied::Set => "set",
|
||||
Applied::Removed => "removed",
|
||||
Applied::Unchanged => "unchanged",
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_router(router = instruments_router, vis = "pub(super)")]
|
||||
impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.add_instrument",
|
||||
title = "Add a payment method",
|
||||
description = "Record a way the company pays, once, so the 1099 worksheet tells card payments (Form 1099-K's, never the company's 1099) from checks. kind: bank (company checks, ACH, wires, bill-pay); business_card or member_card (a company or a member's card the payee charged); member_cash_or_check (a member's own cash, check, ACH, Zelle, or friends-and-family transfer, even card-funded); platform (paid to the payee as a seller through PayPal or Venmo goods and services; never ACH, bill-pay, Zelle or friends-and-family). account: what it pays through (the bank, the card's liability, Due to Members for a member's own). last4 only. Then tag payments with books.set_entry_instrument.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn add_instrument(
|
||||
&self,
|
||||
params: Parameters<AddInstrumentParams>,
|
||||
) -> Result<Json<AddInstrumentOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let kind = InstrumentKind::parse(&p.kind).ok_or_else(|| {
|
||||
format!(
|
||||
"kind {:?}: expected bank, business_card, member_card, member_cash_or_check, or platform",
|
||||
p.kind
|
||||
)
|
||||
})?;
|
||||
let account = resolve_account(&store, &p.account)?;
|
||||
let owner = p
|
||||
.owner_party
|
||||
.as_deref()
|
||||
.map(|r| resolve_party(&store, r))
|
||||
.transpose()?;
|
||||
let last4 = p.last4.as_deref().map(str::trim);
|
||||
let id = store
|
||||
.add_instrument(kind, &p.label, last4, owner, account)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let settings = self.settings();
|
||||
let code = |a: AccountId| {
|
||||
store
|
||||
.ledger()
|
||||
.chart()
|
||||
.get(a)
|
||||
.map(|a| a.code.clone())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let mut warnings = Vec::new();
|
||||
if kind == InstrumentKind::BusinessCard && settings.roles.financing.contains(&code(account))
|
||||
{
|
||||
warnings.push(format!(
|
||||
"account {} is one of the books' financing accounts: a company card's liability usually isn't, and while it is, its payments with no method recorded stay undetermined. Check the financing accounts (books.settings).",
|
||||
code(account)
|
||||
));
|
||||
}
|
||||
if kind.is_card_or_network() && settings.roles.cash.contains(&code(account)) {
|
||||
warnings.push(format!(
|
||||
"account {} is a cash account: only the payments tagged with this method are excluded as card payments; untagged ones from it still count (presumed check, ACH or transfer).",
|
||||
code(account)
|
||||
));
|
||||
}
|
||||
let members = matches!(
|
||||
kind,
|
||||
InstrumentKind::MemberCard | InstrumentKind::MemberCashOrCheck
|
||||
);
|
||||
if members
|
||||
&& !settings.roles.financing.contains(&code(account))
|
||||
&& !settings.roles.cash.contains(&code(account))
|
||||
{
|
||||
warnings.push(format!(
|
||||
"account {} is not one of the books' financing accounts: the 1099 worksheet still holds what members paid through it undetermined until each payment's method is recorded, but name it in books.financing_accounts (books.set_setting) so the books read it as what members paid for the company.",
|
||||
code(account)
|
||||
));
|
||||
}
|
||||
let i = store.ledger().get_instrument(id).expect("just added");
|
||||
Ok(Json(AddInstrumentOut {
|
||||
instrument: instrument_out(&store, i),
|
||||
warnings,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.instruments",
|
||||
title = "Payment methods",
|
||||
description = "The company's payment methods (books.add_instrument), each with its kind, whether its payments are card or payment-network payments (excluded from the company's 1099s), its last four, and the account it pays through. Tag an entry with one by id or label (books.set_entry_instrument, books.draft_entry instrument).",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn instruments(
|
||||
&self,
|
||||
_params: Parameters<InstrumentsParams>,
|
||||
) -> Result<Json<InstrumentsOut>, String> {
|
||||
let store = self.lock()?;
|
||||
Ok(Json(InstrumentsOut {
|
||||
instruments: store
|
||||
.ledger()
|
||||
.instruments()
|
||||
.map(|i| instrument_out(&store, i))
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.set_entry_instrument",
|
||||
title = "Tag an entry's payment method",
|
||||
description = "Tag an entry (draft or posted) with how it was paid (a payment method's id or label, books.instruments), or omit instrument to clear it, so the 1099 worksheet can classify it: a card or payment network's payment is excluded (Form 1099-K's), a bank or a member's own cash or check counts. Needed above all for what members paid for the company (their card or their check: undetermined until tagged). Reporting metadata only: amounts never change. An entry and its reversals are tagged together. Warns when it changes a year whose 1099s are due or a closed period.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
idempotent_hint = true,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn set_entry_instrument(
|
||||
&self,
|
||||
params: Parameters<SetEntryInstrumentParams>,
|
||||
) -> Result<Json<SetEntryInstrumentOut>, String> {
|
||||
let p = params.0;
|
||||
let (changed, warnings) = {
|
||||
let mut store = self.lock()?;
|
||||
let instrument = p
|
||||
.instrument
|
||||
.as_deref()
|
||||
.map(|r| resolve_instrument(&store, r))
|
||||
.transpose()?;
|
||||
let ledger = store.ledger();
|
||||
// The chain's posted payments to a vendor: what the change
|
||||
// reaches on its 1099s.
|
||||
let reached: Vec<(ledger_core::EntryId, NaiveDate)> = ledger
|
||||
.reversal_chain(p.entry_id)
|
||||
.into_iter()
|
||||
.filter_map(|e| ledger.get_entry(e))
|
||||
.filter(|e| {
|
||||
e.status() == ledger_core::EntryStatus::Posted
|
||||
&& ledger.entry_vendor(e.id()).is_some()
|
||||
&& ledger.entry_instrument(e.id()) != instrument
|
||||
})
|
||||
.map(|e| (e.id(), e.date()))
|
||||
.collect();
|
||||
let warnings =
|
||||
filed_year_warnings(ledger, "how a vendor's payment counts", &reached, today());
|
||||
let changed = store
|
||||
.set_entry_instrument(p.entry_id, instrument)
|
||||
.map_err(|e| e.to_string())?;
|
||||
(changed, if changed { warnings } else { Vec::new() })
|
||||
};
|
||||
let entry = self
|
||||
.get_entry(Parameters(GetEntryParams {
|
||||
entry_id: p.entry_id,
|
||||
}))
|
||||
.await?
|
||||
.0;
|
||||
Ok(Json(SetEntryInstrumentOut {
|
||||
entry,
|
||||
changed,
|
||||
warnings,
|
||||
}))
|
||||
}
|
||||
}
|
||||
+180
-19
@@ -367,14 +367,47 @@ pub struct VendorOut {
|
||||
pub id: u64,
|
||||
/// Name.
|
||||
pub name: String,
|
||||
/// "1099-NEC", "1099-MISC", or absent.
|
||||
/// "1099-NEC" or "1099-MISC" when its 1099 decision in force today
|
||||
/// files one; absent otherwise.
|
||||
pub form_1099: Option<String>,
|
||||
/// Its 1099 decision in force today. value "undetermined" with no
|
||||
/// decided_by: nobody has decided.
|
||||
pub decision_1099: Decision1099Out,
|
||||
/// Every 1099 decision recorded, in date order (the one from the
|
||||
/// beginning first): more than one when its status changed on a date.
|
||||
pub decisions_1099: Vec<Decision1099Out>,
|
||||
/// W-9 on file.
|
||||
pub tin_on_file: bool,
|
||||
/// The party the vendor record belongs to.
|
||||
pub party_id: Option<u64>,
|
||||
}
|
||||
|
||||
/// A vendor's 1099 decision.
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
pub struct Decision1099Out {
|
||||
/// "nec", "misc", "not_reportable" or "undetermined".
|
||||
pub value: String,
|
||||
/// With not_reportable: the kind of payee (corporation, tax_exempt,
|
||||
/// united_states, state, foreign_government, foreign_person,
|
||||
/// merchandise, employee, other).
|
||||
pub reason_kind: Option<String>,
|
||||
/// With not_reportable: the facts, in words.
|
||||
pub reason: Option<String>,
|
||||
/// With not_reportable: the rule that exempts that kind of payee.
|
||||
pub authority: Option<String>,
|
||||
/// The day it takes effect, YYYY-MM-DD; absent: from the beginning.
|
||||
pub effective: Option<String>,
|
||||
/// Who decided; absent for a vendor nobody decided, or a decision
|
||||
/// recorded before decisions were kept.
|
||||
pub decided_by: Option<String>,
|
||||
/// When they decided, YYYY-MM-DD.
|
||||
pub decided_on: Option<String>,
|
||||
/// What it rests on, if anything.
|
||||
pub citation: Option<String>,
|
||||
/// Anything kept with it.
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of `books.list_vendors`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ListVendorsOut {
|
||||
@@ -387,12 +420,54 @@ pub struct ListVendorsOut {
|
||||
pub struct SetVendor1099Params {
|
||||
/// Vendor id or name.
|
||||
pub vendor: String,
|
||||
/// "nec" (contractors, attorneys), "misc" (rent, royalties, other), or
|
||||
/// "none" (corporations, most merchants).
|
||||
pub form_1099: String,
|
||||
/// A W-9 is on file (the TIN itself is never stored).
|
||||
#[serde(default)]
|
||||
pub tin_on_file: bool,
|
||||
/// The decision: "nec" (1099-NEC: contractors, freelancers,
|
||||
/// attorneys), "misc" (1099-MISC: rent, royalties, prizes),
|
||||
/// "not_reportable" (with reason_kind and reason), or "undetermined".
|
||||
/// Omit to change only tin_on_file.
|
||||
pub form_1099: Option<String>,
|
||||
/// With not_reportable: the kind of payee no 1099 is required for:
|
||||
/// corporation, tax_exempt, united_states, state, foreign_government,
|
||||
/// foreign_person, merchandise (sells only merchandise), employee, or
|
||||
/// other (give a citation). Never corporation for a payee paid for
|
||||
/// legal services or for medical and health care (a law firm or a
|
||||
/// medical practice, often a "P.C."): those payments stay reportable
|
||||
/// even to a corporation (Treas. Reg. §1.6041-3(p)(1)).
|
||||
pub reason_kind: Option<String>,
|
||||
/// With not_reportable: the facts, in words ("a corporation by name",
|
||||
/// "Delaware public benefit corporation, from the public record").
|
||||
pub reason: Option<String>,
|
||||
/// The day the decision takes effect, YYYY-MM-DD: only when the
|
||||
/// payee's status changed (an LLC's corporate election takes effect
|
||||
/// on its Form 8832 date; a new W-9 showing a new classification, on
|
||||
/// the form's date). Omit for its status from the beginning.
|
||||
pub effective: Option<String>,
|
||||
/// A W-9 is on file (the TIN itself is never stored). Omit to keep it
|
||||
/// as it is.
|
||||
pub tin_on_file: Option<bool>,
|
||||
/// Who decided (a person's name): required with form_1099.
|
||||
pub decided_by: Option<String>,
|
||||
/// When they decided, YYYY-MM-DD. Default: today.
|
||||
pub decided_on: Option<String>,
|
||||
/// The rule or document it rests on ("Treas. Reg. §1.6041-3(p)(1)", a
|
||||
/// W-9 dated 2026-03-02).
|
||||
pub citation: Option<String>,
|
||||
/// Anything else worth keeping with it.
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of `books.set_vendor_1099`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SetVendor1099Out {
|
||||
/// The vendor as it stands now.
|
||||
#[serde(flatten)]
|
||||
pub vendor: VendorOut,
|
||||
/// What the decision did: "set", "removed" (a dated decision taken
|
||||
/// out: the one before it applies again), or "unchanged" (already so;
|
||||
/// nothing journaled but a changed W-9 flag).
|
||||
pub changed: String,
|
||||
/// Payments the change reaches in a closed period, or in a year whose
|
||||
/// 1099s were due: tell the human (a filed 1099 may need correcting).
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.set_entry_vendor`.
|
||||
@@ -421,6 +496,20 @@ pub struct Vendor1099Params {
|
||||
/// with include_tins), to save with its download command.
|
||||
#[serde(default)]
|
||||
pub csv: bool,
|
||||
/// The cash account codes payments are counted out of, e.g.
|
||||
/// `["1000"]`. Defaults to `[books] cash_accounts`, as the CPA
|
||||
/// package's do: one or the other is required.
|
||||
#[serde(default)]
|
||||
pub cash_accounts: Vec<String>,
|
||||
/// The financing liability codes (what the members advanced, loans)
|
||||
/// payments are counted out of. Defaults to `[books]
|
||||
/// financing_accounts`, as the CPA package's do.
|
||||
#[serde(default)]
|
||||
pub financing_accounts: Vec<String>,
|
||||
/// Count only the payments dated on or before this day of `year`
|
||||
/// (YYYY-MM-DD): the worksheet as of a day, the one a CPA package as
|
||||
/// of that day carries. Default: the whole year.
|
||||
pub through: Option<String>,
|
||||
}
|
||||
|
||||
/// One vendor's year.
|
||||
@@ -430,13 +519,37 @@ pub struct Vendor1099LineOut {
|
||||
pub vendor_id: u64,
|
||||
/// Name.
|
||||
pub name: String,
|
||||
/// "1099-NEC", "1099-MISC", or absent.
|
||||
/// "1099-NEC" or "1099-MISC" when its decision at the year's end files
|
||||
/// one; absent otherwise.
|
||||
pub form_1099: Option<String>,
|
||||
/// Its 1099 decision at the year's end.
|
||||
pub decision_1099: Decision1099Out,
|
||||
/// Every decision in force during the year, in date order (more than
|
||||
/// one when its status changed in the year; none when never decided).
|
||||
pub decisions_in_year: Vec<Decision1099Out>,
|
||||
/// W-9 on file.
|
||||
pub tin_on_file: bool,
|
||||
/// Cash paid in the year, decimal string.
|
||||
/// What counts toward its 1099, decimal string: paid by check, ACH,
|
||||
/// transfer or cash (net of refunds and reversals) while it wasn't
|
||||
/// decided not reportable.
|
||||
pub paid: String,
|
||||
/// Marked for a 1099 and paid at least the threshold.
|
||||
/// Paid by card or payment network: reported by the processor on Form
|
||||
/// 1099-K, never on the company's 1099, decimal string.
|
||||
pub excluded_card: String,
|
||||
/// Paid through a financing account (or a member's or a payment
|
||||
/// platform's) with no payment method recorded: counts or not once it
|
||||
/// is (books.set_entry_instrument), decimal string.
|
||||
pub undetermined: String,
|
||||
/// Paid while it was decided not reportable, decimal string.
|
||||
pub not_reportable: String,
|
||||
/// "reportable" (file a 1099), "under_threshold", "not_reportable"
|
||||
/// (its decision says why), or "undetermined" (a decision or a payment
|
||||
/// method is needed first).
|
||||
pub status: String,
|
||||
/// Some of its payments that could count were made while its decision
|
||||
/// was undetermined: ask the human and record one.
|
||||
pub decision_needed: bool,
|
||||
/// File a 1099: status reportable.
|
||||
pub reportable: bool,
|
||||
/// The vendor's party.
|
||||
pub party_id: Option<u64>,
|
||||
@@ -452,6 +565,23 @@ pub struct Vendor1099LineOut {
|
||||
pub tin_note: Option<String>,
|
||||
}
|
||||
|
||||
/// One payment on the 1099 worksheet's lists.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Payment1099Out {
|
||||
/// The entry.
|
||||
pub entry_id: u64,
|
||||
/// Its date, YYYY-MM-DD.
|
||||
pub date: String,
|
||||
/// The vendor it paid.
|
||||
pub vendor_id: u64,
|
||||
/// The vendor's name.
|
||||
pub vendor: String,
|
||||
/// The vendor's part the list is about, decimal string.
|
||||
pub amount: String,
|
||||
/// The payment method it is tagged with (label), if any.
|
||||
pub instrument: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of `books.vendor_1099`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Vendor1099Out {
|
||||
@@ -459,10 +589,26 @@ pub struct Vendor1099Out {
|
||||
pub year: i32,
|
||||
/// Threshold applied, decimal string.
|
||||
pub threshold: String,
|
||||
/// Vendors marked for a 1099 or paid in the year.
|
||||
/// Vendors decided for a 1099 during the year, or with any payment in
|
||||
/// it.
|
||||
pub vendors: Vec<Vendor1099LineOut>,
|
||||
/// Reportable vendors without a W-9 on file: request one.
|
||||
/// Vendors a 1099 is or may be due for without a W-9 on file
|
||||
/// ("(may be reportable)" after the undetermined): request one.
|
||||
pub missing_w9: Vec<String>,
|
||||
/// Payments by card or payment network, excluded (Form 1099-K's).
|
||||
pub excluded_card_payments: Vec<Payment1099Out>,
|
||||
/// Payments with no payment method recorded where it decides whether
|
||||
/// they count: record each with books.set_entry_instrument.
|
||||
pub undetermined_payments: Vec<Payment1099Out>,
|
||||
/// Money received from a vendor for something else (a sale, its
|
||||
/// payment of an invoice, a disposal, a loan), not netted against its
|
||||
/// payments, with the reversals of each.
|
||||
pub other_receipts: Vec<Payment1099Out>,
|
||||
/// Posted entries in the year that paid money out with no vendor tag.
|
||||
pub untagged_entries: Vec<u64>,
|
||||
/// Entries naming several parties whose shares don't resolve (each
|
||||
/// counted in full toward its vendor).
|
||||
pub unresolved: Vec<u64>,
|
||||
/// Caveats for the human and CPA.
|
||||
pub notes: Vec<String>,
|
||||
/// With include_tins: this output holds full TINs, for the CPA.
|
||||
@@ -1208,6 +1354,9 @@ pub struct DraftEntryParams {
|
||||
pub description: String,
|
||||
/// The vendor this entry pays (id or name), for 1099 tracking.
|
||||
pub vendor: Option<String>,
|
||||
/// How it was paid: a payment method's id or label (books.instruments),
|
||||
/// for 1099 tracking.
|
||||
pub instrument: Option<String>,
|
||||
/// The party this entry is with (id or name; see `books.parties`). A
|
||||
/// party with a vendor record also tags the entry's vendor; given with
|
||||
/// `vendor`, both must name the same vendor.
|
||||
@@ -1749,6 +1898,10 @@ pub struct GetEntryOut {
|
||||
pub receipts: Vec<PreviewReceipt>,
|
||||
/// The vendor the entry is tagged with, for 1099 tracking.
|
||||
pub vendor: Option<String>,
|
||||
/// The payment method it is tagged with (label), for 1099 tracking.
|
||||
pub instrument: Option<String>,
|
||||
/// That payment method's id.
|
||||
pub instrument_id: Option<u64>,
|
||||
/// The id of the party the entry is tagged with (its counterparty),
|
||||
/// when it names exactly one.
|
||||
pub party_id: Option<u64>,
|
||||
@@ -3449,8 +3602,10 @@ pub struct ListPartiesOut {
|
||||
/// 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]
|
||||
/// account but receivables when none are set), the cards (the
|
||||
/// business-card payment methods' liabilities, `books.instruments`; while
|
||||
/// none is recorded, 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
|
||||
@@ -3506,13 +3661,19 @@ pub struct PartyTotalsOut {
|
||||
/// Still owed by it now: its open invoices' balances (every year),
|
||||
/// decimal string.
|
||||
pub receivable: String,
|
||||
/// For a party with a vendor record: the cash `books.vendor_1099`
|
||||
/// counts as paid to that vendor in the year (asset accounts only, so
|
||||
/// card payments drop out), decimal string. Absent otherwise.
|
||||
/// For a party with a vendor record: what `books.vendor_1099` counts
|
||||
/// toward that vendor's 1099 in the year (out of the cash and
|
||||
/// financing accounts; card and payment-network payments excluded,
|
||||
/// payments with no method recorded on a financing account left
|
||||
/// undetermined), decimal string. Absent otherwise, or when the
|
||||
/// worksheet can't be made (no cash accounts named).
|
||||
pub amount_1099: Option<String>,
|
||||
/// The information return its vendor record gets ("1099-NEC",
|
||||
/// "1099-MISC"), if any.
|
||||
/// The information return its vendor record's decision in force today
|
||||
/// files ("1099-NEC", "1099-MISC"), if any.
|
||||
pub form_1099: Option<String>,
|
||||
/// Its vendor record's 1099 decision in force today ("nec", "misc",
|
||||
/// "not_reportable", "undetermined"). Absent without a vendor record.
|
||||
pub decision_1099: Option<String>,
|
||||
/// Gross pay on payroll lines naming it, decimal string.
|
||||
pub gross_pay: String,
|
||||
/// Net pay on payroll lines naming it, decimal string.
|
||||
|
||||
@@ -178,7 +178,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(), 109, "expected 109 tools, got {}", tools.len());
|
||||
assert_eq!(tools.len(), 112, "expected 112 tools, got {}", tools.len());
|
||||
// Files come in as tool input: books.begin_upload, never a local path.
|
||||
assert!(tools.iter().any(|t| t["name"] == "books.begin_upload"));
|
||||
assert!(!tools.iter().any(|t| t["name"] == "books.import_local_file"));
|
||||
@@ -820,6 +820,11 @@ fn tin_bearing_files_are_refused_when_given_back_to_be_filed() {
|
||||
"books.add_account",
|
||||
json!({"code": "1000", "name": "Cash", "account_type": "asset"}),
|
||||
);
|
||||
// The 1099 worksheet counts payments out of the books' cash accounts.
|
||||
mcp.call(
|
||||
"books.set_setting",
|
||||
json!({"changes": [{"key": "books.cash_accounts", "value": ["1000"]}], "decided_by": "Test Owner"}),
|
||||
);
|
||||
mcp.call(
|
||||
"books.add_party",
|
||||
json!({"name": "Acme", "roles": ["contractor"]}),
|
||||
@@ -1298,6 +1303,13 @@ fn mcp_statement_pdfs_and_cpa_package_over_stdio() {
|
||||
json!({"as_of": "2026-10-31", "include_tins": false}),
|
||||
);
|
||||
assert!(err.contains("cash_accounts"), "{err}");
|
||||
// The package's 1099 worksheet is books.vendor_1099's for the
|
||||
// package's accounts: the request's, else the settings' (here both
|
||||
// name checking).
|
||||
mcp.call(
|
||||
"books.set_setting",
|
||||
json!({"changes": [{"key": "books.cash_accounts", "value": ["1000"]}], "decided_by": "Test Owner"}),
|
||||
);
|
||||
let pkg = mcp.call(
|
||||
"books.export_cpa_package",
|
||||
json!({"as_of": "2026-10-31", "cash_accounts": ["1000"], "include_tins": false}),
|
||||
@@ -2191,17 +2203,26 @@ fn mcp_vendor_1099_over_stdio() {
|
||||
json!({"code": code, "name": name, "account_type": typ}),
|
||||
);
|
||||
}
|
||||
// The worksheet counts payments out of the books' cash accounts.
|
||||
mcp.call(
|
||||
"books.set_setting",
|
||||
json!({"changes": [{"key": "books.cash_accounts", "value": ["1000"]}], "decided_by": "Test Owner"}),
|
||||
);
|
||||
mcp.call("books.add_vendor", json!({"name": "Dana Designs"}));
|
||||
let v = mcp.call(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "dana designs", "form_1099": "nec"}),
|
||||
json!({"vendor": "dana designs", "form_1099": "nec", "decided_by": "Test Owner"}),
|
||||
);
|
||||
assert_eq!(v["form_1099"], "1099-NEC");
|
||||
assert_eq!(v["decision_1099"]["decided_by"], "Test Owner");
|
||||
let err = mcp.call_err(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Dana Designs", "form_1099": "k"}),
|
||||
json!({"vendor": "Dana Designs", "form_1099": "k", "decided_by": "Test Owner"}),
|
||||
);
|
||||
assert!(
|
||||
err.contains("expected nec, misc, not_reportable, or undetermined"),
|
||||
"{err}"
|
||||
);
|
||||
assert!(err.contains("nec, misc, or none"), "{err}");
|
||||
|
||||
// A 2,500.00 check to Dana, tagged at draft time.
|
||||
let e = mcp.call(
|
||||
@@ -2243,10 +2264,10 @@ fn mcp_vendor_1099_over_stdio() {
|
||||
json!({"year": 2026, "threshold": "3000"}),
|
||||
);
|
||||
assert_eq!(high["vendors"][0]["reportable"], false);
|
||||
// W-9 arrives.
|
||||
// W-9 arrives: the flag alone, the decision kept.
|
||||
mcp.call(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Dana Designs", "form_1099": "nec", "tin_on_file": true}),
|
||||
json!({"vendor": "Dana Designs", "tin_on_file": true}),
|
||||
);
|
||||
drop(mcp);
|
||||
|
||||
|
||||
@@ -485,9 +485,9 @@ fn rich_books(s: &mut Store) -> Rich {
|
||||
let coach = s.add_class("Coach").unwrap();
|
||||
let landlord = s.add_vendor("Landlord").unwrap();
|
||||
let contractor = s.add_vendor("Contractor").unwrap();
|
||||
s.set_vendor_1099(landlord, Some(Form1099::Misc), true)
|
||||
s.set_vendor_1099(landlord, &decided(Form1099::Misc), Some(true))
|
||||
.unwrap();
|
||||
s.set_vendor_1099(contractor, Some(Form1099::Nec), false)
|
||||
s.set_vendor_1099(contractor, &decided(Form1099::Nec), Some(false))
|
||||
.unwrap();
|
||||
s.set_account_tax(id_of(s, "6000"), Some("rent_property"), None)
|
||||
.unwrap();
|
||||
@@ -626,6 +626,52 @@ fn rich_books(s: &mut Store) -> Rich {
|
||||
const START: &str = "2025-01-01";
|
||||
const END: &str = "2025-06-30";
|
||||
|
||||
/// A 1099 decision from the beginning, decided by the owner.
|
||||
fn decided(form: Form1099) -> ledger_core::Dated1099 {
|
||||
ledger_core::Dated1099::from_beginning(
|
||||
ledger_core::Decision1099::Files(form),
|
||||
Some(ledger_core::settings::Source {
|
||||
decided_by: "Oracle Owner".into(),
|
||||
decided_on: d("2025-01-01"),
|
||||
citation: None,
|
||||
note: None,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// The 1099 report the server makes for these books: payments out of
|
||||
/// cash 1000 and financing 2100 (books_toml), no member parties (the
|
||||
/// rich books name none, so no shares resolve against members).
|
||||
fn core_1099(
|
||||
ledger: &Ledger,
|
||||
year: i32,
|
||||
threshold: Option<Decimal>,
|
||||
) -> ledger_core::Vendor1099Report {
|
||||
core_1099_through(ledger, year, threshold, None)
|
||||
}
|
||||
|
||||
/// [`core_1099`], payments dated after `through` left out.
|
||||
fn core_1099_through(
|
||||
ledger: &Ledger,
|
||||
year: i32,
|
||||
threshold: Option<Decimal>,
|
||||
through: Option<chrono::NaiveDate>,
|
||||
) -> ledger_core::Vendor1099Report {
|
||||
let id = |code: &str| ledger.chart().find_by_code(code).unwrap().id;
|
||||
ledger_core::vendor_1099_report(
|
||||
ledger,
|
||||
year,
|
||||
threshold,
|
||||
&ledger_core::PaymentAccounts {
|
||||
cash: vec![id("1000")],
|
||||
financing: vec![id("2100")],
|
||||
},
|
||||
&[],
|
||||
through,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// ------------------------------------------------- statements: csv + json
|
||||
|
||||
/// Trial balance CSV and JSON equal `trial_balance_as_of`, line by line.
|
||||
@@ -1748,10 +1794,13 @@ fn pnl_by_class_excludes_closing_entries() {
|
||||
|
||||
// ------------------------------------------------- 1099
|
||||
|
||||
/// The worksheet equals `vendor_1099_report`; `missing_w9` is the
|
||||
/// reportable vendors without a W-9; the untagged-entries note counts the
|
||||
/// year's posted entries with no vendor; the indexing note appears only
|
||||
/// after 2026. Kills: vendor_1099's year/tag filter and both note guards.
|
||||
/// The worksheet equals `vendor_1099_report` (for the server's cash and
|
||||
/// financing accounts); `missing_w9` is the vendors a 1099 is or may be
|
||||
/// due for without a W-9; the untagged-entries note counts the year's
|
||||
/// untagged entries that paid money out for something (the report's
|
||||
/// untagged_payments, listed as untagged_entries); the indexing note
|
||||
/// appears only after 2026. Kills: vendor_1099's year/tag filter and both
|
||||
/// note guards.
|
||||
#[test]
|
||||
fn vendor_1099_equals_the_core_report_and_counts_untagged_entries() {
|
||||
let dir = test_dir("1099");
|
||||
@@ -1769,11 +1818,9 @@ fn vendor_1099_equals_the_core_report_and_counts_untagged_entries() {
|
||||
let ledger = s.ledger().clone();
|
||||
drop(s);
|
||||
let untagged = |year: i32| {
|
||||
ledger
|
||||
.posted_entries()
|
||||
.filter(|e| chrono::Datelike::year(&e.date()) == year)
|
||||
.filter(|e| ledger.entry_vendor(e.id()).is_none())
|
||||
.count()
|
||||
core_1099(&ledger, year, Some(Decimal::ZERO))
|
||||
.untagged_payments
|
||||
.len()
|
||||
};
|
||||
assert!(untagged(2025) > 0 && untagged(2025) != untagged(2024));
|
||||
let mut m = Mcp::start(&dir, "1000000.00");
|
||||
@@ -1784,8 +1831,9 @@ fn vendor_1099_equals_the_core_report_and_counts_untagged_entries() {
|
||||
args["threshold"] = json!(t);
|
||||
}
|
||||
let out = m.ok("books.vendor_1099", args);
|
||||
let want = ledger_core::vendor_1099_report(&ledger, year, threshold.map(dec)).unwrap();
|
||||
let want = core_1099(&ledger, year, threshold.map(dec));
|
||||
assert_eq!(out["year"], json!(want.year));
|
||||
assert_eq!(out["untagged_entries"], json!(want.untagged_payments));
|
||||
assert_eq!(jamt(&out["threshold"]), want.threshold);
|
||||
let vendors = out["vendors"].as_array().unwrap();
|
||||
assert_eq!(vendors.len(), want.vendors.len(), "{year} vendors");
|
||||
@@ -1793,14 +1841,29 @@ fn vendor_1099_equals_the_core_report_and_counts_untagged_entries() {
|
||||
assert_eq!(g["vendor_id"], json!(w.vendor_id));
|
||||
assert_eq!(g["name"], w.name.as_str());
|
||||
assert_eq!(jamt(&g["paid"]), w.paid, "{year} {}", w.name);
|
||||
assert_eq!(jamt(&g["excluded_card"]), w.excluded_card);
|
||||
assert_eq!(jamt(&g["undetermined"]), w.undetermined);
|
||||
assert_eq!(jamt(&g["not_reportable"]), w.not_reportable);
|
||||
assert_eq!(g["status"], w.status.key());
|
||||
assert_eq!(g["decision_1099"]["value"], w.decision.key());
|
||||
assert_eq!(g["reportable"], json!(w.reportable));
|
||||
assert_eq!(g["tin_on_file"], json!(w.tin_on_file));
|
||||
}
|
||||
let missing: Vec<String> = want
|
||||
.vendors
|
||||
.iter()
|
||||
.filter(|v| v.reportable && !v.tin_on_file)
|
||||
.map(|v| v.name.clone())
|
||||
.filter(|v| !v.tin_on_file)
|
||||
.filter_map(|v| {
|
||||
if v.reportable {
|
||||
Some(v.name.clone())
|
||||
} else if v.status == ledger_core::Status1099::Undetermined
|
||||
&& v.decisions.iter().any(|d| d.decision.form().is_some())
|
||||
{
|
||||
Some(format!("{} (may be reportable)", v.name))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(out["missing_w9"], json!(missing), "{year} missing W-9s");
|
||||
|
||||
@@ -2757,7 +2820,7 @@ fn receipts_and_documents_round_trip() {
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": e, "file_name": "first.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": b64e(first), "sha256": sha256(first),
|
||||
"description": "Receipt ID 1007; Paid from: Bluevine"}),
|
||||
"description": "Receipt ID 1007; Paid from: Quillmere"}),
|
||||
);
|
||||
let b = m.ok(
|
||||
"books.file_document",
|
||||
@@ -2787,7 +2850,7 @@ fn receipts_and_documents_round_trip() {
|
||||
"first.pdf",
|
||||
"application/pdf",
|
||||
Some(e),
|
||||
"Receipt ID 1007; Paid from: Bluevine",
|
||||
"Receipt ID 1007; Paid from: Quillmere",
|
||||
),
|
||||
(
|
||||
&b,
|
||||
@@ -3560,31 +3623,88 @@ fn cpa_package_holds_the_year_and_its_evidence() {
|
||||
// 1099 worksheet.
|
||||
let w = csv_rows(
|
||||
std::str::from_utf8(&files["1099-worksheet-2025.csv"]).unwrap(),
|
||||
&["vendor", "form", "w9_on_file", "paid", "reportable"],
|
||||
&[
|
||||
"vendor_id",
|
||||
"name",
|
||||
"decision_1099",
|
||||
"form_1099",
|
||||
"reason_kind",
|
||||
"reason",
|
||||
"decided_by",
|
||||
"decided_on",
|
||||
"effective",
|
||||
"decisions_in_year",
|
||||
"paid",
|
||||
"excluded_card",
|
||||
"undetermined",
|
||||
"not_reportable",
|
||||
"status",
|
||||
"reportable",
|
||||
"w9_on_file",
|
||||
"tin_last4",
|
||||
],
|
||||
);
|
||||
let want = ledger_core::vendor_1099_report_with_members(
|
||||
&ledger,
|
||||
2025,
|
||||
Some(d(END)),
|
||||
None,
|
||||
&ledger_core::PaymentAccounts::default(),
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
// Through as_of (rules.md B-33), out of the package's cash account
|
||||
// and the settings' financing account (one list each per package).
|
||||
let want = core_1099_through(&ledger, 2025, None, Some(d(END)));
|
||||
assert_eq!(w.len(), want.vendors.len());
|
||||
let contractor = w.iter().find(|row| row[0] == "Contractor").unwrap();
|
||||
let contractor = w.iter().find(|row| row[1] == "Contractor").unwrap();
|
||||
assert_eq!(
|
||||
amt(&contractor[3]),
|
||||
amt(&contractor[10]),
|
||||
dec("900.00"),
|
||||
"the payment after as_of is left out"
|
||||
);
|
||||
for (row, v) in w.iter().zip(&want.vendors) {
|
||||
assert_eq!(row[0], v.name);
|
||||
assert_eq!(row[1], v.form.map(|f| f.label()).unwrap_or(""));
|
||||
assert_eq!(row[2], v.tin_on_file.to_string());
|
||||
assert_eq!(amt(&row[3]), v.paid);
|
||||
assert_eq!(row[4], v.reportable.to_string());
|
||||
assert_eq!(row[0], v.vendor_id.to_string());
|
||||
assert_eq!(row[1], v.name);
|
||||
assert_eq!(row[2], v.decision.key());
|
||||
assert_eq!(row[3], v.form().map(|f| f.label()).unwrap_or(""));
|
||||
assert_eq!(
|
||||
row[6],
|
||||
v.decided.as_ref().map_or("", |s| s.decided_by.as_str())
|
||||
);
|
||||
assert_eq!(amt(&row[10]), v.paid);
|
||||
assert_eq!(amt(&row[11]), v.excluded_card);
|
||||
assert_eq!(amt(&row[12]), v.undetermined);
|
||||
assert_eq!(amt(&row[13]), v.not_reportable);
|
||||
assert_eq!(row[14], v.status.key());
|
||||
assert_eq!(row[15], v.reportable.to_string());
|
||||
assert_eq!(row[16], v.tin_on_file.to_string());
|
||||
}
|
||||
// The package's 1099 worksheet is books.vendor_1099's CSV for the
|
||||
// package's accounts and its as_of, byte for byte (ledger_package
|
||||
// INVARIANT 7, docs/mcp-interface.md): the tool given the same cash
|
||||
// account (the financing account the settings', as the package's)
|
||||
// and through as_of hands over the same file; without through, the
|
||||
// payment after as_of counts, and the files differ.
|
||||
let mut tool_csv = |args: Value| {
|
||||
let out = m.ok("books.vendor_1099", args);
|
||||
handed(&out["csv"])
|
||||
};
|
||||
let same =
|
||||
tool_csv(json!({"year": 2025, "csv": true, "cash_accounts": ["1000"], "through": END}));
|
||||
assert_eq!(same, files["1099-worksheet-2025.csv"]);
|
||||
let named = tool_csv(json!({
|
||||
"year": 2025, "csv": true, "cash_accounts": ["1000"],
|
||||
"financing_accounts": ["2100"], "through": END
|
||||
}));
|
||||
assert_eq!(
|
||||
named, files["1099-worksheet-2025.csv"],
|
||||
"the settings' financing, named"
|
||||
);
|
||||
let whole_year = tool_csv(json!({"year": 2025, "csv": true, "cash_accounts": ["1000"]}));
|
||||
assert_ne!(whole_year, files["1099-worksheet-2025.csv"]);
|
||||
// A through outside the year, or an account the chart lacks: refused.
|
||||
let why = m.refuse(
|
||||
"books.vendor_1099",
|
||||
json!({"year": 2025, "through": "2026-01-01"}),
|
||||
);
|
||||
assert!(why.contains("is a day of 2025"), "{why}");
|
||||
let why = m.refuse(
|
||||
"books.vendor_1099",
|
||||
json!({"year": 2025, "cash_accounts": ["1999"]}),
|
||||
);
|
||||
assert!(why.contains("no account 1999"), "{why}");
|
||||
|
||||
// Without receipts: no evidence at all.
|
||||
let out = m.ok(
|
||||
|
||||
@@ -0,0 +1,929 @@
|
||||
//! The 1099 decision and payment methods through the server (plan P3,
|
||||
//! defects 12, 18 and 23; D-40), driven over stdio against the real
|
||||
//! binary: every success validates against the tool's outputSchema, and
|
||||
//! every refusal leaves `journal.jsonl` byte-identical (the harness is
|
||||
//! `tests/spec_parties.rs`'s). Every name is fictional; the reasons are
|
||||
//! the ones the first company's cutover records.
|
||||
|
||||
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-1099-{}-{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-1099", "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()
|
||||
}
|
||||
|
||||
fn party(&mut self, party: &str, year: i32) -> Value {
|
||||
self.read_only("books.party", json!({"party": party, "year": year}))
|
||||
}
|
||||
|
||||
/// 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", "Bank", "asset"),
|
||||
("1300", "Prepaid Expenses", "asset"),
|
||||
("2050", "Card", "liability"),
|
||||
("2100", "Due to Members", "liability"),
|
||||
("3000", "Capital", "equity"),
|
||||
("4000", "Revenue", "revenue"),
|
||||
("6000", "Contract Labor", "expense"),
|
||||
("6999", "General Expenses", "expense"),
|
||||
];
|
||||
|
||||
/// Books with the chart; cash is 1000, financing 2100.
|
||||
fn books(name: &str) -> Mcp {
|
||||
let dir = test_dir(name);
|
||||
let toml = books_toml(&dir, "Spec Co", "financing_accounts = [\"2100\"]\n");
|
||||
let mut m = Mcp::start(&dir, &toml);
|
||||
m.accounts(CHART);
|
||||
m.books_dir = Some(dir);
|
||||
m
|
||||
}
|
||||
|
||||
impl Mcp {
|
||||
/// The same books, served again (a restart).
|
||||
fn restart(mut self) -> Mcp {
|
||||
let dir = self.books_dir.take().expect("books_dir");
|
||||
drop(self);
|
||||
let mut m = Mcp::start(&dir, &dir.join("wanderbooks.toml"));
|
||||
m.books_dir = Some(dir);
|
||||
m
|
||||
}
|
||||
}
|
||||
|
||||
/// A vendor's decision as books.list_vendors shows it today.
|
||||
fn decision(m: &mut Mcp, vendor: &str) -> Value {
|
||||
let all = m.read_only("books.list_vendors", json!({}));
|
||||
all["vendors"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|v| v["name"] == vendor)
|
||||
.unwrap_or_else(|| panic!("{vendor} not listed: {all}"))
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// A vendor's line on the 2026 worksheet.
|
||||
fn line_2026(m: &mut Mcp, vendor: &str) -> Value {
|
||||
let w = m.read_only("books.vendor_1099", json!({"year": 2026}));
|
||||
w["vendors"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|v| v["name"] == vendor)
|
||||
.unwrap_or_else(|| panic!("{vendor} not on the worksheet: {w}"))
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Defect 23, from its reproduction (P3 brief 1.1): "none" stored the same
|
||||
/// null as a vendor nobody decided on. Now "none" is refused with what to
|
||||
/// say instead, not_reportable is kept with its reason and who decided
|
||||
/// when, a fresh vendor reads undetermined with no one named, and both
|
||||
/// survive a restart.
|
||||
#[test]
|
||||
fn a_vendor_marked_no_1099_is_not_undecided() {
|
||||
let mut m = books("decided");
|
||||
m.ok("books.add_vendor", json!({"name": "Bigco"}));
|
||||
m.ok("books.add_vendor", json!({"name": "Newco"}));
|
||||
for none in ["none", "", "None", "no_1099"] {
|
||||
let why = m.refuse(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Bigco", "form_1099": none, "decided_by": "Riley Chen"}),
|
||||
);
|
||||
assert!(
|
||||
why.contains("not_reportable") && why.contains("undetermined"),
|
||||
"{why}"
|
||||
);
|
||||
assert!(why.contains("Paid by card is not a reason"), "{why}");
|
||||
}
|
||||
let v = m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Bigco", "form_1099": "not_reportable", "reason_kind": "corporation",
|
||||
"reason": "a corporation by name", "decided_by": "Riley Chen",
|
||||
"decided_on": "2026-10-16", "citation": "Treas. Reg. §1.6041-3(p)(1)"}),
|
||||
);
|
||||
assert_eq!(v["changed"], "set");
|
||||
assert!(v["warnings"].as_array().unwrap().is_empty());
|
||||
let check = |m: &mut Mcp| {
|
||||
let b = decision(m, "Bigco");
|
||||
assert!(b["form_1099"].is_null(), "{b}");
|
||||
let d = &b["decision_1099"];
|
||||
assert_eq!(d["value"], "not_reportable");
|
||||
assert_eq!(d["reason_kind"], "corporation");
|
||||
assert_eq!(d["reason"], "a corporation by name");
|
||||
assert_eq!(d["authority"], "Treas. Reg. §1.6041-3(p)(1)");
|
||||
assert_eq!(d["decided_by"], "Riley Chen");
|
||||
assert_eq!(d["decided_on"], "2026-10-16");
|
||||
assert_eq!(d["citation"], "Treas. Reg. §1.6041-3(p)(1)");
|
||||
assert!(d["effective"].is_null(), "from the beginning");
|
||||
assert_eq!(b["decisions_1099"].as_array().unwrap().len(), 1);
|
||||
let n = decision(m, "Newco");
|
||||
assert_eq!(n["decision_1099"]["value"], "undetermined");
|
||||
assert!(n["decision_1099"]["decided_by"].is_null(), "nobody decided");
|
||||
assert!(n["decisions_1099"].as_array().unwrap().is_empty());
|
||||
};
|
||||
check(&mut m);
|
||||
assert_eq!(m.count("vendor.1099_set"), 1);
|
||||
let mut m = m.restart();
|
||||
check(&mut m);
|
||||
}
|
||||
|
||||
/// D-40: the first company's cutover records exactly these decisions (a
|
||||
/// reason kind and its words), and each comes back as given.
|
||||
#[test]
|
||||
fn the_cutovers_reasons_are_recorded_as_given() {
|
||||
let mut m = books("cutover");
|
||||
for (vendor, kind, reason, citation) in [
|
||||
(
|
||||
"Northwind Research, PBC",
|
||||
"corporation",
|
||||
"a corporation (Delaware public benefit corporation, from the public record)",
|
||||
None,
|
||||
),
|
||||
(
|
||||
"Tidewater Hosting, Inc.",
|
||||
"corporation",
|
||||
"a corporation by name",
|
||||
Some("Treas. Reg. §1.6041-3(p)(1)"),
|
||||
),
|
||||
(
|
||||
"Federal Parcel Service",
|
||||
"united_states",
|
||||
"the United States",
|
||||
Some("Treas. Reg. §1.6041-3(p)(3)"),
|
||||
),
|
||||
] {
|
||||
m.ok("books.add_vendor", json!({"name": vendor}));
|
||||
let mut args = json!({"vendor": vendor, "form_1099": "not_reportable", "reason_kind": kind,
|
||||
"reason": reason, "decided_by": "Riley Chen", "decided_on": "2026-10-16"});
|
||||
if let Some(c) = citation {
|
||||
args["citation"] = json!(c);
|
||||
}
|
||||
m.ok("books.set_vendor_1099", args);
|
||||
let d = decision(&mut m, vendor)["decision_1099"].clone();
|
||||
assert_eq!(
|
||||
(
|
||||
d["value"].as_str(),
|
||||
d["reason_kind"].as_str(),
|
||||
d["reason"].as_str()
|
||||
),
|
||||
(Some("not_reportable"), Some(kind), Some(reason))
|
||||
);
|
||||
assert_eq!(d["citation"].as_str(), citation);
|
||||
}
|
||||
}
|
||||
|
||||
/// INVARIANTS 1-3 of tools/instruments.rs: what a decision needs, each
|
||||
/// refusal saying why and changing nothing.
|
||||
#[test]
|
||||
fn set_vendor_1099_says_what_a_decision_needs() {
|
||||
let mut m = books("refusals");
|
||||
m.ok("books.add_vendor", json!({"name": "Harbor Design LLC"}));
|
||||
let v = "Harbor Design LLC";
|
||||
let by = "Riley Chen";
|
||||
for (args, needle) in [
|
||||
(
|
||||
json!({"vendor": v, "form_1099": "not_reportable", "reason": "x", "decided_by": by}),
|
||||
"reason_kind",
|
||||
),
|
||||
(
|
||||
json!({"vendor": v, "form_1099": "not_reportable", "reason_kind": "corporation", "decided_by": by}),
|
||||
"needs reason",
|
||||
),
|
||||
(
|
||||
json!({"vendor": v, "form_1099": "not_reportable", "reason_kind": "paid_by_card", "reason": "x", "decided_by": by}),
|
||||
"paid by card is not a reason",
|
||||
),
|
||||
(
|
||||
json!({"vendor": v, "form_1099": "not_reportable", "reason_kind": "other", "reason": "x", "decided_by": by}),
|
||||
"citation",
|
||||
),
|
||||
(
|
||||
json!({"vendor": v, "form_1099": "nec", "reason": "x", "decided_by": by}),
|
||||
"go with not_reportable",
|
||||
),
|
||||
(json!({"vendor": v, "form_1099": "nec"}), "decided_by"),
|
||||
(
|
||||
json!({"vendor": v, "form_1099": "nec", "effective": "2026-07-01", "decided_by": by}),
|
||||
"from the beginning",
|
||||
),
|
||||
(
|
||||
json!({"vendor": v, "form_1099": "not_reportable", "reason_kind": "corporation", "reason": "EIN 12-3456789", "decided_by": by}),
|
||||
"EIN",
|
||||
),
|
||||
(json!({"vendor": v}), "nothing to change"),
|
||||
(
|
||||
json!({"vendor": v, "reason": "x", "tin_on_file": true}),
|
||||
"go with form_1099",
|
||||
),
|
||||
(
|
||||
json!({"vendor": v, "form_1099": "1099-k", "decided_by": by}),
|
||||
"expected nec",
|
||||
),
|
||||
(
|
||||
json!({"vendor": "Nobody", "form_1099": "nec", "decided_by": by}),
|
||||
"vendor",
|
||||
),
|
||||
] {
|
||||
let why = m.refuse("books.set_vendor_1099", args.clone());
|
||||
assert!(why.contains(needle), "{args}: {why}");
|
||||
}
|
||||
assert_eq!(m.count("vendor.1099_set"), 0);
|
||||
assert_eq!(m.count("journal.format_set"), 0, "nothing declared either");
|
||||
}
|
||||
|
||||
/// P3 brief 1.5 and review R8: tin_on_file omitted keeps the W-9 flag;
|
||||
/// tin_on_file alone keeps the decision and who made it.
|
||||
#[test]
|
||||
fn set_vendor_1099_keeps_the_w9_flag_when_omitted() {
|
||||
let mut m = books("w9-flag");
|
||||
m.ok("books.add_vendor", json!({"name": "Harbor Design LLC"}));
|
||||
let v = "Harbor Design LLC";
|
||||
m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": v, "form_1099": "nec", "tin_on_file": true, "decided_by": "Riley Chen", "decided_on": "2026-03-02"}),
|
||||
);
|
||||
let out = m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": v, "form_1099": "misc", "decided_by": "Riley Chen", "decided_on": "2026-04-01"}),
|
||||
);
|
||||
assert_eq!(out["tin_on_file"], true, "omitted: kept");
|
||||
assert_eq!(out["form_1099"], "1099-MISC");
|
||||
let out = m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": v, "tin_on_file": false}),
|
||||
);
|
||||
assert_eq!(out["changed"], "unchanged");
|
||||
assert_eq!(out["tin_on_file"], false);
|
||||
assert_eq!(out["decision_1099"]["value"], "misc");
|
||||
assert_eq!(out["decision_1099"]["decided_on"], "2026-04-01");
|
||||
assert_eq!(m.count("vendor.1099_set"), 2);
|
||||
assert_eq!(m.count("vendor.w9_flag_set"), 1);
|
||||
// Already so: nothing journaled.
|
||||
m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": v, "tin_on_file": false}),
|
||||
);
|
||||
assert_eq!(m.count("vendor.w9_flag_set"), 1);
|
||||
}
|
||||
|
||||
/// P3 brief 4.4 and review R8: recording a W-9 (books.set_w9) marks the
|
||||
/// vendor's W-9 on file and keeps its 1099 decision with who made it and
|
||||
/// when, verbatim.
|
||||
#[test]
|
||||
fn set_w9_keeps_the_1099_decision_and_its_source() {
|
||||
let mut m = books("set-w9");
|
||||
m.add_party("Harbor Design LLC", &["contractor"]);
|
||||
m.ok("books.add_vendor", json!({"name": "Harbor Design LLC"}));
|
||||
m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Harbor Design LLC", "form_1099": "nec", "decided_by": "Riley Chen",
|
||||
"decided_on": "2026-01-05", "citation": "W-9 requested"}),
|
||||
);
|
||||
let before = decision(&mut m, "Harbor Design LLC")["decision_1099"].clone();
|
||||
m.ok(
|
||||
"books.set_w9",
|
||||
json!({"party": "Harbor Design LLC", "received_on": "2026-03-02",
|
||||
"entity_type": "LLC (partnership)", "tin_last4": "4321", "kept_at": "CPA portal"}),
|
||||
);
|
||||
let after = decision(&mut m, "Harbor Design LLC");
|
||||
assert_eq!(after["tin_on_file"], true);
|
||||
assert_eq!(after["decision_1099"], before);
|
||||
assert_eq!(m.count("vendor.1099_set"), 1, "no decision re-journaled");
|
||||
assert_eq!(m.count("vendor.w9_flag_set"), 1);
|
||||
}
|
||||
|
||||
/// A member's method on an account the books don't name as financing
|
||||
/// warns: the worksheet holds its untagged payments undetermined (never a
|
||||
/// card's), and the settings should say what the account is.
|
||||
#[test]
|
||||
fn a_members_method_off_the_financing_accounts_warns() {
|
||||
let mut m = books("member-method");
|
||||
m.ok(
|
||||
"books.add_account",
|
||||
json!({"code": "2110", "name": "Due to Riley", "account_type": "liability"}),
|
||||
);
|
||||
let out = m.ok(
|
||||
"books.add_instrument",
|
||||
json!({"kind": "member_card", "label": "Riley's Visa", "account": "2110"}),
|
||||
);
|
||||
let w = out["warnings"].as_array().unwrap();
|
||||
assert_eq!(w.len(), 1, "{out}");
|
||||
assert!(
|
||||
w[0].as_str()
|
||||
.unwrap()
|
||||
.contains("not one of the books' financing accounts"),
|
||||
"{out}"
|
||||
);
|
||||
let out = m.ok(
|
||||
"books.add_instrument",
|
||||
json!({"kind": "member_cash_or_check", "label": "Riley's checks", "account": "2100"}),
|
||||
);
|
||||
assert!(out["warnings"].as_array().unwrap().is_empty(), "{out}");
|
||||
}
|
||||
|
||||
/// B-27 and the format-upgrade gate (D-39): books.set_w9 on books that
|
||||
/// never used the vendor_1099_decisions feature journals the W-9 and then
|
||||
/// the vendor's W-9 flag, a line of that feature, so the feature is
|
||||
/// declared before the command's first line, never between its lines
|
||||
/// (where a refused upgrade would leave the W-9 recorded and the flag
|
||||
/// not).
|
||||
#[test]
|
||||
fn set_w9_declares_the_w9_flags_feature_before_its_first_line() {
|
||||
let mut m = books("set-w9-feature");
|
||||
m.add_party("Harbor Design LLC", &["contractor"]);
|
||||
m.ok("books.add_vendor", json!({"name": "Harbor Design LLC"}));
|
||||
let before = m.journal_types().len();
|
||||
m.ok(
|
||||
"books.set_w9",
|
||||
json!({"party": "Harbor Design LLC", "received_on": "2026-03-02",
|
||||
"entity_type": "LLC (partnership)", "tin_last4": "4321", "kept_at": "CPA portal"}),
|
||||
);
|
||||
let added = m.journal_types()[before..].to_vec();
|
||||
assert_eq!(
|
||||
added,
|
||||
["journal.format_set", "party.w9_set", "vendor.w9_flag_set"],
|
||||
"the declaration comes first"
|
||||
);
|
||||
assert_eq!(decision(&mut m, "Harbor Design LLC")["tin_on_file"], true);
|
||||
}
|
||||
|
||||
/// Defect 12 end to end, restarting between steps (P3 brief 8.2): payment
|
||||
/// methods added (and refused when unsound), a draft taking one, a posted
|
||||
/// entry and its reversal tagged together, and the worksheet showing what
|
||||
/// counts, what a card paid, and what is undetermined.
|
||||
#[test]
|
||||
fn payment_methods_round_trip_and_classify_payments() {
|
||||
let mut m = books("methods");
|
||||
m.add_party("Riley Chen", &["member"]);
|
||||
let added = m.ok(
|
||||
"books.add_instrument",
|
||||
json!({"kind": "bank", "label": "Harbor Bank checking", "last4": "1234", "account": "1000"}),
|
||||
);
|
||||
assert_eq!(added["card_or_network"], false);
|
||||
assert!(added["warnings"].as_array().unwrap().is_empty());
|
||||
m.ok(
|
||||
"books.add_instrument",
|
||||
json!({"kind": "business_card", "label": "Harbor Visa", "last4": "0042", "account": "2050"}),
|
||||
);
|
||||
let visa = m.ok(
|
||||
"books.add_instrument",
|
||||
json!({"kind": "member_card", "label": "Riley's Visa", "owner_party": "Riley Chen", "account": "2100"}),
|
||||
);
|
||||
assert_eq!(visa["card_or_network"], true);
|
||||
assert_eq!(visa["account"], "2100");
|
||||
m.ok(
|
||||
"books.add_instrument",
|
||||
json!({"kind": "member_cash_or_check", "label": "Riley's checks", "account": "2100"}),
|
||||
);
|
||||
for (args, needle) in [
|
||||
(
|
||||
json!({"kind": "card", "label": "x", "account": "2050"}),
|
||||
"kind",
|
||||
),
|
||||
(
|
||||
json!({"kind": "bank", "label": "Savings", "last4": "12345", "account": "1000"}),
|
||||
"four digits",
|
||||
),
|
||||
(
|
||||
json!({"kind": "bank", "label": "harbor visa", "account": "1000"}),
|
||||
"already named",
|
||||
),
|
||||
(
|
||||
json!({"kind": "bank", "label": "Capital", "account": "3000"}),
|
||||
"asset",
|
||||
),
|
||||
(
|
||||
json!({"kind": "bank", "label": "Nowhere", "account": "9999"}),
|
||||
"unknown account",
|
||||
),
|
||||
] {
|
||||
let why = m.refuse("books.add_instrument", args.clone());
|
||||
assert!(why.contains(needle), "{args}: {why}");
|
||||
}
|
||||
m.ok("books.add_vendor", json!({"name": "Dana Studio"}));
|
||||
m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Dana Studio", "form_1099": "nec", "decided_by": "Riley Chen"}),
|
||||
);
|
||||
let dana = json!({"vendor": "Dana Studio", "instrument": "Riley's Visa"});
|
||||
let e1 = m.post(
|
||||
"2026-05-01",
|
||||
dana,
|
||||
&[("6000", "debit", "2500.00"), ("2100", "credit", "2500.00")],
|
||||
);
|
||||
assert_eq!(
|
||||
m.read_only("books.get_entry", json!({"entry_id": e1}))["instrument"],
|
||||
"Riley's Visa"
|
||||
);
|
||||
let e2 = m.post(
|
||||
"2026-05-02",
|
||||
json!({"vendor": "Dana Studio"}),
|
||||
&[("6000", "debit", "3000.00"), ("2100", "credit", "3000.00")],
|
||||
);
|
||||
let e3 = m.post(
|
||||
"2026-05-03",
|
||||
json!({"vendor": "Dana Studio"}),
|
||||
&[("6000", "debit", "400.00"), ("2050", "credit", "400.00")],
|
||||
);
|
||||
let mut m = m.restart();
|
||||
let d = line_2026(&mut m, "Dana Studio");
|
||||
assert_eq!(
|
||||
(
|
||||
&d["paid"],
|
||||
&d["excluded_card"],
|
||||
&d["undetermined"],
|
||||
&d["status"]
|
||||
),
|
||||
(
|
||||
&json!("0.00"),
|
||||
&json!("2900.00"),
|
||||
&json!("3000.00"),
|
||||
&json!("undetermined")
|
||||
)
|
||||
);
|
||||
let w = m.read_only("books.vendor_1099", json!({"year": 2026}));
|
||||
let listed = |k: &str| -> Vec<(u64, String, Value)> {
|
||||
w[k].as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|p| {
|
||||
(
|
||||
p["entry_id"].as_u64().unwrap(),
|
||||
p["amount"].as_str().unwrap().to_string(),
|
||||
p["instrument"].clone(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(
|
||||
listed("excluded_card_payments"),
|
||||
[
|
||||
(e1, "2500.00".into(), json!("Riley's Visa")),
|
||||
(e3, "400.00".into(), Value::Null)
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
listed("undetermined_payments"),
|
||||
[(e2, "3000.00".into(), Value::Null)]
|
||||
);
|
||||
assert!(
|
||||
w["notes"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|n| n.as_str().unwrap().contains("books.set_entry_instrument")),
|
||||
"{w}"
|
||||
);
|
||||
// The member paid e2 by check, and it was reversed: tagging either
|
||||
// tags both.
|
||||
let rev = m.ok(
|
||||
"books.reverse_entry",
|
||||
json!({"entry_id": e2, "date": "2026-05-04", "description": "paid twice", "confirmed": true}),
|
||||
)["reversal_id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let out = m.ok(
|
||||
"books.set_entry_instrument",
|
||||
json!({"entry_id": e2, "instrument": "Riley's checks"}),
|
||||
);
|
||||
assert_eq!(out["changed"], true);
|
||||
assert_eq!(out["instrument"], "Riley's checks");
|
||||
assert!(out["warnings"].as_array().unwrap().is_empty());
|
||||
let mut m = m.restart();
|
||||
assert_eq!(
|
||||
m.read_only("books.get_entry", json!({"entry_id": rev}))["instrument"],
|
||||
"Riley's checks"
|
||||
);
|
||||
let d = line_2026(&mut m, "Dana Studio");
|
||||
assert_eq!(
|
||||
(&d["paid"], &d["undetermined"], &d["status"]),
|
||||
(&json!("0.00"), &json!("0.00"), &json!("under_threshold"))
|
||||
);
|
||||
let all = m.read_only("books.instruments", json!({}));
|
||||
let labels: Vec<(&str, bool)> = all["instruments"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|i| {
|
||||
(
|
||||
i["label"].as_str().unwrap(),
|
||||
i["card_or_network"].as_bool().unwrap(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
labels,
|
||||
[
|
||||
("Harbor Bank checking", false),
|
||||
("Harbor Visa", true),
|
||||
("Riley's Visa", true),
|
||||
("Riley's checks", false)
|
||||
]
|
||||
);
|
||||
// Cleared again: back to undetermined, both of the chain.
|
||||
m.ok("books.set_entry_instrument", json!({"entry_id": rev}));
|
||||
assert!(m.read_only("books.get_entry", json!({"entry_id": e2}))["instrument"].is_null());
|
||||
let p = m.party("Dana Studio", 2026);
|
||||
assert_eq!(p["totals"]["decision_1099"], "nec");
|
||||
assert_eq!(p["totals"]["form_1099"], "1099-NEC");
|
||||
}
|
||||
|
||||
/// D-40 and P3 review R15: a decision changed for payments in a closed
|
||||
/// period, or in a year whose 1099s were due (January 31 of the next
|
||||
/// year), warns, naming the year and the entries; one reaching only this
|
||||
/// year's open payments doesn't.
|
||||
#[test]
|
||||
fn a_change_reaching_a_filed_year_warns() {
|
||||
let mut m = books("warns");
|
||||
m.ok("books.add_vendor", json!({"name": "Harbor Design LLC"}));
|
||||
let v = "Harbor Design LLC";
|
||||
m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": v, "form_1099": "nec", "decided_by": "Riley Chen"}),
|
||||
);
|
||||
let harbor = json!({"vendor": v});
|
||||
let pay = |m: &mut Mcp, date: &str| {
|
||||
m.post(
|
||||
date,
|
||||
harbor.clone(),
|
||||
&[("6000", "debit", "700.00"), ("1000", "credit", "700.00")],
|
||||
)
|
||||
};
|
||||
let e2024 = pay(&mut m, "2024-03-01");
|
||||
let e2025 = pay(&mut m, "2025-03-01");
|
||||
pay(&mut m, "2026-03-01");
|
||||
let period = m.ok(
|
||||
"books.create_period",
|
||||
json!({"name": "FY2025", "start": "2025-01-01", "end": "2025-12-31"}),
|
||||
)["period_id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
m.ok(
|
||||
"books.close_period",
|
||||
json!({"period_id": period, "confirmed": true}),
|
||||
);
|
||||
// From July 2026 on: only an open, not-yet-due year's payments: quiet.
|
||||
let out = m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": v, "form_1099": "not_reportable", "reason_kind": "corporation",
|
||||
"reason": "elected to be taxed as a corporation", "effective": "2026-07-01",
|
||||
"decided_by": "Riley Chen"}),
|
||||
);
|
||||
assert!(out["warnings"].as_array().unwrap().is_empty(), "{out}");
|
||||
assert_eq!(out["decisions_1099"].as_array().unwrap().len(), 2);
|
||||
// From the beginning: reaches 2024 (1099s due 2025-01-31) and the
|
||||
// closed 2025.
|
||||
let out = m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": v, "form_1099": "not_reportable", "reason_kind": "corporation",
|
||||
"reason": "a corporation by name", "decided_by": "Riley Chen"}),
|
||||
);
|
||||
let warnings: Vec<String> = out["warnings"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|w| w.as_str().unwrap().to_string())
|
||||
.collect();
|
||||
assert_eq!(warnings.len(), 2, "{warnings:?}");
|
||||
assert!(warnings[0].contains("for 2024") && warnings[0].contains(&format!("entries {e2024}")));
|
||||
assert!(warnings[0].contains("due January 31"), "{}", warnings[0]);
|
||||
assert!(warnings[1].contains("for 2025") && warnings[1].contains("closed period"));
|
||||
assert!(warnings.iter().all(|w| w.contains("corrected")));
|
||||
// A payment method on the closed year's payment warns too.
|
||||
m.ok(
|
||||
"books.add_instrument",
|
||||
json!({"kind": "business_card", "label": "Harbor debit card", "account": "1000"}),
|
||||
);
|
||||
let out = m.ok(
|
||||
"books.set_entry_instrument",
|
||||
json!({"entry_id": e2025, "instrument": "Harbor debit card"}),
|
||||
);
|
||||
let w = out["warnings"].as_array().unwrap();
|
||||
assert_eq!(w.len(), 1, "{out}");
|
||||
assert!(w[0].as_str().unwrap().contains("closed period"));
|
||||
}
|
||||
@@ -783,9 +783,15 @@ fn happy_path(name: &str) -> Mcp {
|
||||
.unwrap();
|
||||
m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Landlord", "form_1099": "misc", "tin_on_file": true}),
|
||||
json!({"vendor": "Landlord", "form_1099": "misc", "tin_on_file": true, "decided_by": "Test Owner"}),
|
||||
);
|
||||
m.ok("books.list_vendors", json!({}));
|
||||
// Payment methods (plan P3, defect 12).
|
||||
m.ok(
|
||||
"books.add_instrument",
|
||||
json!({"kind": "business_card", "label": "Harbor Visa", "last4": "0042", "account": "1000"}),
|
||||
);
|
||||
m.ok("books.instruments", json!({}));
|
||||
m.ok(
|
||||
"books.add_party",
|
||||
json!({"name": "Pat", "roles": ["employee"]}),
|
||||
@@ -872,6 +878,12 @@ fn happy_path(name: &str) -> Mcp {
|
||||
"books.set_entry_party",
|
||||
json!({"entry_id": e2, "party": "Landlord"}),
|
||||
);
|
||||
// Paid with the debit card, then the tag cleared.
|
||||
m.ok(
|
||||
"books.set_entry_instrument",
|
||||
json!({"entry_id": e2, "instrument": "Harbor Visa"}),
|
||||
);
|
||||
m.ok("books.set_entry_instrument", json!({"entry_id": e2}));
|
||||
// Replace the receipt: attach new, unlink old, retire it.
|
||||
m.attach(Some(e2), b"rent receipt v2");
|
||||
m.ok(
|
||||
@@ -2154,7 +2166,7 @@ fn inv6_ambiguous_vendor_reference_refused() {
|
||||
let amb = acme.to_string();
|
||||
m.refuse(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": amb, "form_1099": "nec", "tin_on_file": false}),
|
||||
json!({"vendor": amb, "form_1099": "nec", "tin_on_file": false, "decided_by": "Test Owner"}),
|
||||
);
|
||||
m.refuse(
|
||||
"books.draft_entry",
|
||||
@@ -2169,7 +2181,7 @@ fn inv6_ambiguous_vendor_reference_refused() {
|
||||
// that name).
|
||||
m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Acme", "form_1099": "nec", "tin_on_file": false}),
|
||||
json!({"vendor": "Acme", "form_1099": "nec", "tin_on_file": false, "decided_by": "Test Owner"}),
|
||||
);
|
||||
let out = m.ok(
|
||||
"books.set_entry_vendor",
|
||||
|
||||
@@ -761,7 +761,7 @@ fn set_entry_party_tags_the_vendor_so_vendor_1099_counts_it() {
|
||||
m.ok("books.add_vendor", json!({"name": "Landlord LLC"}));
|
||||
m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Landlord LLC", "form_1099": "misc"}),
|
||||
json!({"vendor": "Landlord LLC", "form_1099": "misc", "decided_by": "Test Owner"}),
|
||||
);
|
||||
let rent = m.post(
|
||||
"2026-02-01",
|
||||
@@ -862,7 +862,7 @@ fn party_totals_match_a_hand_computed_year() {
|
||||
.unwrap();
|
||||
m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Acme Design", "form_1099": "nec"}),
|
||||
json!({"vendor": "Acme Design", "form_1099": "nec", "decided_by": "Test Owner"}),
|
||||
);
|
||||
let acme = json!({"party": "Acme Design"});
|
||||
// Last year: not in 2026.
|
||||
@@ -1122,7 +1122,7 @@ fn parties_lists_by_id_and_filters_by_role() {
|
||||
let mut m = books("list");
|
||||
let a = m.add_party("Acme Design", &["contractor"]);
|
||||
let i = m.add_party("IRS", &["tax_agency"]);
|
||||
let b = m.add_party("Bluevine", &["financial_institution"]);
|
||||
let b = m.add_party("Quillmere", &["financial_institution"]);
|
||||
let u = m.add_party("Utah Division of Corporations", &["government_agency"]);
|
||||
let all = m.read_only("books.parties", json!({}));
|
||||
assert!(all["year"].is_i64(), "defaults to this year");
|
||||
@@ -1284,7 +1284,7 @@ fn require_refuses_until_tagged_or_marked_none() {
|
||||
m.read_only("books.settings", json!({}))["parties"],
|
||||
"require"
|
||||
);
|
||||
let bank = m.add_party("Bluevine", &["financial_institution"]);
|
||||
let bank = m.add_party("Quillmere", &["financial_institution"]);
|
||||
let id = m.draft(
|
||||
"2026-03-01",
|
||||
json!({}),
|
||||
@@ -1323,18 +1323,18 @@ fn require_refuses_until_tagged_or_marked_none() {
|
||||
// Both at once is refused.
|
||||
let e = m.refuse(
|
||||
"books.draft_entry",
|
||||
json!({"date": "2026-03-03", "description": "x", "party": "Bluevine", "no_party_reason": "none"}),
|
||||
json!({"date": "2026-03-03", "description": "x", "party": "Quillmere", "no_party_reason": "none"}),
|
||||
);
|
||||
assert!(e.contains("no_party_reason"), "{e}");
|
||||
let e = m.refuse(
|
||||
"books.set_entry_party",
|
||||
json!({"entry_id": t, "party": "Bluevine", "no_party_reason": "none"}),
|
||||
json!({"entry_id": t, "party": "Quillmere", "no_party_reason": "none"}),
|
||||
);
|
||||
assert!(e.contains("not both"), "{e}");
|
||||
// Tagging later clears the mark; marking again needs no party.
|
||||
m.ok(
|
||||
"books.set_entry_party",
|
||||
json!({"entry_id": t, "party": "Bluevine"}),
|
||||
json!({"entry_id": t, "party": "Quillmere"}),
|
||||
);
|
||||
m.ok(
|
||||
"books.set_entry_party",
|
||||
|
||||
@@ -639,6 +639,27 @@ fn defect24_creditor_of_round_trips_and_a_card_fee_is_not_a_payment() {
|
||||
assert_eq!(m.ok("books.verify", json!({}))["status"], "ok");
|
||||
}
|
||||
|
||||
/// rules.md B-27: `books.update_party` declares the `party_creditors`
|
||||
/// feature before the first line it writes, never between them: a name
|
||||
/// change with the first creditor is journaled as the declaration, then
|
||||
/// the name, then the creditor (a refused format upgrade then leaves
|
||||
/// nothing half done).
|
||||
#[test]
|
||||
fn update_party_declares_the_creditor_feature_before_its_first_line() {
|
||||
let mut m = books("feature-first");
|
||||
let start = m.journal_types().len();
|
||||
let p = m.ok(
|
||||
"books.update_party",
|
||||
json!({"party": "Harbor Bank", "notes": "the card's issuer", "creditor_of": ["2050"]}),
|
||||
);
|
||||
assert_eq!(p["creditor_of"], json!(["2050"]));
|
||||
assert_eq!(
|
||||
m.journal_types()[start..].to_vec(),
|
||||
["journal.format_set", "party.updated", "party.creditor_set"],
|
||||
);
|
||||
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
|
||||
|
||||
@@ -427,7 +427,7 @@ fn books_with(name: &str, extra: &str) -> Mcp {
|
||||
m.ok("books.add_vendor", json!({"name": "Acme Design"}));
|
||||
m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Acme Design", "form_1099": "nec"}),
|
||||
json!({"vendor": "Acme Design", "form_1099": "nec", "decided_by": "Test Owner"}),
|
||||
);
|
||||
m.add_party("Rent Co", &["landlord"]);
|
||||
m.books_dir = Some(dir);
|
||||
@@ -1019,7 +1019,7 @@ fn the_1099_worksheet_carries_full_tins_only_when_asked() {
|
||||
m.ok("books.add_vendor", json!({"name": "Rent Co"}));
|
||||
m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Rent Co", "form_1099": "misc"}),
|
||||
json!({"vendor": "Rent Co", "form_1099": "misc", "decided_by": "Test Owner"}),
|
||||
);
|
||||
m.w9(
|
||||
"Rent Co",
|
||||
@@ -1072,7 +1072,9 @@ fn the_1099_worksheet_carries_full_tins_only_when_asked() {
|
||||
assert_eq!(out["csv"]["mime_type"], "text/csv");
|
||||
let text = String::from_utf8(handed(&out["csv"])).unwrap();
|
||||
assert!(text.contains(SSN), "{text}");
|
||||
assert!(text.starts_with("vendor_id,name,form_1099,paid,reportable,w9_on_file,tin_last4,tin"));
|
||||
assert!(text.starts_with(
|
||||
"vendor_id,name,decision_1099,form_1099,reason_kind,reason,decided_by,decided_on,effective,decisions_in_year,paid,excluded_card,undetermined,not_reportable,status,reportable,w9_on_file,tin_last4,tin,tin_type,tin_note\n"
|
||||
));
|
||||
let rows = csv_rows(text.as_bytes());
|
||||
let acme = rows.iter().find(|r| r["name"] == "Acme Design").unwrap();
|
||||
assert_eq!(acme["tin"], SSN);
|
||||
@@ -1087,7 +1089,7 @@ fn the_1099_worksheet_carries_full_tins_only_when_asked() {
|
||||
assert!(!holds_tin(&bytes, SSN_DIGITS));
|
||||
let text = String::from_utf8(bytes).unwrap();
|
||||
assert!(
|
||||
text.starts_with("vendor_id,name,form_1099,paid,reportable,w9_on_file,tin_last4\n"),
|
||||
text.starts_with("vendor_id,name,decision_1099,form_1099,reason_kind,reason,decided_by,decided_on,effective,decisions_in_year,paid,excluded_card,undetermined,not_reportable,status,reportable,w9_on_file,tin_last4\n"),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
@@ -1190,7 +1192,7 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() {
|
||||
m.ok("books.add_vendor", json!({"name": "Rent Co"}));
|
||||
m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Rent Co", "form_1099": "misc"}),
|
||||
json!({"vendor": "Rent Co", "form_1099": "misc", "decided_by": "Test Owner"}),
|
||||
);
|
||||
m.pay("Acme Design", "2500.00");
|
||||
m.pay("Rent Co", "3000.00");
|
||||
@@ -1802,7 +1804,7 @@ fn the_cpa_package_carries_the_k1_and_1099_worksheets_with_tins() {
|
||||
}
|
||||
// The 1099 worksheet: the payee's full TIN.
|
||||
let w = csv_rows(&pkg["1099-worksheet-2026.csv"]);
|
||||
let acme = w.iter().find(|r| r["vendor"] == "Acme Design").unwrap();
|
||||
let acme = w.iter().find(|r| r["name"] == "Acme Design").unwrap();
|
||||
assert_eq!(acme["tin"], EIN);
|
||||
assert_eq!(acme["tin_type"], "ein");
|
||||
assert_eq!(acme["tin_last4"], "9105");
|
||||
@@ -2100,6 +2102,10 @@ fn idempotent_tools_change_nothing_when_repeated() {
|
||||
.to_string();
|
||||
let settings_file =
|
||||
std::fs::read_to_string(m.data.parent().unwrap().join("wanderbooks.toml")).unwrap();
|
||||
m.ok(
|
||||
"books.add_instrument",
|
||||
json!({"kind": "member_card", "label": "Riley's Visa", "account": "1000"}),
|
||||
);
|
||||
let calls: Vec<(&str, Value)> = vec![
|
||||
("books.verify", json!({"blobs": true})),
|
||||
(
|
||||
@@ -2118,12 +2124,16 @@ fn idempotent_tools_change_nothing_when_repeated() {
|
||||
("books.run_recurring", json!({"through": "2026-03-15"})),
|
||||
(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Acme Design", "form_1099": "misc"}),
|
||||
json!({"vendor": "Acme Design", "form_1099": "misc", "decided_by": "Test Owner"}),
|
||||
),
|
||||
(
|
||||
"books.set_entry_vendor",
|
||||
json!({"entry_id": entry, "vendor": "Acme Design"}),
|
||||
),
|
||||
(
|
||||
"books.set_entry_instrument",
|
||||
json!({"entry_id": entry, "instrument": "Riley's Visa"}),
|
||||
),
|
||||
(
|
||||
"books.label_asset",
|
||||
json!({"asset": asset.to_string(), "serial": "SN-1"}),
|
||||
|
||||
Reference in new issue
Block a user