diff --git a/bookspec/README.md b/bookspec/README.md index f1b760c..70b2ea6 100644 --- a/bookspec/README.md +++ b/bookspec/README.md @@ -103,9 +103,9 @@ the side gives the direction. - `cash_accounts`, `financing_accounts`, `investing_accounts` (in `[setup]`): arrays of account codes, the statement of cash flows' account roles (ASC 230-10-45-10 to 45-15). The cash and financing - accounts are also what a payee is paid from, for `vendor_1099_paid` (a - payment card is neither: its charges go on Form 1099-K; rules.md B-19); - with the cards, what party money is, for + accounts are also what a payee is paid from, for `vendor_1099_paid` and + `vendor_1099` (a payment card is neither: its charges go on Form + 1099-K; rules.md B-19); with the cards, what party money is, for `party_part` and `party_totals` (B-35 item 5: no `cash_accounts`, every asset account but receivables); the cash accounts are what the K-1 worksheet reads as money (`k1`) and an IIF file's bank accounts (B-34). @@ -138,6 +138,20 @@ the side gives the direction. return from the beginning, `form` `"1099-NEC"` or `"1099-MISC"`; each name once. A payee in `setup.parties` with the `vendor` role is that party's vendor record; any other is a vendor record of its own. +- `[[setup.status_changes]]` `{ payee, effective, decision, reason_kind?, + reason? }`: a payee's 1099 decision from `effective` (`YYYY-MM-DD`) on + (rules.md B-19, D-40: an LLC's corporate election, say): `decision` + `"1099-NEC"`, `"1099-MISC"`, `"undetermined"` or `"not_reportable"`, + the last with `reason_kind` (`corporation`, `tax_exempt`, + `united_states`, `state`, `foreign_government`, `foreign_person`, + `merchandise`, `employee`, `other`) and the facts in words (`reason`). + Each payment takes the decision in force on its date. +- `[[setup.instruments]]` `{ label, kind, account, last4?, owner? }`: a + payment method (B-19): `kind` `bank`, `business_card`, `member_card`, + `member_cash_or_check` or `platform`; `account` the code it pays + through; `owner` a party's name (informational). A business card's + liability is a card account (B-35 item 5): while none is recorded, a + liability whose name says "card", not "gift card", is. - `payee` on an event: the payee (by name, one of `setup.payees`) the entry paid. Not on a reversal, which takes its original's, nor on a close. @@ -148,7 +162,9 @@ the side gives the direction. capital lines or income shares for several: B-35). An entry naming a party with a vendor record is tagged with that payee too; with `payee` as well, the payee's party is one of the parties. - Not on a reversal, which takes its original's. +- `instrument` on an event: the payment method (a `setup.instruments` + label) the entry was paid with. Neither `parties` nor `instrument` on a + reversal, which takes its original's. - `close = { start, to, rounding? }` as an event: closes the period from `start` through the event's `date` (rules.md B-11, B-13), `to` `"members"` (split by share, in the order listed) or @@ -174,14 +190,22 @@ the side gives the direction. the changes made after it), not after the last event and every change (1 to the number of events; not on a format query). +Party totals and the 1099 worksheet answer different questions (B-35 +item 9): a member paying a contractor through the members' financing +account is, in party totals, paid to the contractor (money, the +members' financing being money) and nothing to the member until the +company reimburses them; on the 1099 worksheet it is the company's +payment, counted toward the contractor's 1099 unless it was made by card +(B-19), and undetermined until its payment method is recorded. + ### Queries and their values A query over a period takes `from` and `to`; a query as of a day takes `as_of`; the other is refused. A date left out is resolved by the one rule (rules.md B-30) on `setup.today`: an omitted `to` is today, an omitted `from` the first day of the fiscal year that holds `to`, an -omitted `as_of` today (`vendor_1099_paid`, `party_totals` and the -format queries give theirs). A query no crate +omitted `as_of` today (`vendor_1099_paid`, `vendor_1099`, +`party_totals` and the format queries give theirs). A query no crate answers is refused, never skipped. | query | dates | values | checked by | @@ -194,6 +218,7 @@ answers is refused, never skipped. | `members_equity` | `from`, `to` | the statement of changes in members' equity (rules.md B-13): `".beginning"`, `.contributions`, `.net_income`, `.distributions`, `.other`, `.ending`; the example names the figures it shows | `ledger-core` `tests/bookspec.rs` | | `k1` | `from`, `to` | the K-1 worksheet (B-13; Schedule K-1 Item L): `".beginning"`, `.contributions_cash`, `.contributions_property`, `.net_income`, `.distributions`, `.other`, `.ending`; the example names the figures it shows (`cash_accounts` required) | `ledger-core` `tests/bookspec.rs` | | `vendor_1099_paid` | `from` (January 1 of `to`'s year), `to` | each payee's payments that count toward its 1099, by name: out of the cash and financing accounts (`cash_accounts` required), a card's or payment network's left out (B-19), every payee listed | `ledger-core` `tests/bookspec.rs` | +| `vendor_1099` | `payee`; `from` (January 1 of `to`'s year), `to` | one payee's line of the 1099 worksheet (B-19): `status` (`reportable`, `under_threshold`, `not_reportable` or `undetermined`), `threshold`, `paid` (counts toward its 1099), `excluded_card` (a card's or payment network's: Form 1099-K), `undetermined` (through a financing account, its payment method not recorded), `not_reportable` (paid while decided not reportable), every one given | `ledger-core` `tests/bookspec.rs` | | `party_part` | `entry = N`, `party`, no dates | entry N's part for the party (B-35): `resolved`, signed as a line on the party's own account would be (paid or given positive, received negative), the entry's `direction` (`paid`, `received` or `neither`), and what of it moved as money (`cash`) and as property (`in_kind`), every one given | `ledger-core` `tests/bookspec.rs` | | `party_totals` | `party`, `basis` (`cash` or `all`), `from`, `to` | what was `paid` to the party and `received` from it over the period (B-35 item 4: basis `cash` money, `all` money and property in kind; each entry on its first original's side), both given | `ledger-core` `tests/bookspec.rs` | | `party_tin_last4` | `party`, no dates | `tin_last4`: the party's TIN facts' last four, else its W-9's, `""` for none (B-26) | `ledger-core` `tests/bookspec.rs` | @@ -208,8 +233,8 @@ answers is refused, never skipped. A figure query is a table (`[expect.values]`, or `values = { ... }`); a format query, and `uncategorized_accounts`, an array (`values = [ ... ]`). `period`, `day` and `calendar_year` give dates and a year, -`account_names` names, `party_part`'s `direction` and -`party_tin_last4` words, not figures. +`account_names` names, `party_part`'s `direction`, `vendor_1099`'s +`status` and `party_tin_last4` words, not figures. A command is tried on a copy of the books, as the books' own command does it, and changes nothing checked after it. It shows no values: it is diff --git a/bookspec/examples/1099-a-dated-status-applies-by-payment-date.toml b/bookspec/examples/1099-a-dated-status-applies-by-payment-date.toml new file mode 100644 index 0000000..afed987 --- /dev/null +++ b/bookspec/examples/1099-a-dated-status-applies-by-payment-date.toml @@ -0,0 +1,94 @@ +id = "1099-a-dated-status-applies-by-payment-date" +title = "A payee's 1099 status applies to each payment as of the payment's date" +rule = """ +Whether a payee is exempt (a corporation) is judged as of each payment: the +payer may treat a payee as a corporation when the condition is met before +the payment is made. An LLC that elects to be taxed as a corporation keeps +its EIN and becomes a corporation on the election's effective date (the +date its Form 8832 specifies). Payments before that date count toward its +1099-NEC; payments from it on are payments to a corporation, for which no +return is required. (A payee that incorporates is a new legal person: a new +payee, with its own W-9.) +""" +status = "AUTHORITY" +sources = [ + "Treas. Reg. §1.6041-3(p)(1) (docs/spec/sources/irs-1099-payment-method.md)", + "Treas. Reg. §1.6049-4(c)(1)(ii)(A) (\"before a payment is made\")", + "Treas. Reg. §301.7701-2(b)(2), §301.7701-3(c)(1)(iii)", + "DECISION D-40 (wanderbooks owner, 2026-10-20): dated statuses", +] +tags = ["1099", "dated-status", "corporation", "election"] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "cash" +rounding = "half-away-from-zero" +cash_accounts = ["1000"] + +[[setup.accounts]] +code = "1000" +name = "Bank" +type = "asset" + +[[setup.accounts]] +code = "6000" +name = "Contract Labor" +type = "expense" + +[[setup.payees]] +name = "Harbor Design LLC" +form = "1099-NEC" + +[[setup.status_changes]] +payee = "Harbor Design LLC" +effective = "2026-07-01" +decision = "not_reportable" +reason_kind = "corporation" +reason = "elected to be taxed as a corporation (Form 8832), effective July 1" + +[[events]] +date = "2026-03-01" +description = "Design work" +payee = "Harbor Design LLC" +lines = [ + { account = "6000", side = "debit", amount = "1500.00" }, + { account = "1000", side = "credit", amount = "1500.00" }, +] + +[[events]] +date = "2026-06-30" +description = "Design work, the day before the election" +payee = "Harbor Design LLC" +lines = [ + { account = "6000", side = "debit", amount = "1000.00" }, + { account = "1000", side = "credit", amount = "1000.00" }, +] + +[[events]] +date = "2026-07-01" +description = "Design work, the election's day" +payee = "Harbor Design LLC" +lines = [ + { account = "6000", side = "debit", amount = "800.00" }, + { account = "1000", side = "credit", amount = "800.00" }, +] + +[[events]] +date = "2026-09-15" +description = "Design work" +payee = "Harbor Design LLC" +lines = [ + { account = "6000", side = "debit", amount = "1200.00" }, + { account = "1000", side = "credit", amount = "1200.00" }, +] + +# By hand: before July 1, 1,500.00 + 1,000.00 = 2,500.00 counts (at least +# 2,000.00: reportable); from July 1 on, 800.00 + 1,200.00 = 2,000.00 paid +# to a corporation, not reportable. +[[expect]] +query = "vendor_1099" +payee = "Harbor Design LLC" +from = "2026-01-01" +to = "2026-12-31" +values = { status = "reportable", threshold = "2000.00", paid = "2500.00", excluded_card = "0.00", undetermined = "0.00", not_reportable = "2000.00" } diff --git a/bookspec/examples/1099-a-members-check-through-financing-counts.toml b/bookspec/examples/1099-a-members-check-through-financing-counts.toml new file mode 100644 index 0000000..224383a --- /dev/null +++ b/bookspec/examples/1099-a-members-check-through-financing-counts.toml @@ -0,0 +1,99 @@ +id = "1099-a-members-check-through-financing-counts" +title = "A member's own check for the company counts toward the payee's 1099; their card doesn't; unknown stays unknown" +rule = """ +The payer is the person who makes the payment in the course of its trade or +business. A person paying on another's behalf is the payer only if it +performs management or oversight functions or has a significant economic +interest; writing checks at another's direction is neither. A member who +pays a company bill from their own funds at the company's direction, the +company bearing the cost through its liability to the member, leaves the +company the payer: the member's check counts toward the payee's 1099. The +member's personal card is a payment card: excluded (Form 1099-K). A +member's payment whose method isn't recorded is undetermined, never +guessed; the payee is reportable only if what certainly counts reaches the +threshold. +""" +status = "AUTHORITY" +sources = [ + "Treas. Reg. §1.6041-1(a)(1)(i), (e)(1) (docs/spec/sources/irs-1099-payment-method.md)", + "Treas. Reg. §1.6041-1(a)(1)(iv)", + "26 U.S.C. §6041(a), as amended by Pub. L. 119-21 §70433 (docs/spec/sources/irs-1099-thresholds.md)", +] +tags = ["1099", "financing", "member-paid", "instruments"] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "cash" +rounding = "half-away-from-zero" +cash_accounts = ["1000"] +financing_accounts = ["2100"] + +[[setup.accounts]] +code = "1000" +name = "Bank" +type = "asset" + +[[setup.accounts]] +code = "2100" +name = "Due to Members" +type = "liability" + +[[setup.accounts]] +code = "6000" +name = "Contract Labor" +type = "expense" + +[[setup.instruments]] +label = "Riley's checks" +kind = "member_cash_or_check" +account = "2100" + +[[setup.instruments]] +label = "Riley's Visa" +kind = "member_card" +account = "2100" + +[[setup.payees]] +name = "Dana Studio" +form = "1099-NEC" + +[[events]] +date = "2026-05-01" +description = "Logo, paid by a member's personal check" +payee = "Dana Studio" +instrument = "Riley's checks" +lines = [ + { account = "6000", side = "debit", amount = "2500.00" }, + { account = "2100", side = "credit", amount = "2500.00" }, +] + +[[events]] +date = "2026-06-01" +description = "Icons, charged to a member's personal card" +payee = "Dana Studio" +instrument = "Riley's Visa" +lines = [ + { account = "6000", side = "debit", amount = "800.00" }, + { account = "2100", side = "credit", amount = "800.00" }, +] + +[[events]] +date = "2026-07-01" +description = "Revisions, paid by a member (how not recorded)" +payee = "Dana Studio" +lines = [ + { account = "6000", side = "debit", amount = "300.00" }, + { account = "2100", side = "credit", amount = "300.00" }, +] + +# By hand: counts 2,500.00 (the member's check); excluded 800.00 (their +# card); undetermined 300.00. What certainly counts, 2,500.00, is at least +# 2,000.00 however the 300.00 turns out: reportable, on 2,500.00 (or +# 2,800.00 once the 300.00 is known to be a check). +[[expect]] +query = "vendor_1099" +payee = "Dana Studio" +from = "2026-01-01" +to = "2026-12-31" +values = { status = "reportable", threshold = "2000.00", paid = "2500.00", excluded_card = "800.00", undetermined = "300.00", not_reportable = "0.00" } diff --git a/bookspec/examples/1099-and-party-totals-for-a-members-payment.toml b/bookspec/examples/1099-and-party-totals-for-a-members-payment.toml new file mode 100644 index 0000000..2a4b101 --- /dev/null +++ b/bookspec/examples/1099-and-party-totals-for-a-members-payment.toml @@ -0,0 +1,219 @@ +# How a payee's party totals and its 1099 amount relate (wanderbooks +# rules.md B-35 item 9, B-19): a member paying a contractor for the +# company through the members' financing account, by check, by their own +# card and with no method recorded; a company card charge; a company +# check; then the member reimbursed. Party totals count every payment as +# paid to the contractor, in money; the 1099 worksheet counts the +# company's payments that aren't by card, and holds the unrecorded one +# undetermined. The shape: bookspec/README.md. + +id = "1099-and-party-totals-for-a-members-payment" +title = "A payee's party total is its 1099 amount plus its card payments plus its payments of unknown method" +rule = """ +A member who pays a payee for the company from their own funds, the +company owing them for it through its financing account, leaves the +company the payer: on the 1099 worksheet the member's own cash or check +counts toward the payee's 1099, the member's own card is excluded (Form +1099-K), and a payment whose method isn't recorded is undetermined. Party +totals report what moved with each party, whoever handled the money: every +one of those payments is paid to the payee, in money, on its date, and the +member is paid when the company reimburses them. So the payee's party +total paid is its 1099 amount plus what was paid by card or payment +network plus what is undetermined; the reimbursement is paid to the +member and pays no payee. +""" +status = "CHOICE" +sources = [ + "Treas. Reg. §1.6041-1(e)(1): a person paying on another's behalf who only writes the check at its direction isn't the payor (wanderbooks docs/spec/sources/irs-1099-payment-method.md)", + "Treas. Reg. §1.6041-1(a)(1)(iv): a payment card transaction is reported under §6050W, not §6041 (docs/spec/sources/irs-1099-payment-method.md)", + "DECISION (owner, 2026-10-20): money is cash, cards, the members' financing and loans (wanderbooks rules.md B-35 item 5)", + "DECISION D-40 (owner, 2026-10-20): payment methods; an untagged payment through financing is undetermined (rules.md B-19)", +] +tags = ["1099", "parties", "member-paid", "instruments", "cards"] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "cash" +rounding = "half-away-from-zero" +cash_accounts = ["1000"] +financing_accounts = ["2100"] + +[[setup.accounts]] +code = "1000" +name = "Checking" +type = "asset" + +[[setup.accounts]] +code = "2050" +name = "Company Visa" +type = "liability" + +[[setup.accounts]] +code = "2100" +name = "Due to Members" +type = "liability" + +[[setup.accounts]] +code = "3000" +name = "Capital — Tobin Harlow" +type = "equity" + +[[setup.accounts]] +code = "3001" +name = "Capital — Morgan Ellis" +type = "equity" + +[[setup.accounts]] +code = "6000" +name = "Contract Labor" +type = "expense" + +[[setup.parties]] +name = "Tobin Harlow" +roles = ["member"] + +[[setup.parties]] +name = "Morgan Ellis" +roles = ["member"] + +[[setup.parties]] +name = "Dana Studio" +roles = ["vendor", "contractor"] + +[[setup.members]] +name = "Tobin Harlow" +capital_account = "3000" +share = "0.5" + +[[setup.members]] +name = "Morgan Ellis" +capital_account = "3001" +share = "0.5" + +# The contractor's vendor record, marked for a 1099-NEC. +[[setup.payees]] +name = "Dana Studio" +form = "1099-NEC" + +[[setup.instruments]] +label = "Company Visa" +kind = "business_card" +account = "2050" + +[[setup.instruments]] +label = "Tobin's checks" +kind = "member_cash_or_check" +account = "2100" +owner = "Tobin Harlow" + +[[setup.instruments]] +label = "Tobin's Visa" +kind = "member_card" +account = "2100" +owner = "Tobin Harlow" + +# 1. Tobin pays the contractor by personal check. +[[events]] +date = "2026-03-02" +description = "Design work, paid by Tobin's check" +parties = [{ party = "Dana Studio" }] +instrument = "Tobin's checks" +lines = [ + { account = "6000", side = "debit", amount = "1500.00" }, + { account = "2100", side = "credit", amount = "1500.00" }, +] + +# 2. Tobin pays on his own card. +[[events]] +date = "2026-04-01" +description = "Icons, charged to Tobin's own card" +parties = [{ party = "Dana Studio" }] +instrument = "Tobin's Visa" +lines = [ + { account = "6000", side = "debit", amount = "400.00" }, + { account = "2100", side = "credit", amount = "400.00" }, +] + +# 3. Tobin pays; how is not recorded. +[[events]] +date = "2026-05-01" +description = "Revisions, paid by Tobin" +parties = [{ party = "Dana Studio" }] +lines = [ + { account = "6000", side = "debit", amount = "300.00" }, + { account = "2100", side = "credit", amount = "300.00" }, +] + +# 4. Charged to the company card. +[[events]] +date = "2026-06-01" +description = "Banner, on the company card" +parties = [{ party = "Dana Studio" }] +lines = [ + { account = "6000", side = "debit", amount = "200.00" }, + { account = "2050", side = "credit", amount = "200.00" }, +] + +# 5. The company's own check. +[[events]] +date = "2026-06-15" +description = "Brochure, company check" +parties = [{ party = "Dana Studio" }] +lines = [ + { account = "6000", side = "debit", amount = "600.00" }, + { account = "1000", side = "credit", amount = "600.00" }, +] + +# 6. The company reimburses Tobin for events 1 to 3. +[[events]] +date = "2026-07-01" +description = "Tobin reimbursed" +parties = [{ party = "Tobin Harlow" }] +lines = [ + { account = "2100", side = "debit", amount = "2200.00" }, + { account = "1000", side = "credit", amount = "2200.00" }, +] + +[[expect]] +query = "party_part" +entry = 2 +party = "Dana Studio" +values = { resolved = "400.00", direction = "paid", cash = "400.00", in_kind = "0.00" } + +# By hand: 1,500.00 + 400.00 + 300.00 + 200.00 + 600.00 = 3,000.00, every +# payment paid to the contractor, in money. +[[expect]] +query = "party_totals" +party = "Dana Studio" +basis = "cash" +from = "2026-01-01" +to = "2026-12-31" +values = { paid = "3000.00", received = "0.00" } + +# By hand: counts 1,500.00 (Tobin's check) + 600.00 (the company's check) +# = 2,100.00; excluded 400.00 (Tobin's card) + 200.00 (the company card) +# = 600.00; undetermined 300.00. 2,100.00 + 600.00 + 300.00 = 3,000.00, +# the party total. What certainly counts, 2,100.00, is at least 2,000.00: +# reportable. +[[expect]] +query = "vendor_1099" +payee = "Dana Studio" +from = "2026-01-01" +to = "2026-12-31" +values = { status = "reportable", threshold = "2000.00", paid = "2100.00", excluded_card = "600.00", undetermined = "300.00", not_reportable = "0.00" } + +# The reimbursement is paid to Tobin, and pays no payee. +[[expect]] +query = "party_totals" +party = "Tobin Harlow" +basis = "cash" +from = "2026-01-01" +to = "2026-12-31" +values = { paid = "2200.00", received = "0.00" } + +[[expect]] +query = "vendor_1099_paid" +from = "2026-01-01" +to = "2026-12-31" +values = { "Dana Studio" = "2100.00" } diff --git a/bookspec/examples/1099-card-payments-are-excluded.toml b/bookspec/examples/1099-card-payments-are-excluded.toml new file mode 100644 index 0000000..0c05693 --- /dev/null +++ b/bookspec/examples/1099-card-payments-are-excluded.toml @@ -0,0 +1,101 @@ +id = "1099-card-payments-are-excluded" +title = "Payments by card are left off Form 1099-NEC, and listed" +rule = """ +A payment made by payment card (credit, debit or stored-value) or through a +third party payment network that section 6041 would otherwise cover is +reported by the payment settlement entity on Form 1099-K under section 6050W, +not by the payer on Form 1099-NEC or 1099-MISC, whoever's card it was. A +payment by check, ACH or transfer counts toward the payee's 1099-NEC. A +bank payment with no payment method recorded is presumed a check, ACH or +transfer; a debit-card payment from the bank is excluded once tagged with +the card. +""" +status = "AUTHORITY" +sources = [ + "Treas. Reg. §1.6041-1(a)(1)(iv) (docs/spec/sources/irs-1099-payment-method.md)", + "Treas. Reg. §1.6050W-1(b)(1), (b)(3) (payment card; accepted as payment)", + "IRS, Understanding your Form 1099-K (credit, debit or stored value cards)", + "Instructions for Forms 1099-MISC and 1099-NEC (Rev. 12/2026), Form 1099-K", + "DECISION D-40 (wanderbooks owner, 2026-10-20): an untagged bank payment counts", +] +tags = ["1099", "payment-card", "instruments"] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "cash" +rounding = "half-away-from-zero" +cash_accounts = ["1000"] +financing_accounts = ["2100"] + +[[setup.accounts]] +code = "1000" +name = "Bank" +type = "asset" + +[[setup.accounts]] +code = "2050" +name = "Company card" +type = "liability" + +[[setup.accounts]] +code = "2100" +name = "Due to Members" +type = "liability" + +[[setup.accounts]] +code = "6000" +name = "Contract Labor" +type = "expense" + +[[setup.instruments]] +label = "Harbor Visa" +kind = "business_card" +account = "2050" + +[[setup.instruments]] +label = "Harbor debit card" +kind = "business_card" +account = "1000" + +[[setup.payees]] +name = "Harbor Design LLC" +form = "1099-NEC" + +[[events]] +date = "2026-02-10" +description = "Design work, charged to the company card" +payee = "Harbor Design LLC" +lines = [ + { account = "6000", side = "debit", amount = "2500.00" }, + { account = "2050", side = "credit", amount = "2500.00" }, +] + +[[events]] +date = "2026-03-10" +description = "Design work, paid with the debit card" +payee = "Harbor Design LLC" +instrument = "Harbor debit card" +lines = [ + { account = "6000", side = "debit", amount = "1200.00" }, + { account = "1000", side = "credit", amount = "1200.00" }, +] + +[[events]] +date = "2026-04-10" +description = "Design work, paid by check" +payee = "Harbor Design LLC" +lines = [ + { account = "6000", side = "debit", amount = "900.00" }, + { account = "1000", side = "credit", amount = "900.00" }, +] + +# By hand: counts 900.00 (the check); excluded 2,500.00 + 1,200.00 = +# 3,700.00 (the card's account, and the bank payment tagged with the debit +# card); 900.00 < 2,000.00: no 1099 is due. +[[expect]] +query = "vendor_1099" +payee = "Harbor Design LLC" +from = "2026-01-01" +to = "2026-12-31" +values = { status = "under_threshold", threshold = "2000.00", paid = "900.00", excluded_card = "3700.00", undetermined = "0.00", not_reportable = "0.00" } diff --git a/bookspec/examples/1099-money-between-own-accounts-pays-no-one.toml b/bookspec/examples/1099-money-between-own-accounts-pays-no-one.toml new file mode 100644 index 0000000..c4595a9 --- /dev/null +++ b/bookspec/examples/1099-money-between-own-accounts-pays-no-one.toml @@ -0,0 +1,92 @@ +id = "1099-money-between-own-accounts-pays-no-one" +title = "Paying the card bill, reimbursing a member, or paying down a loan pays no payee" +rule = """ +A 1099 reports payments made to the payee. Money moved between the payer's +own payment sources (its bank paying its card's balance, reimbursing a +member who paid for it, paying down a loan's principal) pays no payee, even +when the entry is tagged with the card issuer, the member's payee record or +the lender: the movement out of one source is offset by the movement into +the other. +""" +status = "CONVENTION" +sources = [ + "26 U.S.C. §6041(a) (payments made to the payee: docs/spec/sources/irs-1099-thresholds.md)", + "wanderbooks P3 review R1 (2026-10-20): netting an entry's payment buckets", +] +tags = ["1099", "netting"] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "cash" +rounding = "half-away-from-zero" +cash_accounts = ["1000"] +financing_accounts = ["2100", "2500"] + +[[setup.accounts]] +code = "1000" +name = "Bank" +type = "asset" + +[[setup.accounts]] +code = "2050" +name = "Company card" +type = "liability" + +[[setup.accounts]] +code = "2100" +name = "Due to Members" +type = "liability" + +[[setup.accounts]] +code = "2500" +name = "Equipment loan" +type = "liability" + +[[setup.instruments]] +label = "Harbor Visa" +kind = "business_card" +account = "2050" + +[[setup.payees]] +name = "Harbor Card Services" +form = "1099-NEC" + +[[setup.payees]] +name = "Harbor Equipment Finance" +form = "1099-NEC" + +[[events]] +date = "2026-05-25" +description = "Card bill paid from the bank" +payee = "Harbor Card Services" +lines = [ + { account = "2050", side = "debit", amount = "1200.00" }, + { account = "1000", side = "credit", amount = "1200.00" }, +] + +[[events]] +date = "2026-06-01" +description = "Loan principal" +payee = "Harbor Equipment Finance" +lines = [ + { account = "2500", side = "debit", amount = "3000.00" }, + { account = "1000", side = "credit", amount = "3000.00" }, +] + +# By hand: the bill: +1,200.00 out of the bank, −1,200.00 into the card's +# account: nets to 0.00 in every bucket. The loan: +3,000.00 out of the +# bank, −3,000.00 into the loan (a financing account): 0.00. +[[expect]] +query = "vendor_1099" +payee = "Harbor Card Services" +from = "2026-01-01" +to = "2026-12-31" +values = { status = "under_threshold", threshold = "2000.00", paid = "0.00", excluded_card = "0.00", undetermined = "0.00", not_reportable = "0.00" } + +[[expect]] +query = "vendor_1099" +payee = "Harbor Equipment Finance" +from = "2026-01-01" +to = "2026-12-31" +values = { status = "under_threshold", threshold = "2000.00", paid = "0.00", excluded_card = "0.00", undetermined = "0.00", not_reportable = "0.00" } diff --git a/bookspec/examples/1099-money-from-a-payee-is-netted-only-as-a-refund.toml b/bookspec/examples/1099-money-from-a-payee-is-netted-only-as-a-refund.toml new file mode 100644 index 0000000..c084509 --- /dev/null +++ b/bookspec/examples/1099-money-from-a-payee-is-netted-only-as-a-refund.toml @@ -0,0 +1,108 @@ +id = "1099-money-from-a-payee-is-netted-only-as-a-refund" +title = "Money received from a payee lowers its 1099 only when it refunds what was paid" +rule = """ +A 1099 reports the payments the payer made to the payee in the calendar +year. Money the payee gives back of a payment (a refund crediting the +expense, or a deposit returned) reduces what was paid. Money received from +the payee for anything else (its payment of the payer's invoice, a sale to +it, an asset disposed of to it, a loan from it) is not a refund of a +payment and is not netted against the payments; it is listed beside them. +""" +status = "CONVENTION" +sources = [ + "26 U.S.C. §6041(a) (payments made in the course of the trade or business: docs/spec/sources/irs-1099-thresholds.md)", + "wanderbooks P3 review R6 (2026-10-20): a sale to a payee doesn't lower its 1099", +] +tags = ["1099", "netting", "refund"] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "cash" +rounding = "half-away-from-zero" +cash_accounts = ["1000"] + +[[setup.accounts]] +code = "1000" +name = "Bank" +type = "asset" + +[[setup.accounts]] +code = "1200" +name = "Accounts Receivable" +type = "asset" + +[[setup.accounts]] +code = "1400" +name = "Deposits" +type = "asset" + +[[setup.accounts]] +code = "2600" +name = "Loan payable" +type = "liability" + +[[setup.accounts]] +code = "6000" +name = "Contract Labor" +type = "expense" + +[[setup.payees]] +name = "Dana Studio" +form = "1099-NEC" + +[[events]] +date = "2026-02-01" +description = "Design work" +payee = "Dana Studio" +lines = [ + { account = "6000", side = "debit", amount = "3000.00" }, + { account = "1000", side = "credit", amount = "3000.00" }, +] + +[[events]] +date = "2026-03-01" +description = "Dana Studio pays the company's invoice" +payee = "Dana Studio" +lines = [ + { account = "1000", side = "debit", amount = "450.00" }, + { account = "1200", side = "credit", amount = "450.00" }, +] + +[[events]] +date = "2026-04-01" +description = "Dana Studio lends the company money" +payee = "Dana Studio" +lines = [ + { account = "1000", side = "debit", amount = "1000.00" }, + { account = "2600", side = "credit", amount = "1000.00" }, +] + +[[events]] +date = "2026-05-01" +description = "Deposit for the next project" +payee = "Dana Studio" +lines = [ + { account = "1400", side = "debit", amount = "500.00" }, + { account = "1000", side = "credit", amount = "500.00" }, +] + +[[events]] +date = "2026-06-01" +description = "Deposit returned: the project was cancelled" +payee = "Dana Studio" +lines = [ + { account = "1000", side = "debit", amount = "500.00" }, + { account = "1400", side = "credit", amount = "500.00" }, +] + +# By hand: paid 3,000.00 + 500.00 (the deposit, paid when made) − 500.00 +# (its return, a refund) = 3,000.00: reportable. The invoice payment +# (450.00) and the loan (1,000.00) are listed, not netted (netting them +# would leave 1,550.00, under the threshold). +[[expect]] +query = "vendor_1099" +payee = "Dana Studio" +from = "2026-01-01" +to = "2026-12-31" +values = { status = "reportable", threshold = "2000.00", paid = "3000.00", excluded_card = "0.00", undetermined = "0.00", not_reportable = "0.00" } diff --git a/bookspec/examples/1099-the-threshold-is-the-years.toml b/bookspec/examples/1099-the-threshold-is-the-years.toml new file mode 100644 index 0000000..10b12a4 --- /dev/null +++ b/bookspec/examples/1099-the-threshold-is-the-years.toml @@ -0,0 +1,92 @@ +id = "1099-the-threshold-is-the-years" +title = "The 1099-NEC threshold is the one in force for the year the payments were made" +rule = """ +A payer reports payments to a payee aggregating the threshold or more in a +calendar year: $600 for payments made through December 31, 2025, and +$2,000 ("of $2,000 or more") for payments made after it (indexed for +inflation for calendar years after 2026, not yet known: never guessed). The +threshold is met at exactly the amount. +""" +status = "AUTHORITY" +sources = [ + "26 U.S.C. §6041(a), (h), §6041A(a)(2) (docs/spec/sources/irs-1099-thresholds.md)", + "Pub. L. 119-21 §70433(a), (f): \"shall apply with respect to payments made after December 31, 2025\" (docs/spec/sources/irs-1099-payment-method.md)", + "Instructions for Forms 1099-MISC and 1099-NEC (Rev. April 2025): $600", +] +tags = ["1099", "threshold"] + +[setup] +currency = "USD" +fiscal_year_start = "01-01" +basis = "cash" +rounding = "half-away-from-zero" +cash_accounts = ["1000"] + +[[setup.accounts]] +code = "1000" +name = "Bank" +type = "asset" + +[[setup.accounts]] +code = "6000" +name = "Contract Labor" +type = "expense" + +[[setup.payees]] +name = "Riley Chen" +form = "1099-NEC" + +[[setup.payees]] +name = "Cedar Studio" +form = "1099-NEC" + +[[events]] +date = "2025-11-01" +description = "Illustration" +payee = "Riley Chen" +lines = [ + { account = "6000", side = "debit", amount = "600.00" }, + { account = "1000", side = "credit", amount = "600.00" }, +] + +[[events]] +date = "2026-02-01" +description = "Illustration" +payee = "Riley Chen" +lines = [ + { account = "6000", side = "debit", amount = "1999.99" }, + { account = "1000", side = "credit", amount = "1999.99" }, +] + +[[events]] +date = "2026-02-01" +description = "Photography" +payee = "Cedar Studio" +lines = [ + { account = "6000", side = "debit", amount = "2000.00" }, + { account = "1000", side = "credit", amount = "2000.00" }, +] + +# By hand: 2025, Riley 600.00 against $600: reportable (exactly the +# threshold). 2026, Riley 1,999.99 against $2,000: under; Cedar 2,000.00: +# reportable. +[[expect]] +query = "vendor_1099" +payee = "Riley Chen" +from = "2025-01-01" +to = "2025-12-31" +values = { status = "reportable", threshold = "600.00", paid = "600.00", excluded_card = "0.00", undetermined = "0.00", not_reportable = "0.00" } + +[[expect]] +query = "vendor_1099" +payee = "Riley Chen" +from = "2026-01-01" +to = "2026-12-31" +values = { status = "under_threshold", threshold = "2000.00", paid = "1999.99", excluded_card = "0.00", undetermined = "0.00", not_reportable = "0.00" } + +[[expect]] +query = "vendor_1099" +payee = "Cedar Studio" +from = "2026-01-01" +to = "2026-12-31" +values = { status = "reportable", threshold = "2000.00", paid = "2000.00", excluded_card = "0.00", undetermined = "0.00", not_reportable = "0.00" } diff --git a/bookspec/examples/p3-party-gift-card-is-a-promise.toml b/bookspec/examples/p3-party-gift-card-is-a-promise.toml index 852eaab..99d0cda 100644 --- a/bookspec/examples/p3-party-gift-card-is-a-promise.toml +++ b/bookspec/examples/p3-party-gift-card-is-a-promise.toml @@ -1,7 +1,7 @@ -# P3 defect 24 and the card rule (found in review, 2026-10-21): the -# company's cards are the liabilities whose names say "card", so a gift -# card the company sold (deferred revenue) read as money: received when -# sold and again when redeemed. Regression +# P3 defect 24 and the card rule (found in review, 2026-10-21): with no +# business card recorded, the company's cards are the liabilities whose +# names say "card", so a gift card the company sold (deferred revenue) +# read as money: received when sold and again when redeemed. Regression # test: ledger-core tests/spec_party_totals.rs # defect24_a_gift_card_sold_is_received_once. # The shape: bookspec/README.md. @@ -9,10 +9,12 @@ id = "p3-party-gift-card-is-a-promise" title = "A gift card the company sold is a promise, not a card it pays with" rule = """ -The company's cards are liability accounts whose names say "card", but not -"gift card": gift cards the company sold are revenue it owes its customers -in service (deferred revenue), a promise. Selling one is money received -from the buyer; redeeming it moves no money and counts nothing. +The company's cards are the business cards its books record as payment +methods; while they record none, the liability accounts whose names say +"card", but not "gift card": gift cards the company sold are revenue it +owes its customers in service (deferred revenue), a promise. Selling one +is money received from the buyer; redeeming it moves no money and counts +nothing. """ status = "CONVENTION" sources = [ diff --git a/crates/ledger-cli/tests/local_server.rs b/crates/ledger-cli/tests/local_server.rs index 96a9344..c0f0b1f 100644 --- a/crates/ledger-cli/tests/local_server.rs +++ b/crates/ledger-cli/tests/local_server.rs @@ -202,6 +202,25 @@ async fn connect_with(home: &Home, book: &str, token: Option) -> 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 { 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( diff --git a/crates/ledger-cli/tests/open_while_held.rs b/crates/ledger-cli/tests/open_while_held.rs index ebf6245..fc77d87 100644 --- a/crates/ledger-cli/tests/open_while_held.rs +++ b/crates/ledger-cli/tests/open_while_held.rs @@ -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(); diff --git a/crates/ledger-core/src/arap.rs b/crates/ledger-core/src/arap.rs index d577e97..52ac1b5 100644 --- a/crates/ledger-core/src/arap.rs +++ b/crates/ledger-core/src/arap.rs @@ -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, + pub decisions_1099: Vec, /// 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 { + 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 { diff --git a/crates/ledger-core/src/error.rs b/crates/ledger-core/src/error.rs index 6b60ae3..eacfbe9 100644 --- a/crates/ledger-core/src/error.rs +++ b/crates/ledger-core/src/error.rs @@ -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), diff --git a/crates/ledger-core/src/form1099.rs b/crates/ledger-core/src/form1099.rs index 718e1fe..662926a 100644 --- a/crates/ledger-core/src/form1099.rs +++ b/crates/ledger-core/src/form1099.rs @@ -1,29 +1,48 @@ -//! 1099 tracking: what was paid to each vendor in a calendar year. +//! 1099 tracking: what each vendor was paid in a calendar year, and what +//! of it counts toward the information return the company files for it. //! -//! A vendor is marked with the information return it gets (1099-NEC for -//! nonemployee compensation, 1099-MISC for rents and the like) and whether -//! its W-9 is on file. Entries are tagged with the vendor they paid -//! (`pay_bill` tags automatically). The amount paid is what left the -//! company's payment sources on the vendor's tagged entries: credits minus -//! debits to them ([`PaymentAccounts`]). The sources are the cash accounts -//! (bank, cash on hand) and the financing liabilities (what owners or -//! lenders paid on the company's behalf, e.g. Due to Shareholders): the -//! company is the payer either way. A card payment (Dr expense, Cr credit -//! card) leaves neither, so it is excluded, as it should be: card payments -//! are reported by the card processor on 1099-K, not by the payer. A bill -//! (Cr payables) is not a payment until it is paid, and a prepayment -//! (Dr prepaid, Cr cash) counts in full when made: §6041 reports payments -//! made in the year. An entry with several parties (a payment shared -//! between a contractor and a member) counts only the vendor's share of -//! that payment ([`vendor_1099_report_with_members`]). +//! **The decision.** A vendor's 1099 decision ([`Decision1099`]) says +//! whether it gets one: a 1099-NEC (contractors, attorneys), a 1099-MISC +//! (rent and the like), *not reportable* with its reason (a corporation, +//! the United States or a state, a payee that sells only merchandise: +//! Treas. Reg. §1.6041-3), or *undetermined*, which is what a vendor +//! nobody has decided on reads as. "Not reportable" and "undetermined" are +//! never the same thing (plan defects 18 and 23). Each decision carries +//! who made it and when, and the date it takes effect ([`Dated1099`], +//! D-40): a payee's status can change (an LLC electing to be taxed as a +//! corporation keeps its EIN and changes status on the election's date), +//! so the worksheet applies the decision in force on each payment's date. +//! A payee that incorporates is a new legal person: a new vendor record +//! with its own W-9. A decision recorded without a date (every one written +//! before decisions were dated) is in force from the beginning. +//! +//! **The payments.** Entries are tagged with the vendor they paid +//! (`pay_bill` tags automatically) and, where it matters, with the +//! payment method ([`crate::instruments`]). Each line of a tagged entry +//! goes in a bucket ([`crate::kernel::payment_bucket`]): what *counts* +//! toward the vendor's 1099, what a *card* or payment network paid (the +//! processor reports it on Form 1099-K, never the company: +//! §1.6041-1(a)(1)(iv), whoever's card it was), and what is +//! *undetermined* until the payment method is recorded (a member paying +//! through the financing liability: their card is excluded, their check +//! counts, since the company is still the payor, §1.6041-1(e)(1)). A +//! payment out of the bank with no method recorded counts (presumed check, +//! ACH or transfer, D-40); tag a debit-card payment with the card. Money +//! moved between the company's own sources (the card bill, a member +//! reimbursed) pays no one ([`crate::kernel::net_buckets`]). A bill is not +//! a payment until it is paid, and a prepayment counts when made: §6041 +//! reports payments made in the year. //! //! Thresholds are set by law and change; [`default_threshold`] encodes //! them and the report takes an override. Confirm with the CPA. -use crate::{AccountType, Ledger, LedgerError, VendorId}; -use chrono::Datelike; +use crate::kernel::{self, Bucket, PayRole, Tag}; +use crate::settings::Source; +use crate::{AccountId, AccountType, EntryId, Ledger, LedgerError, VendorId}; +use chrono::{Datelike, NaiveDate}; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, HashMap, HashSet}; /// The information return a vendor gets. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -53,6 +72,356 @@ impl Form1099 { Form1099::Misc => "1099-MISC", } } + + /// "nec" or "misc": the journal's and the tools' key. + pub fn key(self) -> &'static str { + match self { + Form1099::Nec => "nec", + Form1099::Misc => "misc", + } + } +} + +/// Why a vendor gets no 1099: the kind of payee no return is required +/// for. Each has its authority ([`ExemptReason::citation`], +/// sources/irs-1099-payment-method.md); a decision adds the facts in +/// words. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExemptReason { + /// A corporation (Treas. Reg. §1.6041-3(p)(1)), an LLC taxed as one + /// included, shown by its name ("Inc.", "Corp.", never "Co.": + /// §1.6049-4(c)(1)(ii)(A)(1)), a W-9, a Form 8832, or the public + /// record. Payments to a corporation for legal services or for medical + /// and health care stay reportable (§1.6041-3(p)(1); Instructions for + /// Forms 1099-MISC and 1099-NEC): never this reason for those. + Corporation, + /// An organization exempt from tax under §501(a), or an individual + /// retirement plan (§1.6041-3(p)(2)). + TaxExempt, + /// The United States (§1.6041-3(p)(3)). + UnitedStates, + /// A State, the District of Columbia, a U.S. possession, or a + /// political subdivision of any of them (§1.6041-3(p)(4)). + State, + /// A foreign government or a political subdivision of one + /// (§1.6041-3(p)(5)). + ForeignGovernment, + /// A foreign payee documented before payment (§1.6041-4(a)(1)): its + /// payments go on Form 1042-S, if anything. + ForeignPerson, + /// A payee that sells only merchandise, telephone, freight, storage + /// and the like (§1.6041-3(c)). A vendor selling services too stays a + /// 1099 vendor. + Merchandise, + /// An employee: wages go on Form W-2 (§1.6041-2(a)(1)). + Employee, + /// Another reason, which the decision's words and citation give. + Other, +} + +impl ExemptReason { + /// Every reason, in order. + pub const ALL: [ExemptReason; 9] = [ + ExemptReason::Corporation, + ExemptReason::TaxExempt, + ExemptReason::UnitedStates, + ExemptReason::State, + ExemptReason::ForeignGovernment, + ExemptReason::ForeignPerson, + ExemptReason::Merchandise, + ExemptReason::Employee, + ExemptReason::Other, + ]; + + /// Parse a key ("corporation", "united_states", ...), ignoring case and + /// surrounding space. + pub fn parse(s: &str) -> Option { + let s = s.trim().to_ascii_lowercase(); + ExemptReason::ALL.into_iter().find(|r| r.key() == s) + } + + /// Its key, as journaled. + pub fn key(self) -> &'static str { + match self { + ExemptReason::Corporation => "corporation", + ExemptReason::TaxExempt => "tax_exempt", + ExemptReason::UnitedStates => "united_states", + ExemptReason::State => "state", + ExemptReason::ForeignGovernment => "foreign_government", + ExemptReason::ForeignPerson => "foreign_person", + ExemptReason::Merchandise => "merchandise", + ExemptReason::Employee => "employee", + ExemptReason::Other => "other", + } + } + + /// In words: "a corporation", "the United States", ... + pub fn label(self) -> &'static str { + match self { + ExemptReason::Corporation => "a corporation", + ExemptReason::TaxExempt => "a tax-exempt organization", + ExemptReason::UnitedStates => "the United States", + ExemptReason::State => "a state or local government", + ExemptReason::ForeignGovernment => "a foreign government", + ExemptReason::ForeignPerson => "a foreign person", + ExemptReason::Merchandise => "sells only merchandise", + ExemptReason::Employee => "an employee", + ExemptReason::Other => "another reason", + } + } + + /// The authority that exempts it; none for [`ExemptReason::Other`], + /// whose decision cites its own. + pub fn citation(self) -> Option<&'static str> { + Some(match self { + ExemptReason::Corporation => "Treas. Reg. §1.6041-3(p)(1)", + ExemptReason::TaxExempt => "Treas. Reg. §1.6041-3(p)(2)", + ExemptReason::UnitedStates => "Treas. Reg. §1.6041-3(p)(3)", + ExemptReason::State => "Treas. Reg. §1.6041-3(p)(4)", + ExemptReason::ForeignGovernment => "Treas. Reg. §1.6041-3(p)(5)", + ExemptReason::ForeignPerson => "Treas. Reg. §1.6041-4(a)(1)", + ExemptReason::Merchandise => "Treas. Reg. §1.6041-3(c)", + ExemptReason::Employee => "Treas. Reg. §1.6041-2(a)(1)", + ExemptReason::Other => return None, + }) + } +} + +/// A vendor's 1099 decision. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Decision1099 { + /// Nobody has decided (or a line from before decisions were kept, + /// which can't say: plan 5.5). + #[default] + Undetermined, + /// It gets this return when paid at least the threshold. + Files(Form1099), + /// No return is required for it, and why. + NotReportable { + /// The kind of payee. + kind: ExemptReason, + /// The facts, in words ("a corporation by name", "Delaware public + /// benefit corporation, from the public record"). + reason: String, + }, +} + +impl Decision1099 { + /// A not-reportable decision, its reason trimmed and checked + /// ([`Decision1099::check`]). + pub fn not_reportable(kind: ExemptReason, reason: &str) -> Result { + let d = Decision1099::NotReportable { + kind, + reason: reason.trim().to_string(), + }; + d.check()?; + Ok(d) + } + + /// The return it files, if it files one. + pub fn form(&self) -> Option { + match self { + Decision1099::Files(f) => Some(*f), + _ => None, + } + } + + /// "nec", "misc", "not_reportable" or "undetermined". + pub fn key(&self) -> &'static str { + match self { + Decision1099::Undetermined => "undetermined", + Decision1099::Files(f) => f.key(), + Decision1099::NotReportable { .. } => "not_reportable", + } + } + + /// The decision in words: "1099-NEC", "not reportable: a corporation + /// (by name)", "undetermined". + pub fn describe(&self) -> String { + match self { + Decision1099::Undetermined => "undetermined".into(), + Decision1099::Files(f) => f.label().into(), + Decision1099::NotReportable { kind, reason } => { + format!("not reportable: {} ({reason})", kind.label()) + } + } + } + + /// Check a decision before it is journaled: a not-reportable reason is + /// one line of at most `settings::MAX_TEXT` characters, trimmed, with + /// no full TIN (P3 review R16). + pub fn check(&self) -> Result<(), String> { + if let Decision1099::NotReportable { reason, .. } = self { + let t = crate::settings::line_text("reason", reason, crate::settings::MAX_TEXT)?; + if t != *reason { + return Err("reason has space around it".into()); + } + } + Ok(()) + } +} + +/// A 1099 decision with the date it takes effect and who made it (D-40). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Dated1099 { + /// The day it takes effect: a W-9's date, or a tax election's + /// effective date. `None`: from the beginning (the vendor's status all + /// along, and every decision written before decisions were dated). + pub effective: Option, + /// The decision. + pub decision: Decision1099, + /// Who decided it and when; `None` only on lines written before + /// decisions were kept (the store requires one on every new line). + pub source: Option, +} + +impl Dated1099 { + /// A decision in force from the beginning. + pub fn from_beginning(decision: Decision1099, source: Option) -> Dated1099 { + Dated1099 { + effective: None, + decision, + source, + } + } +} + +/// What recording a dated decision does to a vendor's decisions +/// ([`plan_decision`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Applied { + /// Nothing: it is already recorded exactly so, or a dated decision the + /// same as the one in force before its date (no change on that date). + Unchanged, + /// Recorded: added, or replacing the one recorded for that date. + Set, + /// The decision recorded for that date is taken out: the new one is + /// the same as the one in force before it, so nothing changes there. + Removed, +} + +/// The decision in force on `date`: the last one taking effect on or +/// before it (one from the beginning counts). `decisions` are in date +/// order, the undated first ([`apply_decision`] keeps them so). +pub fn in_force(decisions: &[Dated1099], date: NaiveDate) -> Option<&Dated1099> { + decisions + .iter() + .rev() + .find(|d| d.effective.is_none_or(|e| e <= date)) +} + +/// The decision in force the day before `date`, whatever is recorded for +/// `date` itself. +fn in_force_before(decisions: &[Dated1099], date: NaiveDate) -> Option<&Dated1099> { + decisions + .iter() + .rev() + .find(|d| d.effective.is_none_or(|e| e < date)) +} + +/// What recording `new` would do to `decisions` (one per effective date, +/// in date order). +/// +/// ```text +/// GOAL: each payment takes the vendor's 1099 decision in force on its +/// date (D-40), and recording one never changes a figure it +/// doesn't mean to. +/// SOURCE: DECISION D-40 (owner, 2026-10-20: dated statuses); P3 review +/// R8 (an identical re-record journals nothing). +/// SCENARIOS: +/// 1. IF `new` is recorded exactly so already (decision, date, source): +/// SHOULD be Unchanged. +/// 2. IF `new` is undated: SHOULD be Set (the decision from the +/// beginning, replacing any). +/// 3. IF `new` is dated and its decision is the one in force the day +/// before: SHOULD be Removed when a decision is recorded for that +/// date (taking it out changes nothing before or after), else +/// Unchanged. +/// 4. Otherwise: SHOULD be Set (added for that date, or replacing the +/// one recorded for it). +/// ``` +pub fn plan_decision(decisions: &[Dated1099], new: &Dated1099) -> Applied { + let at = decisions.iter().find(|d| d.effective == new.effective); + if at == Some(new) { + return Applied::Unchanged; + } + let Some(date) = new.effective else { + return Applied::Set; + }; + let before = in_force_before(decisions, date) + .map(|d| &d.decision) + .unwrap_or(&Decision1099::Undetermined); + if *before == new.decision { + if at.is_some() { + Applied::Removed + } else { + Applied::Unchanged + } + } else { + Applied::Set + } +} + +/// Record `new` in `decisions` ([`plan_decision`]), keeping them one per +/// effective date, in date order. Returns what it did. +pub fn apply_decision(decisions: &mut Vec, new: Dated1099) -> Applied { + let applied = plan_decision(decisions, &new); + match applied { + Applied::Unchanged => {} + Applied::Removed => decisions.retain(|d| d.effective != new.effective), + Applied::Set => { + match decisions.iter().position(|d| d.effective >= new.effective) { + Some(i) if decisions[i].effective == new.effective => decisions[i] = new, + Some(i) => decisions.insert(i, new), + None => decisions.push(new), + }; + } + } + applied +} + +/// The checks a new 1099 decision passes before it is recorded (replay +/// skips them: rules.md B-28): the decision ([`Decision1099::check`]), +/// its source, its date in the books' calendar, and that a dated decision +/// records a change. Returns what recording it would do. +/// +/// ```text +/// GOAL: no dated decision silently leaves the vendor's earlier payments +/// undecided. +/// SOURCE: DECISION D-40 (owner, 2026-10-20); docs/spec/rules.md B-19. +/// SCENARIOS: +/// 1. IF `new` is dated, would be recorded, and no decision other than +/// undetermined is in force at any time before its date: SHOULD +/// refuse +/// BECAUSE a dated decision records a change, and recording only +/// the change would leave every earlier payment undetermined: +/// record the vendor's status from the beginning (no date) first. +/// 2. IF it would only take out the decision recorded for that date +/// (plan_decision SCENARIO 3): SHOULD be allowed. +/// ``` +pub fn check_new_decision(decisions: &[Dated1099], new: &Dated1099) -> Result { + new.decision.check()?; + if let Some(s) = &new.source { + s.check()?; + } + if let Some(d) = new.effective { + crate::calendar::check(d).map_err(|e| e.to_string())?; + } + let applied = plan_decision(decisions, new); + if let (Some(date), Applied::Set) = (new.effective, applied) { + let decided_before = decisions + .iter() + .filter(|d| d.effective.is_none_or(|e| e < date)) + .any(|d| d.decision != Decision1099::Undetermined); + if !decided_before { + return Err(format!( + "a dated 1099 decision records a change, and nothing is decided before {date}: record the vendor's status from the beginning first (no effective date), then the change" + )); + } + } + Ok(applied) } /// The reporting threshold for payments made in `year`. @@ -60,7 +429,9 @@ impl Form1099 { /// ```text /// GOAL: the dollar amount at which a vendor's payments become reportable. /// SOURCE: docs/spec/rules.md B-19 (26 U.S.C. §6041(a), (h), §6041A(a)(2), -/// Pub. L. 119-21 §70433; sources/irs-1099-thresholds.md). +/// Pub. L. 119-21 §70433(f): payments made after December 31, +/// 2025; sources/irs-1099-thresholds.md, +/// sources/irs-1099-payment-method.md). /// SCENARIOS: /// 1. IF `year` <= 2025: SHOULD be $600. /// 2. IF `year` == 2026: SHOULD be $2,000. @@ -91,6 +462,37 @@ pub fn default_threshold(year: i32) -> Result { }) } +/// Where a vendor stands for the year. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Status1099 { + /// Decided for a 1099, and what counts reaches the threshold however + /// the unknown payments turn out: file one. + Reportable, + /// What could count stays under the threshold, however the unknowns + /// are settled. (Royalties at $10 and attorneys' gross proceeds at $600 + /// are not modeled: confirm those with the CPA.) + UnderThreshold, + /// Decided not reportable for every payment that could count: no + /// 1099, and the decision says why. + NotReportable, + /// A 1099 depends on something not yet known: the vendor's decision + /// for some of the year's payments, or how some were paid. + Undetermined, +} + +impl Status1099 { + /// "reportable", "under_threshold", "not_reportable", "undetermined". + pub fn key(self) -> &'static str { + match self { + Status1099::Reportable => "reportable", + Status1099::UnderThreshold => "under_threshold", + Status1099::NotReportable => "not_reportable", + Status1099::Undetermined => "undetermined", + } + } +} + /// One vendor's year. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Vendor1099Line { @@ -98,16 +500,62 @@ pub struct Vendor1099Line { pub vendor_id: VendorId, /// Name. pub name: String, - /// The return it gets, if marked. - pub form: Option, + /// The decision in force at the end of the year (or of `through`). + pub decision: Decision1099, + /// Who made it and when (none for a line from before decisions were + /// kept, or a vendor never decided). + pub decided: Option, + /// The date it took effect (none: from the beginning). + pub effective: Option, + /// Every decision in force during the year, in date order (more than + /// one when the vendor's status changed in the year; none when the + /// vendor was never decided). + pub decisions: Vec, /// W-9 on file. pub tin_on_file: bool, - /// Cash paid in the year (net of refunds and reversals). + /// What counts toward its 1099: paid by check, ACH, transfer or cash + /// (net of refunds and reversals) while it wasn't decided not + /// reportable. pub paid: Decimal, - /// Marked for a 1099 and paid at least the threshold. + /// Paid by card or payment network: the processor's Form 1099-K, never + /// the company's 1099. + pub excluded_card: Decimal, + /// Paid through a financing liability with no payment method + /// recorded: counts or not once its method is known (net). + pub undetermined: Decimal, + /// Paid while the vendor was decided not reportable (not by card). + pub not_reportable: Decimal, + /// Some of the year's payments that could count were made while the + /// vendor's decision was undetermined: a decision is needed. + pub decision_needed: bool, + /// Where it stands ([`Status1099`]). + pub status: Status1099, + /// `status == Reportable`: file a 1099. pub reportable: bool, } +impl Vendor1099Line { + /// The return it gets, as decided at the end of the year. + pub fn form(&self) -> Option { + self.decision.form() + } +} + +/// One payment on a vendor's year, for the worksheet's lists. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Payment1099 { + /// The entry. + pub entry_id: EntryId, + /// Its date. + pub date: NaiveDate, + /// The vendor it paid. + pub vendor_id: VendorId, + /// The vendor's part of it the list is about. + pub amount: Decimal, + /// The payment method it is tagged with, if any. + pub instrument: Option, +} + /// The year's 1099 report. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Vendor1099Report { @@ -115,84 +563,157 @@ pub struct Vendor1099Report { pub year: i32, /// The threshold applied. pub threshold: Decimal, - /// Every vendor marked for a 1099 or paid in the year, by name. + /// The last day counted, when the report stops before December 31. + pub through: Option, + /// Every vendor decided for a 1099 during the year, or with any figure + /// in it, by name. pub vendors: Vec, /// Entries naming several parties whose shares didn't resolve, in /// posting order: each counted in full toward its vendor until its - /// shares are fixed ([`vendor_1099_report_with_members`] INVARIANT 4). - pub unresolved: Vec, + /// shares are fixed ([`vendor_1099_report`] INVARIANT 5). + pub unresolved: Vec, + /// Payments by card or payment network, excluded (posting order). + pub excluded_card_payments: Vec, + /// Payments whose method must be recorded before they can be + /// classified (posting order); none for a vendor decided not + /// reportable then. + pub undetermined_payments: Vec, + /// Money received from a vendor for something else (a sale to it, its + /// payment of an invoice, an asset disposed of to it, a loan from it), + /// not netted against its payments, with the reversals of each. + pub other_receipts: Vec, + /// Posted entries in the year that paid money out for something, with + /// no vendor tag and no payee that rules out a 1099 (the quieter note). + pub untagged_payments: Vec, } -/// Cash paid per vendor in calendar `year`, from posted entries tagged -/// with the vendor. -pub fn vendor_1099_report( - ledger: &Ledger, - year: i32, - threshold: Option, -) -> Result { - vendor_1099_report_with(ledger, year, threshold, &PaymentAccounts::default()) -} - -/// The accounts payments to vendors come out of, for -/// [`vendor_1099_report_with`]: the books' `[books] cash_accounts` and -/// `[books] financing_accounts`. +/// The accounts payments to vendors come out of: the books' +/// `books.cash_accounts` and `books.financing_accounts`. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct PaymentAccounts { - /// Cash accounts: bank, savings, cash on hand. - pub cash: Vec, - /// Financing liabilities: owner advances, loans (paid on the company's - /// behalf). Never a payment card. - pub financing: Vec, + /// Cash accounts: bank, savings, cash on hand. Required. + pub cash: Vec, + /// Financing liabilities: what members or lenders paid on the + /// company's behalf (Due to Members, loans). + pub financing: Vec, } -impl PaymentAccounts { - /// Whether `account` is a payment source. With no cash accounts given, - /// every asset account is one (books that haven't named their cash). - fn includes(&self, ledger: &Ledger, account: crate::AccountId) -> bool { - if self.cash.is_empty() { +/// Each account's payment role ([`PayRole`]) for one report. +/// +/// ```text +/// GOAL: a payment line's role comes from what the books record, and a +/// line whose method isn't recorded is never presumed a card's +/// unless only a card pays through its account. +/// SOURCE: docs/spec/rules.md B-19 (P3 review R2); DECISION D-40 (a bank +/// payment with no method recorded counts). +/// INVARIANTS (first match wins): +/// 1. Cash: one of the books' cash accounts. +/// 2. Financing: one of the books' financing accounts, or the account of +/// a member's card, a member's cash or check, or a payment platform +/// (a members' account pays by card or by check, a platform's balance +/// by the network or friends-and-family: which, only the entry's +/// method says). +/// 3. CardAccount: the account of a company card (business_card), the +/// card's liability or a card's own balance. +/// 4. Other: anything else (an expense, payables, prepaid). +/// ``` +struct Roles { + cash: HashSet, + financing: HashSet, + card: HashSet, + /// Every instrument's account. + instrument: HashSet, +} + +impl Roles { + fn new(ledger: &Ledger, sources: &PaymentAccounts) -> Roles { + use crate::InstrumentKind as K; + let named = |kinds: &[K]| -> HashSet { ledger - .chart() - .get(account) - .is_some_and(|a| a.account_type == AccountType::Asset) - || self.financing.contains(&account) - } else { - self.cash.contains(&account) || self.financing.contains(&account) + .instruments() + .filter(|i| kinds.contains(&i.kind)) + .map(|i| i.account) + .collect() + }; + Roles { + cash: sources.cash.iter().copied().collect(), + financing: sources + .financing + .iter() + .copied() + .chain(named(&[K::MemberCard, K::MemberCashOrCheck, K::Platform])) + .collect(), + card: named(&[K::BusinessCard]), + instrument: ledger.instruments().map(|i| i.account).collect(), } } + + fn role(&self, account: AccountId) -> PayRole { + if self.cash.contains(&account) { + PayRole::Cash + } else if self.financing.contains(&account) { + PayRole::Financing + } else if self.card.contains(&account) { + PayRole::CardAccount + } else { + PayRole::Other + } + } + + /// Whether money is paid out of `account`. + fn pays(&self, account: AccountId) -> bool { + self.role(account) != PayRole::Other || self.instrument.contains(&account) + } } -/// [`vendor_1099_report`], counting payments out of `sources`, with no -/// members: [`vendor_1099_report_with_members`] with none (an entry naming -/// several parties then needs amount or percent shares to resolve, which -/// a vendor beside anyone always does, since a vendor is not a member). -pub fn vendor_1099_report_with( +/// An entry's payment, per bucket ([`kernel::split_payment`]), netted +/// ([`kernel::net_buckets`]): `[counts, card, undetermined]`. +fn entry_buckets( ledger: &Ledger, - year: i32, - threshold: Option, - sources: &PaymentAccounts, -) -> Result { - vendor_1099_report_with_members(ledger, year, None, threshold, sources, &[]) -} - -/// What an entry paid out of `sources`: credits minus debits to them. -fn payment(ledger: &Ledger, entry: &crate::JournalEntry, sources: &PaymentAccounts) -> Decimal { - entry + entry: &crate::JournalEntry, + roles: &Roles, +) -> Result<[Decimal; 3], LedgerError> { + let tag = ledger + .entry_instrument(entry.id()) + .and_then(|i| ledger.get_instrument(i)); + let lines: Vec<(Option, crate::Side, Decimal)> = entry .lines() .iter() - .filter(|l| sources.includes(ledger, l.account)) - .map(|l| match l.side { - crate::Side::Credit => l.amount.amount, - crate::Side::Debit => -l.amount.amount, + .map(|l| { + let t = match tag { + None => Tag::None, + Some(i) if i.account == l.account => { + if i.kind.is_card_or_network() { + Tag::ThisCard + } else { + Tag::ThisOther + } + } + Some(_) => Tag::Elsewhere, + }; + ( + kernel::payment_bucket(roles.role(l.account), t), + l.side, + l.amount.amount, + ) }) - .sum() + .collect(); + kernel::split_payment(&lines) + .and_then(kernel::net_buckets) + .ok_or_else(|| LedgerError::TooLargeToCompute(format!("entry {}'s payment", entry.id()))) +} + +fn sum3(b: [Decimal; 3]) -> Result { + b[0].checked_add(b[1]) + .and_then(|s| s.checked_add(b[2])) + .ok_or_else(|| LedgerError::TooLargeToCompute("a payment's buckets".into())) } /// The vendor's part of `payment` when the entry names several parties: /// its resolved share scaled from the share target to the payment (the -/// SCENARIOS of [`vendor_1099_report_with_members`], INVARIANT 3). -/// Refused, never wrapped or rounded early, when payment × share is -/// beyond exact arithmetic (both near the line limit; P0 review round 3, -/// S1). +/// SCENARIOS of [`vendor_1099_report`], INVARIANT 4). Refused, never +/// wrapped or rounded early, when payment × share is beyond exact +/// arithmetic (both near the line limit; P0 review round 3, S1). fn vendor_part(payment: Decimal, share: Decimal, target: Decimal) -> Result { if payment == target { Ok(share) @@ -211,49 +732,214 @@ fn vendor_part(payment: Decimal, share: Decimal, target: Decimal) -> Result Result<[Decimal; 3], LedgerError> { + let too_large = + || LedgerError::TooLargeToCompute(format!("a vendor's share ({v}) of a payment")); + let p = sum3(b)?; + if v == p { + return Ok(b); + } + if p.is_zero() || v.is_zero() { + return Ok([Decimal::ZERO; 3]); + } + let (av, ap) = (v.abs(), p.abs()); + let cent = Decimal::new(1, 2); + let times = |x: Decimal| x.checked_mul(ap).ok_or_else(too_large); + let mut parts = [Decimal::ZERO; 3]; + let mut rems = [Decimal::ZERO; 3]; + for i in 0..3 { + if b[i].is_zero() { + continue; + } + // The exact share is num / ap: rounded down to the cent, checked + // exactly (the division alone rounds past 28 digits). + let num = b[i].abs().checked_mul(av).ok_or_else(too_large)?; + let q = num.checked_div(ap).ok_or_else(too_large)?; + let mut part = q.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::ToZero); + while times(part)? > num { + part -= cent; + } + while times(part + cent)? <= num { + part += cent; + } + // The remainder, over the common denominator ap. + rems[i] = num - times(part)?; + parts[i] = part; + } + kernel::apportion(av, &mut parts, &rems, cent).ok_or_else(too_large)?; + if v.is_sign_negative() { + for x in parts.iter_mut().filter(|x| !x.is_zero()) { + *x = -*x; + } + } + Ok(parts) +} + +/// Each posted entry's reversal chain's first entry (the payment itself), +/// from one pass in posting order: a reversal is posted after what it +/// reverses. +fn chain_roots(ledger: &Ledger) -> HashMap { + let mut roots: HashMap = HashMap::new(); + for e in ledger.posted_entries() { + let root = e + .reversal_of() + .and_then(|o| roots.get(&o).copied()) + .unwrap_or(e.id()); + roots.insert(e.id(), root); + } + roots +} + +/// The accounts a refund of a payment credits, giving back what the +/// payment paid for: an expense, payables (a bill's payment refunded), or +/// an asset no money is paid out of that no one owes the company and that +/// is no registered asset's cost (prepaid, a deposit). Revenue, +/// receivables (a customer paying its invoice), a registered asset's cost +/// (a disposal), equity and other liabilities (a loan) are money received +/// for something else (P3 review R6). +fn refund_accounts(ledger: &Ledger, roles: &Roles) -> HashSet { + let payables = ledger.role_account(&crate::PAYABLES); + let receivables = ledger.role_account(&crate::RECEIVABLES); + let costs: HashSet = ledger.registered_assets().map(|a| a.cost_account).collect(); + ledger + .chart() + .all() + .filter(|a| match a.account_type { + AccountType::Expense => true, + AccountType::Liability => Some(a.id) == payables, + AccountType::Asset => { + !roles.pays(a.id) && Some(a.id) != receivables && !costs.contains(&a.id) + } + AccountType::Revenue | AccountType::Equity => false, + }) + .map(|a| a.id) + .collect() +} + +/// What each vendor's year adds up to, before its status is worked out. +#[derive(Default)] +struct Tally { + /// Counts while decided for a 1099, and while undetermined. + paid_files: Decimal, + paid_undecided: Decimal, + card: Decimal, + not_reportable: Decimal, + /// Undetermined amounts per reversal chain (by its first entry), while + /// decided for a 1099, and while undetermined. + unknown_files: BTreeMap, + unknown_undecided: BTreeMap, +} + +fn add(to: &mut Decimal, x: Decimal) -> Result<(), LedgerError> { + *to = to + .checked_add(x) + .ok_or_else(|| LedgerError::TooLargeToCompute("a vendor's payments for the year".into()))?; + Ok(()) +} + +/// The lowest and highest totals `paid` could reach once every chain's +/// unknown amount is settled one way or the other (a chain's unknowns +/// settle together: its entries share their tag; P3 review R3). +fn bounds( + paid: Decimal, + unknown: &BTreeMap, +) -> Result<(Decimal, Decimal), LedgerError> { + let (mut lo, mut hi) = (paid, paid); + for &u in unknown.values() { + add(&mut lo, u.min(Decimal::ZERO))?; + add(&mut hi, u.max(Decimal::ZERO))?; + } + Ok((lo, hi)) +} + +/// The 1099 report for calendar `year`: per vendor, what counts toward +/// its 1099, what a card or payment network paid, what is unknown, and +/// where it stands; payments out of `sources`, each entry's parties' +/// shares resolved with `members` (the company's members, from the +/// settings), entries dated after `through` left out. /// /// ```text /// SOURCE: docs/spec/rules.md B-19 (26 U.S.C. §6041(a): payments made in -/// the year; card payments go on 1099-K), B-33 (a worksheet as of -/// a day); crate::shares (an entry with several parties, each with -/// their share; DECISION, owner, 2026-10-15). +/// the year; Treas. Reg. §1.6041-1(a)(1)(iv): card and network +/// payments go on Form 1099-K; §1.6041-1(e)(1): the company is the +/// payor when a member pays its bill; §1.6041-3: payees no return +/// is required for; sources/irs-1099-payment-method.md); +/// crate::shares (an entry with several parties, each with their +/// share; DECISION, owner, 2026-10-15); DECISION D-40 (owner, +/// 2026-10-20: dated decisions; a bank payment with no method +/// recorded counts); B-33 (a worksheet as of a day). /// DEFINITIONS: -/// payment(e): credits minus debits to `sources` on entry e. +/// buckets(e): kernel::payment_bucket per line (role from `sources` and +/// the instruments' accounts; tag from Ledger::entry_instrument), +/// summed by kernel::split_payment and netted by kernel::net_buckets: +/// [counts, card, undetermined], each credits minus debits. +/// root(e): the first entry of e's reversal chain (the payment itself). +/// decision(e): the vendor's decision in force on root(e)'s date. /// money: crate::parties::money_accounts(ledger, sources.cash, /// sources.financing), what the shares resolve against (the MCP server /// checks them with the same). /// target(e): crate::shares::target(e, money).amount: what the shares /// split (rules.md B-35); a reversal's is its original's negated. /// INVARIANTS: -/// 1. Only posted entries dated in `year` (and, given `through`, on or -/// before it) and tagged with a vendor (Ledger::entry_vendor) count, -/// toward that vendor. `through` makes a worksheet as of a day: the -/// payments from January 1 through it (§6041(a) and §6041A(a)(2) -/// count what is paid "in any calendar year"; a CPA package as of -/// June 30 shows the year to date, rules.md B-33). -/// 2. An entry naming at most one party (Ledger::entry_parties): the -/// vendor's amount is payment(e), exactly as before shares. -/// 3. An entry naming several parties (one of them the vendor's party, -/// crate::shares INVARIANT 5): the vendor's amount is its resolved -/// share s (crate::shares::resolve with money and `members`) scaled -/// to the payment: s when payment(e) = target(e) (no rounding: an -/// expense paid from cash or by a member), else -/// payment(e) × s / target(e) rounded to the nearest cent, half away -/// from zero (0 when the payment is 0: charged to a card; 0 when the -/// target is 0). The other parties' parts are not anyone's 1099. -/// 4. An entry naming several parties whose shares don't resolve (the -/// members changed in the settings, say): counts payment(e) in full -/// toward the vendor, the conservative answer, and is listed in -/// `unresolved` so the human fixes the shares. +/// 1. Refuses when `sources.cash` is empty (name the books' cash +/// accounts; never every asset: P3 review R5), and, with no +/// threshold given, when the books' currency isn't USD (the +/// thresholds are in dollars). +/// 2. Only posted entries dated in `year` (and on or before `through`) +/// and tagged with a vendor count, toward that vendor. `through` +/// makes a worksheet as of a day: the payments from January 1 +/// through it (§6041(a) and §6041A(a)(2) count what is paid "in any +/// calendar year"; a CPA package as of June 30 shows the year to +/// date, rules.md B-33). +/// 3. An entry naming at most one party: the vendor's amounts are +/// buckets(e). +/// 4. An entry naming several parties (one of them the vendor's party, +/// crate::shares INVARIANT 5): the vendor's part V of the payment P +/// (the buckets' sum) is its resolved share s scaled to it: s when +/// P = target(e), else P × s / target(e) rounded to the nearest cent, +/// half away from zero (0 when the target is 0). V is split across +/// the buckets in proportion, exactly (largest remainder, to the +/// cent), so they sum to V. +/// 5. Several parties whose shares don't resolve (the members changed +/// in the settings, say): buckets(e) in full, the conservative +/// answer, and the entry is listed in `unresolved`. +/// 6. A reversal chain whose payment took money in for something else +/// (root(e) nets money in and credits, beside its payment lines, +/// anything a refund doesn't: revenue, receivables, a registered +/// asset's cost account, equity, a liability other than payables; a +/// sale, a customer's payment, a disposal, a loan from the vendor) is +/// left out of the vendor's figures and listed in `other_receipts` +/// (P3 review R6). A refund (money in crediting only an expense, +/// payables, or an asset no money is paid out of and no one owes, as +/// prepaid or a deposit) and a reversal net. +/// 7. Card goes to excluded_card whatever the decision. Counts and +/// undetermined go to not_reportable when decision(e) is not +/// reportable, else to paid and undetermined. +/// 8. Status, with T the threshold, F the payments decided for a 1099, +/// U those made while undetermined, and lo/hi each part's total with +/// every chain's unknown settled low/high: nothing in F or U is +/// NotReportable when anything was paid while not reportable or the +/// year-end decision is, else UnderThreshold; anything in U is +/// Undetermined when hi(F) + max(hi(U), 0) >= T, else +/// UnderThreshold; otherwise Reportable when lo(F) >= T, +/// UnderThreshold when hi(F) < T, else Undetermined. +/// 9. Listed: every vendor with a decision for a 1099 in force during +/// the year, and every vendor with any figure in it. /// SCENARIOS: -/// 1. Paid from cash, or by a member through a financing liability: -/// counts. Charged to a card, or billed and unpaid: doesn't. +/// 1. Paid from the bank (no method recorded, or the bank's): counts. +/// Charged to a card, or tagged with a card or network: excluded, +/// listed. Paid by a member through the financing liability: +/// undetermined until tagged; their card excluded, their cash or +/// check counts. /// 2. A prepayment (Dr prepaid, Cr cash) counts in full; a refund to -/// cash and a reversal subtract. +/// cash and a reversal subtract; the card bill and a member +/// reimbursed pay no one. /// 3. $1,000.00 from the bank (Dr contract labor / Cr 1000) shared 60/40 /// by amount between a contractor and a member: the contractor's /// 1099 counts 600.00. @@ -261,102 +947,370 @@ fn vendor_part(payment: Decimal, share: Decimal, target: Decimal) -> Result, threshold: Option, sources: &PaymentAccounts, members: &[crate::PartyMember], + through: Option, ) -> Result { let threshold = match threshold { Some(t) => t, - None => default_threshold(year)?, + None => { + let currency = ledger.base_currency(); + if currency != crate::Currency::USD { + return Err(LedgerError::Invalid1099(format!( + "the 1099 thresholds are in US dollars and these books keep {}: pass the threshold to use, in {}", + currency.code(), + currency.code() + ))); + } + default_threshold(year)? + } }; + if sources.cash.is_empty() { + return Err(LedgerError::Invalid1099( + "the 1099 worksheet counts payments out of the books' cash accounts, and none are named: set them (the books.cash_accounts setting; books.settings shows it)".into(), + )); + } + let start = NaiveDate::from_ymd_opt(year, 1, 1) + .ok_or_else(|| LedgerError::Invalid1099(format!("year {year} is outside the calendar")))?; + let year_end = NaiveDate::from_ymd_opt(year, 12, 31).unwrap_or(start); + let end = through.map_or(year_end, |t| t.min(year_end)); + let in_window = |d: NaiveDate| d.year() == year && d <= end; + let roles = Roles::new(ledger, sources); let money = crate::parties::money_accounts(ledger, &sources.cash, &sources.financing); - let mut paid: std::collections::HashMap = Default::default(); + let roots = chain_roots(ledger); + let refunds = refund_accounts(ledger, &roles); + let mut tallies: HashMap = HashMap::new(); let mut unresolved = Vec::new(); + let mut excluded_card_payments = Vec::new(); + let mut undetermined_payments = Vec::new(); + let mut other_receipts = Vec::new(); + // Each chain's verdict on INVARIANT 6, by its first entry. + let mut other: HashMap = HashMap::new(); for entry in ledger.posted_entries() { - if entry.date().year() != year || through.is_some_and(|t| entry.date() > t) { + if !in_window(entry.date()) { continue; } let Some(vendor) = ledger.entry_vendor(entry.id()) else { continue; }; - let cash = payment(ledger, entry, sources); + let Some(v) = ledger.get_vendor(vendor) else { + continue; + }; + let root = roots.get(&entry.id()).copied().unwrap_or(entry.id()); + let root_entry = ledger.get_entry(root).unwrap_or(entry); + let netted = entry_buckets(ledger, entry, &roles)?; + // The vendor's part of each bucket. let shares = ledger.entry_parties(entry.id()); - let amount = if shares.len() < 2 { - cash + let parts = if shares.len() < 2 { + netted } else { let party = ledger.party_of_vendor(vendor); match crate::shares::resolve(ledger, entry.id(), shares, &money, members) { - Ok(resolved) => { - match resolved.parts.iter().find(|p| Some(p.party) == party) { - Some(share) => vendor_part(cash, share.amount, resolved.target.amount)?, - // Tagged by hand with a vendor none of the parties - // is: nothing to scale by, so the whole payment. - None => { - unresolved.push(entry.id()); - cash - } + Ok(resolved) => match resolved.parts.iter().find(|p| Some(p.party) == party) { + Some(share) => split_share( + netted, + vendor_part(sum3(netted)?, share.amount, resolved.target.amount)?, + )?, + // Tagged by hand with a vendor none of the parties + // is: nothing to scale by, so the whole payment. + None => { + unresolved.push(entry.id()); + netted } - } + }, Err(_) => { unresolved.push(entry.id()); - cash + netted } } }; - let total = paid.entry(vendor).or_default(); - *total = total.checked_add(amount).ok_or_else(|| { - LedgerError::TooLargeToCompute("a vendor's payments for the year".into()) - })?; - } - let mut vendors: Vec = ledger - .list_vendors() - .into_iter() - .filter(|v| v.form_1099.is_some() || paid.get(&v.id).is_some_and(|p| !p.is_zero())) - .map(|v| { - let amount = paid.get(&v.id).copied().unwrap_or_default(); - Vendor1099Line { - vendor_id: v.id, - name: v.name.clone(), - form: v.form_1099, - // On file by the vendor's flag or its party's W-9 facts. - tin_on_file: v.tin_on_file - || ledger - .party_of_vendor(v.id) - .and_then(|p| ledger.get_party(p)) - .is_some_and(|p| p.w9.is_some()), - paid: amount, - reportable: v.form_1099.is_some() && amount >= threshold, + let [counts, card, unknown] = parts; + let instrument = ledger.entry_instrument(entry.id()); + let payment = |amount| Payment1099 { + entry_id: entry.id(), + date: entry.date(), + vendor_id: vendor, + amount, + instrument, + }; + // INVARIANT 6: money received from the vendor for something else, + // and its chain. + let is_other = match other.get(&root) { + Some(&o) => o, + None => { + let root_paid = sum3(entry_buckets(ledger, root_entry, &roles)?)?; + let o = root_paid < Decimal::ZERO + && root_entry.lines().iter().any(|l| { + l.side == crate::Side::Credit + && !roles.pays(l.account) + && !refunds.contains(&l.account) + }); + other.insert(root, o); + o } - }) - .collect(); + }; + if is_other { + let net = sum3(parts)?; + if !net.is_zero() { + other_receipts.push(payment(net)); + } + continue; + } + let t = tallies.entry(vendor).or_default(); + if !card.is_zero() { + add(&mut t.card, card)?; + excluded_card_payments.push(payment(card)); + } + let decision = v + .decision_on(root_entry.date()) + .cloned() + .unwrap_or_default(); + match decision { + Decision1099::NotReportable { .. } => { + add(&mut t.not_reportable, counts)?; + add(&mut t.not_reportable, unknown)?; + } + Decision1099::Files(_) => { + add(&mut t.paid_files, counts)?; + if !unknown.is_zero() { + add(t.unknown_files.entry(root).or_default(), unknown)?; + undetermined_payments.push(payment(unknown)); + } + } + Decision1099::Undetermined => { + add(&mut t.paid_undecided, counts)?; + if !unknown.is_zero() { + add(t.unknown_undecided.entry(root).or_default(), unknown)?; + undetermined_payments.push(payment(unknown)); + } + } + } + } + let empty = Tally::default(); + let mut vendors = Vec::new(); + for v in ledger.list_vendors() { + let t = tallies.get(&v.id).unwrap_or(&empty); + let decisions = decisions_during(&v.decisions_1099, start, end); + let last = in_force(&v.decisions_1099, end); + let decision = last.map(|d| d.decision.clone()).unwrap_or_default(); + let nonzero = |m: &BTreeMap| m.values().any(|u| !u.is_zero()); + let has_files = !t.paid_files.is_zero() || nonzero(&t.unknown_files); + let has_undecided = !t.paid_undecided.is_zero() || nonzero(&t.unknown_undecided); + let has_exempt = !t.not_reportable.is_zero(); + let files_in_year = decisions.iter().any(|d| d.decision.form().is_some()); + if !(files_in_year || has_files || has_undecided || has_exempt || !t.card.is_zero()) { + continue; + } + let (lo_f, hi_f) = bounds(t.paid_files, &t.unknown_files)?; + let (_, hi_u) = bounds(t.paid_undecided, &t.unknown_undecided)?; + let status = if !has_files && !has_undecided { + if has_exempt || matches!(decision, Decision1099::NotReportable { .. }) { + Status1099::NotReportable + } else { + Status1099::UnderThreshold + } + } else if has_undecided { + let mut hi = hi_f; + add(&mut hi, hi_u.max(Decimal::ZERO))?; + if hi >= threshold { + Status1099::Undetermined + } else { + Status1099::UnderThreshold + } + } else if lo_f >= threshold { + Status1099::Reportable + } else if hi_f < threshold { + Status1099::UnderThreshold + } else { + Status1099::Undetermined + }; + let mut paid = t.paid_files; + add(&mut paid, t.paid_undecided)?; + let mut undetermined = Decimal::ZERO; + for &u in t.unknown_files.values().chain(t.unknown_undecided.values()) { + add(&mut undetermined, u)?; + } + vendors.push(Vendor1099Line { + vendor_id: v.id, + name: v.name.clone(), + decision, + decided: last.and_then(|d| d.source.clone()), + effective: last.and_then(|d| d.effective), + decisions, + // On file by the vendor's flag or its party's W-9 facts. + tin_on_file: v.tin_on_file + || ledger + .party_of_vendor(v.id) + .and_then(|p| ledger.get_party(p)) + .is_some_and(|p| p.w9.is_some()), + paid, + excluded_card: t.card, + undetermined, + not_reportable: t.not_reportable, + decision_needed: has_undecided && hi_u > Decimal::ZERO, + status, + reportable: status == Status1099::Reportable, + }); + } vendors.sort_by(|a, b| a.name.cmp(&b.name).then(a.vendor_id.cmp(&b.vendor_id))); + let untagged_payments = untagged_payments(ledger, &roles, &in_window)?; Ok(Vendor1099Report { year, threshold, + through: (end < year_end).then_some(end), vendors, unresolved, + excluded_card_payments, + undetermined_payments, + other_receipts, + untagged_payments, }) } +/// The decisions in force at some time from `start` through `end`, in +/// date order, a decision the same as the one before it left out. +fn decisions_during(decisions: &[Dated1099], start: NaiveDate, end: NaiveDate) -> Vec { + let mut out: Vec = Vec::new(); + let first = in_force(decisions, start); + let later = decisions + .iter() + .filter(|d| d.effective.is_some_and(|e| e > start && e <= end)); + for d in first.into_iter().chain(later) { + if out.last().map(|l| &l.decision) != Some(&d.decision) { + out.push(d.clone()); + } + } + out +} + +/// The quieter untagged note's entries (P3 review R7): posted entries in +/// the window with no vendor tag that paid money out (their netted counts +/// and undetermined buckets, as if tagged, above zero) for something (a +/// debit to an expense, to payables, or to an asset no money is paid out +/// of and no one owes), skipping payroll runs, entries marked as having +/// no counterparty, entries naming only parties with no 1099 role, and +/// entries reversed within the window. +fn untagged_payments( + ledger: &Ledger, + roles: &Roles, + in_window: &impl Fn(NaiveDate) -> bool, +) -> Result, LedgerError> { + let payroll: HashSet = ledger + .list_payroll_runs() + .into_iter() + .map(|r| r.entry_id) + .collect(); + let payables = ledger.role_account(&crate::PAYABLES); + let receivables = ledger.role_account(&crate::RECEIVABLES); + let reversed_in_window: HashSet = ledger + .posted_entries() + .filter(|e| in_window(e.date())) + .filter_map(|e| e.reversal_of()) + .collect(); + let mut out = Vec::new(); + for e in ledger.posted_entries() { + if !in_window(e.date()) + || ledger.entry_vendor(e.id()).is_some() + || payroll.contains(&e.id()) + || ledger.entry_no_party(e.id()).is_some() + || reversed_in_window.contains(&e.id()) + { + continue; + } + let named = ledger.entry_parties(e.id()); + if !named.is_empty() + && !named.iter().any(|s| { + ledger + .get_party(s.party) + .is_some_and(|p| p.roles.iter().any(|r| r.gets_1099())) + }) + { + continue; + } + let [counts, _, unknown] = entry_buckets(ledger, e, roles)?; + let out_of_pocket = counts + .checked_add(unknown) + .ok_or_else(|| LedgerError::TooLargeToCompute("a payment".into()))?; + if out_of_pocket <= Decimal::ZERO { + continue; + } + let pays_for_something = e.lines().iter().any(|l| { + l.side == crate::Side::Debit + && match ledger.chart().get(l.account).map(|a| a.account_type) { + Some(AccountType::Expense) => true, + Some(AccountType::Asset) => { + !roles.pays(l.account) && Some(l.account) != receivables + } + Some(AccountType::Liability) => Some(l.account) == payables, + _ => false, + } + }); + if pays_for_something { + out.push(e.id()); + } + } + Ok(out) +} + +/// The posted entries tagged with `vendor` whose decision in force would +/// change if `new` were recorded, each with its date (the year its 1099 +/// is in): for the warning when a decision reaches a closed year, or one +/// whose 1099s were due (D-40, P3 review R15). +pub fn decision_changes( + ledger: &Ledger, + vendor: VendorId, + new: &Dated1099, +) -> Result, LedgerError> { + let v = ledger + .get_vendor(vendor) + .ok_or(LedgerError::UnknownVendor(vendor))?; + let mut after = v.decisions_1099.clone(); + apply_decision(&mut after, new.clone()); + let roots = chain_roots(ledger); + let undecided = Decision1099::Undetermined; + let mut out = Vec::new(); + for e in ledger.posted_entries() { + if ledger.entry_vendor(e.id()) != Some(vendor) { + continue; + } + let root = roots.get(&e.id()).copied().unwrap_or(e.id()); + let on = ledger.get_entry(root).map_or(e.date(), |r| r.date()); + let was = in_force(&v.decisions_1099, on).map_or(&undecided, |d| &d.decision); + let will = in_force(&after, on).map_or(&undecided, |d| &d.decision); + if was != will { + out.push((e.id(), e.date())); + } + } + Ok(out) +} + #[cfg(test)] mod tests { use super::*; + fn d(s: &str) -> Decimal { + s.parse().unwrap() + } + /// vendor_part: the vendor's share scaled from the share target to the /// payment, exactly, rounded to the cent; refused, never a panic, when /// payment x share is past exact arithmetic (P0 review round 3, S1). #[test] fn a_vendor_part_is_scaled_exactly_or_refused() { - let d = |s: &str| s.parse::().unwrap(); assert_eq!(vendor_part(d("100"), d("40"), d("100")), Ok(d("40"))); assert_eq!(vendor_part(d("50"), d("40"), d("100")), Ok(d("20"))); assert_eq!(vendor_part(d("1"), d("1"), d("3")), Ok(d("0.33"))); @@ -372,4 +1326,144 @@ mod tests { Err(LedgerError::TooLargeToCompute(_)) )); } + + /// split_share (P3 review R12), by hand. 100.00 netted as 60.00 counts + /// and 40.00 card, the vendor's part 33.33: exactly 19.998 and 13.332, + /// down to the cent 19.99 and 13.33, one cent short of 33.33, which + /// goes to the larger remainder (0.008 against 0.002): 20.00 and + /// 13.33. The parts always sum to the vendor's part, sign included. + #[test] + fn a_vendor_share_splits_across_buckets_to_the_cent() { + let z = Decimal::ZERO; + assert_eq!( + split_share([d("60.00"), d("40.00"), z], d("33.33")), + Ok([d("20.00"), d("13.33"), z]) + ); + assert_eq!( + split_share([d("-60.00"), d("-40.00"), z], d("-33.33")), + Ok([d("-20.00"), d("-13.33"), z]) + ); + // The whole payment: unchanged. + assert_eq!( + split_share([d("1"), d("2"), d("3")], d("6")), + Ok([d("1"), d("2"), d("3")]) + ); + // A cent in thirds: the cent to the first of equal remainders. + assert_eq!( + split_share([d("1.00"), d("1.00"), d("1.00")], d("0.01")), + Ok([d("0.01"), z, z]) + ); + // Nothing paid, or no share: nothing. + assert_eq!(split_share([z, z, z], d("5")), Ok([z, z, z])); + assert_eq!(split_share([d("5"), z, z], z), Ok([z, z, z])); + // Past exact arithmetic: refused, never wrapped. + let big = d("900000000000000"); + assert!(matches!( + split_share([big, big, z], big), + Err(LedgerError::TooLargeToCompute(_)) + )); + } + + fn dated(effective: Option<&str>, decision: Decision1099) -> Dated1099 { + Dated1099 { + effective: effective.map(|s| s.parse().unwrap()), + decision, + source: None, + } + } + + /// plan_decision SCENARIOS 1-4, in_force and check_new_decision + /// SCENARIO 1, by hand. + #[test] + fn dated_decisions_take_effect_on_their_dates() { + let nec = Decision1099::Files(Form1099::Nec); + let corp = Decision1099::not_reportable(ExemptReason::Corporation, "elected").unwrap(); + let mut ds = Vec::new(); + assert_eq!( + apply_decision(&mut ds, dated(None, nec.clone())), + Applied::Set + ); + assert_eq!( + apply_decision(&mut ds, dated(Some("2026-12-30"), corp.clone())), + Applied::Set + ); + let on = |ds: &[Dated1099], s: &str| { + in_force(ds, s.parse().unwrap()).map(|d| d.decision.clone()) + }; + assert_eq!(on(&ds, "2026-12-29"), Some(nec.clone())); + assert_eq!(on(&ds, "2026-12-30"), Some(corp.clone())); + assert_eq!(on(&ds, "2030-01-01"), Some(corp.clone())); + // Recorded exactly so: unchanged. + assert_eq!( + plan_decision(&ds, &dated(Some("2026-12-30"), corp.clone())), + Applied::Unchanged + ); + // A dated decision the same as before its date: no change there. + assert_eq!( + plan_decision(&ds, &dated(Some("2026-06-01"), nec.clone())), + Applied::Unchanged + ); + // Undoing the change: taken out. + assert_eq!( + apply_decision(&mut ds, dated(Some("2026-12-30"), nec.clone())), + Applied::Removed + ); + assert_eq!(ds, vec![dated(None, nec.clone())]); + // Kept in date order, the undated first, one per date. + apply_decision(&mut ds, dated(Some("2027-03-01"), corp.clone())); + apply_decision(&mut ds, dated(Some("2026-03-01"), corp.clone())); + let dates: Vec<_> = ds.iter().map(|d| d.effective).collect(); + assert_eq!( + dates, + [ + None, + Some("2026-03-01".parse().unwrap()), + Some("2027-03-01".parse().unwrap()) + ] + ); + // Nothing decided before a dated decision: refused. + let fresh: Vec = vec![dated(None, Decision1099::Undetermined)]; + assert!(check_new_decision(&fresh, &dated(Some("2026-05-01"), nec.clone())).is_err()); + assert!(check_new_decision(&[], &dated(Some("2026-05-01"), nec.clone())).is_err()); + assert_eq!( + check_new_decision(&fresh, &dated(None, nec.clone())), + Ok(Applied::Set) + ); + } + + #[test] + fn decisions_say_what_they_are() { + let corp = + Decision1099::not_reportable(ExemptReason::Corporation, " a corporation by name ") + .unwrap(); + assert_eq!(corp.key(), "not_reportable"); + assert_eq!(corp.form(), None); + assert_eq!( + corp.describe(), + "not reportable: a corporation (a corporation by name)" + ); + assert_eq!(Decision1099::Files(Form1099::Misc).key(), "misc"); + assert_eq!(Decision1099::Undetermined.key(), "undetermined"); + for bad in ["", "two\nlines", "TIN 123-45-6789"] { + assert!( + Decision1099::not_reportable(ExemptReason::Other, bad).is_err(), + "{bad:?}" + ); + } + let long = "x".repeat(crate::settings::MAX_TEXT + 1); + assert!(Decision1099::not_reportable(ExemptReason::Other, &long).is_err()); + assert!( + Decision1099::NotReportable { + kind: ExemptReason::Other, + reason: " padded".into() + } + .check() + .is_err() + ); + for r in ExemptReason::ALL { + assert_eq!(ExemptReason::parse(r.key()), Some(r)); + assert_eq!(r.citation().is_none(), r == ExemptReason::Other); + } + assert_eq!(ExemptReason::parse("card"), None); + } } diff --git a/crates/ledger-core/src/instruments.rs b/crates/ledger-core/src/instruments.rs new file mode 100644 index 0000000..727a64c --- /dev/null +++ b/crates/ledger-core/src/instruments.rs @@ -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 { + 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, + /// Whose it is (a member's card), if given. Never used to classify. + pub owner_party: Option, + /// 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 { + 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"); + } +} diff --git a/crates/ledger-core/src/k1.rs b/crates/ledger-core/src/k1.rs index f8024a8..f933887 100644 --- a/crates/ledger-core/src/k1.rs +++ b/crates/ledger-core/src/k1.rs @@ -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); diff --git a/crates/ledger-core/src/kernel.rs b/crates/ledger-core/src/kernel.rs index 40d659c..68c0598 100644 --- a/crates/ledger-core/src/kernel.rs +++ b/crates/ledger-core/src/kernel.rs @@ -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(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 { + 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(lines: &[(Option, 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(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. diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index 620d294..5778ad5 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -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, + /// The payment methods ([`crate::instruments`]), by id. + instruments: std::collections::BTreeMap, + next_instrument_id: crate::InstrumentId, + /// The payment method each entry was paid with, for 1099 reporting. + entry_instruments: HashMap, /// Each invoice's payments (entry, amount), for aging as of a date. invoice_payments: HashMap>, /// 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 { + 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, + new: crate::form1099::Dated1099, tin_on_file: bool, - ) -> Result<(), LedgerError> { + ) -> Result { + 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 { 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 { + 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, + account: AccountId, + ) -> Result { + 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, + account: AccountId, + ) -> Result { + 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 + '_ { + 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 { + 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 { + 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, + ) -> 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(()) } diff --git a/crates/ledger-core/src/lib.rs b/crates/ledger-core/src/lib.rs index f244766..466461e 100644 --- a/crates/ledger-core/src/lib.rs +++ b/crates/ledger-core/src/lib.rs @@ -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::{ diff --git a/crates/ledger-core/src/parties.rs b/crates/ledger-core/src/parties.rs index d441a1c..4d0d52e 100644 --- a/crates/ledger-core/src/parties.rs +++ b/crates/ledger-core/src/parties.rs @@ -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 { - ledger + let recorded = ledger.card_accounts(); + if !recorded.is_empty() { + return recorded.into_iter().collect(); + } + let not_cards: std::collections::BTreeSet = ledger + .instruments() + .filter(|i| i.kind != crate::InstrumentKind::BusinessCard) + .map(|i| i.account) + .collect(); + let mut cards: Vec = ledger .chart() .all() .filter(|a| { @@ -709,9 +736,13 @@ pub fn card_accounts(ledger: &crate::Ledger) -> Vec { 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, diff --git a/crates/ledger-core/src/proofs.rs b/crates/ledger-core/src/proofs.rs index fbd5946..c2fa050 100644 --- a/crates/ledger-core/src/proofs.rs +++ b/crates/ledger-core/src/proofs.rs @@ -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, 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] diff --git a/crates/ledger-core/tests/bookspec.rs b/crates/ledger-core/tests/bookspec.rs index 6ed3293..ec64b71 100644 --- a/crates/ledger-core/tests/bookspec.rs +++ b/crates/ledger-core/tests/bookspec.rs @@ -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, #[serde(default)] payees: Vec, + /// A payee's 1099 decision from a date on (rules.md B-19, D-40). + #[serde(default)] + status_changes: Vec, + /// The ways the company (or a member, for it) pays (rules.md B-19). + #[serde(default)] + instruments: Vec, /// 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, + #[serde(default)] + reason: Option, +} + +/// 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, + /// The party it belongs to, by name (informational). + #[serde(default)] + owner: Option, +} + /// 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, + /// The payment method (an `setup.instruments` label) it was paid with. + #[serde(default)] + instrument: Option, } /// A bill from `vendor` for `amount`, dated and due on the event's date: @@ -287,6 +330,8 @@ struct Expect { party: Option, /// `party_totals`' basis: `"cash"` or `"all"` (rules.md B-35 item 4). basis: Option, + /// The payee a `vendor_1099` query asks about (in `setup.payees`). + payee: Option, /// The books after event N, not after the last. after: Option, /// The account a `rename_account` or `set_tax_category` command @@ -482,6 +527,42 @@ struct Books<'a> { payees: BTreeMap, /// The vendors bills came from, by name (a payee's record if one). vendors: BTreeMap, + /// The payment methods, by label. + instruments: BTreeMap, +} + +/// 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 { 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, + ) + }); +} diff --git a/crates/ledger-core/tests/form1099.proptest-regressions b/crates/ledger-core/tests/form1099.proptest-regressions new file mode 100644 index 0000000..dd032c9 --- /dev/null +++ b/crates/ledger-core/tests/form1099.proptest-regressions @@ -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)] diff --git a/crates/ledger-core/tests/form1099.rs b/crates/ledger-core/tests/form1099.rs index 79e5286..bf6d5cf 100644 --- a/crates/ledger-core/tests/form1099.rs +++ b/crates/ledger-core/tests/form1099.rs @@ -1,19 +1,27 @@ -//! 1099 tracking. Numbers computed by hand. +//! 1099 tracking. Numbers computed by hand. Payments count out of the +//! books' cash account (1000) and, where the books name one, their +//! financing liability (Due to Members); every name is fictional. //! -//! 2026 payments: +//! `books()`, 2026 payments: //! - Dana (1099-NEC, W-9): bill 1,500 paid by check + 700 direct = 2,200, //! over the 2,000 threshold. A 300 payment on 2025-12-30 counts in 2025. //! - Ace Rentals (1099-MISC): 1,200 twice, one reversed = 1,200, under. -//! - Sam (1099-NEC): 5,000 by credit card = 0 cash: the card processor's -//! 1099-K covers it. -//! - Bigco (no 1099): 9,000, listed but never reportable. +//! - Sam (1099-NEC): 5,000 charged to a card (2100: a liability no payment +//! comes out of, and no payment method names it) = nothing counts. +//! - Bigco (never decided): 9,000 from the bank: undetermined, a decision +//! is needed, never reportable until one is made (defect 18). use chrono::NaiveDate; use ledger_core::arap::DocumentLine; -use ledger_core::form1099::default_threshold; +use ledger_core::form1099::{Applied, default_threshold}; +use ledger_core::kernel::{self, Bucket, PayRole, Tag}; +use ledger_core::settings::Source; use ledger_core::{ - AccountType, Currency, Form1099, Ledger, LedgerError, Money, Side, vendor_1099_report, + AccountId, AccountType, Currency, Dated1099, Decision1099, EntryId, ExemptReason, Form1099, + InstrumentKind, Ledger, LedgerError, Money, NewAsset, PaymentAccounts, Side, Status1099, + Vendor1099Line, Vendor1099Report, VendorId, vendor_1099_report, }; +use proptest::prelude::*; use rust_decimal::Decimal; use std::str::FromStr; @@ -25,6 +33,98 @@ fn d(y: i32, m: u32, day: u32) -> NaiveDate { NaiveDate::from_ymd_opt(y, m, day).unwrap() } +/// Who decided every decision here (a fictional member). +fn src() -> Source { + Source { + decided_by: "Riley Chen".into(), + decided_on: d(2026, 1, 5), + citation: None, + note: None, + } +} + +/// `decision` for `vendor` from the beginning, decided by Riley Chen. +fn decide(l: &mut Ledger, vendor: VendorId, decision: Decision1099, tin_on_file: bool) { + l.set_vendor_1099( + vendor, + Dated1099::from_beginning(decision, Some(src())), + tin_on_file, + ) + .unwrap(); +} + +/// `decision` for `vendor` from `effective` on (keeping the W-9 flag). +fn decide_from(l: &mut Ledger, vendor: VendorId, effective: NaiveDate, decision: Decision1099) { + let tin = l.get_vendor(vendor).unwrap().tin_on_file; + l.set_vendor_1099( + vendor, + Dated1099 { + effective: Some(effective), + decision, + source: Some(src()), + }, + tin, + ) + .unwrap(); +} + +fn nec() -> Decision1099 { + Decision1099::Files(Form1099::Nec) +} + +fn corp(reason: &str) -> Decision1099 { + Decision1099::not_reportable(ExemptReason::Corporation, reason).unwrap() +} + +/// The report out of the code 1000 cash account and `financing` codes. +fn report_for( + l: &Ledger, + year: i32, + threshold: Option, + financing: &[&str], +) -> Result { + let id = |c: &str| l.chart().find_by_code(c).unwrap().id; + vendor_1099_report( + l, + year, + threshold, + &PaymentAccounts { + cash: vec![id("1000")], + financing: financing.iter().map(|c| id(c)).collect(), + }, + &[], + None, + ) +} + +fn line<'a>(r: &'a Vendor1099Report, name: &str) -> &'a Vendor1099Line { + r.vendors + .iter() + .find(|v| v.name == name) + .unwrap_or_else(|| panic!("{name} not on the worksheet: {r:?}")) +} + +/// Post `amt` debited to `dr`, credited to `cr`, on `date`, tagged with +/// `vendor` when given. +fn pay( + l: &mut Ledger, + date: NaiveDate, + vendor: Option, + dr: AccountId, + cr: AccountId, + amt: &str, +) -> EntryId { + let e = l.create_entry(date, "payment"); + 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(); + if let Some(v) = vendor { + l.set_entry_vendor(e, Some(v)).unwrap(); + } + e +} + fn books() -> Ledger { let mut l = Ledger::new(Currency::USD); let bank = l @@ -43,18 +143,9 @@ fn books() -> Ledger { let ace = l.add_vendor("Ace Rentals").unwrap(); let sam = l.add_vendor("Sam").unwrap(); let bigco = l.add_vendor("Bigco").unwrap(); - l.set_vendor_1099(dana, Some(Form1099::Nec), true).unwrap(); - l.set_vendor_1099(ace, Some(Form1099::Misc), false).unwrap(); - l.set_vendor_1099(sam, Some(Form1099::Nec), true).unwrap(); - let pay = |l: &mut Ledger, date, vendor, dr, cr, amt: &str| { - let e = l.create_entry(date, "payment"); - 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(); - l.set_entry_vendor(e, Some(vendor)).unwrap(); - e - }; + decide(&mut l, dana, nec(), true); + decide(&mut l, ace, Decision1099::Files(Form1099::Misc), false); + decide(&mut l, sam, nec(), true); let bill = l .create_bill( dana, @@ -67,62 +158,134 @@ fn books() -> Ledger { ) .unwrap(); l.pay_bill(bill, d(2026, 3, 20), dec("1500"), bank).unwrap(); - pay(&mut l, d(2026, 6, 1), dana, contract, bank, "700"); - pay(&mut l, d(2025, 12, 30), dana, contract, bank, "300"); - pay(&mut l, d(2026, 1, 1), ace, rent, bank, "1200"); - let wrong = pay(&mut l, d(2026, 2, 1), ace, rent, bank, "1200"); + pay(&mut l, d(2026, 6, 1), Some(dana), contract, bank, "700"); + pay(&mut l, d(2025, 12, 30), Some(dana), contract, bank, "300"); + pay(&mut l, d(2026, 1, 1), Some(ace), rent, bank, "1200"); + let wrong = pay(&mut l, d(2026, 2, 1), Some(ace), rent, bank, "1200"); l.reverse_entry(wrong, d(2026, 2, 2), "duplicate").unwrap(); - pay(&mut l, d(2026, 4, 1), sam, contract, card, "5000"); - pay(&mut l, d(2026, 5, 1), bigco, contract, bank, "9000"); + pay(&mut l, d(2026, 4, 1), Some(sam), contract, card, "5000"); + pay(&mut l, d(2026, 5, 1), Some(bigco), contract, bank, "9000"); // Untagged spending is not anyone's 1099. - let e = l.create_entry(d(2026, 7, 1), "untagged"); - let m = Money::new(dec("50"), Currency::USD); - l.add_line(e, contract, Side::Debit, m).unwrap(); - l.add_line(e, bank, Side::Credit, m).unwrap(); - l.post_entry(e).unwrap(); + pay(&mut l, d(2026, 7, 1), None, contract, bank, "50"); l } #[test] fn cash_paid_per_vendor_and_who_is_reportable() { let l = books(); - let r = vendor_1099_report(&l, 2026, None).unwrap(); + let r = report_for(&l, 2026, None, &[]).unwrap(); assert_eq!(r.threshold, dec("2000")); - let rows: Vec<(&str, Decimal, bool)> = r + let rows: Vec<(&str, Decimal, Status1099, bool)> = r .vendors .iter() - .map(|v| (v.name.as_str(), v.paid, v.reportable)) + .map(|v| (v.name.as_str(), v.paid, v.status, v.reportable)) .collect(); assert_eq!( rows, [ - ("Ace Rentals", dec("1200"), false), - ("Bigco", dec("9000"), false), - ("Dana", dec("2200"), true), - ("Sam", dec("0"), false), + ( + "Ace Rentals", + dec("1200"), + Status1099::UnderThreshold, + false + ), + ("Bigco", dec("9000"), Status1099::Undetermined, false), + ("Dana", dec("2200"), Status1099::Reportable, true), + ("Sam", dec("0"), Status1099::UnderThreshold, false), ] ); let ace = &r.vendors[0]; - assert_eq!(ace.form, Some(Form1099::Misc)); + assert_eq!(ace.form(), Some(Form1099::Misc)); + assert_eq!(ace.decided, Some(src())); assert!(!ace.tin_on_file); + // The untagged 50 paid out for contract labor: the quieter note's one. + assert_eq!(r.untagged_payments.len(), 1); } #[test] fn years_and_thresholds() { let l = books(); - // SOURCE: rules.md B-19 (sources/irs-1099-thresholds.md). + // SOURCE: rules.md B-19 (sources/irs-1099-thresholds.md; Pub. L. + // 119-21 §70433(f): payments made after December 31, 2025). assert_eq!(default_threshold(2025), Ok(dec("600"))); assert_eq!(default_threshold(2026), Ok(dec("2000"))); - let y2025 = vendor_1099_report(&l, 2025, None).unwrap(); - let dana = y2025.vendors.iter().find(|v| v.name == "Dana").unwrap(); + let y2025 = report_for(&l, 2025, None, &[]).unwrap(); + let dana = line(&y2025, "Dana"); assert_eq!(dana.paid, dec("300")); - assert!(!dana.reportable, "300 < 600"); + assert_eq!(dana.status, Status1099::UnderThreshold, "300 < 600"); // An override: at 1,000, Ace's 1,200 is reportable. - let r = vendor_1099_report(&l, 2026, Some(dec("1000"))).unwrap(); - assert!( - r.vendors - .iter() - .any(|v| v.name == "Ace Rentals" && v.reportable) + let r = report_for(&l, 2026, Some(dec("1000")), &[]).unwrap(); + assert!(line(&r, "Ace Rentals").reportable); +} + +/// The threshold is met at exactly the amount ("of $2,000 or more", +/// §6041(a); "equals or exceeds", §6041A(a)(2)), each year's own (bookspec +/// 1099-the-threshold-is-the-years), and so is every bound on an unknown: +/// - 2025, $600: Riley 600.00 from the bank: reportable. +/// - 2026, $2,000: Riley 1,999.99: under; Cedar 2,000.00: reportable; +/// Bigco (never decided) 2,000.00: undetermined (a decision is needed); +/// Tobin 1,500.00 from the bank and 500.00 by a member, method not +/// recorded: 2,000.00 if it counts, so undetermined; 1,499.99 and +/// 500.00: never more than 1,999.99, under. +#[test] +fn the_threshold_is_met_at_exactly_the_amount() { + let mut m = members_books(); + let vendor = |l: &mut Ledger, name: &str, decision: Option| { + let v = l.add_vendor(name).unwrap(); + if let Some(d) = decision { + decide(l, v, d, true); + } + v + }; + let riley = vendor(&mut m.l, "Riley", Some(nec())); + let cedar = vendor(&mut m.l, "Cedar", Some(nec())); + let bigco = vendor(&mut m.l, "Bigco", None); + let tobin = vendor(&mut m.l, "Tobin", Some(nec())); + let ash = vendor(&mut m.l, "Ash", Some(nec())); + let (labor, bank, due) = (m.labor, m.bank, m.due); + for (date, v, cr, amt) in [ + (d(2025, 11, 1), riley, bank, "600.00"), + (d(2026, 2, 1), riley, bank, "1999.99"), + (d(2026, 2, 1), cedar, bank, "2000.00"), + (d(2026, 2, 1), bigco, bank, "2000.00"), + (d(2026, 2, 1), tobin, bank, "1500.00"), + (d(2026, 3, 1), tobin, due, "500.00"), + (d(2026, 2, 1), ash, bank, "1499.99"), + (d(2026, 3, 1), ash, due, "500.00"), + ] { + pay(&mut m.l, date, Some(v), labor, cr, amt); + } + let r = report_for(&m.l, 2025, None, &["2100"]).unwrap(); + assert_eq!(line(&r, "Riley").status, Status1099::Reportable); + let r = report_for(&m.l, 2026, None, &["2100"]).unwrap(); + let status = |n: &str| line(&r, n).status; + assert_eq!(status("Riley"), Status1099::UnderThreshold); + assert_eq!(status("Cedar"), Status1099::Reportable); + assert_eq!(status("Bigco"), Status1099::Undetermined); + assert_eq!(status("Tobin"), Status1099::Undetermined); + assert_eq!(status("Ash"), Status1099::UnderThreshold); +} + +/// The status never settles an undecided part of the year (review R3): a +/// vendor decided 1099-NEC, then undetermined from 2026-07-01 (its status +/// in question), paid 2,100.00 in March and refunded 200.00 in August. +/// Decided 1099-NEC, the August refund makes it 1,900.00, under; decided +/// not reportable, March's 2,100.00 stands, reportable: undetermined, and +/// no decision is asked for money that only came back. +#[test] +fn an_undecided_refund_never_settles_the_status() { + let mut m = members_books(); + let v = m.l.add_vendor("Harbor Design LLC").unwrap(); + decide(&mut m.l, v, nec(), true); + decide_from(&mut m.l, v, d(2026, 7, 1), Decision1099::Undetermined); + let (labor, bank) = (m.labor, m.bank); + pay(&mut m.l, d(2026, 3, 1), Some(v), labor, bank, "2100.00"); + pay(&mut m.l, d(2026, 8, 1), Some(v), bank, labor, "200.00"); + let r = report_for(&m.l, 2026, None, &["2100"]).unwrap(); + let h = line(&r, "Harbor Design LLC"); + assert_eq!( + (h.paid, h.status, h.decision_needed), + (dec("1900.00"), Status1099::Undetermined, false) ); } @@ -184,16 +347,64 @@ fn an_unknown_year_threshold_is_never_guessed() { } let l = books(); assert!(matches!( - vendor_1099_report(&l, 2027, None), + report_for(&l, 2027, None, &[]), Err(LedgerError::RuleNotKnown { .. }) )); // An explicit threshold (from the IRS, or the owner or CPA) works. - let r = vendor_1099_report(&l, 2027, Some(dec("2100"))).unwrap(); + let r = report_for(&l, 2027, Some(dec("2100")), &[]).unwrap(); assert_eq!(r.threshold, dec("2100")); } -/// Spec: a dated rule is due for review from its review date, and stays -/// due; before it, nothing is stale. The engine has no clock: callers pass +/// P3 review R5 and R16: the worksheet never guesses where money is paid +/// from (every asset was a guess that counted a prepayment as nothing), +/// and never compares another currency against dollars. +#[test] +fn the_report_needs_the_cash_accounts_and_dollars() { + let l = books(); + let err = + vendor_1099_report(&l, 2026, None, &PaymentAccounts::default(), &[], None).unwrap_err(); + assert!(matches!(err, LedgerError::Invalid1099(_)), "{err:?}"); + assert!(err.to_string().contains("cash accounts"), "{err}"); + let mut eur = Ledger::new(Currency::new("EUR").unwrap()); + let bank = eur + .add_account("1000", "Bank", AccountType::Asset, None, false) + .unwrap(); + let sources = PaymentAccounts { + cash: vec![bank], + financing: vec![], + }; + let err = vendor_1099_report(&eur, 2026, None, &sources, &[], None).unwrap_err(); + assert!(err.to_string().contains("US dollars"), "{err}"); + assert!(vendor_1099_report(&eur, 2026, Some(dec("1800")), &sources, &[], None).is_ok()); +} + +/// A report through a day (an as-of package, P3 review R9) counts the +/// year's payments on or before it only, and says so; through the year's +/// end or later it is the whole year. Dana's 2026: 1,500.00 on 03-20 and +/// 700.00 on 06-01 (books()). +#[test] +fn a_report_through_a_day_stops_there() { + let l = books(); + let id = l.chart().find_by_code("1000").unwrap().id; + let sources = PaymentAccounts { + cash: vec![id], + financing: vec![], + }; + let through = + |t: NaiveDate| vendor_1099_report(&l, 2026, None, &sources, &[], Some(t)).unwrap(); + let dana = |r: &Vendor1099Report| line(r, "Dana").paid; + let r = through(d(2026, 5, 31)); + assert_eq!((dana(&r), r.through), (dec("1500"), Some(d(2026, 5, 31)))); + let r = through(d(2026, 6, 1)); + assert_eq!(dana(&r), dec("2200"), "a payment on the day counts"); + for t in [d(2026, 12, 31), d(2027, 3, 1)] { + let r = through(t); + assert_eq!((dana(&r), r.through), (dec("2200"), None)); + } +} + +/// Dated rules are due for review from their review date, and stay due; +/// before it, nothing is stale. The engine has no clock: callers pass /// today. #[test] fn dated_rules_go_stale_on_their_review_date() { @@ -213,27 +424,30 @@ fn dated_rules_go_stale_on_their_review_date() { fn forms_have_their_irs_names() { assert_eq!(Form1099::Nec.label(), "1099-NEC"); assert_eq!(Form1099::Misc.label(), "1099-MISC"); - // A label parses back to its form. + // A label (and a key) parses back to its form. for f in [Form1099::Nec, Form1099::Misc] { assert_eq!(Form1099::parse(f.label()), Some(f)); + assert_eq!(Form1099::parse(f.key()), Some(f)); } } -/// vendor_1099_report_with SCENARIOS 1–2, worked by hand. Payment sources: +/// vendor_1099_report SCENARIOS 1-2, worked by hand. Payment sources: /// cash 1000 and the members' financing liability 2200. Kim (1099-MISC, /// landlord) in 2026: /// - 1,000 rent from the bank (Dr rent, Cr 1000): +1,000 /// - 2,400 prepaid rent from the bank (Dr 1300, Cr 1000): +2,400 (paid /// when made; the prepaid asset is not a source, so it doesn't offset) -/// - 800 rent paid by a member (Dr rent, Cr 2200): +800 -/// - 500 rent on the card (Dr rent, Cr 2100): 0 (1099-K) +/// - 800 rent paid by a member (Dr rent, Cr 2200): undetermined until its +/// payment method is recorded +/// - 500 rent on the card (Dr rent, Cr 2100): nothing (no payment method +/// names 2100, and it is no payment source: the processor's 1099-K) /// - 300 refunded to the bank (Dr 1000, Cr rent): −300 /// -/// Total 3,900. The plain report (every asset a source, no financing) -/// sees 1,000 + 0 + 0 + 0 − 300 = 700: the understatement this fixes. +/// Counts 3,100, undetermined 800: reportable (3,100 is over 2,000 even +/// if the 800 doesn't count). The member's check: 3,900; their card: +/// 3,100 with 800 excluded. #[test] fn payments_come_from_cash_and_financing_only() { - use ledger_core::{PaymentAccounts, vendor_1099_report_with}; let mut l = Ledger::new(Currency::USD); let acct = |l: &mut Ledger, code: &str, t| l.add_account(code, code, t, None, false).unwrap(); let bank = acct(&mut l, "1000", AccountType::Asset); @@ -242,7 +456,8 @@ fn payments_come_from_cash_and_financing_only() { let members = acct(&mut l, "2200", AccountType::Liability); let rent = acct(&mut l, "6600", AccountType::Expense); let kim = l.add_vendor("Kim").unwrap(); - l.set_vendor_1099(kim, Some(Form1099::Misc), true).unwrap(); + decide(&mut l, kim, Decision1099::Files(Form1099::Misc), true); + let mut member_paid = 0; for (dr, cr, amt) in [ (rent, bank, "1000"), (prepaid, bank, "2400"), @@ -250,32 +465,1247 @@ fn payments_come_from_cash_and_financing_only() { (rent, card, "500"), (bank, rent, "300"), ] { - let e = l.create_entry(d(2026, 5, 1), "rent"); - 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(); - l.set_entry_vendor(e, Some(kim)).unwrap(); + let e = pay(&mut l, d(2026, 5, 1), Some(kim), dr, cr, amt); + if cr == members { + member_paid = e; + } } - let sources = PaymentAccounts { - cash: vec![bank], - financing: vec![members], - }; - let r = vendor_1099_report_with(&l, 2026, None, &sources).unwrap(); - assert_eq!(r.vendors[0].paid, dec("3900")); - assert!(r.vendors[0].reportable, "3,900 is over 2,000"); - let plain = vendor_1099_report(&l, 2026, None).unwrap(); - assert_eq!(plain.vendors[0].paid, dec("700")); + let kim_line = |l: &Ledger| line(&report_for(l, 2026, None, &["2200"]).unwrap(), "Kim").clone(); + let k = kim_line(&l); + assert_eq!((k.paid, k.undetermined), (dec("3100"), dec("800"))); + assert_eq!(k.status, Status1099::Reportable); + let check = l + .add_instrument( + InstrumentKind::MemberCashOrCheck, + "Member checks", + None, + None, + members, + ) + .unwrap(); + l.set_entry_instrument(member_paid, Some(check)).unwrap(); + let k = kim_line(&l); + assert_eq!((k.paid, k.undetermined), (dec("3900"), dec("0"))); + let visa = l + .add_instrument( + InstrumentKind::MemberCard, + "Member Visa", + None, + None, + members, + ) + .unwrap(); + l.set_entry_instrument(member_paid, Some(visa)).unwrap(); + let k = kim_line(&l); + assert_eq!( + (k.paid, k.excluded_card, k.undetermined), + (dec("3100"), dec("800"), dec("0")) + ); } -/// Books for the shared-payment tests: bank 1000, card 2050, contract -/// labor 6000; Dana (1099-NEC) and member Tobin as parties. +/// D-40's cutover, with made-up amounts: a vendor decided not reportable +/// (a corporation, from the public record) and one left undecided until a +/// W-9 shows what it is, each paid only with a member's personal card. +/// The first is not reportable, the second under the threshold, and +/// Needs attention asks about neither (nothing it could be decided +/// changes the year). Untagged, the undecided one's payment is +/// undetermined, so a decision is needed. +#[test] +fn card_only_vendors_need_no_decision() { + let mut m = members_books(); + let visa = + m.l.add_instrument( + InstrumentKind::MemberCard, + "Riley's Visa", + None, + None, + m.due, + ) + .unwrap(); + let anthropic = m.l.add_vendor("Anthropic, PBC").unwrap(); + decide( + &mut m.l, + anthropic, + corp("a corporation (Delaware public benefit corporation, from the public record)"), + false, + ); + let google = m.l.add_vendor("Google LLC").unwrap(); + let (labor, due) = (m.labor, m.due); + let a = pay( + &mut m.l, + d(2026, 4, 1), + Some(anthropic), + labor, + due, + "40.00", + ); + let g = pay(&mut m.l, d(2026, 4, 2), Some(google), labor, due, "15.00"); + let rows = |l: &Ledger| -> Vec<(Decimal, Decimal, Status1099, bool)> { + let r = report_for(l, 2026, None, &["2100"]).unwrap(); + ["Anthropic, PBC", "Google LLC"] + .into_iter() + .map(|n| { + let v = line(&r, n); + (v.excluded_card, v.undetermined, v.status, v.decision_needed) + }) + .collect() + }; + assert_eq!( + rows(&m.l), + [ + (dec("0"), dec("0"), Status1099::NotReportable, false), + (dec("0"), dec("15.00"), Status1099::UnderThreshold, true), + ] + ); + m.l.set_entry_instrument(a, Some(visa)).unwrap(); + m.l.set_entry_instrument(g, Some(visa)).unwrap(); + assert_eq!( + rows(&m.l), + [ + (dec("40.00"), dec("0"), Status1099::NotReportable, false), + (dec("15.00"), dec("0"), Status1099::UnderThreshold, false), + ] + ); +} + +/// Defect 18, from its reproduction: Bigco, never decided and paid 9,000, +/// and Bigco decided "not reportable: a corporation" are different lines. +/// reportable is false both times; the status (and where the 9,000 goes) +/// is what tells them apart. +#[test] +fn undecided_is_not_not_reportable() { + let mut l = books(); + let r = report_for(&l, 2026, None, &[]).unwrap(); + let b = line(&r, "Bigco"); + assert_eq!(b.decision, Decision1099::Undetermined); + assert_eq!(b.decided, None); + assert_eq!(b.status, Status1099::Undetermined); + assert!(b.decision_needed); + assert_eq!((b.paid, b.not_reportable), (dec("9000"), dec("0"))); + assert!(!b.reportable); + let bigco = b.vendor_id; + decide(&mut l, bigco, corp("a corporation by name"), false); + let r = report_for(&l, 2026, None, &[]).unwrap(); + let b = line(&r, "Bigco"); + assert_eq!(b.decision, corp("a corporation by name")); + assert_eq!(b.decided, Some(src())); + assert_eq!(b.status, Status1099::NotReportable); + assert!(!b.decision_needed); + assert_eq!((b.paid, b.not_reportable), (dec("0"), dec("9000"))); + assert!(!b.reportable); +} + +/// The defect 12 books: bank 1000 (cash), Due to Members 2100 +/// (financing), a card 2050, contract labor 6000; Dana (1099-NEC, W-9). +struct Members { + l: Ledger, + bank: AccountId, + due: AccountId, + card: AccountId, + labor: AccountId, + dana: VendorId, +} + +fn members_books() -> Members { + let mut l = Ledger::new(Currency::USD); + let bank = l + .add_account("1000", "Bank", AccountType::Asset, None, false) + .unwrap(); + let card = l + .add_account("2050", "Harbor Visa", AccountType::Liability, None, false) + .unwrap(); + let due = l + .add_account( + "2100", + "Due to Members", + AccountType::Liability, + None, + false, + ) + .unwrap(); + let labor = l + .add_account("6000", "Contract Labor", AccountType::Expense, None, false) + .unwrap(); + let dana = l.add_vendor("Dana").unwrap(); + decide(&mut l, dana, nec(), true); + Members { + l, + bank, + due, + card, + labor, + dana, + } +} + +fn dana(m: &Members) -> Vendor1099Line { + line(&report_for(&m.l, 2026, None, &["2100"]).unwrap(), "Dana").clone() +} + +/// Defect 12, from its reproduction: a member paid Dana 2,500.00 for the +/// company (Dr 6000 / Cr 2100). With no payment method recorded it is +/// undetermined (their card would be excluded, their check would count); +/// tagged with the member's own card it is excluded, a card payment on +/// Form 1099-K whoever's card it was (Treas. Reg. §1.6041-1(a)(1)(iv)); +/// tagged with their check it counts, the company being the payor +/// (§1.6041-1(e)(1)): reportable at 2,500.00 ≥ 2,000. +#[test] +fn a_members_personal_card_payment_is_not_a_1099_payment() { + let mut m = members_books(); + let e1 = pay( + &mut m.l, + d(2026, 5, 1), + Some(m.dana), + m.labor, + m.due, + "2500.00", + ); + let v = dana(&m); + assert_eq!( + (v.paid, v.excluded_card, v.undetermined), + (dec("0"), dec("0"), dec("2500.00")) + ); + assert_eq!(v.status, Status1099::Undetermined); + let r = report_for(&m.l, 2026, None, &["2100"]).unwrap(); + assert_eq!(r.undetermined_payments.len(), 1); + assert_eq!( + ( + r.undetermined_payments[0].entry_id, + r.undetermined_payments[0].amount + ), + (e1, dec("2500.00")) + ); + let visa = + m.l.add_instrument( + InstrumentKind::MemberCard, + "Riley's Visa", + Some("4242"), + None, + m.due, + ) + .unwrap(); + m.l.set_entry_instrument(e1, Some(visa)).unwrap(); + let v = dana(&m); + assert_eq!( + (v.paid, v.excluded_card, v.undetermined), + (dec("0"), dec("2500.00"), dec("0")) + ); + assert_eq!(v.status, Status1099::UnderThreshold); + let r = report_for(&m.l, 2026, None, &["2100"]).unwrap(); + assert!(r.undetermined_payments.is_empty()); + assert_eq!(r.excluded_card_payments.len(), 1); + assert_eq!(r.excluded_card_payments[0].instrument, Some(visa)); + let check = + m.l.add_instrument( + InstrumentKind::MemberCashOrCheck, + "Riley's checks", + None, + None, + m.due, + ) + .unwrap(); + m.l.set_entry_instrument(e1, Some(check)).unwrap(); + let v = dana(&m); + assert_eq!((v.paid, v.excluded_card), (dec("2500.00"), dec("0"))); + assert_eq!(v.status, Status1099::Reportable); +} + +/// Defect 12, the card account: Sam's 5,000.00 charged to the company card +/// (Dr 6000 / Cr 2050). With the card recorded as a payment method, its +/// account's lines are card payments: excluded and listed, not dropped +/// silently. With no payment method naming 2050 it is no payment line at +/// all, as before. +#[test] +fn card_account_payments_are_listed_not_dropped() { + let mut m = members_books(); + let sam = m.l.add_vendor("Sam").unwrap(); + decide(&mut m.l, sam, nec(), true); + let e2 = pay( + &mut m.l, + d(2026, 6, 1), + Some(sam), + m.labor, + m.card, + "5000.00", + ); + let sam_line = + |m: &Members| line(&report_for(&m.l, 2026, None, &["2100"]).unwrap(), "Sam").clone(); + let s = sam_line(&m); + assert_eq!((s.paid, s.excluded_card), (dec("0"), dec("0"))); + m.l.add_instrument( + InstrumentKind::BusinessCard, + "Harbor Visa", + Some("0042"), + None, + m.card, + ) + .unwrap(); + let s = sam_line(&m); + assert_eq!((s.paid, s.excluded_card), (dec("0"), dec("5000.00"))); + let r = report_for(&m.l, 2026, None, &["2100"]).unwrap(); + assert_eq!(r.excluded_card_payments.len(), 1); + let p = &r.excluded_card_payments[0]; + assert_eq!( + (p.entry_id, p.amount, p.instrument), + (e2, dec("5000.00"), None) + ); +} + +/// A debit card is a payment card (IRS, "Understanding your Form 1099-K": +/// "Credit, debit or stored value cards"): a bank payment tagged with a +/// debit card drawn on that account is excluded. Untagged, the same +/// payment counts (presumed check, ACH or transfer: D-40), and so does +/// every other untagged bank payment, the card notwithstanding. +#[test] +fn a_debit_card_payment_from_the_bank_is_excluded_when_tagged() { + let mut m = members_books(); + let e = pay( + &mut m.l, + d(2026, 2, 1), + Some(m.dana), + m.labor, + m.bank, + "1200.00", + ); + pay( + &mut m.l, + d(2026, 3, 1), + Some(m.dana), + m.labor, + m.bank, + "300.00", + ); + assert_eq!(dana(&m).paid, dec("1500.00")); + let debit = + m.l.add_instrument( + InstrumentKind::BusinessCard, + "Harbor debit card", + None, + None, + m.bank, + ) + .unwrap(); + assert_eq!(dana(&m).paid, dec("1500.00"), "untagged still counts"); + m.l.set_entry_instrument(e, Some(debit)).unwrap(); + let v = dana(&m); + assert_eq!((v.paid, v.excluded_card), (dec("300.00"), dec("1200.00"))); +} + +/// Never guessed (B-19): only a company card's account presumes a card. +/// The members' account 2100, left out of the financing accounts, carries +/// a member's card; a platform's balance 1050 carries a payment platform. +/// Each also pays by check or friends-and-family transfer (counting), so +/// an untagged payment through either is undetermined, never excluded as +/// a card's: 2,500.00 and 300.00 undetermined. Tagged with the platform, +/// the 300.00 is excluded; with the member's card, the 2,500.00. +#[test] +fn a_members_or_a_platforms_account_is_never_presumed_a_card() { + let mut m = members_books(); + let balance = + m.l.add_account("1050", "Platform balance", AccountType::Asset, None, false) + .unwrap(); + let visa = + m.l.add_instrument( + InstrumentKind::MemberCard, + "Riley's Visa", + None, + None, + m.due, + ) + .unwrap(); + let pay_platform = + m.l.add_instrument(InstrumentKind::Platform, "Harbor Pay", None, None, balance) + .unwrap(); + let (labor, due, dana) = (m.labor, m.due, Some(m.dana)); + let member = pay(&mut m.l, d(2026, 5, 1), dana, labor, due, "2500.00"); + let network = pay(&mut m.l, d(2026, 6, 1), dana, labor, balance, "300.00"); + // No financing accounts named: 2100 is known only through the method. + let line_of = |l: &Ledger| line(&report_for(l, 2026, None, &[]).unwrap(), "Dana").clone(); + let v = line_of(&m.l); + assert_eq!( + (v.paid, v.excluded_card, v.undetermined, v.status), + (dec("0"), dec("0"), dec("2800.00"), Status1099::Undetermined) + ); + m.l.set_entry_instrument(network, Some(pay_platform)) + .unwrap(); + m.l.set_entry_instrument(member, Some(visa)).unwrap(); + let v = line_of(&m.l); + assert_eq!( + (v.paid, v.excluded_card, v.undetermined, v.status), + ( + dec("0"), + dec("2800.00"), + dec("0"), + Status1099::UnderThreshold + ) + ); +} + +/// P3 review R2: a tag names one account. A member's payment (Cr 2100) +/// tagged with the company card (whose account is 2050) isn't described +/// by the tag: undetermined. +#[test] +fn a_tag_naming_another_account_is_undetermined() { + let mut m = members_books(); + let e = pay( + &mut m.l, + d(2026, 4, 1), + Some(m.dana), + m.labor, + m.due, + "700.00", + ); + let visa = + m.l.add_instrument( + InstrumentKind::BusinessCard, + "Harbor Visa", + None, + None, + m.card, + ) + .unwrap(); + m.l.set_entry_instrument(e, Some(visa)).unwrap(); + let v = dana(&m); + assert_eq!( + (v.paid, v.excluded_card, v.undetermined), + (dec("0"), dec("0"), dec("700.00")) + ); +} + +/// P3 review R1: money moved between the company's own sources pays no +/// one. A loan's principal paid to the lender (Dr 2500 Loan, a financing +/// account / Cr 1000, tagged with the lender) and a member reimbursed (Dr +/// 2100 / Cr 1000, tagged with Dana) each net to zero in every bucket, +/// though each line alone would count or be undetermined. +#[test] +fn paying_down_a_financing_liability_pays_no_vendor() { + let mut m = members_books(); + let loan = + m.l.add_account( + "2500", + "Equipment Loan", + AccountType::Liability, + None, + false, + ) + .unwrap(); + let lender = m.l.add_vendor("Harbor Equipment Finance").unwrap(); + decide(&mut m.l, lender, nec(), false); + pay( + &mut m.l, + d(2026, 3, 1), + Some(lender), + loan, + m.bank, + "3000.00", + ); + pay( + &mut m.l, + d(2026, 3, 2), + Some(m.dana), + m.due, + m.bank, + "80.00", + ); + pay( + &mut m.l, + d(2026, 3, 3), + Some(m.dana), + m.labor, + m.bank, + "100.00", + ); + let r = report_for(&m.l, 2026, None, &["2100", "2500"]).unwrap(); + let f = line(&r, "Harbor Equipment Finance"); + assert_eq!( + (f.paid, f.excluded_card, f.undetermined), + (dec("0"), dec("0"), dec("0")) + ); + assert_eq!(f.status, Status1099::UnderThreshold); + let v = line(&r, "Dana"); + assert_eq!((v.paid, v.undetermined), (dec("100.00"), dec("0"))); + assert!(r.undetermined_payments.is_empty()); +} + +/// P3 review R1: the card bill (Dr 2050, a card's account / Cr 1000), +/// tagged with the card's issuer, pays the issuer nothing toward a 1099. +#[test] +fn paying_the_card_bill_pays_no_vendor() { + let mut m = members_books(); + m.l.add_instrument( + InstrumentKind::BusinessCard, + "Harbor Visa", + None, + None, + m.card, + ) + .unwrap(); + let issuer = m.l.add_vendor("Harbor Card Services").unwrap(); + decide(&mut m.l, issuer, nec(), false); + pay( + &mut m.l, + d(2026, 5, 25), + Some(issuer), + m.card, + m.bank, + "1200.00", + ); + let r = report_for(&m.l, 2026, None, &["2100"]).unwrap(); + let f = line(&r, "Harbor Card Services"); + assert_eq!( + (f.paid, f.excluded_card, f.undetermined), + (dec("0"), dec("0"), dec("0")) + ); + assert!(r.excluded_card_payments.is_empty()); +} + +/// P3 review R3: an unknown that could go either way never settles the +/// status. Dana paid 3,000.00 from the bank, then 1,500.00 refunded to a +/// member (Dr 2100 / Cr 6000), how it was paid unknown: what counts is +/// between 1,500.00 and 3,000.00, so neither reportable nor under the +/// threshold. Refunded to the member's check: 1,500.00 counts, under; +/// to their card: 3,000.00 counts, reportable. +#[test] +fn a_negative_unknown_keeps_a_reportable_vendor_undetermined() { + let mut m = members_books(); + pay( + &mut m.l, + d(2026, 2, 1), + Some(m.dana), + m.labor, + m.bank, + "3000.00", + ); + let refund = pay( + &mut m.l, + d(2026, 3, 1), + Some(m.dana), + m.due, + m.labor, + "1500.00", + ); + let v = dana(&m); + assert_eq!((v.paid, v.undetermined), (dec("3000.00"), dec("-1500.00"))); + assert_eq!(v.status, Status1099::Undetermined); + let check = + m.l.add_instrument( + InstrumentKind::MemberCashOrCheck, + "Riley's checks", + None, + None, + m.due, + ) + .unwrap(); + m.l.set_entry_instrument(refund, Some(check)).unwrap(); + let v = dana(&m); + assert_eq!( + (v.paid, v.status), + (dec("1500.00"), Status1099::UnderThreshold) + ); + let visa = + m.l.add_instrument( + InstrumentKind::MemberCard, + "Riley's Visa", + None, + None, + m.due, + ) + .unwrap(); + m.l.set_entry_instrument(refund, Some(visa)).unwrap(); + let v = dana(&m); + assert_eq!( + (v.paid, v.excluded_card, v.status), + (dec("3000.00"), dec("-1500.00"), Status1099::Reportable) + ); +} + +/// P3 review R6: money received from a contractor for something else +/// doesn't lower its 1099. Dana paid 3,000.00; a laptop sold to her for +/// 500.00 (Dr 1000 / Cr 4900 Other Income) and its reversal, and an old +/// monitor disposed of to her for 400.00 (Dr 1000 400, Dr 1510 600 / Cr +/// 1500 1,000, the asset's cost account), are left out and listed. A +/// refund of 200.00 (Dr 1000 / Cr 6000) nets: 2,800.00. +#[test] +fn a_sale_to_a_contractor_does_not_lower_its_1099() { + let mut m = members_books(); + let acct = |l: &mut Ledger, code: &str, t| l.add_account(code, code, t, None, false).unwrap(); + let income = acct(&mut m.l, "4900", AccountType::Revenue); + let equipment = acct(&mut m.l, "1500", AccountType::Asset); + let accum = acct(&mut m.l, "1510", AccountType::Asset); + let depr = acct(&mut m.l, "6800", AccountType::Expense); + m.l.register_asset(NewAsset { + description: "Monitor".into(), + purchase_date: d(2024, 1, 1), + cost: Money::new(dec("1000"), Currency::USD), + salvage_value: Money::new(dec("0"), Currency::USD), + useful_life_months: 36, + cost_account: equipment, + accum_depr_account: accum, + expense_account: depr, + }) + .unwrap(); + pay( + &mut m.l, + d(2026, 2, 1), + Some(m.dana), + m.labor, + m.bank, + "3000.00", + ); + let sale = pay( + &mut m.l, + d(2026, 4, 1), + Some(m.dana), + m.bank, + income, + "500.00", + ); + let undo = + m.l.reverse_entry(sale, d(2026, 4, 2), "wrong buyer") + .unwrap(); + let disposal = m.l.create_entry(d(2026, 5, 1), "monitor sold"); + for (a, side, amt) in [ + (m.bank, Side::Debit, "400.00"), + (accum, Side::Debit, "600.00"), + (equipment, Side::Credit, "1000.00"), + ] { + m.l.add_line(disposal, a, side, Money::new(dec(amt), Currency::USD)) + .unwrap(); + } + m.l.post_entry(disposal).unwrap(); + m.l.set_entry_vendor(disposal, Some(m.dana)).unwrap(); + pay( + &mut m.l, + d(2026, 6, 1), + Some(m.dana), + m.bank, + m.labor, + "200.00", + ); + let r = report_for(&m.l, 2026, None, &["2100"]).unwrap(); + let v = line(&r, "Dana"); + assert_eq!(v.paid, dec("2800.00")); + assert_eq!(v.status, Status1099::Reportable); + let other: Vec<(EntryId, Decimal)> = r + .other_receipts + .iter() + .map(|p| (p.entry_id, p.amount)) + .collect(); + assert_eq!( + other, + [ + (sale, dec("-500.00")), + (undo, dec("500.00")), + (disposal, dec("-400.00")) + ] + ); +} + +/// P3 review R6, money in that is no refund: only what gives back what a +/// payment paid for nets. Dana, a contractor who is also a customer, +/// paid 3,000.00 from the bank. Her payment of an invoice (Dr 1000 / Cr +/// 1200 Accounts Receivable, 450.00) and a loan she made the company (Dr +/// 1000 / Cr 2600 Loan payable, 1,000.00) are money received for +/// something else: left out and listed. A 500.00 deposit paid her (Dr 1400 +/// / Cr 1000) counts when paid, and its return (Dr 1000 / Cr 1400) nets as +/// a refund. 3,000.00 counts: reportable (netting the receipts would have +/// left 1,550.00, under the threshold). +#[test] +fn money_from_a_vendor_that_is_no_refund_does_not_lower_its_1099() { + let mut m = members_books(); + let acct = |l: &mut Ledger, code: &str, t| l.add_account(code, code, t, None, false).unwrap(); + let ar = acct(&mut m.l, "1200", AccountType::Asset); + let deposit = acct(&mut m.l, "1400", AccountType::Asset); + let loan = acct(&mut m.l, "2600", AccountType::Liability); + assert_eq!(m.l.role_account(&ledger_core::RECEIVABLES), Some(ar)); + let (bank, labor, dana) = (m.bank, m.labor, Some(m.dana)); + pay(&mut m.l, d(2026, 2, 1), dana, labor, bank, "3000.00"); + let invoice = pay(&mut m.l, d(2026, 3, 1), dana, bank, ar, "450.00"); + let lent = pay(&mut m.l, d(2026, 4, 1), dana, bank, loan, "1000.00"); + pay(&mut m.l, d(2026, 5, 1), dana, deposit, bank, "500.00"); + pay(&mut m.l, d(2026, 6, 1), dana, bank, deposit, "500.00"); + let r = report_for(&m.l, 2026, None, &["2100"]).unwrap(); + let v = line(&r, "Dana"); + assert_eq!((v.paid, v.status), (dec("3000.00"), Status1099::Reportable)); + let other: Vec<(EntryId, Decimal)> = r + .other_receipts + .iter() + .map(|p| (p.entry_id, p.amount)) + .collect(); + assert_eq!(other, [(invoice, dec("-450.00")), (lent, dec("-1000.00"))]); +} + +/// D-40, a status change mid-year: Harbor Design LLC, 1099-NEC from the +/// beginning, elects to be taxed as a corporation effective 2026-07-01 +/// (same EIN, same vendor). Paid from the bank 1,500.00 on 03-01 and +/// 1,000.00 on 06-30 (while NEC: 2,500.00, reportable) and 800.00 on 07-01 +/// and 1,200.00 on 09-15 (while not reportable: 2,000.00). +#[test] +fn a_status_change_mid_year_applies_by_payment_date() { + let mut m = members_books(); + let harbor = m.l.add_vendor("Harbor Design LLC").unwrap(); + decide(&mut m.l, harbor, nec(), true); + let elected = corp("elected to be taxed as a corporation (Form 8832)"); + decide_from(&mut m.l, harbor, d(2026, 7, 1), elected.clone()); + for (date, amt) in [ + (d(2026, 3, 1), "1500.00"), + (d(2026, 6, 30), "1000.00"), + (d(2026, 7, 1), "800.00"), + (d(2026, 9, 15), "1200.00"), + ] { + pay(&mut m.l, date, Some(harbor), m.labor, m.bank, amt); + } + let r = report_for(&m.l, 2026, None, &["2100"]).unwrap(); + let h = line(&r, "Harbor Design LLC"); + assert_eq!((h.paid, h.not_reportable), (dec("2500.00"), dec("2000.00"))); + assert_eq!(h.status, Status1099::Reportable); + assert_eq!(h.decision, elected); + assert_eq!(h.effective, Some(d(2026, 7, 1))); + let changes: Vec<_> = h + .decisions + .iter() + .map(|x| (x.effective, x.decision.clone())) + .collect(); + assert_eq!(changes, [(None, nec()), (Some(d(2026, 7, 1)), elected)]); + // 2027 (the threshold passed in): all of it not reportable. + pay( + &mut m.l, + d(2027, 2, 1), + Some(harbor), + m.labor, + m.bank, + "5000.00", + ); + let r = report_for(&m.l, 2027, Some(dec("2000")), &["2100"]).unwrap(); + let h = line(&r, "Harbor Design LLC"); + assert_eq!((h.paid, h.not_reportable), (dec("0"), dec("5000.00"))); + assert_eq!(h.status, Status1099::NotReportable); + assert_eq!(h.decisions.len(), 1); +} + +/// D-40, two changes in one year, and a reversal crossing them: Tidepool +/// Studio, 1099-NEC from the beginning, 1099-MISC from 2026-03-01 (it +/// rents the company space), not reportable from 2026-09-01 (a corporate +/// election). Paid 2,500.00 on 02-01, 700.00 on 04-01 and 900.00 on 10-01 +/// (each in force on its date: NEC, MISC, not reportable). The February +/// payment, reversed on 10-15 (after both changes), takes February's +/// decision and nets to nothing: 700.00 counts, under the threshold; +/// 900.00 not reportable. The year shows all three decisions in order. +#[test] +fn two_status_changes_in_a_year_and_a_reversal_across_them() { + let mut m = members_books(); + let v = m.l.add_vendor("Tidepool Studio").unwrap(); + decide(&mut m.l, v, nec(), true); + let misc = Decision1099::Files(Form1099::Misc); + decide_from(&mut m.l, v, d(2026, 3, 1), misc.clone()); + let elected = corp("elected to be taxed as a corporation"); + decide_from(&mut m.l, v, d(2026, 9, 1), elected.clone()); + let (labor, bank) = (m.labor, m.bank); + let feb = pay(&mut m.l, d(2026, 2, 1), Some(v), labor, bank, "2500.00"); + pay(&mut m.l, d(2026, 4, 1), Some(v), labor, bank, "700.00"); + pay(&mut m.l, d(2026, 10, 1), Some(v), labor, bank, "900.00"); + let r = report_for(&m.l, 2026, None, &["2100"]).unwrap(); + assert_eq!(line(&r, "Tidepool Studio").status, Status1099::Reportable); + m.l.reverse_entry(feb, d(2026, 10, 15), "paid in error") + .unwrap(); + let r = report_for(&m.l, 2026, None, &["2100"]).unwrap(); + let t = line(&r, "Tidepool Studio"); + assert_eq!( + (t.paid, t.not_reportable, t.status), + (dec("700.00"), dec("900.00"), Status1099::UnderThreshold) + ); + let changes: Vec<_> = t + .decisions + .iter() + .map(|x| (x.effective, x.decision.clone())) + .collect(); + assert_eq!( + changes, + [ + (None, nec()), + (Some(d(2026, 3, 1)), misc), + (Some(d(2026, 9, 1)), elected.clone()) + ] + ); + assert_eq!( + (t.decision.clone(), t.effective), + (elected, Some(d(2026, 9, 1))) + ); + // A change on the year's last day is one of the year's, and its + // decision the year's last; the next year opens with it. + let back = Decision1099::Files(Form1099::Nec); + decide_from(&mut m.l, v, d(2026, 12, 31), back.clone()); + let r = report_for(&m.l, 2026, None, &["2100"]).unwrap(); + let t = line(&r, "Tidepool Studio"); + assert_eq!(t.decisions.len(), 4); + assert_eq!( + (t.decision.clone(), t.effective), + (back.clone(), Some(d(2026, 12, 31))) + ); + let r = report_for(&m.l, 2027, Some(dec("2000")), &["2100"]).unwrap(); + let t = line(&r, "Tidepool Studio"); + assert_eq!( + t.decisions + .iter() + .map(|x| x.decision.clone()) + .collect::>(), + [back] + ); +} + +/// D-40, a payee that incorporates on December 30 is a new legal person: +/// a new vendor record with its own W-9 and decision. Riley Chen (sole +/// proprietor, 1099-NEC) was paid 1,900.00 on 12-29, under the threshold; +/// Riley Chen Design, Inc. (a corporation by name) 500.00 on 12-30 and +/// 300.00 on 12-31: 800.00 not reportable. Neither is reportable, and the +/// one doesn't add to the other. +#[test] +fn an_incorporation_on_december_30_is_a_new_vendor() { + let mut m = members_books(); + let riley = m.l.add_vendor("Riley Chen").unwrap(); + decide(&mut m.l, riley, nec(), true); + let inc = m.l.add_vendor("Riley Chen Design, Inc.").unwrap(); + decide(&mut m.l, inc, corp("a corporation by name"), false); + pay( + &mut m.l, + d(2026, 12, 29), + Some(riley), + m.labor, + m.bank, + "1900.00", + ); + pay( + &mut m.l, + d(2026, 12, 30), + Some(inc), + m.labor, + m.bank, + "500.00", + ); + pay( + &mut m.l, + d(2026, 12, 31), + Some(inc), + m.labor, + m.bank, + "300.00", + ); + let r = report_for(&m.l, 2026, None, &["2100"]).unwrap(); + let a = line(&r, "Riley Chen"); + assert_eq!( + (a.paid, a.status), + (dec("1900.00"), Status1099::UnderThreshold) + ); + assert!(a.tin_on_file); + let b = line(&r, "Riley Chen Design, Inc."); + assert_eq!( + (b.paid, b.not_reportable, b.status), + (dec("0"), dec("800.00"), Status1099::NotReportable) + ); + assert!(!b.tin_on_file); +} + +/// D-40, an LLC electing corporate taxation keeps its EIN and its vendor +/// record: its status changes on the election's effective date +/// (§301.7701-3(c)(1)(iii)), here 2026-12-30. 2,100.00 on 12-15 counts +/// (reportable); 400.00 on 12-30 doesn't. Its 12-15 payment reversed on +/// 2027-01-05 takes the decision in force on the payment's own date +/// (1099-NEC), so 2027 counts −2,100.00, not "not reportable". +#[test] +fn an_llc_electing_corporate_taxation_keeps_its_vendor() { + let mut m = members_books(); + let harbor = m.l.add_vendor("Harbor Design LLC").unwrap(); + decide(&mut m.l, harbor, nec(), true); + decide_from( + &mut m.l, + harbor, + d(2026, 12, 30), + corp("elected S corporation status"), + ); + let paid = pay( + &mut m.l, + d(2026, 12, 15), + Some(harbor), + m.labor, + m.bank, + "2100.00", + ); + pay( + &mut m.l, + d(2026, 12, 30), + Some(harbor), + m.labor, + m.bank, + "400.00", + ); + let r = report_for(&m.l, 2026, None, &["2100"]).unwrap(); + let h = line(&r, "Harbor Design LLC"); + assert_eq!( + (h.paid, h.not_reportable, h.status), + (dec("2100.00"), dec("400.00"), Status1099::Reportable) + ); + m.l.reverse_entry(paid, d(2027, 1, 5), "paid twice") + .unwrap(); + let r = report_for(&m.l, 2027, Some(dec("2000")), &["2100"]).unwrap(); + let h = line(&r, "Harbor Design LLC"); + assert_eq!( + (h.paid, h.not_reportable, h.status), + (dec("-2100.00"), dec("0"), Status1099::UnderThreshold) + ); +} + +/// D-40: a dated decision records a change. With nothing decided before +/// its date it is refused (it would leave every earlier payment +/// undetermined); the same as the decision before its date changes +/// nothing; undoing a change takes it out. +#[test] +fn a_dated_decision_records_a_change() { + let mut m = members_books(); + let v = m.l.add_vendor("Tidepool Studio").unwrap(); + let dated = |decision| Dated1099 { + effective: Some(d(2026, 6, 1)), + decision, + source: Some(src()), + }; + let err = m.l.set_vendor_1099(v, dated(nec()), false).unwrap_err(); + assert!(matches!(err, LedgerError::Invalid1099(_)), "{err:?}"); + assert!(err.to_string().contains("from the beginning"), "{err}"); + assert!(m.l.get_vendor(v).unwrap().decisions_1099.is_empty()); + decide(&mut m.l, v, nec(), false); + assert_eq!( + m.l.set_vendor_1099(v, dated(nec()), false), + Ok(Applied::Unchanged) + ); + assert_eq!( + m.l.set_vendor_1099(v, dated(corp("elected")), false), + Ok(Applied::Set) + ); + assert_eq!(m.l.get_vendor(v).unwrap().decisions_1099.len(), 2); + assert_eq!( + m.l.set_vendor_1099(v, dated(nec()), false), + Ok(Applied::Removed) + ); + assert_eq!(m.l.get_vendor(v).unwrap().decisions_1099.len(), 1); + // A decision for a date counts nothing decided before it, its own + // date's included: with the status from the beginning back to + // undetermined, the 06-01 change can't be replaced either. + m.l.set_vendor_1099(v, dated(corp("elected")), false) + .unwrap(); + decide(&mut m.l, v, Decision1099::Undetermined, false); + assert!(m.l.set_vendor_1099(v, dated(nec()), false).is_err()); + // A decision needs sound words and a sound source. + let bad = Dated1099::from_beginning( + Decision1099::NotReportable { + kind: ExemptReason::Other, + reason: "".into(), + }, + Some(src()), + ); + assert!(m.l.set_vendor_1099(v, bad, false).is_err()); + let unnamed = Dated1099::from_beginning( + nec(), + Some(Source { + decided_by: " ".into(), + ..src() + }), + ); + assert!(m.l.set_vendor_1099(v, unnamed, false).is_err()); + assert!(matches!( + m.l.set_vendor_1099(999, Dated1099::from_beginning(nec(), Some(src())), false), + Err(LedgerError::UnknownVendor(999)) + )); +} + +/// D-40's warning names what a change reaches: recording "not reportable +/// from 2026-07-01" changes the decision for the July and September +/// payments, not March's; from the beginning, all three; recorded again +/// exactly so, none. +#[test] +fn a_decision_change_names_the_payments_it_reaches() { + let mut m = members_books(); + let harbor = m.l.add_vendor("Harbor Design LLC").unwrap(); + decide(&mut m.l, harbor, nec(), true); + let e: Vec = [d(2026, 3, 1), d(2026, 7, 1), d(2026, 9, 15)] + .into_iter() + .map(|date| pay(&mut m.l, date, Some(harbor), m.labor, m.bank, "100.00")) + .collect(); + let from_july = Dated1099 { + effective: Some(d(2026, 7, 1)), + decision: corp("elected"), + source: Some(src()), + }; + let reached = |l: &Ledger, new: &Dated1099| -> Vec { + ledger_core::form1099::decision_changes(l, harbor, new) + .unwrap() + .into_iter() + .map(|(id, _)| id) + .collect() + }; + assert_eq!(reached(&m.l, &from_july), [e[1], e[2]]); + let always = Dated1099::from_beginning(corp("elected"), Some(src())); + assert_eq!(reached(&m.l, &always), e); + m.l.set_vendor_1099(harbor, from_july.clone(), true) + .unwrap(); + assert!(reached(&m.l, &from_july).is_empty()); +} + +/// Plan 5.5 / D-40: a decision written before decisions were kept (no +/// date, no source) is in force from the beginning: a 2020 payment to a +/// vendor whose old line says 1099-NEC counts; an old null reads as +/// undetermined, never as "not reportable". +#[test] +fn an_undated_old_decision_is_in_force_from_the_beginning() { + let mut m = members_books(); + let old = m.l.add_vendor("Ledgerline Bookkeeping").unwrap(); + let blank = m.l.add_vendor("Pinecrest Supply").unwrap(); + m.l.replay_vendor_1099(old, Dated1099::from_beginning(nec(), None), true) + .unwrap(); + m.l.replay_vendor_1099( + blank, + Dated1099::from_beginning(Decision1099::Undetermined, None), + false, + ) + .unwrap(); + pay( + &mut m.l, + d(2020, 5, 1), + Some(old), + m.labor, + m.bank, + "700.00", + ); + pay( + &mut m.l, + d(2020, 5, 2), + Some(blank), + m.labor, + m.bank, + "900.00", + ); + let r = report_for(&m.l, 2020, None, &["2100"]).unwrap(); + let a = line(&r, "Ledgerline Bookkeeping"); + assert_eq!( + (a.paid, a.status, a.decided.clone()), + (dec("700.00"), Status1099::Reportable, None) + ); + let b = line(&r, "Pinecrest Supply"); + assert_eq!( + b.status, + Status1099::Undetermined, + "900 ≥ 600 and undecided" + ); + assert!(b.decision_needed); +} + +/// kernel::payment_bucket, every one of its 16 cases against the B-19 +/// table (a complete check, not a sample). +#[test] +fn payment_bucket_is_the_table_in_all_16_cases() { + use Bucket::*; + let roles = [ + PayRole::Cash, + PayRole::Financing, + PayRole::CardAccount, + PayRole::Other, + ]; + let tags = [Tag::None, Tag::ThisCard, Tag::ThisOther, Tag::Elsewhere]; + let want = |role: PayRole, tag: Tag| match (tag, role) { + (Tag::ThisCard, _) => Some(Card), + (Tag::ThisOther, _) => Some(Counts), + (_, PayRole::Cash) => Some(Counts), + (_, PayRole::Financing) => Some(Undetermined), + (_, PayRole::CardAccount) => Some(Card), + (_, PayRole::Other) => None, + }; + let mut n = 0; + for role in roles { + for tag in tags { + assert_eq!( + kernel::payment_bucket(role, tag), + want(role, tag), + "{role:?} {tag:?}" + ); + n += 1; + } + } + assert_eq!(n, 16); + // Spelled out, the cases that decide money: an untagged member payment + // is undetermined, a card account's line is a card's, a bank line + // counts, and a tag elsewhere changes none of them. + assert_eq!( + kernel::payment_bucket(PayRole::Financing, Tag::None), + Some(Undetermined) + ); + assert_eq!( + kernel::payment_bucket(PayRole::Financing, Tag::Elsewhere), + Some(Undetermined) + ); + assert_eq!( + kernel::payment_bucket(PayRole::CardAccount, Tag::None), + Some(Card) + ); + assert_eq!( + kernel::payment_bucket(PayRole::Cash, Tag::Elsewhere), + Some(Counts) + ); + assert_eq!(kernel::payment_bucket(PayRole::Other, Tag::Elsewhere), None); +} + +/// kernel::split_payment and net_buckets, by hand. +#[test] +fn buckets_net_money_moved_between_the_companys_sources() { + use Bucket::*; + let l = [ + (Some(Counts), Side::Credit, 1000i64), + (Some(Card), Side::Debit, 1000), + (None, Side::Debit, 5), + ]; + assert_eq!(kernel::split_payment(&l), Some([1000, -1000, 0])); + assert_eq!(kernel::net_buckets([1000, -1000, 0]), Some([0, 0, 0])); + // One bucket with the net's sign takes it. + assert_eq!(kernel::net_buckets([1000, -300, 0]), Some([700, 0, 0])); + assert_eq!(kernel::net_buckets([-200, 0, 500]), Some([0, 0, 300])); + // Two with it: unknown which the other offsets. + assert_eq!(kernel::net_buckets([600, 300, -100]), Some([0, 0, 800])); + // One sign: unchanged. + assert_eq!(kernel::net_buckets([600, 300, 0]), Some([600, 300, 0])); + assert_eq!(kernel::net_buckets([-6, 0, -1]), Some([-6, 0, -1])); + assert_eq!(kernel::net_buckets([i64::MAX, i64::MAX, -1]), None); +} + +/// crate::instruments INVARIANTS 1-4: each refusal, one at a time, and +/// nothing added by any of them. +#[test] +fn a_payment_method_is_refused_unless_sound() { + let mut m = members_books(); + let ar = + m.l.add_account( + "1200", + "Accounts Receivable", + AccountType::Asset, + None, + false, + ) + .unwrap(); + let ap = + m.l.add_account( + "2000", + "Accounts Payable", + AccountType::Liability, + None, + false, + ) + .unwrap(); + let header = + m.l.add_account("1001", "Cash accounts", AccountType::Asset, None, true) + .unwrap(); + let equity = + m.l.add_account("3000", "Capital", AccountType::Equity, None, false) + .unwrap(); + let riley = + m.l.add_party("Riley Chen", &[ledger_core::Role::Member]) + .unwrap(); + let k = InstrumentKind::MemberCard; + m.l.add_instrument(k, "Riley's Visa", Some("4242"), Some(riley), m.due) + .unwrap(); + let long = "x".repeat(ledger_core::settings::MAX_TEXT + 1); + let refused: Vec<(&str, Option<&str>, Option, AccountId)> = vec![ + ("", None, None, m.due), + ("two\nlines", None, None, m.due), + ("TIN 123-45-6789", None, None, m.due), + (long.as_str(), None, None, m.due), + (" riley's visa ", None, None, m.due), + ("Other Visa", Some("123"), None, m.due), + ("Other Visa", Some("4111111111111111"), None, m.due), + ("Other Visa", None, Some(999), m.due), + ("Other Visa", None, None, 999), + ("Other Visa", None, None, header), + ("Other Visa", None, None, equity), + ("Other Visa", None, None, m.labor), + ("Other Visa", None, None, ap), + ("Other Visa", None, None, ar), + ]; + for (label, last4, owner, account) in refused { + let r = m.l.add_instrument(k, label, last4, owner, account); + assert!(r.is_err(), "{label:?} {last4:?} {owner:?} {account}"); + } + assert_eq!(m.l.instruments().count(), 1); + assert_eq!(m.l.next_instrument_id(), 2); + assert_eq!(m.l.instrument_by_label("RILEY'S VISA").unwrap().id, 1); +} + +/// set_entry_instrument SCENARIOS 1-3: the tag is metadata on drafts and +/// posted entries alike, carried through the reversal chain both ways, +/// copied to a reversal made later, cleared the same way; an unknown +/// entry or method is refused. +#[test] +fn a_payment_method_tags_the_whole_reversal_chain() { + let mut m = members_books(); + let visa = + m.l.add_instrument( + InstrumentKind::MemberCard, + "Riley's Visa", + None, + None, + m.due, + ) + .unwrap(); + let e = pay( + &mut m.l, + d(2026, 5, 1), + Some(m.dana), + m.labor, + m.due, + "250.00", + ); + let rev = m.l.reverse_entry(e, d(2026, 5, 2), "oops").unwrap(); + let figures = ledger_core::reports::snapshot(&m.l).unwrap(); + m.l.set_entry_instrument(rev, Some(visa)).unwrap(); + assert_eq!(m.l.entry_instrument(e), Some(visa)); + assert_eq!(m.l.entry_instrument(rev), Some(visa)); + assert_eq!( + ledger_core::reports::snapshot(&m.l).unwrap(), + figures, + "no amount moved" + ); + let re = + m.l.reverse_entry(rev, d(2026, 5, 3), "it was right") + .unwrap(); + assert_eq!( + m.l.entry_instrument(re), + Some(visa), + "the reversal step copies it" + ); + m.l.set_entry_instrument(e, None).unwrap(); + assert_eq!( + [ + m.l.entry_instrument(e), + m.l.entry_instrument(rev), + m.l.entry_instrument(re) + ], + [None, None, None] + ); + let draft = m.l.create_entry(d(2026, 6, 1), "draft"); + m.l.set_entry_instrument(draft, Some(visa)).unwrap(); + assert_eq!(m.l.entry_instrument(draft), Some(visa)); + assert!(matches!( + m.l.set_entry_instrument(999, Some(visa)), + Err(LedgerError::UnknownEntry(999)) + )); + assert!(matches!( + m.l.set_entry_instrument(e, Some(99)), + Err(LedgerError::UnknownInstrument(99)) + )); +} + +/// Books shared by the shared-payment tests: bank 1000, card 2050, +/// contract labor 6000; Dana (1099-NEC) and member Tobin as parties. struct Shared { l: Ledger, - bank: ledger_core::AccountId, - card: ledger_core::AccountId, - labor: ledger_core::AccountId, - dana: ledger_core::VendorId, + bank: AccountId, + card: AccountId, + labor: AccountId, + dana: VendorId, dana_p: ledger_core::PartyId, tobin: ledger_core::PartyId, } @@ -292,7 +1722,7 @@ fn shared_books() -> Shared { .add_account("6000", "Contract Labor", AccountType::Expense, None, false) .unwrap(); let dana = l.add_vendor("Dana").unwrap(); - l.set_vendor_1099(dana, Some(Form1099::Nec), true).unwrap(); + decide(&mut l, dana, nec(), true); let dana_p = l.party_of_vendor(dana).unwrap(); let tobin = l.add_party("Tobin", &[ledger_core::Role::Member]).unwrap(); Shared { @@ -310,9 +1740,9 @@ fn shared_books() -> Shared { fn post_shared( s: &mut Shared, date: NaiveDate, - lines: &[(ledger_core::AccountId, Side, &str)], + lines: &[(AccountId, Side, &str)], shares: &[(ledger_core::PartyId, ledger_core::Share)], -) -> ledger_core::EntryId { +) -> EntryId { let e = s.l.create_entry(date, "shared payment"); for (a, side, amt) in lines { s.l.add_line(e, *a, *side, Money::new(dec(amt), Currency::USD)) @@ -327,16 +1757,16 @@ fn post_shared( e } -fn dana_paid(s: &Shared) -> (Decimal, Vec) { - let r = vendor_1099_report(&s.l, 2026, None).unwrap(); +fn dana_paid(s: &Shared) -> (Decimal, Vec) { + let r = report_for(&s.l, 2026, None, &[]).unwrap(); let line = r.vendors.iter().find(|v| v.vendor_id == s.dana).unwrap(); (line.paid, r.unresolved) } -/// vendor_1099_report_with_members SCENARIO 3, by hand: $1,000.00 from -/// the bank (Dr 6000 / Cr 1000), Dana $600.00 and Tobin $400.00 by -/// amount. Payment = target = 1,000.00, so Dana's 1099 counts her share -/// unchanged: 600.00 (not the whole 1,000.00). +/// vendor_1099_report SCENARIO 3, by hand: $1,000.00 from the bank (Dr +/// 6000 / Cr 1000), Dana $600.00 and Tobin $400.00 by amount. Payment = +/// target = 1,000.00, so Dana's 1099 counts her share unchanged: 600.00 +/// (not the whole 1,000.00). #[test] fn a_shared_payment_counts_the_vendors_share() { use ledger_core::Share::Amount; @@ -360,19 +1790,7 @@ fn a_shared_payment_counts_the_vendors_share() { "the vendor tag follows Dana" ); assert_eq!(dana_paid(&s), (dec("600.00"), vec![])); - // With sources and members named, the same. - let r = ledger_core::vendor_1099_report_with_members( - &s.l, - 2026, - None, - None, - &ledger_core::PaymentAccounts { - cash: vec![bank], - financing: vec![], - }, - &[], - ) - .unwrap(); + let r = report_for(&s.l, 2026, None, &[]).unwrap(); assert_eq!(r.vendors[0].paid, dec("600.00")); assert!(!r.vendors[0].reportable, "600.00 is under 2,000"); } @@ -410,7 +1828,7 @@ fn a_percent_split_gives_the_leftover_cent_to_the_first() { /// SCENARIO 6, by hand: SCENARIO 3's payment reversed the same year. The /// reversal carries the shares; its target is −1,000.00, Dana's amount /// resolves to −600.00, and the payment is −1,000.00 = target, so she -/// nets 600.00 − 600.00 = 0.00 (still listed: she is marked for a 1099). +/// nets 600.00 − 600.00 = 0.00 (still listed: she is decided for a 1099). #[test] fn a_reversed_shared_payment_nets_to_zero() { use ledger_core::Share::Amount; @@ -434,12 +1852,12 @@ fn a_reversed_shared_payment_nets_to_zero() { } /// SCENARIO 5, by hand: $1,000.00 of contract labor, 600.00 from the -/// bank and 400.00 on the card, Dana 60% / Tobin 40%. Target (the flow -/// net, the card counted as money) 1,000.00, Dana's share 600.00, the -/// 1099 payment 600.00 (the card is not a payment source): 600.00 × -/// 600.00 / 1,000.00 = 360.00. Then $100.00 with 33.33 from the bank and -/// 66.67 on the card, 50/50: share 50.00, 33.33 × 50.00 / 100.00 = -/// 16.665, rounded half away from zero to 16.67. Total 376.67. +/// bank and 400.00 on a card no payment method names, Dana 60% / Tobin +/// 40%. Target (the flow net, the card counted as money) 1,000.00, Dana's +/// share 600.00, the 1099 payment 600.00 (the card is no payment line): +/// 600.00 × 600.00 / 1,000.00 = 360.00. Then $100.00 with 33.33 from the +/// bank and 66.67 on the card, 50/50: share 50.00, 33.33 × 50.00 / 100.00 +/// = 16.665, rounded half away from zero to 16.67. Total 376.67. #[test] fn a_shared_payment_partly_on_a_card_scales_to_the_payment() { use ledger_core::Share::Percent; @@ -469,7 +1887,58 @@ fn a_shared_payment_partly_on_a_card_scales_to_the_payment() { assert_eq!(dana_paid(&s).0, dec("376.67")); } -/// INVARIANT 4, by hand: amounts of 600.00 and 300.00 on a 1,000.00 +/// P3 review R12, by hand: SCENARIO 5's first payment with the card +/// recorded as a payment method. Netted, the entry paid 600.00 (counts) +/// and 400.00 (card); Dana's 60% is 600.00 of it, split in proportion: +/// 360.00 counts and 240.00 excluded, summing to her share exactly. +/// 100.00 with 33.33 from the bank and 66.67 on the card, 50/50: her +/// share 50.00 splits into 16.665 and 33.335, down to 16.66 and 33.33, +/// the cent short going to the larger remainder (equal: the first), so +/// 16.67 and 33.33. +#[test] +fn a_shared_payment_splits_across_buckets_to_the_cent() { + use ledger_core::Share::Percent; + let mut s = shared_books(); + let card = s.card; + s.l.add_instrument( + InstrumentKind::BusinessCard, + "Harbor Visa", + None, + None, + card, + ) + .unwrap(); + let (bank, labor, dana_p, tobin) = (s.bank, s.labor, s.dana_p, s.tobin); + post_shared( + &mut s, + d(2026, 5, 1), + &[ + (labor, Side::Debit, "1000.00"), + (bank, Side::Credit, "600.00"), + (card, Side::Credit, "400.00"), + ], + &[(dana_p, Percent(dec("60"))), (tobin, Percent(dec("40")))], + ); + let get = |s: &Shared| { + let r = report_for(&s.l, 2026, None, &[]).unwrap(); + let v = line(&r, "Dana"); + (v.paid, v.excluded_card) + }; + assert_eq!(get(&s), (dec("360.00"), dec("240.00"))); + post_shared( + &mut s, + d(2026, 5, 2), + &[ + (labor, Side::Debit, "100.00"), + (bank, Side::Credit, "33.33"), + (card, Side::Credit, "66.67"), + ], + &[(dana_p, Percent(dec("50"))), (tobin, Percent(dec("50")))], + ); + assert_eq!(get(&s), (dec("376.67"), dec("273.33"))); +} + +/// INVARIANT 5, by hand: amounts of 600.00 and 300.00 on a 1,000.00 /// payment don't resolve (shares SCENARIO 5), so the whole 1,000.00 /// counts toward Dana and the entry is listed. A single-party entry /// (300.00, Dana alone) counts in full, as before shares: 1,300.00. @@ -504,7 +1973,7 @@ fn unresolved_shares_count_in_full_and_are_listed() { /// The SCENARIO 3 payment: $1,000.00 from the bank, Dana 600.00 and /// Tobin 400.00 by amount. -fn dana_and_tobin(s: &mut Shared) -> ledger_core::EntryId { +fn dana_and_tobin(s: &mut Shared) -> EntryId { use ledger_core::Share::Amount; let (bank, labor, dana_p, tobin) = (s.bank, s.labor, s.dana_p, s.tobin); post_shared( @@ -660,3 +2129,371 @@ fn a_named_party_gaining_a_vendor_record_keeps_one_vendor_per_entry() { assert_eq!(s.l.get_party(gamma).unwrap().vendor, Some(gv)); assert_eq!(s.l.entry_vendor(g), Some(gv), "the tag follows the party"); } + +// --------------------------------------------------------------------- +// Properties +// --------------------------------------------------------------------- + +/// One generated payment: debit and credit account indexes (into the +/// property books' accounts), cents, month, vendor index, payment method +/// index (0: none), reversed or not. +type Gen = (usize, usize, u32, u32, usize, usize, bool); + +/// Books for the properties: bank 1000 (cash), Due to Members 2100 +/// (financing), a card 2050 (a business card's), contract labor 6000, +/// prepaid 1300, another liability 2001; three vendors decided for a +/// 1099; with methods, the card, a member's card and a member's check. +fn property_books(gens: &[Gen], with_methods: bool) -> (Ledger, Vec, Vec) { + let mut l = Ledger::new(Currency::USD); + let accounts: Vec = [ + ("1000", AccountType::Asset), + ("2100", AccountType::Liability), + ("2050", AccountType::Liability), + ("6000", AccountType::Expense), + ("1300", AccountType::Asset), + ("2001", AccountType::Liability), + ] + .into_iter() + .map(|(c, t)| l.add_account(c, c, t, None, false).unwrap()) + .collect(); + let vendors: Vec = ["Ash", "Birch", "Cedar"] + .into_iter() + .map(|n| { + let v = l.add_vendor(n).unwrap(); + decide(&mut l, v, nec(), false); + v + }) + .collect(); + let mut methods = vec![None]; + if with_methods { + for (k, label, a) in [ + (InstrumentKind::BusinessCard, "Card", accounts[2]), + (InstrumentKind::MemberCard, "Member card", accounts[1]), + ( + InstrumentKind::MemberCashOrCheck, + "Member check", + accounts[1], + ), + ] { + methods.push(Some(l.add_instrument(k, label, None, None, a).unwrap())); + } + } + for &(dr, cr, cents, month, v, method, reversed) in gens { + if dr == cr { + continue; + } + let e = l.create_entry(d(2026, month, 10), "generated"); + let m = Money::new(Decimal::new(i64::from(cents), 2), Currency::USD); + l.add_line(e, accounts[dr], Side::Debit, m).unwrap(); + l.add_line(e, accounts[cr], Side::Credit, m).unwrap(); + l.post_entry(e).unwrap(); + l.set_entry_vendor(e, Some(vendors[v])).unwrap(); + if let Some(i) = methods[method % methods.len()] { + l.set_entry_instrument(e, Some(i)).unwrap(); + } + if reversed { + l.reverse_entry(e, d(2026, month, 20), "undo").unwrap(); + } + } + (l, accounts, vendors) +} + +fn arb_gen(accounts: usize) -> impl Strategy { + ( + 0..accounts, + 0..accounts, + 1u32..500_000, + 1u32..=12, + 0usize..3, + 0usize..4, + any::(), + ) +} + +/// Credits minus debits to `on` over `vendor`'s posted entries. +fn signed_on(l: &Ledger, vendor: VendorId, on: &[AccountId]) -> Decimal { + l.posted_entries() + .filter(|e| l.entry_vendor(e.id()) == Some(vendor)) + .flat_map(|e| e.lines().iter()) + .filter(|ln| on.contains(&ln.account)) + .map(|ln| match ln.side { + Side::Credit => ln.amount.amount, + Side::Debit => -ln.amount.amount, + }) + .sum() +} + +proptest! { + /// The regression property (P3 brief 3.3, with review R5): for books + /// with their cash accounts named and no payment methods or financing + /// accounts, what counts is the old figure exactly (credits minus + /// debits to the cash accounts on each vendor's tagged entries), but + /// for money received from the vendor for something else (review R6: + /// here a credit to a liability, a loan from it), which is left out + /// and listed, each chain's first entry taking money in. + #[test] + fn no_instruments_no_financing_is_the_old_report( + gens in prop::collection::vec(arb_gen(6), 0..12) + ) { + let (l, accounts, vendors) = property_books(&gens, false); + let r = vendor_1099_report( + &l, 2026, None, + &PaymentAccounts { cash: vec![accounts[0]], financing: vec![] }, + &[], None, + ).unwrap(); + for v in &vendors { + let line = r.vendors.iter().find(|x| x.vendor_id == *v).unwrap(); + let other: Decimal = r + .other_receipts + .iter() + .filter(|p| p.vendor_id == *v) + .map(|p| p.amount) + .sum(); + prop_assert_eq!(line.paid + other, signed_on(&l, *v, &accounts[..1])); + prop_assert_eq!(line.excluded_card, Decimal::ZERO); + prop_assert_eq!(line.undetermined, Decimal::ZERO); + } + for p in &r.other_receipts { + let e = l.get_entry(p.entry_id).unwrap(); + prop_assert!(e.reversal_of().is_some() || p.amount < Decimal::ZERO, "{:?}", p); + // Here no account is a financing one or a card's: only a + // credit to a liability (2100, 2050, 2001) is money in for + // something else. + let root = match e.reversal_of() { + Some(o) => l.get_entry(o).unwrap(), + None => e, + }; + prop_assert!( + root.lines().iter().any(|ln| ln.side == Side::Credit + && [accounts[1], accounts[2], accounts[5]].contains(&ln.account)), + "{:?}", + p + ); + } + } + + /// The buckets partition each payment (P3 brief 8.2): for every + /// vendor, counts + card + undetermined, and what it paid the company + /// for something else, is the signed sum of its entries' payment lines + /// (cash, financing, and the card's account), whatever the methods, + /// netting included. + #[test] + fn buckets_partition_each_payment( + gens in prop::collection::vec(arb_gen(6), 0..12) + ) { + let (l, accounts, vendors) = property_books(&gens, true); + let r = vendor_1099_report( + &l, 2026, None, + &PaymentAccounts { cash: vec![accounts[0]], financing: vec![accounts[1]] }, + &[], None, + ).unwrap(); + for v in &vendors { + let line = r.vendors.iter().find(|x| x.vendor_id == *v).unwrap(); + let other: Decimal = r + .other_receipts + .iter() + .filter(|p| p.vendor_id == *v) + .map(|p| p.amount) + .sum(); + prop_assert_eq!( + line.paid + line.excluded_card + line.undetermined + other, + signed_on(&l, *v, &accounts[..3]) + ); + prop_assert_eq!(line.not_reportable, Decimal::ZERO); + } + } + + /// rules.md B-35 item 9: party totals and the 1099 worksheet never + /// contradict. For books whose cash accounts are named, whose company + /// card is recorded as a business-card payment method (or that have + /// no card at all: without the method, 2050's name says nothing, so + /// neither reads it as a card), and whose entries each name one + /// vendor alone and pay it (or take money from it) between one + /// source (cash, the card, the members' financing) and something that + /// isn't one, every vendor's party totals' paid less received (basis + /// cash) is its worksheet's paid + excluded_card + undetermined + + /// not_reportable, plus what the worksheet lists as received from it + /// for something else (those amounts are negative), whatever the + /// methods, refunds and reversals. (Money moved between two sources, + /// a card bill or a reimbursement, is paid to the creditor in party + /// totals and pays no payee on the worksheet: left out here.) + #[test] + fn party_totals_are_the_1099s_buckets( + gens in prop::collection::vec(arb_gen(6), 0..12), + with_methods in any::(), + ) { + let one_source: Vec = gens + .into_iter() + .filter(|g| (g.0 < 3) != (g.1 < 3)) + .collect(); + let (l, accounts, vendors) = property_books(&one_source, with_methods); + let r = vendor_1099_report( + &l, 2026, None, + &PaymentAccounts { cash: vec![accounts[0]], financing: vec![accounts[1]] }, + &[], None, + ).unwrap(); + let money = ledger_core::parties::money_accounts(&l, &[accounts[0]], &[accounts[1]]); + let year = (d(2026, 1, 1), d(2026, 12, 31)); + for v in &vendors { + let party = l.party_of_vendor(*v).unwrap(); + let a = ledger_core::parties::activity_with(&l, party, year, &money, &[]); + prop_assert!(a.unresolved.is_empty(), "{:?}", a.unresolved); + let (paid, received) = a.totals(ledger_core::parties::Basis::Cash); + let other: Decimal = r + .other_receipts + .iter() + .filter(|p| p.vendor_id == *v) + .map(|p| p.amount) + .sum(); + let line = r.vendors.iter().find(|x| x.vendor_id == *v).unwrap(); + prop_assert_eq!( + paid - received, + line.paid + line.excluded_card + line.undetermined + line.not_reportable + other, + "{:?}", + line + ); + } + } + + /// A payment and its reversal sit in the same bucket and net to zero + /// in each (the reversal carries the method): books whose every + /// payment is reversed in the year show nothing anywhere. + #[test] + fn a_reversal_nets_every_bucket_to_zero( + gens in prop::collection::vec(arb_gen(6), 0..10) + ) { + let reversed: Vec = gens + .into_iter() + .map(|g| (g.0, g.1, g.2, g.3, g.4, g.5, true)) + .collect(); + let (l, accounts, _) = property_books(&reversed, true); + let r = vendor_1099_report( + &l, 2026, None, + &PaymentAccounts { cash: vec![accounts[0]], financing: vec![accounts[1]] }, + &[], None, + ).unwrap(); + for line in &r.vendors { + prop_assert_eq!( + (line.paid, line.excluded_card, line.undetermined), + (Decimal::ZERO, Decimal::ZERO, Decimal::ZERO) + ); + prop_assert_eq!(line.status, Status1099::UnderThreshold); + } + } +} + +/// A generated decision: 1099-NEC (2), a corporation (3), else +/// undetermined. +fn gen_decision(k: u8) -> Decision1099 { + match k { + 2 => nec(), + 3 => corp("a corporation by name"), + _ => Decision1099::Undetermined, + } +} + +proptest! { + /// The worksheet ties to the ledger: for every vendor, what counts, + /// what a card paid, what is undetermined, what was paid while not + /// reportable and what was received for something else sum to the + /// signed total of its entries' payment lines in the year (cash, + /// financing and the card's account), whatever its decisions (dated + /// ones included), methods, reversals, sales and receipts; the + /// payment lists sum to their columns; and a vendor left off the + /// worksheet has nothing but receipts for something else. + #[test] + fn the_worksheet_ties_to_the_ledger( + gens in prop::collection::vec(arb_gen(8), 0..14), + // Per vendor: its decision from the beginning, and a change on + // the first of a month (recorded over a decision only). + decisions in prop::collection::vec( + (1u8..4, prop::option::of((1u32..=12, 1u8..4))), + 3, + ), + ) { + // property_books' accounts, then revenue 4000 and receivables + // 1200 (money from a vendor for something else). + let (mut l, mut accounts, vendors) = property_books(&[], true); + for (code, t) in [("4000", AccountType::Revenue), ("1200", AccountType::Asset)] { + accounts.push(l.add_account(code, code, t, None, false).unwrap()); + } + for (v, &(from_start, change)) in vendors.iter().zip(&decisions) { + decide(&mut l, *v, gen_decision(from_start), false); + if from_start >= 2 + && let Some((month, k)) = change + { + decide_from(&mut l, *v, d(2026, month, 1), gen_decision(k)); + } + } + let methods: Vec> = std::iter::once(None) + .chain(l.instruments().map(|i| Some(i.id))) + .collect(); + for &(dr, cr, cents, month, v, method, reversed) in &gens { + if dr == cr { + continue; + } + let e = l.create_entry(d(2026, month, 10), "generated"); + let m = Money::new(Decimal::new(i64::from(cents), 2), Currency::USD); + l.add_line(e, accounts[dr], Side::Debit, m).unwrap(); + l.add_line(e, accounts[cr], Side::Credit, m).unwrap(); + l.post_entry(e).unwrap(); + l.set_entry_vendor(e, Some(vendors[v])).unwrap(); + if let Some(i) = methods[method % methods.len()] { + l.set_entry_instrument(e, Some(i)).unwrap(); + } + if reversed { + l.reverse_entry(e, d(2026, month, 20), "undo").unwrap(); + } + } + let r = vendor_1099_report( + &l, 2026, None, + &PaymentAccounts { cash: vec![accounts[0]], financing: vec![accounts[1]] }, + &[], None, + ).unwrap(); + let of = |list: &[ledger_core::Payment1099], v: VendorId| -> Decimal { + list.iter().filter(|p| p.vendor_id == v).map(|p| p.amount).sum() + }; + for v in &vendors { + let ledger_total = signed_on(&l, *v, &accounts[..3]); + let other = of(&r.other_receipts, *v); + match r.vendors.iter().find(|x| x.vendor_id == *v) { + Some(line) => { + prop_assert_eq!( + line.paid + line.excluded_card + line.undetermined + line.not_reportable + other, + ledger_total + ); + prop_assert_eq!(of(&r.excluded_card_payments, *v), line.excluded_card); + prop_assert_eq!(of(&r.undetermined_payments, *v), line.undetermined); + prop_assert_eq!(line.reportable, line.status == Status1099::Reportable); + } + None => prop_assert_eq!(other, ledger_total), + } + } + } +} + +/// Ledger::card_accounts: the liability accounts company cards pay +/// through, from the payment methods recorded (never an account's name): +/// not a debit card's bank account, a member's card's Due to Members, or a +/// platform's balance. +#[test] +fn card_accounts_are_the_company_cards_liabilities() { + let mut m = members_books(); + assert!(m.l.card_accounts().is_empty(), "no methods recorded: none"); + let paypal = + m.l.add_account("1050", "Payment platform", AccountType::Asset, None, false) + .unwrap(); + for (kind, label, account) in [ + (InstrumentKind::BusinessCard, "Harbor Visa", m.card), + (InstrumentKind::BusinessCard, "Harbor debit card", m.bank), + (InstrumentKind::MemberCard, "Riley's Visa", m.due), + (InstrumentKind::Platform, "Harbor Pay", paypal), + ] { + m.l.add_instrument(kind, label, None, None, account) + .unwrap(); + } + assert_eq!( + m.l.card_accounts().into_iter().collect::>(), + [m.card] + ); +} diff --git a/crates/ledger-core/tests/kernel_bridge.rs b/crates/ledger-core/tests/kernel_bridge.rs index 71fe559..4942d6b 100644 --- a/crates/ledger-core/tests/kernel_bridge.rs +++ b/crates/ledger-core/tests/kernel_bridge.rs @@ -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))); diff --git a/crates/ledger-core/tests/spec_ledger.rs b/crates/ledger-core/tests/spec_ledger.rs index 1d6a64a..5e74ca2 100644 --- a/crates/ledger-core/tests/spec_ledger.rs +++ b/crates/ledger-core/tests/spec_ledger.rs @@ -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( diff --git a/crates/ledger-core/tests/spec_parties.rs b/crates/ledger-core/tests/spec_parties.rs index d5b8019..f2fad5b 100644 --- a/crates/ledger-core/tests/spec_parties.rs +++ b/crates/ledger-core/tests/spec_parties.rs @@ -1689,7 +1689,7 @@ mod shares { members: Vec, } - /// 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 { diff --git a/crates/ledger-core/tests/spec_party_totals.rs b/crates/ledger-core/tests/spec_party_totals.rs index e9e7878..07ba135 100644 --- a/crates/ledger-core/tests/spec_party_totals.rs +++ b/crates/ledger-core/tests/spec_party_totals.rs @@ -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![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 diff --git a/crates/ledger-gui/src/attention.rs b/crates/ledger-gui/src/attention.rs index a356f68..9f866e3 100644 --- a/crates/ledger-gui/src/attention.rs +++ b/crates/ledger-gui/src/attention.rs @@ -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 Vec { 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 = 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::>() + .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 { ), 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 ), }); diff --git a/crates/ledger-gui/src/books.rs b/crates/ledger-gui/src/books.rs index a2cb487..fe52dcf 100644 --- a/crates/ledger-gui/src/books.rs +++ b/crates/ledger-gui/src/books.rs @@ -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 { + 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 { + 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)) } diff --git a/crates/ledger-gui/src/demo.rs b/crates/ledger-gui/src/demo.rs index 9800c79..66d55db 100644 --- a/crates/ledger-gui/src/demo.rs +++ b/crates/ledger-gui/src/demo.rs @@ -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 = [ "Fern & Field Coffee", "Bramble Outdoor Co.", diff --git a/crates/ledger-gui/src/menu.rs b/crates/ledger-gui/src/menu.rs index 8a253b1..59ed5da 100644 --- a/crates/ledger-gui/src/menu.rs +++ b/crates/ledger-gui/src/menu.rs @@ -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)) } diff --git a/crates/ledger-gui/src/pages/company.rs b/crates/ledger-gui/src/pages/company.rs index 0ca6d46..3883e55 100644 --- a/crates/ledger-gui/src/pages/company.rs +++ b/crates/ledger-gui/src/pages/company.rs @@ -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), diff --git a/crates/ledger-gui/src/pages/journal.rs b/crates/ledger-gui/src/pages/journal.rs index 8803225..93410b7 100644 --- a/crates/ledger-gui/src/pages/journal.rs +++ b/crates/ledger-gui/src/pages/journal.rs @@ -631,21 +631,36 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option { 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 { ), ) } + "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); diff --git a/crates/ledger-gui/src/pages/parties.rs b/crates/ledger-gui/src/pages/parties.rs index 86a74a9..4c3f733 100644 --- a/crates/ledger-gui/src/pages/parties.rs +++ b/crates/ledger-gui/src/pages/parties.rs @@ -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(); diff --git a/crates/ledger-gui/src/pages/reports.rs b/crates/ledger-gui/src/pages/reports.rs index 8f3d757..4eb733a 100644 --- a/crates/ledger-gui/src/pages/reports.rs +++ b/crates/ledger-gui/src/pages/reports.rs @@ -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::>() + .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)] diff --git a/crates/ledger-gui/tests/exports.rs b/crates/ledger-gui/tests/exports.rs index d55c68c..acaaa30 100644 --- a/crates/ledger-gui/tests/exports.rs +++ b/crates/ledger-gui/tests/exports.rs @@ -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}" + ); } diff --git a/crates/ledger-gui/tests/parties.rs b/crates/ledger-gui/tests/parties.rs index 8d23ec7..853065b 100644 --- a/crates/ledger-gui/tests/parties.rs +++ b/crates/ledger-gui/tests/parties.rs @@ -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 = 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}"), + ] + ); +} diff --git a/crates/ledger-mcp/handbook/1099.md b/crates/ledger-mcp/handbook/1099.md index 6a97a9d..33fb3db 100644 --- a/crates/ledger-mcp/handbook/1099.md +++ b/crates/ledger-mcp/handbook/1099.md @@ -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). diff --git a/crates/ledger-mcp/handbook/parties-and-tins.md b/crates/ledger-mcp/handbook/parties-and-tins.md index ed6196d..cda97e1 100644 --- a/crates/ledger-mcp/handbook/parties-and-tins.md +++ b/crates/ledger-mcp/handbook/parties-and-tins.md @@ -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. diff --git a/crates/ledger-mcp/src/scopes.rs b/crates/ledger-mcp/src/scopes.rs index 7503413..0400ca5 100644 --- a/crates/ledger-mcp/src/scopes.rs +++ b/crates/ledger-mcp/src/scopes.rs @@ -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). diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index f9ff483..141fbb4 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -557,7 +557,7 @@ fn vendor_1099_report( year: i32, threshold: Option, ) -> Result { - 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, 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, - ) -> Result, String> { + ) -> Result, 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, 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 { + 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), diff --git a/crates/ledger-mcp/src/tools/instruments.rs b/crates/ledger-mcp/src/tools/instruments.rs new file mode 100644 index 0000000..4b19675 --- /dev/null +++ b/crates/ledger-mcp/src/tools/instruments.rs @@ -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, + /// Whose it is (a member's card): party id or name. Informational. + pub owner_party: Option, + /// 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, + /// Whose it is: party id, if recorded. + pub owner_party: Option, + /// 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, +} + +/// 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, +} + +/// 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, +} + +/// 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, +} + +// ---------------------------------------------------------------------- +// 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 { + let ledger = store.ledger(); + let by_id = r + .trim() + .parse::() + .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, 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 { + let mut years: std::collections::BTreeMap, 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::>().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, + ) -> Result, 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, + ) -> Result, 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, + ) -> Result, 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, + })) + } +} diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index 9a3ff6e..ef5fdf0 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -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, + /// 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, /// W-9 on file. pub tin_on_file: bool, /// The party the vendor record belongs to. pub party_id: Option, } +/// 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, + /// With not_reportable: the facts, in words. + pub reason: Option, + /// With not_reportable: the rule that exempts that kind of payee. + pub authority: Option, + /// The day it takes effect, YYYY-MM-DD; absent: from the beginning. + pub effective: Option, + /// Who decided; absent for a vendor nobody decided, or a decision + /// recorded before decisions were kept. + pub decided_by: Option, + /// When they decided, YYYY-MM-DD. + pub decided_on: Option, + /// What it rests on, if anything. + pub citation: Option, + /// Anything kept with it. + pub note: Option, +} + /// 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, + /// 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, + /// With not_reportable: the facts, in words ("a corporation by name", + /// "Delaware public benefit corporation, from the public record"). + pub reason: Option, + /// 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, + /// A W-9 is on file (the TIN itself is never stored). Omit to keep it + /// as it is. + pub tin_on_file: Option, + /// Who decided (a person's name): required with form_1099. + pub decided_by: Option, + /// When they decided, YYYY-MM-DD. Default: today. + pub decided_on: Option, + /// The rule or document it rests on ("Treas. Reg. §1.6041-3(p)(1)", a + /// W-9 dated 2026-03-02). + pub citation: Option, + /// Anything else worth keeping with it. + pub note: Option, +} + +/// 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, } /// 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, + /// 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, + /// 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, } /// 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, + /// 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, /// 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, @@ -452,6 +565,23 @@ pub struct Vendor1099LineOut { pub tin_note: Option, } +/// 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, +} + /// 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, - /// 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, + /// Payments by card or payment network, excluded (Form 1099-K's). + pub excluded_card_payments: Vec, + /// Payments with no payment method recorded where it decides whether + /// they count: record each with books.set_entry_instrument. + pub undetermined_payments: Vec, + /// 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, + /// Posted entries in the year that paid money out with no vendor tag. + pub untagged_entries: Vec, + /// Entries naming several parties whose shares don't resolve (each + /// counted in full toward its vendor). + pub unresolved: Vec, /// Caveats for the human and CPA. pub notes: Vec, /// 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, + /// How it was paid: a payment method's id or label (books.instruments), + /// for 1099 tracking. + pub instrument: Option, /// 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, /// The vendor the entry is tagged with, for 1099 tracking. pub vendor: Option, + /// The payment method it is tagged with (label), for 1099 tracking. + pub instrument: Option, + /// That payment method's id. + pub instrument_id: Option, /// The id of the party the entry is tagged with (its counterparty), /// when it names exactly one. pub party_id: Option, @@ -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, - /// 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, + /// Its vendor record's 1099 decision in force today ("nec", "misc", + /// "not_reportable", "undetermined"). Absent without a vendor record. + pub decision_1099: Option, /// Gross pay on payroll lines naming it, decimal string. pub gross_pay: String, /// Net pay on payroll lines naming it, decimal string. diff --git a/crates/ledger-mcp/tests/mcp_stdio.rs b/crates/ledger-mcp/tests/mcp_stdio.rs index 11daf8b..a389ddb 100644 --- a/crates/ledger-mcp/tests/mcp_stdio.rs +++ b/crates/ledger-mcp/tests/mcp_stdio.rs @@ -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); diff --git a/crates/ledger-mcp/tests/oracle_mcp.rs b/crates/ledger-mcp/tests/oracle_mcp.rs index c2b2001..39f7667 100644 --- a/crates/ledger-mcp/tests/oracle_mcp.rs +++ b/crates/ledger-mcp/tests/oracle_mcp.rs @@ -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, +) -> 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, + through: Option, +) -> 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 = 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( diff --git a/crates/ledger-mcp/tests/spec_1099.rs b/crates/ledger-mcp/tests/spec_1099.rs new file mode 100644 index 0000000..303a616 --- /dev/null +++ b/crates/ledger-mcp/tests/spec_1099.rs @@ -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, + child: Child, + next_id: u64, + data: PathBuf, + validators: HashMap, + /// The books dir when the server owns it: removed after the server + /// stops (fields drop after `Drop::drop`). + books_dir: Option, +} + +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 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 { + std::fs::read(self.data.join("journal.jsonl")).unwrap_or_default() + } + + /// The journal's line types, in order. + fn journal_types(&self) -> Vec { + 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 { + 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 = 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 = 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")); +} diff --git a/crates/ledger-mcp/tests/spec_mcp.rs b/crates/ledger-mcp/tests/spec_mcp.rs index a78c7c2..e63c40b 100644 --- a/crates/ledger-mcp/tests/spec_mcp.rs +++ b/crates/ledger-mcp/tests/spec_mcp.rs @@ -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", diff --git a/crates/ledger-mcp/tests/spec_parties.rs b/crates/ledger-mcp/tests/spec_parties.rs index 2f9dbb2..a1c8ad1 100644 --- a/crates/ledger-mcp/tests/spec_parties.rs +++ b/crates/ledger-mcp/tests/spec_parties.rs @@ -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", diff --git a/crates/ledger-mcp/tests/spec_party_totals.rs b/crates/ledger-mcp/tests/spec_party_totals.rs index b849f18..a51ff45 100644 --- a/crates/ledger-mcp/tests/spec_party_totals.rs +++ b/crates/ledger-mcp/tests/spec_party_totals.rs @@ -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 diff --git a/crates/ledger-mcp/tests/spec_vault.rs b/crates/ledger-mcp/tests/spec_vault.rs index 1ef76fa..71b1b01 100644 --- a/crates/ledger-mcp/tests/spec_vault.rs +++ b/crates/ledger-mcp/tests/spec_vault.rs @@ -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"}), diff --git a/crates/ledger-package/src/lib.rs b/crates/ledger-package/src/lib.rs index 671ed64..be930c5 100644 --- a/crates/ledger-package/src/lib.rs +++ b/crates/ledger-package/src/lib.rs @@ -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, }; diff --git a/crates/ledger-package/src/package.rs b/crates/ledger-package/src/package.rs index 2feac58..f10c2e3 100644 --- a/crates/ledger-package/src/package.rs +++ b/crates/ledger-package/src/package.rs @@ -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)>, /// The zip. pub zip: Vec, - /// 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, /// The receipts in receipts/ (one per entry they are behind). pub receipt_count: usize, @@ -138,16 +140,8 @@ pub fn build_package( 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( )); // 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(¬e) - )); + 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( }, 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 { diff --git a/crates/ledger-package/src/reports.rs b/crates/ledger-package/src/reports.rs index 2cd3422..337013c 100644 --- a/crates/ledger-package/src/reports.rs +++ b/crates/ledger-package/src/reports.rs @@ -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). diff --git a/crates/ledger-package/src/vendor_1099.rs b/crates/ledger-package/src/vendor_1099.rs index c2d0d6c..d40bae5 100644 --- a/crates/ledger-package/src/vendor_1099.rs +++ b/crates/ledger-package/src/vendor_1099.rs @@ -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, threshold: Option, + through: Option, ) -> Result { - 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) -> Option { 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, + /// 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, + /// The date it took effect (none: from the beginning). + pub effective: Option, + /// Every decision in force during the year, in date order. + pub decisions: Vec, /// 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, - /// 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, /// 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, } +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, + /// One line per vendor decided for a 1099 or paid in the year, by name. pub lines: Vec, - /// 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, /// Entries naming several parties whose shares don't resolve (each /// counted in full toward its vendor). pub unresolved: Vec, + /// Payments by card or payment network, excluded. + pub excluded_card_payments: Vec, + /// Payments whose method must be recorded before they can be + /// classified. + pub undetermined_payments: Vec, + /// Money received from a vendor for something else, not netted. + pub other_receipts: Vec, + /// Posted entries that paid money out with no vendor tag. + pub untagged_payments: Vec, /// 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, /// 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, - tins: Option>, -) -> Result { - // 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::>() + .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 { + 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 = 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, + through: Option, + tins: Option>, +) -> Result { + // 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::>() + .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:?}"); + } + } +} diff --git a/crates/ledger-package/tests/package.rs b/crates/ledger-package/tests/package.rs index f1b0aca..eae47b7 100644 --- a/crates/ledger-package/tests/package.rs +++ b/crates/ledger-package/tests/package.rs @@ -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 { 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 { 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 diff --git a/crates/ledger-store/src/form1099.rs b/crates/ledger-store/src/form1099.rs new file mode 100644 index 0000000..3ee42e2 --- /dev/null +++ b/crates/ledger-store/src/form1099.rs @@ -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 { + p.get(k) + .and_then(Value::as_u64) + .ok_or_else(|| corrupt(event, k)) +} + +fn opt_u64(p: &Value, event: &str, k: &str) -> Result, 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, 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 { + 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, +) -> 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), 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)); + } +} diff --git a/crates/ledger-store/src/format.rs b/crates/ledger-store/src/format.rs index 59124d3..02c55f8 100644 --- a/crates/ledger-store/src/format.rs +++ b/crates/ledger-store/src/format.rs @@ -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 diff --git a/crates/ledger-store/src/lib.rs b/crates/ledger-store/src/lib.rs index f9b5b01..5440867 100644 --- a/crates/ledger-store/src/lib.rs +++ b/crates/ledger-store/src/lib.rs @@ -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; diff --git a/crates/ledger-store/src/reconcilable.rs b/crates/ledger-store/src/reconcilable.rs index 73aadc7..e05fcf9 100644 --- a/crates/ledger-store/src/reconcilable.rs +++ b/crates/ledger-store/src/reconcilable.rs @@ -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 { .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 { ledger_core::parties::card_accounts(ledger) } diff --git a/crates/ledger-store/src/schema.rs b/crates/ledger-store/src/schema.rs index 3501ccd..761e4ad 100644 --- a/crates/ledger-store/src/schema.rs +++ b/crates/ledger-store/src/schema.rs @@ -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 +); "#, ]; diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index f0f8db1..b5fcde8 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -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, - 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, + ) -> Result { + 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 { + 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, + account: AccountId, + ) -> Result { + 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, + ) -> Result { + 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, StoreError> { + let up = |e: i64| -> Result, StoreError> { + Ok(conn + .query_row( + "SELECT reversal_of FROM entries WHERE id = ?", + params![e], + |r| r.get::<_, Option>(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 { diff --git a/crates/ledger-store/tests/fixtures/old-books-writer/Cargo.toml b/crates/ledger-store/tests/fixtures/old-books-writer/Cargo.toml index bdb3ecf..061f21f 100644 --- a/crates/ledger-store/tests/fixtures/old-books-writer/Cargo.toml +++ b/crates/ledger-store/tests/fixtures/old-books-writer/Cargo.toml @@ -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 diff --git a/crates/ledger-store/tests/fixtures/old-books-writer/src/bin/p2_1099.rs b/crates/ledger-store/tests/fixtures/old-books-writer/src/bin/p2_1099.rs new file mode 100644 index 0000000..846f8c3 --- /dev/null +++ b/crates/ledger-store/tests/fixtures/old-books-writer/src/bin/p2_1099.rs @@ -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()); +} diff --git a/crates/ledger-store/tests/fixtures/old-books/p2_vendor_1099/expected.json b/crates/ledger-store/tests/fixtures/old-books/p2_vendor_1099/expected.json new file mode 100644 index 0000000..2f67698 --- /dev/null +++ b/crates/ledger-store/tests/fixtures/old-books/p2_vendor_1099/expected.json @@ -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 + } +} diff --git a/crates/ledger-store/tests/fixtures/old-books/p2_vendor_1099/journal.jsonl b/crates/ledger-store/tests/fixtures/old-books/p2_vendor_1099/journal.jsonl new file mode 100644 index 0000000..523c883 --- /dev/null +++ b/crates/ledger-store/tests/fixtures/old-books/p2_vendor_1099/journal.jsonl @@ -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"} diff --git a/crates/ledger-store/tests/form1099.rs b/crates/ledger-store/tests/form1099.rs new file mode 100644 index 0000000..83cdc35 --- /dev/null +++ b/crates/ledger-store/tests/form1099.rs @@ -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, Option, Option, 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 = 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 = 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::>() + .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 = conn + .prepare("SELECT entry_id FROM entry_instruments ORDER BY entry_id") + .unwrap() + .query_map([], |r| r.get(0)) + .unwrap() + .collect::>() + .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::>(), + l.list_periods().into_iter().cloned().collect::>(), + ) + }; + 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::::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, 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::>() + .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 { + 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); +} diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index 010bd89..fbe5aab 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -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), SetEntryVendor(u64, Option), - 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, Option), + SetW9Flag(u64, bool), + /// A payment method: kind, label, account. + AddInstrument(u8, u8, u64), + SetEntryInstrument(u64, Option), 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::()).prop_map(|(v, t)| Op::SetVendor1099(v, t)), + ( + 0u64..4, + 0u8..5, + prop::option::of(0u32..120), + prop::option::of(any::()) + ) + .prop_map(|(v, k, d, t)| Op::SetVendor1099(v, k, d, t)), + (0u64..4, any::()).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, &[]); diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index 08c2728..f564dbb 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -542,8 +542,10 @@ every tool over every book in ledger-store's `tests/fixtures/old-books`. and card accounts due for reconciliation (`{ code, name, reconciled_through?, due_through, next_step }`): the cash accounts (`[books] cash_accounts`, else asset accounts named for cash: cash, - bank, checking, savings, money market), the card accounts (liabilities - named "card", before their first statement), and every other liability + bank, checking, savings, money market), the card accounts (the + business-card payment methods' liabilities, else liabilities named + "card", not "gift", before their first statement: + `ledger_core::parties::card_accounts`), and every other liability with a recorded statement, that have posted activity through the end of last month but no reconciliation (not voided) through it. The viewer's Needs attention uses the same rule (`ledger_store::reconcilable`). @@ -778,23 +780,95 @@ on from it. ## Tools — 1099 tracking -Payments are counted as cash: credits minus debits to asset accounts on -entries tagged with the vendor. Card payments move no asset (the card -processor's 1099-K covers them), so they drop out; reversals of tagged -entries carry the tag and net out. Thresholds: $600 for payments through -2025, $2,000 from 2026 (indexed for inflation after 2026); always -overridable, always for the CPA to confirm. The books keep whether a W-9 -is on file and the TIN's last four; full TINs live only in the TIN vault -("Tools — the TIN vault"). +What counts toward a vendor's 1099 is what left the books' cash accounts +(`books.cash_accounts`) and financing accounts (`books.financing_accounts`) +on entries tagged with the vendor, classified by how each payment was made +(rules.md B-19; plan P3, defects 12, 18 and 23; D-40): + +- out of a cash account with no payment method recorded: counts + (presumed check, ACH or transfer; tag a debit-card payment with the + card); +- by card or payment network (a method of that kind, or a company card's + account): excluded, the processor's Form 1099-K, whoever's card it was + (Treas. Reg. §1.6041-1(a)(1)(iv)); +- through a financing account (a member paid), or the account of a + member's or a platform's method, with no method recorded: + undetermined, until it is (their card: excluded; their own cash or + check: counts, the company being the payor, §1.6041-1(e)(1)). + +Money moved between the company's own sources (the card bill, a member +reimbursed, a loan paid down) pays no one; money received from a vendor +for anything but a refund of what was paid (a sale, its payment of an +invoice, a disposal, a loan) isn't netted and is listed. +Books with no cash accounts named are refused. Thresholds: $600 for +payments through 2025, $2,000 from 2026 (Pub. L. 119-21 §70433; indexed +for inflation after 2026); always overridable, always for the CPA to +confirm. The books keep whether a W-9 is on file and the TIN's last four; +full TINs live only in the TIN vault ("Tools — the TIN vault"). + +Each vendor has a 1099 decision: `nec`, `misc`, `not_reportable` with a +reason kind (`corporation`, `tax_exempt`, `united_states`, `state`, +`foreign_government`, `foreign_person`, `merchandise`, `employee`, `other`) +and the facts in words, or `undetermined` (also what a vendor nobody +decided on reads as). Each is recorded with who decided and when, and the +date it takes effect, or none (from the beginning: the vendor's status +all along, and every decision written before decisions were kept). Each +payment takes the decision in force on its date (its reversal chain's +first entry's). - `books.list_vendors {}` → `{ vendors: [{ id, name, form_1099?, - tin_on_file, party_id }] }`. -- `books.set_vendor_1099 { vendor, form_1099: "nec" | "misc" | "none", - tin_on_file? }` → the vendor. `vendor` is an id or a name. + decision_1099, decisions_1099, tin_on_file, party_id }] }`. + `decision_1099` is the decision in force today, `{ value, reason_kind?, + reason?, authority?, effective?, decided_by?, decided_on?, citation?, + note? }` (`authority`: the rule that exempts the reason kind; no + `decided_by`: nobody decided); `decisions_1099` every recorded + decision, in date order; `form_1099` "1099-NEC" or "1099-MISC" when + today's decision files one. +- `books.set_vendor_1099 { vendor, form_1099?, reason_kind?, reason?, + effective?, tin_on_file?, decided_by?, decided_on?, citation?, note? }` + → the vendor, with `changed` ("set", "removed", "unchanged") and + `warnings`. `vendor` is an id or a name. With `form_1099` (`nec`, + `misc`, `not_reportable`, `undetermined`), `decided_by` is required + (`decided_on` defaults to today); `not_reportable` takes `reason_kind` + and `reason` (`other` a `citation` too), no other value takes them. + "none" is refused, saying what to say instead; paid by card is never a + reason. `effective` (YYYY-MM-DD) only when the payee's status changed: + an LLC's corporate election from its Form 8832 date; a payee that + incorporates is a new vendor. A dated decision with nothing decided + before it is refused; one the same as the decision before its date + takes out any decision recorded for that date ("removed"); recorded + again exactly so, nothing is journaled ("unchanged"). Without + `form_1099`, `tin_on_file` alone marks the W-9, keeping the decision and + who made it; `tin_on_file` omitted keeps the flag. `warnings` names each + year (and its entries) whose payments the change reaches in a closed + period or after that year's 1099s were due (January 31 of the next + year): tell the human, a filed 1099 may need correcting. Journaled as + `vendor.1099_set` (a W-9 flag alone as `vendor.w9_flag_set`), after a + `journal.format_set` declaring the `vendor_1099_decisions` feature once. +- `books.add_instrument { kind, label, last4?, owner_party?, account }` → + `{ id, kind, card_or_network, label, last4?, owner_party?, account_id, + account, warnings }`. `kind`: `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), `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` (id or code) is what it pays through: the + bank account, the card's liability, Due to Members for a member's own + card or cash; an asset or liability, not a header, not payables or + receivables. `label` is unique (ignoring case); `last4` exactly four + digits. Journaled as `instrument.added` (feature `payment_instruments`). +- `books.instruments {}` → `{ instruments: [...] }`, by id. Read-only. +- `books.set_entry_instrument { entry_id, instrument? }` → the entry (as + `books.get_entry`), with `changed` and `warnings` (as above). An id or + label; omit to clear. Metadata only: drafts and posted entries alike; + the entry and its reversals are tagged together. Journaled as + `entry.instrument_set`. - `books.set_entry_vendor { entry_id, vendor? }` → the entry (as - `books.get_entry`, which now shows `vendor`). Omitting `vendor` clears - the tag. Metadata only: posted entries may be tagged. `books.draft_entry` - takes `vendor` too; bills and their payments are tagged automatically. + `books.get_entry`, which shows `vendor`, and `instrument` and + `instrument_id`). Omitting `vendor` clears the tag. Metadata only: + posted entries may be tagged. `books.draft_entry` takes `vendor` and + `instrument` too; bills and their payments are tagged automatically. `books.set_entry_party` tags the vendor too when the party has a vendor record, so tagging by party counts for 1099s the same way. On an entry naming several parties the parties are `books.set_entry_party`'s: @@ -802,27 +876,60 @@ is on file and the TIN's last four; full TINs live only in the TIN vault is; tagging a vendor whose party isn't named, or clearing the tag while its party is named, is refused (drop or add the party with `parties` instead). An entry and its reversal always carry the same tags. -- `books.vendor_1099 { year, threshold?, include_tins?, csv? }` → - `{ year, threshold, vendors: [{ vendor_id, name, form_1099?, tin_on_file, - paid, reportable, party_id?, tin_last4?, tin?, tin_type?, tin_note? }], - missing_w9, notes, warning?, csv? }`. For a year whose threshold +- `books.vendor_1099 { year, threshold?, include_tins?, csv?, + cash_accounts?, financing_accounts?, through? }` → + `{ year, threshold, vendors: [{ vendor_id, name, form_1099?, + decision_1099, decisions_in_year, tin_on_file, paid, excluded_card, + undetermined, not_reportable, status, decision_needed, reportable, + party_id?, tin_last4?, tin?, tin_type?, tin_note? }], missing_w9, + excluded_card_payments, undetermined_payments, other_receipts, + untagged_entries, unresolved, notes, warning?, csv? }`. `paid` is what + counts toward the 1099; `excluded_card` what a card or network paid; + `undetermined` what waits on its payment method; `not_reportable` what + was paid while the vendor was decided not reportable. `status`: + `reportable` (file one: what counts reaches the threshold however the + unknowns settle), `under_threshold`, `not_reportable`, or + `undetermined` (a decision or a method is needed first; + `decision_needed` says which); `reportable` is `status = reportable`. + Each payment list is `[{ entry_id, date, vendor_id, vendor, amount, + instrument? }]`. `missing_w9` names the vendors a 1099 is or may be + ("(may be reportable)") due for without a W-9. The notes are quiet: + untagged entries are one count (those that paid money out for + something) with the tool to tag them. For a year whose threshold wanderbooks doesn't know (after 2026 until the indexed amount is added) - it refuses with what to do; pass `threshold` explicitly once the figure - is confirmed. On an entry naming several parties, `paid` counts only - the vendor's resolved share of the payment (rules.md B-19); an entry - whose shares don't resolve counts in full and `notes` names it. - `tin_last4` comes from the party's W-9 facts. With - `include_tins: true` (default false) each line carries `tin`, the full - TIN formatted as the IRS writes it (123-45-6789; 12-3456789 for an - EIN), and `tin_type`, read from the vault, or `tin_note` saying why not - (not in the vault, the vault locked, a record whose last four or hash - disagree with the books); `warning` then says "This worksheet holds - full TINs, for the CPA; keep it out of the books.". This is the only - tool output that ever holds a full TIN. `csv: true` (default false) - also hands the worksheet over as a CSV file, `csv` (a `HandedFile`, - "Tools — the TIN vault"), named `1099-worksheet-.csv`, or - `1099-worksheet--with-tins.csv` with `include_tins` (TINs only - then). + it refuses with what to do; pass `threshold` explicitly once the + figure is confirmed. Payments are counted out of `cash_accounts` and + `financing_accounts` (account codes; each defaults to the `[books]` + setting, the CPA package's rule: `ledger_package::Settings::with_accounts`; + a code the chart lacks is refused), from January 1 of `year` through + `through` when given (`YYYY-MM-DD`, a day of `year`: the worksheet as of + a day, rules.md B-33), else the whole year. On an entry naming several + parties, the buckets count only the vendor's resolved share of the + payment, split across them to the cent (rules.md B-19); an entry whose + shares don't resolve counts in full and `notes` names it. `tin_last4` + is the party's TIN facts' last four, else its W-9 facts' + (`Party::tin_last4`). With `include_tins: true` (default false) each + line carries `tin`, the full TIN formatted as the IRS writes it (123-45-6789; + 12-3456789 for an EIN), and `tin_type`, read from the vault, or + `tin_note` saying why not (not in the vault, the vault locked, a record + whose last four or hash disagree with the books); `warning` then says + "This worksheet holds full TINs, for the CPA; keep it out of the + books.". This is the only tool output that ever holds a full TIN. + `csv: true` (default false) also hands the worksheet over as a CSV file, + `csv` (a `HandedFile`, "Tools — the TIN vault"), named + `1099-worksheet-.csv`, or `1099-worksheet--with-tins.csv` + with `include_tins` (TINs only then): `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` (and `tin, tin_type, + tin_note`). The CPA package carries the same CSV: one list of cash + accounts and one of financing accounts per package, the request's, else + the settings', for every file in it (R18), the 1099 worksheet through + the package's `as_of`; so `books.vendor_1099` given the package's + `cash_accounts`, `financing_accounts` and `through` = `as_of` (and its + `include_tins`) hands over the package's `1099-worksheet-.csv` + byte for byte (the tool names one with TINs `-with-tins`; checked by `ledger-mcp` `oracle_mcp.rs` + `cpa_package_holds_the_year_and_its_evidence`). ## Tools — parties @@ -875,8 +982,8 @@ texts are at most 500 characters, no control characters. w9_content_base64?, w9_sha256?, w9_file_name? }` or `{ party, clear: true }` → the party. Journaled as `party.w9_set`. A party with a vendor record also gets the record's `tin_on_file` set to match - (journaled as `vendor.1099_set`), so `books.vendor_1099`'s - `missing_w9` follows. + (journaled as `vendor.w9_flag_set`, its 1099 decision and who made it + kept), so `books.vendor_1099`'s `missing_w9` follows. `tin` and `tin_type` put the full TIN in the TIN vault, and the signed form (`w9_upload_id`, or `w9_content_base64` with `w9_sha256`, as in "Tools — receipts & documents") goes there too (below); with them @@ -992,8 +1099,11 @@ rules.md B-35): posted entries dated in the year count. - *Money accounts* (B-35 item 5, P3 defect 24): the `[books] cash_accounts` (every asset account but receivables when none are set), - the cards (liability accounts whose names say "card", not "gift card": - the rule the reconciliation reminders read), and the `[books] + the cards (the business-card payment methods' liability accounts, + `books.instruments`; while the books record none, the liability + accounts whose names say "card", not "gift card"; never a member's + card or a platform's account: the one rule the reconciliation reminders + and the viewer read), and the `[books] financing_accounts` (what the members advanced, loans). Any other liability (an accrued expense, deferred revenue, gift cards sold, distributions payable) is a promise, counted when it is settled, never @@ -1022,9 +1132,11 @@ rules.md B-35): - `invoiced` / `billed`: its invoices' and bills' totals dated in the year. `owed` / `receivable`: its open bills' and invoices' balances now (every year). -- `amount_1099` (vendor parties only): what `books.vendor_1099` counts as - paid to its vendor record in the year (asset accounts only, so card - payments drop out); `form_1099` is the record's mark. +- `amount_1099` (vendor parties only): what `books.vendor_1099` counts + toward its vendor record's 1099 in the year (its `paid`: card and + network payments excluded, member payments with no method recorded + undetermined; absent when the worksheet can't be made); `form_1099` and + `decision_1099` are the record's decision in force today. - `gross_pay` / `net_pay`: payroll lines naming it, in the year. ## Tools — the TIN vault @@ -1400,7 +1512,8 @@ posting. matches accounts by name (tell a CPA who imported an earlier IIF), says when sub-accounts' full names change with it, when the new name moves the account into or out of the cash accounts (`books.cash_accounts` - unset: picked by name) or the card accounts (named "card"), and lists + unset: picked by name) or the card accounts (named "card", while no + business-card payment method is recorded), and lists conventions whose text names the old name. - `books.set_tax_category { account, category?, deductible_percent? }`: map a revenue or expense account to a category (null clears), @@ -1711,10 +1824,13 @@ entries. member role, as the viewer's capital statement finds it; with none, the member's TIN is a gap. **1099 worksheet** (`1099-worksheet-.csv`, `as_of`'s calendar - year): the payments from January 1 through `as_of` (the year to date; - the whole year for a December 31 package; DECISION D-36), and the - README says which. With TINs it adds `party_id, tin_last4, tin, - tin_type, tin_note`. A zip with the full set, each + year) is `books.vendor_1099`'s CSV (above) for the package's cash and + financing accounts: the payments from January 1 through `as_of` (the + year to date; the whole year for a December 31 package; DECISION D-36), + and the README says which. `books.vendor_1099` given the same + `cash_accounts`, `financing_accounts` and `through` hands over the + identical file. With TINs it adds `tin, tin_type, tin_note`. A zip with + the full set, each statement as PDF and XLSX: `profit-and-loss`, `balance-sheet`, `cash-flow` (the direct method, as `books.export` prints it: operating by class, investing and financing by kind with each member named, and diff --git a/docs/parties-and-attachments.md b/docs/parties-and-attachments.md index a48e387..89cd7be 100644 --- a/docs/parties-and-attachments.md +++ b/docs/parties-and-attachments.md @@ -395,7 +395,9 @@ by default; `statement_attachment` + `confirmed: true` records it), reconciliation`; `books.digest` `unreconciled_accounts` and the viewer's Needs attention, one rule (`ledger_store::reconcilable`): the cash accounts (`[books] cash_accounts`, else assets named for cash), the -card accounts (liabilities named "card", before their first statement), +card accounts (`ledger_core::parties::card_accounts`: the business-card +payment methods' liabilities, else the liabilities named "card", not +"gift", before their first statement), and other liabilities with statements, not reconciled through last month's end). The statement of a reconciliation not voided can't be unlinked from it. diff --git a/docs/spec/rules.md b/docs/spec/rules.md index 54fb04c..e9fca0b 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -737,26 +737,131 @@ invoice or bill (a real prepayment is a deposit): DECISIONS (the owner, `aging_as_of_a_date_sees_only_what_existed_then`, `a_payment_never_predates_its_document`. -## B-19 1099 reporting thresholds +## B-19 1099 reporting thresholds, payment methods, and decisions -**Status:** AUTHORITY (`sources/irs-1099-thresholds.md`) +**Status:** AUTHORITY (`sources/irs-1099-thresholds.md`, +`sources/irs-1099-payment-method.md`), with the DECISIONs named below. $600 through 2025; $2,000 for payments made after Dec. 31, 2025 (26 U.S.C. -§6041(a), §6041A(a)(2), as amended by Pub. L. 119-21 §70433); for calendar -years after 2026, indexed to inflation and rounded to the nearest $100 -(§6041(h)). Card payments are reported on Form 1099-K, not 1099-NEC/MISC. -Attorneys' gross proceeds stay at $600 (not modeled). +§6041(a), §6041A(a)(2), as amended by Pub. L. 119-21 §70433; §70433(f): +"shall apply with respect to payments made after December 31, 2025"); for +calendar years after 2026, indexed to inflation and rounded to the nearest +$100 (§6041(h)). §70433 sets the one amount for Form 1099-NEC +(§6041A(a)(2), §70433(c)) and the §6041(a) payments on 1099-MISC alike. +Gross proceeds paid to an attorney (a payment in connection with legal +services that §6041 doesn't cover, a settlement paid to a claimant's +attorney, say: attorneys' fees for the company's own legal work are +§6041's, on Form 1099-NEC) stay at $600 (Treas. Reg. §1.6045-5(a)(1), +(c)(4)), and royalties report at $10 (§6050N(a)); neither is modeled, and +the worksheet says so. -What counts as paid: payments made in the calendar year (§6041(a)), so a -prepayment counts when made. A worksheet as of a day (the CPA package's, -B-33) counts the year's payments from January 1 through that day. The -engine counts credits minus debits to the payment sources on a vendor's -tagged entries: `[books] cash_accounts` and `financing_accounts` (a -member or lender paying on the company's behalf; the company is still the -payer). A card charge leaves neither (1099-K); a -bill is not paid until it is (`form1099::vendor_1099_report_with`, -checked by `payments_come_from_cash_and_financing_only`). A W-9 is on -file by the vendor's flag or its party's W-9 facts. +**Who pays.** The payor is the person engaged in a trade or business who +makes the payment in its course (Treas. Reg. §1.6041-1(a)(1)(i)). A person +paying on behalf of another is the payor only if it performs management +or oversight functions in connection with the payment or has a +significant economic interest in it; "mere administrative or ministerial +functions such as writing checks at another's direction" are neither +(§1.6041-1(e)(1)). A member who pays a company bill from their own funds +at the company's direction, the company bearing the cost through Due to +Members, does only that: the company stays the payor, so a member's own +cash, check or transfer through a financing account counts. + +**What is paid by card or network.** A transaction reportable under both +§6041 and §6050W "[is] reported under section 6050W and not section +6041", and for that test the third-party-network de minimis is +disregarded (§1.6041-1(a)(1)(iv); the 1099-MISC/NEC instructions, "Form +1099-K"). A payment card is "any card, including any stored-value card" +(§1.6050W-1(b)(3)); the IRS names credit, debit and stored-value cards +("Understanding your Form 1099-K"). The exclusion follows the instrument, +whoever's card it was: a member's personal card is excluded too. A payment +card transaction is one in which the card "is accepted as payment" +(§1.6050W-1(b)(1)) by the payee: a card that only funded a transfer the +payee didn't take as a card payment is not one (a member's +friends-and-family transfer is their cash). Automated clearing houses +"do not qualify as TPSOs" (Instructions for Form 1099-K): ACH, bill-pay +and Zelle are bank payments, not network payments. + +**The rule (`form1099::vendor_1099_report`, `kernel::payment_bucket`).** +Payment methods are recorded once (`instrument.added`: bank, +business_card, member_card, member_cash_or_check, platform, each with the +account it pays through) and entries tagged with the one they were paid +with (`entry.instrument_set`, the reversal chain together). Each line of +a vendor-tagged posted entry has a role from its account: cash +(`books.cash_accounts`), financing (`books.financing_accounts`, and the +account of a member_card, member_cash_or_check or platform method: only +the entry's method says how a payment through it was made), a company +card's account (a business_card method's), or other. First match wins: + +| The line | Bucket | +|---|---| +| the entry's method pays through this line's account, card or network | card (excluded) | +| the entry's method pays through this line's account, bank or a member's own cash or check | counts | +| a cash account (no method, or one naming another account) | counts: presumed check, ACH or transfer (DECISION D-40, owner, 2026-10-20); tag a debit-card payment with the card | +| a financing account, or a member's or platform method's account (no method, or one naming another account) | undetermined until the method is recorded | +| a company card's account (a business_card method's) | card (excluded) | +| anything else (an expense, payables, prepaid) | no payment line | + +Each entry's buckets are netted (`kernel::net_buckets`, P3 review R1): +money moved between the company's own sources pays no one, so the card +bill (Dr card / Cr bank), a member reimbursed (Dr Due to Members / Cr +bank) and a loan's principal net to zero; when the signs differ, the net +goes to the one bucket with its sign, else to undetermined. A bill is not +paid until it is; a prepayment counts when made (payments made in the +calendar year, §6041(a)); a worksheet as of a day (the CPA package's, +B-33) counts the year's payments from January 1 through that day; a +refund and a reversal net. Money received from a vendor for something else (the entry nets money +in and credits, beside its payment lines, anything a refund of what was +paid doesn't: revenue, receivables, a registered asset's cost account, +equity, or a liability other than payables; a sale, a customer's payment, +a disposal, a loan) is left out and listed, with its reversals (P3 review +R6); a refund credits only an expense, payables, or an asset no money is +paid out of and no one owes (prepaid, a deposit). +Books with no cash accounts named are refused (never "every asset", P3 +review R5), as are books in another currency without a threshold given. +With cash accounts named and no methods or financing accounts, what counts +is exactly the old figure (credits minus debits to cash), but for money +received for something else. + +**The decision.** Each vendor's 1099 decision is nec, misc, +not_reportable with a reason kind and the facts in words, or undetermined; +"not reportable" is never "undetermined" (plan defects 18, 23). No return +is required for payments of bills for merchandise, telephone, freight, +storage and similar (§1.6041-3(c)), or to a corporation (§1.6041-3(p)(1), +except attorneys' fees and medical and health care; a payor may treat a +payee as a corporation by its name "Incorporated, Inc., Corporation, +Corp., P.C., (but not Company or Co.)", a W-9 or a corporate resolution: +§1.6049-4(c)(1)(ii)(A)), a tax-exempt organization ((p)(2)), the United +States ((p)(3)), a State or its political subdivision ((p)(4)), a foreign +government ((p)(5)) or a documented foreign payee (§1.6041-4(a)(1)); an +employee's wages go on Form W-2 (§1.6041-2(a)(1)). Paid by card is never a +vendor's reason: it is a property of each payment. Every new decision +names who made it and when (`settings::Source`). + +**Dated decisions** (DECISION D-40, owner, 2026-10-20: "legally this has +to be 2 dated statuses"). Each decision takes effect on a date (a W-9's, +or a tax election's: Form 8832's effective date, §301.7701-3(c)(1)(iii)), +or from the beginning; each payment takes the decision in force on its +reversal chain's first entry's date. An LLC electing corporate taxation +keeps its EIN and its vendor record and changes on the election's date; a +payee that incorporates is a new legal person, a new vendor with its own +W-9. A decision written before decisions were kept (no date, no source) +is in force from the beginning, and an old null reads undetermined (plan +5.5). A dated decision with nothing decided before it is refused (it would +leave every earlier payment undetermined); one the same as the decision +before its date takes out any decision recorded for that date. A change +reaching payments in a closed period, or in a year whose 1099s were due +(January 31 of the next year), warns in `books.set_vendor_1099` and +`books.set_entry_instrument`. + +**Status** (per vendor and year; T the threshold, F the payments made +while decided for a 1099, U those while undetermined, lo/hi each with +every reversal chain's unknown settled low or high, P3 review R3): +nothing in F or U is not_reportable when something was paid while not +reportable or the year-end decision is, else under_threshold; anything in +U is undetermined when hi(F) + max(hi(U), 0) ≥ T (a decision is needed), +else under_threshold; otherwise reportable when lo(F) ≥ T, under_threshold +when hi(F) < T, else undetermined. `reportable` is `status = reportable`. +A W-9 is on file by the vendor's flag or its party's W-9 facts. An entry naming several parties (a payment shared between a contractor and a member; `ledger_core::shares`) counts only the vendor's share @@ -769,10 +874,13 @@ the same target the MCP server checks shares against, then scaled to the negates both the payment and the target): the share unchanged when the payment equals the target (the usual case, an expense paid from cash or by a member), else payment × share ÷ target, rounded to the nearest cent, -half away from zero (so a part charged to a card scales out, 1099-K). A -percent split's leftover cent goes to the party named first (shares -INVARIANT 8). A reversal carries the shares and subtracts the same part, -and re-apportioning either one re-apportions the other +half away from zero (so a part on a card no method names scales out). The +vendor's part is split across the entry's netted buckets in proportion, +each rounded down to the cent and the cents short handed out by largest +remainder (`kernel::apportion`, P3 review R12), so they sum to it +exactly. A percent split's leftover cent goes to the party named first +(shares INVARIANT 8). A reversal carries the shares and subtracts the +same part, and re-apportioning either one re-apportions the other (`Ledger::set_entry_parties` INVARIANT 5), so the pair nets to zero. The vendor tag follows the parties: on an entry naming several, `set_entry_vendor` never replaces their shares (refused unless the @@ -784,22 +892,66 @@ named beside another vendor (`add_vendor`), checked by `a_named_party_gaining_a_vendor_record_keeps_one_vendor_per_entry`. Shares that don't resolve count the whole payment toward the vendor (the conservative answer) and the entry is listed (`Vendor1099Report:: -unresolved`; a `books.vendor_1099` note). An entry naming one party -counts as before (`form1099::vendor_1099_report_with_members`, checked -by `a_shared_payment_counts_the_vendors_share`, -`a_percent_split_gives_the_leftover_cent_to_the_first`, -`a_reversed_shared_payment_nets_to_zero`, -`a_shared_payment_partly_on_a_card_scales_to_the_payment`, -`unresolved_shares_count_in_full_and_are_listed`). DECISION (the owner, +unresolved`; a `books.vendor_1099` note). DECISION (the owner, 2026-10-15: shares). +**The quieter note.** Untagged posted entries are named only when they +paid money out (netted counts plus undetermined above zero) for something +(a debit to an expense, to payables, or to an asset no money is paid out +of and no one owes), leaving out payroll runs, entries marked as having +no counterparty, entries naming only parties with no 1099 role, and +entries reversed in the year (P3 review R7): one count, the entries when +ten or fewer, and the tool to tag them. + +**Cards and party totals.** The worksheet's card accounts are the +business-card methods' accounts only. Party money's cards are the same +once one is recorded (B-35 item 5: `parties::card_accounts`), and, while +none is, the liabilities named "card"; a charge to such a card leaves no +cash or financing account, so the worksheet counts it for no one either +way, and lists it as a card's only once the card is recorded. The +worksheet is never a party's totals: B-35 item 9 says how the two relate +(a member paying a payee through the members' financing is paid to the +payee in party totals, and the company's payment here), and they never +contradict. + **Enforced:** `form1099::default_threshold`, a dated rule (`ledger_core::rules::THRESHOLD_1099`, known through 2026, review from 2026-11-01). For a later year the engine refuses with `RuleNotKnown` rather than guess; the MCP digest and the viewer's "Needs attention" list -flag the rule once it is due (DECISION, the owner, 2026-10-13). **Checked:** +flag the rule once it is due (DECISION, the owner, 2026-10-13). The +bucket table is `kernel::payment_bucket` (Kani `payment_bucket_is_the_table`, +`payment_split_is_exact`, `net_buckets_keeps_the_total`). **Checked:** `an_unknown_year_threshold_is_never_guessed`, -`dated_rules_go_stale_on_their_review_date`, MCP stdio test. +`dated_rules_go_stale_on_their_review_date`, +`the_report_needs_the_cash_accounts_and_dollars`, +`payments_come_from_cash_and_financing_only`, +`undecided_is_not_not_reportable`, +`a_members_personal_card_payment_is_not_a_1099_payment`, +`card_account_payments_are_listed_not_dropped`, +`a_debit_card_payment_from_the_bank_is_excluded_when_tagged`, +`a_tag_naming_another_account_is_undetermined`, +`paying_down_a_financing_liability_pays_no_vendor`, +`paying_the_card_bill_pays_no_vendor`, +`a_negative_unknown_keeps_a_reportable_vendor_undetermined`, +`a_sale_to_a_contractor_does_not_lower_its_1099`, +`money_from_a_vendor_that_is_no_refund_does_not_lower_its_1099`, +`a_status_change_mid_year_applies_by_payment_date`, +`two_status_changes_in_a_year_and_a_reversal_across_them`, +`an_incorporation_on_december_30_is_a_new_vendor`, +`an_llc_electing_corporate_taxation_keeps_its_vendor`, +`a_dated_decision_records_a_change`, +`a_decision_change_names_the_payments_it_reaches`, +`an_undated_old_decision_is_in_force_from_the_beginning`, +`payment_bucket_is_the_table_in_all_16_cases`, +`a_shared_payment_splits_across_buckets_to_the_cent`, the properties +`no_instruments_no_financing_is_the_old_report`, +`buckets_partition_each_payment`, `a_reversal_nets_every_bucket_to_zero`, +`the_worksheet_ties_to_the_ledger`, `party_totals_are_the_1099s_buckets` +(ledger-core `tests/form1099.rs`); +`the_untagged_note_counts_only_money_out`, +`the_other_receipts_note_says_what_each_entry_did` (ledger-package); the +MCP stdio tests; bookspec +`bookspec/examples/1099-*.toml`. ## B-20 Every transaction has supporting documents; records are kept @@ -1096,13 +1248,23 @@ writer declares it once, in the `journal.format_set` just before the first line that needs it, with the format in force (at least 2) and the features already in force (`Store::ensure_feature`): an earlier reader stops at the declaration, and books that never use the feature stay -readable by it. P3's: `members_cumulative` (B-13: a members close counted -from its count's first close, `"rounding": "cumulative_since"`), -`account_renamed` (before the first `account.renamed`, B-31), -`require_tax_category` (before the first `settings.set` naming -`policy.require_tax_category`, B-32) and `party_creditors` (before the -first `party.creditor_set`, B-35). A later piece asks the owner's -go-ahead before the first such declaration (plan D-39). +readable by it. A tool journaling several commands as one declares the +feature before the first of them, never between them (a refused format +upgrade would leave the tool's work half done): `books.set_w9` declares +`vendor_1099_decisions` before the W-9 when the vendor's W-9 flag follows +it. P3's: `members_cumulative` (B-13: a members close counted from its +count's first close, `"rounding": "cumulative_since"`), `account_renamed` +(before the first `account.renamed`, B-31), `require_tax_category` +(before the first `settings.set` naming `policy.require_tax_category`, +B-32), `party_creditors` (`party.creditor_set`: defect 24, B-35), +`payment_instruments` (`instrument.added`, `entry.instrument_set`: +defect 12, B-19) and `vendor_1099_decisions` (`vendor.1099_set` with who +decided, a `not_reportable` decision, its reason or the date it takes +effect, and `vendor.w9_flag_set`: defects 18 and 23, D-40, B-19). A value +in a line of a known type that only a newer version writes (a 1099 +decision or reason kind, a payment method's kind) is newer, never +corrupt. A later piece asks the owner's go-ahead before the first such +declaration (plan D-39). **Enforced:** `ledger_store::format` (`JournalFormat::admit`, `declared`, `gate`, `settle`, `declaration`), `journal::ENVELOPE_KEYS`, @@ -1403,8 +1565,10 @@ Existing charts are untouched: the rules are the commands', never replay's A rename can still change what the books pick by name (no amount): with `books.cash_accounts` unset, the cash accounts are the asset accounts -whose names say they hold cash, and the card accounts are the -liabilities named "card" (`ledger_store::reconcilable`); the result says +whose names say they hold cash, and, while the books record no +business-card payment method, the card accounts are the liabilities +named "card" but not "gift" (`ledger_core::parties::card_accounts`, B-35 +item 5); the result says when the new name moves the account into or out of either. It also lists conventions whose text names the old name. QuickBooks matches accounts by name, so a CPA who imported an earlier IIF is told. @@ -1513,13 +1677,19 @@ is the year to date, and a vendor not yet at the threshold is not yet reportable; a December 31 package counts the whole year, as before. The chart of accounts and documents/ are as they stand when the package is made. One list of cash accounts and one of financing accounts serve the -whole package (the request's, else the settings'): the cash flow, the -K-1 worksheet, the IIF's bank accounts and the 1099 payments read the -same. +whole package (the request's, else the settings': +`ledger_package::Settings::with_accounts`): the cash flow, the K-1 +worksheet, the IIF's bank accounts and the 1099 payments read the same. +So the package's 1099 worksheet is `books.vendor_1099`'s CSV for the +same `cash_accounts`, `financing_accounts` and `through` = as_of, byte +for byte; the tool, given no accounts, uses the settings', as a package +given none does. **Enforced:** `ledger_package::build_package` (INVARIANTS 1, 6, 7), -`ledger_package::export_iif`, `ledger_package::vendor_1099_report` and -`ledger_core::vendor_1099_report_with_members` (`through`). +`ledger_package::Settings::with_accounts`, `ledger_package::export_iif`, +`ledger_package::vendor_1099_worksheet` and `ledger_core::vendor_1099_report` +(`through`), `books.vendor_1099` (`cash_accounts`, `financing_accounts`, +`through`). **Checked:** `ledger-package` `tests/package.rs` (`every_file_in_an_as_of_package_stops_at_as_of`: books the same through as_of, with and without activity after it (a 1099 payment with its @@ -1529,9 +1699,11 @@ byte for byte, every file and which files there are, with and without a fixed asset register, mid-year and at year end; `as_of_package_iif_stops_at_as_of`, `the_iif_through_as_of_at_its_edges`, `the_1099_worksheet_stops_at_as_of`, -`one_list_of_cash_accounts_per_package`; the property +`one_list_of_cash_accounts_per_package`, +`payments_to_a_vendor_come_from_the_packages_accounts`; the property `iif_ties_to_the_trial_balance_as_of`, 2,000 cases); `ledger-mcp` -`oracle_mcp.rs` (`cpa_package_holds_the_year_and_its_evidence`); the +`oracle_mcp.rs` (`cpa_package_holds_the_year_and_its_evidence`: the +package's CSV is the tool's for the same accounts and `through`); the bookspec examples `bookspec/examples/as-of-package-stops-at-as-of.toml` and `bookspec/examples/1099-worksheet-through-as-of.toml` (`ledger-core` `tests/bookspec.rs`). @@ -1761,16 +1933,21 @@ not the books' accounting method (`books.accounting_basis`). `member_totals_are_the_members_equity_statement` checks every span). 5. **Money** (P3 defect 24): the cash accounts (`[books] cash_accounts`; every asset account but receivables when none are set), the cards - (liability accounts whose names say "card" but not "gift card", the - rule the reconciliation reminders read, until payment instruments name - them), and `[books] financing_accounts` (what the members advanced, - loans). Every other liability (payables, the payroll withholdings, an + (`parties::card_accounts`, the one rule the reconciliation reminders + and the viewer read too: the liability accounts of the business-card + payment methods the books record, B-19; while they record none, the + liability accounts whose names say "card" but not "gift card"; never + the account of a member's card, a member's cash or check, or a + payment platform, which pay through the members' financing account or + their own balance), and `[books] financing_accounts` (what the members + advanced, loans). Every other liability (payables, the payroll withholdings, an accrued expense, deferred revenue, gift cards the company sold) is a promise: counted when it is settled, never also when it is booked (before P3 an accrual through any liability but payables and payroll - counted twice). So a card account whose name doesn't say "card" is - not money until it is listed in `financing_accounts` or an instrument - names it, and with `financing_accounts` unset what the members + counted twice). So, before any business card is recorded, a card + account whose name doesn't say "card" is not money until it is listed + in `financing_accounts` or a business-card method names it, and with + `financing_accounts` unset what the members advanced is not money either: a purchase through either then counts for no one until it is paid off (the handbook tells the agent to list them). @@ -1819,6 +1996,38 @@ not the books' accounting method (`books.accounting_basis`). through the company's cash accounts, cards and financing (and, on a member's contribution, the K-1's cash), not the statement's ASC 230 cash. +9. **How party totals relate to the 1099 worksheet** (B-19; P3 defects + 12, 18, 23; D-40). They answer different questions, and neither is + read for the other (`books.party` gives both, labelled). Party totals + report what moved with each party, whoever handled the money; the + 1099 worksheet reports what the company, as the payor, paid a payee + toward its information return. A member paying a contractor for the + company through the members' financing account, the entry tagged + with the contractor: in party totals it is paid to the contractor on + that date, in money (the members' financing is money, item 5), and + nothing moves with the member until the company pays them back, which + is paid to the member. On the 1099 worksheet it is the company's + payment to the contractor (Treas. Reg. §1.6041-1(e)(1): the member only + writes the check at the company's direction): it counts toward the + contractor's 1099 when it was the member's own cash, check or + transfer, is excluded when it was a card, the member's own included + (§1.6041-1(a)(1)(iv): Form 1099-K), and is undetermined until its + payment method is recorded; the reimbursement pays no payee (money + between the company's own sources). A company card's charge is paid + in party totals and excluded on the 1099 alike. So the figures never + contradict: over a calendar year, for a payee every entry of which + names it alone, in books whose cash accounts are named, whose company + cards are recorded as business-card payment methods, and that pay only + out of cash accounts, cards and financing accounts, its party totals' + paid less received (basis cash) is exactly its 1099 worksheet's + `paid` + `excluded_card` + `undetermined` + `not_reportable`, less what + the worksheet lists as money received from it for something else + (a refund is netted on the worksheet and received in party totals). + The bookspec example `1099-and-party-totals-for-a-members-payment` + checks a year of it, and the property + `party_totals_are_the_1099s_buckets` checks it over random books. A + bill counts in neither until it is paid, and a payee's dated decision + changes only the 1099 worksheet. **Enforced:** `kernel::party_target`, `kernel::reverse_target`, `shares::{target, split, resolve}`, `parties::{flow, money_accounts, diff --git a/docs/spec/sources/irs-1099-payment-method.md b/docs/spec/sources/irs-1099-payment-method.md new file mode 100644 index 0000000..5e85f9a --- /dev/null +++ b/docs/spec/sources/irs-1099-payment-method.md @@ -0,0 +1,399 @@ +# Information returns: payment method, exempt payees, the payor +Governs `form1099::vendor_1099_report`'s payment-instrument classification (card and third-party-network payments go on Form 1099-K, not 1099-NEC/MISC), its per-vendor "not reportable" reasons, and who the payor is when a member pays a company bill personally. Companion to `irs-1099-thresholds.md`. + +## Pub. L. 119-21 (H.R. 1, July 4, 2025), §70433 +Source: +Retrieved: 2026-10-20 +SHA-256 of the retrieved file (`plaw-119publ21.htm`): `84000b07c382db50ead04ce65b70983e2dcd699a931d80cd3d2edac584e9004d` + +The `<>` is the Government Publishing Office's margin note, as printed. Retrieved again 2026-10-21, with the same SHA-256, for subsections (a) to (e); the statute's line breaks are joined and its page marker (`[[Page 139 STAT. 244]]`, after (a)) left out; its ``…'' quotation marks are as printed. + +**§70433 (heading)** + +> SEC. 70433. INCREASE IN THRESHOLD FOR REQUIRING INFORMATION REPORTING WITH RESPECT TO CERTAIN PAYEES. + +**§70433(a)** + +> (a) In General.--Section 6041(a) is amended by striking ``$600'' and inserting ``$2,000''. + +**§70433(b)** + +> (b) Inflation Adjustment.--Section 6041 is amended by adding at the end the following new subsection: +> ``(h) Inflation Adjustment.--In the case of any calendar year after 2026, the dollar amount in subsection (a) shall be increased by an amount equal to-- +> (1) such dollar amount, multiplied by +> (2) the cost-of-living adjustment determined under section 1(f)(3) for such calendar year, determined by substituting `calendar year 2025' for `calendar year 2016' in subparagraph (A)(ii) thereof. +> +> If any increase under the preceding sentence is not a multiple of $100, such increase shall be rounded to the nearest multiple of $100.''. + +**§70433(c)** + +> (c) Application to Reporting on Remuneration for Services.--Section 6041A(a)(2) is amended by striking ``is $600 or more'' and inserting ``equals or exceeds the dollar amount in effect for such calendar year under section 6041(a)''. + +**§70433(d)** + +> (d) Application to Backup Withholding.--Section 3406(b)(6) is amended-- +> (1) by striking ``$600'' in subparagraph (A) and inserting ``the dollar amount in effect for such calendar year under section 6041(a)'', and +> (2) by striking ``Only Where Aggregate for Calendar Year Is $600 or More'' in the heading and inserting ``Only Where in Excess of Threshold''. + +**§70433(e)** + +> (e) Conforming Amendments.-- +> (1) The heading of section 6041(a) is amended by striking ``of $600 or More'' and inserting ``Exceeding Threshold''. +> (2) Section 6041(a) is amended by striking ``taxable year'' and inserting ``calendar year''. + +**§70433(f)** + +> (f) <> Effective Date.--The amendments made by this section shall apply with respect to payments made after December 31, 2025. + +## Treas. Reg. §1.6041-1 (eCFR, up to date as of 2026-10-19) +Source: +Retrieved: 2026-10-20 +SHA-256 of the retrieved file (`ecfr-1.6041-1.xml`): `a27ab6bfd44679e1b68d794f0ac2e202db10df6d07e9925b6a2c069d85b58ff2` + +**(a)(1)(i)** + +> (a) General rule—(1) Information returns required—(i) Payments required to be reported. Except as otherwise provided in §§ 1.6041-3 and 1.6041-4, every person engaged in a trade or business shall make an information return for each calendar year with respect to payments it makes during the calendar year in the course of its trade or business to another person of fixed or determinable income described in paragraph (a)(1)(i) (A) or (B) of this section. For purposes of the regulations under this section, the person described in this paragraph (a)(1)(i) is a payor. + +**(a)(1)(iv)** + +> (iv) Information returns required under section 6050W for calendar years beginning after December 31, 2010. For payments made by payment card (as defined in § 1.6050W-1(b)(3)) or through a third party payment network (as defined in § 1.6050W-1(c)(3)) after December 31, 2010, that are required to be reported on an information return under section 6050W (relating to payment card and third party network transactions), the following rule applies. Transactions that are described in paragraph (a)(1)(ii) of this section that otherwise would be subject to reporting under both sections 6041 and 6050W are reported under section 6050W and not section 6041. For provisions relating to information reporting for payment card and third party network transactions, see § 1.6050W-1. Solely for purposes of this paragraph, the de minimis threshold for third party network transactions in § 1.6050W-1(c)(4) is disregarded in determining whether the transaction is subject to reporting under section 6050W. + +**(e)(1)** + +> (e) Payment made on behalf of another person—(1) In general. A person that makes a payment in the course of its trade or business on behalf of another person is the payor that must make a return of information under this section with respect to that payment if the payment is described in paragraph (a) of this section and, under all the facts and circumstances, that person— +> +> (i) Performs management or oversight functions in connection with the payment (this would exclude, for example, a person who performs mere administrative or ministerial functions such as writing checks at another's direction); or +> +> (ii) Has a significant economic interest in the payment (i.e., an economic interest that would be compromised if the payment were not made, such as by creation of a mechanic's lien on property to which the payment relates, or a loss of collateral). + +**(e)(2)** + +> (2) Determination of payor obligated to report. If two or more persons meet the requirements for making a return of information with respect to a payment, as set forth in paragraph (e)(1) of this section, the person obligated to report the payment is the person closest in the chain to the payee, unless the parties agree in writing that one of the other parties meeting the requirements set forth in paragraph (e)(1) of this section will report the payment. + +## Treas. Reg. §1.6041-2 (eCFR, up to date as of 2026-10-19) +Source: +Retrieved: 2026-10-20 +SHA-256 of the retrieved file (`ecfr-1.6041-2.xml`): `757ff54afc739e52963c9d7d41ea71ffb5c53c37b854a214349bf86db4541551` + +**(a)(1), first sentence** + +> (a)(1) In general. Wages, as defined in section 3401, paid to an employee are required to be reported on Form W-2. + +## Treas. Reg. §1.6041-3 (eCFR, up to date as of 2026-10-19) +Source: +Retrieved: 2026-10-20 +SHA-256 of the retrieved file (`ecfr-1.6041-3.xml`): `fc79d8bd578b3c152222c20a45000005354eb17bd20c4aa2f0f955020d359d7e` + +**Introductory text** + +> Returns of information are not required under section 6041 and §§ 1.6041-1 and 1.6041-2 for payments described in paragraphs (a) through (q) of this section. See § 1.6041-4 for reporting exemptions regarding payments to foreign persons. + +**(c)** + +> (c) Payments of bills for merchandise, telegrams, telephone, freight, storage, and similar charges. + +**(p)(1) through (p)(5)** + +> (p) Payments made to the following persons: +> +> (1) A corporation described in § 1.6049-4(c)(1)(ii)(A), except with respect to payments made to a corporation after December 31, 1997 for attorneys' fees, and except a corporation engaged in providing medical and health care services or engaged in the billing and collecting of payments in respect to the providing of medical and health care services. However, no reporting is required where payment is made to a hospital or extended care facility described in section 501(c)(3) which is exempt from taxation under section 501(a) or to a hospital or extended care facility owned and operated by the United States, a State, the District of Columbia, a possession of the United States, or a political subdivision, agency or instrumentality of any of the foregoing. For reporting requirements as to payments by cooperatives, and to certain other payments, see sections 6042, 6044, and 6049 and the regulations thereunder in this part. +> +> (2) An organization exempt from taxation under section 501(a), as described in § 1.6049-4(c)(1)(ii)(B)(1), or an individual retirement plan, as described in § 1.6049-4(c)(1)(ii)(C). +> +> (3) The United States, as described in § 1.6049-4(c)(1)(ii)(D). +> +> (4) A State, the District of Columbia, a possession of the United States, or any political subdivision of any of the foregoing, as described in § 1.6049-4(c)(1)(ii)(E). +> +> (5) A foreign government or political subdivision of a foreign government, as described in § 1.6049-4(c)(1)(ii)(F). + +## Treas. Reg. §1.6041-4 (eCFR, up to date as of 2026-10-19) +Source: +Retrieved: 2026-10-20 +SHA-256 of the retrieved file (`ecfr-1.6041-4.xml`): `38ab0107a87144ae1512665cc85cbb8c35271efd9b43e8e4b898ce9c8cabc280` + +**(a)(1), first sentence** + +> Returns of information are not required for payments that a payor can, prior to payment, reliably associate with documentation upon which it may rely to treat as made to a foreign beneficial owner in accordance with § 1.1441-1(e)(1)(ii) or as made to a foreign payee in accordance with § 1.6049-5(d)(1) or presumed to be made to a foreign payee under § 1.6049-5(d)(2), (3), (4), or (5). + +## Treas. Reg. §1.6049-4 (eCFR, up to date as of 2026-10-19) +Source: +Retrieved: 2026-10-20 +SHA-256 of the retrieved file (`ecfr-1.6049-4.xml`): `e37af99c7d0bdba9508d5f9033c044cc784be3761a1a504e1e5b13abb8ea9ab5` + +**(c)(1)(ii)(A)** + +> (A) Corporation. A corporation, as defined in section 7701(a)(3), whether domestic or foreign, is an exempt recipient. In addition, for purposes of this paragraph (c)(1), the term corporation includes a partnership all of whose members are corporations described in this paragraph (c)(1), but only if the partnership files with the payor a certificate stating that each member of the partnership meets one of the requirements of paragraph (c)(1)(ii)(A) (1) through (4) of this section. Absent actual knowledge otherwise, a payor may treat a payee as a corporation (and, therefore, as an exempt recipient) if one of the requirements of paragraph (c)(1)(ii)(A) (1), (2), (3), or (4), of this section are met before a payment is made. +> +> (1) The name of the payee contains an unambiguous expression of corporate status that is Incorporated, Inc., Corporation, Corp., P.C., (but not Company or Co.) or contains the term insurance company, indemnity company, reinsurance company, or assurance company, or its name indicates that it is an entity listed as a per se corporation under § 301.7701-2(b)(8)(i) of this chapter. +> +> (2) The payor has on file a corporate resolution or similar document clearly indicating corporate status. For this purpose, a similar document includes a copy of Form 8832, filed by the entity to elect classification as an association under § 301.7701-3(b) of this chapter. +> +> (3) The payor receives a Form W-9 which includes an EIN and a statement from the payee that it is a domestic corporation. +> +> (4) The payor receives a withholding certificate described in § 1.1441-1(e)(2)(i), that includes a certification that the person whose name is on the certificate is a foreign corporation. + +## Treas. Reg. §1.6050W-1 (eCFR, up to date as of 2026-10-19) +Source: +Retrieved: 2026-10-20 +SHA-256 of the retrieved file (`ecfr-1.6050W-1.xml`): `e533e6c24836a89ad117207da3dc44f0a0fc5df22a8e39cb8afd7ea8d1f1665a` + +**(a)(5)(i)** + +> (5) Participating payee—(i) Definition. In general, the term participating payee means any person, including any governmental unit (and any agency or instrumentality thereof), who: +> +> (A) In the case of a payment card transaction, accepts a payment card (as defined in paragraph (b)(3) of this section) as payment; and +> +> (B) In the case of a third party network transaction, accepts payment from a third party settlement organization (as defined in paragraph (c)(2) of this section) in settlement of such transaction. + +**(b)(1)** + +> (b) Payment card transactions—(1) Definition. The term payment card transaction means any transaction in which a payment card, or any account number or other indicia associated with a payment card, is accepted as payment. + +**(b)(3)** + +> (3) Payment card—(i) The term payment card means any card, including any stored-value card as defined in paragraph (b)(4) of this section, issued pursuant to an agreement or arrangement that provides for— +> +> (A) One or more issuers of such cards; +> +> (B) A network of persons unrelated to each other, and to the issuer, who agree to accept such cards as payment; and +> +> (C) Standards and mechanisms for settling the transactions between the merchant acquiring entities and the persons who agree to accept the cards as payment. +> +> (ii) Persons who agree to accept such cards as payment as described in this paragraph (b)(3) are participating payees within the meaning of paragraph (a)(5)(i)(A) of this section. + +**(b)(4)** + +> (4) Stored-value cards. The term stored-value card means any card with a prepaid value, including any gift card. + +**(b)(5)(i) and (ii)** + +> (5) Transactions for which no return of information is required under section 6050W—(i) Withdrawals and cash advances. The use of a “payment card” as defined in paragraph (b)(3) of this section by a cardholder to withdraw funds at an automated teller machine, or to obtain a cash advance or loan against the cardholder's account, is not a payment card transaction under paragraph (b)(1) of this section because the card is not being accepted as payment by a merchant or other payee. +> +> (ii) Convenience checks. The acceptance of a check issued in connection with a payment card account by a merchant or other payee is not a payment card transaction under paragraph (b)(1) of this section because the check is accepted and processed through the banking system in the same manner as a traditional check, not as a payment card. + +**(c)(1)** + +> (c) Third party network transactions—(1) Definition. The term third party network transaction means any transaction that is settled through a third party payment network. + +**(c)(2)** + +> (2) Third party settlement organization. The term third party settlement organization means the central organization that has the contractual obligation to make payments to participating payees (as defined in paragraph (a)(5)(i)(B) of this section) of third party network transactions. A central organization is a third party settlement organization if it provides a third party payment network (as defined in paragraph (c)(3)(i) of this section) that enables purchasers to transfer funds to providers of goods and services. + +**(c)(3)** + +> (3) Third party payment network. (i) The term third party payment network means any agreement or arrangement that— +> +> (A) Involves the establishment of accounts with a central organization by a substantial number of providers of goods or services who are unrelated to the organization and who have agreed to settle transactions for the provision of the goods or services to purchasers according to the terms of the agreement or arrangement; +> +> (B) Provides standards and mechanisms for settling the transactions; and +> +> (C) Guarantees payment to the persons providing goods or services in settlement of transactions with purchasers pursuant to the agreement or arrangement. +> +> (ii) A third party payment network does not include any agreement or arrangement that provides for the issuance of payment cards. +> +> (iii) Persons who are providers of goods and services as described in this paragraph (c)(3) are participating payees within the meaning of paragraph (a)(5)(i)(B) of this section. + +**(c)(4)** + +> (4) Exception for de minimis payments. A third party settlement organization is required to report any information under paragraph (a)(1) of this section with respect to third party network transactions of any participating payee only if— +> +> (i) The amount that would otherwise be reported under paragraph (a)(1)(ii) of this section with respect to such transactions exceeds $20,000; and +> +> (ii) The aggregate number of such transactions exceeds 200. + +## Treas. Reg. §301.7701-2 (eCFR, up to date as of 2026-10-19) +Source: +Retrieved: 2026-10-20 +SHA-256 of the retrieved file (`ecfr-301.7701-2.xml`): `24815816ed81423ccb332a34a108015d0ee2f29bb1a25a743328cd9eaa313e26` + +**(b)(1) and (2)** + +> (b) Corporations. For federal tax purposes, the term corporation means— +> +> (1) A business entity organized under a Federal or State statute, or under a statute of a federally recognized Indian tribe, if the statute describes or refers to the entity as incorporated or as a corporation, body corporate, or body politic; +> +> (2) An association (as determined under § 301.7701-3); + +## Treas. Reg. §301.7701-3 (eCFR, up to date as of 2026-10-19) +Source: +Retrieved: 2026-10-20 +SHA-256 of the retrieved file (`ecfr-301.7701-3.xml`): `d7fc6976edc288a34d26d458d101f707f2d0ac63820fd1c908bf5feef3fa0e56` + +**(c)(1)(iii), first four sentences** + +> (iii) Effective date of election. An election made under paragraph (c)(1)(i) of this section will be effective on the date specified by the entity on Form 8832 or on the date filed if no such date is specified on the election form. The effective date specified on Form 8832 can not be more than 75 days prior to the date on which the election is filed and can not be more than 12 months after the date on which the election is filed. If an election specifies an effective date more than 75 days prior to the date on which the election is filed, it will be effective 75 days prior to the date it was filed. If an election specifies an effective date more than 12 months from the date on which the election is filed, it will be effective 12 months after the date it was filed. + +## 26 U.S.C. §6050N(a) (royalties) +Source: +Retrieved: 2026-10-21 +SHA-256 of the retrieved file (`usc6050n.html`): `becb9609580ad82de7038feb7f279551a266849d6cc48d7517b34ef7cd9c70d0` + +**(a)** + +> (a) Requirement of reporting Every person— (1) who makes payments of royalties (or similar amounts) aggregating $10 or more to any other person during any calendar year, or (2) who receives payments of royalties (or similar amounts) as a nominee and who makes payments aggregating $10 or more during any calendar year to any other person with respect to the royalties (or similar amounts) so received, shall make a return according to the forms or regulations prescribed by the Secretary, setting forth the aggregate amount of such payments and the name and address of the person to whom paid. + +## Treas. Reg. §1.6045-5 (eCFR, up to date as of 2026-10-19) +Source: +Retrieved: 2026-10-21 +SHA-256 of the retrieved file (`ecfr-1.6045-5.xml`): `a7d4a8096c45165526d2d24785304e91a66d07be5142c81567743055a28e6d3e` + +**(a)(1), first sentence** + +> Except as provided in paragraph (c) of this section, every payor engaged in a trade or business who, in the course of that trade or business, makes payments aggregating $600 or more during a calendar year to an attorney in connection with legal services (whether or not the services are performed for the payor) must file an information return for such payments. + +**(c), lead-in, and (c)(4)** + +> (c) Exceptions. Notwithstanding paragraphs (a) and (b) of this section, a return of information is not required under section 6045(f) with respect to the following payments: +> +> (4) Payments made by a person to the extent that the person is required to report with respect to the same payee the payments or portions thereof under section 6041(a) and § 1.6041-1(a) (or would be required to so report the payments or portions thereof but for the dollar amount limitation contained in section 6041(a) and § 1.6041-1(a)). + +## Instructions for Form 1099-K (Rev. December 2026) +Source: +Retrieved: 2026-10-20 +SHA-256 of the retrieved file (`i1099k.html`): `f692039fa0fd9b3a3c5224ddd0f74f5c2efec5ef7cf0dcd74727fed665a1c2f7` + +**Specific Instructions: payment settlement entity, and Note** + +> A PSE is a domestic or foreign entity that is a merchant acquiring entity, that is, a bank or other organization that has the contractual obligation to make payment to participating payees in settlement of payment card transactions; or a TPSO, that is, the central organization that has the contractual obligation to make payments to participating payees of third party network transactions. +> +> Healthcare networks, in-house accounts payable departments, and automated clearing houses do not qualify as TPSOs and do not report under section 6050W. + +**Specific Instructions: participating payee** + +> A participating payee is any person, including any governmental unit (and any agency or instrumentality of a governmental unit) who accepts a payment card, or any account number or other indicia associated with a payment card, as payment or accepts payment from a TPSO in settlement of a third party network transaction. + +**Specific Instructions: payment card** + +> A payment card is any card, including any stored-value card (having prepaid value, including gift cards), issued according to an agreement or arrangement that provides for all of the following. +> +> One or more issuers of the cards. +> +> A network of persons unrelated to each other, and to the issuer, who agree to accept the cards as payment. +> +> Standards and mechanisms for settling the transactions between the merchant acquiring entities and the persons who agree to accept the cards as payment. + +**Specific Instructions: third party payment network** + +> A third party payment network is any agreement or arrangement that provides for the following. +> +> The establishment of accounts with a central organization by a substantial number of providers of goods or services who are unrelated to the organization and who have agreed to settle transactions for the provision of the goods or services to purchasers according to the terms of the agreement or arrangement. +> +> Standards and mechanisms for settling the transactions. +> +> Guarantee of payment to the persons providing goods or services (participating payees) in settlement of transactions with purchasers pursuant to the agreement or arrangement. +> +> A third party payment network does not include any agreement or arrangement that provides for the issuance of payment cards. + +**Reporting under sections 6041, 6041A, and 6050W** + +> Payments made by payment card or through a third party payment network after December 31, 2010, that otherwise would be reportable under sections 6041 (payments made in the course of a trade or business to another person) or 6041A(a) (payments of remuneration for services and certain direct sales) and 6050W are reported under section 6050W and not section 6041 or 6041A. For purposes of determining whether payments are subject to reporting under section 6050W, rather than section 6041 or 6041A, the de minimis threshold, discussed later under Box 1a, is disregarded. + +## IRS, "Understanding your Form 1099-K" (page last reviewed or updated 28-Jun-2026) +Source: +Retrieved: 2026-10-20 +SHA-256 of the retrieved file (`understanding-1099k.html`): `23f1d27b4b4a5bd103bf2468a056d291aca8b5bcfeca06d1d5272ce1f4dba86d` + +**Introduction** + +> Form 1099-K is a report of payments you received for goods or services during the year from: +> +> Credit, debit or stored value cards such as gift cards (payment cards) +> +> Payment apps or online marketplaces, also called third party settlement organizations or TPSOs + +**Who gets Form 1099-K** + +> If your customers or clients pay you directly by credit, debit or gift card, you'll get a Form 1099-K from your payment card processor no matter how many payments you got or how much they were for. + +**Personal payments from family and friends** + +> Money you received from friends and family as a gift or repayment for a personal expense should not be reported on a Form 1099-K. These payments aren't taxable income. +> +> Be sure to note these types of payments as non-business in the payment apps when possible. + +## Instructions for Forms 1099-MISC and 1099-NEC (Rev. December 2026) +Source: +Retrieved: 2026-10-20 +SHA-256 of the retrieved file (`i1099mec.html`): `2c674f7851fd137245a5a3acc8e182e2eaa11fa9f5ea09cf5c380006dd5dc5e8` + +The Form 1099-K paragraph (under Reminders) still opens with the sentence quoted in `irs-1099-thresholds.md`, word for word (same 12/2026 revision); it is not repeated here. + +**Form 1099-MISC: Reportable payments to corporations** + +> The following payments made to corporations must generally be reported on Form 1099-MISC. +> +> Cash payments for the purchase of fish for resale reported in box 11. +> +> Medical and health care payments reported in box 6. +> +> Substitute payments in lieu of dividends or tax-exempt interest reported in box 8. +> +> Gross proceeds paid to an attorney reported inbox 10. + +**Form 1099-MISC, Box 6: Medical and Health Care Payments (second paragraph)** + +> The exemption from issuing Form 1099-MISC to a corporation does not apply to payments for medical or health care services provided by corporations, including professional corporations. However, you are not required to report payments made to a tax-exempt hospital or extended care facility or to a hospital or extended care facility owned and operated by the United States (or its territories), a state, the District of Columbia, or any of their political subdivisions, agencies, or instrumentalities. + +**Form 1099-NEC: Reportable payments to corporations** + +> The following payments made to corporations must generally be reported on Form 1099-NEC. +> +> Attorneys’ fees reported inbox 1a. +> +> Payments by a federal executive agency for services (vendors) reported inbox 1a. + +**Form 1099-NEC: Payments to corporations for legal services** + +> The exemption from reporting payments made to corporations does not apply to payments for legal services. Therefore, you must report attorneys’ fees (in box 1a of Form 1099-NEC) or gross proceeds (in box 10 of Form 1099-MISC) as described earlier to corporations that provide legal services. + +**Form 1099-NEC: Payments made on behalf of another person** + +> For payments reportable under section 6041, if you make a payment on behalf of another person who is the source of the funds, you may be responsible for filing Form 1099-NEC. You are the payor for information reporting purposes if you perform management or oversight functions in connection with the payment, or have a significant economic interest in the payment (such as a lien). For example, a bank that provides financing to a real estate developer for a construction project maintains an account from which it makes payments for services in connection with the project. The bank performs management and oversight functions over the payments and is responsible for filing information returns for payments of $2,000 or more paid to contractors. For more information, see Regulations section 1.6041-1(e). + +**Form 1099-NEC: Exceptions** + +> Some payments do not have to be reported on Form 1099-NEC, although they may be taxable to the recipient. Payments for which a Form 1099-NEC is not required include all of the following. +> +> Generally, payments to a corporation (including an LLC) that is treated as a C or S corporation). However, see Reportable payments to corporations , earlier. +> +> Payments for merchandise, telegrams, telephone, freight, storage, and similar items. +> +> Payments of rent to real estate agents or property managers. However, the real estate agent or property manager must use Form 1099-MISC to report the rent paid over to the property owner. See Regulations section 1.6041-3(d); Regulations section 1.6041-1(e)(5), Example 5; and, the instructions for box 1a. +> +> Wages, any bonuses, prizes, and awards paid to employees (report these on Form W-2). +> +> Military differential wage payments made to employees while they are on active duty in the U.S. Armed Forces or other uniformed services (report on Form W-2). +> +> Business travel allowances paid to employees (may be reportable on Form W-2). +> +> Cost of current life insurance protection (report on Form W-2 or Form 1099-R). +> +> Payments to a tax-exempt organization including tax-exempt trusts (IRAs, HSAs, Archer MSAs, Coverdell ESAs, and ABLE (529A) accounts), the United States, a state, the District of Columbia, a U.S. territory, or a foreign government. +> +> Compensation for injuries or sickness by the Department of Justice as a PSO disability or survivor’s benefit, or under a state program that provides benefits for surviving dependents of a PSO who has died as the direct and proximate result of a personal injury sustained in the line of duty. +> +> Compensation for wrongful incarceration for any criminal offense for which there was a conviction under federal or state law. See section 139F, certain amounts received by wrongfully incarcerated individuals. + +**Form 1099-MISC: Exceptions, first item** + +> Generally, payments to a corporation (including a limited liability company (LLC) that is treated as a C or S corporation). However, see Reportable payments to corporations , earlier. + +## Derived (wanderbooks) + +- **Cards and networks go on 1099-K.** A payment made by payment card or through a third party payment network that §6041 would otherwise cover is reported under §6050W on Form 1099-K, by the payment settlement entity, and not under §6041, so it does not go on Form 1099-NEC or 1099-MISC (§1.6041-1(a)(1)(iv); the 1099-K instructions extend this to §6041A). For this test the third party network de minimis of §1.6050W-1(c)(4) (more than $20,000 and more than 200 transactions) is disregarded, so the exclusion holds below it. A payment card is any card, including a stored-value card, meaning any card with a prepaid value, gift cards included (§1.6050W-1(b)(3)(i), (b)(4)). The regulation does not name credit or debit cards; the IRS page names credit, debit and stored-value cards as payment cards. The rule covers payments made after December 31, 2010. +- **The card must be accepted by the payee.** A payment card transaction is one in which the card, or its number or other indicia, is accepted as payment (§1.6050W-1(b)(1)); the participating payee is the person who accepts the card as payment ((a)(5)(i)(A), (b)(3)(ii)). The regulation's own exclusions turn on that test: an ATM withdrawal or cash advance is not a payment card transaction because the card is not being accepted as payment by a merchant or other payee ((b)(5)(i)), and neither is a convenience check, which is processed through the banking system ((b)(5)(ii)). So a card that only funds a transfer which the payee does not accept as a card payment is not a payment card transaction with that payee. The texts do not name card-funded transfers; this rests on (b)(1), (a)(5)(i)(A) and (b)(3)(ii), with (b)(5) as the regulation's examples. If the payee instead accepts payment from a third party settlement organization, the transaction is a third party network transaction ((a)(5)(i)(B), (c)(1) to (c)(3)). +- **ACH is not a TPSO.** Automated clearing houses do not qualify as third party settlement organizations and do not report under §6050W (1099-K instructions, Note). A plain ACH transfer (no card, no TPSO) is therefore not a §6050W transaction, and §1.6041-1(a)(1)(iv) does not take it off Form 1099-NEC/MISC. +- **Not reportable under §6041** (§1.6041-3, introductory text): payments of bills for merchandise, telegrams, telephone, freight, storage, and similar charges ((c)); and payments to: + - a corporation ((p)(1)), except attorneys' fees paid after December 31, 1997 and a corporation providing, or billing and collecting for, medical and health care services; even then nothing is reported for a §501(c)(3) tax-exempt hospital or extended care facility, or one owned and operated by the United States, a State, the District of Columbia, a possession, or a political subdivision, agency or instrumentality of any of them; + - an organization exempt from tax under §501(a), or an individual retirement plan ((p)(2)); + - the United States ((p)(3)); + - a State, the District of Columbia, a possession of the United States, or a political subdivision of any of them ((p)(4)); + - a foreign government or a political subdivision of one ((p)(5)); + - a foreign payee or foreign beneficial owner, when the payor holds documentation it may rely on before paying (§1.6041-4(a)(1)). + + Wages paid to an employee go on Form W-2 (§1.6041-2(a)(1)). The 1099-MISC/NEC instructions count an LLC treated as a C or S corporation as a corporation, and list payments to corporations that stay reportable: cash for fish bought for resale (1099-MISC box 11), medical and health care payments (box 6), substitute payments in lieu of dividends or tax-exempt interest (box 8), gross proceeds paid to an attorney (box 10), attorneys' fees (1099-NEC box 1a), and payments by a federal executive agency for services (1099-NEC box 1a). +- **Treating a payee as a corporation.** Absent actual knowledge otherwise, a payor may treat a payee as a corporation if, before payment, its name carries Incorporated, Inc., Corporation, Corp., or P.C. (Company or Co. does not count), or the payor has a corporate resolution or similar document on file (a copy of a Form 8832 association election counts), or a Form W-9 with an EIN stating it is a domestic corporation, or a foreign-corporation withholding certificate (§1.6049-4(c)(1)(ii)(A)(1) to (4)). +- **The payor.** The payor is the person engaged in a trade or business who makes the payment in the course of it (§1.6041-1(a)(1)(i)). A person who pays on behalf of another is the payor only if it performs management or oversight functions in connection with the payment or has a significant economic interest in it; mere administrative or ministerial functions, such as writing checks at another's direction, are not management or oversight (§1.6041-1(e)(1)(i), (ii)). If two or more qualify, the one closest in the chain to the payee reports unless they agree in writing otherwise ((e)(2)). The quoted texts do not name a member paying the company's bill from personal funds; under (e)(1) a member who only makes the payment at the company's direction is not the payor. +- **Corporate status by election.** An entity organized under a Federal or State statute that describes it as incorporated or as a corporation is a corporation, and so is an association as determined under §301.7701-3 (§301.7701-2(b)(1), (2); see `irs-entity-classification.md`). An election on Form 8832 takes effect on the date the form specifies, or on the date filed if it specifies none; the date can be no more than 75 days before nor more than 12 months after filing, and a date outside that range is moved to the limit (§301.7701-3(c)(1)(iii)). +- **What §70433 changed.** It raised the §6041(a) amount from $600 to $2,000 ((a)), indexed it for calendar years after 2026 in a new §6041(h), each increase rounded to the nearest $100 ((b)), made §6041A(a)(2)'s nonemployee-compensation threshold the §6041(a) amount in effect for the calendar year ((c)) and §3406(b)(6)'s backup-withholding amount the same ((d)), and made §6041(a) count by calendar year ((e)(2)). So the $2,000 holds for Form 1099-NEC (§6041A(a)(2)) and for the 1099-MISC payments reported under §6041(a) (its "rent, salaries, wages, premiums, annuities, compensations, remunerations, emoluments, or other fixed or determinable gains, profits, and income": `irs-1099-thresholds.md`) alike. It changed neither §6050N(a)'s $10 for royalties nor §1.6045-5(a)(1)'s $600 for payments to an attorney in connection with legal services (gross proceeds, 1099-MISC box 10), which leaves out what §6041(a) already reports, or would but for its dollar amount ((c)(4)): attorneys' fees for the payor's own legal work are §6041's, at its amount. The texts quoted here don't say whether payments a payee gets on both forms add up toward one threshold. +- **Dates.** §1.6041-1(a)(1)(iv) governs payments made after December 31, 2010. The amendments of Pub. L. 119-21 §70433 (the $2,000 threshold, `irs-1099-thresholds.md`) apply to payments made after December 31, 2025 (§70433(f)). diff --git a/docs/testing.md b/docs/testing.md index 34fcb94..e64b38d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1286,6 +1286,88 @@ cash, in_kind) and `update_party`'s creditor_of diff; the viewer's `Party::tin_last4`'s `or_else` order swapped (TIN facts and W-9 facts never disagree, parties INVARIANT 2a, enforced on replay). +## P3: 1099s (defects 23, 12 and 18; 2026-10-20) + +The 1099 decision kept as a decision (nec, misc, not_reportable with its +reason kind and words, undetermined), with who decided and the date it +takes effect (D-40); payment methods and the tag on each payment; one +report for the server, the package and the viewer (rules.md B-19). No +mutation run (D-33); the list P12 must cover is at the end. + +| Law (rules.md B-19) | Tests | +|---|---| +| Undecided is not "not reportable" (defect 18) | `undecided_is_not_not_reportable` | +| "No 1099" is a decision with who and when; "none" refused (defect 23) | `a_vendor_marked_no_1099_is_not_undecided`, `the_cutovers_reasons_are_recorded_as_given`, `set_vendor_1099_says_what_a_decision_needs` (`ledger-mcp` `spec_1099.rs`) | +| A member's card is excluded, their check counts, unknown is undetermined (defect 12) | `a_members_personal_card_payment_is_not_a_1099_payment`, `payments_come_from_cash_and_financing_only` | +| Card-account payments are listed, not dropped | `card_account_payments_are_listed_not_dropped` | +| A bank payment counts unless tagged with a card (D-40) | `a_debit_card_payment_from_the_bank_is_excluded_when_tagged` | +| A tag describes one account (review R2) | `a_tag_naming_another_account_is_undetermined` | +| Money between the company's own sources pays no one (R1) | `paying_down_a_financing_liability_pays_no_vendor`, `paying_the_card_bill_pays_no_vendor` | +| Unknowns never settle the status (R3) | `a_negative_unknown_keeps_a_reportable_vendor_undetermined` | +| Money from a vendor for anything but a refund doesn't lower its 1099: a sale, its invoice paid, a disposal, a loan (R6) | `a_sale_to_a_contractor_does_not_lower_its_1099`, `money_from_a_vendor_that_is_no_refund_does_not_lower_its_1099`, unit `the_other_receipts_note_says_what_each_entry_did` (`ledger-package`) | +| Cash accounts named, dollars (R5, R16) | `the_report_needs_the_cash_accounts_and_dollars` | +| A shared payment splits across buckets to the cent (R12) | `a_shared_payment_splits_across_buckets_to_the_cent`, unit `a_vendor_share_splits_across_buckets_to_the_cent` | +| Dated decisions (D-40) | `a_status_change_mid_year_applies_by_payment_date`, `an_incorporation_on_december_30_is_a_new_vendor`, `an_llc_electing_corporate_taxation_keeps_its_vendor`, `a_dated_decision_records_a_change`, `a_decision_change_names_the_payments_it_reaches`, `an_undated_old_decision_is_in_force_from_the_beginning`, `two_status_changes_in_a_year_and_a_reversal_across_them`, unit `dated_decisions_take_effect_on_their_dates` | +| A change for a closed or due year warns (D-40, R15) | `a_change_reaching_a_filed_year_warns` (`spec_1099.rs`) | +| The bucket table, all 16 cases; splitting and netting exact | `payment_bucket_is_the_table_in_all_16_cases`, `buckets_net_money_moved_between_the_companys_sources`, Kani `payment_bucket_is_the_table`, `payment_split_is_exact`, `net_buckets_keeps_the_total`, bridge `payment_buckets_agree` | +| Properties | `no_instruments_no_financing_is_the_old_report` (the regression: with cash named and no methods or financing, the old figure exactly), `buckets_partition_each_payment`, `a_reversal_nets_every_bucket_to_zero`, `the_worksheet_ties_to_the_ledger` (paid + excluded + undetermined + not reportable + receipts for something else = every payment line in the year, dated decisions, sales and receipts included; the payment lists sum to their columns) | +| Payment methods: refusals, the reversal chain | `a_payment_method_is_refused_unless_sound`, `a_payment_method_tags_the_whole_reversal_chain`, unit tests in `instruments.rs` | +| W-9 flag kept apart from the decision (brief 1.5, R8) | `set_vendor_1099_keeps_the_w9_flag_when_omitted`, `set_w9_keeps_the_1099_decision_and_its_source`, `set_w9_declares_the_w9_flags_feature_before_its_first_line` (`spec_1099.rs`), `marking_a_w9_keeps_who_decided_the_1099` (`ledger-store` `tests/form1099.rs`) | +| Journal: features declared once before their first line; replay and projection exact; refusals and no-ops journal nothing; newer values newer; old books as they always meant | `ledger-store` `tests/form1099.rs` (`each_feature_is_declared_once_before_its_first_line`, `decisions_and_methods_replay_and_project`, `refusals_and_no_ops_journal_nothing`, `an_unknown_1099_value_is_newer_not_corrupt`, `books_written_before_decisions_read_as_they_always_meant`), unit tests in `ledger-store` `form1099.rs`, the op model (`refused_commands_never_reach_the_journal` with `SetVendor1099`, `SetW9Flag`, `AddInstrument`, `SetEntryInstrument`; an accepted command journals or changes nothing), `books_an_earlier_version_wrote_open_to_its_figures` (fixture `p2_vendor_1099`) | +| Records change no entry (verification 13a) | `decision_and_method_records_change_no_entry` | +| The quieter untagged note (R7) | `the_untagged_note_counts_only_money_out` (`ledger-package`) | +| One builder: the package CSV is the worksheet CSV for the package's accounts; README wording | `ledger-package` `tests/package.rs` (the zip's CSV equals `vendor_1099_csv` for the request's accounts, else the settings', through as_of: `payments_to_a_vendor_come_from_the_packages_accounts`, `one_list_of_cash_accounts_per_package`, `the_1099_worksheet_stops_at_as_of`; the README line pinned); over the MCP, the package's CSV is `books.vendor_1099`'s for the same `cash_accounts`, `financing_accounts` and `through` (`ledger-mcp` `oracle_mcp.rs` `cpa_package_holds_the_year_and_its_evidence`) | +| The viewer counts as the server does (brief 1.4) | `the_viewer_counts_the_1099_as_the_server_does` (`ledger-gui` `tests/parties.rs`) | +| End to end, restarting between steps | `payment_methods_round_trip_and_classify_payments` (`spec_1099.rs`), `mcp_vendor_1099_over_stdio`, `spec_mcp.rs` (every tool exercised and schema-checked, `books.instruments` read-only) | +| bookspec | `bookspec/examples/1099-*.toml` | + +**For P12 (mutation targets, none run):** `ledger-core` `form1099.rs` +(`plan_decision`, `apply_decision`, `check_new_decision`'s decided-before +test, `in_force`/`in_force_before` (`<` against `<=`), `vendor_1099_report`'s +status branches (each `>=`, `<`, `max(.,0)`, `min(.,0)`), the listing +filter, the not-reportable and undetermined routing, the R6 test +(`< 0`, credit side, `refund_accounts` per account type), `Roles::new` +(which methods' accounts are financing, which a card's), `split_share` (the floor corrections, +the sign), `vendor_part`, the unresolved fallback, `decisions_during`, +`untagged_payments` (`> 0`, each skip, each debit kind), `decision_changes`); +`kernel.rs` (`payment_bucket`, `split_payment`, `net_buckets`); +`instruments.rs` and `Ledger::check_add_instrument` (each refusal), +`set_entry_instrument` (the chain), the reversal step's copy, +`set_vendor_w9_flag`; `ledger-store` `form1099.rs` (each decode check, +`newer_content`), `store.rs` (`ensure_feature`, `set_vendor_1099`'s +unchanged path, `set_vendor_w9_flag`, `add_instrument`, +`set_entry_instrument`'s no-op, the replay and projection arms, +`sqlite_reversal_chain`); `ledger-mcp` `tools/instruments.rs` +(`decision_in`'s refusals, `filed_year_warnings`' closed and due tests, +`resolve_instrument`, `add_instrument`'s warnings), `server.rs`'s +`set_vendor_1099` (the W-9-only path) and `set_w9` (the feature declared +first, only when the flag changes); `ledger-package` `vendor_1099.rs` (the CSV columns, every note's +guard, `may_file`, `missing_w9`'s suffix) and `package.rs` (tin_gaps, the +README line). The viewer has no mutation job (its musts are tested). + +## P3: parties and 1099s landed together (2026-10-21) + +The two pieces above, stacked on the IIF, cash flow and rename pieces, and +reconciled where they meet. Built under D-33 (no mutation run); what P12 +must cover is at the end. + +| What | Where | Oracle | +|---|---|---| +| One rule for what a card is: the recorded business cards' liabilities, else (none recorded) liabilities named "card", not "gift"; never a member's card, a member's check or a platform; a debit card on the bank is none; money follows it | `ledger-core` `tests/spec_party_totals.rs` `a_card_is_a_recorded_business_card_else_a_liability_named_card` | B-35 item 5, `parties::card_accounts` INVARIANTS 1-4 | +| Party totals and the 1099 worksheet never contradict: paid less received is the worksheet's paid + excluded_card + undetermined + not_reportable plus its other receipts, for a vendor named alone, whatever the methods, refunds and reversals | property `party_totals_are_the_1099s_buckets` (`ledger-core` `tests/form1099.rs`; `PROPTEST_CASES`, default 256; 2,000 run clean; dropping a bucket from the sum fails at once) | B-35 item 9, by hand | +| The same, one year by hand: a member's check, a member's card, a member's payment of unknown method, a company card charge and a company check to one contractor, then the member reimbursed | `bookspec/examples/1099-and-party-totals-for-a-members-payment.toml` | B-35 item 9; Treas. Reg. §1.6041-1(e)(1), (a)(1)(iv) | +| The CPA package's 1099 worksheet is `books.vendor_1099`'s CSV for the package's accounts (the request's, else the settings', R18) through its as_of, byte for byte; without `through` the files differ; a `through` outside the year and an unknown account are refused | `ledger-mcp` `oracle_mcp.rs` `cpa_package_holds_the_year_and_its_evidence`; `ledger-package` `tests/package.rs` `payments_to_a_vendor_come_from_the_packages_accounts`, `one_list_of_cash_accounts_per_package`, `the_1099_worksheet_stops_at_as_of` | B-33; mcp-interface.md | +| A feature is declared before a command's first line: `books.update_party` with a field and the first creditor journals the declaration, then the field, then the creditor | `ledger-mcp` `tests/spec_party_totals.rs` `update_party_declares_the_creditor_feature_before_its_first_line` | B-27 | +| One bookspec loader for every example, the parties' and the 1099s' with them: `setup.parties`, `setup.members` (`distributions_account`), `setup.payees` (a party's vendor record), `setup.status_changes`, `setup.instruments`, events' `parties` and `instrument`, and the queries `vendor_1099`, `k1`, `party_part`, `party_totals`, `party_tin_last4`; refused: a party or payment method not in the setup, a reversal naming parties, party totals without a basis, a 1099 line leaving a figure out, a wrong direction or status | `ledger-core` `tests/bookspec.rs` (`every_bookspec_example_holds`, `a_party_not_in_the_setup_is_refused`, `a_party_parts_direction_is_checked`, `a_reversal_naming_parties_is_refused`, `party_totals_without_a_basis_are_refused`, `a_vendor_1099_line_leaving_a_figure_out_is_refused`, `a_vendor_1099_status_is_checked`, `a_payment_method_not_in_the_setup_is_refused`) | bookspec/README.md | + +**For P12 (mutation targets, none run, D-33):** `parties::card_accounts` +(the recorded-cards branch, the gift and the other-instruments +exclusions); `ledger_package::Settings::with_accounts` (each list's +fallback); the server's `vendor_1099` (`through`'s year check, the +unknown-code check, the accounts passed) and `update_party`'s feature +declaration; the bookspec loader's party, payee, decision and payment +method setup. + ## Benchmarks (baseline 2026-10-13) No budget, by decision (the owner, 2026-10-13: the books do little work diff --git a/docs/verification.md b/docs/verification.md index 32a4cae..184cdfe 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -161,13 +161,18 @@ sequence of commands, of any length**: closed. - Lean `Step.frozen`, `Steps.frozen` 14a. **How the books are kept changes no entry**: in the model, a record - (a setting, convention, decision, open item or approval) leaves the + (a setting, convention, decision, open item or approval; a vendor's + 1099 decision or W-9 flag, a payment method, an entry's payment-method + tag) leaves the chart, every posted entry, every draft and every period exactly as they were, and any number of them keeps a reachable state reachable. The model's `record` step touches only the records by definition, so the proof says the model is sound, not the engine; the engine's side is checked by `settings_replay_is_pure`, which snapshots every figure and - entry around each record (rules.md B-29). Settings that change how the + entry around each record (rules.md B-29), and for the 1099 records by + `decision_and_method_records_change_no_entry` (ledger-store + `tests/form1099.rs`; they change the 1099 worksheet by design, never + an entry). Settings that change how the same entries are presented (the fiscal year, cash flow roles, members) are outside this law by design. A rename (`rename_account`, P3, rules.md B-31) is a record too: the model's chart is `(code, type)` @@ -181,7 +186,8 @@ sequence of commands, of any length**: and model agree on every rename's refusal and nothing else moves. - Lean `Step.record_changes_no_figure`, `Reachable.records` (no axioms at all) · property `settings_replay_is_pure` · - `prop_rename_changes_no_figure` · model `Cmd::Rename` + `prop_rename_changes_no_figure` · model `Cmd::Rename` · test + `decision_and_method_records_change_no_entry` ### Closing @@ -282,6 +288,24 @@ their numbers are assigned when P3 lands: `sumOver_movement_reversal` · property `a_reversal_is_the_exact_opposite` +### 1099 payments + +21. **A payment to a vendor is split, never lost or counted twice**: + each payment line goes in the bucket rules.md B-19's table names (its + 16 cases: with no payment method naming its account, a financing line + never counts and a card account's line is always a card's); the + buckets are each exactly the credits minus debits of their lines; and + netting money moved between the company's own sources keeps the + total, leaves no bucket opposite it, and leaves buckets of one sign + alone. + - Kani `payment_bucket_is_the_table`, `payment_split_is_exact`, + `net_buckets_keeps_the_total` · bridge `payment_buckets_agree` · + test `payment_bucket_is_the_table_in_all_16_cases` · properties + `buckets_partition_each_payment`, + `no_instruments_no_financing_is_the_old_report`, + `a_reversal_nets_every_bucket_to_zero` (ledger-core + `tests/form1099.rs`) + ## What the proofs found The engine already had 190+ tests, property tests, mutation testing, diff --git a/fuzz/fuzz_targets/fuzz_store_replay.rs b/fuzz/fuzz_targets/fuzz_store_replay.rs index e51d511..5293a7a 100644 --- a/fuzz/fuzz_targets/fuzz_store_replay.rs +++ b/fuzz/fuzz_targets/fuzz_store_replay.rs @@ -33,6 +33,7 @@ const TYPES: &[&str] = &[ "schedule.posted", "schedule.registered", "vendor.1099_set", "vendor.added", "journal.format_set", "settings.set", "convention.set", "convention.retired", "decision.recorded", "open_item.raised", "open_item.resolved", "entry.approved", + "vendor.w9_flag_set", "instrument.added", "entry.instrument_set", ]; const FIELDS: &[&str] = &[ @@ -48,13 +49,14 @@ const FIELDS: &[&str] = &[ "total", "useful_life_months", "vendor_id", "type", "account_type", "rounding", "members", "distributions", "sweep", "format", "features", "changes", "key", "value", "source", "decided_by", "decided_on", "kind", "title", "body_md", "structured", "subject", - "decision_md", "supersedes", "resolution", "approver", "previous", + "decision_md", "supersedes", "resolution", "approver", "previous", "form_1099", + "reason_kind", "effective", "instrument_id", "label", "last4", "owner_party_id", ]; /// A value replay might plausibly see: small ids, amounts as decimal /// strings, dates, sides, currencies, account types, nested objects. fn value(u: &mut Unstructured, depth: u32) -> arbitrary::Result { - Ok(match u.int_in_range(0..=11)? { + Ok(match u.int_in_range(0..=12)? { 0 => json!(u.int_in_range(0u64..=6)?), 1 => json!(format!("{}.{:02}", u.int_in_range(0u32..=5000)?, u.int_in_range(0u32..=99)?)), 2 => json!(format!("2026-{:02}-{:02}", u.int_in_range(1u32..=12)?, u.int_in_range(1u32..=28)?)), @@ -69,6 +71,8 @@ fn value(u: &mut Unstructured, depth: u32) -> arbitrary::Result { Value::Array((0..n).map(|_| object(u, depth + 1)).collect::>()?) } 10 if depth < 2 => object(u, depth + 1)?, + // 1099 decisions and payment methods (plan P3). + 11 => json!(*u.choose(&["nec", "misc", "not_reportable", "corporation", "business_card", "member_card", "0042"])?), _ => json!(String::arbitrary(u)?), }) } diff --git a/plan/served.md b/plan/served.md index 5d39621..9a5d8a5 100644 --- a/plan/served.md +++ b/plan/served.md @@ -627,12 +627,12 @@ The `WANDERBOOKS_TOML` template (`ledger-cli/src/main.rs:237-347`) becomes the e | When | Step | Acceptance | |---|---|---| | P0 | Install new binaries (still stdio). Capture **golden JSON** of TB, P&L, BS, CF, members' equity, GL, journal, vendor_1099, parties, assets, schedules and digest, as of 2026-10-11, every month-end, FY-to-date and today, through the old binary over MCP. By decision (2026-10-16) the first company's goldens are kept privately in its own books dir, not in this public repo: `export_cpa_package` zips plus a canonical JSON form made by that dir's `goldens/canon.py` (which normalizes the text each export varies, such as its timestamp), with the comparison protocol in its README. | Goldens stored | -| P1 | The agent reads `/home/swaits/Documents/the first company-Books/wanderbooks.toml` (and `books-data/config.json` only by asking the owner to state its values, since it sits under `books-data/`) and calls `import_settings`. Expected diff:
• the 15 `[company]` keys; EIN as identity
• members 50/50 → 3000/3001 with `files_jointly_with`
• `accounting_basis=accrual`, `policy.evidence.threshold=0.00` (the owner, 2026-10-12)
• `cash_accounts=["1000"]`, `financing_accounts=["2100"]`; the citation is corrected to ASC 230-10-45-14(b)
• `parties=require` (2026-10-15), `timezone=America/Denver` (D-13, decided)
• `policy.approval_rules` stays `off`

