P3: dated 1099 decisions, payment methods and the card exception (defects 23, 12, 18, D-40)
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:
swaitsandClaude Opus 5.5 committed 2026-10-21 12:12:26 -06:00
1 parent c2ceabbae9
commit 1304b9c0ad
77 files changed
+11455 -970

No files matched your search

+22
View File
@@ -202,6 +202,25 @@ async fn connect_with(home: &Home, book: &str, token: Option<String>) -> Result<
.map_err(|e| e.to_string())
}
/// Name cash 1000 the books' cash account (adding it if the chart lacks
/// it): the 1099 worksheet counts payments out of the books' cash
/// accounts and is refused without them.
async fn name_cash(c: &Client) {
let _ = call(
c,
"books.add_account",
json!({"code": "1000", "name": "Cash", "account_type": "asset"}),
)
.await;
call(
c,
"books.set_setting",
json!({"changes": [{"key": "books.cash_accounts", "value": ["1000"]}], "decided_by": "Test Owner"}),
)
.await
.unwrap();
}
/// Call a tool: Ok(structured result) or Err(the error text).
async fn call(c: &Client, tool: &str, args: Value) -> Result<Value, String> {
let args = args.as_object().cloned().unwrap_or_default();
@@ -704,6 +723,7 @@ async fn a_book_in_the_root_keeps_its_vault_and_key_inside_its_own_folder() {
"the TIN wasn't sealed into the book's vault"
);
// A file with TINs is handed over, never written loose in the root.
name_cash(&c).await;
let w = call(
&c,
"books.vendor_1099",
@@ -2525,6 +2545,7 @@ async fn lapsed_uploads_are_swept_while_the_server_runs() {
grant["expires"] = json!(0);
std::fs::write(&json, grant.to_string()).unwrap();
// An export too (a file the books made), aged the same way.
name_cash(&c).await;
let made = call(&c, "books.vendor_1099", json!({"year": 2026, "csv": true}))
.await
.unwrap();
@@ -2637,6 +2658,7 @@ async fn a_file_the_books_make_is_fetched_by_its_own_token() {
.await
.unwrap();
}
name_cash(&c).await;
// The CPA package, TINs and all: handed over, never inline, never a
// path; the command runs as this token.
let got = call(
+11 -1
View File
@@ -2358,7 +2358,17 @@ fn the_commands_given_run_as_the_agents_own_token() {
);
assert!(out.status.success(), "{out:?}");
assert_eq!(std::fs::read(folder.join("r (1).pdf")).unwrap(), bytes);
// A file the books make (an export) is saved the same way.
// A file the books make (an export) is saved the same way. The 1099
// worksheet counts payments out of the books' cash accounts.
let _ = a.call(
"books.add_account",
json!({"code": "1000", "name": "Cash", "account_type": "asset"}),
);
a.call(
"books.set_setting",
json!({"changes": [{"key": "books.cash_accounts", "value": ["1000"]}], "decided_by": "Test Owner"}),
)
.unwrap();
let w = a
.call("books.vendor_1099", json!({"year": 2026, "csv": true}))
.unwrap();
+22 -3
View File
@@ -42,16 +42,35 @@ pub struct Vendor {
pub id: VendorId,
/// Display name.
pub name: String,
/// The information return this vendor gets, if any (see
/// [`crate::form1099`]).
/// Its 1099 decisions, each from the date it takes effect, in date
/// order (one from the beginning first); none when nobody has decided
/// (see [`crate::form1099`], D-40).
#[serde(default)]
pub form_1099: Option<crate::form1099::Form1099>,
pub decisions_1099: Vec<crate::form1099::Dated1099>,
/// A W-9 (taxpayer identification number) is on file. The number
/// itself is never stored here.
#[serde(default)]
pub tin_on_file: bool,
}
impl Vendor {
/// The 1099 decision in force on `date`, if any was recorded by then.
pub fn decision_on(&self, date: NaiveDate) -> Option<&crate::form1099::Decision1099> {
crate::form1099::in_force(&self.decisions_1099, date).map(|d| &d.decision)
}
/// The latest 1099 decision recorded (the one in force from the last
/// effective date on), if any.
pub fn latest_1099(&self) -> Option<&crate::form1099::Dated1099> {
self.decisions_1099.last()
}
/// The return its latest decision files, if it files one.
pub fn form_1099(&self) -> Option<crate::form1099::Form1099> {
self.latest_1099().and_then(|d| d.decision.form())
}
}
/// Payment status of an invoice or bill.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
pub enum DocumentStatus {
+13
View File
@@ -522,6 +522,19 @@ pub enum LedgerError {
#[error("unknown vendor id {0}")]
UnknownVendor(u64),
/// A 1099 decision, or the 1099 report, was refused: the message says
/// why and what to do.
#[error("{0}")]
Invalid1099(String),
/// The payment method named does not exist.
#[error("unknown payment method (instrument) id {0}")]
UnknownInstrument(u64),
/// A payment method (instrument) was refused: the message says why.
#[error("{0}")]
InvalidInstrument(String),
/// The referenced invoice does not exist.
#[error("unknown invoice id {0}")]
UnknownInvoice(u64),
File diff suppressed because it is too large. Load diff
+202
View File
@@ -0,0 +1,202 @@
//! Payment instruments: how the company (or a member, for it) paid.
//!
//! A payment to a contractor by check counts toward its 1099; the same
//! payment by card or through a payment network is the card processor's
//! to report, on Form 1099-K, and never the company's (Treas. Reg.
//! §1.6041-1(a)(1)(iv)). The books record each way of paying once
//! ([`Instrument`]: the company's bank, its business card, a member's own
//! card, a member's own cash or check, a payment platform), each with the
//! ledger account it pays through, and tag the entries paid with one
//! ([`crate::Ledger::set_entry_instrument`]). The 1099 worksheet reads the
//! tag ([`crate::form1099`]), and the business cards' liability accounts
//! are the books' card accounts ([`crate::Ledger::card_accounts`]), known
//! from what the books record rather than an account's name.
//!
//! ```text
//! GOAL: every payment's method is recorded once, plainly, so the 1099
//! worksheet never guesses whether a payment was by card.
//! SOURCE: docs/spec/rules.md B-19 (Treas. Reg. §1.6041-1(a)(1)(iv),
//! §1.6050W-1(b)(1), (b)(3), (c)(3); Instructions for Form 1099-K:
//! automated clearing houses are not payment networks;
//! sources/irs-1099-payment-method.md); plan/served.md 4.9, 5.2.
//! INVARIANTS:
//! 1. A label is one line, at most settings::MAX_TEXT characters, holds
//! no full TIN, and is unique among instruments (ignoring case and
//! surrounding space).
//! 2. last4, when given, is exactly four ASCII digits: no full card or
//! account number ever enters the books.
//! 3. The account is a real asset or liability account, not a header,
//! and not the payables or receivables control account (a bill is not
//! paid until it is paid).
//! 4. The owner party, when given, exists. It is informational: it never
//! classifies a payment (the payment-card exception follows the
//! instrument, whoever's card it is).
//! ```
use crate::{AccountId, PartyId};
use serde::{Deserialize, Serialize};
/// Opaque instrument handle.
pub type InstrumentId = u64;
/// What kind of payment an instrument makes.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InstrumentKind {
/// The company's own bank account: its checks, ACH, wires, bill-pay.
Bank,
/// A company credit or debit card the payee charged.
BusinessCard,
/// A member's personal credit or debit card the payee charged.
MemberCard,
/// A member's own cash, check, ACH, Zelle, or a friends-and-family
/// transfer from a member's own app or card.
MemberCashOrCheck,
/// A payment the payee received as a seller through a payment network
/// that settles it (PayPal or Venmo goods and services).
Platform,
}
impl InstrumentKind {
/// Every kind, in order.
pub const ALL: [InstrumentKind; 5] = [
InstrumentKind::Bank,
InstrumentKind::BusinessCard,
InstrumentKind::MemberCard,
InstrumentKind::MemberCashOrCheck,
InstrumentKind::Platform,
];
/// Parse a key ("bank", "business_card", ...), ignoring case and
/// surrounding space.
pub fn parse(s: &str) -> Option<InstrumentKind> {
let s = s.trim().to_ascii_lowercase();
InstrumentKind::ALL.into_iter().find(|k| k.key() == s)
}
/// Its key: "bank", "business_card", "member_card",
/// "member_cash_or_check", "platform".
pub fn key(self) -> &'static str {
match self {
InstrumentKind::Bank => "bank",
InstrumentKind::BusinessCard => "business_card",
InstrumentKind::MemberCard => "member_card",
InstrumentKind::MemberCashOrCheck => "member_cash_or_check",
InstrumentKind::Platform => "platform",
}
}
/// In words, for people.
pub fn label(self) -> &'static str {
match self {
InstrumentKind::Bank => "the company's bank (check, ACH, wire)",
InstrumentKind::BusinessCard => "a company card",
InstrumentKind::MemberCard => "a member's personal card",
InstrumentKind::MemberCashOrCheck => "a member's own cash or check",
InstrumentKind::Platform => "a payment network (goods and services)",
}
}
/// Whether a payment made with it is a payment-card or third-party
/// network transaction: reported by the processor on Form 1099-K and
/// excluded from 1099-NEC and 1099-MISC (Treas. Reg.
/// §1.6041-1(a)(1)(iv)), whoever's card it was.
pub fn is_card_or_network(self) -> bool {
matches!(
self,
InstrumentKind::BusinessCard | InstrumentKind::MemberCard | InstrumentKind::Platform
)
}
}
/// One way the company pays.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Instrument {
/// Opaque handle.
pub id: InstrumentId,
/// What kind of payment it makes.
pub kind: InstrumentKind,
/// Its name ("Harbor Bank checking", "Riley's Visa"), unique.
pub label: String,
/// The last four digits of its card or account number, if given.
pub last4: Option<String>,
/// Whose it is (a member's card), if given. Never used to classify.
pub owner_party: Option<PartyId>,
/// The ledger account it pays through: the bank account, the card's
/// liability, or the members' financing liability (Due to Members).
pub account: AccountId,
}
/// Check a label: one line, at most `MAX_TEXT` characters, no full TIN
/// (INVARIANT 1, all but uniqueness). Returns it trimmed.
pub fn check_label(label: &str) -> Result<String, String> {
crate::settings::line_text("label", label, crate::settings::MAX_TEXT)
}
/// Check a last four (INVARIANT 2).
pub fn check_last4(last4: &str) -> Result<(), String> {
if last4.len() == 4 && last4.bytes().all(|b| b.is_ascii_digit()) {
Ok(())
} else {
Err(format!(
"last4 {:?} must be exactly four digits (never a whole card or account number)",
crate::parties::redact_full_tins(last4)
))
}
}
/// The key two labels are compared by: trimmed, lower case.
pub fn label_key(label: &str) -> String {
label.trim().to_lowercase()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kinds_round_trip_and_only_cards_and_networks_are_excluded() {
for k in InstrumentKind::ALL {
assert_eq!(InstrumentKind::parse(k.key()), Some(k));
assert_eq!(
InstrumentKind::parse(&format!(" {} ", k.key().to_uppercase())),
Some(k)
);
assert!(!k.label().is_empty());
}
assert_eq!(InstrumentKind::parse("card"), None);
let excluded: Vec<_> = InstrumentKind::ALL
.into_iter()
.filter(|k| k.is_card_or_network())
.collect();
assert_eq!(
excluded,
[
InstrumentKind::BusinessCard,
InstrumentKind::MemberCard,
InstrumentKind::Platform
]
);
}
#[test]
fn last4_is_four_digits_only() {
assert!(check_last4("0042").is_ok());
for bad in ["", "123", "12345", "12a4", "1234", "4111111111111111"] {
assert!(check_last4(bad).is_err(), "{bad:?}");
}
// A refusal never echoes a full TIN-shaped number.
let why = check_last4("123-45-6789").unwrap_err();
assert!(!why.contains("6789"), "{why}");
}
#[test]
fn labels_are_one_bounded_line() {
assert_eq!(check_label(" Harbor Bank ").unwrap(), "Harbor Bank");
assert!(check_label("").is_err());
assert!(check_label("a\nb").is_err());
assert!(check_label(&"x".repeat(crate::settings::MAX_TEXT + 1)).is_err());
assert!(check_label("card 123-45-6789").is_err());
assert_eq!(label_key(" Riley's Visa "), "riley's visa");
}
}
+1 -1
View File
@@ -577,7 +577,7 @@ mod tests {
let mut l = Ledger::new(Currency::USD);
let mut add =
|code: &str, name: &str, t| l.add_account(code, name, t, None, false).unwrap();
let cash = add("1000", "Bluevine", AccountType::Asset);
let cash = add("1000", "Quillmere", AccountType::Asset);
let prepaid = add("1300", "Prepaid Expenses", AccountType::Asset);
let equip = add("1500", "Equipment", AccountType::Asset);
let a = add("3000", "Capital A", AccountType::Equity);
+159
View File
@@ -25,6 +25,9 @@
//! * a trial balance puts each movement in exactly one column, so its
//! columns differ by the net of all movements (B-6);
//! * a closing line zeroes the account it closes (B-11);
//! * a payment to a vendor splits, line by line, into what counts toward
//! its 1099, what a card or payment network paid, and what is unknown,
//! and money moved between the company's own sources nets out (B-19);
//! * an allocation's parts sum to the whole, to the last unit, by largest
//! remainder (B-13);
//! * a cumulative allocation hands out exactly the new amount, and leaves
@@ -273,6 +276,162 @@ pub fn credit_line<T: Amount>(credit: T) -> Option<(Side, T)> {
closing_line(credit)
}
// ---------------------------------------------------------------------------
// 1099 payments: where each payment line goes (rules.md B-19)
// ---------------------------------------------------------------------------
/// A line's role in a payment to a vendor, from its account.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PayRole {
/// One of the books' cash accounts: bank, cash on hand.
Cash,
/// One of the books' financing liabilities (what members or lenders
/// paid on the company's behalf), or an account a member's card or
/// cash, or a payment platform, pays through: how a payment through
/// it was made, only the entry's method says.
Financing,
/// The account of a company card (a business card's liability).
CardAccount,
/// None of these (an expense, payables, prepaid): not a payment line.
Other,
}
/// What an entry's payment-method tag says about one of its lines.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Tag {
/// The entry has no payment method recorded.
None,
/// The method pays through this line's account and is a card or a
/// payment network.
ThisCard,
/// The method pays through this line's account and is neither (the
/// company's bank, a member's own cash or check).
ThisOther,
/// The method pays through another account: it doesn't describe this
/// line.
Elsewhere,
}
/// Where a payment line's amount goes on the 1099 worksheet.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Bucket {
/// Counts toward the payee's 1099.
Counts,
/// Paid by card or payment network: Form 1099-K's, never 1099-NEC or
/// 1099-MISC's.
Card,
/// Can't be classified until the payment method is recorded.
Undetermined,
}
impl Bucket {
/// Every bucket, in [`split_payment`]'s order.
pub const ALL: [Bucket; 3] = [Bucket::Counts, Bucket::Card, Bucket::Undetermined];
/// Its place in [`split_payment`]'s array.
pub const fn index(self) -> usize {
match self {
Bucket::Counts => 0,
Bucket::Card => 1,
Bucket::Undetermined => 2,
}
}
}
/// The bucket a payment line goes in, from its role and the entry's tag.
///
/// ```text
/// GOAL: every payment line to a vendor counts toward its 1099, is a card
/// or payment-network payment (Form 1099-K), or is unknown; never
/// guessed.
/// SOURCE: docs/spec/rules.md B-19 (Treas. Reg. §1.6041-1(a)(1)(iv): a
/// transaction reportable under §6050W is not reported under
/// §6041; §1.6041-1(e)(1): a member writing a check at the
/// company's direction doesn't make the member the payor;
/// sources/irs-1099-payment-method.md); DECISION D-40 (owner,
/// 2026-10-20: a bank payment with no method recorded counts).
/// INVARIANTS (first match wins):
/// 1. The tag names this line's account: Card for a card or network,
/// else Counts.
/// 2. Otherwise the role decides: Cash counts (presumed check, ACH or
/// transfer, D-40); Financing is Undetermined (a member's card or
/// their check, unknown until tagged); CardAccount is Card; Other is
/// no payment line (None).
/// ```
pub fn payment_bucket(role: PayRole, tag: Tag) -> Option<Bucket> {
match (tag, role) {
(Tag::ThisCard, _) => Some(Bucket::Card),
(Tag::ThisOther, _) => Some(Bucket::Counts),
(Tag::None | Tag::Elsewhere, PayRole::Cash) => Some(Bucket::Counts),
(Tag::None | Tag::Elsewhere, PayRole::Financing) => Some(Bucket::Undetermined),
(Tag::None | Tag::Elsewhere, PayRole::CardAccount) => Some(Bucket::Card),
(Tag::None | Tag::Elsewhere, PayRole::Other) => None,
}
}
/// What an entry paid, per bucket: credits minus debits of the lines in
/// each (a line in no bucket is not a payment line). `None` on overflow.
pub fn split_payment<T: Amount>(lines: &[(Option<Bucket>, Side, T)]) -> Option<[T; 3]> {
let mut out = [T::ZERO; 3];
for &(bucket, side, amount) in lines {
if let Some(b) = bucket {
let i = b.index();
out[i] = match side {
Side::Credit => out[i].checked_add(amount)?,
Side::Debit => out[i].checked_sub(amount)?,
};
}
}
Some(out)
}
/// Net one entry's buckets ([`split_payment`]): money moved between the
/// company's own payment sources pays no one.
///
/// ```text
/// GOAL: a card bill paid from the bank, a member reimbursed, or a loan
/// paid down never counts toward a vendor's 1099.
/// SOURCE: docs/spec/rules.md B-19 (26 U.S.C. §6041(a): payments made to
/// the payee; P3 review R1, 2026-10-20).
/// INVARIANTS:
/// 1. The buckets' sum P is kept exactly.
/// 2. No bucket ends opposite in sign to P.
/// 3. Buckets all of one sign (zeros aside) come back unchanged.
/// SCENARIOS:
/// 1. IF the signs differ and P = 0 (Dr 2050 / Cr 1000, the card bill):
/// SHOULD be [0, 0, 0].
/// 2. IF the signs differ and exactly one bucket has P's sign: SHOULD
/// give it P and the others 0.
/// 3. IF two buckets have P's sign: SHOULD put P in Undetermined
/// BECAUSE which source the other side offsets is unknown (split
/// the entry).
/// ```
pub fn net_buckets<T: Amount>(b: [T; 3]) -> Option<[T; 3]> {
let positive = b.iter().any(|&x| x > T::ZERO);
let negative = b.iter().any(|&x| x < T::ZERO);
if !(positive && negative) {
return Some(b);
}
let p = b[0].checked_add(b[1])?.checked_add(b[2])?;
let mut out = [T::ZERO; 3];
if p == T::ZERO {
return Some(out);
}
let mut taker = Bucket::Undetermined.index();
let mut takers = 0;
for (i, &x) in b.iter().enumerate() {
if x != T::ZERO && (x > T::ZERO) == (p > T::ZERO) {
taker = i;
takers += 1;
}
}
if takers != 1 {
taker = Bucket::Undetermined.index();
}
out[taker] = p;
Some(out)
}
/// Make rounded parts sum to the whole: the last part becomes `whole`
/// minus the others, absorbing every unit of rounding. Returns `None` on
/// overflow, leaving `parts` unchanged.
+255 -6
View File
@@ -255,6 +255,11 @@ pub struct Ledger {
next_recurring_id: RecurringId,
/// Who each entry paid or was billed by, for 1099 reporting.
entry_vendors: HashMap<EntryId, VendorId>,
/// The payment methods ([`crate::instruments`]), by id.
instruments: std::collections::BTreeMap<crate::InstrumentId, crate::Instrument>,
next_instrument_id: crate::InstrumentId,
/// The payment method each entry was paid with, for 1099 reporting.
entry_instruments: HashMap<EntryId, crate::InstrumentId>,
/// Each invoice's payments (entry, amount), for aging as of a date.
invoice_payments: HashMap<InvoiceId, Vec<(EntryId, Decimal)>>,
/// Each bill's payments (entry, amount), for aging as of a date.
@@ -313,6 +318,9 @@ impl Ledger {
recurring: std::collections::BTreeMap::new(),
next_recurring_id: 1,
entry_vendors: HashMap::new(),
instruments: std::collections::BTreeMap::new(),
next_instrument_id: 1,
entry_instruments: HashMap::new(),
invoice_payments: HashMap::new(),
bill_payments: HashMap::new(),
roles: std::collections::BTreeMap::new(),
@@ -630,6 +638,15 @@ impl Ledger {
self.next_recurring_id == other.next_recurring_id,
),
("entry_vendors", self.entry_vendors == other.entry_vendors),
("instruments", self.instruments == other.instruments),
(
"next_instrument_id",
self.next_instrument_id == other.next_instrument_id,
),
(
"entry_instruments",
self.entry_instruments == other.entry_instruments,
),
(
"invoice_payments",
self.invoice_payments == other.invoice_payments,
@@ -1665,6 +1682,10 @@ impl Ledger {
if let Some(v) = self.entry_vendor(entry) {
self.entry_vendors.insert(id, v);
}
// Paid the same way: its 1099 buckets net to zero too.
if let Some(i) = self.entry_instrument(entry) {
self.entry_instruments.insert(id, i);
}
// Every party and share: the reversal undoes each one's part.
if let Some(p) = self.entry_parties.get(&entry).cloned() {
self.entry_parties.insert(id, p);
@@ -3030,7 +3051,7 @@ impl Ledger {
Vendor {
id,
name: name.to_string(),
form_1099: None,
decisions_1099: Vec::new(),
tin_on_file: false,
},
);
@@ -3062,20 +3083,248 @@ impl Ledger {
self.vendors.get(&id)
}
/// Set which information return a vendor gets (or none), and whether
/// their W-9 is on file.
/// Check a 1099 decision for a vendor before it is recorded
/// ([`crate::form1099::check_new_decision`]): the vendor exists, the
/// decision, its source and its date are sound, and a dated decision
/// records a change. Returns what recording it would do.
pub fn check_vendor_1099(
&self,
id: VendorId,
new: &crate::form1099::Dated1099,
) -> Result<crate::form1099::Applied, LedgerError> {
let v = self
.vendors
.get(&id)
.ok_or(LedgerError::UnknownVendor(id))?;
crate::form1099::check_new_decision(&v.decisions_1099, new)
.map_err(LedgerError::Invalid1099)
}
/// Record a vendor's 1099 decision from the date it takes effect (or
/// from the beginning), with who decided, and whether its W-9 is on
/// file, checked ([`Self::check_vendor_1099`]).
///
/// ```text
/// GOAL: "no 1099" is a decision kept with its reason and who made it,
/// never the same as "never decided" (plan defects 18, 23), and
/// each payment takes the decision in force on its date (D-40).
/// SOURCE: docs/spec/rules.md B-19; crate::form1099::plan_decision.
/// SCENARIOS:
/// 1. IF the vendor is unknown, or the decision fails its checks:
/// SHOULD refuse, changing nothing.
/// 2. Otherwise SHOULD record it (crate::form1099::apply_decision)
/// and set the W-9 flag, returning what it did.
/// ```
pub fn set_vendor_1099(
&mut self,
id: VendorId,
form: Option<crate::form1099::Form1099>,
new: crate::form1099::Dated1099,
tin_on_file: bool,
) -> Result<(), LedgerError> {
) -> Result<crate::form1099::Applied, LedgerError> {
self.check_vendor_1099(id, &new)?;
self.replay_vendor_1099(id, new, tin_on_file)
}
/// [`Self::set_vendor_1099`] as journaled: the vendor must exist, and
/// the decision is recorded as the line says, without the checks a
/// new decision passes (rules.md B-28). A line written before
/// decisions were kept replays as a decision from the beginning with
/// no source (a null as undetermined: plan 5.5).
pub fn replay_vendor_1099(
&mut self,
id: VendorId,
new: crate::form1099::Dated1099,
tin_on_file: bool,
) -> Result<crate::form1099::Applied, LedgerError> {
let v = self
.vendors
.get_mut(&id)
.ok_or(LedgerError::UnknownVendor(id))?;
v.form_1099 = form;
let applied = crate::form1099::apply_decision(&mut v.decisions_1099, new);
v.tin_on_file = tin_on_file;
Ok(applied)
}
/// Mark whether a vendor's W-9 is on file, keeping its 1099 decisions
/// and who made them (P3 review R8). Returns whether it changed.
pub fn set_vendor_w9_flag(&mut self, id: VendorId, on_file: bool) -> Result<bool, LedgerError> {
let v = self
.vendors
.get_mut(&id)
.ok_or(LedgerError::UnknownVendor(id))?;
let changed = v.tin_on_file != on_file;
v.tin_on_file = on_file;
Ok(changed)
}
// ------------------------------------------------------------------
// Payment methods (crate::instruments)
// ------------------------------------------------------------------
/// The id the next payment method gets.
pub fn next_instrument_id(&self) -> crate::InstrumentId {
self.next_instrument_id
}
/// Check a new payment method (crate::instruments INVARIANTS 1-4).
/// Returns its label, trimmed.
pub fn check_add_instrument(
&self,
label: &str,
last4: Option<&str>,
owner_party: Option<crate::PartyId>,
account: AccountId,
) -> Result<String, LedgerError> {
let label =
crate::instruments::check_label(label).map_err(LedgerError::InvalidInstrument)?;
let key = crate::instruments::label_key(&label);
if let Some(i) = self
.instruments
.values()
.find(|i| crate::instruments::label_key(&i.label) == key)
{
return Err(LedgerError::InvalidInstrument(format!(
"payment method {} is already named {:?}: give this one another name",
i.id, i.label
)));
}
if let Some(l) = last4 {
crate::instruments::check_last4(l).map_err(LedgerError::InvalidInstrument)?;
}
if let Some(p) = owner_party
&& !self.parties.contains_key(&p)
{
return Err(LedgerError::UnknownParty(p));
}
let a = self
.chart
.get(account)
.ok_or(LedgerError::UnknownAccount(account))?;
if a.placeholder {
return Err(LedgerError::PlaceholderAccount(a.code.clone()));
}
if !matches!(a.account_type, AccountType::Asset | AccountType::Liability) {
return Err(LedgerError::InvalidInstrument(format!(
"account {} {:?} is {:?}: a payment method pays through an asset (a bank account) or a liability (a card, or what's owed to the members)",
a.code, a.name, a.account_type
)));
}
let control = [&crate::PAYABLES, &crate::RECEIVABLES]
.into_iter()
.find(|c| self.role_account(c) == Some(account));
if let Some(c) = control {
return Err(LedgerError::InvalidInstrument(format!(
"account {} is the {} control account, which no payment is made from: name the bank, card, or members' account the money left",
a.code, c.role
)));
}
Ok(label)
}
/// Add a payment method ([`Self::check_add_instrument`]). Returns its
/// id.
pub fn add_instrument(
&mut self,
kind: crate::InstrumentKind,
label: &str,
last4: Option<&str>,
owner_party: Option<crate::PartyId>,
account: AccountId,
) -> Result<crate::InstrumentId, LedgerError> {
let label = self.check_add_instrument(label, last4, owner_party, account)?;
let id = self.next_instrument_id;
self.next_instrument_id += 1;
self.instruments.insert(
id,
crate::Instrument {
id,
kind,
label,
last4: last4.map(str::to_string),
owner_party,
account,
},
);
Ok(id)
}
/// Every payment method, in id order.
pub fn instruments(&self) -> impl Iterator<Item = &crate::Instrument> + '_ {
self.instruments.values()
}
/// One payment method.
pub fn get_instrument(&self, id: crate::InstrumentId) -> Option<&crate::Instrument> {
self.instruments.get(&id)
}
/// The liability accounts that are company cards: the account of each
/// business-card payment method that is a liability (a credit card's
/// balance; a debit card draws on an asset and isn't one). A member's
/// card pays through the members' financing account, and a platform
/// through its own balance: neither is a card account. For whatever
/// needs to tell a card from other liabilities (party money, the cash
/// flow's sections), from what the books record rather than an
/// account's name.
pub fn card_accounts(&self) -> std::collections::BTreeSet<AccountId> {
self.instruments
.values()
.filter(|i| i.kind == crate::InstrumentKind::BusinessCard)
.filter(|i| {
self.chart
.get(i.account)
.is_some_and(|a| a.account_type == AccountType::Liability)
})
.map(|i| i.account)
.collect()
}
/// The payment method with this label (ignoring case and surrounding
/// space).
pub fn instrument_by_label(&self, label: &str) -> Option<&crate::Instrument> {
let key = crate::instruments::label_key(label);
self.instruments
.values()
.find(|i| crate::instruments::label_key(&i.label) == key)
}
/// The payment method an entry is tagged with.
pub fn entry_instrument(&self, entry: EntryId) -> Option<crate::InstrumentId> {
self.entry_instruments.get(&entry).copied()
}
/// Tag an entry (draft or posted) with the payment method it was paid
/// with, or clear the tag.
///
/// ```text
/// GOAL: the 1099 worksheet knows how each payment was made.
/// SOURCE: docs/spec/rules.md B-19; crate::instruments.
/// SCENARIOS:
/// 1. IF the entry or the payment method is unknown: SHOULD refuse.
/// 2. Tagging MUST change no amount (it's metadata, allowed on posted
/// entries, like the vendor tag).
/// 3. The entry's reversal chain carries the same tag, so a payment
/// and its reversal always sit in the same bucket and net to zero.
/// ```
pub fn set_entry_instrument(
&mut self,
entry: EntryId,
instrument: Option<crate::InstrumentId>,
) -> Result<(), LedgerError> {
if !self.entries.contains_key(&entry) {
return Err(LedgerError::UnknownEntry(entry));
}
if let Some(i) = instrument
&& !self.instruments.contains_key(&i)
{
return Err(LedgerError::UnknownInstrument(i));
}
for e in self.reversal_chain(entry) {
match instrument {
Some(i) => self.entry_instruments.insert(e, i),
None => self.entry_instruments.remove(&e),
};
}
Ok(())
}
+4 -2
View File
@@ -26,6 +26,7 @@ pub mod classes;
pub mod conventions;
pub mod error;
pub mod form1099;
pub mod instruments;
pub mod journal;
pub mod k1;
pub mod kernel;
@@ -63,9 +64,10 @@ pub use cashflow::{
pub use classes::{Class, ClassId};
pub use error::LedgerError;
pub use form1099::{
Form1099, PaymentAccounts, Vendor1099Line, Vendor1099Report, vendor_1099_report,
vendor_1099_report_with, vendor_1099_report_with_members,
Dated1099, Decision1099, ExemptReason, Form1099, Payment1099, PaymentAccounts, Status1099,
Vendor1099Line, Vendor1099Report, vendor_1099_report,
};
pub use instruments::{Instrument, InstrumentId, InstrumentKind};
pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
pub use kernel::{EntryFlows, Flow};
pub use ledger::{
+41 -10
View File
@@ -693,15 +693,42 @@ impl MoneyAccounts {
}
}
/// The company's cards: the liability accounts whose names say "card"
/// (ignoring case), but not "gift card" (gift cards the company sold are
/// deferred revenue it owes its customers, a promise, not a card it pays
/// with), the rule the monthly reconciliation reminders read
/// (`ledger_store::reconcilable`), so the two never disagree on what a
/// card is. CONVENTION until payment instruments name the card accounts
/// (plan/served.md P3 defect 12).
/// The company's cards: the one rule for what a card is, which party
/// money, the monthly reconciliation reminders
/// (`ledger_store::reconcilable`) and the viewer all read, so none
/// disagrees on it.
///
/// ```text
/// GOAL: a card is known from what the books record, and a promise (a
/// gift card sold) or a member's own card is never the company's.
/// SOURCE: rules.md B-35 item 5 (DECISION, owner, 2026-10-20: money is
/// cash, cards, the members' financing and loans), B-19 (payment
/// methods, plan P3 defect 12).
/// INVARIANTS:
/// 1. When the books record a business-card payment method: exactly
/// the liability accounts of those methods
/// (crate::Ledger::card_accounts), whatever the accounts are named.
/// 2. Else (books from before payment methods, or that never record
/// one; CONVENTION): the liability accounts whose names say "card"
/// (ignoring case) but not "gift" (gift cards the company sold are
/// deferred revenue it owes its customers, a promise, not a card it
/// pays with).
/// 3. Never the account of a member's card, a member's cash or check,
/// or a payment platform: a member's card pays through the members'
/// financing account, a platform through its own balance.
/// 4. In chart order (ascending account id), each once.
/// ```
pub fn card_accounts(ledger: &crate::Ledger) -> Vec<crate::AccountId> {
ledger
let recorded = ledger.card_accounts();
if !recorded.is_empty() {
return recorded.into_iter().collect();
}
let not_cards: std::collections::BTreeSet<crate::AccountId> = ledger
.instruments()
.filter(|i| i.kind != crate::InstrumentKind::BusinessCard)
.map(|i| i.account)
.collect();
let mut cards: Vec<crate::AccountId> = ledger
.chart()
.all()
.filter(|a| {
@@ -709,9 +736,13 @@ pub fn card_accounts(ledger: &crate::Ledger) -> Vec<crate::AccountId> {
a.account_type == crate::AccountType::Liability
&& name.contains("card")
&& !name.contains("gift")
&& !not_cards.contains(&a.id)
})
.map(|a| a.id)
.collect()
.collect();
cards.sort_unstable();
cards.dedup();
cards
}
/// The [`MoneyAccounts`] of `ledger`: `cash` the books' cash accounts
@@ -1042,7 +1073,7 @@ pub fn activity(
/// 6. A card charge counts as paid (the company paid them, by card); a
/// bill doesn't until it is paid; a member's property contribution is
/// received in kind. Not the 1099 amount: that is
/// form1099::vendor_1099_report_with.
/// form1099::vendor_1099_report.
/// ```
pub fn activity_with(
ledger: &crate::Ledger,
+125
View File
@@ -442,6 +442,131 @@ fn closing_entry_balances() {
kani::cover!(len == NOMINAL + 2, "three accounts closed to two members");
}
// ---------------------------------------------------------------------------
// 1099 payments (rules.md B-19)
// ---------------------------------------------------------------------------
fn any_role() -> kernel::PayRole {
let r: u8 = kani::any();
kani::assume(r < 4);
match r {
0 => kernel::PayRole::Cash,
1 => kernel::PayRole::Financing,
2 => kernel::PayRole::CardAccount,
_ => kernel::PayRole::Other,
}
}
fn any_tag() -> kernel::Tag {
let t: u8 = kani::any();
kani::assume(t < 4);
match t {
0 => kernel::Tag::None,
1 => kernel::Tag::ThisCard,
2 => kernel::Tag::ThisOther,
_ => kernel::Tag::Elsewhere,
}
}
/// `kernel::payment_bucket` is the B-19 table, all 16 cases: a tag naming
/// the line's account decides (card or network: Card; otherwise Counts);
/// else the role does, and with no tag naming its account a financing line
/// never counts and a card account's line is always Card.
#[kani::proof]
fn payment_bucket_is_the_table() {
use kernel::{Bucket, PayRole, Tag};
let (role, tag) = (any_role(), any_tag());
let got = kernel::payment_bucket(role, tag);
let want = match tag {
Tag::ThisCard => Some(Bucket::Card),
Tag::ThisOther => Some(Bucket::Counts),
Tag::None | Tag::Elsewhere => match role {
PayRole::Cash => Some(Bucket::Counts),
PayRole::Financing => Some(Bucket::Undetermined),
PayRole::CardAccount => Some(Bucket::Card),
PayRole::Other => None,
},
};
assert_eq!(got, want);
if matches!(tag, Tag::None | Tag::Elsewhere) {
assert!(role != PayRole::Financing || got != Some(Bucket::Counts));
assert!(role != PayRole::CardAccount || got == Some(Bucket::Card));
}
}
/// `kernel::split_payment` never fails on journal-line amounts, and each
/// bucket is the exact credits-minus-debits of its lines: the buckets
/// together are the whole payment, so nothing is lost or counted twice.
#[kani::proof]
#[kani::unwind(6)]
fn payment_split_is_exact() {
let n = any_len();
let lines: [(Option<kernel::Bucket>, Side, i64); N] = core::array::from_fn(|_| {
let b: u8 = kani::any();
kani::assume(b < 4);
let bucket = match b {
0 => Some(kernel::Bucket::Counts),
1 => Some(kernel::Bucket::Card),
2 => Some(kernel::Bucket::Undetermined),
_ => None,
};
(bucket, any_side(), any_amount())
});
let got = kernel::split_payment(&lines[..n]).expect("never fails in range");
for b in kernel::Bucket::ALL {
let mut want = 0i64;
for &(lb, side, amount) in &lines[..n] {
if lb == Some(b) {
want -= wide(side, amount);
}
}
assert_eq!(got[b.index()], want);
}
kani::cover!(n == N, "four lines");
}
/// `kernel::net_buckets` keeps the payment's total, never leaves a bucket
/// opposite the total, and leaves buckets of one sign alone (money moved
/// between the company's own sources pays no one: P3 review R1); with
/// mixed signs the whole net goes to the one bucket of its sign, or to
/// Undetermined when two share it (its SCENARIOS 1-3).
#[kani::proof]
fn net_buckets_keeps_the_total() {
let b: [i64; 3] = core::array::from_fn(|_| any_amount());
let p = b[0] + b[1] + b[2];
let out = kernel::net_buckets(b).expect("never fails in range");
assert_eq!(out[0] + out[1] + out[2], p);
for x in out {
assert!(x == 0 || (x > 0) == (p > 0));
}
let one_sign = b.iter().all(|&x| x >= 0) || b.iter().all(|&x| x <= 0);
if one_sign {
assert_eq!(out, b);
} else {
// Mixed signs: the whole net lands in one bucket, the one with its
// sign when there is exactly one, else Undetermined.
let with_sign: [bool; 3] = core::array::from_fn(|i| b[i] != 0 && (b[i] > 0) == (p > 0));
let takers = with_sign.iter().filter(|&&s| s).count();
let mut taker = kernel::Bucket::Undetermined.index();
if takers == 1 {
for (i, &s) in with_sign.iter().enumerate() {
if s {
taker = i;
}
}
}
for (i, &x) in out.iter().enumerate() {
assert_eq!(x, if i == taker { p } else { 0 });
}
}
kani::cover!(b[0] > 0 && b[2] < 0 && p > 0, "a payment net of a refund");
kani::cover!(
b[0] > 0 && b[1] > 0 && b[2] < 0 && p > 0,
"two buckets share the net's sign"
);
kani::cover!(!one_sign && p == 0, "money moved between own sources");
}
/// An allocation's parts sum to the whole exactly, whatever the rounding
/// of the others, and only the last part changes.
#[kani::proof]
+247 -27
View File
@@ -5,15 +5,16 @@
//! Each example is a setup, events and expected values worked by hand from
//! its cited source (`bookspec/README.md` gives the shape). Every example
//! must load, whatever it asks or whichever appendix it is in: its shape,
//! its accounts, fixed assets, parties, members and payees, every event
//! (an entry, a reversal, a close or a bill, posting entry #N as event N,
//! with the parties and payee it is tagged with) and every change that
//! its accounts, fixed assets, parties, members, payees, payees' dated
//! 1099 decisions and payment methods, every event (an entry, a reversal,
//! a close or a bill, posting entry #N as event N, with the parties,
//! payee and payment method it is tagged with) and every change that
//! posts no entry (a rename, a policy). The queries the core answers
//! (`cash_flow`, `trial_balance`, `profit_and_loss`, `vendor_1099_paid`,
//! `entry`, `balances`, `members_equity`, `k1`, `party_part`,
//! `party_totals`, `party_tin_last4`, `period`, `day`, `calendar_year`,
//! `account_names`, `uncategorized_accounts`) are then checked value by
//! value, and the commands it tries
//! `vendor_1099`, `entry`, `balances`, `members_equity`, `k1`,
//! `party_part`, `party_totals`, `party_tin_last4`, `period`, `day`,
//! `calendar_year`, `account_names`, `uncategorized_accounts`) are then
//! checked value by value, and the commands it tries
//! (`rename_account`, `add_account`, `set_tax_category`) accepted or
//! refused on a copy of the books, each after the event it names (`after`)
//! or the last; the formats appendix's (`iif_lines`, `iif_changes`) are
@@ -27,13 +28,14 @@ use std::path::{Path, PathBuf};
use chrono::NaiveDate;
use ledger_core::calendar::{PeriodDefaults, period_defaults};
use ledger_core::parties::{Basis, MoneyAccounts};
use ledger_core::settings::Company;
use ledger_core::settings::{Company, Source};
use ledger_core::{
AccountType, CashFlowParams, CashFlowSection, CashFlowStatement, CloseTo, Currency,
DocumentLine, EntryId, Form1099, Ledger, LedgerError, MemberCapital, MemberClose, Money,
NewAsset, PartyId, PartyMember, PartyShare, PaymentAccounts, ReconcilingKind, Role, Share,
Side, TinFacts, TinType, VendorId, W9Facts, cash_flow, members_equity,
profit_and_loss_for_period, trial_balance_as_of, vendor_1099_report_with_members,
AccountType, CashFlowParams, CashFlowSection, CashFlowStatement, CloseTo, Currency, Dated1099,
Decision1099, DocumentLine, EntryId, ExemptReason, Form1099, InstrumentId, InstrumentKind,
Ledger, LedgerError, MemberCapital, MemberClose, Money, NewAsset, PartyId, PartyMember,
PartyShare, PaymentAccounts, ReconcilingKind, Role, Share, Side, TinFacts, TinType, VendorId,
W9Facts, cash_flow, members_equity, profit_and_loss_for_period, trial_balance_as_of,
vendor_1099_report,
};
use rust_decimal::Decimal;
use serde::Deserialize;
@@ -83,6 +85,12 @@ struct Setup {
members: Vec<MemberSpec>,
#[serde(default)]
payees: Vec<PayeeSpec>,
/// A payee's 1099 decision from a date on (rules.md B-19, D-40).
#[serde(default)]
status_changes: Vec<StatusChangeSpec>,
/// The ways the company (or a member, for it) pays (rules.md B-19).
#[serde(default)]
instruments: Vec<InstrumentSpec>,
/// The day the books are asked on, in the company's time zone: what
/// an omitted date means (rules.md B-30). A query that omits a date
/// needs it.
@@ -181,6 +189,38 @@ struct PartySpec {
tin_purged: bool,
}
/// A payee's 1099 decision from `effective` on (rules.md B-19, D-40).
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct StatusChangeSpec {
payee: String,
effective: String,
/// `"1099-NEC"`, `"1099-MISC"`, `"not_reportable"` (with `reason_kind`
/// and `reason`) or `"undetermined"`.
decision: String,
#[serde(default)]
reason_kind: Option<String>,
#[serde(default)]
reason: Option<String>,
}
/// A payment method (rules.md B-19).
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct InstrumentSpec {
label: String,
/// `bank`, `business_card`, `member_card`, `member_cash_or_check` or
/// `platform`.
kind: String,
/// The account it pays through, by code.
account: String,
#[serde(default)]
last4: Option<String>,
/// The party it belongs to, by name (informational).
#[serde(default)]
owner: Option<String>,
}
/// A party named on an entry, with its share: an `amount`, a `percent`,
/// or neither (derived from the entry: rules.md B-35).
#[derive(Deserialize)]
@@ -214,6 +254,9 @@ struct Event {
/// shares.
#[serde(default)]
parties: Vec<ShareSpec>,
/// The payment method (an `setup.instruments` label) it was paid with.
#[serde(default)]
instrument: Option<String>,
}
/// A bill from `vendor` for `amount`, dated and due on the event's date:
@@ -287,6 +330,8 @@ struct Expect {
party: Option<String>,
/// `party_totals`' basis: `"cash"` or `"all"` (rules.md B-35 item 4).
basis: Option<String>,
/// The payee a `vendor_1099` query asks about (in `setup.payees`).
payee: Option<String>,
/// The books after event N, not after the last.
after: Option<usize>,
/// The account a `rename_account` or `set_tax_category` command
@@ -482,6 +527,42 @@ struct Books<'a> {
payees: BTreeMap<String, VendorId>,
/// The vendors bills came from, by name (a payee's record if one).
vendors: BTreeMap<String, VendorId>,
/// The payment methods, by label.
instruments: BTreeMap<String, InstrumentId>,
}
/// Who an example's decisions and records are made by.
fn bookspec_source(id: &str) -> Source {
Source {
decided_by: "bookspec".into(),
decided_on: date("2026-01-01", id),
citation: None,
note: None,
}
}
/// A payee's 1099 decision in an example's words (bookspec/README.md).
fn decision(ex: &str, what: &str, kind: Option<&str>, reason: Option<&str>) -> Decision1099 {
match what {
"not_reportable" => {
let kind = kind.and_then(ExemptReason::parse).unwrap_or_else(|| {
panic!("{ex}: not_reportable needs a reason_kind, got {kind:?}")
});
let reason = reason.unwrap_or_else(|| panic!("{ex}: not_reportable needs a reason"));
Decision1099::not_reportable(kind, reason).unwrap_or_else(|e| panic!("{ex}: {e}"))
}
"undetermined" | "1099-NEC" | "1099-MISC" => {
assert!(
kind.is_none() && reason.is_none(),
"{ex}: only not_reportable takes reason_kind and reason"
);
match Form1099::parse(what) {
Some(f) if what.starts_with("1099-") => Decision1099::Files(f),
_ => Decision1099::Undetermined,
}
}
other => panic!("{ex}: decision {other:?}"),
}
}
/// The example's directory (`setup.parties`), by name: each party with
@@ -569,7 +650,8 @@ fn add_parties(ex: &Example, l: &mut Ledger) -> BTreeMap<String, PartyId> {
impl<'a> Books<'a> {
/// The books an example sets up, before any event: its shape checked,
/// its accounts, fixed assets, parties, members and payees.
/// its accounts, fixed assets, parties, members, payees, their dated
/// decisions, and payment methods.
fn new(ex: &'a Example) -> Self {
let s = &ex.setup;
let currency = Currency::new(&s.currency).unwrap();
@@ -684,6 +766,7 @@ impl<'a> Books<'a> {
})
})
.collect();
let source = bookspec_source(&ex.id);
let mut payees = BTreeMap::new();
for p in &s.payees {
let form = Form1099::parse(&p.form)
@@ -697,7 +780,8 @@ impl<'a> Books<'a> {
.add_vendor(&p.name)
.unwrap_or_else(|e| panic!("{}: payee {}: {e}", ex.id, p.name)),
};
l.set_vendor_1099(v, Some(form), false)
let dated = Dated1099::from_beginning(Decision1099::Files(form), Some(source.clone()));
l.set_vendor_1099(v, dated, false)
.unwrap_or_else(|e| panic!("{}: payee {}: {e}", ex.id, p.name));
assert!(
payees.insert(p.name.clone(), v).is_none(),
@@ -706,6 +790,47 @@ impl<'a> Books<'a> {
p.name
);
}
for c in &s.status_changes {
let what = format!("{} status change of {}", ex.id, c.payee);
let &v = payees
.get(&c.payee)
.unwrap_or_else(|| panic!("{what}: payee not in setup"));
let dated = Dated1099 {
effective: Some(date(&c.effective, &what)),
decision: decision(
&what,
&c.decision,
c.reason_kind.as_deref(),
c.reason.as_deref(),
),
source: Some(source.clone()),
};
l.set_vendor_1099(v, dated, false)
.unwrap_or_else(|e| panic!("{what}: {e}"));
}
let mut instruments = BTreeMap::new();
for i in &s.instruments {
let what = format!("{} instrument {}", ex.id, i.label);
let kind = InstrumentKind::parse(&i.kind)
.unwrap_or_else(|| panic!("{what}: kind {:?}", i.kind));
let account = l
.chart()
.find_by_code(&i.account)
.unwrap_or_else(|| panic!("{what}: no account {}", i.account))
.id;
let owner = i.owner.as_ref().map(|o| {
*parties
.get(o)
.unwrap_or_else(|| panic!("{what}: owner {o:?} not in setup.parties"))
});
let id = l
.add_instrument(kind, &i.label, i.last4.as_deref(), owner, account)
.unwrap_or_else(|e| panic!("{what}: {e}"));
assert!(
instruments.insert(i.label.clone(), id).is_none(),
"{what}: twice"
);
}
Books {
ex,
l,
@@ -715,6 +840,7 @@ impl<'a> Books<'a> {
parties,
vendors: payees.clone(),
payees,
instruments,
}
}
@@ -735,8 +861,8 @@ impl<'a> Books<'a> {
"{ex} event {n}: a reversal takes its original's payee, a bill names its vendor, and a close has none"
);
assert!(
e.parties.is_empty() || e.reverses.is_none(),
"{ex} event {n}: a reversal takes its original's parties"
(e.parties.is_empty() && e.instrument.is_none()) || e.reverses.is_none(),
"{ex} event {n}: a reversal takes its original's parties and payment method"
);
let posted = match (&e.close, e.reverses, e.lines.is_empty()) {
(None, None, true) if e.bill.is_some() => {
@@ -883,6 +1009,15 @@ impl<'a> Books<'a> {
.set_entry_vendor(n, Some(v))
.unwrap_or_else(|err| panic!("{ex} event {n}: {err}"));
}
if let Some(label) = &e.instrument {
let i = *self
.instruments
.get(label)
.unwrap_or_else(|| panic!("{ex} event {n}: payment method {label:?} not in setup"));
self.l
.set_entry_instrument(n, Some(i))
.unwrap_or_else(|err| panic!("{ex} event {n}: {err}"));
}
}
}
@@ -1173,10 +1308,10 @@ fn check_profit_and_loss(ex: &Example, l: &Ledger, x: &Expect) {
}
}
/// The 1099 report a `vendor_1099_paid` expectation asks for: the
/// calendar year of `to`, from January 1 through `to`, out of the cash
/// and financing accounts, an entry's shares resolved with the members
/// who are parties (rules.md B-19, B-33).
/// The 1099 report a `vendor_1099_paid` or `vendor_1099` expectation
/// asks for: the calendar year of `to`, from January 1 through `to`, out
/// of the cash and financing accounts, an entry's shares resolved with
/// the members who are parties (rules.md B-19, B-33).
fn report_1099(b: &Books<'_>, x: &Expect) -> (NaiveDate, NaiveDate, ledger_core::Vendor1099Report) {
let (ex, l) = (b.ex, &b.l);
// A calendar-year count, never the fiscal default (rules.md B-30):
@@ -1205,7 +1340,7 @@ fn report_1099(b: &Books<'_>, x: &Expect) -> (NaiveDate, NaiveDate, ledger_core:
cash: codes(l, &ex.setup.cash_accounts, &ex.id),
financing: codes(l, &ex.setup.financing_accounts, &ex.id),
};
let r = vendor_1099_report_with_members(l, year, Some(to), None, &sources, &b.party_members)
let r = vendor_1099_report(l, year, None, &sources, &b.party_members, Some(to))
.unwrap_or_else(|e| panic!("{}: {e}", ex.id));
(from, to, r)
}
@@ -1229,6 +1364,55 @@ fn check_vendor_1099_paid(b: &Books<'_>, x: &Expect) {
}
}
/// Check one `vendor_1099` expectation: one payee's line of the 1099
/// worksheet ([`report_1099`]): `status` (`reportable`,
/// `under_threshold`, `not_reportable`, `undetermined`), the `threshold`
/// applied, and what was `paid` (counts toward its 1099),
/// `excluded_card` (a card or payment network's, Form 1099-K),
/// `undetermined` (its payment method not recorded) and `not_reportable`
/// (paid while decided not reportable); every one given (rules.md B-19).
fn check_vendor_1099(b: &Books<'_>, x: &Expect) {
let ex = b.ex;
let name = x
.payee
.as_deref()
.unwrap_or_else(|| panic!("{}: vendor_1099 names its payee", ex.id));
let v = *b
.payees
.get(name)
.unwrap_or_else(|| panic!("{}: payee {name:?} not in setup", ex.id));
let (from, to, r) = report_1099(b, x);
let line = r
.vendors
.iter()
.find(|l| l.vendor_id == v)
.unwrap_or_else(|| panic!("{}: the 1099 worksheet has no line for {name}", ex.id));
let keys = [
"status",
"threshold",
"paid",
"excluded_card",
"undetermined",
"not_reportable",
];
exactly(ex, x, &keys.iter().map(|k| k.to_string()).collect());
let what = |k: &str| format!("{} 1099 [{from} .. {to}] {name}: {k}", ex.id);
for (key, want) in x.figures(&ex.id) {
if key == "status" {
assert_eq!(line.status.key(), want, "{}", what(key));
continue;
}
let got = match key.as_str() {
"threshold" => r.threshold,
"paid" => line.paid,
"excluded_card" => line.excluded_card,
"undetermined" => line.undetermined,
_ => line.not_reportable,
};
assert_eq!(got, exact(want, &what(key)), "{}", what(key));
}
}
/// The money party shares resolve against: the cash accounts (every
/// asset account but receivables when none are named), the cards and the
/// financing accounts (rules.md B-35 item 5).
@@ -1772,6 +1956,7 @@ fn check(b: &Books<'_>, x: &Expect) -> Option<&'static str> {
&["party_part", "party_totals", "party_tin_last4"],
);
only(x.basis.is_some(), "basis", &["party_totals"]);
only(x.payee.is_some(), "payee", &["vendor_1099"]);
only(
x.code.is_some(),
"code",
@@ -1817,6 +2002,7 @@ fn check(b: &Books<'_>, x: &Expect) -> Option<&'static str> {
"trial_balance" => check_trial_balance(ex, l, x),
"profit_and_loss" => check_profit_and_loss(ex, l, x),
"vendor_1099_paid" => check_vendor_1099_paid(b, x),
"vendor_1099" => check_vendor_1099(b, x),
"entry" => check_entry(ex, l, x),
"balances" => check_balances(ex, l, x),
"members_equity" => check_members_equity(ex, l, &b.members, x),
@@ -1909,19 +2095,20 @@ fn every_bookspec_example_holds() {
}
// Each query the examples use ran: the cash flow's six examples at
// least, the as-of and 1099 examples', the members closes', the
// formats appendix's, and P3's default period, rename, tax category
// and parties examples'.
// formats appendix's, and P3's default period, rename, tax category,
// parties and 1099 examples'.
for (q, at_least) in [
("cash_flow", 6),
("trial_balance", 2),
("profit_and_loss", 2),
("vendor_1099_paid", 3),
("vendor_1099_paid", 4),
("vendor_1099", 10),
("entry", 8),
("balances", 4),
("members_equity", 10),
("k1", 2),
("party_part", 27),
("party_totals", 21),
("party_part", 28),
("party_totals", 23),
("party_tin_last4", 5),
("iif_lines", 1),
("iif_changes", 1),
@@ -2297,3 +2484,36 @@ fn party_totals_without_a_basis_are_refused() {
t.replacen("basis = \"all\"\n", "", 1)
});
}
/// A `vendor_1099` line lists every figure it covers: one left out is
/// refused.
#[test]
#[should_panic(expected = "lists exactly what it covers")]
fn a_vendor_1099_line_leaving_a_figure_out_is_refused() {
run_edited("1099-card-payments-are-excluded.toml", |t| {
t.replacen(", not_reportable = \"0.00\" }", " }", 1)
});
}
/// A `vendor_1099` line's status is checked: card payments that would
/// reach the threshold leave the payee under it.
#[test]
#[should_panic(expected = "Harbor Design LLC: status")]
fn a_vendor_1099_status_is_checked() {
run_edited("1099-card-payments-are-excluded.toml", |t| {
t.replacen("status = \"under_threshold\"", "status = \"reportable\"", 1)
});
}
/// A payment method an event names must be in the setup.
#[test]
#[should_panic(expected = "payment method \"Harbor debit\" not in setup")]
fn a_payment_method_not_in_the_setup_is_refused() {
run_edited("1099-card-payments-are-excluded.toml", |t| {
t.replacen(
"instrument = \"Harbor debit card\"",
"instrument = \"Harbor debit\"",
1,
)
});
}
@@ -0,0 +1,9 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc f1f81d6b2a71deee0cee08164bdccd96db567e863caae4f1e56263a089cb32d7 # shrinks to gens = [(0, 1, 1, 1, 0, 0, false)]
cc 02aee1c532df63f34663271ebef6be02960e18365ee077c9347cd77be1e4cecb # shrinks to gens = [(0, 5, 1, 1, 0, 0, false)]
cc b89cbcbdcf87d96e77d29b1fa57a347b7f206c338e27b7a4687b8d5b2cf29162 # shrinks to gens = [(1, 3, 1, 1, 0, 2, false)]
File diff suppressed because it is too large. Load diff
+21
View File
@@ -123,6 +123,27 @@ proptest! {
prop_assert_eq!(ri, kernel::reversal(&to_dec(&lines)));
}
/// The 1099 kernel (rules.md B-19): a payment split into its buckets,
/// and netted, the same at both types.
#[test]
fn payment_buckets_agree(lines in arb_lines()) {
let bucket = |k: u8| match k {
0 => Some(kernel::Bucket::Counts),
1 => Some(kernel::Bucket::Card),
2 => Some(kernel::Bucket::Undetermined),
_ => None,
};
let i: Vec<_> = lines.iter().map(|&(k, s, a)| (bucket(k), s, a)).collect();
let d: Vec<_> = lines.iter().map(|&(k, s, a)| (bucket(k), s, dec(a))).collect();
let si = kernel::split_payment(&i).unwrap();
let sd = kernel::split_payment(&d).unwrap();
prop_assert_eq!(si.map(dec), sd);
prop_assert_eq!(
kernel::net_buckets(si).map(|b| b.map(dec)),
kernel::net_buckets(sd)
);
}
#[test]
fn balances_agree(t in arb_type(), m in arb_cents()) {
prop_assert_eq!(kernel::normal_balance(t, m).map(dec), kernel::normal_balance(t, dec(m)));
+7 -1
View File
@@ -56,7 +56,13 @@ fn books() -> Books {
.add_account("1900", "Other Assets", AccountType::Asset, None, true)
.unwrap();
let cash = l
.add_account("1000", "Bluevine Checking", AccountType::Asset, None, false)
.add_account(
"1000",
"Quillmere Checking",
AccountType::Asset,
None,
false,
)
.unwrap();
let card = l
.add_account(
+1 -1
View File
@@ -1689,7 +1689,7 @@ mod shares {
members: Vec<PartyMember>,
}
/// Bluevine 1000, Due to Shareholders 2100, capital 3000 and 3001,
/// Quillmere 1000, Due to Shareholders 2100, capital 3000 and 3001,
/// equipment 1500, an expense 6050; members Tobin and Morgan at
/// 50/50; a party Acme (no records).
fn books() -> Books {
@@ -884,6 +884,72 @@ fn defect24_a_gift_card_sold_is_received_once() {
assert_eq!(b.totals(c, Basis::All), (Decimal::ZERO, d("100.00")));
}
/// B-35 item 5 and `parties::card_accounts` INVARIANTS 1-4: one rule for
/// what a card is. Before any business card is recorded, the liabilities
/// whose names say "card" but not "gift" (2050 Business Card; never 2410
/// Gift Card Liability), less a member's card's account; once one is
/// recorded, exactly the business cards' liabilities, whatever their
/// names, and a debit card on the bank (an asset) is none. A member's
/// card or a platform is never the company's card, and a card read as a
/// card is money: a charge to it is paid.
#[test]
fn a_card_is_a_recorded_business_card_else_a_liability_named_card() {
use ledger_core::InstrumentKind as K;
let mut b = books();
let cards = |b: &Books| ledger_core::parties::card_accounts(&b.l);
assert_eq!(cards(&b), vec![b.card]);
// A member's own card, on a liability named for it: not the
// company's, before or after a business card is recorded.
let personal =
b.l.add_account("2060", "Tobin's Card", AccountType::Liability, None, false)
.unwrap();
assert_eq!(cards(&b), vec![b.card, personal]);
b.l.add_instrument(K::MemberCard, "Tobin's card", None, Some(b.tobin), personal)
.unwrap();
assert_eq!(
cards(&b),
vec![b.card],
"a member's card is not a card account"
);
// A platform's balance is no card either.
b.l.add_instrument(K::Platform, "Payment platform", None, None, b.prepaid)
.unwrap();
assert_eq!(cards(&b), vec![b.card]);
// A business card on the bank (a debit card, an asset): no card
// account, so the name rule still holds.
b.l.add_instrument(K::BusinessCard, "Debit card", None, None, b.cash)
.unwrap();
assert_eq!(cards(&b), vec![b.card]);
// A business card recorded on a liability not named "card": now
// exactly the recorded cards' liabilities; 2050, named "card" but not
// recorded, no longer is.
let visa =
b.l.add_account("2070", "Harbor Visa", AccountType::Liability, None, false)
.unwrap();
b.l.add_instrument(K::BusinessCard, "Harbor Visa", None, None, visa)
.unwrap();
assert_eq!(cards(&b), vec![visa]);
assert_eq!(
b.l.card_accounts().into_iter().collect::<Vec<_>>(),
vec![visa]
);
// Money follows the rule: a charge to the recorded card is paid; one
// to 2050, a liability like any other now, is a promise.
let v = b.vendor;
let on_visa = b.post(
date(4, 1),
&[(b.hosting, Dr, "30.00"), (visa, Cr, "30.00")],
&[whole(v)],
);
let on_2050 = b.post(
date(4, 2),
&[(b.hosting, Dr, "20.00"), (b.card, Cr, "20.00")],
&[whole(v)],
);
assert_eq!(b.resolved(on_visa).0.direction, Direction::Paid);
assert_eq!(b.resolved(on_2050).0.direction, Direction::Neither);
}
/// B-35 item 3, case 3: E is the movement of equity other than retained
/// earnings, so a members' entry reads as the statement of members' equity
/// reads it. Retained earnings given up for equipment moves no member's
+108 -8
View File
@@ -1,6 +1,7 @@
//! What the books need: drafts left unposted, missing receipts, schedules
//! and recurring entries due, open past periods, uncategorized tax lines,
//! 1099 vendors without a W-9, overdue bills and invoices, money owed to
//! 1099 vendors without a W-9 or without a 1099 decision, payments whose
//! method the 1099 worksheet needs, overdue bills and invoices, money owed to
//! owners, fixed asset depreciation not posted, assets without a tag or a
//! tax basis, contractors and landlords paid without W-9 facts, bank and
//! card accounts not reconciled through last month, and legal
@@ -12,7 +13,7 @@ use crate::books::{Attention, Books, Severity};
use crate::fmt;
use crate::nav::Route;
use chrono::Datelike;
use ledger_core::{AccountType, EntryStatus, PeriodStatus, vendor_1099_report};
use ledger_core::{AccountType, EntryStatus, PeriodStatus};
use rust_decimal::Decimal;
/// A figure the books can't compute (rules.md B-28: books an earlier
@@ -38,7 +39,9 @@ fn cant_compute(what: String, why: &ledger_core::LedgerError, target: Option<Rou
/// prompt that gets the agent to fix it; never change the books.
/// SOURCE: DECISION (owner: the viewer is read-only; its "Needs
/// attention" list offers prompts, AGENTS.md); the conditions
/// below; docs/spec/sources/irs-1099-thresholds.md (W-9s).
/// below; docs/spec/sources/irs-1099-thresholds.md (W-9s);
/// DECISION D-40 (owner, 2026-10-20: ask only about vendors paid
/// in a way that could need a 1099).
/// INVARIANTS:
/// 1. Pure: reads `b`, writes nothing.
/// 2. One item per: draft entry (Todo); posted entry missing a receipt
@@ -48,8 +51,21 @@ fn cant_compute(what: String, why: &ledger_core::LedgerError, target: Option<Rou
/// category, with activity or not (ledger_core::tax::
/// uncategorized_accounts: one item; Info, Todo under
/// policy.require_tax_category = refuse); legal rule known only
/// through a past year (Todo); 1099
/// vendor without a W-9 (Problem); overdue bills, and overdue
/// through a past year (Todo); for
/// this calendar year, and last year in January, from
/// Books::vendor_1099 (the server's books.vendor_1099): a vendor a
/// 1099 is due for (reportable) without a W-9 (Problem); a vendor
/// paid that year in a way that could count toward a 1099 while its
/// 1099 decision was undetermined (Vendor1099Line::decision_needed:
/// Problem when its status is undetermined, else Todo, its prompt
/// naming books.set_vendor_1099); the year's payments whose method
/// decides whether they count (Vendor1099Report::
/// undetermined_payments, none for a vendor decided not reportable),
/// one Todo counting reversal chains, its prompt naming
/// books.instruments, books.add_instrument and
/// books.set_entry_instrument; a year whose worksheet can't be made
/// for want of named cash accounts while vendors were paid in it
/// (Todo); overdue bills, and overdue
/// invoices (Problem, one each, with the total); money owed to
/// owners (Info, per account with a balance); the database differing
/// from the journal or unreadable, and damaged or missing stored
@@ -305,9 +321,93 @@ pub fn find(b: &Books) -> Vec<Attention> {
for year in years {
// A year whose threshold isn't known is flagged by the stale-rule
// item above; never guess it here.
let Ok(r) = vendor_1099_report(&b.ledger, year, None) else {
continue;
let r = match b.vendor_1099(year) {
Ok(r) => r,
Err(ledger_core::LedgerError::Invalid1099(why))
if b.ledger.posted_entries().any(|e| {
e.date().year() == year && b.ledger.entry_vendor(e.id()).is_some()
}) =>
{
out.push(Attention {
severity: Severity::Todo,
title: format!("The {year} 1099 worksheet can't be made"),
detail: format!("{why}."),
target: Some(Route::Reports(crate::nav::ReportKind::Vendor1099)),
prompt: format!(
"In wanderbooks, the {year} 1099 worksheet can't be made: {why}. Ask me which accounts hold the company's cash, then set them with books.set_setting."
),
});
continue;
}
Err(_) => continue,
};
let in_year = if year == today.year() {
String::new()
} else {
format!(" for {year}")
};
for v in r.vendors.iter().filter(|v| v.decision_needed) {
let party = b.ledger.party_of_vendor(v.vendor_id);
let unknown = if v.undetermined > Decimal::ZERO {
format!(
" and {} more whose payment method isn't recorded",
fmt::dollars(v.undetermined)
)
} else {
String::new()
};
out.push(Attention {
severity: if v.status == ledger_core::Status1099::Undetermined {
Severity::Problem
} else {
Severity::Todo
},
title: format!("Decide whether {} gets a 1099{in_year}", v.name),
detail: format!(
"Paid {} by check, ACH, transfer or cash{unknown} in {year}, with no 1099 decision.",
fmt::dollars(v.paid)
),
target: Some(party.map_or(Route::Company, Route::Party)),
prompt: format!(
"In wanderbooks, {} was paid {}{unknown} in {year} and has no 1099 decision. Ask me whether it gets a 1099-NEC, a 1099-MISC, or none and why (a corporation, the United States or a state, a payee selling only merchandise, ...); then record it with books.set_vendor_1099, with who decided and when.",
v.name,
fmt::dollars(v.paid)
),
});
}
// Payments whose method decides whether they count, a payment
// and its reversals counted once.
let mut chains: Vec<ledger_core::EntryId> = r
.undetermined_payments
.iter()
.map(|p| b.ledger.reversal_chain(p.entry_id)[0])
.collect();
chains.sort_unstable();
chains.dedup();
if !chains.is_empty() {
let n = chains.len();
let ids = chains
.iter()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join(", ");
let (payments, entries) = if n == 1 {
("payment has", "entry")
} else {
("payments have", "entries")
};
out.push(Attention {
severity: Severity::Todo,
title: format!("{n} {payments} no payment method{in_year}"),
detail: format!(
"Paid for the company through a financing account in {year} ({entries} {ids}): a member's card doesn't count toward a 1099, their cash or check does, so the 1099 worksheet can't tell until each is recorded."
),
target: Some(Route::Reports(crate::nav::ReportKind::Vendor1099)),
prompt: format!(
"In wanderbooks, {entries} {ids} paid a vendor in {year} through a financing account with no payment method recorded, so the 1099 worksheet can't tell whether they count. Ask me how each was paid (whose card, or cash, check or a transfer); add any method not yet in books.instruments with books.add_instrument, then tag each entry with books.set_entry_instrument."
),
});
}
for v in r.vendors.iter().filter(|v| v.reportable && !v.tin_on_file) {
if year == today.year() {
asked_w9.insert(v.vendor_id);
@@ -323,7 +423,7 @@ pub fn find(b: &Books) -> Vec<Attention> {
),
target: Some(party.map_or(Route::Company, Route::Party)),
prompt: format!(
"Remind me to request a W-9 from {}; once I have it, mark it on file in wanderbooks with books.set_vendor_1099.",
"Remind me to request a W-9 from {}; once I have it, record what it says in wanderbooks with books.set_w9 (that marks it on file and keeps the 1099 decision).",
v.name
),
});
+50 -34
View File
@@ -156,18 +156,19 @@ impl AssetTrail {
/// 4a. Each of 3 and 4 is None when it can't be summed exactly (a
/// document is restored as an earlier version journaled it, of any
/// size: rules.md B-28), never a wrong number.
/// 5. form_1099: its vendor record's line in
/// vendor_1099_report_with(ledger, year, None, cash and financing
/// accounts), when it has a vendor
/// record in that report and the report knows the year's threshold
/// (threshold_1099 is then that threshold).
/// 5. form_1099: its vendor record's line in Books::vendor_1099(year)
/// (the server's books.vendor_1099), when it has a vendor record in
/// that report and the report can be made (the year's threshold is
/// known, the cash accounts are named; threshold_1099 is then that
/// threshold).
/// 6. payroll_runs: the runs with a line whose employee is the party,
/// or (older runs, no party on the line) whose employee_name is the
/// party's name ignoring case and surrounding space.
/// 7. events: indexes into Books::journal of party.* events with its
/// id, entry.party_set or entry.parties_set naming it, and its
/// records' vendor.added,
/// customer.added, vendor.1099_set, and entry.vendor_set events.
/// customer.added, vendor.1099_set, vendor.w9_flag_set, and
/// entry.vendor_set events.
/// ```
#[derive(Clone, Debug, Default, PartialEq)]
pub struct PartyInfo {
@@ -1020,12 +1021,45 @@ impl Books {
}
/// Whether a party files a 1099: it has a 1099 role (contractor,
/// landlord), or its vendor record is marked for a 1099.
/// landlord), or its vendor record has been decided for one (from any
/// date: its W-9 matters for those payments).
pub fn files_1099(&self, p: &Party) -> bool {
p.roles.iter().any(|r| r.gets_1099())
|| p.vendor
.and_then(|v| self.ledger.get_vendor(v))
.is_some_and(|v| v.form_1099.is_some())
.is_some_and(|v| v.decisions_1099.iter().any(|d| d.decision.form().is_some()))
}
/// The 1099 report for calendar `year`, the server's
/// (ledger_package::vendor_1099_report, books.vendor_1099 with no
/// accounts named): payments out of the settings' cash and financing
/// accounts, classified by
/// payment method, shares resolved with the members. Every 1099
/// figure the viewer shows comes from here.
pub fn vendor_1099(&self, year: i32) -> Result<ledger_core::Vendor1099Report, LedgerError> {
let settings = ledger_package::Settings {
company: &self.settings.company,
cash_accounts: &self.settings.cash_accounts,
financing_accounts: &self.settings.financing_accounts,
investing_accounts: &self.settings.investing_accounts,
};
ledger_package::vendor_1099_report(&self.ledger, &settings, year, None, None)
}
/// The 1099 worksheet for calendar `year` without TINs: the server's
/// books.vendor_1099 (its lines are Books::vendor_1099's, with its
/// notes).
pub fn vendor_1099_worksheet(
&self,
year: i32,
) -> Result<ledger_package::Vendor1099Worksheet, String> {
let settings = ledger_package::Settings {
company: &self.settings.company,
cash_accounts: &self.settings.cash_accounts,
financing_accounts: &self.settings.financing_accounts,
investing_accounts: &self.settings.investing_accounts,
};
ledger_package::vendor_1099_worksheet(&self.ledger, &settings, year, None, None, None)
}
/// Every party's [`PartyInfo`].
@@ -1089,11 +1123,7 @@ impl Books {
}
}
}
let sources = ledger_core::PaymentAccounts {
cash: self.cash_accounts.clone(),
financing: self.financing_accounts.clone(),
};
if let Ok(r) = ledger_core::vendor_1099_report_with(l, year, None, &sources) {
if let Ok(r) = self.vendor_1099(year) {
for v in &r.vendors {
if let Some(info) = l.party_of_vendor(v.vendor_id).and_then(|p| out.get_mut(&p)) {
info.form_1099 = Some(v.clone());
@@ -1143,7 +1173,7 @@ impl Books {
}
"vendor.added" => u(p, "id").and_then(|v| l.party_of_vendor(v)),
"customer.added" => u(p, "id").and_then(|c| l.party_of_customer(c)),
"vendor.1099_set" | "entry.vendor_set" => {
"vendor.1099_set" | "vendor.w9_flag_set" | "entry.vendor_set" => {
u(p, "vendor_id").and_then(|v| l.party_of_vendor(v))
}
_ => None,
@@ -1894,27 +1924,13 @@ impl Books {
}
/// A party's 1099 worksheet line for a calendar year and that year's
/// threshold: ledger-core's vendor_1099_report_with_members line for
/// its vendor record, with Books::money_cash_accounts, the financing
/// accounts and the members (the server's books.vendor_1099 and
/// books.party amount_1099); None without a vendor record in the
/// report, or when the year's threshold isn't known.
/// threshold: its vendor record's line in Books::vendor_1099 (the
/// server's books.vendor_1099); None without a vendor record in the
/// report, or when the report can't be made (the year's threshold
/// isn't known, or no cash accounts are named).
pub fn party_1099(&self, party: PartyId, year: i32) -> Option<(Vendor1099Line, Decimal)> {
let l = &self.ledger;
let vendor = l.get_party(party)?.vendor?;
let sources = ledger_core::PaymentAccounts {
cash: self.money_cash_accounts.clone(),
financing: self.financing_accounts.clone(),
};
let r = ledger_core::vendor_1099_report_with_members(
l,
year,
None,
None,
&sources,
&self.members,
)
.ok()?;
let vendor = self.ledger.get_party(party)?.vendor?;
let r = self.vendor_1099(year).ok()?;
let line = r.vendors.iter().find(|v| v.vendor_id == vendor)?.clone();
Some((line, r.threshold))
}
+26 -5
View File
@@ -9,8 +9,8 @@
use chrono::{Datelike, Days, Months, NaiveDate};
use ledger_core::{
AccountType, CloseTo, Currency, Form1099, Frequency, Money, NewAsset, NewRecurring,
NewSchedule, ScheduleKind, Side, arap::DocumentLine,
AccountType, CloseTo, Currency, Dated1099, Decision1099, Form1099, Frequency, InstrumentKind,
Money, NewAsset, NewRecurring, NewSchedule, ScheduleKind, Side, arap::DocumentLine,
};
use ledger_store::{Statement, Store, StoreError, Subject};
use rust_decimal::Decimal;
@@ -245,12 +245,33 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
let client_class = s.add_class("Client work")?;
let template_class = s.add_class("Templates")?;
// Each vendor's 1099 decision, as the owner made it when they were
// first paid; the card is a payment method, so what was charged to it
// shows on the 1099 worksheet as excluded (Form 1099-K's).
let decided = |form| {
Dated1099::from_beginning(
Decision1099::Files(form),
Some(ledger_core::settings::Source {
decided_by: "Alex Rivera".into(),
decided_on: start,
citation: None,
note: None,
}),
)
};
let sam = s.add_vendor("Sam Okafor")?;
s.set_vendor_1099(sam, Some(Form1099::Nec), true)?;
s.set_vendor_1099(sam, &decided(Form1099::Nec), Some(true))?;
let riley = s.add_vendor("Riley Chen")?;
s.set_vendor_1099(riley, Some(Form1099::Nec), false)?;
s.set_vendor_1099(riley, &decided(Form1099::Nec), Some(false))?;
let landlord = s.add_vendor("Everett Lofts LLC")?;
s.set_vendor_1099(landlord, Some(Form1099::Misc), true)?;
s.set_vendor_1099(landlord, &decided(Form1099::Misc), Some(true))?;
s.add_instrument(
InstrumentKind::BusinessCard,
"Business credit card",
Some("4242"),
None,
card,
)?;
let customers: Vec<u64> = [
"Fern & Field Coffee",
"Bramble Outdoor Co.",
+3 -2
View File
@@ -201,6 +201,7 @@ impl Job {
&settings,
year,
None,
None,
Some(&tin),
)?;
let csv = ledger_package::vendor_1099_csv(&w, true);
@@ -210,10 +211,10 @@ impl Job {
let gaps = w
.lines
.iter()
.filter(|l| l.reportable && l.tin.is_none())
.filter(|l| l.may_file() && l.tin.is_none())
.count();
let note = (gaps > 0).then(|| {
format!("{gaps} reportable payee(s) have no TIN in it: ask your agent to collect them.")
format!("{gaps} payee(s) a 1099 is or may be due for have no TIN in it: ask your agent to collect them.")
});
Ok((path, note))
}
+16 -8
View File
@@ -11,8 +11,8 @@ use crate::theme::{self, pal};
use crate::widgets;
use egui::{CornerRadius, RichText, Sense, Ui, vec2};
use egui_phosphor::regular as icon;
use ledger_core::PeriodStatus;
use ledger_core::assets::{accumulated_depreciation, book_value};
use ledger_core::{PeriodStatus, vendor_1099_report};
use rust_decimal::Decimal;
fn kv(ui: &mut Ui, k: &str, v: &str) {
@@ -597,7 +597,7 @@ fn parties(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
}
};
let year = chrono::Datelike::year(&b.today);
let r1099 = vendor_1099_report(&b.ledger, year, None).ok();
let r1099 = b.vendor_1099(year).ok();
columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i {
0 => {
widgets::titled_card(
@@ -618,13 +618,21 @@ fn parties(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
.unwrap_or_default();
ui.horizontal_wrapped(|ui| {
name(ui, &v.name, b.ledger.party_of_vendor(v.id));
if let Some(f) = v.form_1099 {
widgets::pill(ui, f.label(), p.accent, p.accent_soft);
if v.tin_on_file {
widgets::pill(ui, "W-9", p.positive, p.positive_soft);
} else {
widgets::pill(ui, "no W-9", p.negative, p.negative_soft);
match v.decision_on(b.today) {
Some(ledger_core::Decision1099::Files(f)) => {
widgets::pill(ui, f.label(), p.accent, p.accent_soft);
if v.tin_on_file {
widgets::pill(ui, "W-9", p.positive, p.positive_soft);
} else {
widgets::pill(ui, "no W-9", p.negative, p.negative_soft);
}
}
Some(ledger_core::Decision1099::NotReportable { .. }) => {
widgets::pill(ui, "No 1099", p.muted, p.hover);
}
// Undecided: nothing to show (the 1099
// worksheet and Needs attention ask).
_ => {}
}
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
+71 -15
View File
@@ -631,21 +631,36 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
let vendor = p.get("vendor_id").and_then(|v| v.as_u64());
let name = vendor
.and_then(|v| b.ledger.get_vendor(v))
.map(|v| v.name.clone());
let form = p
.get("form_1099")
.and_then(|v| v.as_str())
.and_then(ledger_core::Form1099::parse)
.map(|f| f.label());
let w9 = p.get("tin_on_file").and_then(|v| v.as_bool());
let text = match (name, form, w9) {
(Some(n), Some(f), Some(w)) => format!(
"1099 details of {n}: {f}, W-9 {}",
if w { "on file" } else { "not on file" }
),
(Some(n), None, Some(_)) => format!("{n} no longer gets a 1099"),
(Some(n), _, None) => format!("Updated the 1099 details of {n}"),
(None, ..) => "Updated a vendor's 1099 details".into(),
.map_or_else(|| "a vendor".to_string(), |v| v.name.clone());
let w9 = |w: bool| if w { "on file" } else { "not on file" };
let text = match ledger_store::form1099::decision_of(line) {
// Recorded with who decided (plan defects 18, 23; D-40).
Ok((_, d, _)) if d.source.is_some() => {
let s = d.source.as_ref().expect("checked");
let what = match &d.decision {
ledger_core::Decision1099::Undetermined => "undetermined".to_string(),
other => crate::pages::reports::decision_text(other),
};
let from = d
.effective
.map(|e| format!(" from {}", fmt::date(e)))
.unwrap_or_default();
format!(
"1099 decision for {name}{from}: {what} (by {}, {})",
s.decided_by,
fmt::date(s.decided_on)
)
}
// Written before decisions were kept: a form, or none
// (which can't say whether anyone decided: plan 5.5).
Ok((_, d, flag)) => match d.decision.form() {
Some(f) => format!("1099 details of {name}: {}, W-9 {}", f.label(), w9(flag)),
None => format!(
"1099 details of {name}: no form recorded (undetermined), W-9 {}",
w9(flag)
),
},
Err(_) => format!("Updated the 1099 details of {name}"),
};
ev(
icon::IDENTIFICATION_CARD,
@@ -657,6 +672,47 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
),
)
}
"vendor.w9_flag_set" => {
let vendor = p.get("vendor_id").and_then(|v| v.as_u64());
let name = vendor
.and_then(|v| b.ledger.get_vendor(v))
.map_or_else(|| "a vendor".to_string(), |v| v.name.clone());
let text = match p.get("tin_on_file").and_then(|v| v.as_bool()) {
Some(true) => format!("W-9 of {name} marked on file"),
Some(false) => format!("W-9 of {name} marked not on file"),
None => format!("Updated the W-9 of {name}"),
};
ev(
icon::IDENTIFICATION_CARD,
text,
Some(
vendor
.and_then(|v| b.ledger.party_of_vendor(v))
.map_or(Route::Company, Route::Party),
),
)
}
"instrument.added" => {
let text = match ledger_store::form1099::instrument_of(line) {
Ok(i) => format!("Added payment method {} ({})", i.label, i.kind.label()),
Err(_) => "Added a payment method".to_string(),
};
ev(icon::CREDIT_CARD, text, None)
}
"entry.instrument_set" => {
let e = entry_id.unwrap_or(0);
let instrument = p.get("instrument_id").and_then(|v| v.as_u64());
Some(Event {
icon: icon::CREDIT_CARD,
text: match instrument.and_then(|i| b.ledger.get_instrument(i)) {
Some(i) => format!("Payment method of #{e}: {}", i.label),
None if instrument.is_none() => format!("Cleared the payment method of #{e}"),
None => format!("Tagged #{e} with a payment method"),
},
route: entry_id.map(Route::Entry),
also: None,
})
}
"entry.vendor_set" => {
let vendor = p.get("vendor_id").and_then(|v| v.as_u64());
let e = entry_id.unwrap_or(0);
+20 -7
View File
@@ -751,21 +751,27 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) {
b.party_1099(id, tax_year)
};
if let Some((l, threshold)) = &form {
let sub = match l.form {
Some(_) if l.reportable => (
let sub = match l.status {
ledger_core::Status1099::Reportable => (
format!("Reportable · threshold {}", fmt::dollars(*threshold)),
p.warning,
),
Some(_) => (
ledger_core::Status1099::UnderThreshold => (
format!("Under the {} threshold", fmt::dollars(*threshold)),
p.muted,
),
None => ("Not marked for a 1099".into(), p.muted),
ledger_core::Status1099::NotReportable => ("Not reportable".into(), p.muted),
ledger_core::Status1099::Undetermined if l.decision_needed => {
("Needs a 1099 decision".into(), p.warning)
}
ledger_core::Status1099::Undetermined => {
("Undetermined: how was it paid?".into(), p.warning)
}
};
tiles.push((
format!("1099 amount, {tax_year}"),
Some(l.paid),
"Cash paid in the calendar year, from the 1099 worksheet (ledger-core): posted entries tagged with its vendor record, net of refunds and reversals. Card payments aren't counted (the card issuer reports them).".into(),
"What counts toward its 1099 in the calendar year, from the 1099 worksheet (the server's books.vendor_1099): posted entries tagged with its vendor record, paid by check, ACH, transfer or cash, net of refunds and reversals. Card and payment-network payments aren't counted (the processor reports them on Form 1099-K); what a member paid with no payment method recorded is undetermined until it is.".into(),
Some(sub),
));
}
@@ -1176,8 +1182,15 @@ fn records_card(ui: &mut Ui, b: &Books, party: &Party, info: &PartyInfo, go: &mu
k(ui, "Vendor");
ui.horizontal_wrapped(|ui| {
ui.label(format!("{} · vendor {}", v.name, v.id));
if let Some(f) = v.form_1099 {
widgets::pill(ui, f.label(), p.accent, p.accent_soft);
match v.decision_on(b.today) {
Some(ledger_core::Decision1099::Files(f)) => {
widgets::pill(ui, f.label(), p.accent, p.accent_soft);
}
Some(ledger_core::Decision1099::NotReportable { .. }) => {
widgets::pill(ui, "No 1099", p.muted, p.hover);
}
// Undecided: nothing (Needs attention asks).
_ => {}
}
});
ui.end_row();
+72 -22
View File
@@ -18,7 +18,7 @@ use ledger_core::reports::{
balance_sheet_as_of, general_ledger, members_equity, profit_and_loss_for_period,
trial_balance_as_of,
};
use ledger_core::{AccountId, AccountType, MemberCapital, tax_summary, vendor_1099_report};
use ledger_core::{AccountId, AccountType, MemberCapital, tax_summary};
use rust_decimal::Decimal;
use std::collections::BTreeMap;
@@ -2193,51 +2193,94 @@ fn gl(ui: &mut Ui, b: &Books, s: NaiveDate, e: NaiveDate, shell: &mut Shell<'_>)
}
}
/// A 1099 decision in the viewer's words: "1099-NEC", "Not reportable: a
/// corporation (by name)", "Undecided".
pub fn decision_text(d: &ledger_core::Decision1099) -> String {
match d {
ledger_core::Decision1099::Undetermined => "Undecided".into(),
ledger_core::Decision1099::Files(f) => f.label().into(),
ledger_core::Decision1099::NotReportable { kind, reason } => {
format!("Not reportable: {} ({reason})", kind.label())
}
}
}
/// A worksheet line's 1099 decisions in words, each change with its date.
fn decisions_text(l: &ledger_package::Vendor1099WorksheetLine) -> String {
if l.decisions.is_empty() {
return decision_text(&ledger_core::Decision1099::Undetermined);
}
l.decisions
.iter()
.enumerate()
.map(|(i, d)| match d.effective {
Some(e) if i > 0 => format!("from {}: {}", fmt::date(e), decision_text(&d.decision)),
_ => decision_text(&d.decision),
})
.collect::<Vec<_>>()
.join(" → ")
}
fn vendor_1099(ui: &mut Ui, b: &Books, year: i32, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let r = match vendor_1099_report(&b.ledger, year, None) {
Ok(r) => r,
let w = match b.vendor_1099_worksheet(year) {
Ok(w) => w,
Err(e) => {
// Never guess a threshold (rules.md B-19): say why there's no
// worksheet, in the engine's words.
ui.label(RichText::new(e.to_string()).color(p.muted));
// Never guess a threshold or the cash accounts (rules.md
// B-19): say why there's no worksheet, in the engine's words.
ui.label(RichText::new(e).color(p.muted));
return;
}
};
ui.label(
RichText::new(format!(
"Calendar {year}. Threshold {} ($600 through 2025; $2,000 from 2026, indexed after). Counts cash paid on entries tagged with the vendor; card payments are reported by the card processor (1099-K) and drop out.",
fmt::dollars(r.threshold)
"Calendar {year}. Threshold {} ($600 through 2025; $2,000 from 2026, indexed after). Counts what each vendor was paid by check, ACH, transfer or cash on entries tagged with it, under the 1099 decision in force on each payment's date. Card and payment-network payments are reported by the processor on Form 1099-K and don't count; what a member paid with no payment method recorded is undetermined until it is.",
fmt::dollars(w.threshold)
))
.color(p.muted),
);
ui.add_space(10.0);
let mut t = Table::new(vec!["Paid".into()]);
for v in &r.vendors {
let form = v.form.map(|f| f.label()).unwrap_or("No 1099");
let mut t = Table::new(vec![
"Counts".into(),
"Card or network".into(),
"Undetermined".into(),
]);
for v in &w.lines {
let w9 = if v.tin_on_file {
"W-9 on file"
} else {
"no W-9"
};
let status = if v.reportable {
if v.tin_on_file {
"File a 1099"
} else {
"File a 1099 · get a W-9 first"
let status = match v.status {
ledger_core::Status1099::Reportable if v.tin_on_file => "File a 1099",
ledger_core::Status1099::Reportable => "File a 1099 · get a W-9 first",
ledger_core::Status1099::UnderThreshold => "Under the threshold",
ledger_core::Status1099::NotReportable => "No 1099",
ledger_core::Status1099::Undetermined if v.decision_needed => {
"Decide whether it gets a 1099"
}
} else {
"Not reportable"
ledger_core::Status1099::Undetermined => "Record how it was paid",
};
let mut detail = format!("{} · {w9} · {status}", decisions_text(v));
if !v.not_reportable.is_zero() {
detail.push_str(&format!(
" · {} paid while not reportable",
fmt::dollars(v.not_reportable)
));
}
t.rows.push(Row {
kind: Kind::Line,
label: v.name.clone(),
detail: format!("{form} · {w9} · {status}"),
cells: vec![Cell::of(v.paid)],
detail,
cells: vec![
Cell::of(v.paid),
Cell::of(v.excluded_card),
Cell::of(v.undetermined),
],
});
}
if r.vendors.is_empty() {
t.note("No vendors marked for a 1099 and none paid this year.");
if w.lines.is_empty() {
t.note("No vendors decided for a 1099 and none paid this year.");
}
let pdf = PdfInfo::new(
b,
@@ -2245,6 +2288,13 @@ fn vendor_1099(ui: &mut Ui, b: &Books, year: i32, shell: &mut Shell<'_>) {
&format!("Calendar year {year}"),
);
render(ui, &t, &pdf, shell);
// The worksheet's notes, as books.vendor_1099 gives them, whole (a
// table note is one clipped line).
ui.add_space(10.0);
for n in &w.notes {
ui.add(egui::Label::new(RichText::new(n).font(theme::regular(12.0)).color(p.muted)).wrap());
ui.add_space(4.0);
}
}
#[cfg(test)]
+19 -5
View File
@@ -67,6 +67,19 @@ struct RootBook {
document: u64,
}
/// A 1099-NEC decision from the beginning, decided by the test.
fn nec() -> ledger_core::Dated1099 {
ledger_core::Dated1099::from_beginning(
ledger_core::Decision1099::Files(ledger_core::Form1099::Nec),
Some(ledger_core::settings::Source {
decided_by: "test".into(),
decided_on: today(),
citation: None,
note: None,
}),
)
}
fn root_book() -> RootBook {
let root = tempfile::Builder::new()
.prefix("wb-gui-root-")
@@ -391,8 +404,7 @@ fn the_menus_1099_worksheet_carries_a_payees_full_tin() {
let mut s = Store::open(&f.book).unwrap();
// A contractor marked for a 1099-NEC, paid past 2026's threshold.
let vendor = s.add_vendor("Pat Payee").unwrap();
s.set_vendor_1099(vendor, Some(ledger_core::Form1099::Nec), true)
.unwrap();
s.set_vendor_1099(vendor, &nec(), Some(true)).unwrap();
let party = s.ledger().party_of_vendor(vendor).unwrap();
let key = f
.book
@@ -479,8 +491,7 @@ fn the_menus_worksheet_says_which_payees_lack_a_tin() {
{
let mut s = Store::open(&f.book).unwrap();
let vendor = s.add_vendor("Pat Payee").unwrap();
s.set_vendor_1099(vendor, Some(ledger_core::Form1099::Nec), true)
.unwrap();
s.set_vendor_1099(vendor, &nec(), Some(true)).unwrap();
let e = s.create_entry(today(), "Design work").unwrap();
for (code, side) in [("6100", Side::Debit), ("1000", Side::Credit)] {
let id = s.ledger().chart().find_by_code(code).unwrap().id;
@@ -492,5 +503,8 @@ fn the_menus_worksheet_says_which_payees_lack_a_tin() {
let b = load(&f.book);
let (_, note) = Job::Worksheet(2026).run(&b).unwrap();
let note = note.expect("the missing TIN is noted");
assert!(note.starts_with("1 reportable payee(s)"), "{note}");
assert!(
note.starts_with("1 payee(s) a 1099 is or may be due for have no TIN"),
"{note}"
);
}
+278 -17
View File
@@ -7,8 +7,8 @@ use chrono::NaiveDate;
use egui_kittest::Harness;
use ledger_core::arap::DocumentLine;
use ledger_core::{
AccountType, Currency, EntryId, Form1099, Money, PartyField, PartyId, PayrollLine, Role, Side,
TinFacts, TinType, W4Facts, W9Facts,
AccountType, Currency, Dated1099, Decision1099, EntryId, Form1099, Money, PartyField, PartyId,
PayrollLine, Role, Side, TinFacts, TinType, W4Facts, W9Facts, vendor_1099_report,
};
use ledger_gui::books::Books;
use ledger_gui::nav::{Route, Section};
@@ -146,6 +146,22 @@ fn fixture(tag: &str) -> Fixture {
let labor = s
.add_account("6050", "Contract Labor", AccountType::Expense, None, false)
.unwrap();
// The books' cash account, which the 1099 worksheet counts payments
// out of (P3 review R5: never every asset).
let source = ledger_core::settings::Source {
decided_by: "test".into(),
decided_on: d(2025, 1, 1),
citation: None,
note: None,
};
s.change_settings(
&[ledger_core::settings::Change {
key: "books.cash_accounts".into(),
value: Some(serde_json::json!(["1000"])),
}],
&source,
)
.unwrap();
let e = s.create_entry(d(2025, 1, 1), "Owner contribution").unwrap();
s.add_line(e, cash, Side::Debit, usd(10_000_000)).unwrap();
s.add_line(e, capital, Side::Credit, usd(10_000_000))
@@ -160,8 +176,12 @@ fn fixture(tag: &str) -> Fixture {
};
let sam_vendor = s.add_vendor("Sam Okafor").unwrap();
s.set_vendor_1099(sam_vendor, Some(Form1099::Nec), false)
.unwrap();
s.set_vendor_1099(
sam_vendor,
&Dated1099::from_beginning(Decision1099::Files(Form1099::Nec), Some(source.clone())),
Some(false),
)
.unwrap();
let sam = s.ledger().party_of_vendor(sam_vendor).unwrap();
s.set_party_role(sam, Role::Contractor, true).unwrap();
let riley = s.add_party("Riley Chen", &[Role::Contractor]).unwrap();
@@ -259,8 +279,12 @@ fn fixture(tag: &str) -> Fixture {
s.set_entry_party(e, Some(old)).unwrap();
s.update_party(old, PartyField::Archived(true)).unwrap();
// What the members advanced is money (rules.md B-35 item 5): Due to
// Shareholders is a financing account.
journal_settings(&mut s, "[books]\nfinancing_accounts = [\"2100\"]\n");
// Shareholders is a financing account; the cash account stays the
// books' (the settings file names both).
journal_settings(
&mut s,
"[books]\ncash_accounts = [\"1000\"]\nfinancing_accounts = [\"2100\"]\n",
);
drop(s);
let oracle = Store::open(&data).unwrap().ledger().clone();
Fixture {
@@ -301,17 +325,19 @@ fn each_partys_totals_are_the_books_numbers() {
assert_eq!(sam.open_bills, Some(cents(30_000)));
assert_eq!(sam.bills, vec![1]);
// The 1099 amount is ledger-core's worksheet line, exactly, over the
// viewer's payment accounts (every asset account, no cash accounts
// being set; the financing account).
// viewer's payment accounts: the books' cash account and the
// financing account.
let owed = o.chart().find_by_code("2100").unwrap().id;
let r = ledger_core::vendor_1099_report_with(
let r = vendor_1099_report(
o,
2026,
None,
&ledger_core::PaymentAccounts {
cash: vec![],
cash: vec![o.chart().find_by_code("1000").unwrap().id],
financing: vec![owed],
},
&[],
None,
)
.unwrap();
let line = r
@@ -321,9 +347,18 @@ fn each_partys_totals_are_the_books_numbers() {
.unwrap();
assert_eq!(sam.form_1099.as_ref(), Some(line));
assert_eq!(sam.threshold_1099, Some(r.threshold));
// (Cash and what the owner paid for the company: the card isn't in
// it.)
assert_eq!(line.paid, cents(180_000));
// Cash counts (1,000 + the bill's 500); what the owner paid for the
// company through the financing account waits on how they paid
// (rules.md B-19); the card's charge is no payment out of cash.
assert_eq!(line.paid, cents(150_000));
assert_eq!(line.undetermined, cents(30_000));
// Party totals count all of it as paid to Sam (B-35 item 9): the
// 1099's buckets, and the card's 200 (no business card is recorded,
// so the worksheet lists it nowhere).
assert_eq!(
sam.paid_ytd,
line.paid + line.undetermined + line.excluded_card + cents(20_000)
);
// Every entry tagged, drafts included, newest first.
assert!(sam.entries.contains(&f.bill_entry));
assert!(sam.entries.contains(&f.reversal));
@@ -614,10 +649,13 @@ fn journal_text_for_a_creditor() {
)),
"{texts:#?}"
);
// Declared with the feature already in force (the fixture's 1099
// decisions'), sorted.
assert!(
texts
.iter()
.any(|(t, _)| t == "Journal format 2 declared, with party_creditors"),
.any(|(t, _)| t
== "Journal format 2 declared, with party_creditors, vendor_1099_decisions"),
"{texts:#?}"
);
let mut h = Harness::builder()
@@ -890,8 +928,12 @@ fn party_money_by_period_and_what_for() {
// The 1099 amount follows the period's calendar year.
let (line, _) = b.party_1099(f.sam, 2025).unwrap();
assert_eq!(line.paid, cents(50_000));
// 2026: the cash 1,000 and the bill's 500 paid count; the owner's
// 300 through the financing account waits on how it was paid
// (rules.md B-19); the card's 200 is no payment out of cash.
let (line, _) = b.party_1099(f.sam, 2026).unwrap();
assert_eq!(line.paid, cents(180_000));
assert_eq!(line.paid, cents(150_000));
assert_eq!(line.undetermined, cents(30_000));
}
#[test]
@@ -1064,7 +1106,7 @@ fn member_books(tag: &str) -> MemberBooks {
let dir = TempDir::new(tag);
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
let acct = |s: &mut Store, c: &str, n: &str, t| s.add_account(c, n, t, None, false).unwrap();
let bank = acct(&mut s, "1000", "Bluevine", AccountType::Asset);
let bank = acct(&mut s, "1000", "Quillmere", AccountType::Asset);
let equip = acct(&mut s, "1500", "Equipment", AccountType::Asset);
let due = acct(
&mut s,
@@ -1694,7 +1736,7 @@ fn owed_to_members_reads_the_financing_accounts_first() {
/// core does (every asset account but receivables: Books::
/// money_cash_accounts), so the laptop on 1500 reads as cash: Tobin's 2026
/// contributions are $600.00 cash, none property. The Cash page keeps its
/// name-based guess ("Bluevine" names no cash word: no cash accounts).
/// name-based guess ("Quillmere" names no cash word: no cash accounts).
#[test]
fn without_cash_accounts_the_capital_statement_reads_money_as_core_does() {
let m = member_books("capital-no-cash");
@@ -1797,3 +1839,222 @@ fn party_money_is_the_servers_without_cash_accounts_and_after_today() {
(d(2026, 1, 1), d(2026, 11, 20))
);
}
/// P3 brief 1.4: the viewer computed the 1099 four ways (every asset with
/// no financing on its 1099 page, its Needs attention and its company
/// page; cash and financing without members on a party). Now every 1099
/// figure it shows is the server's (`ledger_package::vendor_1099_report`
/// for the books' own settings), checked here against ledger-core's
/// report for the same accounts. By hand: Harbor Design LLC (1099-NEC, no
/// W-9) paid 2,600.00 from the bank and 2,500.00 by a member through Due
/// to Members (2100, financing) with no payment method recorded: counts
/// 2,600.00 (reportable), undetermined 2,500.00. Cedar Studio, never
/// decided, paid 300.00 from the bank.
#[test]
fn the_viewer_counts_the_1099_as_the_server_does() {
let dir = TempDir::new("viewer-1099");
let data = dir.0.join("books-data");
let mut s = Store::create(&data, Currency::USD).unwrap();
let cash = s
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let due = s
.add_account(
"2100",
"Due to Members",
AccountType::Liability,
None,
false,
)
.unwrap();
let labor = s
.add_account("6050", "Contract Labor", AccountType::Expense, None, false)
.unwrap();
let source = ledger_core::settings::Source {
decided_by: "test".into(),
decided_on: d(2026, 1, 1),
citation: None,
note: None,
};
s.change_settings(
&[
ledger_core::settings::Change {
key: "books.cash_accounts".into(),
value: Some(serde_json::json!(["1000"])),
},
ledger_core::settings::Change {
key: "books.financing_accounts".into(),
value: Some(serde_json::json!(["2100"])),
},
],
&source,
)
.unwrap();
let harbor = s.add_vendor("Harbor Design LLC").unwrap();
s.set_vendor_1099(
harbor,
&Dated1099::from_beginning(Decision1099::Files(Form1099::Nec), Some(source.clone())),
Some(false),
)
.unwrap();
let cedar = s.add_vendor("Cedar Studio").unwrap();
let pay = |s: &mut Store, date, vendor, cr, c| {
let e = s.create_entry(date, "work").unwrap();
s.add_line(e, labor, Side::Debit, usd(c)).unwrap();
s.add_line(e, cr, Side::Credit, usd(c)).unwrap();
s.post_entry(e).unwrap();
s.set_entry_vendor(e, Some(vendor)).unwrap();
e
};
pay(&mut s, d(2026, 3, 1), harbor, cash, 260_000);
let member_paid = pay(&mut s, d(2026, 4, 1), harbor, due, 250_000);
pay(&mut s, d(2026, 5, 1), cedar, cash, 30_000);
drop(s);
let b = load(&dir.0);
let oracle = Store::open(&data).unwrap().ledger().clone();
let want = vendor_1099_report(
&oracle,
2026,
None,
&ledger_core::PaymentAccounts {
cash: vec![cash],
financing: vec![due],
},
&[],
None,
)
.unwrap();
assert_eq!(b.vendor_1099(2026).unwrap(), want);
let h = want.vendors.iter().find(|v| v.vendor_id == harbor).unwrap();
assert_eq!((h.paid, h.undetermined), (cents(260_000), cents(250_000)));
assert!(h.reportable);
// Its party: the same line, in the party's info and its 1099 tile.
let party = b.ledger.party_of_vendor(harbor).unwrap();
assert_eq!(b.party(party).unwrap().form_1099.as_ref(), Some(h));
assert_eq!(b.party_1099(party, 2026), Some((h.clone(), want.threshold)));
// The worksheet page's rows are the same lines.
let w = b.vendor_1099_worksheet(2026).unwrap();
let rows: Vec<(u64, Decimal, Decimal)> = w
.lines
.iter()
.map(|l| (l.vendor_id, l.paid, l.undetermined))
.collect();
let lines: Vec<(u64, Decimal, Decimal)> = want
.vendors
.iter()
.map(|l| (l.vendor_id, l.paid, l.undetermined))
.collect();
assert_eq!(rows, lines);
// Needs attention: the W-9 (2,600.00 counts, over 2,000), Cedar's
// decision (paid by check, under the threshold: a Todo), and the
// member's payment with no method.
let items = attention::find(&b);
let item = |prefix: &str| items.iter().find(|i| i.title.starts_with(prefix));
let w9 = item("Harbor Design LLC needs a W-9").expect("the W-9 item");
assert!(w9.detail.contains("$2,600.00"), "{}", w9.detail);
assert!(w9.prompt.contains("books.set_w9"), "{}", w9.prompt);
let decide = item("Decide whether Cedar Studio gets a 1099").expect("Cedar's decision");
assert_eq!(decide.severity, ledger_gui::books::Severity::Todo);
assert!(decide.prompt.contains("books.set_vendor_1099"));
assert!(item("Decide whether Harbor").is_none(), "Harbor is decided");
let method = item("1 payment has no payment method").expect("the method item");
assert!(
method.detail.contains(&member_paid.to_string()),
"{}",
method.detail
);
for tool in [
"books.instruments",
"books.add_instrument",
"books.set_entry_instrument",
] {
assert!(method.prompt.contains(tool), "{tool}: {}", method.prompt);
}
}
/// The journal page says what each 1099 record did (P3 brief 5): a
/// decision with who made it and from when, the W-9 flag on its own, a
/// payment method added, an entry's method set and cleared; and an old
/// line with no form says "undetermined", never "no longer gets a 1099".
#[test]
fn the_journal_says_what_each_1099_record_did() {
let dir = TempDir::new("journal-1099");
let data = dir.0.join("books-data");
let mut s = Store::create(&data, Currency::USD).unwrap();
let cash = s
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let card = s
.add_account("2050", "Harbor Visa", AccountType::Liability, None, false)
.unwrap();
let harbor = s.add_vendor("Harbor Design LLC").unwrap();
let source = ledger_core::settings::Source {
decided_by: "Riley Chen".into(),
decided_on: d(2026, 10, 16),
citation: None,
note: None,
};
s.set_vendor_1099(
harbor,
&Dated1099::from_beginning(Decision1099::Files(Form1099::Nec), Some(source.clone())),
None,
)
.unwrap();
s.set_vendor_1099(
harbor,
&Dated1099 {
effective: Some(d(2026, 12, 30)),
decision: Decision1099::not_reportable(
ledger_core::ExemptReason::Corporation,
"elected to be taxed as a corporation",
)
.unwrap(),
source: Some(source),
},
None,
)
.unwrap();
s.set_vendor_w9_flag(harbor, true).unwrap();
let visa = s
.add_instrument(
ledger_core::InstrumentKind::BusinessCard,
"Harbor Visa",
None,
None,
card,
)
.unwrap();
let e = s.create_entry(d(2026, 3, 1), "work").unwrap();
s.add_line(e, card, Side::Credit, usd(10_000)).unwrap();
s.add_line(e, cash, Side::Debit, usd(10_000)).unwrap();
s.set_entry_instrument(e, Some(visa)).unwrap();
s.set_entry_instrument(e, None).unwrap();
drop(s);
let b = load(&dir.0);
let texts: Vec<String> = b
.journal
.iter()
.filter(|l| {
matches!(
l.line_type.as_str(),
"vendor.1099_set"
| "vendor.w9_flag_set"
| "instrument.added"
| "entry.instrument_set"
)
})
.filter_map(|l| describe(&b, l))
.map(|e| e.text)
.collect();
assert_eq!(
texts,
[
"1099 decision for Harbor Design LLC: 1099-NEC (by Riley Chen, Oct 16, 2026)".to_string(),
"1099 decision for Harbor Design LLC from Dec 30, 2026: Not reportable: a corporation (elected to be taxed as a corporation) (by Riley Chen, Oct 16, 2026)".to_string(),
"W-9 of Harbor Design LLC marked on file".to_string(),
"Added payment method Harbor Visa (a company card)".to_string(),
format!("Payment method of #{e}: Harbor Visa"),
format!("Cleared the payment method of #{e}"),
]
);
}
+5 -3
View File
@@ -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.
+4 -1
View File
@@ -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
View File
@@ -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),
+503
View File
@@ -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
View File
@@ -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.
+27 -6
View File
@@ -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);
+153 -33
View File
@@ -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(
+929
View File
@@ -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"));
}
+15 -3
View File
@@ -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",
+7 -7
View File
@@ -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
+17 -7
View File
@@ -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"}),
+3 -2
View File
@@ -41,6 +41,7 @@ pub use reports::{
pub use text::{csv_escape, money, parse_date, share_text};
pub use tins::{TinSource, tin_kind, tin_type_key, vault_tin};
pub use vendor_1099::{
Vendor1099Worksheet, Vendor1099WorksheetLine, form_1099_label, payment_accounts,
vendor_1099_csv, vendor_1099_csv_name, vendor_1099_report, vendor_1099_worksheet,
VENDOR_1099_CSV_HEADER, Vendor1099Worksheet, Vendor1099WorksheetLine, form_1099_label,
payment_accounts, vendor_1099_csv, vendor_1099_csv_name, vendor_1099_report,
vendor_1099_worksheet,
};
+31 -76
View File
@@ -15,18 +15,20 @@
//! XLSX, for the fiscal year containing as_of, through as_of; the
//! fixed asset register when an asset was bought by as_of; the
//! general ledger and journal (XLSX); the journal through as_of as
//! IIF; the 1099 worksheet for as_of's calendar year through as_of
//! (or why it can't be made); with members, the K-1 worksheet; a
//! README first.
//! IIF; the 1099 worksheet for as_of's calendar year through as_of,
//! books.vendor_1099's for the package's cash and financing accounts
//! (INVARIANT 7), or why it can't be made; with members, the K-1
//! worksheet; a README first.
//! 2. With members, k1-members.csv has one row per member: name, party,
//! share, capital account, TIN and type (or last four without a TIN
//! source), and the K-1 worksheet's capital figures (ledger_core::k1)
//! for the fiscal year to date; the members' total row with more than
//! one. The 1099 worksheet adds each payee's party, last four, TIN,
//! and type with a TIN source.
//! one. The 1099 worksheet is books.vendor_1099's CSV
//! (crate::vendor_1099_csv), each payee's TIN and type added with a
//! TIN source.
//! 3. A TIN the source can't give never fails the package: the row says
//! why, and so do the README and tin_gaps (members, and 1099 payees
//! that are reportable).
//! a 1099 is or may be due for).
//! 4. With include_receipts: every current file behind the year's
//! posted entries, filed on them or linked to them, once per entry,
//! each with a row in receipts/index.csv; and the company's
@@ -62,7 +64,7 @@ use crate::reports::{
};
use crate::text::{csv_escape, money, share_text};
use crate::tins::{TinSource, tin_type_key};
use crate::vendor_1099::vendor_1099_report;
use crate::vendor_1099::{vendor_1099_csv, vendor_1099_csv_name, vendor_1099_worksheet};
/// What the package is built for.
#[derive(Clone, Copy)]
@@ -95,8 +97,8 @@ pub struct Package {
pub files: Vec<(String, Vec<u8>)>,
/// The zip.
pub zip: Vec<u8>,
/// With a TIN source: each member or reportable 1099 payee whose TIN
/// the package lacks, and why (INVARIANT 3).
/// With a TIN source: each member, or 1099 payee a 1099 is or may be
/// due for, whose TIN the package lacks, and why (INVARIANT 3).
pub tin_gaps: Vec<String>,
/// The receipts in receipts/ (one per entry they are behind).
pub receipt_count: usize,
@@ -138,16 +140,8 @@ pub fn build_package<B: PackageBooks + ?Sized>(
let pdf = |st: &ledger_import::Statement| statement_pdf(st, &meta).map_err(|e| e.to_string());
let members = report_members(company);
// One list of each per package (INVARIANT 7).
let cash_codes = if req.cash_accounts.is_empty() {
settings.cash_accounts
} else {
req.cash_accounts
};
let financing_codes = if req.financing_accounts.is_empty() {
settings.financing_accounts
} else {
req.financing_accounts
};
let payments = settings.with_accounts(req.cash_accounts, req.financing_accounts);
let (cash_codes, financing_codes) = (payments.cash_accounts, payments.financing_accounts);
// The financial statements, each as PDF and XLSX.
let mut statements: Vec<(&str, ledger_import::Statement)> = vec![
@@ -373,68 +367,29 @@ pub fn build_package<B: PackageBooks + ?Sized>(
));
// 1099 worksheet for the calendar year of as_of, through as_of
// (INVARIANT 6): the payments from January 1, out of the package's
// cash and financing accounts (INVARIANT 7).
// cash and financing accounts (INVARIANT 7), the same worksheet and
// CSV books.vendor_1099 and the viewer hand over for those accounts
// and that day (one builder, one figure).
let year = chrono::Datelike::year(&as_of);
let mut worksheet_1099 = false;
let payments = Settings {
cash_accounts: cash_codes,
financing_accounts: financing_codes,
..*settings
};
match vendor_1099_report(ledger, &payments, year, Some(as_of), None) {
Ok(r1099) => {
let mut w = String::from("vendor,form,w9_on_file,paid,reportable");
if tins.is_some() {
w.push_str(",party_id,tin_last4,tin,tin_type,tin_note");
}
w.push('\n');
for v in &r1099.vendors {
w.push_str(&format!(
"\"{}\",{},{},{},{}",
v.name.replace('"', "\"\""),
v.form.map(|f| f.label()).unwrap_or(""),
v.tin_on_file,
money(v.paid),
v.reportable
));
// The payee's full TIN from the source (INVARIANT 2).
if let Some(tins) = tins {
let party = ledger.party_of_vendor(v.vendor_id);
let last4 = party
.and_then(|id| ledger.get_party(id))
.and_then(|p| p.tin_last4())
.map(str::to_string);
let got = match party {
None => Err("TIN not in the vault: no party for this vendor".into()),
Some(id) => tins(id),
};
let (tin, kind, note) = match got {
Ok(t) => (
t.formatted(),
tin_type_key(t.kind).to_string(),
String::new(),
),
Err(why) => {
if v.reportable {
tin_gaps.push(format!("1099 payee {}: {why}", v.name));
}
(String::new(), String::new(), why)
}
};
w.push_str(&format!(
",{},{},{tin},{kind},{}",
party.map(|id| id.to_string()).unwrap_or_default(),
last4.unwrap_or_default(),
csv_escape(&note)
));
match vendor_1099_worksheet(ledger, &payments, year, None, Some(as_of), tins) {
Ok(w) => {
// A payee a 1099 is or may be due for, whose TIN the package
// lacks (INVARIANT 3).
for l in w.lines.iter().filter(|l| l.may_file()) {
if let Some(why) = &l.tin_note {
tin_gaps.push(format!("1099 payee {}: {why}", l.name));
}
w.push('\n');
}
files.push((format!("1099-worksheet-{year}.csv"), w.into_bytes()));
files.push((
vendor_1099_csv_name(year, false),
vendor_1099_csv(&w, tins.is_some()).into_bytes(),
));
worksheet_1099 = true;
}
// The threshold isn't known for this year: never guess it
// (rules.md B-19); say so in the package instead.
// The worksheet can't be made (the year's threshold isn't known,
// or the package names no cash accounts): never guess (rules.md
// B-19); say so in the package instead.
Err(e) => files.push((
format!("1099-worksheet-{year}-NOT-INCLUDED.txt"),
format!("{e}\n").into_bytes(),
@@ -611,7 +566,7 @@ pub fn build_package<B: PackageBooks + ?Sized>(
},
worksheet = if worksheet_1099 {
format!(
"1099-worksheet-{year}.csv: cash paid per vendor marked for a 1099{}, against the year's threshold{}.\n",
"1099-worksheet-{year}.csv: each vendor paid in {year}{} or decided for a 1099: what counts toward its 1099 against the year's threshold, what was paid by card or payment network (reported by the processor on Form 1099-K, Treas. Reg. §1.6041-1(a)(1)(iv)), what can't be classified until its payment method is recorded, and its 1099 decision with the reason, who made it, and when it took effect{}.\n",
if year_end {
String::new()
} else {
+23
View File
@@ -48,6 +48,29 @@ pub struct Settings<'a> {
pub investing_accounts: &'a [String],
}
impl<'a> Settings<'a> {
/// These settings with the cash and financing accounts a request
/// names: each list the request's when it gives one, else these
/// settings'. The one rule the CPA package (one list of each per
/// package, for every file in it: crate::package INVARIANT 7) and
/// `books.vendor_1099` read, so the package's 1099 worksheet is the
/// tool's for the same accounts.
pub fn with_accounts(
&self,
cash_accounts: &'a [String],
financing_accounts: &'a [String],
) -> Settings<'a> {
let or = |given: &'a [String], kept: &'a [String]| {
if given.is_empty() { kept } else { given }
};
Settings {
cash_accounts: or(cash_accounts, self.cash_accounts),
financing_accounts: or(financing_accounts, self.financing_accounts),
..*self
}
}
}
/// Resolve a statement's date range (INVARIANT 1): the dates as given,
/// each omitted one the books' default for `company` on `today`
/// (rules.md B-30).
+417 -62
View File
@@ -1,26 +1,36 @@
//! The 1099 worksheet: what `books.vendor_1099` answers and hands over as
//! CSV, and what the viewer saves, with each payee's TIN when asked.
//! CSV, what the CPA package carries, and what the viewer saves and shows,
//! with each payee's TIN when asked.
//!
//! ```text
//! GOAL: the CPA gets one 1099 worksheet, the same from the server and
//! the viewer: cash paid per vendor, who is reportable, who lacks a
//! W-9, and each payee's TIN.
//! SOURCE: ledger_core::form1099 (payments out of the cash and financing
//! accounts; an entry's shares resolved with the members); IRS
//! GOAL: the CPA gets one 1099 worksheet, the same from the server, the
//! package and the viewer: per vendor what counts toward its 1099,
//! what a card or payment network paid, what is unknown, its 1099
//! decision with the reason and who made it, who lacks a W-9, and
//! each payee's TIN.
//! SOURCE: ledger_core::form1099 (the one report: payments out of the cash
//! and financing accounts, classified by payment method; an entry's
//! shares resolved with the members; dated decisions); IRS
//! thresholds (rules.md B-19: never guessed); RFC 4180 (CSV).
//! INVARIANTS:
//! 1. Payments come from the settings' cash and financing accounts
//! (codes the chart lacks are skipped), an entry's shares resolved
//! with the company's members.
//! 1. Payments come from the cash and financing accounts of the
//! `Settings` given (codes the chart lacks are skipped): the books'
//! settings, or the CPA package's and `books.vendor_1099`'s request
//! accounts (`Settings::with_accounts`), an entry's shares resolved
//! with the company's members; with no cash accounts named,
//! refused.
//! 2. A year whose threshold isn't known is refused, never guessed.
//! 3. Full TINs only from the TIN source given (crate INVARIANT 2): with
//! one, every line carries its TIN or a note why not; without one,
//! none does.
//! 4. The CSV has a header and one row per line, in the worksheet's
//! order; the TIN columns only when asked.
//! 5. The notes are quiet: the untagged entries are one count and a
//! tool hint, each list of payments one line.
//! ```
use ledger_core::{EntryId, Ledger, PartyId, VendorId};
use chrono::NaiveDate;
use ledger_core::{Decision1099, EntryId, Ledger, PartyId, Payment1099, Status1099, VendorId};
use rust_decimal::Decimal;
use crate::reports::Settings;
@@ -43,29 +53,29 @@ pub fn payment_accounts(ledger: &Ledger, settings: &Settings<'_>) -> ledger_core
}
}
/// The 1099 report for `year`, the one every tool, the worksheet, and the
/// CPA package use: payments out of [`payment_accounts`], an entry's
/// shares resolved with the settings' members. Given `through`, only the
/// payments from January 1 through it (the CPA package as of a day,
/// rules.md B-33).
/// The 1099 report for `year`, the one every tool, the worksheet, the
/// CPA package and the viewer use: payments out of [`payment_accounts`],
/// an entry's shares resolved with the settings' members. Given
/// `through`, only the payments from January 1 through it (the CPA
/// package as of a day, rules.md B-33).
pub fn vendor_1099_report(
ledger: &Ledger,
settings: &Settings<'_>,
year: i32,
through: Option<chrono::NaiveDate>,
threshold: Option<Decimal>,
through: Option<NaiveDate>,
) -> Result<ledger_core::Vendor1099Report, ledger_core::LedgerError> {
ledger_core::vendor_1099_report_with_members(
ledger_core::vendor_1099_report(
ledger,
year,
through,
threshold,
&payment_accounts(ledger, settings),
&settings.company.party_members(ledger),
through,
)
}
/// A 1099 form's label ("1099-NEC", "1099-MISC"), if the vendor is marked.
/// A 1099 form's label ("1099-NEC", "1099-MISC"), if the vendor files one.
pub fn form_1099_label(form: Option<ledger_core::Form1099>) -> Option<String> {
form.map(|f| f.label().to_string())
}
@@ -77,18 +87,41 @@ pub struct Vendor1099WorksheetLine {
pub vendor_id: VendorId,
/// Its name.
pub name: String,
/// "1099-NEC", "1099-MISC", or none.
/// "1099-NEC", "1099-MISC", or none: the form its year-end decision
/// files.
pub form_1099: Option<String>,
/// Its decision at the end of the year: "nec", "misc",
/// "not_reportable" or "undetermined".
pub decision_1099: String,
/// The decision itself, with its reason when not reportable.
pub decision: Decision1099,
/// Who decided it and when (none: never decided, or decided before
/// decisions were kept).
pub decided: Option<ledger_core::settings::Source>,
/// The date it took effect (none: from the beginning).
pub effective: Option<NaiveDate>,
/// Every decision in force during the year, in date order.
pub decisions: Vec<ledger_core::Dated1099>,
/// W-9 on file.
pub tin_on_file: bool,
/// Cash paid in the year.
/// What counts toward its 1099.
pub paid: Decimal,
/// Marked for a 1099 and paid at least the threshold.
/// Paid by card or payment network: reported on Form 1099-K.
pub excluded_card: Decimal,
/// Paid with no payment method recorded where it matters.
pub undetermined: Decimal,
/// Paid while it was decided not reportable.
pub not_reportable: Decimal,
/// A decision is needed for some of the year's payments.
pub decision_needed: bool,
/// Where it stands.
pub status: Status1099,
/// File a 1099 (status reportable).
pub reportable: bool,
/// The vendor's party.
pub party_id: Option<PartyId>,
/// The TIN's last four, from the party's W-9 facts, else its TIN
/// facts.
/// The TIN's last four ([`ledger_core::Party::tin_last4`]: its TIN
/// facts, else its W-9 facts).
pub tin_last4: Option<String>,
/// The full TIN, formatted (123-45-6789, 12-3456789): only with a TIN
/// source, and only when it has one.
@@ -99,6 +132,17 @@ pub struct Vendor1099WorksheetLine {
pub tin_note: Option<String>,
}
impl Vendor1099WorksheetLine {
/// Whether a 1099 may be due: reportable, or undetermined while decided
/// for one some time in the year (P3 review R16: its W-9 and TIN
/// matter as much).
pub fn may_file(&self) -> bool {
self.reportable
|| (self.status == Status1099::Undetermined
&& self.decisions.iter().any(|d| d.decision.form().is_some()))
}
}
/// The 1099 worksheet for a calendar year.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Vendor1099Worksheet {
@@ -106,58 +150,145 @@ pub struct Vendor1099Worksheet {
pub year: i32,
/// The threshold applied.
pub threshold: Decimal,
/// One line per vendor marked for a 1099 or paid in the year, by name.
/// The last day counted, when it stops before December 31.
pub through: Option<NaiveDate>,
/// One line per vendor decided for a 1099 or paid in the year, by name.
pub lines: Vec<Vendor1099WorksheetLine>,
/// Reportable vendors with no W-9 on file.
/// Vendors a 1099 is or may be due for with no W-9 on file (" (may be
/// reportable)" after those undetermined).
pub missing_w9: Vec<String>,
/// Entries naming several parties whose shares don't resolve (each
/// counted in full toward its vendor).
pub unresolved: Vec<EntryId>,
/// Payments by card or payment network, excluded.
pub excluded_card_payments: Vec<Payment1099>,
/// Payments whose method must be recorded before they can be
/// classified.
pub undetermined_payments: Vec<Payment1099>,
/// Money received from a vendor for something else, not netted.
pub other_receipts: Vec<Payment1099>,
/// Posted entries that paid money out with no vendor tag.
pub untagged_payments: Vec<EntryId>,
/// What to check: what counts, exemptions, the threshold's indexing,
/// untagged entries, unresolved shares.
/// untagged entries, card payments, unknown methods, decisions needed,
/// unresolved shares.
pub notes: Vec<String>,
/// Whether the lines carry TINs from a TIN source.
pub tins_included: bool,
}
/// The 1099 worksheet for `year` (threshold: the IRS figure to use when
/// the year's isn't known, else none), each line with its full TIN from
/// `tins` when given (INVARIANT 3).
pub fn vendor_1099_worksheet(
ledger: &Ledger,
settings: &Settings<'_>,
year: i32,
threshold: Option<Decimal>,
tins: Option<TinSource<'_>>,
) -> Result<Vendor1099Worksheet, String> {
// A year past what wanderbooks knows is refused with guidance
// (research or ask, pass `threshold`, then update the rule).
let r =
vendor_1099_report(ledger, settings, year, None, threshold).map_err(|e| e.to_string())?;
let missing_w9 = r
.vendors
/// "entry 4" or "entries 4, 9".
fn entries_text(ids: &[EntryId]) -> String {
let list = ids
.iter()
.filter(|v| v.reportable && !v.tin_on_file)
.map(|v| v.name.clone())
.collect();
let untagged = ledger
.posted_entries()
.filter(|e| {
chrono::Datelike::year(&e.date()) == year && ledger.entry_vendor(e.id()).is_none()
})
.count();
.map(|id| id.to_string())
.collect::<Vec<_>>()
.join(", ");
if ids.len() == 1 {
format!("entry {list}")
} else {
format!("entries {list}")
}
}
/// "1 payment" or "3 payments", with their total.
fn payments_text(list: &[Payment1099]) -> String {
let total: Decimal = list.iter().map(|p| p.amount).sum();
let n = list.len();
format!(
"{n} payment{} totaling {}",
if n == 1 { "" } else { "s" },
money(total)
)
}
/// The worksheet's notes (INVARIANT 5).
fn notes_for(ledger: &Ledger, r: &ledger_core::Vendor1099Report) -> Vec<String> {
let year = r.year;
let name = |id: VendorId| {
ledger
.get_vendor(id)
.map_or_else(|| format!("vendor {id}"), |v| v.name.clone())
};
let mut notes = vec![
"Only entries tagged with a vendor count (books.set_entry_vendor; books.pay_bill tags automatically).".to_string(),
"Corporations are generally exempt, except attorneys' fees and medical payments; royalties report at $10. Confirm with the CPA.".to_string(),
"Each vendor's 1099 decision says why when none is due (a corporation, the United States or a state: Treas. Reg. §1.6041-3); payments to a corporation for legal or medical services stay reportable, and royalties report at $10. Confirm with the CPA.".to_string(),
];
if let Some(t) = r.through {
notes.push(format!(
"Payments from {year}-01-01 through {t} only: later payments in {year} aren't counted."
));
}
if year > 2026 {
notes.push(format!(
"The $2,000 threshold is indexed for inflation after 2026; confirm the {year} amount and pass threshold to override."
));
}
if untagged > 0 {
if !r.excluded_card_payments.is_empty() {
notes.push(format!(
"{untagged} posted entries in {year} have no vendor tag; review any that paid contractors or rent."
"{} by card or payment network are excluded: the processor reports them on Form 1099-K, whoever's card it was (Treas. Reg. §1.6041-1(a)(1)(iv)).",
payments_text(&r.excluded_card_payments)
));
}
if !r.undetermined_payments.is_empty() {
let ids: Vec<EntryId> = r.undetermined_payments.iter().map(|p| p.entry_id).collect();
notes.push(format!(
"{} through a financing account (or a member's or a payment platform's) have no payment method recorded ({}), so whether they count is undetermined: a card or the platform's goods-and-services payment is excluded, cash, a check or a friends-and-family transfer counts. Record how each was paid with books.set_entry_instrument (books.instruments lists the methods).",
payments_text(&r.undetermined_payments),
entries_text(&ids)
));
}
let undecided: Vec<&str> = r
.vendors
.iter()
.filter(|v| v.decision_needed)
.map(|v| v.name.as_str())
.collect();
if !undecided.is_empty() {
notes.push(format!(
"No 1099 decision for payments that could count: {}. Record one with books.set_vendor_1099.",
undecided.join(", ")
));
}
for v in &r.vendors {
let mut forms: Vec<&'static str> = v
.decisions
.iter()
.filter_map(|d| d.decision.form().map(|f| f.label()))
.collect();
forms.dedup();
if forms.len() > 1 {
notes.push(format!(
"{}'s 1099 form changed during {year} ({}): each payment goes on the form for what it paid for. Confirm with the CPA.",
v.name,
forms.join(", then ")
));
}
}
for p in &r.other_receipts {
// Money in is negative; its reversal gives it back.
let what = if p.amount < Decimal::ZERO {
format!("received {} from", money(-p.amount))
} else {
format!("reversed {} received from", money(p.amount))
};
notes.push(format!(
"Entry {} {what} {} for something else (a sale, its payment of an invoice, a disposal, a loan): not netted against its payments.",
p.entry_id,
name(p.vendor_id)
));
}
let untagged = &r.untagged_payments;
if !untagged.is_empty() {
let n = untagged.len();
let which = if n <= 10 {
format!(" ({})", entries_text(untagged))
} else {
String::new()
};
notes.push(format!(
"{n} posted entr{} in {year} paid money out with no vendor tag{which}; tag any that paid a contractor or landlord with books.set_entry_vendor.",
if n == 1 { "y" } else { "ies" }
));
}
if !r.unresolved.is_empty() {
@@ -170,8 +301,28 @@ pub fn vendor_1099_worksheet(
.join(", ")
));
}
notes
}
/// The 1099 worksheet for `year` (threshold: the IRS figure to use when
/// the year's isn't known, else none), entries dated after `through`
/// left out, each line with its full TIN from `tins` when given
/// (INVARIANT 3).
pub fn vendor_1099_worksheet(
ledger: &Ledger,
settings: &Settings<'_>,
year: i32,
threshold: Option<Decimal>,
through: Option<NaiveDate>,
tins: Option<TinSource<'_>>,
) -> Result<Vendor1099Worksheet, String> {
// A year past what wanderbooks knows is refused with guidance
// (research or ask, pass `threshold`, then update the rule).
let r = vendor_1099_report(ledger, settings, year, threshold, through)
.map_err(|e| e.to_string())?;
let notes = notes_for(ledger, &r);
let mut lines = Vec::new();
for v in r.vendors {
for v in &r.vendors {
let party_id = ledger.party_of_vendor(v.vendor_id);
let tin_last4 = party_id
.and_then(|id| ledger.get_party(id))
@@ -179,10 +330,20 @@ pub fn vendor_1099_worksheet(
.map(str::to_string);
let mut line = Vendor1099WorksheetLine {
vendor_id: v.vendor_id,
name: v.name,
form_1099: form_1099_label(v.form),
name: v.name.clone(),
form_1099: form_1099_label(v.form()),
decision_1099: v.decision.key().to_string(),
decision: v.decision.clone(),
decided: v.decided.clone(),
effective: v.effective,
decisions: v.decisions.clone(),
tin_on_file: v.tin_on_file,
paid: v.paid,
excluded_card: v.excluded_card,
undetermined: v.undetermined,
not_reportable: v.not_reportable,
decision_needed: v.decision_needed,
status: v.status,
reportable: v.reportable,
party_id,
tin_last4,
@@ -202,32 +363,94 @@ pub fn vendor_1099_worksheet(
}
lines.push(line);
}
let missing_w9 = lines
.iter()
.filter(|l| l.may_file() && !l.tin_on_file)
.map(|l| {
if l.reportable {
l.name.clone()
} else {
format!("{} (may be reportable)", l.name)
}
})
.collect();
Ok(Vendor1099Worksheet {
year: r.year,
threshold: r.threshold,
through: r.through,
lines,
missing_w9,
unresolved: r.unresolved,
excluded_card_payments: r.excluded_card_payments,
undetermined_payments: r.undetermined_payments,
other_receipts: r.other_receipts,
untagged_payments: r.untagged_payments,
notes,
tins_included: tins.is_some(),
})
}
/// The worksheet as CSV (INVARIANT 4): vendor id, name, form, paid,
/// reportable, W-9 on file, and last four; `with_tins` adds the TIN, its
/// type, and the note why it's missing.
/// A dated decision for the CSV: its key, with its kind when not
/// reportable, and its date when it has one ("nec",
/// "not_reportable:corporation from 2026-12-30").
fn decision_cell(d: &ledger_core::Dated1099) -> String {
let mut s = d.decision.key().to_string();
if let Decision1099::NotReportable { kind, .. } = &d.decision {
s.push(':');
s.push_str(kind.key());
}
if let Some(e) = d.effective {
s.push_str(&format!(" from {e}"));
}
s
}
/// The worksheet's CSV header (INVARIANT 4).
pub const VENDOR_1099_CSV_HEADER: &str = "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";
/// The worksheet as CSV (INVARIANT 4): per vendor its id and name, its
/// year-end 1099 decision (key, form, reason kind and words, who decided,
/// when, the date it took effect) and every decision in force during the
/// year, what counts, what was paid by card or network, what is
/// undetermined, what was paid while not reportable, its status, whether
/// it is reportable, its W-9 flag and last four; `with_tins` adds the TIN,
/// its type, and the note why it's missing.
pub fn vendor_1099_csv(worksheet: &Vendor1099Worksheet, with_tins: bool) -> String {
let mut csv = String::from("vendor_id,name,form_1099,paid,reportable,w9_on_file,tin_last4");
let mut csv = String::from(VENDOR_1099_CSV_HEADER);
if with_tins {
csv.push_str(",tin,tin_type,tin_note");
}
csv.push('\n');
for l in &worksheet.lines {
let (kind, reason) = match &l.decision {
Decision1099::NotReportable { kind, reason } => (kind.key(), reason.as_str()),
_ => ("", ""),
};
let mut cells = vec![
l.vendor_id.to_string(),
csv_escape(&l.name),
l.decision_1099.clone(),
l.form_1099.clone().unwrap_or_default(),
kind.to_string(),
csv_escape(reason),
csv_escape(l.decided.as_ref().map_or("", |s| s.decided_by.as_str())),
l.decided
.as_ref()
.map(|s| s.decided_on.to_string())
.unwrap_or_default(),
l.effective.map(|e| e.to_string()).unwrap_or_default(),
csv_escape(
&l.decisions
.iter()
.map(decision_cell)
.collect::<Vec<_>>()
.join("; "),
),
money(l.paid),
money(l.excluded_card),
money(l.undetermined),
money(l.not_reportable),
l.status.key().to_string(),
l.reportable.to_string(),
l.tin_on_file.to_string(),
l.tin_last4.clone().unwrap_or_default(),
@@ -252,3 +475,135 @@ pub fn vendor_1099_csv_name(year: i32, with_tins: bool) -> String {
if with_tins { "-with-tins" } else { "" }
)
}
#[cfg(test)]
mod tests {
use super::*;
use ledger_core::{AccountType, Currency, Money, Side};
fn dec(s: &str) -> Decimal {
s.parse().unwrap()
}
fn date(s: &str) -> NaiveDate {
s.parse().unwrap()
}
/// The untagged note (P3 review R7), by hand. Bank 1000 (cash), Due to
/// Members 2100 (financing), card 2050 (a business card's), capital
/// 3000, labor 6000, prepaid 1300, equipment 1500. Untagged, in 2026:
/// 1. a contribution, Dr 1000 / Cr 3000 500: money in, not counted;
/// 2. a member reimbursed, Dr 2100 / Cr 1000 80: nets to zero;
/// 3. a property contribution, Dr 1500 / Cr 3000: no payment line;
/// 4. the card bill, Dr 2050 / Cr 1000 300: nets to zero;
/// 5. a distribution, Dr 3000 / Cr 1000 200: paid out, but for no
/// expense or asset: not counted;
/// 6. a fee a member paid, Dr 6000 / Cr 2100 25: counted;
/// 7. a prepayment, Dr 1300 / Cr 1000 120: counted;
/// 8. a payment, Dr 6000 / Cr 1000 40, and 9 its reversal: both in
/// the year, so neither counts.
///
/// Two entries: 6 and 7.
#[test]
fn the_untagged_note_counts_only_money_out() {
let mut l = Ledger::new(Currency::USD);
let mut acct = |code: &str, t| l.add_account(code, code, t, None, false).unwrap();
let bank = acct("1000", AccountType::Asset);
let prepaid = acct("1300", AccountType::Asset);
let equipment = acct("1500", AccountType::Asset);
let card = acct("2050", AccountType::Liability);
let due = acct("2100", AccountType::Liability);
let capital = acct("3000", AccountType::Equity);
let labor = acct("6000", AccountType::Expense);
let post = |l: &mut Ledger, dr, cr, amt: &str| {
let e = l.create_entry(date("2026-05-01"), "x");
let m = Money::new(dec(amt), Currency::USD);
l.add_line(e, dr, Side::Debit, m).unwrap();
l.add_line(e, cr, Side::Credit, m).unwrap();
l.post_entry(e).unwrap();
e
};
post(&mut l, bank, capital, "500");
post(&mut l, due, bank, "80");
post(&mut l, equipment, capital, "900");
post(&mut l, card, bank, "300");
post(&mut l, capital, bank, "200");
let fee = post(&mut l, labor, due, "25");
let pre = post(&mut l, prepaid, bank, "120");
let wrong = post(&mut l, labor, bank, "40");
l.reverse_entry(wrong, date("2026-05-02"), "oops").unwrap();
l.add_instrument(
ledger_core::InstrumentKind::BusinessCard,
"Card",
None,
None,
card,
)
.unwrap();
let r = ledger_core::vendor_1099_report(
&l,
2026,
None,
&ledger_core::PaymentAccounts {
cash: vec![bank],
financing: vec![due],
},
&[],
None,
)
.unwrap();
assert_eq!(r.untagged_payments, vec![fee, pre]);
let notes = notes_for(&l, &r);
assert!(
notes.contains(&format!(
"2 posted entries in 2026 paid money out with no vendor tag (entries {fee}, {pre}); tag any that paid a contractor or landlord with books.set_entry_vendor."
)),
"{notes:?}"
);
}
/// The note on money received from a vendor for something else says
/// what each entry did: the sale received the money, its reversal
/// gave it back (never "received -500.00").
#[test]
fn the_other_receipts_note_says_what_each_entry_did() {
let mut l = Ledger::new(Currency::USD);
let bank = l
.add_account("1000", "Bank", AccountType::Asset, None, false)
.unwrap();
let sales = l
.add_account("4000", "Sales", AccountType::Revenue, None, false)
.unwrap();
let dana = l.add_vendor("Dana").unwrap();
let e = l.create_entry(date("2026-04-01"), "laptop sold to Dana");
let m = Money::new(dec("500.00"), Currency::USD);
l.add_line(e, bank, Side::Debit, m).unwrap();
l.add_line(e, sales, Side::Credit, m).unwrap();
l.post_entry(e).unwrap();
l.set_entry_vendor(e, Some(dana)).unwrap();
let undo = l.reverse_entry(e, date("2026-04-02"), "not Dana").unwrap();
let r = ledger_core::vendor_1099_report(
&l,
2026,
None,
&ledger_core::PaymentAccounts {
cash: vec![bank],
financing: vec![],
},
&[],
None,
)
.unwrap();
let notes = notes_for(&l, &r);
for want in [
format!(
"Entry {e} received 500.00 from Dana for something else (a sale, its payment of an invoice, a disposal, a loan): not netted against its payments."
),
format!(
"Entry {undo} reversed 500.00 received from Dana for something else (a sale, its payment of an invoice, a disposal, a loan): not netted against its payments."
),
] {
assert!(notes.contains(&want), "{want} not in {notes:?}");
}
}
}
+181 -44
View File
@@ -83,8 +83,7 @@ fn books(dir: &Path) -> Books {
.unwrap();
let vendor = s.add_vendor("Acme, \"Design\"").unwrap();
let acme = s.ledger().party_of_vendor(vendor).unwrap();
s.set_vendor_1099(vendor, Some(Form1099::Nec), true)
.unwrap();
s.set_vendor_1099(vendor, &nec(), Some(true)).unwrap();
s.set_party_tin(
acme,
Some(TinFacts {
@@ -205,6 +204,26 @@ fn company(members: bool) -> Company {
}
}
/// A 1099-NEC decision from the beginning, decided by a member.
fn nec() -> ledger_core::Dated1099 {
ledger_core::Dated1099::from_beginning(
ledger_core::Decision1099::Files(Form1099::Nec),
Some(ledger_core::settings::Source {
decided_by: "Tobin Example".into(),
decided_on: d("2026-01-02"),
citation: None,
note: None,
}),
)
}
/// The worksheet CSV's row for Acme in `books`, without TINs.
fn acme_row(vendor: u64) -> String {
format!(
"{vendor},\"Acme, \"\"Design\"\"\",nec,1099-NEC,,,Tobin Example,2026-01-02,,nec,2500.00,0.00,0.00,0.00,reportable,true,true,9105"
)
}
fn cash() -> Vec<String> {
vec!["1000".to_string()]
}
@@ -353,9 +372,28 @@ fn a_single_member_package_holds_the_documented_files_in_order() {
sale = b.sale,
)
);
// The package's 1099 worksheet is books.vendor_1099's CSV, byte for
// byte (one builder: P3 brief 5).
let w = vendor_1099_worksheet(
b.store.ledger(),
&settings(&company, &cash),
2026,
None,
None,
None,
)
.unwrap();
assert_eq!(
text(&p, "1099-worksheet-2026.csv"),
"vendor,form,w9_on_file,paid,reportable\n\"Acme, \"\"Design\"\"\",1099-NEC,true,2500.00,true\n"
vendor_1099_csv(&w, false)
);
assert_eq!(
text(&p, "1099-worksheet-2026.csv"),
format!(
"{}\n{}\n",
ledger_package::VENDOR_1099_CSV_HEADER,
acme_row(b.vendor)
)
);
let readme = text(&p, "README.txt");
assert!(readme.starts_with("Example Labs LLC — books package for the CPA\n\nFiscal year to date: 2026-01-01 through 2026-12-31. Cash basis.\n"), "{readme}");
@@ -364,6 +402,12 @@ fn a_single_member_package_holds_the_documented_files_in_order() {
"{readme}"
);
assert!(readme.contains("receipts/: the 2 receipts behind the period's entries"));
// Defect 12's wording: what the worksheet holds, every vendor paid in
// the year (decided for a 1099 or not), not "marked for a 1099".
assert!(
readme.contains("1099-worksheet-2026.csv: each vendor paid in 2026 or decided for a 1099: what counts toward its 1099 against the year's threshold, what was paid by card or payment network (reported by the processor on Form 1099-K, Treas. Reg. §1.6041-1(a)(1)(iv)), what can't be classified until its payment method is recorded, and its 1099 decision with the reason, who made it, and when it took effect.\n"),
"{readme}"
);
assert!(readme.contains("cash flow, tax summary, trial balance."));
assert!(!readme.contains("k1-members.csv") && !readme.contains("full taxpayer"));
assert!(!names(&p).contains(&"k1-members.csv"));
@@ -487,9 +531,9 @@ fn members_get_the_k1_worksheet_with_full_tins_only_from_a_source() {
assert_eq!(
text(&with, "1099-worksheet-2026.csv"),
format!(
"vendor,form,w9_on_file,paid,reportable,party_id,tin_last4,tin,tin_type,tin_note\n\
\"Acme, \"\"Design\"\"\",1099-NEC,true,2500.00,true,{},9105,{EIN},ein,\n",
b.acme
"{},tin,tin_type,tin_note\n{},{EIN},ein,\n",
ledger_package::VENDOR_1099_CSV_HEADER,
acme_row(b.vendor)
)
);
assert!(!text(&without, "1099-worksheet-2026.csv").contains(EIN));
@@ -585,7 +629,7 @@ fn the_1099_worksheet_carries_tins_only_from_a_source() {
let cash = cash();
let s = settings(&company, &cash);
let ledger = b.store.ledger();
let plain = vendor_1099_worksheet(ledger, &s, 2026, None, None).unwrap();
let plain = vendor_1099_worksheet(ledger, &s, 2026, None, None, None).unwrap();
assert_eq!(plain.year, 2026);
assert_eq!(plain.threshold, Decimal::from(2000));
assert!(!plain.tins_included);
@@ -598,44 +642,49 @@ fn the_1099_worksheet_carries_tins_only_from_a_source() {
assert_eq!(l.tin_last4.as_deref(), Some("9105"));
assert_eq!((&l.tin, &l.tin_type, &l.tin_note), (&None, &None, &None));
assert!(plain.missing_w9.is_empty());
// Five posted entries in 2026, one tagged.
// Of 2026's untagged entries only the rent paid money out for
// something: the contributions and the sale brought money in (P3
// review R7, the quieter note).
assert_eq!(plain.untagged_payments, vec![b.rent]);
assert_eq!(
plain.notes,
[
"Only entries tagged with a vendor count (books.set_entry_vendor; books.pay_bill tags automatically).",
"Corporations are generally exempt, except attorneys' fees and medical payments; royalties report at $10. Confirm with the CPA.",
"3 posted entries in 2026 have no vendor tag; review any that paid contractors or rent.",
"Only entries tagged with a vendor count (books.set_entry_vendor; books.pay_bill tags automatically).".to_string(),
"Each vendor's 1099 decision says why when none is due (a corporation, the United States or a state: Treas. Reg. §1.6041-3); payments to a corporation for legal or medical services stay reportable, and royalties report at $10. Confirm with the CPA.".to_string(),
format!("1 posted entry in 2026 paid money out with no vendor tag (entry {}); tag any that paid a contractor or landlord with books.set_entry_vendor.", b.rent),
]
);
assert_eq!(
vendor_1099_csv(&plain, false),
format!(
"vendor_id,name,form_1099,paid,reportable,w9_on_file,tin_last4\n{},\"Acme, \"\"Design\"\"\",1099-NEC,2500.00,true,true,9105\n",
b.vendor
"{}\n{}\n",
ledger_package::VENDOR_1099_CSV_HEADER,
acme_row(b.vendor)
)
);
let source = tin_source(&b);
let full = vendor_1099_worksheet(ledger, &s, 2026, None, Some(&source)).unwrap();
let full = vendor_1099_worksheet(ledger, &s, 2026, None, None, Some(&source)).unwrap();
assert!(full.tins_included);
assert_eq!(full.lines[0].tin.as_deref(), Some(EIN));
assert_eq!(full.lines[0].tin_type.as_deref(), Some("ein"));
assert_eq!(
vendor_1099_csv(&full, true),
format!(
"vendor_id,name,form_1099,paid,reportable,w9_on_file,tin_last4,tin,tin_type,tin_note\n{},\"Acme, \"\"Design\"\"\",1099-NEC,2500.00,true,true,9105,{EIN},ein,\n",
b.vendor
"{},tin,tin_type,tin_note\n{},{EIN},ein,\n",
ledger_package::VENDOR_1099_CSV_HEADER,
acme_row(b.vendor)
)
);
// A source without the payee's TIN: the note says why.
let none = |_: PartyId| -> Result<ledger_vault::Tin, String> {
Err("the TIN vault is locked, \"for now\"".into())
};
let locked = vendor_1099_worksheet(ledger, &s, 2026, None, Some(&none)).unwrap();
let locked = vendor_1099_worksheet(ledger, &s, 2026, None, None, Some(&none)).unwrap();
assert_eq!(
vendor_1099_csv(&locked, true).lines().nth(1).unwrap(),
format!(
"{},\"Acme, \"\"Design\"\"\",1099-NEC,2500.00,true,true,9105,,,\"the TIN vault is locked, \"\"for now\"\"\"",
b.vendor
"{},,,\"the TIN vault is locked, \"\"for now\"\"\"",
acme_row(b.vendor)
)
);
assert_eq!(vendor_1099_csv_name(2026, false), "1099-worksheet-2026.csv");
@@ -645,9 +694,10 @@ fn the_1099_worksheet_carries_tins_only_from_a_source() {
);
// A year whose threshold isn't known is refused, never guessed; given
// one, it is used.
let far = vendor_1099_worksheet(ledger, &s, 2031, None, None);
let far = vendor_1099_worksheet(ledger, &s, 2031, None, None, None);
assert!(far.is_err(), "{far:?}");
let given = vendor_1099_worksheet(ledger, &s, 2031, Some(Decimal::from(2100)), None).unwrap();
let given =
vendor_1099_worksheet(ledger, &s, 2031, Some(Decimal::from(2100)), None, None).unwrap();
assert_eq!(given.threshold, Decimal::from(2100));
assert!(
given
@@ -918,9 +968,13 @@ fn the_years_first_and_last_days_receipts_are_in_and_the_days_beyond_are_not() {
}
/// Contract labor paid three ways: from checking (cash), from a prepaid
/// asset that isn't cash, and by a member's advance (financing).
/// asset that isn't cash, and by a member's advance (financing). The
/// worksheet counts payments out of the accounts it is given; the CPA
/// package gives it its own (the request's, else the settings': one list
/// of each per package, P3 review R18), so the package's 1099 worksheet
/// is books.vendor_1099's for those accounts, byte for byte.
#[test]
fn payments_to_a_vendor_come_from_the_settings_accounts_alone() {
fn payments_to_a_vendor_come_from_the_packages_accounts() {
let dir = tempfile::tempdir().unwrap();
let (mut s, _) = plain_books(dir.path());
for (code, name, ty) in [
@@ -931,8 +985,7 @@ fn payments_to_a_vendor_come_from_the_settings_accounts_alone() {
s.add_account(code, name, ty, None, false).unwrap();
}
let vendor = s.add_vendor("Acme Design").unwrap();
s.set_vendor_1099(vendor, Some(Form1099::Nec), true)
.unwrap();
s.set_vendor_1099(vendor, &nec(), Some(true)).unwrap();
for (from, amount) in [("1000", "1000.00"), ("1300", "400.00"), ("2100", "700.00")] {
let e = s.create_entry(d("2026-05-01"), "Acme design work").unwrap();
s.add_line(e, id_of(&s, "6100"), Side::Debit, usd(amount))
@@ -960,11 +1013,49 @@ fn payments_to_a_vendor_come_from_the_settings_accounts_alone() {
financing: vec![id_of(&s, "2100")],
}
);
// Checking and the advance count; the prepaid asset doesn't.
let w = vendor_1099_worksheet(ledger, &st, 2026, None, None).unwrap();
// Checking counts; the member's advance is undetermined until how
// they paid is recorded (their card wouldn't count, their check
// would); the prepaid asset is no payment source.
let w = vendor_1099_worksheet(ledger, &st, 2026, None, None, None).unwrap();
assert_eq!(w.lines.len(), 1);
assert_eq!(w.lines[0].vendor_id, vendor);
assert_eq!(w.lines[0].paid, Decimal::from(1700));
assert_eq!(w.lines[0].paid, Decimal::from(1000));
assert_eq!(w.lines[0].undetermined, Decimal::from(700));
assert_eq!(w.lines[0].status, ledger_core::Status1099::UnderThreshold);
assert_eq!(w.undetermined_payments.len(), 1);
// A package naming no accounts carries this worksheet, byte for byte
// (its settings name checking alone: the cash flow refuses a code the
// chart lacks).
let checking = vec!["1000".to_string()];
let st = Settings {
cash_accounts: &checking,
..st
};
let plain = build_package(&s, &st, &request(false, None)).unwrap();
assert_eq!(
text(&plain, "1099-worksheet-2026.csv"),
vendor_1099_csv(&w, false)
);
// One naming the prepaid asset as cash carries the worksheet for its
// accounts (the 400.00 out of the prepaid asset counts, and the
// settings' financing stays), the one books.vendor_1099 gives for
// them through the package's as_of; the cash flow, the K-1 and the
// IIF read the same list.
let wider = vec!["1000".to_string(), "1300".to_string()];
let req = PackageRequest {
cash_accounts: &wider,
..request(false, None)
};
let p = build_package(&s, &st, &req).unwrap();
let theirs = st.with_accounts(&wider, &[]);
assert_eq!(theirs.financing_accounts, financing.as_slice());
let w = vendor_1099_worksheet(ledger, &theirs, 2026, None, Some(req.as_of), None).unwrap();
assert_eq!(w.lines[0].paid, Decimal::from(1400));
assert_eq!(w.lines[0].undetermined, Decimal::from(700));
assert_eq!(
text(&p, "1099-worksheet-2026.csv"),
vendor_1099_csv(&w, false)
);
}
#[test]
@@ -1380,8 +1471,7 @@ fn the_1099_worksheet_stops_at_as_of() {
s.add_account("6100", "Contract labor", AccountType::Expense, None, false)
.unwrap();
let vendor = s.add_vendor("Contractor Example").unwrap();
s.set_vendor_1099(vendor, Some(Form1099::Nec), false)
.unwrap();
s.set_vendor_1099(vendor, &nec(), Some(false)).unwrap();
for (date, amount) in [("2026-06-30", "1500.00"), ("2026-07-01", "1000.00")] {
let e = s.create_entry(d(date), "Contract work").unwrap();
s.add_line(e, id_of(&s, "6100"), Side::Debit, usd(amount))
@@ -1400,25 +1490,52 @@ fn the_1099_worksheet_stops_at_as_of() {
};
build_package(&s, &settings(&company, &cash), &req).unwrap()
};
// The worksheet the tools make through a day (books.vendor_1099's
// `through`), as CSV.
let through = |day: Option<&str>| {
let w = vendor_1099_worksheet(
s.ledger(),
&settings(&company, &cash),
2026,
None,
day.map(d),
None,
)
.unwrap();
vendor_1099_csv(&w, false)
};
let row = |csv: &str| csv.lines().nth(1).unwrap().to_string();
let mid = at("2026-06-30");
assert_eq!(
text(&mid, "1099-worksheet-2026.csv"),
"vendor,form,w9_on_file,paid,reportable\n\"Contractor Example\",1099-NEC,false,1500.00,false\n"
let mid_csv = text(&mid, "1099-worksheet-2026.csv");
assert_eq!(mid_csv, through(Some("2026-06-30")));
// paid 1,500.00, under the $2,000 threshold: not yet reportable.
assert!(
row(mid_csv).ends_with(",1500.00,0.00,0.00,0.00,under_threshold,false,false,"),
"{mid_csv}"
);
assert!(text(&mid, "README.txt").contains(
"\n1099-worksheet-2026.csv: cash paid per vendor marked for a 1099 from January 1 through 2026-06-30, against the year's threshold.\n"
"\n1099-worksheet-2026.csv: each vendor paid in 2026 from January 1 through 2026-06-30 or decided for a 1099: what counts toward its 1099 against the year's threshold, "
));
let year_end = at("2026-12-31");
assert_eq!(
text(&year_end, "1099-worksheet-2026.csv"),
"vendor,form,w9_on_file,paid,reportable\n\"Contractor Example\",1099-NEC,false,2500.00,true\n"
let year_csv = text(&year_end, "1099-worksheet-2026.csv");
assert_eq!(year_csv, through(None));
assert!(
row(year_csv).ends_with(",2500.00,0.00,0.00,0.00,reportable,true,false,"),
"{year_csv}"
);
assert!(text(&year_end, "README.txt").contains(
"\n1099-worksheet-2026.csv: cash paid per vendor marked for a 1099, against the year's threshold.\n"
"\n1099-worksheet-2026.csv: each vendor paid in 2026 or decided for a 1099: what counts toward its 1099 against the year's threshold, "
));
// The worksheet the tools make for the whole year is unchanged.
let w =
vendor_1099_worksheet(s.ledger(), &settings(&company, &cash), 2026, None, None).unwrap();
let w = vendor_1099_worksheet(
s.ledger(),
&settings(&company, &cash),
2026,
None,
None,
None,
)
.unwrap();
assert_eq!(w.lines[0].paid, Decimal::from(2500));
}
@@ -1526,8 +1643,7 @@ fn one_list_of_cash_accounts_per_package() {
],
);
let vendor = s.add_vendor("Contractor Example").unwrap();
s.set_vendor_1099(vendor, Some(Form1099::Nec), false)
.unwrap();
s.set_vendor_1099(vendor, &nec(), Some(false)).unwrap();
let e = s.create_entry(d("2026-05-01"), "Contract work").unwrap();
s.add_line(e, id_of(&s, "6100"), Side::Debit, usd("2100.00"))
.unwrap();
@@ -1551,8 +1667,29 @@ fn one_list_of_cash_accounts_per_package() {
};
assert_eq!(savings(&given), "ACCNT\tSavings\tBANK\t1010");
assert_eq!(savings(&plain), "ACCNT\tSavings\tOCASSET\t1010");
assert!(text(&given, "1099-worksheet-2026.csv").contains(",2100.00,true\n"));
assert!(text(&plain, "1099-worksheet-2026.csv").contains(",0.00,false\n"));
assert!(
text(&given, "1099-worksheet-2026.csv")
.contains(",2100.00,0.00,0.00,0.00,reportable,true,false,\n")
);
assert!(
text(&plain, "1099-worksheet-2026.csv")
.contains(",0.00,0.00,0.00,0.00,under_threshold,false,false,\n")
);
// Each is books.vendor_1099's CSV for the same accounts.
let tool = |codes: &[String]| {
let w = vendor_1099_worksheet(
s.ledger(),
&settings(&company, codes),
2026,
None,
Some(d("2026-12-31")),
None,
)
.unwrap();
vendor_1099_csv(&w, false)
};
assert_eq!(text(&given, "1099-worksheet-2026.csv"), tool(&both));
assert_eq!(text(&plain, "1099-worksheet-2026.csv"), tool(&cash));
}
/// Plan D-36 (P3, defect 7): general-journal.iif is plain ASCII, and
+368
View File
@@ -0,0 +1,368 @@
//! Journal payloads of 1099 decisions and payment methods (plan P3,
//! defects 12, 18 and 23; D-40). One encoder and one decoder per event,
//! so the writer and every reader (replay, the projection, the viewer)
//! read a line the same way.
//!
//! ```text
//! GOAL: every event round-trips: decode(encode(x)) == x, and every line
//! written before decisions were kept reads as it always meant.
//! SOURCE: plan/served.md 5.2, 5.5 (a legacy null is undetermined);
//! rules.md B-27 (an older reader says "newer", never "corrupt").
//! INVARIANTS:
//! 1. vendor.1099_set {vendor_id, form_1099: "nec"|"misc"|
//! "not_reportable"|null, reason_kind?, reason?, effective?,
//! tin_on_file, source?}. A line with no source is one written before
//! decisions were kept: undated, read as its form or undetermined.
//! A line with a source is the vendor_1099_decisions feature's.
//! 2. not_reportable needs a source, a reason_kind and a reason; a date
//! needs a source; a reason or reason_kind beside anything else is
//! corrupt. A form or reason kind this version doesn't know is newer
//! (format::newer_content), never corrupt.
//! 3. vendor.w9_flag_set {vendor_id, tin_on_file}.
//! 4. instrument.added {instrument_id, kind, label, last4?,
//! owner_party_id?, account_id}; entry.instrument_set {entry_id,
//! instrument_id|null}. Optional keys are left out when absent.
//! 5. Decoding checks shape only (rules.md B-28): what a command checks
//! (limits, the chart) stays with the command.
//! ```
use crate::error::StoreError;
use crate::journal::JournalLine;
use crate::meta::{source_json, source_of};
use chrono::NaiveDate;
use ledger_core::form1099::{Dated1099, Decision1099, ExemptReason, Form1099};
use ledger_core::{Instrument, InstrumentId, InstrumentKind, VendorId};
use serde_json::{Map, Value, json};
const SET: &str = "vendor.1099_set";
fn corrupt(event: &str, what: &str) -> StoreError {
StoreError::JournalCorrupt(format!("{event}: missing or invalid '{what}'"))
}
fn u64_of(p: &Value, event: &str, k: &str) -> Result<u64, StoreError> {
p.get(k)
.and_then(Value::as_u64)
.ok_or_else(|| corrupt(event, k))
}
fn opt_u64(p: &Value, event: &str, k: &str) -> Result<Option<u64>, StoreError> {
match p.get(k) {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_u64().map(Some).ok_or_else(|| corrupt(event, k)),
}
}
fn opt_str<'a>(p: &'a Value, event: &str, k: &str) -> Result<Option<&'a str>, StoreError> {
match p.get(k) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(s)) => Ok(Some(s)),
Some(_) => Err(corrupt(event, k)),
}
}
/// `vendor.1099_set` for a decision with its source (INVARIANT 1).
pub fn decision_set(vendor: VendorId, d: &Dated1099, tin_on_file: bool) -> Value {
let mut o = Map::new();
o.insert("vendor_id".into(), json!(vendor));
match &d.decision {
Decision1099::Undetermined => {
o.insert("form_1099".into(), Value::Null);
}
Decision1099::Files(f) => {
o.insert("form_1099".into(), json!(f.key()));
}
Decision1099::NotReportable { kind, reason } => {
o.insert("form_1099".into(), json!("not_reportable"));
o.insert("reason_kind".into(), json!(kind.key()));
o.insert("reason".into(), json!(reason));
}
}
if let Some(e) = d.effective {
o.insert("effective".into(), json!(e.to_string()));
}
o.insert("tin_on_file".into(), json!(tin_on_file));
if let Some(s) = &d.source {
o.insert("source".into(), source_json(s));
}
Value::Object(o)
}
/// Read a `vendor.1099_set` (INVARIANTS 1-2): the vendor, its decision,
/// and its W-9 flag.
pub fn decision_of(line: &JournalLine) -> Result<(VendorId, Dated1099, bool), StoreError> {
let p = &line.payload;
let vendor = u64_of(p, SET, "vendor_id")?;
// Every line has carried the flag; a missing one was false.
let tin_on_file = match p.get("tin_on_file") {
None | Some(Value::Null) => false,
Some(v) => v.as_bool().ok_or_else(|| corrupt(SET, "tin_on_file"))?,
};
let source = match p.get("source") {
None | Some(Value::Null) => None,
Some(_) => Some(source_of(p, SET, "source")?),
};
let effective = match opt_str(p, SET, "effective")? {
None => None,
Some(s) => {
Some(NaiveDate::parse_from_str(s, "%Y-%m-%d").map_err(|_| corrupt(SET, "effective"))?)
}
};
let kind = opt_str(p, SET, "reason_kind")?;
let reason = opt_str(p, SET, "reason")?;
let decision = match opt_str(p, SET, "form_1099")? {
None => Decision1099::Undetermined,
Some("not_reportable") => {
let kind = kind.ok_or_else(|| corrupt(SET, "reason_kind"))?;
let reason = reason
.filter(|r| !r.trim().is_empty())
.ok_or_else(|| corrupt(SET, "reason"))?;
Decision1099::NotReportable {
kind: ExemptReason::parse(kind).ok_or_else(|| {
crate::format::newer_content(line, format!("the 1099 reason kind {kind:?}"))
})?,
reason: reason.to_string(),
}
}
Some(s) => Decision1099::Files(Form1099::parse(s).ok_or_else(|| {
crate::format::newer_content(line, format!("the 1099 decision {s:?}"))
})?),
};
let not_reportable = matches!(decision, Decision1099::NotReportable { .. });
if !not_reportable && (kind.is_some() || reason.is_some()) {
return Err(corrupt(SET, "reason"));
}
if source.is_none() && (not_reportable || effective.is_some()) {
return Err(corrupt(SET, "source"));
}
Ok((
vendor,
Dated1099 {
effective,
decision,
source,
},
tin_on_file,
))
}
/// `vendor.w9_flag_set {vendor_id, tin_on_file}` (INVARIANT 3).
pub fn w9_flag_set(vendor: VendorId, tin_on_file: bool) -> Value {
json!({ "vendor_id": vendor, "tin_on_file": tin_on_file })
}
/// Read a `vendor.w9_flag_set`.
pub fn w9_flag_of(p: &Value) -> Result<(VendorId, bool), StoreError> {
const E: &str = "vendor.w9_flag_set";
Ok((
u64_of(p, E, "vendor_id")?,
p.get("tin_on_file")
.and_then(Value::as_bool)
.ok_or_else(|| corrupt(E, "tin_on_file"))?,
))
}
/// `instrument.added` (INVARIANT 4).
pub fn instrument_added(i: &Instrument) -> Value {
let mut o = Map::new();
o.insert("instrument_id".into(), json!(i.id));
o.insert("kind".into(), json!(i.kind.key()));
o.insert("label".into(), json!(i.label));
if let Some(l) = &i.last4 {
o.insert("last4".into(), json!(l));
}
if let Some(p) = i.owner_party {
o.insert("owner_party_id".into(), json!(p));
}
o.insert("account_id".into(), json!(i.account));
Value::Object(o)
}
/// Read an `instrument.added`.
pub fn instrument_of(line: &JournalLine) -> Result<Instrument, StoreError> {
const E: &str = "instrument.added";
let p = &line.payload;
let kind = opt_str(p, E, "kind")?.ok_or_else(|| corrupt(E, "kind"))?;
Ok(Instrument {
id: u64_of(p, E, "instrument_id")?,
kind: InstrumentKind::parse(kind).ok_or_else(|| {
crate::format::newer_content(line, format!("the payment method kind {kind:?}"))
})?,
label: opt_str(p, E, "label")?
.ok_or_else(|| corrupt(E, "label"))?
.to_string(),
last4: opt_str(p, E, "last4")?.map(str::to_string),
owner_party: opt_u64(p, E, "owner_party_id")?,
account: u64_of(p, E, "account_id")?,
})
}
/// `entry.instrument_set {entry_id, instrument_id|null}` (INVARIANT 4).
pub fn entry_instrument_set(
entry: ledger_core::EntryId,
instrument: Option<InstrumentId>,
) -> Value {
json!({ "entry_id": entry, "instrument_id": instrument })
}
/// Read an `entry.instrument_set`.
pub fn entry_instrument_of(
p: &Value,
) -> Result<(ledger_core::EntryId, Option<InstrumentId>), StoreError> {
const E: &str = "entry.instrument_set";
Ok((u64_of(p, E, "entry_id")?, opt_u64(p, E, "instrument_id")?))
}
#[cfg(test)]
mod tests {
use super::*;
use ledger_core::settings::Source;
fn line(line_type: &str, payload: Value) -> JournalLine {
JournalLine {
seq: 7,
ts: "2026-10-20T00:00:00Z".into(),
line_type: line_type.into(),
payload,
prev: String::new(),
}
}
fn source() -> Source {
Source {
decided_by: "Riley Chen".into(),
decided_on: "2026-10-16".parse().unwrap(),
citation: Some("Treas. Reg. §1.6041-3(p)(1)".into()),
note: None,
}
}
/// INVARIANT 1: every decision round-trips.
#[test]
fn decisions_round_trip() {
for (effective, decision) in [
(None, Decision1099::Undetermined),
(None, Decision1099::Files(Form1099::Nec)),
(
Some("2026-12-30".parse().unwrap()),
Decision1099::not_reportable(ExemptReason::Corporation, "a corporation by name")
.unwrap(),
),
] {
let d = Dated1099 {
effective,
decision,
source: Some(source()),
};
let got = decision_of(&line(SET, decision_set(4, &d, true))).unwrap();
assert_eq!(got, (4, d, true));
}
}
/// INVARIANT 1, plan 5.5: lines written before decisions were kept
/// read as their form, undated, with no source; a null (or no form)
/// as undetermined.
#[test]
fn legacy_lines_read_as_they_always_meant() {
let legacy = |form: Value| {
decision_of(&line(
SET,
json!({"vendor_id": 2, "form_1099": form, "tin_on_file": true}),
))
.unwrap()
};
let nec = legacy(json!("nec"));
assert_eq!(
nec,
(
2,
Dated1099::from_beginning(Decision1099::Files(Form1099::Nec), None),
true
)
);
assert_eq!(
legacy(Value::Null).1,
Dated1099::from_beginning(Decision1099::Undetermined, None)
);
let bare = decision_of(&line(SET, json!({"vendor_id": 2}))).unwrap();
assert_eq!(
bare,
(
2,
Dated1099::from_beginning(Decision1099::Undetermined, None),
false
)
);
}
/// INVARIANT 2.
#[test]
fn malformed_decisions_are_corrupt_and_newer_ones_newer() {
let src = source_json(&source());
let bad = [
// not_reportable without a reason, its kind, or a source.
json!({"vendor_id": 1, "form_1099": "not_reportable", "reason_kind": "corporation", "tin_on_file": false, "source": src}),
json!({"vendor_id": 1, "form_1099": "not_reportable", "reason": "x", "tin_on_file": false, "source": src}),
json!({"vendor_id": 1, "form_1099": "not_reportable", "reason_kind": "corporation", "reason": "x", "tin_on_file": false}),
// A date without a source; a reason beside a form.
json!({"vendor_id": 1, "form_1099": "nec", "effective": "2026-01-01", "tin_on_file": false}),
json!({"vendor_id": 1, "form_1099": "nec", "reason": "x", "tin_on_file": false, "source": src}),
json!({"vendor_id": 1, "form_1099": 7, "tin_on_file": false}),
json!({"vendor_id": 1, "form_1099": "nec", "effective": "2026-13-01", "tin_on_file": false, "source": src}),
];
for p in bad {
let e = decision_of(&line(SET, p.clone())).unwrap_err();
assert!(matches!(e, StoreError::JournalCorrupt(_)), "{p}: {e:?}");
}
let newer = [
json!({"vendor_id": 1, "form_1099": "k", "tin_on_file": false, "source": src}),
json!({"vendor_id": 1, "form_1099": "not_reportable", "reason_kind": "martian", "reason": "x", "tin_on_file": false, "source": src}),
];
for p in newer {
let e = decision_of(&line(SET, p.clone())).unwrap_err();
assert!(matches!(e, StoreError::NewerJournal { .. }), "{p}: {e:?}");
}
}
/// INVARIANT 4.
#[test]
fn instruments_round_trip() {
for i in [
Instrument {
id: 3,
kind: InstrumentKind::BusinessCard,
label: "Harbor Visa".into(),
last4: Some("0042".into()),
owner_party: None,
account: 14,
},
Instrument {
id: 4,
kind: InstrumentKind::MemberCashOrCheck,
label: "Riley's checks".into(),
last4: None,
owner_party: Some(2),
account: 9,
},
] {
let p = instrument_added(&i);
assert!(p.get("last4").is_none() || i.last4.is_some());
assert_eq!(instrument_of(&line("instrument.added", p)).unwrap(), i);
}
let e = instrument_of(&line(
"instrument.added",
json!({"instrument_id": 1, "kind": "crypto", "label": "x", "account_id": 1}),
))
.unwrap_err();
assert!(matches!(e, StoreError::NewerJournal { .. }), "{e:?}");
assert_eq!(
entry_instrument_of(&entry_instrument_set(5, None)).unwrap(),
(5, None)
);
assert_eq!(
entry_instrument_of(&entry_instrument_set(5, Some(2))).unwrap(),
(5, Some(2))
);
assert_eq!(w9_flag_of(&w9_flag_set(3, true)).unwrap(), (3, true));
}
}
+19 -3
View File
@@ -36,14 +36,17 @@ pub const SUPPORTED_JOURNAL_FORMAT: u32 = 2;
/// older version can't read: a new event type (which it would refuse as
/// unknown), or a field or value on an event type it knows (which it
/// would misread silently). A writer declares the feature
/// (`journal.format_set`) before its first such line, so the older
/// version stops there instead (rules.md B-27). Books that never use one
/// stay readable by versions before it.
/// (`journal.format_set`) before its first such line
/// ([`crate::Store`]'s `ensure_feature`), so the older version stops
/// there instead (rules.md B-27). Books that never use one stay readable
/// by versions before it.
pub const KNOWN_FEATURES: &[&str] = &[
MEMBERS_CUMULATIVE,
FEATURE_ACCOUNT_RENAMED,
FEATURE_REQUIRE_TAX_CATEGORY,
PARTY_CREDITORS,
PAYMENT_INSTRUMENTS,
VENDOR_1099_DECISIONS,
];
/// Feature: `period.closed` closes to members with `"rounding":
@@ -75,6 +78,16 @@ pub(crate) fn setting_feature(key: &str) -> Option<&'static str> {
/// creditor of a liability (rules.md B-35, P3 defect 24).
pub const PARTY_CREDITORS: &str = "party_creditors";
/// Payment methods (`instrument.added`) and the entries tagged with one
/// (`entry.instrument_set`): plan P3, defect 12.
pub const PAYMENT_INSTRUMENTS: &str = "payment_instruments";
/// 1099 decisions kept as decisions (`vendor.1099_set` with
/// `not_reportable`, its reason, the date it takes effect and who decided:
/// plan P3, defects 18 and 23, D-40) and W-9 flags kept apart from them
/// (`vendor.w9_flag_set`).
pub const VENDOR_1099_DECISIONS: &str = "vendor_1099_decisions";
/// The line type that declares the journal format.
pub const FORMAT_SET: &str = "journal.format_set";
@@ -107,6 +120,7 @@ pub const EVENT_TYPES: &[&str] = &[
"document.filed",
"entry.approved",
"entry.created",
"entry.instrument_set",
"entry.line_added",
"entry.line_class_set",
"entry.no_party",
@@ -118,6 +132,7 @@ pub const EVENT_TYPES: &[&str] = &[
"fx.rate_set",
"invoice.created",
"invoice.payment_received",
"instrument.added",
FORMAT_SET,
"journal.tail_repaired",
"line.cleared",
@@ -150,6 +165,7 @@ pub const EVENT_TYPES: &[&str] = &[
"store.created",
"vendor.1099_set",
"vendor.added",
"vendor.w9_flag_set",
];
/// The journal format in force: the last `journal.format_set` read, or
+1
View File
@@ -25,6 +25,7 @@ pub mod blob;
pub mod chain;
pub mod error;
pub mod exports;
pub mod form1099;
pub mod format;
pub mod journal;
pub mod meta;
+8 -4
View File
@@ -13,7 +13,10 @@
//! cash accounts: `[books] cash_accounts`, resolved; when none are set,
//! the asset accounts (not placeholders) whose names say they hold
//! cash ("cash", "bank", "checking", "savings", "money market").
//! card accounts: liability accounts whose names contain "card".
//! card accounts: `ledger_core::parties::card_accounts`: the
//! liabilities of the business-card payment methods the books record,
//! else (none recorded) the liabilities whose names say "card" but
//! not "gift"; never a member's card or a platform's account.
//! INVARIANTS:
//! 1. reconcilable_accounts: the cash accounts, then the card accounts,
//! then every other liability with a recorded statement, each once.
@@ -55,9 +58,10 @@ pub fn cash_accounts(ledger: &Ledger, configured: &[String]) -> Vec<AccountId> {
.collect()
}
/// The card accounts: liabilities whose names contain "card"
/// (`ledger_core::parties::card_accounts`, the same rule party money
/// reads, so the two never disagree on what a card is).
/// The card accounts (`ledger_core::parties::card_accounts`: the
/// business-card payment methods' liabilities, else the liabilities named
/// "card", not "gift"), the same rule party money and the viewer read, so
/// none disagrees on what a card is.
pub fn card_accounts(ledger: &Ledger) -> Vec<AccountId> {
ledger_core::parties::card_accounts(ledger)
}
+34 -1
View File
@@ -5,7 +5,7 @@
//! is the current version.
/// Current schema version.
pub const SCHEMA_VERSION: u32 = 12;
pub const SCHEMA_VERSION: u32 = 13;
/// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`.
pub const MIGRATIONS: &[&str] = &[
@@ -323,5 +323,38 @@ CREATE TABLE IF NOT EXISTS entry_approvals(
note TEXT NULL,
source_json TEXT NOT NULL
);
"#,
r#"
-- v13: 1099 decisions and payment methods (plan P3, defects 12, 18 and
-- 23; D-40). Each 1099 decision recorded with who decided (journal
-- feature vendor_1099_decisions), line by line: the date it takes effect
-- (NULL: from the beginning), nec, misc, not_reportable or NULL
-- (undetermined), and for not_reportable its kind and reason. A decision
-- from the beginning also sets vendors.form_1099, as every decision
-- written before these did. Payment methods (feature payment_instruments)
-- and the method each entry was paid with. Rows only come from lines
-- written under the features, so books written before them project the
-- same as before.
CREATE TABLE IF NOT EXISTS vendor_1099_decisions(
seq INTEGER PRIMARY KEY,
vendor_id INTEGER NOT NULL,
effective TEXT NULL,
form_1099 TEXT NULL,
reason_kind TEXT NULL,
reason TEXT NULL,
source_json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS instruments(
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL,
label TEXT NOT NULL,
last4 TEXT NULL,
owner_party INTEGER NULL,
account_id INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS entry_instruments(
entry_id INTEGER PRIMARY KEY,
instrument_id INTEGER NOT NULL
);
"#,
];
+287 -31
View File
@@ -587,16 +587,25 @@ impl Store {
/// first line that needs it: a new event type, or a field or value on
/// a known event type that an older version would otherwise misread
/// silently. Declared with the format in force (at least format 2,
/// crate::meta::FORMAT) and every feature already in force; nothing is
/// crate::meta::FORMAT) and every feature already in force, so an
/// older reader stops at the declaration (rules.md B-27); nothing is
/// journaled when it is already declared. Books that never use the
/// feature stay readable by older versions.
///
/// Each command here that writes a feature's line declares it first.
/// A caller that journals several commands as one (a tool drafting an
/// entry and tagging it) calls this before the first of them, once
/// every check has passed: a declaration never lands after another
/// line of the same command (where a refusal to upgrade the format
/// would leave the command half done), and a refused command leaves
/// none.
///
/// ```text
/// GOAL: an older wanderbooks stops at the declaration (NewerJournal)
/// instead of misreading, or failing on, a line it can't read.
/// SOURCE: rules.md B-27; plan/served.md 5.1 (format gate).
/// ```
fn ensure_feature(&mut self, feature: &str) -> Result<(), StoreError> {
pub fn ensure_feature(&mut self, feature: &str) -> Result<(), StoreError> {
self.ensure_features(&[feature])
}
@@ -3079,25 +3088,149 @@ impl Store {
Ok(())
}
/// Set which 1099 a vendor gets (or none) and whether its W-9 is on
/// file. Journaled as `vendor.1099_set`.
/// Record a vendor's 1099 decision, from the date it takes effect (or
/// from the beginning), with who decided; and its W-9 flag when given
/// (`None` keeps it). Journaled as `vendor.1099_set` under the
/// `vendor_1099_decisions` feature. Returns what it did to the
/// vendor's decisions.
///
/// ```text
/// GOAL: the decision ("no 1099" included) is kept as a decision, with
/// who made it and when (plan defects 18, 23; D-40).
/// SOURCE: ledger_core::form1099 (plan_decision, check_new_decision);
/// P3 review R8.
/// SCENARIOS:
/// 1. IF the decision has no source: SHOULD refuse
/// BECAUSE every new decision names who made it.
/// 2. IF the core refuses it: SHOULD refuse, journaling nothing.
/// 3. IF it changes no decision: SHOULD journal nothing but a
/// changed W-9 flag (Store::set_vendor_w9_flag).
/// 4. Otherwise: SHOULD declare the feature once, then journal it.
/// ```
pub fn set_vendor_1099(
&mut self,
vendor: VendorId,
form: Option<ledger_core::Form1099>,
tin_on_file: bool,
) -> Result<(), StoreError> {
self.rehearse(|l| l.set_vendor_1099(vendor, form, tin_on_file))?;
self.ledger
decision: &ledger_core::Dated1099,
tin_on_file: Option<bool>,
) -> Result<ledger_core::form1099::Applied, StoreError> {
if decision.source.is_none() {
return Err(LedgerError::Invalid1099(
"a 1099 decision names who made it (decided_by)".into(),
)
.into());
}
let applied = self.ledger.check_vendor_1099(vendor, decision)?;
let current = self
.ledger
.get_vendor(vendor)
.ok_or(LedgerError::UnknownVendor(vendor))?;
.ok_or(LedgerError::UnknownVendor(vendor))?
.tin_on_file;
let flag = tin_on_file.unwrap_or(current);
if applied == ledger_core::form1099::Applied::Unchanged {
self.set_vendor_w9_flag(vendor, flag)?;
return Ok(applied);
}
self.ensure_feature(crate::format::VENDOR_1099_DECISIONS)?;
let line = self.append_journal(
"vendor.1099_set",
json!({ "vendor_id": vendor, "form_1099": form, "tin_on_file": tin_on_file }),
crate::form1099::decision_set(vendor, decision, flag),
)?;
self.ledger.set_vendor_1099(vendor, form, tin_on_file)?;
let got = self
.ledger
.set_vendor_1099(vendor, decision.clone(), flag)?;
debug_assert_eq!(got, applied, "check and record diverged");
apply_line_to_sqlite(&self.conn, &line)?;
Ok(())
Ok(applied)
}
/// Mark whether a vendor's W-9 is on file, keeping its 1099 decisions
/// and who made them (P3 review R8). Journaled as
/// `vendor.w9_flag_set` under the `vendor_1099_decisions` feature;
/// nothing when the flag is already so. Returns whether it changed.
pub fn set_vendor_w9_flag(
&mut self,
vendor: VendorId,
on_file: bool,
) -> Result<bool, StoreError> {
let v = self
.ledger
.get_vendor(vendor)
.ok_or(LedgerError::UnknownVendor(vendor))?;
if v.tin_on_file == on_file {
return Ok(false);
}
self.ensure_feature(crate::format::VENDOR_1099_DECISIONS)?;
let line = self.append_journal(
"vendor.w9_flag_set",
crate::form1099::w9_flag_set(vendor, on_file),
)?;
self.ledger.set_vendor_w9_flag(vendor, on_file)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(true)
}
/// Add a payment method (ledger_core::instruments), journaled as
/// `instrument.added` under the `payment_instruments` feature. Returns
/// its id.
pub fn add_instrument(
&mut self,
kind: ledger_core::InstrumentKind,
label: &str,
last4: Option<&str>,
owner_party: Option<ledger_core::PartyId>,
account: AccountId,
) -> Result<ledger_core::InstrumentId, StoreError> {
let label = self
.ledger
.check_add_instrument(label, last4, owner_party, account)?;
let id = self.ledger.next_instrument_id();
let instrument = ledger_core::Instrument {
id,
kind,
label,
last4: last4.map(str::to_string),
owner_party,
account,
};
self.ensure_feature(crate::format::PAYMENT_INSTRUMENTS)?;
let line = self.append_journal(
"instrument.added",
crate::form1099::instrument_added(&instrument),
)?;
let live =
self.ledger
.add_instrument(kind, &instrument.label, last4, owner_party, account)?;
debug_assert_eq!(live, id, "id allocator diverged from journal");
apply_line_to_sqlite(&self.conn, &line)?;
Ok(live)
}
/// Tag an entry (and its reversal chain) with the payment method it
/// was paid with, or clear it. Journaled as `entry.instrument_set`
/// under the `payment_instruments` feature; nothing when every entry
/// of the chain already says so. Returns whether it changed.
pub fn set_entry_instrument(
&mut self,
entry: EntryId,
instrument: Option<ledger_core::InstrumentId>,
) -> Result<bool, StoreError> {
self.rehearse(|l| l.set_entry_instrument(entry, instrument))?;
if self
.ledger
.reversal_chain(entry)
.into_iter()
.all(|e| self.ledger.entry_instrument(e) == instrument)
{
return Ok(false);
}
self.ensure_feature(crate::format::PAYMENT_INSTRUMENTS)?;
let line = self.append_journal(
"entry.instrument_set",
crate::form1099::entry_instrument_set(entry, instrument),
)?;
self.ledger.set_entry_instrument(entry, instrument)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(true)
}
/// Tag an entry with the vendor it paid, or clear the tag. Journaled as
@@ -4743,19 +4876,32 @@ pub(crate) fn replay_line_into_ledger(
}
}
"vendor.1099_set" => {
let form = match p.get("form_1099").and_then(|v| v.as_str()) {
Some(s) => Some(ledger_core::Form1099::parse(s).ok_or_else(|| {
StoreError::JournalCorrupt("vendor.1099_set form_1099".into())
})?),
None => None,
};
ledger.set_vendor_1099(
get_u64(p, "vendor_id")?,
form,
p.get("tin_on_file")
.and_then(|v| v.as_bool())
.unwrap_or(false),
let (vendor, decision, tin_on_file) = crate::form1099::decision_of(line)?;
ledger.replay_vendor_1099(vendor, decision, tin_on_file)?;
}
"vendor.w9_flag_set" => {
let (vendor, on_file) = crate::form1099::w9_flag_of(p)?;
ledger.set_vendor_w9_flag(vendor, on_file)?;
}
"instrument.added" => {
let i = crate::form1099::instrument_of(line)?;
let got = ledger.add_instrument(
i.kind,
&i.label,
i.last4.as_deref(),
i.owner_party,
i.account,
)?;
if got != i.id {
return Err(StoreError::ReplayMismatch(format!(
"payment method id: journal says {}, replay got {got}",
i.id
)));
}
}
"entry.instrument_set" => {
let (entry, instrument) = crate::form1099::entry_instrument_of(p)?;
ledger.set_entry_instrument(entry, instrument)?;
}
"entry.vendor_set" => {
ledger.set_entry_vendor(
@@ -5488,6 +5634,12 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
SELECT ?, vendor_id FROM entry_vendors WHERE entry_id = ?",
params![get_u64(p, "id")? as i64, get_u64(p, "reverses")? as i64],
)?;
// And the way it was paid.
conn.execute(
"INSERT OR REPLACE INTO entry_instruments(entry_id, instrument_id) \
SELECT ?, instrument_id FROM entry_instruments WHERE entry_id = ?",
params![get_u64(p, "id")? as i64, get_u64(p, "reverses")? as i64],
)?;
conn.execute(
"INSERT INTO entries(id, date, description, status, reversal_of) VALUES (?,?,?,'posted',?)",
params![
@@ -5698,17 +5850,81 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
)?;
}
"vendor.1099_set" => {
let (vendor, d, tin_on_file) = crate::form1099::decision_of(line)?;
let key = match &d.decision {
ledger_core::Decision1099::Undetermined => None,
other => Some(other.key()),
};
// A decision from the beginning is the vendor's form, as every
// line written before decisions were dated was.
if d.effective.is_none() {
conn.execute(
"UPDATE vendors SET form_1099 = ? WHERE id = ?",
params![key, vendor as i64],
)?;
}
conn.execute(
"UPDATE vendors SET form_1099 = ?, tin_on_file = ? WHERE id = ?",
"UPDATE vendors SET tin_on_file = ? WHERE id = ?",
params![tin_on_file, vendor as i64],
)?;
if let Some(s) = &d.source {
let (kind, reason) = match &d.decision {
ledger_core::Decision1099::NotReportable { kind, reason } => {
(Some(kind.key()), Some(reason.as_str()))
}
_ => (None, None),
};
conn.execute(
"INSERT INTO vendor_1099_decisions(seq, vendor_id, effective, form_1099, reason_kind, reason, source_json) VALUES (?,?,?,?,?,?,?)",
params![
line.seq as i64,
vendor as i64,
d.effective.map(|e| e.to_string()),
key,
kind,
reason,
crate::meta::source_json(s).to_string(),
],
)?;
}
}
"vendor.w9_flag_set" => {
let (vendor, on_file) = crate::form1099::w9_flag_of(p)?;
conn.execute(
"UPDATE vendors SET tin_on_file = ? WHERE id = ?",
params![on_file, vendor as i64],
)?;
}
"instrument.added" => {
let i = crate::form1099::instrument_of(line)?;
conn.execute(
"INSERT INTO instruments(id, kind, label, last4, owner_party, account_id) VALUES (?,?,?,?,?,?)",
params![
p.get("form_1099").and_then(|v| v.as_str()),
p.get("tin_on_file")
.and_then(|v| v.as_bool())
.unwrap_or(false),
get_u64(p, "vendor_id")? as i64,
i.id as i64,
i.kind.key(),
i.label,
i.last4,
i.owner_party.map(|p| p as i64),
i.account as i64,
],
)?;
}
"entry.instrument_set" => {
let (entry, instrument) = crate::form1099::entry_instrument_of(p)?;
// The whole reversal chain, as the ledger tags it.
for e in sqlite_reversal_chain(conn, entry as i64)? {
match instrument {
Some(i) => conn.execute(
"INSERT OR REPLACE INTO entry_instruments(entry_id, instrument_id) VALUES (?, ?)",
params![e, i as i64],
)?,
None => conn.execute(
"DELETE FROM entry_instruments WHERE entry_id = ?",
params![e],
)?,
};
}
}
"entry.vendor_set" => match p.get("vendor_id").and_then(|v| v.as_u64()) {
Some(v) => {
conn.execute(
@@ -6108,6 +6324,46 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
Ok(())
}
/// An entry's reversal chain in the projection, as
/// `Ledger::reversal_chain` walks it: up to the original, then down
/// through each posted reversal. A draft (not projected) is its own chain.
fn sqlite_reversal_chain(conn: &Connection, entry: i64) -> Result<Vec<i64>, StoreError> {
let up = |e: i64| -> Result<Option<i64>, StoreError> {
Ok(conn
.query_row(
"SELECT reversal_of FROM entries WHERE id = ?",
params![e],
|r| r.get::<_, Option<i64>>(0),
)
.optional()?
.flatten())
};
let mut top = entry;
while let Some(o) = up(top)? {
if o == entry {
break;
}
top = o;
}
let mut chain = vec![top];
let mut cur = top;
while let Some(r) = conn
.query_row(
"SELECT id FROM entries WHERE reversal_of = ? ORDER BY id LIMIT 1",
params![cur],
|r| r.get::<_, i64>(0),
)
.optional()?
{
if chain.contains(&r) {
break;
}
chain.push(r);
cur = r;
}
Ok(chain)
}
/// What [`Store::verify_and_heal`] found.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VerifyReport {
@@ -7,6 +7,8 @@
# cargo run --bin extras -- OUT (the edges of chrono and Decimal)
# cargo run --bin extras2 -- OUT (depreciation for 10000-01; 1,044 drafts)
# cargo run --bin older -- OUT (x_older_rules, with its JournalWriter)
# cargo run --bin p2_1099 -- OUT (p2_vendor_1099: P2's 1099 marks, run
# against 9c99a0cf, the revision before P3)
# cargo run --bin expect -- OUT (expected.json beside each journal)
# and copy each journal.jsonl, blobs/ and expected.json into old-books/.
# x_member_closes was written later, the same way, by main 00226a4c
@@ -0,0 +1,42 @@
//! The P2 books of 1099 marks: a vendor marked 1099-NEC, two marked
//! "none" (books.set_vendor_1099 "none" and ""), one undecided whose W-9
//! sync wrote a null, and payments out of the bank (1000), the members'
//! financing (2100) and a card (2050). Every name is fictional.
use chrono::NaiveDate;
use ledger_core::{AccountType, Currency, Form1099, Money, Side};
use ledger_store::Store;
use rust_decimal::Decimal;
fn main() {
let out = std::path::PathBuf::from(std::env::args().nth(1).unwrap()).join("p2_vendor_1099");
let _ = std::fs::remove_dir_all(&out);
let mut s = Store::create(&out, Currency::USD).unwrap();
let a = |s: &mut Store, c: &str, n: &str, t| s.add_account(c, n, t, None, false).unwrap();
let bank = a(&mut s, "1000", "Bank", AccountType::Asset);
let card = a(&mut s, "2050", "Company card", AccountType::Liability);
let due = a(&mut s, "2100", "Due to Members", AccountType::Liability);
let labor = a(&mut s, "6000", "Contract Labor", AccountType::Expense);
let harbor = s.add_vendor("Harbor Design LLC").unwrap();
let bigco = s.add_vendor("Bigco Supply").unwrap();
let cedar = s.add_vendor("Cedar Studio").unwrap();
let dana = s.add_vendor("Dana Studio").unwrap();
s.set_vendor_1099(harbor, Some(Form1099::Nec), false).unwrap(); // "nec"
s.set_vendor_1099(bigco, None, false).unwrap(); // "none"
s.set_vendor_1099(cedar, None, false).unwrap(); // ""
s.set_vendor_1099(dana, None, true).unwrap(); // set_w9's sync, undecided
let d = |y, m, day| NaiveDate::from_ymd_opt(y, m, day).unwrap();
let mut pay = |date, vendor, cr, cents: i64| {
let e = s.create_entry(date, "work").unwrap();
let m = Money::new(Decimal::new(cents, 2), Currency::USD);
s.add_line(e, labor, Side::Debit, m).unwrap();
s.add_line(e, cr, Side::Credit, m).unwrap();
s.post_entry(e).unwrap();
s.set_entry_vendor(e, Some(vendor)).unwrap();
};
pay(d(2025, 6, 1), dana, due, 65_000);
pay(d(2026, 3, 1), harbor, bank, 250_000);
pay(d(2026, 4, 1), harbor, due, 70_000);
pay(d(2026, 5, 1), harbor, card, 40_000);
pay(d(2026, 6, 1), bigco, bank, 90_000);
println!("WROTE {}", out.display());
}
@@ -0,0 +1,272 @@
{
"assets": [],
"bills": [],
"entries": [
{
"date": "2025-06-01",
"description": "work",
"id": 1,
"lines": [
{
"account": 4,
"amount": {
"amount": "650.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 3,
"amount": {
"amount": "650.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-03-01",
"description": "work",
"id": 2,
"lines": [
{
"account": 4,
"amount": {
"amount": "2500.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 1,
"amount": {
"amount": "2500.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-04-01",
"description": "work",
"id": 3,
"lines": [
{
"account": 4,
"amount": {
"amount": "700.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 3,
"amount": {
"amount": "700.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-05-01",
"description": "work",
"id": 4,
"lines": [
{
"account": 4,
"amount": {
"amount": "400.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 2,
"amount": {
"amount": "400.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-06-01",
"description": "work",
"id": 5,
"lines": [
{
"account": 4,
"amount": {
"amount": "900.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 1,
"amount": {
"amount": "900.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
}
],
"entry_parties": [
[
1,
[
{
"party_id": 4,
"share": "derived"
}
]
],
[
2,
[
{
"party_id": 1,
"share": "derived"
}
]
],
[
3,
[
{
"party_id": 1,
"share": "derived"
}
]
],
[
4,
[
{
"party_id": 1,
"share": "derived"
}
]
],
[
5,
[
{
"party_id": 2,
"share": "derived"
}
]
]
],
"invoices": [],
"payroll_runs": [],
"periods": [],
"recurring": [],
"schedules": [],
"snapshot": {
"Ok": {
"balance_sheet": {
"assets": "-3400",
"balanced": true,
"current_earnings": "-5150",
"equity": "-5150",
"liabilities": "1750"
},
"profit_and_loss": {
"expenses": "5150",
"net_income": "-5150",
"revenue": "0"
},
"trial_balance": {
"lines": [
{
"code": "1000",
"credit": "3400",
"debit": "0",
"name": "Bank"
},
{
"code": "2050",
"credit": "400",
"debit": "0",
"name": "Company card"
},
{
"code": "2100",
"credit": "1350",
"debit": "0",
"name": "Due to Members"
},
{
"code": "6000",
"credit": "0",
"debit": "5150",
"name": "Contract Labor"
}
],
"total_credits": "5150",
"total_debits": "5150"
}
}
},
"verify": {
"Ok": null
}
}
@@ -0,0 +1,38 @@
{"payload":{"base_currency":"USD"},"prev":"0000000000000000000000000000000000000000000000000000000000000000","seq":1,"ts":"2026-10-21T03:02:53Z","type":"store.created"}
{"payload":{"account_type":"Asset","code":"1000","currency":"USD","id":1,"name":"Bank","parent":null,"placeholder":false},"prev":"faa687f5a03c5dd12cd38d92bb550079de448ca93b0277fd87734c450f762985","seq":2,"ts":"2026-10-21T03:02:53Z","type":"account.added"}
{"payload":{"account_type":"Liability","code":"2050","currency":"USD","id":2,"name":"Company card","parent":null,"placeholder":false},"prev":"35820b1110264a25167acc22ae911dfef3d362c497b17acca930ee921b97b7d6","seq":3,"ts":"2026-10-21T03:02:53Z","type":"account.added"}
{"payload":{"account_type":"Liability","code":"2100","currency":"USD","id":3,"name":"Due to Members","parent":null,"placeholder":false},"prev":"f6431387d04e613df69f33702293c8c23f902f15e555b5d4c2aca49e545842bf","seq":4,"ts":"2026-10-21T03:02:53Z","type":"account.added"}
{"payload":{"account_type":"Expense","code":"6000","currency":"USD","id":4,"name":"Contract Labor","parent":null,"placeholder":false},"prev":"bb8832a8ca94611084d543b5ba32e0343944e55440b2849c2c8e1d2b6a8d2b99","seq":5,"ts":"2026-10-21T03:02:53Z","type":"account.added"}
{"payload":{"id":1,"name":"Harbor Design LLC"},"prev":"2dcde6845ee05cfb4ef2f23194a161986fe52b246c928e76745990e97409e091","seq":6,"ts":"2026-10-21T03:02:53Z","type":"vendor.added"}
{"payload":{"id":2,"name":"Bigco Supply"},"prev":"0721eef61ec9b3ed1ead9282f481e8a67191183bfd00d51184a8262a849de4b6","seq":7,"ts":"2026-10-21T03:02:53Z","type":"vendor.added"}
{"payload":{"id":3,"name":"Cedar Studio"},"prev":"4c6928038c741dcd6cd7c5dea501b3a7fbed9227002baec525323689097bb447","seq":8,"ts":"2026-10-21T03:02:53Z","type":"vendor.added"}
{"payload":{"id":4,"name":"Dana Studio"},"prev":"1f5a42c9b9512441793dd94dfefc80316b656af83a521c9e9c5f0fa339cd383f","seq":9,"ts":"2026-10-21T03:02:53Z","type":"vendor.added"}
{"payload":{"form_1099":"nec","tin_on_file":false,"vendor_id":1},"prev":"28175697977371c53fd918f0b858ba22f03eb7f8698e0ac4d94d4b08da26a96c","seq":10,"ts":"2026-10-21T03:02:53Z","type":"vendor.1099_set"}
{"payload":{"form_1099":null,"tin_on_file":false,"vendor_id":2},"prev":"22f935f5a504501919d62eeaab1c109d9878adf718b698fb77d947e33ebf8cf8","seq":11,"ts":"2026-10-21T03:02:53Z","type":"vendor.1099_set"}
{"payload":{"form_1099":null,"tin_on_file":false,"vendor_id":3},"prev":"1b4cdc7b0207eef330446cfafbb85acffe255268bba7a525c9a2281cb9491cd1","seq":12,"ts":"2026-10-21T03:02:53Z","type":"vendor.1099_set"}
{"payload":{"form_1099":null,"tin_on_file":true,"vendor_id":4},"prev":"fa22b64dc51595facd1613fd593971999599200ce695d5136645baf429f57c5c","seq":13,"ts":"2026-10-21T03:02:53Z","type":"vendor.1099_set"}
{"payload":{"date":"2025-06-01","description":"work","id":1},"prev":"aada8778c28f9bd17317f894db215169cbd033feb73f9024683c9a87034ecbe6","seq":14,"ts":"2026-10-21T03:02:53Z","type":"entry.created"}
{"payload":{"account":4,"amount":"650.00","currency":"USD","id":1,"side":"debit"},"prev":"a6c85a896b3525734f943e9e6c1d802f0dbb81da242af57160ecd1fb4a6994ba","seq":15,"ts":"2026-10-21T03:02:53Z","type":"entry.line_added"}
{"payload":{"account":3,"amount":"650.00","currency":"USD","id":1,"side":"credit"},"prev":"65f150b229ce65ce0fa00be0db83cc9b48a2b7bbf9b66f977186ca180661f517","seq":16,"ts":"2026-10-21T03:02:53Z","type":"entry.line_added"}
{"payload":{"date":"2025-06-01","description":"work","id":1,"lines":[{"account":4,"amount":"650.00","currency":"USD","side":"debit"},{"account":3,"amount":"650.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"15cab2a9658cdcbffd3f754d0dfb8bf50915aeeff62ad86b29dfeae085243d1a","seq":17,"ts":"2026-10-21T03:02:53Z","type":"entry.posted"}
{"payload":{"entry_id":1,"vendor_id":4},"prev":"75a257d69a1e96e999076f04a75caf1011ca684aa1eb4e0c35ab19d2a30d4715","seq":18,"ts":"2026-10-21T03:02:53Z","type":"entry.vendor_set"}
{"payload":{"date":"2026-03-01","description":"work","id":2},"prev":"965a3395b67428dc095d1d6ed8ed75873474bef20b49a976cfa088f3676d024a","seq":19,"ts":"2026-10-21T03:02:53Z","type":"entry.created"}
{"payload":{"account":4,"amount":"2500.00","currency":"USD","id":2,"side":"debit"},"prev":"c7ecf610d918f3b346db3239226a9d4ac586dea59a45f627519fcbb4eb11f466","seq":20,"ts":"2026-10-21T03:02:53Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"2500.00","currency":"USD","id":2,"side":"credit"},"prev":"4a25b78987fff2213ade495a096a9cda705c5a6c44c285b065794b5e268d2765","seq":21,"ts":"2026-10-21T03:02:53Z","type":"entry.line_added"}
{"payload":{"date":"2026-03-01","description":"work","id":2,"lines":[{"account":4,"amount":"2500.00","currency":"USD","side":"debit"},{"account":1,"amount":"2500.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"8d16aeafb1d1fe79d085cd574c0bd6bc5167807a462c7994ddf8625bb03fc92f","seq":22,"ts":"2026-10-21T03:02:53Z","type":"entry.posted"}
{"payload":{"entry_id":2,"vendor_id":1},"prev":"bce9b391e47b12594c9ae5302b9b5e4c75e0eef0d0e92dd5c78e3a8e0de8bc51","seq":23,"ts":"2026-10-21T03:02:53Z","type":"entry.vendor_set"}
{"payload":{"date":"2026-04-01","description":"work","id":3},"prev":"cfa11ea24eef3250c1c490d5a7c7748c5fd9c5d92d657bbd67b3115859c00546","seq":24,"ts":"2026-10-21T03:02:53Z","type":"entry.created"}
{"payload":{"account":4,"amount":"700.00","currency":"USD","id":3,"side":"debit"},"prev":"645f86b699b6d72c89a6396a2ce7151a633e05a160586a1d7e91ff9638752067","seq":25,"ts":"2026-10-21T03:02:53Z","type":"entry.line_added"}
{"payload":{"account":3,"amount":"700.00","currency":"USD","id":3,"side":"credit"},"prev":"3da5a99cfa096a7de27f8f8e117aafafde22c8f8516d8753235e688c18566ab6","seq":26,"ts":"2026-10-21T03:02:53Z","type":"entry.line_added"}
{"payload":{"date":"2026-04-01","description":"work","id":3,"lines":[{"account":4,"amount":"700.00","currency":"USD","side":"debit"},{"account":3,"amount":"700.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"7e9c4bce2009f0dc611e45aaa173dc2c973f999a06da1a6ef498be0beae9ad45","seq":27,"ts":"2026-10-21T03:02:53Z","type":"entry.posted"}
{"payload":{"entry_id":3,"vendor_id":1},"prev":"6357688b27ccbbb24d545fbe1bc454d3c73fa0aef4daf5d3ac666cfe7666d066","seq":28,"ts":"2026-10-21T03:02:53Z","type":"entry.vendor_set"}
{"payload":{"date":"2026-05-01","description":"work","id":4},"prev":"29b5ac4f6c46158b22f5cb6a18c2a9f88a386637d8424a283a587a17dbc56569","seq":29,"ts":"2026-10-21T03:02:53Z","type":"entry.created"}
{"payload":{"account":4,"amount":"400.00","currency":"USD","id":4,"side":"debit"},"prev":"f8eef20eca997c3a82f6963177f5f3482230852d2fa7abc965581d50e6ba0886","seq":30,"ts":"2026-10-21T03:02:53Z","type":"entry.line_added"}
{"payload":{"account":2,"amount":"400.00","currency":"USD","id":4,"side":"credit"},"prev":"622cac7da511368e75195f733595a7da35d35ba2e70594e8b54aba20e888584e","seq":31,"ts":"2026-10-21T03:02:53Z","type":"entry.line_added"}
{"payload":{"date":"2026-05-01","description":"work","id":4,"lines":[{"account":4,"amount":"400.00","currency":"USD","side":"debit"},{"account":2,"amount":"400.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"4541dabebf1bb9f268645a4398980e9542de4ea14b47d906a22dc42920579cc8","seq":32,"ts":"2026-10-21T03:02:53Z","type":"entry.posted"}
{"payload":{"entry_id":4,"vendor_id":1},"prev":"ccfdcf25dde07c0cfd75f5fb30ab42761fe2f3a901e89b63fb4836bf851702a1","seq":33,"ts":"2026-10-21T03:02:53Z","type":"entry.vendor_set"}
{"payload":{"date":"2026-06-01","description":"work","id":5},"prev":"52a15b817fa526b761c3f3ec4e9d2c8684a42f0ed7dd2fa6c96fd8b378063d57","seq":34,"ts":"2026-10-21T03:02:53Z","type":"entry.created"}
{"payload":{"account":4,"amount":"900.00","currency":"USD","id":5,"side":"debit"},"prev":"4e20498034f7dc588e1515af8a32a8df74009203bd1a78b27f2e1ba5d7cf9666","seq":35,"ts":"2026-10-21T03:02:53Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"900.00","currency":"USD","id":5,"side":"credit"},"prev":"199142dd14c4e3096c81a6426ae9e94e3e920b846656ac8eb0ee1fab322ba3a6","seq":36,"ts":"2026-10-21T03:02:53Z","type":"entry.line_added"}
{"payload":{"date":"2026-06-01","description":"work","id":5,"lines":[{"account":4,"amount":"900.00","currency":"USD","side":"debit"},{"account":1,"amount":"900.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"7c071ebfd5242722f07a46d87fecdf5549125894e4085b509c4789c7ca403bc9","seq":37,"ts":"2026-10-21T03:02:53Z","type":"entry.posted"}
{"payload":{"entry_id":5,"vendor_id":2},"prev":"fb00c3db22192ed708b2db0838b423a990d57560e43f30d62e150c815434e1d7","seq":38,"ts":"2026-10-21T03:02:53Z","type":"entry.vendor_set"}
+690
View File
@@ -0,0 +1,690 @@
//! 1099 decisions and payment methods in the journal (plan P3, defects
//! 12, 18 and 23; D-40): declared as features before their first line,
//! replayed and projected exactly, refused without a trace, and books
//! written before them read as they always meant. Every name is
//! fictional.
//!
//! ```text
//! GOAL: the decision ("no 1099" included) and how each payment was made
//! are kept as records, and no version ever misreads them.
//! SOURCE: rules.md B-19, B-27; ledger_store::form1099 INVARIANTS 1-5.
//! ```
use chrono::NaiveDate;
use ledger_core::form1099::Applied;
use ledger_core::settings::Source;
use ledger_core::{
AccountType, Currency, Dated1099, Decision1099, ExemptReason, Form1099, InstrumentKind, Money,
Side,
};
use ledger_store::format::{FORMAT_SET, PAYMENT_INSTRUMENTS, VENDOR_1099_DECISIONS};
use ledger_store::journal::read_and_verify_journal;
use ledger_store::{DB_FILE, JOURNAL_FILE, JournalWriter, Snapshot, Store, StoreError};
use rust_decimal::Decimal;
use serde_json::json;
use std::path::{Path, PathBuf};
/// A test's dir, removed when the test ends, pass or panic.
struct TestDir(PathBuf);
impl std::ops::Deref for TestDir {
type Target = Path;
fn deref(&self) -> &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 dir = std::env::temp_dir().join(format!("wanderbooks-1099-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
TestDir(dir)
}
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
fn src() -> Source {
Source {
decided_by: "Riley Chen".into(),
decided_on: d(2026, 10, 16),
citation: Some("Treas. Reg. §1.6041-3(p)(1)".into()),
note: None,
}
}
fn from_beginning(decision: Decision1099) -> Dated1099 {
Dated1099::from_beginning(decision, Some(src()))
}
/// Books: bank 1000, Due to Members 2100, contract labor 6000; Harbor
/// Design LLC paid 2,500.00 by a member (Dr 6000 / Cr 2100) and reversed,
/// then paid again.
struct Books {
store: Store,
due: u64,
harbor: u64,
paid: u64,
reversal: u64,
}
fn books(dir: &Path) -> Books {
let mut store = Store::create(dir, Currency::USD).unwrap();
store
.add_account("1000", "Bank", AccountType::Asset, None, false)
.unwrap();
let due = store
.add_account(
"2100",
"Due to Members",
AccountType::Liability,
None,
false,
)
.unwrap();
let labor = store
.add_account("6000", "Contract Labor", AccountType::Expense, None, false)
.unwrap();
let harbor = store.add_vendor("Harbor Design LLC").unwrap();
let pay = |store: &mut Store, day| {
let e = store.create_entry(d(2026, 5, day), "Design work").unwrap();
let m = Money::new(Decimal::new(250_000, 2), Currency::USD);
store.add_line(e, labor, Side::Debit, m).unwrap();
store.add_line(e, due, Side::Credit, m).unwrap();
store.post_entry(e).unwrap();
store.set_entry_vendor(e, Some(harbor)).unwrap();
e
};
let paid = pay(&mut store, 1);
let reversal = store.reverse_entry(paid, d(2026, 5, 2), "twice").unwrap();
pay(&mut store, 3);
Books {
store,
due,
harbor,
paid,
reversal,
}
}
/// A worksheet line: name, paid, undetermined, excluded card, status,
/// decision needed.
type Row1099 = (String, Decimal, Decimal, Decimal, &'static str, bool);
/// A row of the projection's vendor_1099_decisions: effective, form,
/// reason kind, source.
type DecisionRow = (Option<String>, Option<String>, Option<String>, String);
fn types(dir: &Path) -> Vec<(String, serde_json::Value)> {
read_and_verify_journal(&dir.join(JOURNAL_FILE))
.unwrap()
.into_iter()
.map(|l| (l.line_type, l.payload))
.collect()
}
/// B-27: the first line that needs a feature comes after a declaration of
/// it (with every feature already in force), once. A version that doesn't
/// know the feature stops at the declaration (`tests/format.rs`
/// old_reader_gets_newer_journal, for any feature it doesn't know).
#[test]
fn each_feature_is_declared_once_before_its_first_line() {
let dir = test_dir("declare");
let mut b = books(&dir);
let before = types(&dir).len();
assert!(
!types(&dir).iter().any(|(t, _)| t == FORMAT_SET),
"books without either feature declare nothing"
);
b.store
.set_vendor_1099(
b.harbor,
&from_beginning(Decision1099::Files(Form1099::Nec)),
None,
)
.unwrap();
b.store.set_vendor_w9_flag(b.harbor, true).unwrap();
let card = b
.store
.add_instrument(
InstrumentKind::MemberCard,
"Riley's Visa",
Some("4242"),
None,
b.due,
)
.unwrap();
b.store.set_entry_instrument(b.paid, Some(card)).unwrap();
b.store
.add_instrument(
InstrumentKind::MemberCashOrCheck,
"Riley's checks",
None,
None,
b.due,
)
.unwrap();
let added: Vec<String> = types(&dir)[before..]
.iter()
.map(|(t, p)| match t.as_str() {
FORMAT_SET => format!("{t} {}", p["features"]),
_ => t.clone(),
})
.collect();
assert_eq!(
added,
[
format!("{FORMAT_SET} [\"{VENDOR_1099_DECISIONS}\"]"),
"vendor.1099_set".to_string(),
"vendor.w9_flag_set".to_string(),
format!("{FORMAT_SET} [\"{PAYMENT_INSTRUMENTS}\",\"{VENDOR_1099_DECISIONS}\"]"),
"instrument.added".to_string(),
"entry.instrument_set".to_string(),
"instrument.added".to_string(),
]
);
assert_eq!(b.store.journal_format().format, 2);
}
/// Every record replays and projects exactly: reopening gives back the
/// same ledger, part for part; a projection rebuilt from the journal
/// equals the one the writer kept, table by table (verify); the viewer's
/// snapshot reads the same; and the projection holds each decision with
/// who made it, each method, and the tag on the whole reversal chain.
#[test]
fn decisions_and_methods_replay_and_project() {
let dir = test_dir("replay");
let mut b = books(&dir);
let corp = Decision1099::not_reportable(
ExemptReason::Corporation,
"Delaware public benefit corporation, from the public record",
)
.unwrap();
b.store
.set_vendor_1099(
b.harbor,
&from_beginning(Decision1099::Files(Form1099::Nec)),
Some(true),
)
.unwrap();
b.store
.set_vendor_1099(
b.harbor,
&Dated1099 {
effective: Some(d(2026, 12, 30)),
decision: corp.clone(),
source: Some(src()),
},
None,
)
.unwrap();
let card = b
.store
.add_instrument(
InstrumentKind::MemberCard,
"Riley's Visa",
Some("4242"),
None,
b.due,
)
.unwrap();
assert!(
b.store
.set_entry_instrument(b.reversal, Some(card))
.unwrap()
);
let chain = [b.paid as i64, b.reversal as i64];
let live = b.store.ledger().clone();
let v = live.get_vendor(b.harbor).unwrap();
assert!(v.tin_on_file);
assert_eq!(v.decisions_1099.len(), 2);
assert_eq!(v.decision_on(d(2026, 12, 31)), Some(&corp));
assert_eq!(live.entry_instrument(b.paid), Some(card));
drop(b);
let store = Store::open(&dir).unwrap();
assert!(live.differs_from(store.ledger()).is_empty());
store.verify().unwrap();
drop(store);
std::fs::remove_file(dir.join(DB_FILE)).unwrap();
let store = Store::open(&dir).unwrap();
store.verify().unwrap();
assert!(live.differs_from(store.ledger()).is_empty());
drop(store);
let snap = Snapshot::load(&dir).unwrap();
assert!(live.differs_from(snap.ledger()).is_empty());
let conn = rusqlite::Connection::open(dir.join(DB_FILE)).unwrap();
let decisions: Vec<DecisionRow> = conn
.prepare("SELECT effective, form_1099, reason_kind, source_json FROM vendor_1099_decisions ORDER BY seq")
.unwrap()
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))
.unwrap()
.collect::<Result<_, _>>()
.unwrap();
assert_eq!(decisions.len(), 2);
assert_eq!(
(decisions[0].0.as_deref(), decisions[0].1.as_deref()),
(None, Some("nec"))
);
assert_eq!(
(
decisions[1].0.as_deref(),
decisions[1].1.as_deref(),
decisions[1].2.as_deref()
),
(
Some("2026-12-30"),
Some("not_reportable"),
Some("corporation")
)
);
assert!(decisions[1].3.contains("Riley Chen"));
let (form, on_file): (String, bool) = conn
.query_row("SELECT form_1099, tin_on_file FROM vendors", [], |r| {
Ok((r.get(0)?, r.get(1)?))
})
.unwrap();
assert_eq!(
(form.as_str(), on_file),
("nec", true),
"the decision from the beginning"
);
let tagged: Vec<i64> = conn
.prepare("SELECT entry_id FROM entry_instruments ORDER BY entry_id")
.unwrap()
.query_map([], |r| r.get(0))
.unwrap()
.collect::<Result<_, _>>()
.unwrap();
assert_eq!(tagged, chain);
}
/// The engine-side half of verification item 13a (P3 review R14): a 1099
/// decision, a W-9 flag, a payment method and a payment-method tag change
/// no entry, no balance and no period.
#[test]
fn decision_and_method_records_change_no_entry() {
let dir = test_dir("no-entry");
let mut b = books(&dir);
let figures = |s: &Store| {
let l = s.ledger();
(
ledger_core::reports::snapshot(l).unwrap(),
l.posted_entries().cloned().collect::<Vec<_>>(),
l.list_periods().into_iter().cloned().collect::<Vec<_>>(),
)
};
let start = figures(&b.store);
b.store
.set_vendor_1099(
b.harbor,
&from_beginning(Decision1099::Files(Form1099::Misc)),
Some(false),
)
.unwrap();
assert_eq!(figures(&b.store), start);
b.store.set_vendor_w9_flag(b.harbor, true).unwrap();
assert_eq!(figures(&b.store), start);
let i = b
.store
.add_instrument(InstrumentKind::Platform, "Harbor Pay", None, None, b.due)
.unwrap();
assert_eq!(figures(&b.store), start);
b.store.set_entry_instrument(b.paid, Some(i)).unwrap();
assert_eq!(figures(&b.store), start);
b.store.set_entry_instrument(b.paid, None).unwrap();
assert_eq!(figures(&b.store), start);
}
/// A refused command journals nothing, not even a feature declaration;
/// one that changes nothing journals nothing either (P3 review R8).
#[test]
fn refusals_and_no_ops_journal_nothing() {
let dir = test_dir("refused");
let mut b = books(&dir);
let journal = || std::fs::read(dir.join(JOURNAL_FILE)).unwrap();
let at_start = journal();
let nec = Decision1099::Files(Form1099::Nec);
// No source.
let err = b
.store
.set_vendor_1099(
b.harbor,
&Dated1099::from_beginning(nec.clone(), None),
None,
)
.unwrap_err();
assert!(err.to_string().contains("decided_by"), "{err}");
// Dated with nothing decided before it.
let dated = Dated1099 {
effective: Some(d(2026, 7, 1)),
decision: nec.clone(),
source: Some(src()),
};
assert!(b.store.set_vendor_1099(b.harbor, &dated, None).is_err());
// A blank reason; an unknown vendor.
let blank = Dated1099::from_beginning(
Decision1099::NotReportable {
kind: ExemptReason::Other,
reason: " ".into(),
},
Some(src()),
);
assert!(b.store.set_vendor_1099(b.harbor, &blank, None).is_err());
assert!(
b.store
.set_vendor_1099(99, &from_beginning(nec.clone()), None)
.is_err()
);
assert!(b.store.set_vendor_w9_flag(99, true).is_err());
// A bad method, an unknown entry or method.
assert!(
b.store
.add_instrument(InstrumentKind::Bank, "Bank", Some("12345"), None, b.due)
.is_err()
);
assert!(b.store.set_entry_instrument(999, None).is_err());
assert!(b.store.set_entry_instrument(b.paid, Some(7)).is_err());
assert_eq!(journal(), at_start, "refusals left a trace");
// A flag already so, a tag already so: nothing.
assert!(!b.store.set_vendor_w9_flag(b.harbor, false).unwrap());
assert!(!b.store.set_entry_instrument(b.paid, None).unwrap());
assert_eq!(journal(), at_start);
// Recorded once; the same again: unchanged, nothing more.
assert_eq!(
b.store
.set_vendor_1099(b.harbor, &from_beginning(nec.clone()), None)
.unwrap(),
Applied::Set
);
let once = journal();
assert_eq!(
b.store
.set_vendor_1099(b.harbor, &from_beginning(nec.clone()), None)
.unwrap(),
Applied::Unchanged
);
assert_eq!(journal(), once);
// The same decision with a W-9 flag: only the flag is journaled.
assert_eq!(
b.store
.set_vendor_1099(b.harbor, &from_beginning(nec), Some(true))
.unwrap(),
Applied::Unchanged
);
let last = types(&dir).pop().unwrap();
assert_eq!(last.0, "vendor.w9_flag_set");
}
/// P3 review R8: marking a W-9 on file keeps the 1099 decision and who
/// made it, verbatim.
#[test]
fn marking_a_w9_keeps_who_decided_the_1099() {
let dir = test_dir("w9");
let mut b = books(&dir);
let decision = from_beginning(Decision1099::Files(Form1099::Nec));
b.store
.set_vendor_1099(b.harbor, &decision, Some(false))
.unwrap();
assert!(b.store.set_vendor_w9_flag(b.harbor, true).unwrap());
let v = b.store.ledger().get_vendor(b.harbor).unwrap();
assert!(v.tin_on_file);
assert_eq!(v.decisions_1099, [decision]);
}
/// B-27: a value only a newer version writes, in a line of a type this
/// version knows (a 1099 decision, a reason kind, a payment method's
/// kind), is newer, never corrupt; a malformed one is corrupt.
#[test]
fn an_unknown_1099_value_is_newer_not_corrupt() {
let source = json!({"decided_by": "Riley Chen", "decided_on": "2026-10-16"});
let cases = [
(
"vendor.1099_set",
json!({"vendor_id": 1, "form_1099": "1099-k", "tin_on_file": false, "source": source}),
Some("the 1099 decision \"1099-k\""),
),
(
"vendor.1099_set",
json!({"vendor_id": 1, "form_1099": "not_reportable", "reason_kind": "charity_lottery", "reason": "x", "tin_on_file": false, "source": source}),
Some("the 1099 reason kind \"charity_lottery\""),
),
(
"instrument.added",
json!({"instrument_id": 1, "kind": "crypto_wallet", "label": "x", "account_id": 1}),
Some("the payment method kind \"crypto_wallet\""),
),
(
"vendor.1099_set",
json!({"vendor_id": 1, "form_1099": "not_reportable", "reason_kind": "corporation", "tin_on_file": false, "source": source}),
None,
),
];
for (i, (t, payload, need)) in cases.into_iter().enumerate() {
let dir = test_dir(&format!("newer-{i}"));
let b = books(&dir);
drop(b);
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
w.append(
FORMAT_SET,
json!({"format": 2, "features": [PAYMENT_INSTRUMENTS, VENDOR_1099_DECISIONS]}),
)
.unwrap();
let seq = w.append(t, payload).unwrap().seq;
drop(w);
std::fs::remove_file(dir.join(DB_FILE)).unwrap();
match (Store::open(&dir), need) {
(
Err(StoreError::NewerJournal {
seq: s, need: n, ..
}),
Some(want),
) => {
assert_eq!((s, n.as_str()), (seq, want));
}
(Err(StoreError::JournalCorrupt(_)), None) => {}
(got, want) => panic!("case {i}: {:?} for {want:?}", got.err()),
}
}
}
/// Plan 5.5, D-40: books written before decisions were kept (lines with a
/// form or null and a W-9 flag, no source, no date) open unchanged, byte
/// for byte, with no feature declared: each old line reads as a decision
/// from the beginning (a null as undetermined, never "not reportable"),
/// and the projection is the one those books always had.
#[test]
fn books_written_before_decisions_read_as_they_always_meant() {
let dir = test_dir("legacy");
let b = books(&dir);
let (harbor, paid) = (b.harbor, b.paid);
drop(b);
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
for (vendor, name) in [(2u64, "Pinecrest Supply"), (3, "Ledgerline Bookkeeping")] {
w.append("vendor.added", json!({"id": vendor, "name": name}))
.unwrap();
}
for p in [
json!({"vendor_id": harbor, "form_1099": "nec", "tin_on_file": false}),
json!({"vendor_id": 2, "form_1099": null, "tin_on_file": false}),
// The old W-9 sync on an undecided vendor.
json!({"vendor_id": 3, "form_1099": null, "tin_on_file": true}),
] {
w.append("vendor.1099_set", p).unwrap();
}
drop(w);
std::fs::remove_file(dir.join(DB_FILE)).unwrap();
let journal = std::fs::read(dir.join(JOURNAL_FILE)).unwrap();
let store = Store::open(&dir).unwrap();
store.verify().unwrap();
let l = store.ledger();
let h = l.get_vendor(harbor).unwrap();
assert_eq!(
h.decisions_1099,
[Dated1099::from_beginning(
Decision1099::Files(Form1099::Nec),
None
)]
);
for v in [2, 3] {
assert_eq!(
l.get_vendor(v).unwrap().decisions_1099,
[Dated1099::from_beginning(Decision1099::Undetermined, None)]
);
}
assert!(l.get_vendor(3).unwrap().tin_on_file);
assert_eq!(l.entry_instrument(paid), None);
assert_eq!(store.journal_format().features, Vec::<String>::new());
drop(store);
assert_eq!(std::fs::read(dir.join(JOURNAL_FILE)).unwrap(), journal);
let conn = rusqlite::Connection::open(dir.join(DB_FILE)).unwrap();
let forms: Vec<(i64, Option<String>, bool)> = conn
.prepare("SELECT id, form_1099, tin_on_file FROM vendors ORDER BY id")
.unwrap()
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
.unwrap()
.collect::<Result<_, _>>()
.unwrap();
assert_eq!(
forms,
[
(1, Some("nec".to_string()), false),
(2, None, false),
(3, None, true)
]
);
let n: i64 = conn
.query_row("SELECT COUNT(*) FROM vendor_1099_decisions", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(n, 0, "nothing an old line didn't say");
}
/// P3 brief 8.2, compatibility: tests/fixtures/old-books/p2_vendor_1099,
/// written through the Store API of the version before P3 (a vendor
/// marked 1099-NEC, two marked "none" and "", an undecided one whose W-9
/// sync wrote a null; payments out of the bank 1000, the members' 2100 and
/// a card 2050), opens to its record: its journal byte for byte, no
/// feature declared, every null undetermined and "nec" a 1099-NEC from
/// the beginning that nobody is named for; and the 1099 figures, by hand,
/// for cash 1000 and financing 2100:
/// - 2026, threshold 2,000.00: Harbor Design LLC paid 2,500.00 from the
/// bank (counts), 700.00 by a member (undetermined) and 400.00 on the
/// card (no payment method names 2050: no payment line): reportable,
/// 2,500.00 counting whatever the 700.00 turns out. Bigco Supply,
/// undecided, 900.00 from the bank: under the threshold, a decision
/// needed. Cedar Studio and Dana Studio: nothing in 2026, not listed.
/// - 2025, threshold 600.00: Dana Studio, undecided, 650.00 by a member
/// with no method: undetermined (it could reach 600.00), W-9 on file;
/// Harbor Design LLC listed (decided for a 1099), nothing paid.
#[test]
fn books_written_under_p2_open_to_their_record() {
let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/old-books/p2_vendor_1099/journal.jsonl");
let dir = test_dir("p2-fixture");
std::fs::copy(&fixture, dir.join(JOURNAL_FILE)).unwrap();
let journal = std::fs::read(dir.join(JOURNAL_FILE)).unwrap();
let store = Store::open(&dir).unwrap();
store.verify().unwrap();
assert!(store.journal_format().features.is_empty());
let l = store.ledger();
let by_name = |n: &str| l.list_vendors().into_iter().find(|v| v.name == n).unwrap();
assert_eq!(
by_name("Harbor Design LLC").decisions_1099,
[Dated1099::from_beginning(
Decision1099::Files(Form1099::Nec),
None
)]
);
for n in ["Bigco Supply", "Cedar Studio", "Dana Studio"] {
assert_eq!(
by_name(n).decisions_1099,
[Dated1099::from_beginning(Decision1099::Undetermined, None)],
"{n}"
);
}
assert!(by_name("Dana Studio").tin_on_file);
let code = |c: &str| l.chart().find_by_code(c).unwrap().id;
let report = |year| {
ledger_core::vendor_1099_report(
l,
year,
None,
&ledger_core::PaymentAccounts {
cash: vec![code("1000")],
financing: vec![code("2100")],
},
&[],
None,
)
.unwrap()
};
let rows = |r: &ledger_core::Vendor1099Report| -> Vec<Row1099> {
r.vendors
.iter()
.map(|v| {
(
v.name.clone(),
v.paid,
v.undetermined,
v.excluded_card,
v.status.key(),
v.decision_needed,
)
})
.collect()
};
let c = |n: i64| Decimal::new(n, 2);
assert_eq!(
rows(&report(2026)),
[
(
"Bigco Supply".into(),
c(90_000),
c(0),
c(0),
"under_threshold",
true
),
(
"Harbor Design LLC".into(),
c(250_000),
c(70_000),
c(0),
"reportable",
false
),
]
);
assert_eq!(
rows(&report(2025)),
[
(
"Dana Studio".into(),
c(0),
c(65_000),
c(0),
"undetermined",
true
),
// Decided for a 1099 from the beginning: listed, nothing paid.
(
"Harbor Design LLC".into(),
c(0),
c(0),
c(0),
"under_threshold",
false
),
]
);
drop(store);
assert_eq!(std::fs::read(dir.join(JOURNAL_FILE)).unwrap(), journal);
}
+135 -35
View File
@@ -764,7 +764,7 @@ fn run_recurring_skips_occurrences_in_a_closed_period() {
#[test]
fn vendor_1099_tags_replay_and_project() {
use ledger_core::arap::DocumentLine;
use ledger_core::{Form1099, vendor_1099_report};
use ledger_core::{Dated1099, Decision1099, Form1099, PaymentAccounts, vendor_1099_report};
let dir = test_dir("form1099");
let mut store = Store::create(&dir, Currency::USD).unwrap();
let cash = store
@@ -774,10 +774,36 @@ fn vendor_1099_tags_replay_and_project() {
.add_account("6000", "Contract Labor", AccountType::Expense, None, false)
.unwrap();
let dana = store.add_vendor("Dana").unwrap();
store
.set_vendor_1099(dana, Some(Form1099::Nec), true)
.unwrap();
let d = |m, day| NaiveDate::from_ymd_opt(2026, m, day).unwrap();
store
.set_vendor_1099(
dana,
&Dated1099::from_beginning(
Decision1099::Files(Form1099::Nec),
Some(ledger_core::settings::Source {
decided_by: "Riley Chen".into(),
decided_on: d(1, 5),
citation: None,
note: None,
}),
),
Some(true),
)
.unwrap();
let report = |l: &ledger_core::Ledger| {
vendor_1099_report(
l,
2026,
None,
&PaymentAccounts {
cash: vec![cash],
financing: vec![],
},
&[],
None,
)
.unwrap()
};
let bill = store
.create_bill(
dana,
@@ -803,7 +829,7 @@ fn vendor_1099_tags_replay_and_project() {
store.post_entry(dup).unwrap();
store.set_entry_vendor(dup, Some(dana)).unwrap();
store.reverse_entry(dup, d(6, 3), "duplicate").unwrap();
let before = vendor_1099_report(store.ledger(), 2026, None).unwrap();
let before = report(store.ledger());
assert_eq!(before.vendors[0].paid, Decimal::from(2200));
assert!(before.vendors[0].reportable);
drop(store);
@@ -814,10 +840,7 @@ fn vendor_1099_tags_replay_and_project() {
std::fs::remove_file(dir.join(ledger_store::DB_FILE)).unwrap();
}
let store = Store::open(&dir).unwrap();
assert_eq!(
vendor_1099_report(store.ledger(), 2026, None).unwrap(),
before
);
assert_eq!(report(store.ledger()), before);
let conn = rusqlite::Connection::open(dir.join(ledger_store::DB_FILE)).unwrap();
let tagged: i64 = conn
.query_row(
@@ -832,6 +855,16 @@ fn vendor_1099_tags_replay_and_project() {
.query_row("SELECT form_1099 FROM vendors", [], |r| r.get(0))
.unwrap();
assert_eq!(form, "nec");
// The decision, with who made it, line by line.
let (decided, by): (String, String) = conn
.query_row(
"SELECT form_1099, source_json FROM vendor_1099_decisions",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(decided, "nec");
assert!(by.contains("Riley Chen"), "{by}");
}
}
@@ -972,7 +1005,14 @@ mod refusals {
AddClass(u8),
SetLineClass(u64, usize, Option<u32>),
SetEntryVendor(u64, Option<u64>),
SetVendor1099(u64, bool),
/// A 1099 decision (0 undetermined, 1 nec, 2 a corporation, 3 a
/// blank reason, 4 no source), dated or not, with a W-9 flag or
/// none.
SetVendor1099(u64, u8, Option<u32>, Option<bool>),
SetW9Flag(u64, bool),
/// A payment method: kind, label, account.
AddInstrument(u8, u8, u64),
SetEntryInstrument(u64, Option<u64>),
SetAccountTax(u64, u8),
Renumber(u64, u8),
// P3 (defects 8, 15): renames, accounts added with a category,
@@ -1051,7 +1091,16 @@ mod refusals {
(id.clone(), 0usize..4, prop::option::of(0u32..4))
.prop_map(|(e, i, c)| Op::SetLineClass(e, i, c)),
(id.clone(), prop::option::of(0u64..4)).prop_map(|(e, v)| Op::SetEntryVendor(e, v)),
(0u64..4, any::<bool>()).prop_map(|(v, t)| Op::SetVendor1099(v, t)),
(
0u64..4,
0u8..5,
prop::option::of(0u32..120),
prop::option::of(any::<bool>())
)
.prop_map(|(v, k, d, t)| Op::SetVendor1099(v, k, d, t)),
(0u64..4, any::<bool>()).prop_map(|(v, t)| Op::SetW9Flag(v, t)),
(0u8..6, 0u8..3, 0u64..12).prop_map(|(k, l, a)| Op::AddInstrument(k, l, a)),
(id.clone(), prop::option::of(0u64..4)).prop_map(|(e, i)| Op::SetEntryInstrument(e, i)),
(0u64..10, 0u8..4).prop_map(|(a, k)| Op::SetAccountTax(a, k)),
(0u64..10, 0u8..9).prop_map(|(a, c)| Op::Renumber(a, c)),
(0u64..12, 0u8..8).prop_map(|(a, n)| Op::Rename(a, n)),
@@ -1222,7 +1271,51 @@ mod refusals {
Op::AddClass(n) => store.add_class(["A", "B", ""][n as usize]).map(drop),
Op::SetLineClass(e, i, c) => store.set_line_class(e, i, c),
Op::SetEntryVendor(e, v) => store.set_entry_vendor(e, v),
Op::SetVendor1099(v, t) => store.set_vendor_1099(v, Some(Form1099::Nec), t),
Op::SetVendor1099(v, k, d, t) => {
let decision = match k {
0 => ledger_core::Decision1099::Undetermined,
1 => ledger_core::Decision1099::Files(Form1099::Nec),
2 => ledger_core::Decision1099::not_reportable(
ledger_core::ExemptReason::Corporation,
"a corporation by name",
)
.unwrap(),
_ => ledger_core::Decision1099::NotReportable {
kind: ledger_core::ExemptReason::Other,
reason: " ".into(),
},
};
let source = (k != 4).then(|| ledger_core::settings::Source {
decided_by: "Riley Chen".into(),
decided_on: day(0),
citation: None,
note: None,
});
let dated = ledger_core::Dated1099 {
effective: d.map(day),
decision,
source,
};
store.set_vendor_1099(v, &dated, t).map(drop)
}
Op::SetW9Flag(v, t) => store.set_vendor_w9_flag(v, t).map(drop),
Op::AddInstrument(k, l, a) => store
.add_instrument(
[
ledger_core::InstrumentKind::Bank,
ledger_core::InstrumentKind::BusinessCard,
ledger_core::InstrumentKind::MemberCard,
ledger_core::InstrumentKind::MemberCashOrCheck,
ledger_core::InstrumentKind::Platform,
ledger_core::InstrumentKind::Bank,
][k as usize],
["Harbor Bank", "Riley's Visa", ""][l as usize],
(k == 5).then_some("12345"),
None,
a,
)
.map(drop),
Op::SetEntryInstrument(e, i) => store.set_entry_instrument(e, i).map(drop),
Op::SetAccountTax(a, k) => store.set_account_tax(a, TAX[k as usize], None),
// The chart's codes, control codes (payroll's 6100 and 2110,
// revenue, receivables), a blank, and fresh ones.
@@ -1512,6 +1605,7 @@ mod refusals {
let journal = dir.join(ledger_store::JOURNAL_FILE);
for op in ops {
check_invariants(&store)?;
let ledger_before = store.ledger().clone();
let mut before = std::fs::read_to_string(&journal).unwrap();
let result = apply(&mut store, op, &mut || {
before = std::fs::read_to_string(&journal).unwrap();
@@ -1524,21 +1618,34 @@ mod refusals {
Err(e) => eprintln!("REFUSED {kind} {e}"),
}
}
// The converse law: an accepted command is on the record. Only
// a batch run may find nothing to do, and a setting changed to
// the value it has changes nothing.
if result.is_ok()
&& !matches!(
op,
Op::RunDepreciation(..) | Op::RunRecurring(..) | Op::SetTaxPolicy(..)
)
{
// The converse law: an accepted command is on the record, or
// changed nothing at all (a batch run with nothing due; a
// setting changed to the value it has; a 1099 decision, W-9
// flag or payment method already so).
if result.is_ok() {
let after = std::fs::read_to_string(&journal).unwrap();
prop_assert!(
after.len() > before.len(),
"accepted {:?} but journaled nothing",
op
);
if after.len() == before.len() {
let parts = ledger_before.differs_from(store.ledger());
prop_assert!(
parts.is_empty(),
"accepted {:?}, journaled nothing, but changed {:?}",
op,
parts
);
prop_assert!(
matches!(
op,
Op::RunDepreciation(..)
| Op::RunRecurring(..)
| Op::SetTaxPolicy(..)
| Op::SetVendor1099(..)
| Op::SetW9Flag(..)
| Op::SetEntryInstrument(..)
),
"accepted {:?} but journaled nothing",
op
);
}
}
if let Err(e) = result {
let after = std::fs::read_to_string(&journal).unwrap();
@@ -1929,15 +2036,8 @@ fn every_figure_of(l: &ledger_core::Ledger) -> (usize, usize) {
financing: vec![],
};
tally(
ledger_core::form1099::vendor_1099_report_with_members(
l,
year,
None,
None,
&sources,
&[],
)
.map(|_| ()),
ledger_core::form1099::vendor_1099_report(l, year, None, &sources, &[], None)
.map(|_| ()),
);
}
let money = ledger_core::parties::money_accounts(l, &cash, &[]);