Conventions are proposed in one batch preview from `AGENTS.md:74-227`:
• 9 posting templates: joint contribution as two entries; expense from joint personal funds → 2100; expense from Bluevine; household reimbursement (`Dr 2100 / Cr 1000`) with a `note` that the owner gives the go-ahead before money moves, recorded with its source and not an enforced gate; 50/50 distribution; revenue 4000; card 2050 expense and payment; cash entries dated by bank posting date
• policies: numbering, one file per receipt (PDF preferred), email as PDF, capitalization (`policy.capitalization` plus decisions 2026-10-11 and 2026-10-16)
• `close_step`: "ask the owner whether a member paid anything personally"
• `calendar`: Utah annual report around Oct 4, renewal fee to taxes & licenses
• memory-only rules become conventions: post when ready; decisions to the owner go one question at a time; TINs only in the vault

Decisions: one file per receipt (10-11), threshold (10-12), cutover with Sheet frozen as of 10-11 and reference attachment 37 (10-14), renumber 6100→6050 and card at 2050 (10-14), TINs in vault (10-15); light governance, meaning no consent paperwork, spending limits, signed memos or amendments for the two-member LLC, with decisions recorded with their source (10-16); local isolation "you, hidden away", the viewer reading the disk directly, no added permission prompts, and self-host now with hosting later (10-16).

Open items:
• IRS name control "WALN" discrepancy; letter = attachment 70, mailing pending
• deferred revenue to be raised with the CPA before the first subscription

Per-entry notes: entries 4 and 5 dated Oct 7 to match the Sheet. | Every golden report and export is byte-identical before and after the import. GUI shows the first company correctly with `wanderbooks.toml` renamed away. No `approval_rule` convention is created, and every daily-flow post still succeeds without an approval | +| P1 | The agent reads `/home/swaits/Documents/the first company-Books/wanderbooks.toml` (and `books-data/config.json` only by asking the owner to state its values, since it sits under `books-data/`) and calls `import_settings`. Expected diff:
• the 15 `[company]` keys; EIN as identity
• members 50/50 → 3000/3001 with `files_jointly_with`
• `accounting_basis=accrual`, `policy.evidence.threshold=0.00` (the owner, 2026-10-12)
• `cash_accounts=["1000"]`, `financing_accounts=["2100"]`; the citation is corrected to ASC 230-10-45-14(b)
• `parties=require` (2026-10-15), `timezone=America/Denver` (D-13, decided)
• `policy.approval_rules` stays `off`

Conventions are proposed in one batch preview from `AGENTS.md:74-227`:
• 9 posting templates: joint contribution as two entries; expense from joint personal funds → 2100; expense from Quillmere; household reimbursement (`Dr 2100 / Cr 1000`) with a `note` that the owner gives the go-ahead before money moves, recorded with its source and not an enforced gate; 50/50 distribution; revenue 4000; card 2050 expense and payment; cash entries dated by bank posting date
• policies: numbering, one file per receipt (PDF preferred), email as PDF, capitalization (`policy.capitalization` plus decisions 2026-10-11 and 2026-10-16)
• `close_step`: "ask the owner whether a member paid anything personally"
• `calendar`: Utah annual report around Oct 4, renewal fee to taxes & licenses
• memory-only rules become conventions: post when ready; decisions to the owner go one question at a time; TINs only in the vault

Decisions: one file per receipt (10-11), threshold (10-12), cutover with Sheet frozen as of 10-11 and reference attachment 37 (10-14), renumber 6100→6050 and card at 2050 (10-14), TINs in vault (10-15); light governance, meaning no consent paperwork, spending limits, signed memos or amendments for the two-member LLC, with decisions recorded with their source (10-16); local isolation "you, hidden away", the viewer reading the disk directly, no added permission prompts, and self-host now with hosting later (10-16).

Open items:
• IRS name control "WALN" discrepancy; letter = attachment 70, mailing pending
• deferred revenue to be raised with the CPA before the first subscription

Per-entry notes: entries 4 and 5 dated Oct 7 to match the Sheet. | Every golden report and export is byte-identical before and after the import. GUI shows the first company correctly with `wanderbooks.toml` renamed away. No `approval_rule` convention is created, and every daily-flow post still succeeds without an approval | | P2 (pre-cutover) | Run `verify-sheet` and `drive-manifest verify` a final time and file their outputs as `workpaper` documents. Recapture goldens. | Both pass | | P2 cutover (about 10 min) | 1. Ask the owner the two open cutover questions (section 10.3: Q-1 backups, Q-3 old copies; Q-2 is answered), one at a time, in plain English.
2. Quit the agent sessions and the GUI.
3. `wanderbooks setup`, with no sudo: it creates the data root (0700) and asks for the owner passphrase. Nothing is installed to run in the background (D-21).
4. Nothing to start: each command below opens the books while it runs, and afterwards the app or Claude opens them (D-23).
5. `wanderbooks books adopt /home/swaits/Documents/the first company-Books/books-data --id fernhill` (archive target from Q-1; old copies moved per Q-3).
6. `wanderbooks connect claude-code --book fernhill` (removes the old stdio registration made by `just mcp-add`, adds the bridge's stdio registration, `wanderbooks mcp --book fernhill`).
7. `wanderbooks connect codex --book fernhill`.
8. `wanderbooks gui fernhill`.
9. Remove `justfile`, `.claude/settings.json` hook, `.codex/hooks.json`, `scripts/` (archived under the data root's `legacy/`); `AGENTS.md`/`CLAUDE.md` become a 3-line pointer ("connect the wanderbooks MCP; call books.start").
10. The `company/` records are already filed (Q-2, D-20); the local copies stay until P6. | `books.verify` over MCP equals H. Goldens byte-equal. `books.start` works from Claude Code started in `~` and in `/tmp/empty`, and reports `isolation: "same_user (advisory)"`. No connect text, tool text, handbook section or result in a recorded cutover session names the data root. No step asked for sudo. If Q-3 is "move": the project folder holds no journal, blobs, vault or git history containing them. Viewer updates within 1 s. With every holder closed the books close cleanly (`books.verify` ok with the same head at the next open), and a Claude Code session started with the books closed opens them by itself, with no reconnect. **Rollback (nothing posted on the server):** close the books (`wanderbooks close`), move `legacy/fernhill-/` back to the project folder (or use the untouched source if Q-3 was "no"), and re-register the P1 binary's stdio server as before cutover. **Rollback (entries posted):** `wanderbooks books export-legacy fernhill` writes a data dir with the same head check, then the same re-registration | | P3 | Defect fixes change reports (cash flow, 1099 sections, asset fields, digest). Each difference is hand-adjudicated in the P3 change log. 1099 classification and 704(c) fields are shown to the CPA. | Change log signed off | | P4 | `list_drafts`. Leftover drafts are discarded or set to `awaiting_evidence`. | Zero unexplained drafts | -| P6 | • `consolidate_duplicates` resolves the 11 redundant records (preview first).
• Extraction is backfilled for all 66 attachments.
• Receipt numbers 1000–1029 are annotated from the "Receipt ID" in each description. Anomalies become annotations: 1009 unissued; 1011/1020 byte-identical pairs (merged); 43/45/47 have no ID. The next number is derived as 1030.
• Instruments: Bluevine, joint Amex, member cards, Chase Ink 2050.
• `verify-sheet.py`'s ALIASES/RECLASSIFIED/RENUMBERED tables (`scripts/verify-sheet.py:52, 69-108`) become a `mapping` convention, then `compare_reference` runs against attachment 37 as of 2026-10-11.
• `drive-manifest.json` text goes to `record_inventory`; the Drive connector listing goes to `check_inventory`. | `compare_reference` gives 0 differences. Inventory has 0 gaps. Drive deletion becomes possible (still the owner's call) | +| P6 | • `consolidate_duplicates` resolves the 11 redundant records (preview first).
• Extraction is backfilled for all 66 attachments.
• Receipt numbers 1000–1029 are annotated from the "Receipt ID" in each description. Anomalies become annotations: 1009 unissued; 1011/1020 byte-identical pairs (merged); 43/45/47 have no ID. The next number is derived as 1030.
• Instruments: Quillmere, joint Amex, member cards, Chase Ink 2050.
• `verify-sheet.py`'s ALIASES/RECLASSIFIED/RENUMBERED tables (`scripts/verify-sheet.py:52, 69-108`) become a `mapping` convention, then `compare_reference` runs against attachment 37 as of 2026-10-11.
• `drive-manifest.json` text goes to `record_inventory`; the Drive connector listing goes to `check_inventory`. | `compare_reference` gives 0 differences. Inventory has 0 gaps. Drive deletion becomes possible (still the owner's call) | | P7 | `wanderbooks vault init --book fernhill` (on the human page, behind the owner passphrase): generates the HPKE keypair; keyslots for passphrase, recovery key (printed) and optional passkey PRF; reseals every record; journals `vault.created`, `vault.keyslot_added`×N, `vault.resealed`. A TIN is checked in the viewer, then `keys/legacy.key` and `~/.config/wanderbooks/vault.key` are shredded (the owner confirms). | Last-four and kind match the journal. No vault key material remains on disk except the wrapped keyslots in the data root (no v1 key, no `~/.config/wanderbooks/vault.key`) | | P10 | Re-audit (section 8). | — | diff --git a/verify/lean/Wanderbooks/Ledger.lean b/verify/lean/Wanderbooks/Ledger.lean index 21a9885..965d7f9 100644 --- a/verify/lean/Wanderbooks/Ledger.lean +++ b/verify/lean/Wanderbooks/Ledger.lean @@ -131,8 +131,10 @@ inductive Step : Ledger → Ledger → Prop Step L { L with posted := L.posted ++ closingFor L p ts id, periods := closeIn p L.periods } /-- `apply_settings`, `apply_convention`, `apply_decision`, `apply_open_item`, `apply_approval` and their retirements and - resolutions: a record of how the books are kept, and nothing else. - `rename_account` too: a name is outside the chart the model keeps. -/ + resolutions, and the 1099 records `set_vendor_1099`, + `set_vendor_w9_flag`, `add_instrument` and `set_entry_instrument`: a + record of how the books are kept, and nothing else. `rename_account` + too: a name is outside the chart the model keeps. -/ | record (L : Ledger) (r : Nat) : Step L { L with records := r :: L.records } /-- States reachable from empty books. -/