P3: dated 1099 decisions, payment methods and the card exception (defects 23, 12, 18, D-40)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
A vendor's 1099 decision is its own dated fact (files NEC or MISC, not reportable with its reason, or undetermined), applied by each payment's date. Payment methods are recorded; card and network payments are left out and listed; a member's check through financing counts; a bank payment with no method counts (D-40); $2,000 for payments after 2025. One worksheet for the tool, the viewer and the CPA package. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
77 files changed
+11455
-970
No files matched your search
+33
-8
@@ -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): `"<member>.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): `"<member>.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
|
||||
|
||||
@@ -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" }
|
||||
@@ -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" }
|
||||
@@ -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" }
|
||||
@@ -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" }
|
||||
@@ -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" }
|
||||
@@ -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" }
|
||||
@@ -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" }
|
||||
@@ -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 = [
|
||||
|
||||
@@ -202,6 +202,25 @@ async fn connect_with(home: &Home, book: &str, token: Option<String>) -> Result<
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Name cash 1000 the books' cash account (adding it if the chart lacks
|
||||
/// it): the 1099 worksheet counts payments out of the books' cash
|
||||
/// accounts and is refused without them.
|
||||
async fn name_cash(c: &Client) {
|
||||
let _ = call(
|
||||
c,
|
||||
"books.add_account",
|
||||
json!({"code": "1000", "name": "Cash", "account_type": "asset"}),
|
||||
)
|
||||
.await;
|
||||
call(
|
||||
c,
|
||||
"books.set_setting",
|
||||
json!({"changes": [{"key": "books.cash_accounts", "value": ["1000"]}], "decided_by": "Test Owner"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Call a tool: Ok(structured result) or Err(the error text).
|
||||
async fn call(c: &Client, tool: &str, args: Value) -> Result<Value, String> {
|
||||
let args = args.as_object().cloned().unwrap_or_default();
|
||||
@@ -704,6 +723,7 @@ async fn a_book_in_the_root_keeps_its_vault_and_key_inside_its_own_folder() {
|
||||
"the TIN wasn't sealed into the book's vault"
|
||||
);
|
||||
// A file with TINs is handed over, never written loose in the root.
|
||||
name_cash(&c).await;
|
||||
let w = call(
|
||||
&c,
|
||||
"books.vendor_1099",
|
||||
@@ -2525,6 +2545,7 @@ async fn lapsed_uploads_are_swept_while_the_server_runs() {
|
||||
grant["expires"] = json!(0);
|
||||
std::fs::write(&json, grant.to_string()).unwrap();
|
||||
// An export too (a file the books made), aged the same way.
|
||||
name_cash(&c).await;
|
||||
let made = call(&c, "books.vendor_1099", json!({"year": 2026, "csv": true}))
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -2637,6 +2658,7 @@ async fn a_file_the_books_make_is_fetched_by_its_own_token() {
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
name_cash(&c).await;
|
||||
// The CPA package, TINs and all: handed over, never inline, never a
|
||||
// path; the command runs as this token.
|
||||
let got = call(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -42,16 +42,35 @@ pub struct Vendor {
|
||||
pub id: VendorId,
|
||||
/// Display name.
|
||||
pub name: String,
|
||||
/// The information return this vendor gets, if any (see
|
||||
/// [`crate::form1099`]).
|
||||
/// Its 1099 decisions, each from the date it takes effect, in date
|
||||
/// order (one from the beginning first); none when nobody has decided
|
||||
/// (see [`crate::form1099`], D-40).
|
||||
#[serde(default)]
|
||||
pub form_1099: Option<crate::form1099::Form1099>,
|
||||
pub decisions_1099: Vec<crate::form1099::Dated1099>,
|
||||
/// A W-9 (taxpayer identification number) is on file. The number
|
||||
/// itself is never stored here.
|
||||
#[serde(default)]
|
||||
pub tin_on_file: bool,
|
||||
}
|
||||
|
||||
impl Vendor {
|
||||
/// The 1099 decision in force on `date`, if any was recorded by then.
|
||||
pub fn decision_on(&self, date: NaiveDate) -> Option<&crate::form1099::Decision1099> {
|
||||
crate::form1099::in_force(&self.decisions_1099, date).map(|d| &d.decision)
|
||||
}
|
||||
|
||||
/// The latest 1099 decision recorded (the one in force from the last
|
||||
/// effective date on), if any.
|
||||
pub fn latest_1099(&self) -> Option<&crate::form1099::Dated1099> {
|
||||
self.decisions_1099.last()
|
||||
}
|
||||
|
||||
/// The return its latest decision files, if it files one.
|
||||
pub fn form_1099(&self) -> Option<crate::form1099::Form1099> {
|
||||
self.latest_1099().and_then(|d| d.decision.form())
|
||||
}
|
||||
}
|
||||
|
||||
/// Payment status of an invoice or bill.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
|
||||
pub enum DocumentStatus {
|
||||
|
||||
@@ -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),
|
||||
|
||||
+1252
-158
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,202 @@
|
||||
//! Payment instruments: how the company (or a member, for it) paid.
|
||||
//!
|
||||
//! A payment to a contractor by check counts toward its 1099; the same
|
||||
//! payment by card or through a payment network is the card processor's
|
||||
//! to report, on Form 1099-K, and never the company's (Treas. Reg.
|
||||
//! §1.6041-1(a)(1)(iv)). The books record each way of paying once
|
||||
//! ([`Instrument`]: the company's bank, its business card, a member's own
|
||||
//! card, a member's own cash or check, a payment platform), each with the
|
||||
//! ledger account it pays through, and tag the entries paid with one
|
||||
//! ([`crate::Ledger::set_entry_instrument`]). The 1099 worksheet reads the
|
||||
//! tag ([`crate::form1099`]), and the business cards' liability accounts
|
||||
//! are the books' card accounts ([`crate::Ledger::card_accounts`]), known
|
||||
//! from what the books record rather than an account's name.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: every payment's method is recorded once, plainly, so the 1099
|
||||
//! worksheet never guesses whether a payment was by card.
|
||||
//! SOURCE: docs/spec/rules.md B-19 (Treas. Reg. §1.6041-1(a)(1)(iv),
|
||||
//! §1.6050W-1(b)(1), (b)(3), (c)(3); Instructions for Form 1099-K:
|
||||
//! automated clearing houses are not payment networks;
|
||||
//! sources/irs-1099-payment-method.md); plan/served.md 4.9, 5.2.
|
||||
//! INVARIANTS:
|
||||
//! 1. A label is one line, at most settings::MAX_TEXT characters, holds
|
||||
//! no full TIN, and is unique among instruments (ignoring case and
|
||||
//! surrounding space).
|
||||
//! 2. last4, when given, is exactly four ASCII digits: no full card or
|
||||
//! account number ever enters the books.
|
||||
//! 3. The account is a real asset or liability account, not a header,
|
||||
//! and not the payables or receivables control account (a bill is not
|
||||
//! paid until it is paid).
|
||||
//! 4. The owner party, when given, exists. It is informational: it never
|
||||
//! classifies a payment (the payment-card exception follows the
|
||||
//! instrument, whoever's card it is).
|
||||
//! ```
|
||||
|
||||
use crate::{AccountId, PartyId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Opaque instrument handle.
|
||||
pub type InstrumentId = u64;
|
||||
|
||||
/// What kind of payment an instrument makes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InstrumentKind {
|
||||
/// The company's own bank account: its checks, ACH, wires, bill-pay.
|
||||
Bank,
|
||||
/// A company credit or debit card the payee charged.
|
||||
BusinessCard,
|
||||
/// A member's personal credit or debit card the payee charged.
|
||||
MemberCard,
|
||||
/// A member's own cash, check, ACH, Zelle, or a friends-and-family
|
||||
/// transfer from a member's own app or card.
|
||||
MemberCashOrCheck,
|
||||
/// A payment the payee received as a seller through a payment network
|
||||
/// that settles it (PayPal or Venmo goods and services).
|
||||
Platform,
|
||||
}
|
||||
|
||||
impl InstrumentKind {
|
||||
/// Every kind, in order.
|
||||
pub const ALL: [InstrumentKind; 5] = [
|
||||
InstrumentKind::Bank,
|
||||
InstrumentKind::BusinessCard,
|
||||
InstrumentKind::MemberCard,
|
||||
InstrumentKind::MemberCashOrCheck,
|
||||
InstrumentKind::Platform,
|
||||
];
|
||||
|
||||
/// Parse a key ("bank", "business_card", ...), ignoring case and
|
||||
/// surrounding space.
|
||||
pub fn parse(s: &str) -> Option<InstrumentKind> {
|
||||
let s = s.trim().to_ascii_lowercase();
|
||||
InstrumentKind::ALL.into_iter().find(|k| k.key() == s)
|
||||
}
|
||||
|
||||
/// Its key: "bank", "business_card", "member_card",
|
||||
/// "member_cash_or_check", "platform".
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
InstrumentKind::Bank => "bank",
|
||||
InstrumentKind::BusinessCard => "business_card",
|
||||
InstrumentKind::MemberCard => "member_card",
|
||||
InstrumentKind::MemberCashOrCheck => "member_cash_or_check",
|
||||
InstrumentKind::Platform => "platform",
|
||||
}
|
||||
}
|
||||
|
||||
/// In words, for people.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
InstrumentKind::Bank => "the company's bank (check, ACH, wire)",
|
||||
InstrumentKind::BusinessCard => "a company card",
|
||||
InstrumentKind::MemberCard => "a member's personal card",
|
||||
InstrumentKind::MemberCashOrCheck => "a member's own cash or check",
|
||||
InstrumentKind::Platform => "a payment network (goods and services)",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a payment made with it is a payment-card or third-party
|
||||
/// network transaction: reported by the processor on Form 1099-K and
|
||||
/// excluded from 1099-NEC and 1099-MISC (Treas. Reg.
|
||||
/// §1.6041-1(a)(1)(iv)), whoever's card it was.
|
||||
pub fn is_card_or_network(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
InstrumentKind::BusinessCard | InstrumentKind::MemberCard | InstrumentKind::Platform
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// One way the company pays.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Instrument {
|
||||
/// Opaque handle.
|
||||
pub id: InstrumentId,
|
||||
/// What kind of payment it makes.
|
||||
pub kind: InstrumentKind,
|
||||
/// Its name ("Harbor Bank checking", "Riley's Visa"), unique.
|
||||
pub label: String,
|
||||
/// The last four digits of its card or account number, if given.
|
||||
pub last4: Option<String>,
|
||||
/// Whose it is (a member's card), if given. Never used to classify.
|
||||
pub owner_party: Option<PartyId>,
|
||||
/// The ledger account it pays through: the bank account, the card's
|
||||
/// liability, or the members' financing liability (Due to Members).
|
||||
pub account: AccountId,
|
||||
}
|
||||
|
||||
/// Check a label: one line, at most `MAX_TEXT` characters, no full TIN
|
||||
/// (INVARIANT 1, all but uniqueness). Returns it trimmed.
|
||||
pub fn check_label(label: &str) -> Result<String, String> {
|
||||
crate::settings::line_text("label", label, crate::settings::MAX_TEXT)
|
||||
}
|
||||
|
||||
/// Check a last four (INVARIANT 2).
|
||||
pub fn check_last4(last4: &str) -> Result<(), String> {
|
||||
if last4.len() == 4 && last4.bytes().all(|b| b.is_ascii_digit()) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"last4 {:?} must be exactly four digits (never a whole card or account number)",
|
||||
crate::parties::redact_full_tins(last4)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// The key two labels are compared by: trimmed, lower case.
|
||||
pub fn label_key(label: &str) -> String {
|
||||
label.trim().to_lowercase()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn kinds_round_trip_and_only_cards_and_networks_are_excluded() {
|
||||
for k in InstrumentKind::ALL {
|
||||
assert_eq!(InstrumentKind::parse(k.key()), Some(k));
|
||||
assert_eq!(
|
||||
InstrumentKind::parse(&format!(" {} ", k.key().to_uppercase())),
|
||||
Some(k)
|
||||
);
|
||||
assert!(!k.label().is_empty());
|
||||
}
|
||||
assert_eq!(InstrumentKind::parse("card"), None);
|
||||
let excluded: Vec<_> = InstrumentKind::ALL
|
||||
.into_iter()
|
||||
.filter(|k| k.is_card_or_network())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
excluded,
|
||||
[
|
||||
InstrumentKind::BusinessCard,
|
||||
InstrumentKind::MemberCard,
|
||||
InstrumentKind::Platform
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last4_is_four_digits_only() {
|
||||
assert!(check_last4("0042").is_ok());
|
||||
for bad in ["", "123", "12345", "12a4", "1234", "4111111111111111"] {
|
||||
assert!(check_last4(bad).is_err(), "{bad:?}");
|
||||
}
|
||||
// A refusal never echoes a full TIN-shaped number.
|
||||
let why = check_last4("123-45-6789").unwrap_err();
|
||||
assert!(!why.contains("6789"), "{why}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_are_one_bounded_line() {
|
||||
assert_eq!(check_label(" Harbor Bank ").unwrap(), "Harbor Bank");
|
||||
assert!(check_label("").is_err());
|
||||
assert!(check_label("a\nb").is_err());
|
||||
assert!(check_label(&"x".repeat(crate::settings::MAX_TEXT + 1)).is_err());
|
||||
assert!(check_label("card 123-45-6789").is_err());
|
||||
assert_eq!(label_key(" Riley's Visa "), "riley's visa");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
//! * a trial balance puts each movement in exactly one column, so its
|
||||
//! columns differ by the net of all movements (B-6);
|
||||
//! * a closing line zeroes the account it closes (B-11);
|
||||
//! * a payment to a vendor splits, line by line, into what counts toward
|
||||
//! its 1099, what a card or payment network paid, and what is unknown,
|
||||
//! and money moved between the company's own sources nets out (B-19);
|
||||
//! * an allocation's parts sum to the whole, to the last unit, by largest
|
||||
//! remainder (B-13);
|
||||
//! * a cumulative allocation hands out exactly the new amount, and leaves
|
||||
@@ -273,6 +276,162 @@ pub fn credit_line<T: Amount>(credit: T) -> Option<(Side, T)> {
|
||||
closing_line(credit)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1099 payments: where each payment line goes (rules.md B-19)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A line's role in a payment to a vendor, from its account.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PayRole {
|
||||
/// One of the books' cash accounts: bank, cash on hand.
|
||||
Cash,
|
||||
/// One of the books' financing liabilities (what members or lenders
|
||||
/// paid on the company's behalf), or an account a member's card or
|
||||
/// cash, or a payment platform, pays through: how a payment through
|
||||
/// it was made, only the entry's method says.
|
||||
Financing,
|
||||
/// The account of a company card (a business card's liability).
|
||||
CardAccount,
|
||||
/// None of these (an expense, payables, prepaid): not a payment line.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// What an entry's payment-method tag says about one of its lines.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Tag {
|
||||
/// The entry has no payment method recorded.
|
||||
None,
|
||||
/// The method pays through this line's account and is a card or a
|
||||
/// payment network.
|
||||
ThisCard,
|
||||
/// The method pays through this line's account and is neither (the
|
||||
/// company's bank, a member's own cash or check).
|
||||
ThisOther,
|
||||
/// The method pays through another account: it doesn't describe this
|
||||
/// line.
|
||||
Elsewhere,
|
||||
}
|
||||
|
||||
/// Where a payment line's amount goes on the 1099 worksheet.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Bucket {
|
||||
/// Counts toward the payee's 1099.
|
||||
Counts,
|
||||
/// Paid by card or payment network: Form 1099-K's, never 1099-NEC or
|
||||
/// 1099-MISC's.
|
||||
Card,
|
||||
/// Can't be classified until the payment method is recorded.
|
||||
Undetermined,
|
||||
}
|
||||
|
||||
impl Bucket {
|
||||
/// Every bucket, in [`split_payment`]'s order.
|
||||
pub const ALL: [Bucket; 3] = [Bucket::Counts, Bucket::Card, Bucket::Undetermined];
|
||||
|
||||
/// Its place in [`split_payment`]'s array.
|
||||
pub const fn index(self) -> usize {
|
||||
match self {
|
||||
Bucket::Counts => 0,
|
||||
Bucket::Card => 1,
|
||||
Bucket::Undetermined => 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The bucket a payment line goes in, from its role and the entry's tag.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: every payment line to a vendor counts toward its 1099, is a card
|
||||
/// or payment-network payment (Form 1099-K), or is unknown; never
|
||||
/// guessed.
|
||||
/// SOURCE: docs/spec/rules.md B-19 (Treas. Reg. §1.6041-1(a)(1)(iv): a
|
||||
/// transaction reportable under §6050W is not reported under
|
||||
/// §6041; §1.6041-1(e)(1): a member writing a check at the
|
||||
/// company's direction doesn't make the member the payor;
|
||||
/// sources/irs-1099-payment-method.md); DECISION D-40 (owner,
|
||||
/// 2026-10-20: a bank payment with no method recorded counts).
|
||||
/// INVARIANTS (first match wins):
|
||||
/// 1. The tag names this line's account: Card for a card or network,
|
||||
/// else Counts.
|
||||
/// 2. Otherwise the role decides: Cash counts (presumed check, ACH or
|
||||
/// transfer, D-40); Financing is Undetermined (a member's card or
|
||||
/// their check, unknown until tagged); CardAccount is Card; Other is
|
||||
/// no payment line (None).
|
||||
/// ```
|
||||
pub fn payment_bucket(role: PayRole, tag: Tag) -> Option<Bucket> {
|
||||
match (tag, role) {
|
||||
(Tag::ThisCard, _) => Some(Bucket::Card),
|
||||
(Tag::ThisOther, _) => Some(Bucket::Counts),
|
||||
(Tag::None | Tag::Elsewhere, PayRole::Cash) => Some(Bucket::Counts),
|
||||
(Tag::None | Tag::Elsewhere, PayRole::Financing) => Some(Bucket::Undetermined),
|
||||
(Tag::None | Tag::Elsewhere, PayRole::CardAccount) => Some(Bucket::Card),
|
||||
(Tag::None | Tag::Elsewhere, PayRole::Other) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What an entry paid, per bucket: credits minus debits of the lines in
|
||||
/// each (a line in no bucket is not a payment line). `None` on overflow.
|
||||
pub fn split_payment<T: Amount>(lines: &[(Option<Bucket>, Side, T)]) -> Option<[T; 3]> {
|
||||
let mut out = [T::ZERO; 3];
|
||||
for &(bucket, side, amount) in lines {
|
||||
if let Some(b) = bucket {
|
||||
let i = b.index();
|
||||
out[i] = match side {
|
||||
Side::Credit => out[i].checked_add(amount)?,
|
||||
Side::Debit => out[i].checked_sub(amount)?,
|
||||
};
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Net one entry's buckets ([`split_payment`]): money moved between the
|
||||
/// company's own payment sources pays no one.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: a card bill paid from the bank, a member reimbursed, or a loan
|
||||
/// paid down never counts toward a vendor's 1099.
|
||||
/// SOURCE: docs/spec/rules.md B-19 (26 U.S.C. §6041(a): payments made to
|
||||
/// the payee; P3 review R1, 2026-10-20).
|
||||
/// INVARIANTS:
|
||||
/// 1. The buckets' sum P is kept exactly.
|
||||
/// 2. No bucket ends opposite in sign to P.
|
||||
/// 3. Buckets all of one sign (zeros aside) come back unchanged.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the signs differ and P = 0 (Dr 2050 / Cr 1000, the card bill):
|
||||
/// SHOULD be [0, 0, 0].
|
||||
/// 2. IF the signs differ and exactly one bucket has P's sign: SHOULD
|
||||
/// give it P and the others 0.
|
||||
/// 3. IF two buckets have P's sign: SHOULD put P in Undetermined
|
||||
/// BECAUSE which source the other side offsets is unknown (split
|
||||
/// the entry).
|
||||
/// ```
|
||||
pub fn net_buckets<T: Amount>(b: [T; 3]) -> Option<[T; 3]> {
|
||||
let positive = b.iter().any(|&x| x > T::ZERO);
|
||||
let negative = b.iter().any(|&x| x < T::ZERO);
|
||||
if !(positive && negative) {
|
||||
return Some(b);
|
||||
}
|
||||
let p = b[0].checked_add(b[1])?.checked_add(b[2])?;
|
||||
let mut out = [T::ZERO; 3];
|
||||
if p == T::ZERO {
|
||||
return Some(out);
|
||||
}
|
||||
let mut taker = Bucket::Undetermined.index();
|
||||
let mut takers = 0;
|
||||
for (i, &x) in b.iter().enumerate() {
|
||||
if x != T::ZERO && (x > T::ZERO) == (p > T::ZERO) {
|
||||
taker = i;
|
||||
takers += 1;
|
||||
}
|
||||
}
|
||||
if takers != 1 {
|
||||
taker = Bucket::Undetermined.index();
|
||||
}
|
||||
out[taker] = p;
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Make rounded parts sum to the whole: the last part becomes `whole`
|
||||
/// minus the others, absorbing every unit of rounding. Returns `None` on
|
||||
/// overflow, leaving `parts` unchanged.
|
||||
|
||||
@@ -255,6 +255,11 @@ pub struct Ledger {
|
||||
next_recurring_id: RecurringId,
|
||||
/// Who each entry paid or was billed by, for 1099 reporting.
|
||||
entry_vendors: HashMap<EntryId, VendorId>,
|
||||
/// The payment methods ([`crate::instruments`]), by id.
|
||||
instruments: std::collections::BTreeMap<crate::InstrumentId, crate::Instrument>,
|
||||
next_instrument_id: crate::InstrumentId,
|
||||
/// The payment method each entry was paid with, for 1099 reporting.
|
||||
entry_instruments: HashMap<EntryId, crate::InstrumentId>,
|
||||
/// Each invoice's payments (entry, amount), for aging as of a date.
|
||||
invoice_payments: HashMap<InvoiceId, Vec<(EntryId, Decimal)>>,
|
||||
/// Each bill's payments (entry, amount), for aging as of a date.
|
||||
@@ -313,6 +318,9 @@ impl Ledger {
|
||||
recurring: std::collections::BTreeMap::new(),
|
||||
next_recurring_id: 1,
|
||||
entry_vendors: HashMap::new(),
|
||||
instruments: std::collections::BTreeMap::new(),
|
||||
next_instrument_id: 1,
|
||||
entry_instruments: HashMap::new(),
|
||||
invoice_payments: HashMap::new(),
|
||||
bill_payments: HashMap::new(),
|
||||
roles: std::collections::BTreeMap::new(),
|
||||
@@ -630,6 +638,15 @@ impl Ledger {
|
||||
self.next_recurring_id == other.next_recurring_id,
|
||||
),
|
||||
("entry_vendors", self.entry_vendors == other.entry_vendors),
|
||||
("instruments", self.instruments == other.instruments),
|
||||
(
|
||||
"next_instrument_id",
|
||||
self.next_instrument_id == other.next_instrument_id,
|
||||
),
|
||||
(
|
||||
"entry_instruments",
|
||||
self.entry_instruments == other.entry_instruments,
|
||||
),
|
||||
(
|
||||
"invoice_payments",
|
||||
self.invoice_payments == other.invoice_payments,
|
||||
@@ -1665,6 +1682,10 @@ impl Ledger {
|
||||
if let Some(v) = self.entry_vendor(entry) {
|
||||
self.entry_vendors.insert(id, v);
|
||||
}
|
||||
// Paid the same way: its 1099 buckets net to zero too.
|
||||
if let Some(i) = self.entry_instrument(entry) {
|
||||
self.entry_instruments.insert(id, i);
|
||||
}
|
||||
// Every party and share: the reversal undoes each one's part.
|
||||
if let Some(p) = self.entry_parties.get(&entry).cloned() {
|
||||
self.entry_parties.insert(id, p);
|
||||
@@ -3030,7 +3051,7 @@ impl Ledger {
|
||||
Vendor {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
form_1099: None,
|
||||
decisions_1099: Vec::new(),
|
||||
tin_on_file: false,
|
||||
},
|
||||
);
|
||||
@@ -3062,20 +3083,248 @@ impl Ledger {
|
||||
self.vendors.get(&id)
|
||||
}
|
||||
|
||||
/// Set which information return a vendor gets (or none), and whether
|
||||
/// their W-9 is on file.
|
||||
/// Check a 1099 decision for a vendor before it is recorded
|
||||
/// ([`crate::form1099::check_new_decision`]): the vendor exists, the
|
||||
/// decision, its source and its date are sound, and a dated decision
|
||||
/// records a change. Returns what recording it would do.
|
||||
pub fn check_vendor_1099(
|
||||
&self,
|
||||
id: VendorId,
|
||||
new: &crate::form1099::Dated1099,
|
||||
) -> Result<crate::form1099::Applied, LedgerError> {
|
||||
let v = self
|
||||
.vendors
|
||||
.get(&id)
|
||||
.ok_or(LedgerError::UnknownVendor(id))?;
|
||||
crate::form1099::check_new_decision(&v.decisions_1099, new)
|
||||
.map_err(LedgerError::Invalid1099)
|
||||
}
|
||||
|
||||
/// Record a vendor's 1099 decision from the date it takes effect (or
|
||||
/// from the beginning), with who decided, and whether its W-9 is on
|
||||
/// file, checked ([`Self::check_vendor_1099`]).
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: "no 1099" is a decision kept with its reason and who made it,
|
||||
/// never the same as "never decided" (plan defects 18, 23), and
|
||||
/// each payment takes the decision in force on its date (D-40).
|
||||
/// SOURCE: docs/spec/rules.md B-19; crate::form1099::plan_decision.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the vendor is unknown, or the decision fails its checks:
|
||||
/// SHOULD refuse, changing nothing.
|
||||
/// 2. Otherwise SHOULD record it (crate::form1099::apply_decision)
|
||||
/// and set the W-9 flag, returning what it did.
|
||||
/// ```
|
||||
pub fn set_vendor_1099(
|
||||
&mut self,
|
||||
id: VendorId,
|
||||
form: Option<crate::form1099::Form1099>,
|
||||
new: crate::form1099::Dated1099,
|
||||
tin_on_file: bool,
|
||||
) -> Result<(), LedgerError> {
|
||||
) -> Result<crate::form1099::Applied, LedgerError> {
|
||||
self.check_vendor_1099(id, &new)?;
|
||||
self.replay_vendor_1099(id, new, tin_on_file)
|
||||
}
|
||||
|
||||
/// [`Self::set_vendor_1099`] as journaled: the vendor must exist, and
|
||||
/// the decision is recorded as the line says, without the checks a
|
||||
/// new decision passes (rules.md B-28). A line written before
|
||||
/// decisions were kept replays as a decision from the beginning with
|
||||
/// no source (a null as undetermined: plan 5.5).
|
||||
pub fn replay_vendor_1099(
|
||||
&mut self,
|
||||
id: VendorId,
|
||||
new: crate::form1099::Dated1099,
|
||||
tin_on_file: bool,
|
||||
) -> Result<crate::form1099::Applied, LedgerError> {
|
||||
let v = self
|
||||
.vendors
|
||||
.get_mut(&id)
|
||||
.ok_or(LedgerError::UnknownVendor(id))?;
|
||||
v.form_1099 = form;
|
||||
let applied = crate::form1099::apply_decision(&mut v.decisions_1099, new);
|
||||
v.tin_on_file = tin_on_file;
|
||||
Ok(applied)
|
||||
}
|
||||
|
||||
/// Mark whether a vendor's W-9 is on file, keeping its 1099 decisions
|
||||
/// and who made them (P3 review R8). Returns whether it changed.
|
||||
pub fn set_vendor_w9_flag(&mut self, id: VendorId, on_file: bool) -> Result<bool, LedgerError> {
|
||||
let v = self
|
||||
.vendors
|
||||
.get_mut(&id)
|
||||
.ok_or(LedgerError::UnknownVendor(id))?;
|
||||
let changed = v.tin_on_file != on_file;
|
||||
v.tin_on_file = on_file;
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Payment methods (crate::instruments)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// The id the next payment method gets.
|
||||
pub fn next_instrument_id(&self) -> crate::InstrumentId {
|
||||
self.next_instrument_id
|
||||
}
|
||||
|
||||
/// Check a new payment method (crate::instruments INVARIANTS 1-4).
|
||||
/// Returns its label, trimmed.
|
||||
pub fn check_add_instrument(
|
||||
&self,
|
||||
label: &str,
|
||||
last4: Option<&str>,
|
||||
owner_party: Option<crate::PartyId>,
|
||||
account: AccountId,
|
||||
) -> Result<String, LedgerError> {
|
||||
let label =
|
||||
crate::instruments::check_label(label).map_err(LedgerError::InvalidInstrument)?;
|
||||
let key = crate::instruments::label_key(&label);
|
||||
if let Some(i) = self
|
||||
.instruments
|
||||
.values()
|
||||
.find(|i| crate::instruments::label_key(&i.label) == key)
|
||||
{
|
||||
return Err(LedgerError::InvalidInstrument(format!(
|
||||
"payment method {} is already named {:?}: give this one another name",
|
||||
i.id, i.label
|
||||
)));
|
||||
}
|
||||
if let Some(l) = last4 {
|
||||
crate::instruments::check_last4(l).map_err(LedgerError::InvalidInstrument)?;
|
||||
}
|
||||
if let Some(p) = owner_party
|
||||
&& !self.parties.contains_key(&p)
|
||||
{
|
||||
return Err(LedgerError::UnknownParty(p));
|
||||
}
|
||||
let a = self
|
||||
.chart
|
||||
.get(account)
|
||||
.ok_or(LedgerError::UnknownAccount(account))?;
|
||||
if a.placeholder {
|
||||
return Err(LedgerError::PlaceholderAccount(a.code.clone()));
|
||||
}
|
||||
if !matches!(a.account_type, AccountType::Asset | AccountType::Liability) {
|
||||
return Err(LedgerError::InvalidInstrument(format!(
|
||||
"account {} {:?} is {:?}: a payment method pays through an asset (a bank account) or a liability (a card, or what's owed to the members)",
|
||||
a.code, a.name, a.account_type
|
||||
)));
|
||||
}
|
||||
let control = [&crate::PAYABLES, &crate::RECEIVABLES]
|
||||
.into_iter()
|
||||
.find(|c| self.role_account(c) == Some(account));
|
||||
if let Some(c) = control {
|
||||
return Err(LedgerError::InvalidInstrument(format!(
|
||||
"account {} is the {} control account, which no payment is made from: name the bank, card, or members' account the money left",
|
||||
a.code, c.role
|
||||
)));
|
||||
}
|
||||
Ok(label)
|
||||
}
|
||||
|
||||
/// Add a payment method ([`Self::check_add_instrument`]). Returns its
|
||||
/// id.
|
||||
pub fn add_instrument(
|
||||
&mut self,
|
||||
kind: crate::InstrumentKind,
|
||||
label: &str,
|
||||
last4: Option<&str>,
|
||||
owner_party: Option<crate::PartyId>,
|
||||
account: AccountId,
|
||||
) -> Result<crate::InstrumentId, LedgerError> {
|
||||
let label = self.check_add_instrument(label, last4, owner_party, account)?;
|
||||
let id = self.next_instrument_id;
|
||||
self.next_instrument_id += 1;
|
||||
self.instruments.insert(
|
||||
id,
|
||||
crate::Instrument {
|
||||
id,
|
||||
kind,
|
||||
label,
|
||||
last4: last4.map(str::to_string),
|
||||
owner_party,
|
||||
account,
|
||||
},
|
||||
);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Every payment method, in id order.
|
||||
pub fn instruments(&self) -> impl Iterator<Item = &crate::Instrument> + '_ {
|
||||
self.instruments.values()
|
||||
}
|
||||
|
||||
/// One payment method.
|
||||
pub fn get_instrument(&self, id: crate::InstrumentId) -> Option<&crate::Instrument> {
|
||||
self.instruments.get(&id)
|
||||
}
|
||||
|
||||
/// The liability accounts that are company cards: the account of each
|
||||
/// business-card payment method that is a liability (a credit card's
|
||||
/// balance; a debit card draws on an asset and isn't one). A member's
|
||||
/// card pays through the members' financing account, and a platform
|
||||
/// through its own balance: neither is a card account. For whatever
|
||||
/// needs to tell a card from other liabilities (party money, the cash
|
||||
/// flow's sections), from what the books record rather than an
|
||||
/// account's name.
|
||||
pub fn card_accounts(&self) -> std::collections::BTreeSet<AccountId> {
|
||||
self.instruments
|
||||
.values()
|
||||
.filter(|i| i.kind == crate::InstrumentKind::BusinessCard)
|
||||
.filter(|i| {
|
||||
self.chart
|
||||
.get(i.account)
|
||||
.is_some_and(|a| a.account_type == AccountType::Liability)
|
||||
})
|
||||
.map(|i| i.account)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The payment method with this label (ignoring case and surrounding
|
||||
/// space).
|
||||
pub fn instrument_by_label(&self, label: &str) -> Option<&crate::Instrument> {
|
||||
let key = crate::instruments::label_key(label);
|
||||
self.instruments
|
||||
.values()
|
||||
.find(|i| crate::instruments::label_key(&i.label) == key)
|
||||
}
|
||||
|
||||
/// The payment method an entry is tagged with.
|
||||
pub fn entry_instrument(&self, entry: EntryId) -> Option<crate::InstrumentId> {
|
||||
self.entry_instruments.get(&entry).copied()
|
||||
}
|
||||
|
||||
/// Tag an entry (draft or posted) with the payment method it was paid
|
||||
/// with, or clear the tag.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: the 1099 worksheet knows how each payment was made.
|
||||
/// SOURCE: docs/spec/rules.md B-19; crate::instruments.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the entry or the payment method is unknown: SHOULD refuse.
|
||||
/// 2. Tagging MUST change no amount (it's metadata, allowed on posted
|
||||
/// entries, like the vendor tag).
|
||||
/// 3. The entry's reversal chain carries the same tag, so a payment
|
||||
/// and its reversal always sit in the same bucket and net to zero.
|
||||
/// ```
|
||||
pub fn set_entry_instrument(
|
||||
&mut self,
|
||||
entry: EntryId,
|
||||
instrument: Option<crate::InstrumentId>,
|
||||
) -> Result<(), LedgerError> {
|
||||
if !self.entries.contains_key(&entry) {
|
||||
return Err(LedgerError::UnknownEntry(entry));
|
||||
}
|
||||
if let Some(i) = instrument
|
||||
&& !self.instruments.contains_key(&i)
|
||||
{
|
||||
return Err(LedgerError::UnknownInstrument(i));
|
||||
}
|
||||
for e in self.reversal_chain(entry) {
|
||||
match instrument {
|
||||
Some(i) => self.entry_instruments.insert(e, i),
|
||||
None => self.entry_instruments.remove(&e),
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -693,15 +693,42 @@ impl MoneyAccounts {
|
||||
}
|
||||
}
|
||||
|
||||
/// The company's cards: the liability accounts whose names say "card"
|
||||
/// (ignoring case), but not "gift card" (gift cards the company sold are
|
||||
/// deferred revenue it owes its customers, a promise, not a card it pays
|
||||
/// with), the rule the monthly reconciliation reminders read
|
||||
/// (`ledger_store::reconcilable`), so the two never disagree on what a
|
||||
/// card is. CONVENTION until payment instruments name the card accounts
|
||||
/// (plan/served.md P3 defect 12).
|
||||
/// The company's cards: the one rule for what a card is, which party
|
||||
/// money, the monthly reconciliation reminders
|
||||
/// (`ledger_store::reconcilable`) and the viewer all read, so none
|
||||
/// disagrees on it.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: a card is known from what the books record, and a promise (a
|
||||
/// gift card sold) or a member's own card is never the company's.
|
||||
/// SOURCE: rules.md B-35 item 5 (DECISION, owner, 2026-10-20: money is
|
||||
/// cash, cards, the members' financing and loans), B-19 (payment
|
||||
/// methods, plan P3 defect 12).
|
||||
/// INVARIANTS:
|
||||
/// 1. When the books record a business-card payment method: exactly
|
||||
/// the liability accounts of those methods
|
||||
/// (crate::Ledger::card_accounts), whatever the accounts are named.
|
||||
/// 2. Else (books from before payment methods, or that never record
|
||||
/// one; CONVENTION): the liability accounts whose names say "card"
|
||||
/// (ignoring case) but not "gift" (gift cards the company sold are
|
||||
/// deferred revenue it owes its customers, a promise, not a card it
|
||||
/// pays with).
|
||||
/// 3. Never the account of a member's card, a member's cash or check,
|
||||
/// or a payment platform: a member's card pays through the members'
|
||||
/// financing account, a platform through its own balance.
|
||||
/// 4. In chart order (ascending account id), each once.
|
||||
/// ```
|
||||
pub fn card_accounts(ledger: &crate::Ledger) -> Vec<crate::AccountId> {
|
||||
ledger
|
||||
let recorded = ledger.card_accounts();
|
||||
if !recorded.is_empty() {
|
||||
return recorded.into_iter().collect();
|
||||
}
|
||||
let not_cards: std::collections::BTreeSet<crate::AccountId> = ledger
|
||||
.instruments()
|
||||
.filter(|i| i.kind != crate::InstrumentKind::BusinessCard)
|
||||
.map(|i| i.account)
|
||||
.collect();
|
||||
let mut cards: Vec<crate::AccountId> = ledger
|
||||
.chart()
|
||||
.all()
|
||||
.filter(|a| {
|
||||
@@ -709,9 +736,13 @@ pub fn card_accounts(ledger: &crate::Ledger) -> Vec<crate::AccountId> {
|
||||
a.account_type == crate::AccountType::Liability
|
||||
&& name.contains("card")
|
||||
&& !name.contains("gift")
|
||||
&& !not_cards.contains(&a.id)
|
||||
})
|
||||
.map(|a| a.id)
|
||||
.collect()
|
||||
.collect();
|
||||
cards.sort_unstable();
|
||||
cards.dedup();
|
||||
cards
|
||||
}
|
||||
|
||||
/// The [`MoneyAccounts`] of `ledger`: `cash` the books' cash accounts
|
||||
@@ -1042,7 +1073,7 @@ pub fn activity(
|
||||
/// 6. A card charge counts as paid (the company paid them, by card); a
|
||||
/// bill doesn't until it is paid; a member's property contribution is
|
||||
/// received in kind. Not the 1099 amount: that is
|
||||
/// form1099::vendor_1099_report_with.
|
||||
/// form1099::vendor_1099_report.
|
||||
/// ```
|
||||
pub fn activity_with(
|
||||
ledger: &crate::Ledger,
|
||||
|
||||
@@ -442,6 +442,131 @@ fn closing_entry_balances() {
|
||||
kani::cover!(len == NOMINAL + 2, "three accounts closed to two members");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1099 payments (rules.md B-19)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn any_role() -> kernel::PayRole {
|
||||
let r: u8 = kani::any();
|
||||
kani::assume(r < 4);
|
||||
match r {
|
||||
0 => kernel::PayRole::Cash,
|
||||
1 => kernel::PayRole::Financing,
|
||||
2 => kernel::PayRole::CardAccount,
|
||||
_ => kernel::PayRole::Other,
|
||||
}
|
||||
}
|
||||
|
||||
fn any_tag() -> kernel::Tag {
|
||||
let t: u8 = kani::any();
|
||||
kani::assume(t < 4);
|
||||
match t {
|
||||
0 => kernel::Tag::None,
|
||||
1 => kernel::Tag::ThisCard,
|
||||
2 => kernel::Tag::ThisOther,
|
||||
_ => kernel::Tag::Elsewhere,
|
||||
}
|
||||
}
|
||||
|
||||
/// `kernel::payment_bucket` is the B-19 table, all 16 cases: a tag naming
|
||||
/// the line's account decides (card or network: Card; otherwise Counts);
|
||||
/// else the role does, and with no tag naming its account a financing line
|
||||
/// never counts and a card account's line is always Card.
|
||||
#[kani::proof]
|
||||
fn payment_bucket_is_the_table() {
|
||||
use kernel::{Bucket, PayRole, Tag};
|
||||
let (role, tag) = (any_role(), any_tag());
|
||||
let got = kernel::payment_bucket(role, tag);
|
||||
let want = match tag {
|
||||
Tag::ThisCard => Some(Bucket::Card),
|
||||
Tag::ThisOther => Some(Bucket::Counts),
|
||||
Tag::None | Tag::Elsewhere => match role {
|
||||
PayRole::Cash => Some(Bucket::Counts),
|
||||
PayRole::Financing => Some(Bucket::Undetermined),
|
||||
PayRole::CardAccount => Some(Bucket::Card),
|
||||
PayRole::Other => None,
|
||||
},
|
||||
};
|
||||
assert_eq!(got, want);
|
||||
if matches!(tag, Tag::None | Tag::Elsewhere) {
|
||||
assert!(role != PayRole::Financing || got != Some(Bucket::Counts));
|
||||
assert!(role != PayRole::CardAccount || got == Some(Bucket::Card));
|
||||
}
|
||||
}
|
||||
|
||||
/// `kernel::split_payment` never fails on journal-line amounts, and each
|
||||
/// bucket is the exact credits-minus-debits of its lines: the buckets
|
||||
/// together are the whole payment, so nothing is lost or counted twice.
|
||||
#[kani::proof]
|
||||
#[kani::unwind(6)]
|
||||
fn payment_split_is_exact() {
|
||||
let n = any_len();
|
||||
let lines: [(Option<kernel::Bucket>, Side, i64); N] = core::array::from_fn(|_| {
|
||||
let b: u8 = kani::any();
|
||||
kani::assume(b < 4);
|
||||
let bucket = match b {
|
||||
0 => Some(kernel::Bucket::Counts),
|
||||
1 => Some(kernel::Bucket::Card),
|
||||
2 => Some(kernel::Bucket::Undetermined),
|
||||
_ => None,
|
||||
};
|
||||
(bucket, any_side(), any_amount())
|
||||
});
|
||||
let got = kernel::split_payment(&lines[..n]).expect("never fails in range");
|
||||
for b in kernel::Bucket::ALL {
|
||||
let mut want = 0i64;
|
||||
for &(lb, side, amount) in &lines[..n] {
|
||||
if lb == Some(b) {
|
||||
want -= wide(side, amount);
|
||||
}
|
||||
}
|
||||
assert_eq!(got[b.index()], want);
|
||||
}
|
||||
kani::cover!(n == N, "four lines");
|
||||
}
|
||||
|
||||
/// `kernel::net_buckets` keeps the payment's total, never leaves a bucket
|
||||
/// opposite the total, and leaves buckets of one sign alone (money moved
|
||||
/// between the company's own sources pays no one: P3 review R1); with
|
||||
/// mixed signs the whole net goes to the one bucket of its sign, or to
|
||||
/// Undetermined when two share it (its SCENARIOS 1-3).
|
||||
#[kani::proof]
|
||||
fn net_buckets_keeps_the_total() {
|
||||
let b: [i64; 3] = core::array::from_fn(|_| any_amount());
|
||||
let p = b[0] + b[1] + b[2];
|
||||
let out = kernel::net_buckets(b).expect("never fails in range");
|
||||
assert_eq!(out[0] + out[1] + out[2], p);
|
||||
for x in out {
|
||||
assert!(x == 0 || (x > 0) == (p > 0));
|
||||
}
|
||||
let one_sign = b.iter().all(|&x| x >= 0) || b.iter().all(|&x| x <= 0);
|
||||
if one_sign {
|
||||
assert_eq!(out, b);
|
||||
} else {
|
||||
// Mixed signs: the whole net lands in one bucket, the one with its
|
||||
// sign when there is exactly one, else Undetermined.
|
||||
let with_sign: [bool; 3] = core::array::from_fn(|i| b[i] != 0 && (b[i] > 0) == (p > 0));
|
||||
let takers = with_sign.iter().filter(|&&s| s).count();
|
||||
let mut taker = kernel::Bucket::Undetermined.index();
|
||||
if takers == 1 {
|
||||
for (i, &s) in with_sign.iter().enumerate() {
|
||||
if s {
|
||||
taker = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i, &x) in out.iter().enumerate() {
|
||||
assert_eq!(x, if i == taker { p } else { 0 });
|
||||
}
|
||||
}
|
||||
kani::cover!(b[0] > 0 && b[2] < 0 && p > 0, "a payment net of a refund");
|
||||
kani::cover!(
|
||||
b[0] > 0 && b[1] > 0 && b[2] < 0 && p > 0,
|
||||
"two buckets share the net's sign"
|
||||
);
|
||||
kani::cover!(!one_sign && p == 0, "money moved between own sources");
|
||||
}
|
||||
|
||||
/// An allocation's parts sum to the whole exactly, whatever the rounding
|
||||
/// of the others, and only the last part changes.
|
||||
#[kani::proof]
|
||||
|
||||
@@ -5,15 +5,16 @@
|
||||
//! Each example is a setup, events and expected values worked by hand from
|
||||
//! its cited source (`bookspec/README.md` gives the shape). Every example
|
||||
//! must load, whatever it asks or whichever appendix it is in: its shape,
|
||||
//! its accounts, fixed assets, parties, members and payees, every event
|
||||
//! (an entry, a reversal, a close or a bill, posting entry #N as event N,
|
||||
//! with the parties and payee it is tagged with) and every change that
|
||||
//! its accounts, fixed assets, parties, members, payees, payees' dated
|
||||
//! 1099 decisions and payment methods, every event (an entry, a reversal,
|
||||
//! a close or a bill, posting entry #N as event N, with the parties,
|
||||
//! payee and payment method it is tagged with) and every change that
|
||||
//! posts no entry (a rename, a policy). The queries the core answers
|
||||
//! (`cash_flow`, `trial_balance`, `profit_and_loss`, `vendor_1099_paid`,
|
||||
//! `entry`, `balances`, `members_equity`, `k1`, `party_part`,
|
||||
//! `party_totals`, `party_tin_last4`, `period`, `day`, `calendar_year`,
|
||||
//! `account_names`, `uncategorized_accounts`) are then checked value by
|
||||
//! value, and the commands it tries
|
||||
//! `vendor_1099`, `entry`, `balances`, `members_equity`, `k1`,
|
||||
//! `party_part`, `party_totals`, `party_tin_last4`, `period`, `day`,
|
||||
//! `calendar_year`, `account_names`, `uncategorized_accounts`) are then
|
||||
//! checked value by value, and the commands it tries
|
||||
//! (`rename_account`, `add_account`, `set_tax_category`) accepted or
|
||||
//! refused on a copy of the books, each after the event it names (`after`)
|
||||
//! or the last; the formats appendix's (`iif_lines`, `iif_changes`) are
|
||||
@@ -27,13 +28,14 @@ use std::path::{Path, PathBuf};
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::calendar::{PeriodDefaults, period_defaults};
|
||||
use ledger_core::parties::{Basis, MoneyAccounts};
|
||||
use ledger_core::settings::Company;
|
||||
use ledger_core::settings::{Company, Source};
|
||||
use ledger_core::{
|
||||
AccountType, CashFlowParams, CashFlowSection, CashFlowStatement, CloseTo, Currency,
|
||||
DocumentLine, EntryId, Form1099, Ledger, LedgerError, MemberCapital, MemberClose, Money,
|
||||
NewAsset, PartyId, PartyMember, PartyShare, PaymentAccounts, ReconcilingKind, Role, Share,
|
||||
Side, TinFacts, TinType, VendorId, W9Facts, cash_flow, members_equity,
|
||||
profit_and_loss_for_period, trial_balance_as_of, vendor_1099_report_with_members,
|
||||
AccountType, CashFlowParams, CashFlowSection, CashFlowStatement, CloseTo, Currency, Dated1099,
|
||||
Decision1099, DocumentLine, EntryId, ExemptReason, Form1099, InstrumentId, InstrumentKind,
|
||||
Ledger, LedgerError, MemberCapital, MemberClose, Money, NewAsset, PartyId, PartyMember,
|
||||
PartyShare, PaymentAccounts, ReconcilingKind, Role, Share, Side, TinFacts, TinType, VendorId,
|
||||
W9Facts, cash_flow, members_equity, profit_and_loss_for_period, trial_balance_as_of,
|
||||
vendor_1099_report,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::Deserialize;
|
||||
@@ -83,6 +85,12 @@ struct Setup {
|
||||
members: Vec<MemberSpec>,
|
||||
#[serde(default)]
|
||||
payees: Vec<PayeeSpec>,
|
||||
/// A payee's 1099 decision from a date on (rules.md B-19, D-40).
|
||||
#[serde(default)]
|
||||
status_changes: Vec<StatusChangeSpec>,
|
||||
/// The ways the company (or a member, for it) pays (rules.md B-19).
|
||||
#[serde(default)]
|
||||
instruments: Vec<InstrumentSpec>,
|
||||
/// The day the books are asked on, in the company's time zone: what
|
||||
/// an omitted date means (rules.md B-30). A query that omits a date
|
||||
/// needs it.
|
||||
@@ -181,6 +189,38 @@ struct PartySpec {
|
||||
tin_purged: bool,
|
||||
}
|
||||
|
||||
/// A payee's 1099 decision from `effective` on (rules.md B-19, D-40).
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct StatusChangeSpec {
|
||||
payee: String,
|
||||
effective: String,
|
||||
/// `"1099-NEC"`, `"1099-MISC"`, `"not_reportable"` (with `reason_kind`
|
||||
/// and `reason`) or `"undetermined"`.
|
||||
decision: String,
|
||||
#[serde(default)]
|
||||
reason_kind: Option<String>,
|
||||
#[serde(default)]
|
||||
reason: Option<String>,
|
||||
}
|
||||
|
||||
/// A payment method (rules.md B-19).
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct InstrumentSpec {
|
||||
label: String,
|
||||
/// `bank`, `business_card`, `member_card`, `member_cash_or_check` or
|
||||
/// `platform`.
|
||||
kind: String,
|
||||
/// The account it pays through, by code.
|
||||
account: String,
|
||||
#[serde(default)]
|
||||
last4: Option<String>,
|
||||
/// The party it belongs to, by name (informational).
|
||||
#[serde(default)]
|
||||
owner: Option<String>,
|
||||
}
|
||||
|
||||
/// A party named on an entry, with its share: an `amount`, a `percent`,
|
||||
/// or neither (derived from the entry: rules.md B-35).
|
||||
#[derive(Deserialize)]
|
||||
@@ -214,6 +254,9 @@ struct Event {
|
||||
/// shares.
|
||||
#[serde(default)]
|
||||
parties: Vec<ShareSpec>,
|
||||
/// The payment method (an `setup.instruments` label) it was paid with.
|
||||
#[serde(default)]
|
||||
instrument: Option<String>,
|
||||
}
|
||||
|
||||
/// A bill from `vendor` for `amount`, dated and due on the event's date:
|
||||
@@ -287,6 +330,8 @@ struct Expect {
|
||||
party: Option<String>,
|
||||
/// `party_totals`' basis: `"cash"` or `"all"` (rules.md B-35 item 4).
|
||||
basis: Option<String>,
|
||||
/// The payee a `vendor_1099` query asks about (in `setup.payees`).
|
||||
payee: Option<String>,
|
||||
/// The books after event N, not after the last.
|
||||
after: Option<usize>,
|
||||
/// The account a `rename_account` or `set_tax_category` command
|
||||
@@ -482,6 +527,42 @@ struct Books<'a> {
|
||||
payees: BTreeMap<String, VendorId>,
|
||||
/// The vendors bills came from, by name (a payee's record if one).
|
||||
vendors: BTreeMap<String, VendorId>,
|
||||
/// The payment methods, by label.
|
||||
instruments: BTreeMap<String, InstrumentId>,
|
||||
}
|
||||
|
||||
/// Who an example's decisions and records are made by.
|
||||
fn bookspec_source(id: &str) -> Source {
|
||||
Source {
|
||||
decided_by: "bookspec".into(),
|
||||
decided_on: date("2026-01-01", id),
|
||||
citation: None,
|
||||
note: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A payee's 1099 decision in an example's words (bookspec/README.md).
|
||||
fn decision(ex: &str, what: &str, kind: Option<&str>, reason: Option<&str>) -> Decision1099 {
|
||||
match what {
|
||||
"not_reportable" => {
|
||||
let kind = kind.and_then(ExemptReason::parse).unwrap_or_else(|| {
|
||||
panic!("{ex}: not_reportable needs a reason_kind, got {kind:?}")
|
||||
});
|
||||
let reason = reason.unwrap_or_else(|| panic!("{ex}: not_reportable needs a reason"));
|
||||
Decision1099::not_reportable(kind, reason).unwrap_or_else(|e| panic!("{ex}: {e}"))
|
||||
}
|
||||
"undetermined" | "1099-NEC" | "1099-MISC" => {
|
||||
assert!(
|
||||
kind.is_none() && reason.is_none(),
|
||||
"{ex}: only not_reportable takes reason_kind and reason"
|
||||
);
|
||||
match Form1099::parse(what) {
|
||||
Some(f) if what.starts_with("1099-") => Decision1099::Files(f),
|
||||
_ => Decision1099::Undetermined,
|
||||
}
|
||||
}
|
||||
other => panic!("{ex}: decision {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The example's directory (`setup.parties`), by name: each party with
|
||||
@@ -569,7 +650,8 @@ fn add_parties(ex: &Example, l: &mut Ledger) -> BTreeMap<String, PartyId> {
|
||||
|
||||
impl<'a> Books<'a> {
|
||||
/// The books an example sets up, before any event: its shape checked,
|
||||
/// its accounts, fixed assets, parties, members and payees.
|
||||
/// its accounts, fixed assets, parties, members, payees, their dated
|
||||
/// decisions, and payment methods.
|
||||
fn new(ex: &'a Example) -> Self {
|
||||
let s = &ex.setup;
|
||||
let currency = Currency::new(&s.currency).unwrap();
|
||||
@@ -684,6 +766,7 @@ impl<'a> Books<'a> {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let source = bookspec_source(&ex.id);
|
||||
let mut payees = BTreeMap::new();
|
||||
for p in &s.payees {
|
||||
let form = Form1099::parse(&p.form)
|
||||
@@ -697,7 +780,8 @@ impl<'a> Books<'a> {
|
||||
.add_vendor(&p.name)
|
||||
.unwrap_or_else(|e| panic!("{}: payee {}: {e}", ex.id, p.name)),
|
||||
};
|
||||
l.set_vendor_1099(v, Some(form), false)
|
||||
let dated = Dated1099::from_beginning(Decision1099::Files(form), Some(source.clone()));
|
||||
l.set_vendor_1099(v, dated, false)
|
||||
.unwrap_or_else(|e| panic!("{}: payee {}: {e}", ex.id, p.name));
|
||||
assert!(
|
||||
payees.insert(p.name.clone(), v).is_none(),
|
||||
@@ -706,6 +790,47 @@ impl<'a> Books<'a> {
|
||||
p.name
|
||||
);
|
||||
}
|
||||
for c in &s.status_changes {
|
||||
let what = format!("{} status change of {}", ex.id, c.payee);
|
||||
let &v = payees
|
||||
.get(&c.payee)
|
||||
.unwrap_or_else(|| panic!("{what}: payee not in setup"));
|
||||
let dated = Dated1099 {
|
||||
effective: Some(date(&c.effective, &what)),
|
||||
decision: decision(
|
||||
&what,
|
||||
&c.decision,
|
||||
c.reason_kind.as_deref(),
|
||||
c.reason.as_deref(),
|
||||
),
|
||||
source: Some(source.clone()),
|
||||
};
|
||||
l.set_vendor_1099(v, dated, false)
|
||||
.unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
}
|
||||
let mut instruments = BTreeMap::new();
|
||||
for i in &s.instruments {
|
||||
let what = format!("{} instrument {}", ex.id, i.label);
|
||||
let kind = InstrumentKind::parse(&i.kind)
|
||||
.unwrap_or_else(|| panic!("{what}: kind {:?}", i.kind));
|
||||
let account = l
|
||||
.chart()
|
||||
.find_by_code(&i.account)
|
||||
.unwrap_or_else(|| panic!("{what}: no account {}", i.account))
|
||||
.id;
|
||||
let owner = i.owner.as_ref().map(|o| {
|
||||
*parties
|
||||
.get(o)
|
||||
.unwrap_or_else(|| panic!("{what}: owner {o:?} not in setup.parties"))
|
||||
});
|
||||
let id = l
|
||||
.add_instrument(kind, &i.label, i.last4.as_deref(), owner, account)
|
||||
.unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
assert!(
|
||||
instruments.insert(i.label.clone(), id).is_none(),
|
||||
"{what}: twice"
|
||||
);
|
||||
}
|
||||
Books {
|
||||
ex,
|
||||
l,
|
||||
@@ -715,6 +840,7 @@ impl<'a> Books<'a> {
|
||||
parties,
|
||||
vendors: payees.clone(),
|
||||
payees,
|
||||
instruments,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -735,8 +861,8 @@ impl<'a> Books<'a> {
|
||||
"{ex} event {n}: a reversal takes its original's payee, a bill names its vendor, and a close has none"
|
||||
);
|
||||
assert!(
|
||||
e.parties.is_empty() || e.reverses.is_none(),
|
||||
"{ex} event {n}: a reversal takes its original's parties"
|
||||
(e.parties.is_empty() && e.instrument.is_none()) || e.reverses.is_none(),
|
||||
"{ex} event {n}: a reversal takes its original's parties and payment method"
|
||||
);
|
||||
let posted = match (&e.close, e.reverses, e.lines.is_empty()) {
|
||||
(None, None, true) if e.bill.is_some() => {
|
||||
@@ -883,6 +1009,15 @@ impl<'a> Books<'a> {
|
||||
.set_entry_vendor(n, Some(v))
|
||||
.unwrap_or_else(|err| panic!("{ex} event {n}: {err}"));
|
||||
}
|
||||
if let Some(label) = &e.instrument {
|
||||
let i = *self
|
||||
.instruments
|
||||
.get(label)
|
||||
.unwrap_or_else(|| panic!("{ex} event {n}: payment method {label:?} not in setup"));
|
||||
self.l
|
||||
.set_entry_instrument(n, Some(i))
|
||||
.unwrap_or_else(|err| panic!("{ex} event {n}: {err}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1173,10 +1308,10 @@ fn check_profit_and_loss(ex: &Example, l: &Ledger, x: &Expect) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The 1099 report a `vendor_1099_paid` expectation asks for: the
|
||||
/// calendar year of `to`, from January 1 through `to`, out of the cash
|
||||
/// and financing accounts, an entry's shares resolved with the members
|
||||
/// who are parties (rules.md B-19, B-33).
|
||||
/// The 1099 report a `vendor_1099_paid` or `vendor_1099` expectation
|
||||
/// asks for: the calendar year of `to`, from January 1 through `to`, out
|
||||
/// of the cash and financing accounts, an entry's shares resolved with
|
||||
/// the members who are parties (rules.md B-19, B-33).
|
||||
fn report_1099(b: &Books<'_>, x: &Expect) -> (NaiveDate, NaiveDate, ledger_core::Vendor1099Report) {
|
||||
let (ex, l) = (b.ex, &b.l);
|
||||
// A calendar-year count, never the fiscal default (rules.md B-30):
|
||||
@@ -1205,7 +1340,7 @@ fn report_1099(b: &Books<'_>, x: &Expect) -> (NaiveDate, NaiveDate, ledger_core:
|
||||
cash: codes(l, &ex.setup.cash_accounts, &ex.id),
|
||||
financing: codes(l, &ex.setup.financing_accounts, &ex.id),
|
||||
};
|
||||
let r = vendor_1099_report_with_members(l, year, Some(to), None, &sources, &b.party_members)
|
||||
let r = vendor_1099_report(l, year, None, &sources, &b.party_members, Some(to))
|
||||
.unwrap_or_else(|e| panic!("{}: {e}", ex.id));
|
||||
(from, to, r)
|
||||
}
|
||||
@@ -1229,6 +1364,55 @@ fn check_vendor_1099_paid(b: &Books<'_>, x: &Expect) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check one `vendor_1099` expectation: one payee's line of the 1099
|
||||
/// worksheet ([`report_1099`]): `status` (`reportable`,
|
||||
/// `under_threshold`, `not_reportable`, `undetermined`), the `threshold`
|
||||
/// applied, and what was `paid` (counts toward its 1099),
|
||||
/// `excluded_card` (a card or payment network's, Form 1099-K),
|
||||
/// `undetermined` (its payment method not recorded) and `not_reportable`
|
||||
/// (paid while decided not reportable); every one given (rules.md B-19).
|
||||
fn check_vendor_1099(b: &Books<'_>, x: &Expect) {
|
||||
let ex = b.ex;
|
||||
let name = x
|
||||
.payee
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| panic!("{}: vendor_1099 names its payee", ex.id));
|
||||
let v = *b
|
||||
.payees
|
||||
.get(name)
|
||||
.unwrap_or_else(|| panic!("{}: payee {name:?} not in setup", ex.id));
|
||||
let (from, to, r) = report_1099(b, x);
|
||||
let line = r
|
||||
.vendors
|
||||
.iter()
|
||||
.find(|l| l.vendor_id == v)
|
||||
.unwrap_or_else(|| panic!("{}: the 1099 worksheet has no line for {name}", ex.id));
|
||||
let keys = [
|
||||
"status",
|
||||
"threshold",
|
||||
"paid",
|
||||
"excluded_card",
|
||||
"undetermined",
|
||||
"not_reportable",
|
||||
];
|
||||
exactly(ex, x, &keys.iter().map(|k| k.to_string()).collect());
|
||||
let what = |k: &str| format!("{} 1099 [{from} .. {to}] {name}: {k}", ex.id);
|
||||
for (key, want) in x.figures(&ex.id) {
|
||||
if key == "status" {
|
||||
assert_eq!(line.status.key(), want, "{}", what(key));
|
||||
continue;
|
||||
}
|
||||
let got = match key.as_str() {
|
||||
"threshold" => r.threshold,
|
||||
"paid" => line.paid,
|
||||
"excluded_card" => line.excluded_card,
|
||||
"undetermined" => line.undetermined,
|
||||
_ => line.not_reportable,
|
||||
};
|
||||
assert_eq!(got, exact(want, &what(key)), "{}", what(key));
|
||||
}
|
||||
}
|
||||
|
||||
/// The money party shares resolve against: the cash accounts (every
|
||||
/// asset account but receivables when none are named), the cards and the
|
||||
/// financing accounts (rules.md B-35 item 5).
|
||||
@@ -1772,6 +1956,7 @@ fn check(b: &Books<'_>, x: &Expect) -> Option<&'static str> {
|
||||
&["party_part", "party_totals", "party_tin_last4"],
|
||||
);
|
||||
only(x.basis.is_some(), "basis", &["party_totals"]);
|
||||
only(x.payee.is_some(), "payee", &["vendor_1099"]);
|
||||
only(
|
||||
x.code.is_some(),
|
||||
"code",
|
||||
@@ -1817,6 +2002,7 @@ fn check(b: &Books<'_>, x: &Expect) -> Option<&'static str> {
|
||||
"trial_balance" => check_trial_balance(ex, l, x),
|
||||
"profit_and_loss" => check_profit_and_loss(ex, l, x),
|
||||
"vendor_1099_paid" => check_vendor_1099_paid(b, x),
|
||||
"vendor_1099" => check_vendor_1099(b, x),
|
||||
"entry" => check_entry(ex, l, x),
|
||||
"balances" => check_balances(ex, l, x),
|
||||
"members_equity" => check_members_equity(ex, l, &b.members, x),
|
||||
@@ -1909,19 +2095,20 @@ fn every_bookspec_example_holds() {
|
||||
}
|
||||
// Each query the examples use ran: the cash flow's six examples at
|
||||
// least, the as-of and 1099 examples', the members closes', the
|
||||
// formats appendix's, and P3's default period, rename, tax category
|
||||
// and parties examples'.
|
||||
// formats appendix's, and P3's default period, rename, tax category,
|
||||
// parties and 1099 examples'.
|
||||
for (q, at_least) in [
|
||||
("cash_flow", 6),
|
||||
("trial_balance", 2),
|
||||
("profit_and_loss", 2),
|
||||
("vendor_1099_paid", 3),
|
||||
("vendor_1099_paid", 4),
|
||||
("vendor_1099", 10),
|
||||
("entry", 8),
|
||||
("balances", 4),
|
||||
("members_equity", 10),
|
||||
("k1", 2),
|
||||
("party_part", 27),
|
||||
("party_totals", 21),
|
||||
("party_part", 28),
|
||||
("party_totals", 23),
|
||||
("party_tin_last4", 5),
|
||||
("iif_lines", 1),
|
||||
("iif_changes", 1),
|
||||
@@ -2297,3 +2484,36 @@ fn party_totals_without_a_basis_are_refused() {
|
||||
t.replacen("basis = \"all\"\n", "", 1)
|
||||
});
|
||||
}
|
||||
|
||||
/// A `vendor_1099` line lists every figure it covers: one left out is
|
||||
/// refused.
|
||||
#[test]
|
||||
#[should_panic(expected = "lists exactly what it covers")]
|
||||
fn a_vendor_1099_line_leaving_a_figure_out_is_refused() {
|
||||
run_edited("1099-card-payments-are-excluded.toml", |t| {
|
||||
t.replacen(", not_reportable = \"0.00\" }", " }", 1)
|
||||
});
|
||||
}
|
||||
|
||||
/// A `vendor_1099` line's status is checked: card payments that would
|
||||
/// reach the threshold leave the payee under it.
|
||||
#[test]
|
||||
#[should_panic(expected = "Harbor Design LLC: status")]
|
||||
fn a_vendor_1099_status_is_checked() {
|
||||
run_edited("1099-card-payments-are-excluded.toml", |t| {
|
||||
t.replacen("status = \"under_threshold\"", "status = \"reportable\"", 1)
|
||||
});
|
||||
}
|
||||
|
||||
/// A payment method an event names must be in the setup.
|
||||
#[test]
|
||||
#[should_panic(expected = "payment method \"Harbor debit\" not in setup")]
|
||||
fn a_payment_method_not_in_the_setup_is_refused() {
|
||||
run_edited("1099-card-payments-are-excluded.toml", |t| {
|
||||
t.replacen(
|
||||
"instrument = \"Harbor debit card\"",
|
||||
"instrument = \"Harbor debit\"",
|
||||
1,
|
||||
)
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
# Seeds for failure cases proptest has generated in the past. It is
|
||||
# automatically read and these particular cases re-run before any
|
||||
# novel cases are generated.
|
||||
#
|
||||
# It is recommended to check this file in to source control so that
|
||||
# everyone who runs the test benefits from these saved cases.
|
||||
cc f1f81d6b2a71deee0cee08164bdccd96db567e863caae4f1e56263a089cb32d7 # shrinks to gens = [(0, 1, 1, 1, 0, 0, false)]
|
||||
cc 02aee1c532df63f34663271ebef6be02960e18365ee077c9347cd77be1e4cecb # shrinks to gens = [(0, 5, 1, 1, 0, 0, false)]
|
||||
cc b89cbcbdcf87d96e77d29b1fa57a347b7f206c338e27b7a4687b8d5b2cf29162 # shrinks to gens = [(1, 3, 1, 1, 0, 2, false)]
|
||||
+1948
-111
File diff suppressed because it is too large.
Load diff
@@ -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)));
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1689,7 +1689,7 @@ mod shares {
|
||||
members: Vec<PartyMember>,
|
||||
}
|
||||
|
||||
/// Bluevine 1000, Due to Shareholders 2100, capital 3000 and 3001,
|
||||
/// Quillmere 1000, Due to Shareholders 2100, capital 3000 and 3001,
|
||||
/// equipment 1500, an expense 6050; members Tobin and Morgan at
|
||||
/// 50/50; a party Acme (no records).
|
||||
fn books() -> Books {
|
||||
|
||||
@@ -884,6 +884,72 @@ fn defect24_a_gift_card_sold_is_received_once() {
|
||||
assert_eq!(b.totals(c, Basis::All), (Decimal::ZERO, d("100.00")));
|
||||
}
|
||||
|
||||
/// B-35 item 5 and `parties::card_accounts` INVARIANTS 1-4: one rule for
|
||||
/// what a card is. Before any business card is recorded, the liabilities
|
||||
/// whose names say "card" but not "gift" (2050 Business Card; never 2410
|
||||
/// Gift Card Liability), less a member's card's account; once one is
|
||||
/// recorded, exactly the business cards' liabilities, whatever their
|
||||
/// names, and a debit card on the bank (an asset) is none. A member's
|
||||
/// card or a platform is never the company's card, and a card read as a
|
||||
/// card is money: a charge to it is paid.
|
||||
#[test]
|
||||
fn a_card_is_a_recorded_business_card_else_a_liability_named_card() {
|
||||
use ledger_core::InstrumentKind as K;
|
||||
let mut b = books();
|
||||
let cards = |b: &Books| ledger_core::parties::card_accounts(&b.l);
|
||||
assert_eq!(cards(&b), vec![b.card]);
|
||||
// A member's own card, on a liability named for it: not the
|
||||
// company's, before or after a business card is recorded.
|
||||
let personal =
|
||||
b.l.add_account("2060", "Tobin's Card", AccountType::Liability, None, false)
|
||||
.unwrap();
|
||||
assert_eq!(cards(&b), vec![b.card, personal]);
|
||||
b.l.add_instrument(K::MemberCard, "Tobin's card", None, Some(b.tobin), personal)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
cards(&b),
|
||||
vec![b.card],
|
||||
"a member's card is not a card account"
|
||||
);
|
||||
// A platform's balance is no card either.
|
||||
b.l.add_instrument(K::Platform, "Payment platform", None, None, b.prepaid)
|
||||
.unwrap();
|
||||
assert_eq!(cards(&b), vec![b.card]);
|
||||
// A business card on the bank (a debit card, an asset): no card
|
||||
// account, so the name rule still holds.
|
||||
b.l.add_instrument(K::BusinessCard, "Debit card", None, None, b.cash)
|
||||
.unwrap();
|
||||
assert_eq!(cards(&b), vec![b.card]);
|
||||
// A business card recorded on a liability not named "card": now
|
||||
// exactly the recorded cards' liabilities; 2050, named "card" but not
|
||||
// recorded, no longer is.
|
||||
let visa =
|
||||
b.l.add_account("2070", "Harbor Visa", AccountType::Liability, None, false)
|
||||
.unwrap();
|
||||
b.l.add_instrument(K::BusinessCard, "Harbor Visa", None, None, visa)
|
||||
.unwrap();
|
||||
assert_eq!(cards(&b), vec![visa]);
|
||||
assert_eq!(
|
||||
b.l.card_accounts().into_iter().collect::<Vec<_>>(),
|
||||
vec![visa]
|
||||
);
|
||||
// Money follows the rule: a charge to the recorded card is paid; one
|
||||
// to 2050, a liability like any other now, is a promise.
|
||||
let v = b.vendor;
|
||||
let on_visa = b.post(
|
||||
date(4, 1),
|
||||
&[(b.hosting, Dr, "30.00"), (visa, Cr, "30.00")],
|
||||
&[whole(v)],
|
||||
);
|
||||
let on_2050 = b.post(
|
||||
date(4, 2),
|
||||
&[(b.hosting, Dr, "20.00"), (b.card, Cr, "20.00")],
|
||||
&[whole(v)],
|
||||
);
|
||||
assert_eq!(b.resolved(on_visa).0.direction, Direction::Paid);
|
||||
assert_eq!(b.resolved(on_2050).0.direction, Direction::Neither);
|
||||
}
|
||||
|
||||
/// B-35 item 3, case 3: E is the movement of equity other than retained
|
||||
/// earnings, so a members' entry reads as the statement of members' equity
|
||||
/// reads it. Retained earnings given up for equipment moves no member's
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! What the books need: drafts left unposted, missing receipts, schedules
|
||||
//! and recurring entries due, open past periods, uncategorized tax lines,
|
||||
//! 1099 vendors without a W-9, overdue bills and invoices, money owed to
|
||||
//! 1099 vendors without a W-9 or without a 1099 decision, payments whose
|
||||
//! method the 1099 worksheet needs, overdue bills and invoices, money owed to
|
||||
//! owners, fixed asset depreciation not posted, assets without a tag or a
|
||||
//! tax basis, contractors and landlords paid without W-9 facts, bank and
|
||||
//! card accounts not reconciled through last month, and legal
|
||||
@@ -12,7 +13,7 @@ use crate::books::{Attention, Books, Severity};
|
||||
use crate::fmt;
|
||||
use crate::nav::Route;
|
||||
use chrono::Datelike;
|
||||
use ledger_core::{AccountType, EntryStatus, PeriodStatus, vendor_1099_report};
|
||||
use ledger_core::{AccountType, EntryStatus, PeriodStatus};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
/// A figure the books can't compute (rules.md B-28: books an earlier
|
||||
@@ -38,7 +39,9 @@ fn cant_compute(what: String, why: &ledger_core::LedgerError, target: Option<Rou
|
||||
/// prompt that gets the agent to fix it; never change the books.
|
||||
/// SOURCE: DECISION (owner: the viewer is read-only; its "Needs
|
||||
/// attention" list offers prompts, AGENTS.md); the conditions
|
||||
/// below; docs/spec/sources/irs-1099-thresholds.md (W-9s).
|
||||
/// below; docs/spec/sources/irs-1099-thresholds.md (W-9s);
|
||||
/// DECISION D-40 (owner, 2026-10-20: ask only about vendors paid
|
||||
/// in a way that could need a 1099).
|
||||
/// INVARIANTS:
|
||||
/// 1. Pure: reads `b`, writes nothing.
|
||||
/// 2. One item per: draft entry (Todo); posted entry missing a receipt
|
||||
@@ -48,8 +51,21 @@ fn cant_compute(what: String, why: &ledger_core::LedgerError, target: Option<Rou
|
||||
/// category, with activity or not (ledger_core::tax::
|
||||
/// uncategorized_accounts: one item; Info, Todo under
|
||||
/// policy.require_tax_category = refuse); legal rule known only
|
||||
/// through a past year (Todo); 1099
|
||||
/// vendor without a W-9 (Problem); overdue bills, and overdue
|
||||
/// through a past year (Todo); for
|
||||
/// this calendar year, and last year in January, from
|
||||
/// Books::vendor_1099 (the server's books.vendor_1099): a vendor a
|
||||
/// 1099 is due for (reportable) without a W-9 (Problem); a vendor
|
||||
/// paid that year in a way that could count toward a 1099 while its
|
||||
/// 1099 decision was undetermined (Vendor1099Line::decision_needed:
|
||||
/// Problem when its status is undetermined, else Todo, its prompt
|
||||
/// naming books.set_vendor_1099); the year's payments whose method
|
||||
/// decides whether they count (Vendor1099Report::
|
||||
/// undetermined_payments, none for a vendor decided not reportable),
|
||||
/// one Todo counting reversal chains, its prompt naming
|
||||
/// books.instruments, books.add_instrument and
|
||||
/// books.set_entry_instrument; a year whose worksheet can't be made
|
||||
/// for want of named cash accounts while vendors were paid in it
|
||||
/// (Todo); overdue bills, and overdue
|
||||
/// invoices (Problem, one each, with the total); money owed to
|
||||
/// owners (Info, per account with a balance); the database differing
|
||||
/// from the journal or unreadable, and damaged or missing stored
|
||||
@@ -305,9 +321,93 @@ pub fn find(b: &Books) -> Vec<Attention> {
|
||||
for year in years {
|
||||
// A year whose threshold isn't known is flagged by the stale-rule
|
||||
// item above; never guess it here.
|
||||
let Ok(r) = vendor_1099_report(&b.ledger, year, None) else {
|
||||
continue;
|
||||
let r = match b.vendor_1099(year) {
|
||||
Ok(r) => r,
|
||||
Err(ledger_core::LedgerError::Invalid1099(why))
|
||||
if b.ledger.posted_entries().any(|e| {
|
||||
e.date().year() == year && b.ledger.entry_vendor(e.id()).is_some()
|
||||
}) =>
|
||||
{
|
||||
out.push(Attention {
|
||||
severity: Severity::Todo,
|
||||
title: format!("The {year} 1099 worksheet can't be made"),
|
||||
detail: format!("{why}."),
|
||||
target: Some(Route::Reports(crate::nav::ReportKind::Vendor1099)),
|
||||
prompt: format!(
|
||||
"In wanderbooks, the {year} 1099 worksheet can't be made: {why}. Ask me which accounts hold the company's cash, then set them with books.set_setting."
|
||||
),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
Err(_) => continue,
|
||||
};
|
||||
let in_year = if year == today.year() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" for {year}")
|
||||
};
|
||||
for v in r.vendors.iter().filter(|v| v.decision_needed) {
|
||||
let party = b.ledger.party_of_vendor(v.vendor_id);
|
||||
let unknown = if v.undetermined > Decimal::ZERO {
|
||||
format!(
|
||||
" and {} more whose payment method isn't recorded",
|
||||
fmt::dollars(v.undetermined)
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
out.push(Attention {
|
||||
severity: if v.status == ledger_core::Status1099::Undetermined {
|
||||
Severity::Problem
|
||||
} else {
|
||||
Severity::Todo
|
||||
},
|
||||
title: format!("Decide whether {} gets a 1099{in_year}", v.name),
|
||||
detail: format!(
|
||||
"Paid {} by check, ACH, transfer or cash{unknown} in {year}, with no 1099 decision.",
|
||||
fmt::dollars(v.paid)
|
||||
),
|
||||
target: Some(party.map_or(Route::Company, Route::Party)),
|
||||
prompt: format!(
|
||||
"In wanderbooks, {} was paid {}{unknown} in {year} and has no 1099 decision. Ask me whether it gets a 1099-NEC, a 1099-MISC, or none and why (a corporation, the United States or a state, a payee selling only merchandise, ...); then record it with books.set_vendor_1099, with who decided and when.",
|
||||
v.name,
|
||||
fmt::dollars(v.paid)
|
||||
),
|
||||
});
|
||||
}
|
||||
// Payments whose method decides whether they count, a payment
|
||||
// and its reversals counted once.
|
||||
let mut chains: Vec<ledger_core::EntryId> = r
|
||||
.undetermined_payments
|
||||
.iter()
|
||||
.map(|p| b.ledger.reversal_chain(p.entry_id)[0])
|
||||
.collect();
|
||||
chains.sort_unstable();
|
||||
chains.dedup();
|
||||
if !chains.is_empty() {
|
||||
let n = chains.len();
|
||||
let ids = chains
|
||||
.iter()
|
||||
.map(|e| e.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let (payments, entries) = if n == 1 {
|
||||
("payment has", "entry")
|
||||
} else {
|
||||
("payments have", "entries")
|
||||
};
|
||||
out.push(Attention {
|
||||
severity: Severity::Todo,
|
||||
title: format!("{n} {payments} no payment method{in_year}"),
|
||||
detail: format!(
|
||||
"Paid for the company through a financing account in {year} ({entries} {ids}): a member's card doesn't count toward a 1099, their cash or check does, so the 1099 worksheet can't tell until each is recorded."
|
||||
),
|
||||
target: Some(Route::Reports(crate::nav::ReportKind::Vendor1099)),
|
||||
prompt: format!(
|
||||
"In wanderbooks, {entries} {ids} paid a vendor in {year} through a financing account with no payment method recorded, so the 1099 worksheet can't tell whether they count. Ask me how each was paid (whose card, or cash, check or a transfer); add any method not yet in books.instruments with books.add_instrument, then tag each entry with books.set_entry_instrument."
|
||||
),
|
||||
});
|
||||
}
|
||||
for v in r.vendors.iter().filter(|v| v.reportable && !v.tin_on_file) {
|
||||
if year == today.year() {
|
||||
asked_w9.insert(v.vendor_id);
|
||||
@@ -323,7 +423,7 @@ pub fn find(b: &Books) -> Vec<Attention> {
|
||||
),
|
||||
target: Some(party.map_or(Route::Company, Route::Party)),
|
||||
prompt: format!(
|
||||
"Remind me to request a W-9 from {}; once I have it, mark it on file in wanderbooks with books.set_vendor_1099.",
|
||||
"Remind me to request a W-9 from {}; once I have it, record what it says in wanderbooks with books.set_w9 (that marks it on file and keeps the 1099 decision).",
|
||||
v.name
|
||||
),
|
||||
});
|
||||
|
||||
@@ -156,18 +156,19 @@ impl AssetTrail {
|
||||
/// 4a. Each of 3 and 4 is None when it can't be summed exactly (a
|
||||
/// document is restored as an earlier version journaled it, of any
|
||||
/// size: rules.md B-28), never a wrong number.
|
||||
/// 5. form_1099: its vendor record's line in
|
||||
/// vendor_1099_report_with(ledger, year, None, cash and financing
|
||||
/// accounts), when it has a vendor
|
||||
/// record in that report and the report knows the year's threshold
|
||||
/// (threshold_1099 is then that threshold).
|
||||
/// 5. form_1099: its vendor record's line in Books::vendor_1099(year)
|
||||
/// (the server's books.vendor_1099), when it has a vendor record in
|
||||
/// that report and the report can be made (the year's threshold is
|
||||
/// known, the cash accounts are named; threshold_1099 is then that
|
||||
/// threshold).
|
||||
/// 6. payroll_runs: the runs with a line whose employee is the party,
|
||||
/// or (older runs, no party on the line) whose employee_name is the
|
||||
/// party's name ignoring case and surrounding space.
|
||||
/// 7. events: indexes into Books::journal of party.* events with its
|
||||
/// id, entry.party_set or entry.parties_set naming it, and its
|
||||
/// records' vendor.added,
|
||||
/// customer.added, vendor.1099_set, and entry.vendor_set events.
|
||||
/// customer.added, vendor.1099_set, vendor.w9_flag_set, and
|
||||
/// entry.vendor_set events.
|
||||
/// ```
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct PartyInfo {
|
||||
@@ -1020,12 +1021,45 @@ impl Books {
|
||||
}
|
||||
|
||||
/// Whether a party files a 1099: it has a 1099 role (contractor,
|
||||
/// landlord), or its vendor record is marked for a 1099.
|
||||
/// landlord), or its vendor record has been decided for one (from any
|
||||
/// date: its W-9 matters for those payments).
|
||||
pub fn files_1099(&self, p: &Party) -> bool {
|
||||
p.roles.iter().any(|r| r.gets_1099())
|
||||
|| p.vendor
|
||||
.and_then(|v| self.ledger.get_vendor(v))
|
||||
.is_some_and(|v| v.form_1099.is_some())
|
||||
.is_some_and(|v| v.decisions_1099.iter().any(|d| d.decision.form().is_some()))
|
||||
}
|
||||
|
||||
/// The 1099 report for calendar `year`, the server's
|
||||
/// (ledger_package::vendor_1099_report, books.vendor_1099 with no
|
||||
/// accounts named): payments out of the settings' cash and financing
|
||||
/// accounts, classified by
|
||||
/// payment method, shares resolved with the members. Every 1099
|
||||
/// figure the viewer shows comes from here.
|
||||
pub fn vendor_1099(&self, year: i32) -> Result<ledger_core::Vendor1099Report, LedgerError> {
|
||||
let settings = ledger_package::Settings {
|
||||
company: &self.settings.company,
|
||||
cash_accounts: &self.settings.cash_accounts,
|
||||
financing_accounts: &self.settings.financing_accounts,
|
||||
investing_accounts: &self.settings.investing_accounts,
|
||||
};
|
||||
ledger_package::vendor_1099_report(&self.ledger, &settings, year, None, None)
|
||||
}
|
||||
|
||||
/// The 1099 worksheet for calendar `year` without TINs: the server's
|
||||
/// books.vendor_1099 (its lines are Books::vendor_1099's, with its
|
||||
/// notes).
|
||||
pub fn vendor_1099_worksheet(
|
||||
&self,
|
||||
year: i32,
|
||||
) -> Result<ledger_package::Vendor1099Worksheet, String> {
|
||||
let settings = ledger_package::Settings {
|
||||
company: &self.settings.company,
|
||||
cash_accounts: &self.settings.cash_accounts,
|
||||
financing_accounts: &self.settings.financing_accounts,
|
||||
investing_accounts: &self.settings.investing_accounts,
|
||||
};
|
||||
ledger_package::vendor_1099_worksheet(&self.ledger, &settings, year, None, None, None)
|
||||
}
|
||||
|
||||
/// Every party's [`PartyInfo`].
|
||||
@@ -1089,11 +1123,7 @@ impl Books {
|
||||
}
|
||||
}
|
||||
}
|
||||
let sources = ledger_core::PaymentAccounts {
|
||||
cash: self.cash_accounts.clone(),
|
||||
financing: self.financing_accounts.clone(),
|
||||
};
|
||||
if let Ok(r) = ledger_core::vendor_1099_report_with(l, year, None, &sources) {
|
||||
if let Ok(r) = self.vendor_1099(year) {
|
||||
for v in &r.vendors {
|
||||
if let Some(info) = l.party_of_vendor(v.vendor_id).and_then(|p| out.get_mut(&p)) {
|
||||
info.form_1099 = Some(v.clone());
|
||||
@@ -1143,7 +1173,7 @@ impl Books {
|
||||
}
|
||||
"vendor.added" => u(p, "id").and_then(|v| l.party_of_vendor(v)),
|
||||
"customer.added" => u(p, "id").and_then(|c| l.party_of_customer(c)),
|
||||
"vendor.1099_set" | "entry.vendor_set" => {
|
||||
"vendor.1099_set" | "vendor.w9_flag_set" | "entry.vendor_set" => {
|
||||
u(p, "vendor_id").and_then(|v| l.party_of_vendor(v))
|
||||
}
|
||||
_ => None,
|
||||
@@ -1894,27 +1924,13 @@ impl Books {
|
||||
}
|
||||
|
||||
/// A party's 1099 worksheet line for a calendar year and that year's
|
||||
/// threshold: ledger-core's vendor_1099_report_with_members line for
|
||||
/// its vendor record, with Books::money_cash_accounts, the financing
|
||||
/// accounts and the members (the server's books.vendor_1099 and
|
||||
/// books.party amount_1099); None without a vendor record in the
|
||||
/// report, or when the year's threshold isn't known.
|
||||
/// threshold: its vendor record's line in Books::vendor_1099 (the
|
||||
/// server's books.vendor_1099); None without a vendor record in the
|
||||
/// report, or when the report can't be made (the year's threshold
|
||||
/// isn't known, or no cash accounts are named).
|
||||
pub fn party_1099(&self, party: PartyId, year: i32) -> Option<(Vendor1099Line, Decimal)> {
|
||||
let l = &self.ledger;
|
||||
let vendor = l.get_party(party)?.vendor?;
|
||||
let sources = ledger_core::PaymentAccounts {
|
||||
cash: self.money_cash_accounts.clone(),
|
||||
financing: self.financing_accounts.clone(),
|
||||
};
|
||||
let r = ledger_core::vendor_1099_report_with_members(
|
||||
l,
|
||||
year,
|
||||
None,
|
||||
None,
|
||||
&sources,
|
||||
&self.members,
|
||||
)
|
||||
.ok()?;
|
||||
let vendor = self.ledger.get_party(party)?.vendor?;
|
||||
let r = self.vendor_1099(year).ok()?;
|
||||
let line = r.vendors.iter().find(|v| v.vendor_id == vendor)?.clone();
|
||||
Some((line, r.threshold))
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
|
||||
use chrono::{Datelike, Days, Months, NaiveDate};
|
||||
use ledger_core::{
|
||||
AccountType, CloseTo, Currency, Form1099, Frequency, Money, NewAsset, NewRecurring,
|
||||
NewSchedule, ScheduleKind, Side, arap::DocumentLine,
|
||||
AccountType, CloseTo, Currency, Dated1099, Decision1099, Form1099, Frequency, InstrumentKind,
|
||||
Money, NewAsset, NewRecurring, NewSchedule, ScheduleKind, Side, arap::DocumentLine,
|
||||
};
|
||||
use ledger_store::{Statement, Store, StoreError, Subject};
|
||||
use rust_decimal::Decimal;
|
||||
@@ -245,12 +245,33 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
|
||||
let client_class = s.add_class("Client work")?;
|
||||
let template_class = s.add_class("Templates")?;
|
||||
|
||||
// Each vendor's 1099 decision, as the owner made it when they were
|
||||
// first paid; the card is a payment method, so what was charged to it
|
||||
// shows on the 1099 worksheet as excluded (Form 1099-K's).
|
||||
let decided = |form| {
|
||||
Dated1099::from_beginning(
|
||||
Decision1099::Files(form),
|
||||
Some(ledger_core::settings::Source {
|
||||
decided_by: "Alex Rivera".into(),
|
||||
decided_on: start,
|
||||
citation: None,
|
||||
note: None,
|
||||
}),
|
||||
)
|
||||
};
|
||||
let sam = s.add_vendor("Sam Okafor")?;
|
||||
s.set_vendor_1099(sam, Some(Form1099::Nec), true)?;
|
||||
s.set_vendor_1099(sam, &decided(Form1099::Nec), Some(true))?;
|
||||
let riley = s.add_vendor("Riley Chen")?;
|
||||
s.set_vendor_1099(riley, Some(Form1099::Nec), false)?;
|
||||
s.set_vendor_1099(riley, &decided(Form1099::Nec), Some(false))?;
|
||||
let landlord = s.add_vendor("Everett Lofts LLC")?;
|
||||
s.set_vendor_1099(landlord, Some(Form1099::Misc), true)?;
|
||||
s.set_vendor_1099(landlord, &decided(Form1099::Misc), Some(true))?;
|
||||
s.add_instrument(
|
||||
InstrumentKind::BusinessCard,
|
||||
"Business credit card",
|
||||
Some("4242"),
|
||||
None,
|
||||
card,
|
||||
)?;
|
||||
let customers: Vec<u64> = [
|
||||
"Fern & Field Coffee",
|
||||
"Bramble Outdoor Co.",
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -631,21 +631,36 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
|
||||
let vendor = p.get("vendor_id").and_then(|v| v.as_u64());
|
||||
let name = vendor
|
||||
.and_then(|v| b.ledger.get_vendor(v))
|
||||
.map(|v| v.name.clone());
|
||||
let form = p
|
||||
.get("form_1099")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(ledger_core::Form1099::parse)
|
||||
.map(|f| f.label());
|
||||
let w9 = p.get("tin_on_file").and_then(|v| v.as_bool());
|
||||
let text = match (name, form, w9) {
|
||||
(Some(n), Some(f), Some(w)) => format!(
|
||||
"1099 details of {n}: {f}, W-9 {}",
|
||||
if w { "on file" } else { "not on file" }
|
||||
),
|
||||
(Some(n), None, Some(_)) => format!("{n} no longer gets a 1099"),
|
||||
(Some(n), _, None) => format!("Updated the 1099 details of {n}"),
|
||||
(None, ..) => "Updated a vendor's 1099 details".into(),
|
||||
.map_or_else(|| "a vendor".to_string(), |v| v.name.clone());
|
||||
let w9 = |w: bool| if w { "on file" } else { "not on file" };
|
||||
let text = match ledger_store::form1099::decision_of(line) {
|
||||
// Recorded with who decided (plan defects 18, 23; D-40).
|
||||
Ok((_, d, _)) if d.source.is_some() => {
|
||||
let s = d.source.as_ref().expect("checked");
|
||||
let what = match &d.decision {
|
||||
ledger_core::Decision1099::Undetermined => "undetermined".to_string(),
|
||||
other => crate::pages::reports::decision_text(other),
|
||||
};
|
||||
let from = d
|
||||
.effective
|
||||
.map(|e| format!(" from {}", fmt::date(e)))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"1099 decision for {name}{from}: {what} (by {}, {})",
|
||||
s.decided_by,
|
||||
fmt::date(s.decided_on)
|
||||
)
|
||||
}
|
||||
// Written before decisions were kept: a form, or none
|
||||
// (which can't say whether anyone decided: plan 5.5).
|
||||
Ok((_, d, flag)) => match d.decision.form() {
|
||||
Some(f) => format!("1099 details of {name}: {}, W-9 {}", f.label(), w9(flag)),
|
||||
None => format!(
|
||||
"1099 details of {name}: no form recorded (undetermined), W-9 {}",
|
||||
w9(flag)
|
||||
),
|
||||
},
|
||||
Err(_) => format!("Updated the 1099 details of {name}"),
|
||||
};
|
||||
ev(
|
||||
icon::IDENTIFICATION_CARD,
|
||||
@@ -657,6 +672,47 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
|
||||
),
|
||||
)
|
||||
}
|
||||
"vendor.w9_flag_set" => {
|
||||
let vendor = p.get("vendor_id").and_then(|v| v.as_u64());
|
||||
let name = vendor
|
||||
.and_then(|v| b.ledger.get_vendor(v))
|
||||
.map_or_else(|| "a vendor".to_string(), |v| v.name.clone());
|
||||
let text = match p.get("tin_on_file").and_then(|v| v.as_bool()) {
|
||||
Some(true) => format!("W-9 of {name} marked on file"),
|
||||
Some(false) => format!("W-9 of {name} marked not on file"),
|
||||
None => format!("Updated the W-9 of {name}"),
|
||||
};
|
||||
ev(
|
||||
icon::IDENTIFICATION_CARD,
|
||||
text,
|
||||
Some(
|
||||
vendor
|
||||
.and_then(|v| b.ledger.party_of_vendor(v))
|
||||
.map_or(Route::Company, Route::Party),
|
||||
),
|
||||
)
|
||||
}
|
||||
"instrument.added" => {
|
||||
let text = match ledger_store::form1099::instrument_of(line) {
|
||||
Ok(i) => format!("Added payment method {} ({})", i.label, i.kind.label()),
|
||||
Err(_) => "Added a payment method".to_string(),
|
||||
};
|
||||
ev(icon::CREDIT_CARD, text, None)
|
||||
}
|
||||
"entry.instrument_set" => {
|
||||
let e = entry_id.unwrap_or(0);
|
||||
let instrument = p.get("instrument_id").and_then(|v| v.as_u64());
|
||||
Some(Event {
|
||||
icon: icon::CREDIT_CARD,
|
||||
text: match instrument.and_then(|i| b.ledger.get_instrument(i)) {
|
||||
Some(i) => format!("Payment method of #{e}: {}", i.label),
|
||||
None if instrument.is_none() => format!("Cleared the payment method of #{e}"),
|
||||
None => format!("Tagged #{e} with a payment method"),
|
||||
},
|
||||
route: entry_id.map(Route::Entry),
|
||||
also: None,
|
||||
})
|
||||
}
|
||||
"entry.vendor_set" => {
|
||||
let vendor = p.get("vendor_id").and_then(|v| v.as_u64());
|
||||
let e = entry_id.unwrap_or(0);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -18,7 +18,7 @@ use ledger_core::reports::{
|
||||
balance_sheet_as_of, general_ledger, members_equity, profit_and_loss_for_period,
|
||||
trial_balance_as_of,
|
||||
};
|
||||
use ledger_core::{AccountId, AccountType, MemberCapital, tax_summary, vendor_1099_report};
|
||||
use ledger_core::{AccountId, AccountType, MemberCapital, tax_summary};
|
||||
use rust_decimal::Decimal;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -2193,51 +2193,94 @@ fn gl(ui: &mut Ui, b: &Books, s: NaiveDate, e: NaiveDate, shell: &mut Shell<'_>)
|
||||
}
|
||||
}
|
||||
|
||||
/// A 1099 decision in the viewer's words: "1099-NEC", "Not reportable: a
|
||||
/// corporation (by name)", "Undecided".
|
||||
pub fn decision_text(d: &ledger_core::Decision1099) -> String {
|
||||
match d {
|
||||
ledger_core::Decision1099::Undetermined => "Undecided".into(),
|
||||
ledger_core::Decision1099::Files(f) => f.label().into(),
|
||||
ledger_core::Decision1099::NotReportable { kind, reason } => {
|
||||
format!("Not reportable: {} ({reason})", kind.label())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A worksheet line's 1099 decisions in words, each change with its date.
|
||||
fn decisions_text(l: &ledger_package::Vendor1099WorksheetLine) -> String {
|
||||
if l.decisions.is_empty() {
|
||||
return decision_text(&ledger_core::Decision1099::Undetermined);
|
||||
}
|
||||
l.decisions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, d)| match d.effective {
|
||||
Some(e) if i > 0 => format!("from {}: {}", fmt::date(e), decision_text(&d.decision)),
|
||||
_ => decision_text(&d.decision),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" → ")
|
||||
}
|
||||
|
||||
fn vendor_1099(ui: &mut Ui, b: &Books, year: i32, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
let r = match vendor_1099_report(&b.ledger, year, None) {
|
||||
Ok(r) => r,
|
||||
let w = match b.vendor_1099_worksheet(year) {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
// Never guess a threshold (rules.md B-19): say why there's no
|
||||
// worksheet, in the engine's words.
|
||||
ui.label(RichText::new(e.to_string()).color(p.muted));
|
||||
// Never guess a threshold or the cash accounts (rules.md
|
||||
// B-19): say why there's no worksheet, in the engine's words.
|
||||
ui.label(RichText::new(e).color(p.muted));
|
||||
return;
|
||||
}
|
||||
};
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"Calendar {year}. Threshold {} ($600 through 2025; $2,000 from 2026, indexed after). Counts cash paid on entries tagged with the vendor; card payments are reported by the card processor (1099-K) and drop out.",
|
||||
fmt::dollars(r.threshold)
|
||||
"Calendar {year}. Threshold {} ($600 through 2025; $2,000 from 2026, indexed after). Counts what each vendor was paid by check, ACH, transfer or cash on entries tagged with it, under the 1099 decision in force on each payment's date. Card and payment-network payments are reported by the processor on Form 1099-K and don't count; what a member paid with no payment method recorded is undetermined until it is.",
|
||||
fmt::dollars(w.threshold)
|
||||
))
|
||||
.color(p.muted),
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
let mut t = Table::new(vec!["Paid".into()]);
|
||||
for v in &r.vendors {
|
||||
let form = v.form.map(|f| f.label()).unwrap_or("No 1099");
|
||||
let mut t = Table::new(vec![
|
||||
"Counts".into(),
|
||||
"Card or network".into(),
|
||||
"Undetermined".into(),
|
||||
]);
|
||||
for v in &w.lines {
|
||||
let w9 = if v.tin_on_file {
|
||||
"W-9 on file"
|
||||
} else {
|
||||
"no W-9"
|
||||
};
|
||||
let status = if v.reportable {
|
||||
if v.tin_on_file {
|
||||
"File a 1099"
|
||||
} else {
|
||||
"File a 1099 · get a W-9 first"
|
||||
let status = match v.status {
|
||||
ledger_core::Status1099::Reportable if v.tin_on_file => "File a 1099",
|
||||
ledger_core::Status1099::Reportable => "File a 1099 · get a W-9 first",
|
||||
ledger_core::Status1099::UnderThreshold => "Under the threshold",
|
||||
ledger_core::Status1099::NotReportable => "No 1099",
|
||||
ledger_core::Status1099::Undetermined if v.decision_needed => {
|
||||
"Decide whether it gets a 1099"
|
||||
}
|
||||
} else {
|
||||
"Not reportable"
|
||||
ledger_core::Status1099::Undetermined => "Record how it was paid",
|
||||
};
|
||||
let mut detail = format!("{} · {w9} · {status}", decisions_text(v));
|
||||
if !v.not_reportable.is_zero() {
|
||||
detail.push_str(&format!(
|
||||
" · {} paid while not reportable",
|
||||
fmt::dollars(v.not_reportable)
|
||||
));
|
||||
}
|
||||
t.rows.push(Row {
|
||||
kind: Kind::Line,
|
||||
label: v.name.clone(),
|
||||
detail: format!("{form} · {w9} · {status}"),
|
||||
cells: vec![Cell::of(v.paid)],
|
||||
detail,
|
||||
cells: vec![
|
||||
Cell::of(v.paid),
|
||||
Cell::of(v.excluded_card),
|
||||
Cell::of(v.undetermined),
|
||||
],
|
||||
});
|
||||
}
|
||||
if r.vendors.is_empty() {
|
||||
t.note("No vendors marked for a 1099 and none paid this year.");
|
||||
if w.lines.is_empty() {
|
||||
t.note("No vendors decided for a 1099 and none paid this year.");
|
||||
}
|
||||
let pdf = PdfInfo::new(
|
||||
b,
|
||||
@@ -2245,6 +2288,13 @@ fn vendor_1099(ui: &mut Ui, b: &Books, year: i32, shell: &mut Shell<'_>) {
|
||||
&format!("Calendar year {year}"),
|
||||
);
|
||||
render(ui, &t, &pdf, shell);
|
||||
// The worksheet's notes, as books.vendor_1099 gives them, whole (a
|
||||
// table note is one clipped line).
|
||||
ui.add_space(10.0);
|
||||
for n in &w.notes {
|
||||
ui.add(egui::Label::new(RichText::new(n).font(theme::regular(12.0)).color(p.muted)).wrap());
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
@@ -7,8 +7,8 @@ use chrono::NaiveDate;
|
||||
use egui_kittest::Harness;
|
||||
use ledger_core::arap::DocumentLine;
|
||||
use ledger_core::{
|
||||
AccountType, Currency, EntryId, Form1099, Money, PartyField, PartyId, PayrollLine, Role, Side,
|
||||
TinFacts, TinType, W4Facts, W9Facts,
|
||||
AccountType, Currency, Dated1099, Decision1099, EntryId, Form1099, Money, PartyField, PartyId,
|
||||
PayrollLine, Role, Side, TinFacts, TinType, W4Facts, W9Facts, vendor_1099_report,
|
||||
};
|
||||
use ledger_gui::books::Books;
|
||||
use ledger_gui::nav::{Route, Section};
|
||||
@@ -146,6 +146,22 @@ fn fixture(tag: &str) -> Fixture {
|
||||
let labor = s
|
||||
.add_account("6050", "Contract Labor", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
// The books' cash account, which the 1099 worksheet counts payments
|
||||
// out of (P3 review R5: never every asset).
|
||||
let source = ledger_core::settings::Source {
|
||||
decided_by: "test".into(),
|
||||
decided_on: d(2025, 1, 1),
|
||||
citation: None,
|
||||
note: None,
|
||||
};
|
||||
s.change_settings(
|
||||
&[ledger_core::settings::Change {
|
||||
key: "books.cash_accounts".into(),
|
||||
value: Some(serde_json::json!(["1000"])),
|
||||
}],
|
||||
&source,
|
||||
)
|
||||
.unwrap();
|
||||
let e = s.create_entry(d(2025, 1, 1), "Owner contribution").unwrap();
|
||||
s.add_line(e, cash, Side::Debit, usd(10_000_000)).unwrap();
|
||||
s.add_line(e, capital, Side::Credit, usd(10_000_000))
|
||||
@@ -160,8 +176,12 @@ fn fixture(tag: &str) -> Fixture {
|
||||
};
|
||||
|
||||
let sam_vendor = s.add_vendor("Sam Okafor").unwrap();
|
||||
s.set_vendor_1099(sam_vendor, Some(Form1099::Nec), false)
|
||||
.unwrap();
|
||||
s.set_vendor_1099(
|
||||
sam_vendor,
|
||||
&Dated1099::from_beginning(Decision1099::Files(Form1099::Nec), Some(source.clone())),
|
||||
Some(false),
|
||||
)
|
||||
.unwrap();
|
||||
let sam = s.ledger().party_of_vendor(sam_vendor).unwrap();
|
||||
s.set_party_role(sam, Role::Contractor, true).unwrap();
|
||||
let riley = s.add_party("Riley Chen", &[Role::Contractor]).unwrap();
|
||||
@@ -259,8 +279,12 @@ fn fixture(tag: &str) -> Fixture {
|
||||
s.set_entry_party(e, Some(old)).unwrap();
|
||||
s.update_party(old, PartyField::Archived(true)).unwrap();
|
||||
// What the members advanced is money (rules.md B-35 item 5): Due to
|
||||
// Shareholders is a financing account.
|
||||
journal_settings(&mut s, "[books]\nfinancing_accounts = [\"2100\"]\n");
|
||||
// Shareholders is a financing account; the cash account stays the
|
||||
// books' (the settings file names both).
|
||||
journal_settings(
|
||||
&mut s,
|
||||
"[books]\ncash_accounts = [\"1000\"]\nfinancing_accounts = [\"2100\"]\n",
|
||||
);
|
||||
drop(s);
|
||||
let oracle = Store::open(&data).unwrap().ledger().clone();
|
||||
Fixture {
|
||||
@@ -301,17 +325,19 @@ fn each_partys_totals_are_the_books_numbers() {
|
||||
assert_eq!(sam.open_bills, Some(cents(30_000)));
|
||||
assert_eq!(sam.bills, vec![1]);
|
||||
// The 1099 amount is ledger-core's worksheet line, exactly, over the
|
||||
// viewer's payment accounts (every asset account, no cash accounts
|
||||
// being set; the financing account).
|
||||
// viewer's payment accounts: the books' cash account and the
|
||||
// financing account.
|
||||
let owed = o.chart().find_by_code("2100").unwrap().id;
|
||||
let r = ledger_core::vendor_1099_report_with(
|
||||
let r = vendor_1099_report(
|
||||
o,
|
||||
2026,
|
||||
None,
|
||||
&ledger_core::PaymentAccounts {
|
||||
cash: vec![],
|
||||
cash: vec![o.chart().find_by_code("1000").unwrap().id],
|
||||
financing: vec![owed],
|
||||
},
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let line = r
|
||||
@@ -321,9 +347,18 @@ fn each_partys_totals_are_the_books_numbers() {
|
||||
.unwrap();
|
||||
assert_eq!(sam.form_1099.as_ref(), Some(line));
|
||||
assert_eq!(sam.threshold_1099, Some(r.threshold));
|
||||
// (Cash and what the owner paid for the company: the card isn't in
|
||||
// it.)
|
||||
assert_eq!(line.paid, cents(180_000));
|
||||
// Cash counts (1,000 + the bill's 500); what the owner paid for the
|
||||
// company through the financing account waits on how they paid
|
||||
// (rules.md B-19); the card's charge is no payment out of cash.
|
||||
assert_eq!(line.paid, cents(150_000));
|
||||
assert_eq!(line.undetermined, cents(30_000));
|
||||
// Party totals count all of it as paid to Sam (B-35 item 9): the
|
||||
// 1099's buckets, and the card's 200 (no business card is recorded,
|
||||
// so the worksheet lists it nowhere).
|
||||
assert_eq!(
|
||||
sam.paid_ytd,
|
||||
line.paid + line.undetermined + line.excluded_card + cents(20_000)
|
||||
);
|
||||
// Every entry tagged, drafts included, newest first.
|
||||
assert!(sam.entries.contains(&f.bill_entry));
|
||||
assert!(sam.entries.contains(&f.reversal));
|
||||
@@ -614,10 +649,13 @@ fn journal_text_for_a_creditor() {
|
||||
)),
|
||||
"{texts:#?}"
|
||||
);
|
||||
// Declared with the feature already in force (the fixture's 1099
|
||||
// decisions'), sorted.
|
||||
assert!(
|
||||
texts
|
||||
.iter()
|
||||
.any(|(t, _)| t == "Journal format 2 declared, with party_creditors"),
|
||||
.any(|(t, _)| t
|
||||
== "Journal format 2 declared, with party_creditors, vendor_1099_decisions"),
|
||||
"{texts:#?}"
|
||||
);
|
||||
let mut h = Harness::builder()
|
||||
@@ -890,8 +928,12 @@ fn party_money_by_period_and_what_for() {
|
||||
// The 1099 amount follows the period's calendar year.
|
||||
let (line, _) = b.party_1099(f.sam, 2025).unwrap();
|
||||
assert_eq!(line.paid, cents(50_000));
|
||||
// 2026: the cash 1,000 and the bill's 500 paid count; the owner's
|
||||
// 300 through the financing account waits on how it was paid
|
||||
// (rules.md B-19); the card's 200 is no payment out of cash.
|
||||
let (line, _) = b.party_1099(f.sam, 2026).unwrap();
|
||||
assert_eq!(line.paid, cents(180_000));
|
||||
assert_eq!(line.paid, cents(150_000));
|
||||
assert_eq!(line.undetermined, cents(30_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1064,7 +1106,7 @@ fn member_books(tag: &str) -> MemberBooks {
|
||||
let dir = TempDir::new(tag);
|
||||
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
|
||||
let acct = |s: &mut Store, c: &str, n: &str, t| s.add_account(c, n, t, None, false).unwrap();
|
||||
let bank = acct(&mut s, "1000", "Bluevine", AccountType::Asset);
|
||||
let bank = acct(&mut s, "1000", "Quillmere", AccountType::Asset);
|
||||
let equip = acct(&mut s, "1500", "Equipment", AccountType::Asset);
|
||||
let due = acct(
|
||||
&mut s,
|
||||
@@ -1694,7 +1736,7 @@ fn owed_to_members_reads_the_financing_accounts_first() {
|
||||
/// core does (every asset account but receivables: Books::
|
||||
/// money_cash_accounts), so the laptop on 1500 reads as cash: Tobin's 2026
|
||||
/// contributions are $600.00 cash, none property. The Cash page keeps its
|
||||
/// name-based guess ("Bluevine" names no cash word: no cash accounts).
|
||||
/// name-based guess ("Quillmere" names no cash word: no cash accounts).
|
||||
#[test]
|
||||
fn without_cash_accounts_the_capital_statement_reads_money_as_core_does() {
|
||||
let m = member_books("capital-no-cash");
|
||||
@@ -1797,3 +1839,222 @@ fn party_money_is_the_servers_without_cash_accounts_and_after_today() {
|
||||
(d(2026, 1, 1), d(2026, 11, 20))
|
||||
);
|
||||
}
|
||||
|
||||
/// P3 brief 1.4: the viewer computed the 1099 four ways (every asset with
|
||||
/// no financing on its 1099 page, its Needs attention and its company
|
||||
/// page; cash and financing without members on a party). Now every 1099
|
||||
/// figure it shows is the server's (`ledger_package::vendor_1099_report`
|
||||
/// for the books' own settings), checked here against ledger-core's
|
||||
/// report for the same accounts. By hand: Harbor Design LLC (1099-NEC, no
|
||||
/// W-9) paid 2,600.00 from the bank and 2,500.00 by a member through Due
|
||||
/// to Members (2100, financing) with no payment method recorded: counts
|
||||
/// 2,600.00 (reportable), undetermined 2,500.00. Cedar Studio, never
|
||||
/// decided, paid 300.00 from the bank.
|
||||
#[test]
|
||||
fn the_viewer_counts_the_1099_as_the_server_does() {
|
||||
let dir = TempDir::new("viewer-1099");
|
||||
let data = dir.0.join("books-data");
|
||||
let mut s = Store::create(&data, Currency::USD).unwrap();
|
||||
let cash = s
|
||||
.add_account("1000", "Checking", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let due = s
|
||||
.add_account(
|
||||
"2100",
|
||||
"Due to Members",
|
||||
AccountType::Liability,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let labor = s
|
||||
.add_account("6050", "Contract Labor", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let source = ledger_core::settings::Source {
|
||||
decided_by: "test".into(),
|
||||
decided_on: d(2026, 1, 1),
|
||||
citation: None,
|
||||
note: None,
|
||||
};
|
||||
s.change_settings(
|
||||
&[
|
||||
ledger_core::settings::Change {
|
||||
key: "books.cash_accounts".into(),
|
||||
value: Some(serde_json::json!(["1000"])),
|
||||
},
|
||||
ledger_core::settings::Change {
|
||||
key: "books.financing_accounts".into(),
|
||||
value: Some(serde_json::json!(["2100"])),
|
||||
},
|
||||
],
|
||||
&source,
|
||||
)
|
||||
.unwrap();
|
||||
let harbor = s.add_vendor("Harbor Design LLC").unwrap();
|
||||
s.set_vendor_1099(
|
||||
harbor,
|
||||
&Dated1099::from_beginning(Decision1099::Files(Form1099::Nec), Some(source.clone())),
|
||||
Some(false),
|
||||
)
|
||||
.unwrap();
|
||||
let cedar = s.add_vendor("Cedar Studio").unwrap();
|
||||
let pay = |s: &mut Store, date, vendor, cr, c| {
|
||||
let e = s.create_entry(date, "work").unwrap();
|
||||
s.add_line(e, labor, Side::Debit, usd(c)).unwrap();
|
||||
s.add_line(e, cr, Side::Credit, usd(c)).unwrap();
|
||||
s.post_entry(e).unwrap();
|
||||
s.set_entry_vendor(e, Some(vendor)).unwrap();
|
||||
e
|
||||
};
|
||||
pay(&mut s, d(2026, 3, 1), harbor, cash, 260_000);
|
||||
let member_paid = pay(&mut s, d(2026, 4, 1), harbor, due, 250_000);
|
||||
pay(&mut s, d(2026, 5, 1), cedar, cash, 30_000);
|
||||
drop(s);
|
||||
let b = load(&dir.0);
|
||||
let oracle = Store::open(&data).unwrap().ledger().clone();
|
||||
let want = vendor_1099_report(
|
||||
&oracle,
|
||||
2026,
|
||||
None,
|
||||
&ledger_core::PaymentAccounts {
|
||||
cash: vec![cash],
|
||||
financing: vec![due],
|
||||
},
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(b.vendor_1099(2026).unwrap(), want);
|
||||
let h = want.vendors.iter().find(|v| v.vendor_id == harbor).unwrap();
|
||||
assert_eq!((h.paid, h.undetermined), (cents(260_000), cents(250_000)));
|
||||
assert!(h.reportable);
|
||||
// Its party: the same line, in the party's info and its 1099 tile.
|
||||
let party = b.ledger.party_of_vendor(harbor).unwrap();
|
||||
assert_eq!(b.party(party).unwrap().form_1099.as_ref(), Some(h));
|
||||
assert_eq!(b.party_1099(party, 2026), Some((h.clone(), want.threshold)));
|
||||
// The worksheet page's rows are the same lines.
|
||||
let w = b.vendor_1099_worksheet(2026).unwrap();
|
||||
let rows: Vec<(u64, Decimal, Decimal)> = w
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| (l.vendor_id, l.paid, l.undetermined))
|
||||
.collect();
|
||||
let lines: Vec<(u64, Decimal, Decimal)> = want
|
||||
.vendors
|
||||
.iter()
|
||||
.map(|l| (l.vendor_id, l.paid, l.undetermined))
|
||||
.collect();
|
||||
assert_eq!(rows, lines);
|
||||
// Needs attention: the W-9 (2,600.00 counts, over 2,000), Cedar's
|
||||
// decision (paid by check, under the threshold: a Todo), and the
|
||||
// member's payment with no method.
|
||||
let items = attention::find(&b);
|
||||
let item = |prefix: &str| items.iter().find(|i| i.title.starts_with(prefix));
|
||||
let w9 = item("Harbor Design LLC needs a W-9").expect("the W-9 item");
|
||||
assert!(w9.detail.contains("$2,600.00"), "{}", w9.detail);
|
||||
assert!(w9.prompt.contains("books.set_w9"), "{}", w9.prompt);
|
||||
let decide = item("Decide whether Cedar Studio gets a 1099").expect("Cedar's decision");
|
||||
assert_eq!(decide.severity, ledger_gui::books::Severity::Todo);
|
||||
assert!(decide.prompt.contains("books.set_vendor_1099"));
|
||||
assert!(item("Decide whether Harbor").is_none(), "Harbor is decided");
|
||||
let method = item("1 payment has no payment method").expect("the method item");
|
||||
assert!(
|
||||
method.detail.contains(&member_paid.to_string()),
|
||||
"{}",
|
||||
method.detail
|
||||
);
|
||||
for tool in [
|
||||
"books.instruments",
|
||||
"books.add_instrument",
|
||||
"books.set_entry_instrument",
|
||||
] {
|
||||
assert!(method.prompt.contains(tool), "{tool}: {}", method.prompt);
|
||||
}
|
||||
}
|
||||
|
||||
/// The journal page says what each 1099 record did (P3 brief 5): a
|
||||
/// decision with who made it and from when, the W-9 flag on its own, a
|
||||
/// payment method added, an entry's method set and cleared; and an old
|
||||
/// line with no form says "undetermined", never "no longer gets a 1099".
|
||||
#[test]
|
||||
fn the_journal_says_what_each_1099_record_did() {
|
||||
let dir = TempDir::new("journal-1099");
|
||||
let data = dir.0.join("books-data");
|
||||
let mut s = Store::create(&data, Currency::USD).unwrap();
|
||||
let cash = s
|
||||
.add_account("1000", "Checking", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let card = s
|
||||
.add_account("2050", "Harbor Visa", AccountType::Liability, None, false)
|
||||
.unwrap();
|
||||
let harbor = s.add_vendor("Harbor Design LLC").unwrap();
|
||||
let source = ledger_core::settings::Source {
|
||||
decided_by: "Riley Chen".into(),
|
||||
decided_on: d(2026, 10, 16),
|
||||
citation: None,
|
||||
note: None,
|
||||
};
|
||||
s.set_vendor_1099(
|
||||
harbor,
|
||||
&Dated1099::from_beginning(Decision1099::Files(Form1099::Nec), Some(source.clone())),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
s.set_vendor_1099(
|
||||
harbor,
|
||||
&Dated1099 {
|
||||
effective: Some(d(2026, 12, 30)),
|
||||
decision: Decision1099::not_reportable(
|
||||
ledger_core::ExemptReason::Corporation,
|
||||
"elected to be taxed as a corporation",
|
||||
)
|
||||
.unwrap(),
|
||||
source: Some(source),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
s.set_vendor_w9_flag(harbor, true).unwrap();
|
||||
let visa = s
|
||||
.add_instrument(
|
||||
ledger_core::InstrumentKind::BusinessCard,
|
||||
"Harbor Visa",
|
||||
None,
|
||||
None,
|
||||
card,
|
||||
)
|
||||
.unwrap();
|
||||
let e = s.create_entry(d(2026, 3, 1), "work").unwrap();
|
||||
s.add_line(e, card, Side::Credit, usd(10_000)).unwrap();
|
||||
s.add_line(e, cash, Side::Debit, usd(10_000)).unwrap();
|
||||
s.set_entry_instrument(e, Some(visa)).unwrap();
|
||||
s.set_entry_instrument(e, None).unwrap();
|
||||
drop(s);
|
||||
let b = load(&dir.0);
|
||||
let texts: Vec<String> = b
|
||||
.journal
|
||||
.iter()
|
||||
.filter(|l| {
|
||||
matches!(
|
||||
l.line_type.as_str(),
|
||||
"vendor.1099_set"
|
||||
| "vendor.w9_flag_set"
|
||||
| "instrument.added"
|
||||
| "entry.instrument_set"
|
||||
)
|
||||
})
|
||||
.filter_map(|l| describe(&b, l))
|
||||
.map(|e| e.text)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
texts,
|
||||
[
|
||||
"1099 decision for Harbor Design LLC: 1099-NEC (by Riley Chen, Oct 16, 2026)".to_string(),
|
||||
"1099 decision for Harbor Design LLC from Dec 30, 2026: Not reportable: a corporation (elected to be taxed as a corporation) (by Riley Chen, Oct 16, 2026)".to_string(),
|
||||
"W-9 of Harbor Design LLC marked on file".to_string(),
|
||||
"Added payment method Harbor Visa (a company card)".to_string(),
|
||||
format!("Payment method of #{e}: Harbor Visa"),
|
||||
format!("Cleared the payment method of #{e}"),
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
# 1099s
|
||||
|
||||
- Mark contractors and landlords with books.set_vendor_1099 (and whether their W-9 is on file), and tag every payment to them with its vendor or party (books.draft_entry vendor or party, books.set_entry_vendor, or books.set_entry_party; books.pay_bill tags automatically).
|
||||
- books.vendor_1099 is the year-end worksheet for the CPA. With include_tins: true it carries the full TINs from the vault, for the CPA's copy; csv: true also hands it over as a CSV file (run its download command to save it).
|
||||
- Card payments are reported by the card processor (1099-K) and are left out.
|
||||
- Decide every vendor with books.set_vendor_1099 and who decided: nec, misc, or not_reportable with its reason_kind and the facts (a corporation, the United States or a state, a payee selling only merchandise...); undetermined is "not decided yet". "Paid by card" is never a vendor's reason. Never corporation for a payee paid for legal services or for medical and health care: those payments stay reportable even to a corporation (nec or misc). Give effective only when a payee's status changed (an LLC's corporate election, from its Form 8832 date); a payee that incorporates is a new vendor with its own W-9. Ask the human; the CPA confirms.
|
||||
- Record each way the company pays once (books.add_instrument: the bank, the company card, each member's own card, each member's own cash or checks, a payment platform), and tag every payment to a vendor with its vendor or party (books.draft_entry vendor or party, books.set_entry_vendor, books.set_entry_party; books.pay_bill tags automatically).
|
||||
- Tag what members paid for the company with the card or the cash they used (books.set_entry_instrument): their card is excluded, their own check counts. A card that only funded a friends-and-family transfer is their cash. Tag what a payment platform's balance paid the same way: goods and services excluded, friends-and-family counts; untagged, both stay undetermined.
|
||||
- Card and payment-network payments are excluded (the processor's Form 1099-K), whoever's card it was. A bank payment with no method recorded counts: tag a debit-card payment with the card.
|
||||
- books.vendor_1099 is the year-end worksheet for the CPA. undetermined means "tell me how it was paid" or "decide this vendor". With include_tins: true it carries the full TINs from the vault; csv: true also hands it over as a CSV file (run its download command to save it).
|
||||
@@ -6,7 +6,7 @@
|
||||
- The post_entry preview lists each party's resolved share (parties) and refuses to post (party_shares, parties_error) until the shares add up to what the entry moved; show the split to the human before confirming.
|
||||
- A share's resolved amount is signed as the party's own account would show it: positive, the company paid it or gave it value; negative, received from it. Each part says its direction (paid, received, or neither: a bill, an invoice, a fee on its own card, a distribution still owed) and what of it was money (cash) and what property (in_kind); a reversal is exactly the opposite of what it reverses. A members' entry that moves their capital counts what moved it: a property contribution is received, in kind.
|
||||
- Party totals (books.party, books.parties) take a basis: cash, the default, counts money; all adds property contributed or distributed in kind (for a member, the statement of members' equity). Say which basis you report. It is not the books' accounting method.
|
||||
- Money is the cash accounts, the cards (a card account's name says "card"; a gift card the company sold is deferred revenue, not a card) and the financing accounts setting ([books] financing_accounts: what the members advanced, loans): list the members' payable and every loan there, and any card whose account name doesn't say "card". Any other liability (an accrual, deferred revenue, distributions payable) counts once, when it is settled.
|
||||
- Money is the cash accounts, the cards (the business cards recorded as payment methods, books.add_instrument kind business_card; until one is, a card account's name says "card"; a gift card the company sold is deferred revenue, not a card; a member's own card pays through the members' financing account) and the financing accounts setting ([books] financing_accounts: what the members advanced, loans): list the members' payable and every loan there, and record every company card as a payment method (or, until then, list any card whose account name doesn't say "card"). Any other liability (an accrual, deferred revenue, distributions payable) counts once, when it is settled.
|
||||
- Name whose each card, loan and advance is: books.update_party creditor_of, the issuer for its card, the lender for its loan, each member for what the members advanced. Then a fee on the card, interest on the loan, or a distribution credited to what the members are owed never reads as a payment to them; it counts when paid. A distribution declared to distributions payable counts when it is paid, too. Record a reimbursement and a distribution as two entries, and a member's cash either as a loan (through the members' financing, tagged with the payee it paid) or as capital, not both.
|
||||
- Every post_entry preview says where the entry stands (party_policy, party_missing, party_suggestions): under the `[books] parties` setting "suggest" (the default), when party_missing, name the likely party to the human and tag it (or add it) before confirming; under "require", posting is refused (party_required) until the entry names a party or is marked as having none (books.set_entry_party with party null and no_party_reason, or draft_entry no_party_reason: only for an entry with truly no counterparty, like a transfer between the company's own accounts); under "off", don't raise it.
|
||||
- Record W-9 facts (books.set_w9) for contractors and landlords before paying them, and W-4 facts (books.set_w4) for employees before their first payroll; name the employee party on every books.run_payroll line.
|
||||
|
||||
@@ -62,7 +62,7 @@ const C: Needs = PostWhenConfirmed;
|
||||
const K: Needs = PostForApprovalRules;
|
||||
|
||||
/// Every tool and what it needs (INVARIANT 1).
|
||||
pub const TABLE: [(&str, Needs); 112] = [
|
||||
pub const TABLE: [(&str, Needs); 115] = [
|
||||
("books.handbook", R),
|
||||
("books.start", R),
|
||||
// Settings are committed at once: the settings commit of the plan.
|
||||
@@ -116,6 +116,9 @@ pub const TABLE: [(&str, Needs); 112] = [
|
||||
("books.list_vendors", R),
|
||||
("books.set_vendor_1099", W),
|
||||
("books.set_entry_vendor", W),
|
||||
("books.add_instrument", W),
|
||||
("books.instruments", R),
|
||||
("books.set_entry_instrument", W),
|
||||
// Until P7 these can put full TINs out (with include_tins, or a W-9
|
||||
// itself): only an admin token, never a plain one (plan 3.3, "no
|
||||
// scope unwraps the vault", holds for every other scope).
|
||||
|
||||
+177
-62
@@ -557,7 +557,7 @@ fn vendor_1099_report(
|
||||
year: i32,
|
||||
threshold: Option<Decimal>,
|
||||
) -> Result<ledger_core::Vendor1099Report, ledger_core::LedgerError> {
|
||||
ledger_package::vendor_1099_report(ledger, &package_settings(settings), year, None, threshold)
|
||||
ledger_package::vendor_1099_report(ledger, &package_settings(settings), year, threshold, None)
|
||||
}
|
||||
|
||||
impl BooksServer {
|
||||
@@ -750,6 +750,7 @@ impl BooksServer {
|
||||
+ Self::access_router()
|
||||
+ Self::files_router()
|
||||
+ Self::backup_router()
|
||||
+ Self::instruments_router()
|
||||
}
|
||||
|
||||
/// The settings in force for this request (reloaded at its start, in
|
||||
@@ -915,8 +916,10 @@ impl BooksServer {
|
||||
/// Cash and card accounts due for reconciliation
|
||||
/// (ledger_store::reconcilable, the viewer's Needs-attention rule):
|
||||
/// the cash accounts (`[books] cash_accounts`, else assets named for
|
||||
/// cash), the card accounts (liabilities named "card", before their
|
||||
/// first statement), and every other liability with a recorded
|
||||
/// cash), the card accounts (ledger_core::parties::card_accounts: the
|
||||
/// business-card payment methods' liabilities, else liabilities named
|
||||
/// "card", not "gift", before their first statement), and every other
|
||||
/// liability with a recorded
|
||||
/// statement, with posted activity through the end of last month, not
|
||||
/// reconciled through it.
|
||||
///
|
||||
@@ -2729,6 +2732,8 @@ mod access_tools;
|
||||
mod backup_tools;
|
||||
#[path = "tools/files.rs"]
|
||||
mod files;
|
||||
#[path = "tools/instruments.rs"]
|
||||
mod instruments;
|
||||
#[path = "tools/kept.rs"]
|
||||
mod kept;
|
||||
pub use kept::SettingKeyOut;
|
||||
@@ -3025,6 +3030,11 @@ impl BooksServer {
|
||||
.as_deref()
|
||||
.map(|v| resolve_vendor(&store, v))
|
||||
.transpose()?;
|
||||
let instrument = p
|
||||
.instrument
|
||||
.as_deref()
|
||||
.map(|r| instruments::resolve_instrument(&store, r))
|
||||
.transpose()?;
|
||||
let party = p
|
||||
.party
|
||||
.as_deref()
|
||||
@@ -3064,6 +3074,10 @@ impl BooksServer {
|
||||
));
|
||||
}
|
||||
}
|
||||
// A payment method exists only under the payment_instruments
|
||||
// feature (books.add_instrument declared it), so tagging the draft
|
||||
// with one below declares nothing after the draft's first line
|
||||
// (B-27).
|
||||
let id = store
|
||||
.create_entry(date, &p.description)
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -3082,6 +3096,11 @@ impl BooksServer {
|
||||
} else if let Some(r) = no_party {
|
||||
store.set_entry_no_party(id, r).map_err(|e| e.to_string())?;
|
||||
}
|
||||
if instrument.is_some() {
|
||||
store
|
||||
.set_entry_instrument(id, instrument)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(Json(DraftEntryOut {
|
||||
entry_id: id,
|
||||
status: "draft".to_string(),
|
||||
@@ -3901,6 +3920,12 @@ impl BooksServer {
|
||||
.entry_vendor(p.entry_id)
|
||||
.and_then(|v| store.ledger().get_vendor(v))
|
||||
.map(|v| v.name.clone()),
|
||||
instrument: store
|
||||
.ledger()
|
||||
.entry_instrument(p.entry_id)
|
||||
.and_then(|i| store.ledger().get_instrument(i))
|
||||
.map(|i| i.label.clone()),
|
||||
instrument_id: store.ledger().entry_instrument(p.entry_id),
|
||||
party_id: store.ledger().entry_party(p.entry_id),
|
||||
party: store
|
||||
.ledger()
|
||||
@@ -4869,7 +4894,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.list_vendors",
|
||||
title = "List vendors",
|
||||
description = "Vendors, with which 1099 each gets, whether its W-9 is on file, and the party each belongs to (books.party).",
|
||||
description = "Vendors, each with its 1099 decision in force today (nec, misc, not_reportable with its reason, or undetermined; who decided and when) and every dated decision, whether its W-9 is on file, and the party it belongs to (books.party). undetermined with no decided_by: nobody has decided yet (books.set_vendor_1099).",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn list_vendors(&self) -> Result<Json<ListVendorsOut>, String> {
|
||||
@@ -4879,51 +4904,59 @@ impl BooksServer {
|
||||
.ledger()
|
||||
.list_vendors()
|
||||
.into_iter()
|
||||
.map(|v| VendorOut {
|
||||
id: v.id,
|
||||
name: v.name.clone(),
|
||||
form_1099: form_1099_label(v.form_1099),
|
||||
tin_on_file: v.tin_on_file,
|
||||
party_id: store.ledger().party_of_vendor(v.id),
|
||||
})
|
||||
.map(|v| instruments::vendor_out(&store, v))
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.set_vendor_1099",
|
||||
title = "Set a vendor's 1099 status",
|
||||
description = "Mark which information return a vendor gets: \"nec\" (1099-NEC: contractors, freelancers, attorneys), \"misc\" (1099-MISC: rent, royalties, prizes), or \"none\" (corporations generally, merchants, anyone paid only by card). Record whether a W-9 is on file; never store the TIN itself. Ask the human when unsure; the CPA confirms.",
|
||||
title = "Record a vendor's 1099 decision",
|
||||
description = "Record whether a vendor gets a 1099, with who decided (decided_by): nec (1099-NEC: contractors, attorneys), misc (1099-MISC: rent, royalties), not_reportable with reason_kind (corporation, tax_exempt, united_states, state, foreign_government, foreign_person, merchandise, employee, other with a citation) and reason in words, or undetermined. A corporation paid for legal or medical services stays nec/misc. Paid by card is never a reason (cards are excluded per payment). effective: only when the payee's status changed (an LLC's corporate election, from its date); a payee that incorporates is a new vendor. tin_on_file: a W-9 is on file (alone, it keeps the decision). Ask the human; CPA confirms.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
idempotent_hint = true,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn set_vendor_1099(
|
||||
&self,
|
||||
params: Parameters<SetVendor1099Params>,
|
||||
) -> Result<Json<VendorOut>, String> {
|
||||
) -> Result<Json<SetVendor1099Out>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let id = resolve_vendor(&store, &p.vendor)?;
|
||||
let form = match p.form_1099.trim().to_lowercase().as_str() {
|
||||
"none" | "" => None,
|
||||
other => Some(
|
||||
ledger_core::Form1099::parse(other)
|
||||
.ok_or_else(|| format!("form_1099 {other:?}: expected nec, misc, or none"))?,
|
||||
),
|
||||
let decision = instruments::decision_in(&p)?;
|
||||
let (changed, warnings) = match decision {
|
||||
None => {
|
||||
let flag = p.tin_on_file.expect("decision_in: one of the two");
|
||||
store
|
||||
.set_vendor_w9_flag(id, flag)
|
||||
.map_err(|e| e.to_string())?;
|
||||
(ledger_core::form1099::Applied::Unchanged, Vec::new())
|
||||
}
|
||||
Some(d) => {
|
||||
let reached = ledger_core::form1099::decision_changes(store.ledger(), id, &d)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let applied = store
|
||||
.set_vendor_1099(id, &d, p.tin_on_file)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let name = &store.ledger().get_vendor(id).expect("resolved").name;
|
||||
let warnings = instruments::filed_year_warnings(
|
||||
store.ledger(),
|
||||
&format!("{name}'s 1099 decision"),
|
||||
&reached,
|
||||
today(),
|
||||
);
|
||||
(applied, warnings)
|
||||
}
|
||||
};
|
||||
store
|
||||
.set_vendor_1099(id, form, p.tin_on_file)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let v = store.ledger().get_vendor(id).expect("exists");
|
||||
Ok(Json(VendorOut {
|
||||
id,
|
||||
name: v.name.clone(),
|
||||
form_1099: form_1099_label(v.form_1099),
|
||||
tin_on_file: v.tin_on_file,
|
||||
party_id: store.ledger().party_of_vendor(id),
|
||||
Ok(Json(SetVendor1099Out {
|
||||
vendor: instruments::vendor_out(&store, v),
|
||||
changed: instruments::applied_key(changed).to_string(),
|
||||
warnings,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -4962,7 +4995,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.vendor_1099",
|
||||
title = "1099 worksheet",
|
||||
description = "1099 worksheet for a calendar year: cash paid to each vendor (credits minus debits to the [books] cash and financing accounts on entries tagged with the vendor, so card payments, which the card processor reports on 1099-K, are excluded; on an entry naming several parties, only the vendor's resolved share of that payment), which vendors are reportable (marked NEC/MISC and paid at least the threshold: $600 through 2025, $2,000 in 2026, indexed for inflation after that), which are missing a W-9, and each payee's TIN last four. include_tins: true adds each payee's full TIN from the TIN vault (or why it can't), for the CPA's worksheet; csv: true also hands it over as a CSV file (run its download command to save it). For a year whose threshold wanderbooks doesn't know yet it refuses rather than guess: get the figure from the IRS or the human, pass it as `threshold`, and tell the human, who can report it upstream so the rule is updated for everyone. For the CPA to confirm.",
|
||||
description = "The 1099 worksheet for a calendar year. Per vendor: what counts toward its 1099 (paid out of the cash accounts, or through a financing account with the method recorded, while not decided not_reportable; an entry with several parties counts only the vendor's share), what was paid by card or payment network (excluded: Form 1099-K's), what is undetermined (a member paid, method not recorded: books.set_entry_instrument), its 1099 decision, and its status (reportable at the threshold: $600 through 2025, $2,000 in 2026, indexed after; under_threshold; not_reportable; undetermined). Lists those payments, W-9s missing, each payee's TIN last four. include_tins adds full TINs from the vault; csv hands it over. cash_accounts and financing_accounts default to the settings' (as books.export_cpa_package's do); through stops the year at a day: given a package's accounts and its as_of, the CSV is the package's 1099 worksheet. A year whose threshold isn't known is refused: pass threshold from the IRS. For the CPA to confirm.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
@@ -4975,21 +5008,44 @@ impl BooksServer {
|
||||
) -> Result<Json<Vendor1099Out>, String> {
|
||||
let p = params.0;
|
||||
let threshold = p.threshold.as_deref().map(parse_amount).transpose()?;
|
||||
let through = p.through.as_deref().map(parse_date).transpose()?;
|
||||
if let Some(t) = through
|
||||
&& chrono::Datelike::year(&t) != p.year
|
||||
{
|
||||
return Err(format!(
|
||||
"through ({t}) is a day of {}: the worksheet counts payments made in {} from January 1 through it",
|
||||
p.year, p.year
|
||||
));
|
||||
}
|
||||
let store = self.lock()?;
|
||||
let ledger = store.ledger();
|
||||
for code in p.cash_accounts.iter().chain(&p.financing_accounts) {
|
||||
if ledger.chart().find_by_code(code).is_none() {
|
||||
return Err(format!(
|
||||
"no account {code} in the chart (books.chart lists them)"
|
||||
));
|
||||
}
|
||||
}
|
||||
// With include_tins, the vault is opened once (never creating a
|
||||
// key: a locked vault becomes a note on each line).
|
||||
let vault = p.include_tins.then(|| self.open_vault(&store, false));
|
||||
let tins = vault
|
||||
.as_ref()
|
||||
.map(|vault| move |id| vault_tin(ledger, vault, id));
|
||||
// The accounts given, else the settings': the CPA package's rule
|
||||
// (ledger_package::Settings::with_accounts), so its 1099 worksheet
|
||||
// is this one for the same accounts and `through` its as_of.
|
||||
let settings = self.settings();
|
||||
let accounts =
|
||||
package_settings(&settings).with_accounts(&p.cash_accounts, &p.financing_accounts);
|
||||
// A year past what wanderbooks knows is refused with guidance
|
||||
// (research or ask, pass `threshold`, then update the rule).
|
||||
let w = ledger_package::vendor_1099_worksheet(
|
||||
ledger,
|
||||
&package_settings(&self.settings()),
|
||||
&accounts,
|
||||
p.year,
|
||||
threshold,
|
||||
through,
|
||||
tins.as_ref().map(|f| f as ledger_package::TinSource<'_>),
|
||||
)?;
|
||||
let warning = p.include_tins.then(|| {
|
||||
@@ -5006,6 +5062,27 @@ impl BooksServer {
|
||||
Some(self.hand_over(&book_name(&store), &name, "text/csv", csv.into_bytes())?)
|
||||
}
|
||||
};
|
||||
let payments = |list: &[ledger_core::Payment1099]| -> Vec<Payment1099Out> {
|
||||
list.iter()
|
||||
.map(|p| Payment1099Out {
|
||||
entry_id: p.entry_id,
|
||||
date: p.date.to_string(),
|
||||
vendor_id: p.vendor_id,
|
||||
vendor: ledger
|
||||
.get_vendor(p.vendor_id)
|
||||
.map(|v| v.name.clone())
|
||||
.unwrap_or_default(),
|
||||
amount: money(p.amount),
|
||||
instrument: p
|
||||
.instrument
|
||||
.and_then(|i| ledger.get_instrument(i))
|
||||
.map(|i| i.label.clone()),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let excluded_card_payments = payments(&w.excluded_card_payments);
|
||||
let undetermined_payments = payments(&w.undetermined_payments);
|
||||
let other_receipts = payments(&w.other_receipts);
|
||||
let vendors = w
|
||||
.lines
|
||||
.into_iter()
|
||||
@@ -5013,8 +5090,23 @@ impl BooksServer {
|
||||
vendor_id: l.vendor_id,
|
||||
name: l.name,
|
||||
form_1099: l.form_1099,
|
||||
decision_1099: instruments::decision_out(Some(&ledger_core::Dated1099 {
|
||||
effective: l.effective,
|
||||
decision: l.decision.clone(),
|
||||
source: l.decided.clone(),
|
||||
})),
|
||||
decisions_in_year: l
|
||||
.decisions
|
||||
.iter()
|
||||
.map(|d| instruments::decision_out(Some(d)))
|
||||
.collect(),
|
||||
tin_on_file: l.tin_on_file,
|
||||
paid: money(l.paid),
|
||||
excluded_card: money(l.excluded_card),
|
||||
undetermined: money(l.undetermined),
|
||||
not_reportable: money(l.not_reportable),
|
||||
status: l.status.key().to_string(),
|
||||
decision_needed: l.decision_needed,
|
||||
reportable: l.reportable,
|
||||
party_id: l.party_id,
|
||||
tin_last4: l.tin_last4,
|
||||
@@ -5028,6 +5120,11 @@ impl BooksServer {
|
||||
threshold: money(w.threshold),
|
||||
vendors,
|
||||
missing_w9: w.missing_w9,
|
||||
excluded_card_payments,
|
||||
undetermined_payments,
|
||||
other_receipts,
|
||||
untagged_entries: w.untagged_payments,
|
||||
unresolved: w.unresolved,
|
||||
notes: w.notes,
|
||||
warning,
|
||||
csv: csv_file,
|
||||
@@ -7005,6 +7102,15 @@ impl BooksServer {
|
||||
.set_party_creditor(id, *a, *on)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
// The creditor lines' journal feature, declared before this
|
||||
// command's first line, never between its lines (rules.md B-27: a
|
||||
// refused format upgrade must not leave the name changed and the
|
||||
// creditors not).
|
||||
if !creditor.is_empty() {
|
||||
store
|
||||
.ensure_feature(ledger_store::format::PARTY_CREDITORS)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
for f in fields {
|
||||
store.update_party(id, f).map_err(|e| e.to_string())?;
|
||||
}
|
||||
@@ -7299,6 +7405,19 @@ impl BooksServer {
|
||||
}
|
||||
};
|
||||
let on_file = facts.is_some();
|
||||
// The vendor record's W-9 flag follows (below): its feature is
|
||||
// declared before this command's first line, never after it
|
||||
// (B-27: a refusal to upgrade the format must not leave the W-9
|
||||
// recorded and the flag not).
|
||||
let vendor = store.ledger().get_party(id).and_then(|p| p.vendor);
|
||||
if vendor
|
||||
.and_then(|v| store.ledger().get_vendor(v))
|
||||
.is_some_and(|v| v.tin_on_file != on_file)
|
||||
{
|
||||
store
|
||||
.ensure_feature(ledger_store::format::VENDOR_1099_DECISIONS)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
if replace_tin {
|
||||
store.set_party_tin(id, None).map_err(|e| e.to_string())?;
|
||||
}
|
||||
@@ -7308,18 +7427,11 @@ impl BooksServer {
|
||||
.set_party_tin(id, tin_facts)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
// Keep the vendor record's W-9 flag (the 1099 worksheet's) in step.
|
||||
let vendor = store
|
||||
.ledger()
|
||||
.get_party(id)
|
||||
.and_then(|p| p.vendor)
|
||||
.and_then(|v| store.ledger().get_vendor(v))
|
||||
.map(|v| (v.id, v.form_1099, v.tin_on_file));
|
||||
if let Some((v, form, tin_on_file)) = vendor
|
||||
&& tin_on_file != on_file
|
||||
{
|
||||
// Keep the vendor record's W-9 flag (the 1099 worksheet's) in
|
||||
// step, its 1099 decision and who made it kept (P3 review R8).
|
||||
if let Some(v) = vendor {
|
||||
store
|
||||
.set_vendor_1099(v, form, on_file)
|
||||
.set_vendor_w9_flag(v, on_file)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
let party = store.ledger().get_party(id).expect("resolved");
|
||||
@@ -7722,20 +7834,22 @@ impl BooksServer {
|
||||
entries.sort_by(|a, b| b.date.cmp(&a.date).then(b.entry_id.cmp(&a.entry_id)));
|
||||
let invoices = party_invoices(ledger, party);
|
||||
let bills = party_bills(ledger, party);
|
||||
let amount_1099 = match party.vendor {
|
||||
Some(v) => {
|
||||
let r = vendor_1099_report(ledger, &self.settings(), year, Some(Decimal::ZERO))
|
||||
.map_err(|e| e.to_string())?;
|
||||
Some(money(
|
||||
r.vendors
|
||||
.iter()
|
||||
.find(|l| l.vendor_id == v)
|
||||
.map(|l| l.paid)
|
||||
.unwrap_or_default(),
|
||||
))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
// The worksheet's figure; none when it can't be made (no cash
|
||||
// accounts named), never a failed answer.
|
||||
let amount_1099 = party.vendor.and_then(|v| {
|
||||
let r = vendor_1099_report(ledger, &self.settings(), year, Some(Decimal::ZERO)).ok()?;
|
||||
Some(money(
|
||||
r.vendors
|
||||
.iter()
|
||||
.find(|l| l.vendor_id == v)
|
||||
.map(|l| l.paid)
|
||||
.unwrap_or_default(),
|
||||
))
|
||||
});
|
||||
let decided_now = party
|
||||
.vendor
|
||||
.and_then(|v| ledger.get_vendor(v))
|
||||
.map(|v| v.decision_on(today()).cloned().unwrap_or_default());
|
||||
// Documents and runs are restored as journaled (rules.md B-28): a
|
||||
// total past exact arithmetic refuses the answer, never a panic.
|
||||
let mut payroll = Vec::new();
|
||||
@@ -7780,10 +7894,8 @@ impl BooksServer {
|
||||
"what is receivable",
|
||||
)?),
|
||||
amount_1099,
|
||||
form_1099: party
|
||||
.vendor
|
||||
.and_then(|v| ledger.get_vendor(v))
|
||||
.and_then(|v| form_1099_label(v.form_1099)),
|
||||
form_1099: form_1099_label(decided_now.as_ref().and_then(|d| d.form())),
|
||||
decision_1099: decided_now.as_ref().map(|d| d.key().to_string()),
|
||||
gross_pay: money(gross),
|
||||
net_pay: money(net),
|
||||
};
|
||||
@@ -10063,7 +10175,7 @@ mod tests {
|
||||
const D: Hints = (Some(false), Some(true), None);
|
||||
const WI: Hints = (Some(false), Some(false), Some(true));
|
||||
const DI: Hints = (Some(false), Some(true), Some(true));
|
||||
let table: [(&str, Hints); 112] = [
|
||||
let table: [(&str, Hints); 115] = [
|
||||
("books.handbook", R),
|
||||
("books.start", R),
|
||||
("books.set_setting", DI),
|
||||
@@ -10113,9 +10225,12 @@ mod tests {
|
||||
("books.run_recurring", DI),
|
||||
("books.record_payout", W),
|
||||
("books.list_vendors", R),
|
||||
("books.set_vendor_1099", W),
|
||||
("books.set_vendor_1099", WI),
|
||||
("books.set_entry_vendor", W),
|
||||
("books.vendor_1099", W),
|
||||
("books.add_instrument", W),
|
||||
("books.instruments", R),
|
||||
("books.set_entry_instrument", WI),
|
||||
("books.chart_template", R),
|
||||
("books.tax_categories", R),
|
||||
("books.renumber_account", D),
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
//! 1099 tracking through the server: payment methods
|
||||
//! (`books.add_instrument`, `books.instruments`,
|
||||
//! `books.set_entry_instrument`), and the 1099 decision's shape in and out
|
||||
//! (`books.set_vendor_1099`, `books.list_vendors`, `books.vendor_1099`;
|
||||
//! plan P3, defects 12, 18 and 23; D-40).
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: the agent records how each payment was made and why each vendor
|
||||
//! does or doesn't get a 1099, and the worksheet never guesses.
|
||||
//! SOURCE: docs/spec/rules.md B-19 (Treas. Reg. §1.6041-1(a)(1)(iv),
|
||||
//! §1.6041-3; sources/irs-1099-payment-method.md); plan/served.md
|
||||
//! 4.9; DECISION D-40 (owner, 2026-10-20).
|
||||
//! INVARIANTS:
|
||||
//! 1. A 1099 decision names who made it (decided_by; decided_on
|
||||
//! defaults to today in the company's time zone). "none" is refused:
|
||||
//! no 1099 is not_reportable with its reason, or undetermined.
|
||||
//! 2. not_reportable takes a reason_kind and a reason, and other takes
|
||||
//! a citation too; no other decision takes either.
|
||||
//! 3. tin_on_file omitted keeps the W-9 flag; form_1099 omitted changes
|
||||
//! only it, keeping the decision and who made it (P3 review R8).
|
||||
//! 4. A change that reaches payments in a closed period, or in a year
|
||||
//! whose 1099s were due (January 31 of the next year), warns.
|
||||
//! 5. A payment method is named by id or label; a number that is one
|
||||
//! method's id and another's label is refused.
|
||||
//! ```
|
||||
|
||||
use super::*;
|
||||
use ledger_core::form1099::{Applied, Dated1099, Decision1099, ExemptReason, Form1099};
|
||||
use ledger_core::{InstrumentId, InstrumentKind};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Parameters and results
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
/// Parameters for `books.add_instrument`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct AddInstrumentParams {
|
||||
/// bank (the company's checks, ACH, wires, bill-pay), business_card (a
|
||||
/// company card the payee charged), member_card (a member's personal
|
||||
/// card the payee charged), member_cash_or_check (a member's own cash,
|
||||
/// check, ACH, Zelle, or a friends-and-family transfer), or platform (a
|
||||
/// payment the payee received as a seller through PayPal or Venmo goods
|
||||
/// and services).
|
||||
pub kind: String,
|
||||
/// Its name ("Harbor Bank checking", "Riley's Visa"), unique.
|
||||
pub label: String,
|
||||
/// The last four digits of its card or account number (never more).
|
||||
pub last4: Option<String>,
|
||||
/// Whose it is (a member's card): party id or name. Informational.
|
||||
pub owner_party: Option<String>,
|
||||
/// The account it pays through (id or code): the bank account, the
|
||||
/// card's liability, or the members' financing account (Due to
|
||||
/// Members) for a member's own card or cash.
|
||||
pub account: String,
|
||||
}
|
||||
|
||||
/// A payment method.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct InstrumentOut {
|
||||
/// Its id.
|
||||
pub id: u64,
|
||||
/// Its kind (as books.add_instrument takes it).
|
||||
pub kind: String,
|
||||
/// Whether a payment made with it is a card or payment-network
|
||||
/// payment: excluded from the company's 1099s (Form 1099-K's).
|
||||
pub card_or_network: bool,
|
||||
/// Its name.
|
||||
pub label: String,
|
||||
/// Its last four digits, if recorded.
|
||||
pub last4: Option<String>,
|
||||
/// Whose it is: party id, if recorded.
|
||||
pub owner_party: Option<u64>,
|
||||
/// The account it pays through: id.
|
||||
pub account_id: u64,
|
||||
/// That account's code.
|
||||
pub account: String,
|
||||
}
|
||||
|
||||
/// Result of `books.add_instrument`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct AddInstrumentOut {
|
||||
/// The payment method.
|
||||
#[serde(flatten)]
|
||||
pub instrument: InstrumentOut,
|
||||
/// Things to check, if any.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.instruments`.
|
||||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
pub struct InstrumentsParams {}
|
||||
|
||||
/// Result of `books.instruments`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct InstrumentsOut {
|
||||
/// Every payment method, by id.
|
||||
pub instruments: Vec<InstrumentOut>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.set_entry_instrument`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct SetEntryInstrumentParams {
|
||||
/// Entry id (draft or posted).
|
||||
pub entry_id: u64,
|
||||
/// The payment method (id or label, books.instruments); omit to clear.
|
||||
pub instrument: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of `books.set_entry_instrument`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SetEntryInstrumentOut {
|
||||
/// The entry, as books.get_entry shows it.
|
||||
#[serde(flatten)]
|
||||
pub entry: GetEntryOut,
|
||||
/// Whether anything changed (false: the entry and its reversals were
|
||||
/// tagged so already).
|
||||
pub changed: bool,
|
||||
/// Payments the change reaches in a closed period, or in a year whose
|
||||
/// 1099s were due: tell the human.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Shapes
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
/// A dated decision as the server shows it (none: never decided).
|
||||
pub(super) fn decision_out(d: Option<&Dated1099>) -> Decision1099Out {
|
||||
let undecided = Decision1099::Undetermined;
|
||||
let decision = d.map_or(&undecided, |d| &d.decision);
|
||||
let (reason_kind, reason, authority) = match decision {
|
||||
Decision1099::NotReportable { kind, reason } => (
|
||||
Some(kind.key().to_string()),
|
||||
Some(reason.clone()),
|
||||
kind.citation().map(str::to_string),
|
||||
),
|
||||
_ => (None, None, None),
|
||||
};
|
||||
let source = d.and_then(|d| d.source.as_ref());
|
||||
Decision1099Out {
|
||||
value: decision.key().to_string(),
|
||||
reason_kind,
|
||||
reason,
|
||||
authority,
|
||||
effective: d.and_then(|d| d.effective).map(|e| e.to_string()),
|
||||
decided_by: source.map(|s| s.decided_by.clone()),
|
||||
decided_on: source.map(|s| s.decided_on.to_string()),
|
||||
citation: source.and_then(|s| s.citation.clone()),
|
||||
note: source.and_then(|s| s.note.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A vendor as the server shows it: its decision in force today, and every
|
||||
/// one recorded.
|
||||
pub(super) fn vendor_out(store: &Store, v: &ledger_core::Vendor) -> VendorOut {
|
||||
let now = ledger_core::form1099::in_force(&v.decisions_1099, today());
|
||||
VendorOut {
|
||||
id: v.id,
|
||||
name: v.name.clone(),
|
||||
form_1099: form_1099_label(now.and_then(|d| d.decision.form())),
|
||||
decision_1099: decision_out(now),
|
||||
decisions_1099: v
|
||||
.decisions_1099
|
||||
.iter()
|
||||
.map(|d| decision_out(Some(d)))
|
||||
.collect(),
|
||||
tin_on_file: v.tin_on_file,
|
||||
party_id: store.ledger().party_of_vendor(v.id),
|
||||
}
|
||||
}
|
||||
|
||||
fn instrument_out(store: &Store, i: &ledger_core::Instrument) -> InstrumentOut {
|
||||
InstrumentOut {
|
||||
id: i.id,
|
||||
kind: i.kind.key().to_string(),
|
||||
card_or_network: i.kind.is_card_or_network(),
|
||||
label: i.label.clone(),
|
||||
last4: i.last4.clone(),
|
||||
owner_party: i.owner_party,
|
||||
account_id: i.account,
|
||||
account: store
|
||||
.ledger()
|
||||
.chart()
|
||||
.get(i.account)
|
||||
.map(|a| a.code.clone())
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a payment method by id or label (INVARIANT 5).
|
||||
pub(super) fn resolve_instrument(store: &Store, r: &str) -> Result<InstrumentId, String> {
|
||||
let ledger = store.ledger();
|
||||
let by_id = r
|
||||
.trim()
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.filter(|id| ledger.get_instrument(*id).is_some());
|
||||
let by_label = ledger.instrument_by_label(r).map(|i| i.id);
|
||||
match (by_id, by_label) {
|
||||
(Some(i), Some(l)) if i != l => Err(format!(
|
||||
"payment method {r:?} is ambiguous: it is method {i}'s id and method {l}'s label; pass the id"
|
||||
)),
|
||||
(Some(i), _) | (None, Some(i)) => Ok(i),
|
||||
(None, None) => Err(format!(
|
||||
"unknown payment method {r:?}: use an id or label from books.instruments (books.add_instrument adds one)"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The decision `books.set_vendor_1099` was given, or none when it
|
||||
/// changes only the W-9 flag (INVARIANTS 1-3).
|
||||
pub(super) fn decision_in(p: &SetVendor1099Params) -> Result<Option<Dated1099>, String> {
|
||||
let Some(value) = p.form_1099.as_deref() else {
|
||||
if p.reason_kind.is_some() || p.reason.is_some() || p.effective.is_some() {
|
||||
return Err(
|
||||
"reason_kind, reason and effective go with form_1099 (the decision they describe)"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if p.tin_on_file.is_none() {
|
||||
return Err(
|
||||
"nothing to change: pass form_1099 (the decision, with decided_by) or tin_on_file"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
return Ok(None);
|
||||
};
|
||||
let value = value.trim().to_ascii_lowercase();
|
||||
let decision = match value.as_str() {
|
||||
"not_reportable" => {
|
||||
let kind = p.reason_kind.as_deref().ok_or(
|
||||
"not_reportable needs reason_kind: corporation, tax_exempt, united_states, state, foreign_government, foreign_person, merchandise, employee, or other",
|
||||
)?;
|
||||
let kind = ExemptReason::parse(kind).ok_or_else(|| {
|
||||
format!(
|
||||
"reason_kind {kind:?}: expected corporation, tax_exempt, united_states, state, foreign_government, foreign_person, merchandise, employee, or other (paid by card is not a reason: card payments are excluded payment by payment)"
|
||||
)
|
||||
})?;
|
||||
let reason = p.reason.as_deref().filter(|r| !r.trim().is_empty()).ok_or(
|
||||
"not_reportable needs reason: the facts, in words (\"a corporation by name\")",
|
||||
)?;
|
||||
if kind == ExemptReason::Other
|
||||
&& p.citation.as_deref().is_none_or(|c| c.trim().is_empty())
|
||||
{
|
||||
return Err(
|
||||
"reason_kind other needs a citation: the rule or document that says no 1099 is due"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
Decision1099::not_reportable(kind, reason)?
|
||||
}
|
||||
"none" | "" | "no" | "no_1099" => {
|
||||
return Err(format!(
|
||||
"form_1099 {value:?}: say not_reportable with its reason_kind and reason (a corporation, the United States or a state, a payee selling only merchandise, ...), or undetermined. Paid by card is not a reason: card payments are excluded payment by payment"
|
||||
));
|
||||
}
|
||||
"undetermined" => Decision1099::Undetermined,
|
||||
other => Decision1099::Files(Form1099::parse(other).ok_or_else(|| {
|
||||
format!("form_1099 {other:?}: expected nec, misc, not_reportable, or undetermined")
|
||||
})?),
|
||||
};
|
||||
if !matches!(decision, Decision1099::NotReportable { .. })
|
||||
&& (p.reason_kind.is_some() || p.reason.is_some())
|
||||
{
|
||||
return Err(format!(
|
||||
"reason_kind and reason go with not_reportable, not {value}"
|
||||
));
|
||||
}
|
||||
let decided_by = p
|
||||
.decided_by
|
||||
.as_deref()
|
||||
.ok_or("decided_by is required with form_1099: who decided (a person's name)")?;
|
||||
let source = ledger_core::settings::Source {
|
||||
decided_by: decided_by.trim().to_string(),
|
||||
decided_on: match &p.decided_on {
|
||||
Some(d) => parse_date(d)?,
|
||||
None => today(),
|
||||
},
|
||||
// A blank citation or note is none.
|
||||
citation: p
|
||||
.citation
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|c| !c.is_empty())
|
||||
.map(str::to_string),
|
||||
note: p
|
||||
.note
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|n| !n.is_empty())
|
||||
.map(str::to_string),
|
||||
};
|
||||
Ok(Some(Dated1099 {
|
||||
effective: p.effective.as_deref().map(parse_date).transpose()?,
|
||||
decision,
|
||||
source: Some(source),
|
||||
}))
|
||||
}
|
||||
|
||||
/// What a change reaching `payments` (entry, date) warns of (INVARIANT 4):
|
||||
/// one line per calendar year with a payment in a closed period or whose
|
||||
/// 1099s were due by `today`.
|
||||
pub(super) fn filed_year_warnings(
|
||||
ledger: &Ledger,
|
||||
what: &str,
|
||||
payments: &[(ledger_core::EntryId, NaiveDate)],
|
||||
today: NaiveDate,
|
||||
) -> Vec<String> {
|
||||
let mut years: std::collections::BTreeMap<i32, (Vec<ledger_core::EntryId>, bool)> =
|
||||
Default::default();
|
||||
for &(e, d) in payments {
|
||||
let year = chrono::Datelike::year(&d);
|
||||
let closed = ledger.closed_period_containing(d).is_some();
|
||||
let due = NaiveDate::from_ymd_opt(year + 1, 1, 31).is_none_or(|due| today > due);
|
||||
if closed || due {
|
||||
let y = years.entry(year).or_default();
|
||||
y.0.push(e);
|
||||
y.1 |= closed;
|
||||
}
|
||||
}
|
||||
years
|
||||
.into_iter()
|
||||
.map(|(year, (mut ids, closed))| {
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
let ids = ids.iter().map(|i| i.to_string()).collect::<Vec<_>>().join(", ");
|
||||
let when = if closed {
|
||||
", in a closed period".to_string()
|
||||
} else {
|
||||
format!(", a year whose 1099s were due January 31, {}", year + 1)
|
||||
};
|
||||
format!(
|
||||
"This changes {what} for {year} (entries {ids}){when}: if a {year} 1099 was filed, a corrected one may be needed. Tell the human and the CPA."
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `books.set_vendor_1099`'s "changed".
|
||||
pub(super) fn applied_key(a: Applied) -> &'static str {
|
||||
match a {
|
||||
Applied::Set => "set",
|
||||
Applied::Removed => "removed",
|
||||
Applied::Unchanged => "unchanged",
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_router(router = instruments_router, vis = "pub(super)")]
|
||||
impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.add_instrument",
|
||||
title = "Add a payment method",
|
||||
description = "Record a way the company pays, once, so the 1099 worksheet tells card payments (Form 1099-K's, never the company's 1099) from checks. kind: bank (company checks, ACH, wires, bill-pay); business_card or member_card (a company or a member's card the payee charged); member_cash_or_check (a member's own cash, check, ACH, Zelle, or friends-and-family transfer, even card-funded); platform (paid to the payee as a seller through PayPal or Venmo goods and services; never ACH, bill-pay, Zelle or friends-and-family). account: what it pays through (the bank, the card's liability, Due to Members for a member's own). last4 only. Then tag payments with books.set_entry_instrument.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn add_instrument(
|
||||
&self,
|
||||
params: Parameters<AddInstrumentParams>,
|
||||
) -> Result<Json<AddInstrumentOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let kind = InstrumentKind::parse(&p.kind).ok_or_else(|| {
|
||||
format!(
|
||||
"kind {:?}: expected bank, business_card, member_card, member_cash_or_check, or platform",
|
||||
p.kind
|
||||
)
|
||||
})?;
|
||||
let account = resolve_account(&store, &p.account)?;
|
||||
let owner = p
|
||||
.owner_party
|
||||
.as_deref()
|
||||
.map(|r| resolve_party(&store, r))
|
||||
.transpose()?;
|
||||
let last4 = p.last4.as_deref().map(str::trim);
|
||||
let id = store
|
||||
.add_instrument(kind, &p.label, last4, owner, account)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let settings = self.settings();
|
||||
let code = |a: AccountId| {
|
||||
store
|
||||
.ledger()
|
||||
.chart()
|
||||
.get(a)
|
||||
.map(|a| a.code.clone())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let mut warnings = Vec::new();
|
||||
if kind == InstrumentKind::BusinessCard && settings.roles.financing.contains(&code(account))
|
||||
{
|
||||
warnings.push(format!(
|
||||
"account {} is one of the books' financing accounts: a company card's liability usually isn't, and while it is, its payments with no method recorded stay undetermined. Check the financing accounts (books.settings).",
|
||||
code(account)
|
||||
));
|
||||
}
|
||||
if kind.is_card_or_network() && settings.roles.cash.contains(&code(account)) {
|
||||
warnings.push(format!(
|
||||
"account {} is a cash account: only the payments tagged with this method are excluded as card payments; untagged ones from it still count (presumed check, ACH or transfer).",
|
||||
code(account)
|
||||
));
|
||||
}
|
||||
let members = matches!(
|
||||
kind,
|
||||
InstrumentKind::MemberCard | InstrumentKind::MemberCashOrCheck
|
||||
);
|
||||
if members
|
||||
&& !settings.roles.financing.contains(&code(account))
|
||||
&& !settings.roles.cash.contains(&code(account))
|
||||
{
|
||||
warnings.push(format!(
|
||||
"account {} is not one of the books' financing accounts: the 1099 worksheet still holds what members paid through it undetermined until each payment's method is recorded, but name it in books.financing_accounts (books.set_setting) so the books read it as what members paid for the company.",
|
||||
code(account)
|
||||
));
|
||||
}
|
||||
let i = store.ledger().get_instrument(id).expect("just added");
|
||||
Ok(Json(AddInstrumentOut {
|
||||
instrument: instrument_out(&store, i),
|
||||
warnings,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.instruments",
|
||||
title = "Payment methods",
|
||||
description = "The company's payment methods (books.add_instrument), each with its kind, whether its payments are card or payment-network payments (excluded from the company's 1099s), its last four, and the account it pays through. Tag an entry with one by id or label (books.set_entry_instrument, books.draft_entry instrument).",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn instruments(
|
||||
&self,
|
||||
_params: Parameters<InstrumentsParams>,
|
||||
) -> Result<Json<InstrumentsOut>, String> {
|
||||
let store = self.lock()?;
|
||||
Ok(Json(InstrumentsOut {
|
||||
instruments: store
|
||||
.ledger()
|
||||
.instruments()
|
||||
.map(|i| instrument_out(&store, i))
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.set_entry_instrument",
|
||||
title = "Tag an entry's payment method",
|
||||
description = "Tag an entry (draft or posted) with how it was paid (a payment method's id or label, books.instruments), or omit instrument to clear it, so the 1099 worksheet can classify it: a card or payment network's payment is excluded (Form 1099-K's), a bank or a member's own cash or check counts. Needed above all for what members paid for the company (their card or their check: undetermined until tagged). Reporting metadata only: amounts never change. An entry and its reversals are tagged together. Warns when it changes a year whose 1099s are due or a closed period.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
idempotent_hint = true,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn set_entry_instrument(
|
||||
&self,
|
||||
params: Parameters<SetEntryInstrumentParams>,
|
||||
) -> Result<Json<SetEntryInstrumentOut>, String> {
|
||||
let p = params.0;
|
||||
let (changed, warnings) = {
|
||||
let mut store = self.lock()?;
|
||||
let instrument = p
|
||||
.instrument
|
||||
.as_deref()
|
||||
.map(|r| resolve_instrument(&store, r))
|
||||
.transpose()?;
|
||||
let ledger = store.ledger();
|
||||
// The chain's posted payments to a vendor: what the change
|
||||
// reaches on its 1099s.
|
||||
let reached: Vec<(ledger_core::EntryId, NaiveDate)> = ledger
|
||||
.reversal_chain(p.entry_id)
|
||||
.into_iter()
|
||||
.filter_map(|e| ledger.get_entry(e))
|
||||
.filter(|e| {
|
||||
e.status() == ledger_core::EntryStatus::Posted
|
||||
&& ledger.entry_vendor(e.id()).is_some()
|
||||
&& ledger.entry_instrument(e.id()) != instrument
|
||||
})
|
||||
.map(|e| (e.id(), e.date()))
|
||||
.collect();
|
||||
let warnings =
|
||||
filed_year_warnings(ledger, "how a vendor's payment counts", &reached, today());
|
||||
let changed = store
|
||||
.set_entry_instrument(p.entry_id, instrument)
|
||||
.map_err(|e| e.to_string())?;
|
||||
(changed, if changed { warnings } else { Vec::new() })
|
||||
};
|
||||
let entry = self
|
||||
.get_entry(Parameters(GetEntryParams {
|
||||
entry_id: p.entry_id,
|
||||
}))
|
||||
.await?
|
||||
.0;
|
||||
Ok(Json(SetEntryInstrumentOut {
|
||||
entry,
|
||||
changed,
|
||||
warnings,
|
||||
}))
|
||||
}
|
||||
}
|
||||
+180
-19
@@ -367,14 +367,47 @@ pub struct VendorOut {
|
||||
pub id: u64,
|
||||
/// Name.
|
||||
pub name: String,
|
||||
/// "1099-NEC", "1099-MISC", or absent.
|
||||
/// "1099-NEC" or "1099-MISC" when its 1099 decision in force today
|
||||
/// files one; absent otherwise.
|
||||
pub form_1099: Option<String>,
|
||||
/// Its 1099 decision in force today. value "undetermined" with no
|
||||
/// decided_by: nobody has decided.
|
||||
pub decision_1099: Decision1099Out,
|
||||
/// Every 1099 decision recorded, in date order (the one from the
|
||||
/// beginning first): more than one when its status changed on a date.
|
||||
pub decisions_1099: Vec<Decision1099Out>,
|
||||
/// W-9 on file.
|
||||
pub tin_on_file: bool,
|
||||
/// The party the vendor record belongs to.
|
||||
pub party_id: Option<u64>,
|
||||
}
|
||||
|
||||
/// A vendor's 1099 decision.
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
pub struct Decision1099Out {
|
||||
/// "nec", "misc", "not_reportable" or "undetermined".
|
||||
pub value: String,
|
||||
/// With not_reportable: the kind of payee (corporation, tax_exempt,
|
||||
/// united_states, state, foreign_government, foreign_person,
|
||||
/// merchandise, employee, other).
|
||||
pub reason_kind: Option<String>,
|
||||
/// With not_reportable: the facts, in words.
|
||||
pub reason: Option<String>,
|
||||
/// With not_reportable: the rule that exempts that kind of payee.
|
||||
pub authority: Option<String>,
|
||||
/// The day it takes effect, YYYY-MM-DD; absent: from the beginning.
|
||||
pub effective: Option<String>,
|
||||
/// Who decided; absent for a vendor nobody decided, or a decision
|
||||
/// recorded before decisions were kept.
|
||||
pub decided_by: Option<String>,
|
||||
/// When they decided, YYYY-MM-DD.
|
||||
pub decided_on: Option<String>,
|
||||
/// What it rests on, if anything.
|
||||
pub citation: Option<String>,
|
||||
/// Anything kept with it.
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of `books.list_vendors`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ListVendorsOut {
|
||||
@@ -387,12 +420,54 @@ pub struct ListVendorsOut {
|
||||
pub struct SetVendor1099Params {
|
||||
/// Vendor id or name.
|
||||
pub vendor: String,
|
||||
/// "nec" (contractors, attorneys), "misc" (rent, royalties, other), or
|
||||
/// "none" (corporations, most merchants).
|
||||
pub form_1099: String,
|
||||
/// A W-9 is on file (the TIN itself is never stored).
|
||||
#[serde(default)]
|
||||
pub tin_on_file: bool,
|
||||
/// The decision: "nec" (1099-NEC: contractors, freelancers,
|
||||
/// attorneys), "misc" (1099-MISC: rent, royalties, prizes),
|
||||
/// "not_reportable" (with reason_kind and reason), or "undetermined".
|
||||
/// Omit to change only tin_on_file.
|
||||
pub form_1099: Option<String>,
|
||||
/// With not_reportable: the kind of payee no 1099 is required for:
|
||||
/// corporation, tax_exempt, united_states, state, foreign_government,
|
||||
/// foreign_person, merchandise (sells only merchandise), employee, or
|
||||
/// other (give a citation). Never corporation for a payee paid for
|
||||
/// legal services or for medical and health care (a law firm or a
|
||||
/// medical practice, often a "P.C."): those payments stay reportable
|
||||
/// even to a corporation (Treas. Reg. §1.6041-3(p)(1)).
|
||||
pub reason_kind: Option<String>,
|
||||
/// With not_reportable: the facts, in words ("a corporation by name",
|
||||
/// "Delaware public benefit corporation, from the public record").
|
||||
pub reason: Option<String>,
|
||||
/// The day the decision takes effect, YYYY-MM-DD: only when the
|
||||
/// payee's status changed (an LLC's corporate election takes effect
|
||||
/// on its Form 8832 date; a new W-9 showing a new classification, on
|
||||
/// the form's date). Omit for its status from the beginning.
|
||||
pub effective: Option<String>,
|
||||
/// A W-9 is on file (the TIN itself is never stored). Omit to keep it
|
||||
/// as it is.
|
||||
pub tin_on_file: Option<bool>,
|
||||
/// Who decided (a person's name): required with form_1099.
|
||||
pub decided_by: Option<String>,
|
||||
/// When they decided, YYYY-MM-DD. Default: today.
|
||||
pub decided_on: Option<String>,
|
||||
/// The rule or document it rests on ("Treas. Reg. §1.6041-3(p)(1)", a
|
||||
/// W-9 dated 2026-03-02).
|
||||
pub citation: Option<String>,
|
||||
/// Anything else worth keeping with it.
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of `books.set_vendor_1099`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SetVendor1099Out {
|
||||
/// The vendor as it stands now.
|
||||
#[serde(flatten)]
|
||||
pub vendor: VendorOut,
|
||||
/// What the decision did: "set", "removed" (a dated decision taken
|
||||
/// out: the one before it applies again), or "unchanged" (already so;
|
||||
/// nothing journaled but a changed W-9 flag).
|
||||
pub changed: String,
|
||||
/// Payments the change reaches in a closed period, or in a year whose
|
||||
/// 1099s were due: tell the human (a filed 1099 may need correcting).
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.set_entry_vendor`.
|
||||
@@ -421,6 +496,20 @@ pub struct Vendor1099Params {
|
||||
/// with include_tins), to save with its download command.
|
||||
#[serde(default)]
|
||||
pub csv: bool,
|
||||
/// The cash account codes payments are counted out of, e.g.
|
||||
/// `["1000"]`. Defaults to `[books] cash_accounts`, as the CPA
|
||||
/// package's do: one or the other is required.
|
||||
#[serde(default)]
|
||||
pub cash_accounts: Vec<String>,
|
||||
/// The financing liability codes (what the members advanced, loans)
|
||||
/// payments are counted out of. Defaults to `[books]
|
||||
/// financing_accounts`, as the CPA package's do.
|
||||
#[serde(default)]
|
||||
pub financing_accounts: Vec<String>,
|
||||
/// Count only the payments dated on or before this day of `year`
|
||||
/// (YYYY-MM-DD): the worksheet as of a day, the one a CPA package as
|
||||
/// of that day carries. Default: the whole year.
|
||||
pub through: Option<String>,
|
||||
}
|
||||
|
||||
/// One vendor's year.
|
||||
@@ -430,13 +519,37 @@ pub struct Vendor1099LineOut {
|
||||
pub vendor_id: u64,
|
||||
/// Name.
|
||||
pub name: String,
|
||||
/// "1099-NEC", "1099-MISC", or absent.
|
||||
/// "1099-NEC" or "1099-MISC" when its decision at the year's end files
|
||||
/// one; absent otherwise.
|
||||
pub form_1099: Option<String>,
|
||||
/// Its 1099 decision at the year's end.
|
||||
pub decision_1099: Decision1099Out,
|
||||
/// Every decision in force during the year, in date order (more than
|
||||
/// one when its status changed in the year; none when never decided).
|
||||
pub decisions_in_year: Vec<Decision1099Out>,
|
||||
/// W-9 on file.
|
||||
pub tin_on_file: bool,
|
||||
/// Cash paid in the year, decimal string.
|
||||
/// What counts toward its 1099, decimal string: paid by check, ACH,
|
||||
/// transfer or cash (net of refunds and reversals) while it wasn't
|
||||
/// decided not reportable.
|
||||
pub paid: String,
|
||||
/// Marked for a 1099 and paid at least the threshold.
|
||||
/// Paid by card or payment network: reported by the processor on Form
|
||||
/// 1099-K, never on the company's 1099, decimal string.
|
||||
pub excluded_card: String,
|
||||
/// Paid through a financing account (or a member's or a payment
|
||||
/// platform's) with no payment method recorded: counts or not once it
|
||||
/// is (books.set_entry_instrument), decimal string.
|
||||
pub undetermined: String,
|
||||
/// Paid while it was decided not reportable, decimal string.
|
||||
pub not_reportable: String,
|
||||
/// "reportable" (file a 1099), "under_threshold", "not_reportable"
|
||||
/// (its decision says why), or "undetermined" (a decision or a payment
|
||||
/// method is needed first).
|
||||
pub status: String,
|
||||
/// Some of its payments that could count were made while its decision
|
||||
/// was undetermined: ask the human and record one.
|
||||
pub decision_needed: bool,
|
||||
/// File a 1099: status reportable.
|
||||
pub reportable: bool,
|
||||
/// The vendor's party.
|
||||
pub party_id: Option<u64>,
|
||||
@@ -452,6 +565,23 @@ pub struct Vendor1099LineOut {
|
||||
pub tin_note: Option<String>,
|
||||
}
|
||||
|
||||
/// One payment on the 1099 worksheet's lists.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Payment1099Out {
|
||||
/// The entry.
|
||||
pub entry_id: u64,
|
||||
/// Its date, YYYY-MM-DD.
|
||||
pub date: String,
|
||||
/// The vendor it paid.
|
||||
pub vendor_id: u64,
|
||||
/// The vendor's name.
|
||||
pub vendor: String,
|
||||
/// The vendor's part the list is about, decimal string.
|
||||
pub amount: String,
|
||||
/// The payment method it is tagged with (label), if any.
|
||||
pub instrument: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of `books.vendor_1099`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Vendor1099Out {
|
||||
@@ -459,10 +589,26 @@ pub struct Vendor1099Out {
|
||||
pub year: i32,
|
||||
/// Threshold applied, decimal string.
|
||||
pub threshold: String,
|
||||
/// Vendors marked for a 1099 or paid in the year.
|
||||
/// Vendors decided for a 1099 during the year, or with any payment in
|
||||
/// it.
|
||||
pub vendors: Vec<Vendor1099LineOut>,
|
||||
/// Reportable vendors without a W-9 on file: request one.
|
||||
/// Vendors a 1099 is or may be due for without a W-9 on file
|
||||
/// ("(may be reportable)" after the undetermined): request one.
|
||||
pub missing_w9: Vec<String>,
|
||||
/// Payments by card or payment network, excluded (Form 1099-K's).
|
||||
pub excluded_card_payments: Vec<Payment1099Out>,
|
||||
/// Payments with no payment method recorded where it decides whether
|
||||
/// they count: record each with books.set_entry_instrument.
|
||||
pub undetermined_payments: Vec<Payment1099Out>,
|
||||
/// Money received from a vendor for something else (a sale, its
|
||||
/// payment of an invoice, a disposal, a loan), not netted against its
|
||||
/// payments, with the reversals of each.
|
||||
pub other_receipts: Vec<Payment1099Out>,
|
||||
/// Posted entries in the year that paid money out with no vendor tag.
|
||||
pub untagged_entries: Vec<u64>,
|
||||
/// Entries naming several parties whose shares don't resolve (each
|
||||
/// counted in full toward its vendor).
|
||||
pub unresolved: Vec<u64>,
|
||||
/// Caveats for the human and CPA.
|
||||
pub notes: Vec<String>,
|
||||
/// With include_tins: this output holds full TINs, for the CPA.
|
||||
@@ -1208,6 +1354,9 @@ pub struct DraftEntryParams {
|
||||
pub description: String,
|
||||
/// The vendor this entry pays (id or name), for 1099 tracking.
|
||||
pub vendor: Option<String>,
|
||||
/// How it was paid: a payment method's id or label (books.instruments),
|
||||
/// for 1099 tracking.
|
||||
pub instrument: Option<String>,
|
||||
/// The party this entry is with (id or name; see `books.parties`). A
|
||||
/// party with a vendor record also tags the entry's vendor; given with
|
||||
/// `vendor`, both must name the same vendor.
|
||||
@@ -1749,6 +1898,10 @@ pub struct GetEntryOut {
|
||||
pub receipts: Vec<PreviewReceipt>,
|
||||
/// The vendor the entry is tagged with, for 1099 tracking.
|
||||
pub vendor: Option<String>,
|
||||
/// The payment method it is tagged with (label), for 1099 tracking.
|
||||
pub instrument: Option<String>,
|
||||
/// That payment method's id.
|
||||
pub instrument_id: Option<u64>,
|
||||
/// The id of the party the entry is tagged with (its counterparty),
|
||||
/// when it names exactly one.
|
||||
pub party_id: Option<u64>,
|
||||
@@ -3449,8 +3602,10 @@ pub struct ListPartiesOut {
|
||||
/// books' accounting method.)
|
||||
///
|
||||
/// Money is what left or reached the `[books] cash_accounts` (every asset
|
||||
/// account but receivables when none are set), the cards (liabilities
|
||||
/// whose names say "card", not "gift card"), and the `[books]
|
||||
/// account but receivables when none are set), the cards (the
|
||||
/// business-card payment methods' liabilities, `books.instruments`; while
|
||||
/// none is recorded, liabilities whose names say "card", not "gift
|
||||
/// card"), and the `[books]
|
||||
/// financing_accounts` (what the members
|
||||
/// advanced, loans): a card charge or a member paying for the company is
|
||||
/// paid to the payee then; cash that pays one of these down is paid to
|
||||
@@ -3506,13 +3661,19 @@ pub struct PartyTotalsOut {
|
||||
/// Still owed by it now: its open invoices' balances (every year),
|
||||
/// decimal string.
|
||||
pub receivable: String,
|
||||
/// For a party with a vendor record: the cash `books.vendor_1099`
|
||||
/// counts as paid to that vendor in the year (asset accounts only, so
|
||||
/// card payments drop out), decimal string. Absent otherwise.
|
||||
/// For a party with a vendor record: what `books.vendor_1099` counts
|
||||
/// toward that vendor's 1099 in the year (out of the cash and
|
||||
/// financing accounts; card and payment-network payments excluded,
|
||||
/// payments with no method recorded on a financing account left
|
||||
/// undetermined), decimal string. Absent otherwise, or when the
|
||||
/// worksheet can't be made (no cash accounts named).
|
||||
pub amount_1099: Option<String>,
|
||||
/// The information return its vendor record gets ("1099-NEC",
|
||||
/// "1099-MISC"), if any.
|
||||
/// The information return its vendor record's decision in force today
|
||||
/// files ("1099-NEC", "1099-MISC"), if any.
|
||||
pub form_1099: Option<String>,
|
||||
/// Its vendor record's 1099 decision in force today ("nec", "misc",
|
||||
/// "not_reportable", "undetermined"). Absent without a vendor record.
|
||||
pub decision_1099: Option<String>,
|
||||
/// Gross pay on payroll lines naming it, decimal string.
|
||||
pub gross_pay: String,
|
||||
/// Net pay on payroll lines naming it, decimal string.
|
||||
|
||||
@@ -178,7 +178,7 @@ fn mcp_end_to_end_over_stdio() {
|
||||
// Tool listing: the narrow tools; chart is read-only.
|
||||
let list = mcp.request("tools/list", json!({}));
|
||||
let tools = list["result"]["tools"].as_array().unwrap();
|
||||
assert_eq!(tools.len(), 109, "expected 109 tools, got {}", tools.len());
|
||||
assert_eq!(tools.len(), 112, "expected 112 tools, got {}", tools.len());
|
||||
// Files come in as tool input: books.begin_upload, never a local path.
|
||||
assert!(tools.iter().any(|t| t["name"] == "books.begin_upload"));
|
||||
assert!(!tools.iter().any(|t| t["name"] == "books.import_local_file"));
|
||||
@@ -820,6 +820,11 @@ fn tin_bearing_files_are_refused_when_given_back_to_be_filed() {
|
||||
"books.add_account",
|
||||
json!({"code": "1000", "name": "Cash", "account_type": "asset"}),
|
||||
);
|
||||
// The 1099 worksheet counts payments out of the books' cash accounts.
|
||||
mcp.call(
|
||||
"books.set_setting",
|
||||
json!({"changes": [{"key": "books.cash_accounts", "value": ["1000"]}], "decided_by": "Test Owner"}),
|
||||
);
|
||||
mcp.call(
|
||||
"books.add_party",
|
||||
json!({"name": "Acme", "roles": ["contractor"]}),
|
||||
@@ -1298,6 +1303,13 @@ fn mcp_statement_pdfs_and_cpa_package_over_stdio() {
|
||||
json!({"as_of": "2026-10-31", "include_tins": false}),
|
||||
);
|
||||
assert!(err.contains("cash_accounts"), "{err}");
|
||||
// The package's 1099 worksheet is books.vendor_1099's for the
|
||||
// package's accounts: the request's, else the settings' (here both
|
||||
// name checking).
|
||||
mcp.call(
|
||||
"books.set_setting",
|
||||
json!({"changes": [{"key": "books.cash_accounts", "value": ["1000"]}], "decided_by": "Test Owner"}),
|
||||
);
|
||||
let pkg = mcp.call(
|
||||
"books.export_cpa_package",
|
||||
json!({"as_of": "2026-10-31", "cash_accounts": ["1000"], "include_tins": false}),
|
||||
@@ -2191,17 +2203,26 @@ fn mcp_vendor_1099_over_stdio() {
|
||||
json!({"code": code, "name": name, "account_type": typ}),
|
||||
);
|
||||
}
|
||||
// The worksheet counts payments out of the books' cash accounts.
|
||||
mcp.call(
|
||||
"books.set_setting",
|
||||
json!({"changes": [{"key": "books.cash_accounts", "value": ["1000"]}], "decided_by": "Test Owner"}),
|
||||
);
|
||||
mcp.call("books.add_vendor", json!({"name": "Dana Designs"}));
|
||||
let v = mcp.call(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "dana designs", "form_1099": "nec"}),
|
||||
json!({"vendor": "dana designs", "form_1099": "nec", "decided_by": "Test Owner"}),
|
||||
);
|
||||
assert_eq!(v["form_1099"], "1099-NEC");
|
||||
assert_eq!(v["decision_1099"]["decided_by"], "Test Owner");
|
||||
let err = mcp.call_err(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Dana Designs", "form_1099": "k"}),
|
||||
json!({"vendor": "Dana Designs", "form_1099": "k", "decided_by": "Test Owner"}),
|
||||
);
|
||||
assert!(
|
||||
err.contains("expected nec, misc, not_reportable, or undetermined"),
|
||||
"{err}"
|
||||
);
|
||||
assert!(err.contains("nec, misc, or none"), "{err}");
|
||||
|
||||
// A 2,500.00 check to Dana, tagged at draft time.
|
||||
let e = mcp.call(
|
||||
@@ -2243,10 +2264,10 @@ fn mcp_vendor_1099_over_stdio() {
|
||||
json!({"year": 2026, "threshold": "3000"}),
|
||||
);
|
||||
assert_eq!(high["vendors"][0]["reportable"], false);
|
||||
// W-9 arrives.
|
||||
// W-9 arrives: the flag alone, the decision kept.
|
||||
mcp.call(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Dana Designs", "form_1099": "nec", "tin_on_file": true}),
|
||||
json!({"vendor": "Dana Designs", "tin_on_file": true}),
|
||||
);
|
||||
drop(mcp);
|
||||
|
||||
|
||||
@@ -485,9 +485,9 @@ fn rich_books(s: &mut Store) -> Rich {
|
||||
let coach = s.add_class("Coach").unwrap();
|
||||
let landlord = s.add_vendor("Landlord").unwrap();
|
||||
let contractor = s.add_vendor("Contractor").unwrap();
|
||||
s.set_vendor_1099(landlord, Some(Form1099::Misc), true)
|
||||
s.set_vendor_1099(landlord, &decided(Form1099::Misc), Some(true))
|
||||
.unwrap();
|
||||
s.set_vendor_1099(contractor, Some(Form1099::Nec), false)
|
||||
s.set_vendor_1099(contractor, &decided(Form1099::Nec), Some(false))
|
||||
.unwrap();
|
||||
s.set_account_tax(id_of(s, "6000"), Some("rent_property"), None)
|
||||
.unwrap();
|
||||
@@ -626,6 +626,52 @@ fn rich_books(s: &mut Store) -> Rich {
|
||||
const START: &str = "2025-01-01";
|
||||
const END: &str = "2025-06-30";
|
||||
|
||||
/// A 1099 decision from the beginning, decided by the owner.
|
||||
fn decided(form: Form1099) -> ledger_core::Dated1099 {
|
||||
ledger_core::Dated1099::from_beginning(
|
||||
ledger_core::Decision1099::Files(form),
|
||||
Some(ledger_core::settings::Source {
|
||||
decided_by: "Oracle Owner".into(),
|
||||
decided_on: d("2025-01-01"),
|
||||
citation: None,
|
||||
note: None,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// The 1099 report the server makes for these books: payments out of
|
||||
/// cash 1000 and financing 2100 (books_toml), no member parties (the
|
||||
/// rich books name none, so no shares resolve against members).
|
||||
fn core_1099(
|
||||
ledger: &Ledger,
|
||||
year: i32,
|
||||
threshold: Option<Decimal>,
|
||||
) -> ledger_core::Vendor1099Report {
|
||||
core_1099_through(ledger, year, threshold, None)
|
||||
}
|
||||
|
||||
/// [`core_1099`], payments dated after `through` left out.
|
||||
fn core_1099_through(
|
||||
ledger: &Ledger,
|
||||
year: i32,
|
||||
threshold: Option<Decimal>,
|
||||
through: Option<chrono::NaiveDate>,
|
||||
) -> ledger_core::Vendor1099Report {
|
||||
let id = |code: &str| ledger.chart().find_by_code(code).unwrap().id;
|
||||
ledger_core::vendor_1099_report(
|
||||
ledger,
|
||||
year,
|
||||
threshold,
|
||||
&ledger_core::PaymentAccounts {
|
||||
cash: vec![id("1000")],
|
||||
financing: vec![id("2100")],
|
||||
},
|
||||
&[],
|
||||
through,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// ------------------------------------------------- statements: csv + json
|
||||
|
||||
/// Trial balance CSV and JSON equal `trial_balance_as_of`, line by line.
|
||||
@@ -1748,10 +1794,13 @@ fn pnl_by_class_excludes_closing_entries() {
|
||||
|
||||
// ------------------------------------------------- 1099
|
||||
|
||||
/// The worksheet equals `vendor_1099_report`; `missing_w9` is the
|
||||
/// reportable vendors without a W-9; the untagged-entries note counts the
|
||||
/// year's posted entries with no vendor; the indexing note appears only
|
||||
/// after 2026. Kills: vendor_1099's year/tag filter and both note guards.
|
||||
/// The worksheet equals `vendor_1099_report` (for the server's cash and
|
||||
/// financing accounts); `missing_w9` is the vendors a 1099 is or may be
|
||||
/// due for without a W-9; the untagged-entries note counts the year's
|
||||
/// untagged entries that paid money out for something (the report's
|
||||
/// untagged_payments, listed as untagged_entries); the indexing note
|
||||
/// appears only after 2026. Kills: vendor_1099's year/tag filter and both
|
||||
/// note guards.
|
||||
#[test]
|
||||
fn vendor_1099_equals_the_core_report_and_counts_untagged_entries() {
|
||||
let dir = test_dir("1099");
|
||||
@@ -1769,11 +1818,9 @@ fn vendor_1099_equals_the_core_report_and_counts_untagged_entries() {
|
||||
let ledger = s.ledger().clone();
|
||||
drop(s);
|
||||
let untagged = |year: i32| {
|
||||
ledger
|
||||
.posted_entries()
|
||||
.filter(|e| chrono::Datelike::year(&e.date()) == year)
|
||||
.filter(|e| ledger.entry_vendor(e.id()).is_none())
|
||||
.count()
|
||||
core_1099(&ledger, year, Some(Decimal::ZERO))
|
||||
.untagged_payments
|
||||
.len()
|
||||
};
|
||||
assert!(untagged(2025) > 0 && untagged(2025) != untagged(2024));
|
||||
let mut m = Mcp::start(&dir, "1000000.00");
|
||||
@@ -1784,8 +1831,9 @@ fn vendor_1099_equals_the_core_report_and_counts_untagged_entries() {
|
||||
args["threshold"] = json!(t);
|
||||
}
|
||||
let out = m.ok("books.vendor_1099", args);
|
||||
let want = ledger_core::vendor_1099_report(&ledger, year, threshold.map(dec)).unwrap();
|
||||
let want = core_1099(&ledger, year, threshold.map(dec));
|
||||
assert_eq!(out["year"], json!(want.year));
|
||||
assert_eq!(out["untagged_entries"], json!(want.untagged_payments));
|
||||
assert_eq!(jamt(&out["threshold"]), want.threshold);
|
||||
let vendors = out["vendors"].as_array().unwrap();
|
||||
assert_eq!(vendors.len(), want.vendors.len(), "{year} vendors");
|
||||
@@ -1793,14 +1841,29 @@ fn vendor_1099_equals_the_core_report_and_counts_untagged_entries() {
|
||||
assert_eq!(g["vendor_id"], json!(w.vendor_id));
|
||||
assert_eq!(g["name"], w.name.as_str());
|
||||
assert_eq!(jamt(&g["paid"]), w.paid, "{year} {}", w.name);
|
||||
assert_eq!(jamt(&g["excluded_card"]), w.excluded_card);
|
||||
assert_eq!(jamt(&g["undetermined"]), w.undetermined);
|
||||
assert_eq!(jamt(&g["not_reportable"]), w.not_reportable);
|
||||
assert_eq!(g["status"], w.status.key());
|
||||
assert_eq!(g["decision_1099"]["value"], w.decision.key());
|
||||
assert_eq!(g["reportable"], json!(w.reportable));
|
||||
assert_eq!(g["tin_on_file"], json!(w.tin_on_file));
|
||||
}
|
||||
let missing: Vec<String> = want
|
||||
.vendors
|
||||
.iter()
|
||||
.filter(|v| v.reportable && !v.tin_on_file)
|
||||
.map(|v| v.name.clone())
|
||||
.filter(|v| !v.tin_on_file)
|
||||
.filter_map(|v| {
|
||||
if v.reportable {
|
||||
Some(v.name.clone())
|
||||
} else if v.status == ledger_core::Status1099::Undetermined
|
||||
&& v.decisions.iter().any(|d| d.decision.form().is_some())
|
||||
{
|
||||
Some(format!("{} (may be reportable)", v.name))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(out["missing_w9"], json!(missing), "{year} missing W-9s");
|
||||
|
||||
@@ -2757,7 +2820,7 @@ fn receipts_and_documents_round_trip() {
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": e, "file_name": "first.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": b64e(first), "sha256": sha256(first),
|
||||
"description": "Receipt ID 1007; Paid from: Bluevine"}),
|
||||
"description": "Receipt ID 1007; Paid from: Quillmere"}),
|
||||
);
|
||||
let b = m.ok(
|
||||
"books.file_document",
|
||||
@@ -2787,7 +2850,7 @@ fn receipts_and_documents_round_trip() {
|
||||
"first.pdf",
|
||||
"application/pdf",
|
||||
Some(e),
|
||||
"Receipt ID 1007; Paid from: Bluevine",
|
||||
"Receipt ID 1007; Paid from: Quillmere",
|
||||
),
|
||||
(
|
||||
&b,
|
||||
@@ -3560,31 +3623,88 @@ fn cpa_package_holds_the_year_and_its_evidence() {
|
||||
// 1099 worksheet.
|
||||
let w = csv_rows(
|
||||
std::str::from_utf8(&files["1099-worksheet-2025.csv"]).unwrap(),
|
||||
&["vendor", "form", "w9_on_file", "paid", "reportable"],
|
||||
&[
|
||||
"vendor_id",
|
||||
"name",
|
||||
"decision_1099",
|
||||
"form_1099",
|
||||
"reason_kind",
|
||||
"reason",
|
||||
"decided_by",
|
||||
"decided_on",
|
||||
"effective",
|
||||
"decisions_in_year",
|
||||
"paid",
|
||||
"excluded_card",
|
||||
"undetermined",
|
||||
"not_reportable",
|
||||
"status",
|
||||
"reportable",
|
||||
"w9_on_file",
|
||||
"tin_last4",
|
||||
],
|
||||
);
|
||||
let want = ledger_core::vendor_1099_report_with_members(
|
||||
&ledger,
|
||||
2025,
|
||||
Some(d(END)),
|
||||
None,
|
||||
&ledger_core::PaymentAccounts::default(),
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
// Through as_of (rules.md B-33), out of the package's cash account
|
||||
// and the settings' financing account (one list each per package).
|
||||
let want = core_1099_through(&ledger, 2025, None, Some(d(END)));
|
||||
assert_eq!(w.len(), want.vendors.len());
|
||||
let contractor = w.iter().find(|row| row[0] == "Contractor").unwrap();
|
||||
let contractor = w.iter().find(|row| row[1] == "Contractor").unwrap();
|
||||
assert_eq!(
|
||||
amt(&contractor[3]),
|
||||
amt(&contractor[10]),
|
||||
dec("900.00"),
|
||||
"the payment after as_of is left out"
|
||||
);
|
||||
for (row, v) in w.iter().zip(&want.vendors) {
|
||||
assert_eq!(row[0], v.name);
|
||||
assert_eq!(row[1], v.form.map(|f| f.label()).unwrap_or(""));
|
||||
assert_eq!(row[2], v.tin_on_file.to_string());
|
||||
assert_eq!(amt(&row[3]), v.paid);
|
||||
assert_eq!(row[4], v.reportable.to_string());
|
||||
assert_eq!(row[0], v.vendor_id.to_string());
|
||||
assert_eq!(row[1], v.name);
|
||||
assert_eq!(row[2], v.decision.key());
|
||||
assert_eq!(row[3], v.form().map(|f| f.label()).unwrap_or(""));
|
||||
assert_eq!(
|
||||
row[6],
|
||||
v.decided.as_ref().map_or("", |s| s.decided_by.as_str())
|
||||
);
|
||||
assert_eq!(amt(&row[10]), v.paid);
|
||||
assert_eq!(amt(&row[11]), v.excluded_card);
|
||||
assert_eq!(amt(&row[12]), v.undetermined);
|
||||
assert_eq!(amt(&row[13]), v.not_reportable);
|
||||
assert_eq!(row[14], v.status.key());
|
||||
assert_eq!(row[15], v.reportable.to_string());
|
||||
assert_eq!(row[16], v.tin_on_file.to_string());
|
||||
}
|
||||
// The package's 1099 worksheet is books.vendor_1099's CSV for the
|
||||
// package's accounts and its as_of, byte for byte (ledger_package
|
||||
// INVARIANT 7, docs/mcp-interface.md): the tool given the same cash
|
||||
// account (the financing account the settings', as the package's)
|
||||
// and through as_of hands over the same file; without through, the
|
||||
// payment after as_of counts, and the files differ.
|
||||
let mut tool_csv = |args: Value| {
|
||||
let out = m.ok("books.vendor_1099", args);
|
||||
handed(&out["csv"])
|
||||
};
|
||||
let same =
|
||||
tool_csv(json!({"year": 2025, "csv": true, "cash_accounts": ["1000"], "through": END}));
|
||||
assert_eq!(same, files["1099-worksheet-2025.csv"]);
|
||||
let named = tool_csv(json!({
|
||||
"year": 2025, "csv": true, "cash_accounts": ["1000"],
|
||||
"financing_accounts": ["2100"], "through": END
|
||||
}));
|
||||
assert_eq!(
|
||||
named, files["1099-worksheet-2025.csv"],
|
||||
"the settings' financing, named"
|
||||
);
|
||||
let whole_year = tool_csv(json!({"year": 2025, "csv": true, "cash_accounts": ["1000"]}));
|
||||
assert_ne!(whole_year, files["1099-worksheet-2025.csv"]);
|
||||
// A through outside the year, or an account the chart lacks: refused.
|
||||
let why = m.refuse(
|
||||
"books.vendor_1099",
|
||||
json!({"year": 2025, "through": "2026-01-01"}),
|
||||
);
|
||||
assert!(why.contains("is a day of 2025"), "{why}");
|
||||
let why = m.refuse(
|
||||
"books.vendor_1099",
|
||||
json!({"year": 2025, "cash_accounts": ["1999"]}),
|
||||
);
|
||||
assert!(why.contains("no account 1999"), "{why}");
|
||||
|
||||
// Without receipts: no evidence at all.
|
||||
let out = m.ok(
|
||||
|
||||
@@ -0,0 +1,929 @@
|
||||
//! The 1099 decision and payment methods through the server (plan P3,
|
||||
//! defects 12, 18 and 23; D-40), driven over stdio against the real
|
||||
//! binary: every success validates against the tool's outputSchema, and
|
||||
//! every refusal leaves `journal.jsonl` byte-identical (the harness is
|
||||
//! `tests/spec_parties.rs`'s). Every name is fictional; the reasons are
|
||||
//! the ones the first company's cutover records.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(180);
|
||||
|
||||
// ---------------------------------------------------------------- harness
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Out {
|
||||
Ok(Value),
|
||||
ToolErr(String),
|
||||
RpcErr(Value),
|
||||
}
|
||||
|
||||
impl Out {
|
||||
fn refused(&self) -> bool {
|
||||
!matches!(self, Out::Ok(_))
|
||||
}
|
||||
fn err_text(&self) -> String {
|
||||
match self {
|
||||
Out::Ok(v) => panic!("expected a refusal, got {v}"),
|
||||
Out::ToolErr(t) => t.clone(),
|
||||
Out::RpcErr(e) => e.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Mcp {
|
||||
stdin: ChildStdin,
|
||||
rx: Receiver<String>,
|
||||
child: Child,
|
||||
next_id: u64,
|
||||
data: PathBuf,
|
||||
validators: HashMap<String, jsonschema::Validator>,
|
||||
/// The books dir when the server owns it: removed after the server
|
||||
/// stops (fields drop after `Drop::drop`).
|
||||
books_dir: Option<TestDir>,
|
||||
}
|
||||
|
||||
impl Drop for Mcp {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
static DIR_SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// A test's dir, removed when the test ends, pass or panic.
|
||||
struct TestDir(PathBuf);
|
||||
|
||||
impl std::ops::Deref for TestDir {
|
||||
type Target = std::path::Path;
|
||||
fn deref(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<std::path::Path> for TestDir {
|
||||
fn as_ref(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn test_dir(name: &str) -> TestDir {
|
||||
let n = DIR_SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"ledger-mcp-spec-1099-{}-{name}-{n}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
TestDir(dir)
|
||||
}
|
||||
|
||||
/// `wanderbooks.toml` with a company name and extra `[books]` TOML. The
|
||||
/// receipt threshold is high so ordinary test entries need no receipt.
|
||||
fn books_toml(dir: &Path, company: &str, books_extra: &str) -> PathBuf {
|
||||
let path = dir.join("wanderbooks.toml");
|
||||
std::fs::write(
|
||||
&path,
|
||||
format!(
|
||||
"[company]\nname = \"{company}\"\nfiscal_year_start = \"01-01\"\n\
|
||||
[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n{books_extra}"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
impl Mcp {
|
||||
fn start(dir: &Path, toml: &Path) -> Self {
|
||||
let run = dir.join("run");
|
||||
std::fs::create_dir_all(&run).unwrap();
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp"))
|
||||
.arg("--data-dir")
|
||||
.arg(dir.join("books-data"))
|
||||
.arg("--books-toml")
|
||||
.arg(toml)
|
||||
.env("XDG_RUNTIME_DIR", &run)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("spawn ledger-mcp");
|
||||
let stdin = child.stdin.take().unwrap();
|
||||
let stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
let (tx, rx) = channel();
|
||||
std::thread::spawn(move || {
|
||||
for line in stdout.lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if tx.send(line).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut mcp = Self {
|
||||
books_dir: None,
|
||||
stdin,
|
||||
rx,
|
||||
child,
|
||||
next_id: 1,
|
||||
data: dir.join("books-data"),
|
||||
validators: HashMap::new(),
|
||||
};
|
||||
let init = mcp.rpc(
|
||||
"initialize",
|
||||
json!({
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "spec-1099", "version": "0.1.0"},
|
||||
}),
|
||||
);
|
||||
assert!(init.get("result").is_some(), "initialize failed: {init}");
|
||||
mcp.send_line(
|
||||
&json!({"jsonrpc": "2.0", "method": "notifications/initialized"}).to_string(),
|
||||
);
|
||||
let list = mcp.rpc("tools/list", json!({}));
|
||||
for tool in list["result"]["tools"].as_array().expect("tools") {
|
||||
let name = tool["name"].as_str().unwrap().to_string();
|
||||
let validator = jsonschema::validator_for(&tool["outputSchema"])
|
||||
.unwrap_or_else(|e| panic!("{name}'s outputSchema is not valid: {e}"));
|
||||
mcp.validators.insert(name, validator);
|
||||
}
|
||||
mcp
|
||||
}
|
||||
|
||||
fn journal(&self) -> Vec<u8> {
|
||||
std::fs::read(self.data.join("journal.jsonl")).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The journal's line types, in order.
|
||||
fn journal_types(&self) -> Vec<String> {
|
||||
String::from_utf8(self.journal())
|
||||
.unwrap()
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.map(|l| {
|
||||
let v: Value = serde_json::from_str(l).unwrap();
|
||||
v["type"]
|
||||
.as_str()
|
||||
.or_else(|| v["line_type"].as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn send_line(&mut self, line: &str) {
|
||||
writeln!(self.stdin, "{line}").unwrap();
|
||||
self.stdin.flush().unwrap();
|
||||
}
|
||||
|
||||
fn recv(&mut self) -> Option<Value> {
|
||||
loop {
|
||||
match self.rx.recv_timeout(TIMEOUT) {
|
||||
Ok(line) => {
|
||||
let v: Value = serde_json::from_str(&line)
|
||||
.unwrap_or_else(|e| panic!("server wrote non-JSON {line:?}: {e}"));
|
||||
if v.get("id").is_none() && v.get("method").is_some() {
|
||||
continue;
|
||||
}
|
||||
return Some(v);
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rpc(&mut self, method: &str, params: Value) -> Value {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params});
|
||||
self.send_line(&msg.to_string());
|
||||
let resp = self
|
||||
.recv()
|
||||
.unwrap_or_else(|| panic!("no response to {method} (id {id})"));
|
||||
assert_eq!(resp["id"], json!(id), "response id mismatch: {resp}");
|
||||
resp
|
||||
}
|
||||
|
||||
/// Call a tool; a refusal must leave the journal unchanged, a success
|
||||
/// must match the outputSchema.
|
||||
fn tool(&mut self, name: &str, args: Value) -> Out {
|
||||
let before = self.journal();
|
||||
let resp = self.rpc("tools/call", json!({"name": name, "arguments": args}));
|
||||
let out = if let Some(err) = resp.get("error") {
|
||||
Out::RpcErr(err.clone())
|
||||
} else {
|
||||
let result = &resp["result"];
|
||||
if result.get("isError") == Some(&json!(true)) {
|
||||
Out::ToolErr(
|
||||
result["content"]
|
||||
.as_array()
|
||||
.and_then(|c| c.iter().find_map(|b| b["text"].as_str()))
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
)
|
||||
} else {
|
||||
let sc = result["structuredContent"].clone();
|
||||
let v = self
|
||||
.validators
|
||||
.get(name)
|
||||
.unwrap_or_else(|| panic!("{name} is not in tools/list"));
|
||||
let errors: Vec<String> = v.iter_errors(&sc).map(|e| e.to_string()).collect();
|
||||
assert!(
|
||||
errors.is_empty(),
|
||||
"{name} violates its outputSchema: {errors:?}\n{sc}"
|
||||
);
|
||||
Out::Ok(sc)
|
||||
}
|
||||
};
|
||||
if out.refused() {
|
||||
assert!(
|
||||
self.journal() == before,
|
||||
"refused {name} {args} changed the journal ({out:?})"
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn ok(&mut self, name: &str, args: Value) -> Value {
|
||||
let a = args.to_string();
|
||||
match self.tool(name, args) {
|
||||
Out::Ok(v) => v,
|
||||
other => panic!("{name} {a}: expected success, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `tool`, expecting a refusal that says why.
|
||||
fn refuse(&mut self, name: &str, args: Value) -> String {
|
||||
let a = args.to_string();
|
||||
let out = self.tool(name, args);
|
||||
assert!(out.refused(), "{name} {a} should be refused, got {out:?}");
|
||||
let why = out.err_text();
|
||||
assert!(!why.trim().is_empty(), "{name} {a}: a refusal says why");
|
||||
why
|
||||
}
|
||||
|
||||
/// `ok`, and the journal must not change (read-only tools).
|
||||
fn read_only(&mut self, name: &str, args: Value) -> Value {
|
||||
let before = self.journal();
|
||||
let v = self.ok(name, args);
|
||||
assert!(
|
||||
self.journal() == before,
|
||||
"read-only {name} changed the journal"
|
||||
);
|
||||
v
|
||||
}
|
||||
|
||||
fn accounts(&mut self, accts: &[(&str, &str, &str)]) {
|
||||
for (code, name, ty) in accts {
|
||||
self.ok(
|
||||
"books.add_account",
|
||||
json!({"code": code, "name": name, "account_type": ty}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draft an entry with `args` extra (party, vendor) and lines; the id.
|
||||
fn draft(&mut self, date: &str, extra: Value, lines: &[(&str, &str, &str)]) -> u64 {
|
||||
let mut args = json!({"date": date, "description": format!("entry {date}")});
|
||||
for (k, v) in extra.as_object().unwrap() {
|
||||
args[k] = v.clone();
|
||||
}
|
||||
let id = self.ok("books.draft_entry", args)["entry_id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
for (account, side, amount) in lines {
|
||||
self.ok(
|
||||
"books.add_line",
|
||||
json!({"entry_id": id, "account": account, "side": side, "amount": amount}),
|
||||
);
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
/// `draft`, then post it (high receipt threshold: no receipt needed).
|
||||
fn post(&mut self, date: &str, extra: Value, lines: &[(&str, &str, &str)]) -> u64 {
|
||||
let id = self.draft(date, extra, lines);
|
||||
self.ok(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": id, "confirmed": true}),
|
||||
);
|
||||
id
|
||||
}
|
||||
|
||||
fn add_party(&mut self, name: &str, roles: &[&str]) -> u64 {
|
||||
self.ok("books.add_party", json!({"name": name, "roles": roles}))["party_id"]
|
||||
.as_u64()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn party(&mut self, party: &str, year: i32) -> Value {
|
||||
self.read_only("books.party", json!({"party": party, "year": year}))
|
||||
}
|
||||
|
||||
/// How many journal lines have this type.
|
||||
fn count(&self, line_type: &str) -> usize {
|
||||
self.journal_types()
|
||||
.iter()
|
||||
.filter(|t| t.as_str() == line_type)
|
||||
.count()
|
||||
}
|
||||
}
|
||||
|
||||
const CHART: &[(&str, &str, &str)] = &[
|
||||
("1000", "Bank", "asset"),
|
||||
("1300", "Prepaid Expenses", "asset"),
|
||||
("2050", "Card", "liability"),
|
||||
("2100", "Due to Members", "liability"),
|
||||
("3000", "Capital", "equity"),
|
||||
("4000", "Revenue", "revenue"),
|
||||
("6000", "Contract Labor", "expense"),
|
||||
("6999", "General Expenses", "expense"),
|
||||
];
|
||||
|
||||
/// Books with the chart; cash is 1000, financing 2100.
|
||||
fn books(name: &str) -> Mcp {
|
||||
let dir = test_dir(name);
|
||||
let toml = books_toml(&dir, "Spec Co", "financing_accounts = [\"2100\"]\n");
|
||||
let mut m = Mcp::start(&dir, &toml);
|
||||
m.accounts(CHART);
|
||||
m.books_dir = Some(dir);
|
||||
m
|
||||
}
|
||||
|
||||
impl Mcp {
|
||||
/// The same books, served again (a restart).
|
||||
fn restart(mut self) -> Mcp {
|
||||
let dir = self.books_dir.take().expect("books_dir");
|
||||
drop(self);
|
||||
let mut m = Mcp::start(&dir, &dir.join("wanderbooks.toml"));
|
||||
m.books_dir = Some(dir);
|
||||
m
|
||||
}
|
||||
}
|
||||
|
||||
/// A vendor's decision as books.list_vendors shows it today.
|
||||
fn decision(m: &mut Mcp, vendor: &str) -> Value {
|
||||
let all = m.read_only("books.list_vendors", json!({}));
|
||||
all["vendors"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|v| v["name"] == vendor)
|
||||
.unwrap_or_else(|| panic!("{vendor} not listed: {all}"))
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// A vendor's line on the 2026 worksheet.
|
||||
fn line_2026(m: &mut Mcp, vendor: &str) -> Value {
|
||||
let w = m.read_only("books.vendor_1099", json!({"year": 2026}));
|
||||
w["vendors"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|v| v["name"] == vendor)
|
||||
.unwrap_or_else(|| panic!("{vendor} not on the worksheet: {w}"))
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Defect 23, from its reproduction (P3 brief 1.1): "none" stored the same
|
||||
/// null as a vendor nobody decided on. Now "none" is refused with what to
|
||||
/// say instead, not_reportable is kept with its reason and who decided
|
||||
/// when, a fresh vendor reads undetermined with no one named, and both
|
||||
/// survive a restart.
|
||||
#[test]
|
||||
fn a_vendor_marked_no_1099_is_not_undecided() {
|
||||
let mut m = books("decided");
|
||||
m.ok("books.add_vendor", json!({"name": "Bigco"}));
|
||||
m.ok("books.add_vendor", json!({"name": "Newco"}));
|
||||
for none in ["none", "", "None", "no_1099"] {
|
||||
let why = m.refuse(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Bigco", "form_1099": none, "decided_by": "Riley Chen"}),
|
||||
);
|
||||
assert!(
|
||||
why.contains("not_reportable") && why.contains("undetermined"),
|
||||
"{why}"
|
||||
);
|
||||
assert!(why.contains("Paid by card is not a reason"), "{why}");
|
||||
}
|
||||
let v = m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Bigco", "form_1099": "not_reportable", "reason_kind": "corporation",
|
||||
"reason": "a corporation by name", "decided_by": "Riley Chen",
|
||||
"decided_on": "2026-10-16", "citation": "Treas. Reg. §1.6041-3(p)(1)"}),
|
||||
);
|
||||
assert_eq!(v["changed"], "set");
|
||||
assert!(v["warnings"].as_array().unwrap().is_empty());
|
||||
let check = |m: &mut Mcp| {
|
||||
let b = decision(m, "Bigco");
|
||||
assert!(b["form_1099"].is_null(), "{b}");
|
||||
let d = &b["decision_1099"];
|
||||
assert_eq!(d["value"], "not_reportable");
|
||||
assert_eq!(d["reason_kind"], "corporation");
|
||||
assert_eq!(d["reason"], "a corporation by name");
|
||||
assert_eq!(d["authority"], "Treas. Reg. §1.6041-3(p)(1)");
|
||||
assert_eq!(d["decided_by"], "Riley Chen");
|
||||
assert_eq!(d["decided_on"], "2026-10-16");
|
||||
assert_eq!(d["citation"], "Treas. Reg. §1.6041-3(p)(1)");
|
||||
assert!(d["effective"].is_null(), "from the beginning");
|
||||
assert_eq!(b["decisions_1099"].as_array().unwrap().len(), 1);
|
||||
let n = decision(m, "Newco");
|
||||
assert_eq!(n["decision_1099"]["value"], "undetermined");
|
||||
assert!(n["decision_1099"]["decided_by"].is_null(), "nobody decided");
|
||||
assert!(n["decisions_1099"].as_array().unwrap().is_empty());
|
||||
};
|
||||
check(&mut m);
|
||||
assert_eq!(m.count("vendor.1099_set"), 1);
|
||||
let mut m = m.restart();
|
||||
check(&mut m);
|
||||
}
|
||||
|
||||
/// D-40: the first company's cutover records exactly these decisions (a
|
||||
/// reason kind and its words), and each comes back as given.
|
||||
#[test]
|
||||
fn the_cutovers_reasons_are_recorded_as_given() {
|
||||
let mut m = books("cutover");
|
||||
for (vendor, kind, reason, citation) in [
|
||||
(
|
||||
"Northwind Research, PBC",
|
||||
"corporation",
|
||||
"a corporation (Delaware public benefit corporation, from the public record)",
|
||||
None,
|
||||
),
|
||||
(
|
||||
"Tidewater Hosting, Inc.",
|
||||
"corporation",
|
||||
"a corporation by name",
|
||||
Some("Treas. Reg. §1.6041-3(p)(1)"),
|
||||
),
|
||||
(
|
||||
"Federal Parcel Service",
|
||||
"united_states",
|
||||
"the United States",
|
||||
Some("Treas. Reg. §1.6041-3(p)(3)"),
|
||||
),
|
||||
] {
|
||||
m.ok("books.add_vendor", json!({"name": vendor}));
|
||||
let mut args = json!({"vendor": vendor, "form_1099": "not_reportable", "reason_kind": kind,
|
||||
"reason": reason, "decided_by": "Riley Chen", "decided_on": "2026-10-16"});
|
||||
if let Some(c) = citation {
|
||||
args["citation"] = json!(c);
|
||||
}
|
||||
m.ok("books.set_vendor_1099", args);
|
||||
let d = decision(&mut m, vendor)["decision_1099"].clone();
|
||||
assert_eq!(
|
||||
(
|
||||
d["value"].as_str(),
|
||||
d["reason_kind"].as_str(),
|
||||
d["reason"].as_str()
|
||||
),
|
||||
(Some("not_reportable"), Some(kind), Some(reason))
|
||||
);
|
||||
assert_eq!(d["citation"].as_str(), citation);
|
||||
}
|
||||
}
|
||||
|
||||
/// INVARIANTS 1-3 of tools/instruments.rs: what a decision needs, each
|
||||
/// refusal saying why and changing nothing.
|
||||
#[test]
|
||||
fn set_vendor_1099_says_what_a_decision_needs() {
|
||||
let mut m = books("refusals");
|
||||
m.ok("books.add_vendor", json!({"name": "Harbor Design LLC"}));
|
||||
let v = "Harbor Design LLC";
|
||||
let by = "Riley Chen";
|
||||
for (args, needle) in [
|
||||
(
|
||||
json!({"vendor": v, "form_1099": "not_reportable", "reason": "x", "decided_by": by}),
|
||||
"reason_kind",
|
||||
),
|
||||
(
|
||||
json!({"vendor": v, "form_1099": "not_reportable", "reason_kind": "corporation", "decided_by": by}),
|
||||
"needs reason",
|
||||
),
|
||||
(
|
||||
json!({"vendor": v, "form_1099": "not_reportable", "reason_kind": "paid_by_card", "reason": "x", "decided_by": by}),
|
||||
"paid by card is not a reason",
|
||||
),
|
||||
(
|
||||
json!({"vendor": v, "form_1099": "not_reportable", "reason_kind": "other", "reason": "x", "decided_by": by}),
|
||||
"citation",
|
||||
),
|
||||
(
|
||||
json!({"vendor": v, "form_1099": "nec", "reason": "x", "decided_by": by}),
|
||||
"go with not_reportable",
|
||||
),
|
||||
(json!({"vendor": v, "form_1099": "nec"}), "decided_by"),
|
||||
(
|
||||
json!({"vendor": v, "form_1099": "nec", "effective": "2026-07-01", "decided_by": by}),
|
||||
"from the beginning",
|
||||
),
|
||||
(
|
||||
json!({"vendor": v, "form_1099": "not_reportable", "reason_kind": "corporation", "reason": "EIN 12-3456789", "decided_by": by}),
|
||||
"EIN",
|
||||
),
|
||||
(json!({"vendor": v}), "nothing to change"),
|
||||
(
|
||||
json!({"vendor": v, "reason": "x", "tin_on_file": true}),
|
||||
"go with form_1099",
|
||||
),
|
||||
(
|
||||
json!({"vendor": v, "form_1099": "1099-k", "decided_by": by}),
|
||||
"expected nec",
|
||||
),
|
||||
(
|
||||
json!({"vendor": "Nobody", "form_1099": "nec", "decided_by": by}),
|
||||
"vendor",
|
||||
),
|
||||
] {
|
||||
let why = m.refuse("books.set_vendor_1099", args.clone());
|
||||
assert!(why.contains(needle), "{args}: {why}");
|
||||
}
|
||||
assert_eq!(m.count("vendor.1099_set"), 0);
|
||||
assert_eq!(m.count("journal.format_set"), 0, "nothing declared either");
|
||||
}
|
||||
|
||||
/// P3 brief 1.5 and review R8: tin_on_file omitted keeps the W-9 flag;
|
||||
/// tin_on_file alone keeps the decision and who made it.
|
||||
#[test]
|
||||
fn set_vendor_1099_keeps_the_w9_flag_when_omitted() {
|
||||
let mut m = books("w9-flag");
|
||||
m.ok("books.add_vendor", json!({"name": "Harbor Design LLC"}));
|
||||
let v = "Harbor Design LLC";
|
||||
m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": v, "form_1099": "nec", "tin_on_file": true, "decided_by": "Riley Chen", "decided_on": "2026-03-02"}),
|
||||
);
|
||||
let out = m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": v, "form_1099": "misc", "decided_by": "Riley Chen", "decided_on": "2026-04-01"}),
|
||||
);
|
||||
assert_eq!(out["tin_on_file"], true, "omitted: kept");
|
||||
assert_eq!(out["form_1099"], "1099-MISC");
|
||||
let out = m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": v, "tin_on_file": false}),
|
||||
);
|
||||
assert_eq!(out["changed"], "unchanged");
|
||||
assert_eq!(out["tin_on_file"], false);
|
||||
assert_eq!(out["decision_1099"]["value"], "misc");
|
||||
assert_eq!(out["decision_1099"]["decided_on"], "2026-04-01");
|
||||
assert_eq!(m.count("vendor.1099_set"), 2);
|
||||
assert_eq!(m.count("vendor.w9_flag_set"), 1);
|
||||
// Already so: nothing journaled.
|
||||
m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": v, "tin_on_file": false}),
|
||||
);
|
||||
assert_eq!(m.count("vendor.w9_flag_set"), 1);
|
||||
}
|
||||
|
||||
/// P3 brief 4.4 and review R8: recording a W-9 (books.set_w9) marks the
|
||||
/// vendor's W-9 on file and keeps its 1099 decision with who made it and
|
||||
/// when, verbatim.
|
||||
#[test]
|
||||
fn set_w9_keeps_the_1099_decision_and_its_source() {
|
||||
let mut m = books("set-w9");
|
||||
m.add_party("Harbor Design LLC", &["contractor"]);
|
||||
m.ok("books.add_vendor", json!({"name": "Harbor Design LLC"}));
|
||||
m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Harbor Design LLC", "form_1099": "nec", "decided_by": "Riley Chen",
|
||||
"decided_on": "2026-01-05", "citation": "W-9 requested"}),
|
||||
);
|
||||
let before = decision(&mut m, "Harbor Design LLC")["decision_1099"].clone();
|
||||
m.ok(
|
||||
"books.set_w9",
|
||||
json!({"party": "Harbor Design LLC", "received_on": "2026-03-02",
|
||||
"entity_type": "LLC (partnership)", "tin_last4": "4321", "kept_at": "CPA portal"}),
|
||||
);
|
||||
let after = decision(&mut m, "Harbor Design LLC");
|
||||
assert_eq!(after["tin_on_file"], true);
|
||||
assert_eq!(after["decision_1099"], before);
|
||||
assert_eq!(m.count("vendor.1099_set"), 1, "no decision re-journaled");
|
||||
assert_eq!(m.count("vendor.w9_flag_set"), 1);
|
||||
}
|
||||
|
||||
/// A member's method on an account the books don't name as financing
|
||||
/// warns: the worksheet holds its untagged payments undetermined (never a
|
||||
/// card's), and the settings should say what the account is.
|
||||
#[test]
|
||||
fn a_members_method_off_the_financing_accounts_warns() {
|
||||
let mut m = books("member-method");
|
||||
m.ok(
|
||||
"books.add_account",
|
||||
json!({"code": "2110", "name": "Due to Riley", "account_type": "liability"}),
|
||||
);
|
||||
let out = m.ok(
|
||||
"books.add_instrument",
|
||||
json!({"kind": "member_card", "label": "Riley's Visa", "account": "2110"}),
|
||||
);
|
||||
let w = out["warnings"].as_array().unwrap();
|
||||
assert_eq!(w.len(), 1, "{out}");
|
||||
assert!(
|
||||
w[0].as_str()
|
||||
.unwrap()
|
||||
.contains("not one of the books' financing accounts"),
|
||||
"{out}"
|
||||
);
|
||||
let out = m.ok(
|
||||
"books.add_instrument",
|
||||
json!({"kind": "member_cash_or_check", "label": "Riley's checks", "account": "2100"}),
|
||||
);
|
||||
assert!(out["warnings"].as_array().unwrap().is_empty(), "{out}");
|
||||
}
|
||||
|
||||
/// B-27 and the format-upgrade gate (D-39): books.set_w9 on books that
|
||||
/// never used the vendor_1099_decisions feature journals the W-9 and then
|
||||
/// the vendor's W-9 flag, a line of that feature, so the feature is
|
||||
/// declared before the command's first line, never between its lines
|
||||
/// (where a refused upgrade would leave the W-9 recorded and the flag
|
||||
/// not).
|
||||
#[test]
|
||||
fn set_w9_declares_the_w9_flags_feature_before_its_first_line() {
|
||||
let mut m = books("set-w9-feature");
|
||||
m.add_party("Harbor Design LLC", &["contractor"]);
|
||||
m.ok("books.add_vendor", json!({"name": "Harbor Design LLC"}));
|
||||
let before = m.journal_types().len();
|
||||
m.ok(
|
||||
"books.set_w9",
|
||||
json!({"party": "Harbor Design LLC", "received_on": "2026-03-02",
|
||||
"entity_type": "LLC (partnership)", "tin_last4": "4321", "kept_at": "CPA portal"}),
|
||||
);
|
||||
let added = m.journal_types()[before..].to_vec();
|
||||
assert_eq!(
|
||||
added,
|
||||
["journal.format_set", "party.w9_set", "vendor.w9_flag_set"],
|
||||
"the declaration comes first"
|
||||
);
|
||||
assert_eq!(decision(&mut m, "Harbor Design LLC")["tin_on_file"], true);
|
||||
}
|
||||
|
||||
/// Defect 12 end to end, restarting between steps (P3 brief 8.2): payment
|
||||
/// methods added (and refused when unsound), a draft taking one, a posted
|
||||
/// entry and its reversal tagged together, and the worksheet showing what
|
||||
/// counts, what a card paid, and what is undetermined.
|
||||
#[test]
|
||||
fn payment_methods_round_trip_and_classify_payments() {
|
||||
let mut m = books("methods");
|
||||
m.add_party("Riley Chen", &["member"]);
|
||||
let added = m.ok(
|
||||
"books.add_instrument",
|
||||
json!({"kind": "bank", "label": "Harbor Bank checking", "last4": "1234", "account": "1000"}),
|
||||
);
|
||||
assert_eq!(added["card_or_network"], false);
|
||||
assert!(added["warnings"].as_array().unwrap().is_empty());
|
||||
m.ok(
|
||||
"books.add_instrument",
|
||||
json!({"kind": "business_card", "label": "Harbor Visa", "last4": "0042", "account": "2050"}),
|
||||
);
|
||||
let visa = m.ok(
|
||||
"books.add_instrument",
|
||||
json!({"kind": "member_card", "label": "Riley's Visa", "owner_party": "Riley Chen", "account": "2100"}),
|
||||
);
|
||||
assert_eq!(visa["card_or_network"], true);
|
||||
assert_eq!(visa["account"], "2100");
|
||||
m.ok(
|
||||
"books.add_instrument",
|
||||
json!({"kind": "member_cash_or_check", "label": "Riley's checks", "account": "2100"}),
|
||||
);
|
||||
for (args, needle) in [
|
||||
(
|
||||
json!({"kind": "card", "label": "x", "account": "2050"}),
|
||||
"kind",
|
||||
),
|
||||
(
|
||||
json!({"kind": "bank", "label": "Savings", "last4": "12345", "account": "1000"}),
|
||||
"four digits",
|
||||
),
|
||||
(
|
||||
json!({"kind": "bank", "label": "harbor visa", "account": "1000"}),
|
||||
"already named",
|
||||
),
|
||||
(
|
||||
json!({"kind": "bank", "label": "Capital", "account": "3000"}),
|
||||
"asset",
|
||||
),
|
||||
(
|
||||
json!({"kind": "bank", "label": "Nowhere", "account": "9999"}),
|
||||
"unknown account",
|
||||
),
|
||||
] {
|
||||
let why = m.refuse("books.add_instrument", args.clone());
|
||||
assert!(why.contains(needle), "{args}: {why}");
|
||||
}
|
||||
m.ok("books.add_vendor", json!({"name": "Dana Studio"}));
|
||||
m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Dana Studio", "form_1099": "nec", "decided_by": "Riley Chen"}),
|
||||
);
|
||||
let dana = json!({"vendor": "Dana Studio", "instrument": "Riley's Visa"});
|
||||
let e1 = m.post(
|
||||
"2026-05-01",
|
||||
dana,
|
||||
&[("6000", "debit", "2500.00"), ("2100", "credit", "2500.00")],
|
||||
);
|
||||
assert_eq!(
|
||||
m.read_only("books.get_entry", json!({"entry_id": e1}))["instrument"],
|
||||
"Riley's Visa"
|
||||
);
|
||||
let e2 = m.post(
|
||||
"2026-05-02",
|
||||
json!({"vendor": "Dana Studio"}),
|
||||
&[("6000", "debit", "3000.00"), ("2100", "credit", "3000.00")],
|
||||
);
|
||||
let e3 = m.post(
|
||||
"2026-05-03",
|
||||
json!({"vendor": "Dana Studio"}),
|
||||
&[("6000", "debit", "400.00"), ("2050", "credit", "400.00")],
|
||||
);
|
||||
let mut m = m.restart();
|
||||
let d = line_2026(&mut m, "Dana Studio");
|
||||
assert_eq!(
|
||||
(
|
||||
&d["paid"],
|
||||
&d["excluded_card"],
|
||||
&d["undetermined"],
|
||||
&d["status"]
|
||||
),
|
||||
(
|
||||
&json!("0.00"),
|
||||
&json!("2900.00"),
|
||||
&json!("3000.00"),
|
||||
&json!("undetermined")
|
||||
)
|
||||
);
|
||||
let w = m.read_only("books.vendor_1099", json!({"year": 2026}));
|
||||
let listed = |k: &str| -> Vec<(u64, String, Value)> {
|
||||
w[k].as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|p| {
|
||||
(
|
||||
p["entry_id"].as_u64().unwrap(),
|
||||
p["amount"].as_str().unwrap().to_string(),
|
||||
p["instrument"].clone(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(
|
||||
listed("excluded_card_payments"),
|
||||
[
|
||||
(e1, "2500.00".into(), json!("Riley's Visa")),
|
||||
(e3, "400.00".into(), Value::Null)
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
listed("undetermined_payments"),
|
||||
[(e2, "3000.00".into(), Value::Null)]
|
||||
);
|
||||
assert!(
|
||||
w["notes"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|n| n.as_str().unwrap().contains("books.set_entry_instrument")),
|
||||
"{w}"
|
||||
);
|
||||
// The member paid e2 by check, and it was reversed: tagging either
|
||||
// tags both.
|
||||
let rev = m.ok(
|
||||
"books.reverse_entry",
|
||||
json!({"entry_id": e2, "date": "2026-05-04", "description": "paid twice", "confirmed": true}),
|
||||
)["reversal_id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let out = m.ok(
|
||||
"books.set_entry_instrument",
|
||||
json!({"entry_id": e2, "instrument": "Riley's checks"}),
|
||||
);
|
||||
assert_eq!(out["changed"], true);
|
||||
assert_eq!(out["instrument"], "Riley's checks");
|
||||
assert!(out["warnings"].as_array().unwrap().is_empty());
|
||||
let mut m = m.restart();
|
||||
assert_eq!(
|
||||
m.read_only("books.get_entry", json!({"entry_id": rev}))["instrument"],
|
||||
"Riley's checks"
|
||||
);
|
||||
let d = line_2026(&mut m, "Dana Studio");
|
||||
assert_eq!(
|
||||
(&d["paid"], &d["undetermined"], &d["status"]),
|
||||
(&json!("0.00"), &json!("0.00"), &json!("under_threshold"))
|
||||
);
|
||||
let all = m.read_only("books.instruments", json!({}));
|
||||
let labels: Vec<(&str, bool)> = all["instruments"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|i| {
|
||||
(
|
||||
i["label"].as_str().unwrap(),
|
||||
i["card_or_network"].as_bool().unwrap(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
labels,
|
||||
[
|
||||
("Harbor Bank checking", false),
|
||||
("Harbor Visa", true),
|
||||
("Riley's Visa", true),
|
||||
("Riley's checks", false)
|
||||
]
|
||||
);
|
||||
// Cleared again: back to undetermined, both of the chain.
|
||||
m.ok("books.set_entry_instrument", json!({"entry_id": rev}));
|
||||
assert!(m.read_only("books.get_entry", json!({"entry_id": e2}))["instrument"].is_null());
|
||||
let p = m.party("Dana Studio", 2026);
|
||||
assert_eq!(p["totals"]["decision_1099"], "nec");
|
||||
assert_eq!(p["totals"]["form_1099"], "1099-NEC");
|
||||
}
|
||||
|
||||
/// D-40 and P3 review R15: a decision changed for payments in a closed
|
||||
/// period, or in a year whose 1099s were due (January 31 of the next
|
||||
/// year), warns, naming the year and the entries; one reaching only this
|
||||
/// year's open payments doesn't.
|
||||
#[test]
|
||||
fn a_change_reaching_a_filed_year_warns() {
|
||||
let mut m = books("warns");
|
||||
m.ok("books.add_vendor", json!({"name": "Harbor Design LLC"}));
|
||||
let v = "Harbor Design LLC";
|
||||
m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": v, "form_1099": "nec", "decided_by": "Riley Chen"}),
|
||||
);
|
||||
let harbor = json!({"vendor": v});
|
||||
let pay = |m: &mut Mcp, date: &str| {
|
||||
m.post(
|
||||
date,
|
||||
harbor.clone(),
|
||||
&[("6000", "debit", "700.00"), ("1000", "credit", "700.00")],
|
||||
)
|
||||
};
|
||||
let e2024 = pay(&mut m, "2024-03-01");
|
||||
let e2025 = pay(&mut m, "2025-03-01");
|
||||
pay(&mut m, "2026-03-01");
|
||||
let period = m.ok(
|
||||
"books.create_period",
|
||||
json!({"name": "FY2025", "start": "2025-01-01", "end": "2025-12-31"}),
|
||||
)["period_id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
m.ok(
|
||||
"books.close_period",
|
||||
json!({"period_id": period, "confirmed": true}),
|
||||
);
|
||||
// From July 2026 on: only an open, not-yet-due year's payments: quiet.
|
||||
let out = m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": v, "form_1099": "not_reportable", "reason_kind": "corporation",
|
||||
"reason": "elected to be taxed as a corporation", "effective": "2026-07-01",
|
||||
"decided_by": "Riley Chen"}),
|
||||
);
|
||||
assert!(out["warnings"].as_array().unwrap().is_empty(), "{out}");
|
||||
assert_eq!(out["decisions_1099"].as_array().unwrap().len(), 2);
|
||||
// From the beginning: reaches 2024 (1099s due 2025-01-31) and the
|
||||
// closed 2025.
|
||||
let out = m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": v, "form_1099": "not_reportable", "reason_kind": "corporation",
|
||||
"reason": "a corporation by name", "decided_by": "Riley Chen"}),
|
||||
);
|
||||
let warnings: Vec<String> = out["warnings"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|w| w.as_str().unwrap().to_string())
|
||||
.collect();
|
||||
assert_eq!(warnings.len(), 2, "{warnings:?}");
|
||||
assert!(warnings[0].contains("for 2024") && warnings[0].contains(&format!("entries {e2024}")));
|
||||
assert!(warnings[0].contains("due January 31"), "{}", warnings[0]);
|
||||
assert!(warnings[1].contains("for 2025") && warnings[1].contains("closed period"));
|
||||
assert!(warnings.iter().all(|w| w.contains("corrected")));
|
||||
// A payment method on the closed year's payment warns too.
|
||||
m.ok(
|
||||
"books.add_instrument",
|
||||
json!({"kind": "business_card", "label": "Harbor debit card", "account": "1000"}),
|
||||
);
|
||||
let out = m.ok(
|
||||
"books.set_entry_instrument",
|
||||
json!({"entry_id": e2025, "instrument": "Harbor debit card"}),
|
||||
);
|
||||
let w = out["warnings"].as_array().unwrap();
|
||||
assert_eq!(w.len(), 1, "{out}");
|
||||
assert!(w[0].as_str().unwrap().contains("closed period"));
|
||||
}
|
||||
@@ -783,9 +783,15 @@ fn happy_path(name: &str) -> Mcp {
|
||||
.unwrap();
|
||||
m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Landlord", "form_1099": "misc", "tin_on_file": true}),
|
||||
json!({"vendor": "Landlord", "form_1099": "misc", "tin_on_file": true, "decided_by": "Test Owner"}),
|
||||
);
|
||||
m.ok("books.list_vendors", json!({}));
|
||||
// Payment methods (plan P3, defect 12).
|
||||
m.ok(
|
||||
"books.add_instrument",
|
||||
json!({"kind": "business_card", "label": "Harbor Visa", "last4": "0042", "account": "1000"}),
|
||||
);
|
||||
m.ok("books.instruments", json!({}));
|
||||
m.ok(
|
||||
"books.add_party",
|
||||
json!({"name": "Pat", "roles": ["employee"]}),
|
||||
@@ -872,6 +878,12 @@ fn happy_path(name: &str) -> Mcp {
|
||||
"books.set_entry_party",
|
||||
json!({"entry_id": e2, "party": "Landlord"}),
|
||||
);
|
||||
// Paid with the debit card, then the tag cleared.
|
||||
m.ok(
|
||||
"books.set_entry_instrument",
|
||||
json!({"entry_id": e2, "instrument": "Harbor Visa"}),
|
||||
);
|
||||
m.ok("books.set_entry_instrument", json!({"entry_id": e2}));
|
||||
// Replace the receipt: attach new, unlink old, retire it.
|
||||
m.attach(Some(e2), b"rent receipt v2");
|
||||
m.ok(
|
||||
@@ -2154,7 +2166,7 @@ fn inv6_ambiguous_vendor_reference_refused() {
|
||||
let amb = acme.to_string();
|
||||
m.refuse(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": amb, "form_1099": "nec", "tin_on_file": false}),
|
||||
json!({"vendor": amb, "form_1099": "nec", "tin_on_file": false, "decided_by": "Test Owner"}),
|
||||
);
|
||||
m.refuse(
|
||||
"books.draft_entry",
|
||||
@@ -2169,7 +2181,7 @@ fn inv6_ambiguous_vendor_reference_refused() {
|
||||
// that name).
|
||||
m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Acme", "form_1099": "nec", "tin_on_file": false}),
|
||||
json!({"vendor": "Acme", "form_1099": "nec", "tin_on_file": false, "decided_by": "Test Owner"}),
|
||||
);
|
||||
let out = m.ok(
|
||||
"books.set_entry_vendor",
|
||||
|
||||
@@ -761,7 +761,7 @@ fn set_entry_party_tags_the_vendor_so_vendor_1099_counts_it() {
|
||||
m.ok("books.add_vendor", json!({"name": "Landlord LLC"}));
|
||||
m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Landlord LLC", "form_1099": "misc"}),
|
||||
json!({"vendor": "Landlord LLC", "form_1099": "misc", "decided_by": "Test Owner"}),
|
||||
);
|
||||
let rent = m.post(
|
||||
"2026-02-01",
|
||||
@@ -862,7 +862,7 @@ fn party_totals_match_a_hand_computed_year() {
|
||||
.unwrap();
|
||||
m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Acme Design", "form_1099": "nec"}),
|
||||
json!({"vendor": "Acme Design", "form_1099": "nec", "decided_by": "Test Owner"}),
|
||||
);
|
||||
let acme = json!({"party": "Acme Design"});
|
||||
// Last year: not in 2026.
|
||||
@@ -1122,7 +1122,7 @@ fn parties_lists_by_id_and_filters_by_role() {
|
||||
let mut m = books("list");
|
||||
let a = m.add_party("Acme Design", &["contractor"]);
|
||||
let i = m.add_party("IRS", &["tax_agency"]);
|
||||
let b = m.add_party("Bluevine", &["financial_institution"]);
|
||||
let b = m.add_party("Quillmere", &["financial_institution"]);
|
||||
let u = m.add_party("Utah Division of Corporations", &["government_agency"]);
|
||||
let all = m.read_only("books.parties", json!({}));
|
||||
assert!(all["year"].is_i64(), "defaults to this year");
|
||||
@@ -1284,7 +1284,7 @@ fn require_refuses_until_tagged_or_marked_none() {
|
||||
m.read_only("books.settings", json!({}))["parties"],
|
||||
"require"
|
||||
);
|
||||
let bank = m.add_party("Bluevine", &["financial_institution"]);
|
||||
let bank = m.add_party("Quillmere", &["financial_institution"]);
|
||||
let id = m.draft(
|
||||
"2026-03-01",
|
||||
json!({}),
|
||||
@@ -1323,18 +1323,18 @@ fn require_refuses_until_tagged_or_marked_none() {
|
||||
// Both at once is refused.
|
||||
let e = m.refuse(
|
||||
"books.draft_entry",
|
||||
json!({"date": "2026-03-03", "description": "x", "party": "Bluevine", "no_party_reason": "none"}),
|
||||
json!({"date": "2026-03-03", "description": "x", "party": "Quillmere", "no_party_reason": "none"}),
|
||||
);
|
||||
assert!(e.contains("no_party_reason"), "{e}");
|
||||
let e = m.refuse(
|
||||
"books.set_entry_party",
|
||||
json!({"entry_id": t, "party": "Bluevine", "no_party_reason": "none"}),
|
||||
json!({"entry_id": t, "party": "Quillmere", "no_party_reason": "none"}),
|
||||
);
|
||||
assert!(e.contains("not both"), "{e}");
|
||||
// Tagging later clears the mark; marking again needs no party.
|
||||
m.ok(
|
||||
"books.set_entry_party",
|
||||
json!({"entry_id": t, "party": "Bluevine"}),
|
||||
json!({"entry_id": t, "party": "Quillmere"}),
|
||||
);
|
||||
m.ok(
|
||||
"books.set_entry_party",
|
||||
|
||||
@@ -639,6 +639,27 @@ fn defect24_creditor_of_round_trips_and_a_card_fee_is_not_a_payment() {
|
||||
assert_eq!(m.ok("books.verify", json!({}))["status"], "ok");
|
||||
}
|
||||
|
||||
/// rules.md B-27: `books.update_party` declares the `party_creditors`
|
||||
/// feature before the first line it writes, never between them: a name
|
||||
/// change with the first creditor is journaled as the declaration, then
|
||||
/// the name, then the creditor (a refused format upgrade then leaves
|
||||
/// nothing half done).
|
||||
#[test]
|
||||
fn update_party_declares_the_creditor_feature_before_its_first_line() {
|
||||
let mut m = books("feature-first");
|
||||
let start = m.journal_types().len();
|
||||
let p = m.ok(
|
||||
"books.update_party",
|
||||
json!({"party": "Harbor Bank", "notes": "the card's issuer", "creditor_of": ["2050"]}),
|
||||
);
|
||||
assert_eq!(p["creditor_of"], json!(["2050"]));
|
||||
assert_eq!(
|
||||
m.journal_types()[start..].to_vec(),
|
||||
["journal.format_set", "party.updated", "party.creditor_set"],
|
||||
);
|
||||
assert_eq!(m.ok("books.verify", json!({}))["status"], "ok");
|
||||
}
|
||||
|
||||
/// What `books.post_entry` previews is what it posts: each party's
|
||||
/// `resolved`, `direction`, `cash` and `in_kind` in the preview are
|
||||
/// `books.get_entry`'s once confirmed (a derived split is then journaled as
|
||||
|
||||
@@ -427,7 +427,7 @@ fn books_with(name: &str, extra: &str) -> Mcp {
|
||||
m.ok("books.add_vendor", json!({"name": "Acme Design"}));
|
||||
m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Acme Design", "form_1099": "nec"}),
|
||||
json!({"vendor": "Acme Design", "form_1099": "nec", "decided_by": "Test Owner"}),
|
||||
);
|
||||
m.add_party("Rent Co", &["landlord"]);
|
||||
m.books_dir = Some(dir);
|
||||
@@ -1019,7 +1019,7 @@ fn the_1099_worksheet_carries_full_tins_only_when_asked() {
|
||||
m.ok("books.add_vendor", json!({"name": "Rent Co"}));
|
||||
m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Rent Co", "form_1099": "misc"}),
|
||||
json!({"vendor": "Rent Co", "form_1099": "misc", "decided_by": "Test Owner"}),
|
||||
);
|
||||
m.w9(
|
||||
"Rent Co",
|
||||
@@ -1072,7 +1072,9 @@ fn the_1099_worksheet_carries_full_tins_only_when_asked() {
|
||||
assert_eq!(out["csv"]["mime_type"], "text/csv");
|
||||
let text = String::from_utf8(handed(&out["csv"])).unwrap();
|
||||
assert!(text.contains(SSN), "{text}");
|
||||
assert!(text.starts_with("vendor_id,name,form_1099,paid,reportable,w9_on_file,tin_last4,tin"));
|
||||
assert!(text.starts_with(
|
||||
"vendor_id,name,decision_1099,form_1099,reason_kind,reason,decided_by,decided_on,effective,decisions_in_year,paid,excluded_card,undetermined,not_reportable,status,reportable,w9_on_file,tin_last4,tin,tin_type,tin_note\n"
|
||||
));
|
||||
let rows = csv_rows(text.as_bytes());
|
||||
let acme = rows.iter().find(|r| r["name"] == "Acme Design").unwrap();
|
||||
assert_eq!(acme["tin"], SSN);
|
||||
@@ -1087,7 +1089,7 @@ fn the_1099_worksheet_carries_full_tins_only_when_asked() {
|
||||
assert!(!holds_tin(&bytes, SSN_DIGITS));
|
||||
let text = String::from_utf8(bytes).unwrap();
|
||||
assert!(
|
||||
text.starts_with("vendor_id,name,form_1099,paid,reportable,w9_on_file,tin_last4\n"),
|
||||
text.starts_with("vendor_id,name,decision_1099,form_1099,reason_kind,reason,decided_by,decided_on,effective,decisions_in_year,paid,excluded_card,undetermined,not_reportable,status,reportable,w9_on_file,tin_last4\n"),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
@@ -1190,7 +1192,7 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() {
|
||||
m.ok("books.add_vendor", json!({"name": "Rent Co"}));
|
||||
m.ok(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Rent Co", "form_1099": "misc"}),
|
||||
json!({"vendor": "Rent Co", "form_1099": "misc", "decided_by": "Test Owner"}),
|
||||
);
|
||||
m.pay("Acme Design", "2500.00");
|
||||
m.pay("Rent Co", "3000.00");
|
||||
@@ -1802,7 +1804,7 @@ fn the_cpa_package_carries_the_k1_and_1099_worksheets_with_tins() {
|
||||
}
|
||||
// The 1099 worksheet: the payee's full TIN.
|
||||
let w = csv_rows(&pkg["1099-worksheet-2026.csv"]);
|
||||
let acme = w.iter().find(|r| r["vendor"] == "Acme Design").unwrap();
|
||||
let acme = w.iter().find(|r| r["name"] == "Acme Design").unwrap();
|
||||
assert_eq!(acme["tin"], EIN);
|
||||
assert_eq!(acme["tin_type"], "ein");
|
||||
assert_eq!(acme["tin_last4"], "9105");
|
||||
@@ -2100,6 +2102,10 @@ fn idempotent_tools_change_nothing_when_repeated() {
|
||||
.to_string();
|
||||
let settings_file =
|
||||
std::fs::read_to_string(m.data.parent().unwrap().join("wanderbooks.toml")).unwrap();
|
||||
m.ok(
|
||||
"books.add_instrument",
|
||||
json!({"kind": "member_card", "label": "Riley's Visa", "account": "1000"}),
|
||||
);
|
||||
let calls: Vec<(&str, Value)> = vec![
|
||||
("books.verify", json!({"blobs": true})),
|
||||
(
|
||||
@@ -2118,12 +2124,16 @@ fn idempotent_tools_change_nothing_when_repeated() {
|
||||
("books.run_recurring", json!({"through": "2026-03-15"})),
|
||||
(
|
||||
"books.set_vendor_1099",
|
||||
json!({"vendor": "Acme Design", "form_1099": "misc"}),
|
||||
json!({"vendor": "Acme Design", "form_1099": "misc", "decided_by": "Test Owner"}),
|
||||
),
|
||||
(
|
||||
"books.set_entry_vendor",
|
||||
json!({"entry_id": entry, "vendor": "Acme Design"}),
|
||||
),
|
||||
(
|
||||
"books.set_entry_instrument",
|
||||
json!({"entry_id": entry, "instrument": "Riley's Visa"}),
|
||||
),
|
||||
(
|
||||
"books.label_asset",
|
||||
json!({"asset": asset.to_string(), "serial": "SN-1"}),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -15,18 +15,20 @@
|
||||
//! XLSX, for the fiscal year containing as_of, through as_of; the
|
||||
//! fixed asset register when an asset was bought by as_of; the
|
||||
//! general ledger and journal (XLSX); the journal through as_of as
|
||||
//! IIF; the 1099 worksheet for as_of's calendar year through as_of
|
||||
//! (or why it can't be made); with members, the K-1 worksheet; a
|
||||
//! README first.
|
||||
//! IIF; the 1099 worksheet for as_of's calendar year through as_of,
|
||||
//! books.vendor_1099's for the package's cash and financing accounts
|
||||
//! (INVARIANT 7), or why it can't be made; with members, the K-1
|
||||
//! worksheet; a README first.
|
||||
//! 2. With members, k1-members.csv has one row per member: name, party,
|
||||
//! share, capital account, TIN and type (or last four without a TIN
|
||||
//! source), and the K-1 worksheet's capital figures (ledger_core::k1)
|
||||
//! for the fiscal year to date; the members' total row with more than
|
||||
//! one. The 1099 worksheet adds each payee's party, last four, TIN,
|
||||
//! and type with a TIN source.
|
||||
//! one. The 1099 worksheet is books.vendor_1099's CSV
|
||||
//! (crate::vendor_1099_csv), each payee's TIN and type added with a
|
||||
//! TIN source.
|
||||
//! 3. A TIN the source can't give never fails the package: the row says
|
||||
//! why, and so do the README and tin_gaps (members, and 1099 payees
|
||||
//! that are reportable).
|
||||
//! a 1099 is or may be due for).
|
||||
//! 4. With include_receipts: every current file behind the year's
|
||||
//! posted entries, filed on them or linked to them, once per entry,
|
||||
//! each with a row in receipts/index.csv; and the company's
|
||||
@@ -62,7 +64,7 @@ use crate::reports::{
|
||||
};
|
||||
use crate::text::{csv_escape, money, share_text};
|
||||
use crate::tins::{TinSource, tin_type_key};
|
||||
use crate::vendor_1099::vendor_1099_report;
|
||||
use crate::vendor_1099::{vendor_1099_csv, vendor_1099_csv_name, vendor_1099_worksheet};
|
||||
|
||||
/// What the package is built for.
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -95,8 +97,8 @@ pub struct Package {
|
||||
pub files: Vec<(String, Vec<u8>)>,
|
||||
/// The zip.
|
||||
pub zip: Vec<u8>,
|
||||
/// With a TIN source: each member or reportable 1099 payee whose TIN
|
||||
/// the package lacks, and why (INVARIANT 3).
|
||||
/// With a TIN source: each member, or 1099 payee a 1099 is or may be
|
||||
/// due for, whose TIN the package lacks, and why (INVARIANT 3).
|
||||
pub tin_gaps: Vec<String>,
|
||||
/// The receipts in receipts/ (one per entry they are behind).
|
||||
pub receipt_count: usize,
|
||||
@@ -138,16 +140,8 @@ pub fn build_package<B: PackageBooks + ?Sized>(
|
||||
let pdf = |st: &ledger_import::Statement| statement_pdf(st, &meta).map_err(|e| e.to_string());
|
||||
let members = report_members(company);
|
||||
// One list of each per package (INVARIANT 7).
|
||||
let cash_codes = if req.cash_accounts.is_empty() {
|
||||
settings.cash_accounts
|
||||
} else {
|
||||
req.cash_accounts
|
||||
};
|
||||
let financing_codes = if req.financing_accounts.is_empty() {
|
||||
settings.financing_accounts
|
||||
} else {
|
||||
req.financing_accounts
|
||||
};
|
||||
let payments = settings.with_accounts(req.cash_accounts, req.financing_accounts);
|
||||
let (cash_codes, financing_codes) = (payments.cash_accounts, payments.financing_accounts);
|
||||
|
||||
// The financial statements, each as PDF and XLSX.
|
||||
let mut statements: Vec<(&str, ledger_import::Statement)> = vec![
|
||||
@@ -373,68 +367,29 @@ pub fn build_package<B: PackageBooks + ?Sized>(
|
||||
));
|
||||
// 1099 worksheet for the calendar year of as_of, through as_of
|
||||
// (INVARIANT 6): the payments from January 1, out of the package's
|
||||
// cash and financing accounts (INVARIANT 7).
|
||||
// cash and financing accounts (INVARIANT 7), the same worksheet and
|
||||
// CSV books.vendor_1099 and the viewer hand over for those accounts
|
||||
// and that day (one builder, one figure).
|
||||
let year = chrono::Datelike::year(&as_of);
|
||||
let mut worksheet_1099 = false;
|
||||
let payments = Settings {
|
||||
cash_accounts: cash_codes,
|
||||
financing_accounts: financing_codes,
|
||||
..*settings
|
||||
};
|
||||
match vendor_1099_report(ledger, &payments, year, Some(as_of), None) {
|
||||
Ok(r1099) => {
|
||||
let mut w = String::from("vendor,form,w9_on_file,paid,reportable");
|
||||
if tins.is_some() {
|
||||
w.push_str(",party_id,tin_last4,tin,tin_type,tin_note");
|
||||
}
|
||||
w.push('\n');
|
||||
for v in &r1099.vendors {
|
||||
w.push_str(&format!(
|
||||
"\"{}\",{},{},{},{}",
|
||||
v.name.replace('"', "\"\""),
|
||||
v.form.map(|f| f.label()).unwrap_or(""),
|
||||
v.tin_on_file,
|
||||
money(v.paid),
|
||||
v.reportable
|
||||
));
|
||||
// The payee's full TIN from the source (INVARIANT 2).
|
||||
if let Some(tins) = tins {
|
||||
let party = ledger.party_of_vendor(v.vendor_id);
|
||||
let last4 = party
|
||||
.and_then(|id| ledger.get_party(id))
|
||||
.and_then(|p| p.tin_last4())
|
||||
.map(str::to_string);
|
||||
let got = match party {
|
||||
None => Err("TIN not in the vault: no party for this vendor".into()),
|
||||
Some(id) => tins(id),
|
||||
};
|
||||
let (tin, kind, note) = match got {
|
||||
Ok(t) => (
|
||||
t.formatted(),
|
||||
tin_type_key(t.kind).to_string(),
|
||||
String::new(),
|
||||
),
|
||||
Err(why) => {
|
||||
if v.reportable {
|
||||
tin_gaps.push(format!("1099 payee {}: {why}", v.name));
|
||||
}
|
||||
(String::new(), String::new(), why)
|
||||
}
|
||||
};
|
||||
w.push_str(&format!(
|
||||
",{},{},{tin},{kind},{}",
|
||||
party.map(|id| id.to_string()).unwrap_or_default(),
|
||||
last4.unwrap_or_default(),
|
||||
csv_escape(¬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<B: PackageBooks + ?Sized>(
|
||||
},
|
||||
worksheet = if worksheet_1099 {
|
||||
format!(
|
||||
"1099-worksheet-{year}.csv: cash paid per vendor marked for a 1099{}, against the year's threshold{}.\n",
|
||||
"1099-worksheet-{year}.csv: each vendor paid in {year}{} or decided for a 1099: what counts toward its 1099 against the year's threshold, what was paid by card or payment network (reported by the processor on Form 1099-K, Treas. Reg. §1.6041-1(a)(1)(iv)), what can't be classified until its payment method is recorded, and its 1099 decision with the reason, who made it, and when it took effect{}.\n",
|
||||
if year_end {
|
||||
String::new()
|
||||
} else {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -1,26 +1,36 @@
|
||||
//! The 1099 worksheet: what `books.vendor_1099` answers and hands over as
|
||||
//! CSV, and what the viewer saves, with each payee's TIN when asked.
|
||||
//! CSV, what the CPA package carries, and what the viewer saves and shows,
|
||||
//! with each payee's TIN when asked.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: the CPA gets one 1099 worksheet, the same from the server and
|
||||
//! the viewer: cash paid per vendor, who is reportable, who lacks a
|
||||
//! W-9, and each payee's TIN.
|
||||
//! SOURCE: ledger_core::form1099 (payments out of the cash and financing
|
||||
//! accounts; an entry's shares resolved with the members); IRS
|
||||
//! GOAL: the CPA gets one 1099 worksheet, the same from the server, the
|
||||
//! package and the viewer: per vendor what counts toward its 1099,
|
||||
//! what a card or payment network paid, what is unknown, its 1099
|
||||
//! decision with the reason and who made it, who lacks a W-9, and
|
||||
//! each payee's TIN.
|
||||
//! SOURCE: ledger_core::form1099 (the one report: payments out of the cash
|
||||
//! and financing accounts, classified by payment method; an entry's
|
||||
//! shares resolved with the members; dated decisions); IRS
|
||||
//! thresholds (rules.md B-19: never guessed); RFC 4180 (CSV).
|
||||
//! INVARIANTS:
|
||||
//! 1. Payments come from the settings' cash and financing accounts
|
||||
//! (codes the chart lacks are skipped), an entry's shares resolved
|
||||
//! with the company's members.
|
||||
//! 1. Payments come from the cash and financing accounts of the
|
||||
//! `Settings` given (codes the chart lacks are skipped): the books'
|
||||
//! settings, or the CPA package's and `books.vendor_1099`'s request
|
||||
//! accounts (`Settings::with_accounts`), an entry's shares resolved
|
||||
//! with the company's members; with no cash accounts named,
|
||||
//! refused.
|
||||
//! 2. A year whose threshold isn't known is refused, never guessed.
|
||||
//! 3. Full TINs only from the TIN source given (crate INVARIANT 2): with
|
||||
//! one, every line carries its TIN or a note why not; without one,
|
||||
//! none does.
|
||||
//! 4. The CSV has a header and one row per line, in the worksheet's
|
||||
//! order; the TIN columns only when asked.
|
||||
//! 5. The notes are quiet: the untagged entries are one count and a
|
||||
//! tool hint, each list of payments one line.
|
||||
//! ```
|
||||
|
||||
use ledger_core::{EntryId, Ledger, PartyId, VendorId};
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::{Decision1099, EntryId, Ledger, PartyId, Payment1099, Status1099, VendorId};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
use crate::reports::Settings;
|
||||
@@ -43,29 +53,29 @@ pub fn payment_accounts(ledger: &Ledger, settings: &Settings<'_>) -> ledger_core
|
||||
}
|
||||
}
|
||||
|
||||
/// The 1099 report for `year`, the one every tool, the worksheet, and the
|
||||
/// CPA package use: payments out of [`payment_accounts`], an entry's
|
||||
/// shares resolved with the settings' members. Given `through`, only the
|
||||
/// payments from January 1 through it (the CPA package as of a day,
|
||||
/// rules.md B-33).
|
||||
/// The 1099 report for `year`, the one every tool, the worksheet, the
|
||||
/// CPA package and the viewer use: payments out of [`payment_accounts`],
|
||||
/// an entry's shares resolved with the settings' members. Given
|
||||
/// `through`, only the payments from January 1 through it (the CPA
|
||||
/// package as of a day, rules.md B-33).
|
||||
pub fn vendor_1099_report(
|
||||
ledger: &Ledger,
|
||||
settings: &Settings<'_>,
|
||||
year: i32,
|
||||
through: Option<chrono::NaiveDate>,
|
||||
threshold: Option<Decimal>,
|
||||
through: Option<NaiveDate>,
|
||||
) -> Result<ledger_core::Vendor1099Report, ledger_core::LedgerError> {
|
||||
ledger_core::vendor_1099_report_with_members(
|
||||
ledger_core::vendor_1099_report(
|
||||
ledger,
|
||||
year,
|
||||
through,
|
||||
threshold,
|
||||
&payment_accounts(ledger, settings),
|
||||
&settings.company.party_members(ledger),
|
||||
through,
|
||||
)
|
||||
}
|
||||
|
||||
/// A 1099 form's label ("1099-NEC", "1099-MISC"), if the vendor is marked.
|
||||
/// A 1099 form's label ("1099-NEC", "1099-MISC"), if the vendor files one.
|
||||
pub fn form_1099_label(form: Option<ledger_core::Form1099>) -> Option<String> {
|
||||
form.map(|f| f.label().to_string())
|
||||
}
|
||||
@@ -77,18 +87,41 @@ pub struct Vendor1099WorksheetLine {
|
||||
pub vendor_id: VendorId,
|
||||
/// Its name.
|
||||
pub name: String,
|
||||
/// "1099-NEC", "1099-MISC", or none.
|
||||
/// "1099-NEC", "1099-MISC", or none: the form its year-end decision
|
||||
/// files.
|
||||
pub form_1099: Option<String>,
|
||||
/// Its decision at the end of the year: "nec", "misc",
|
||||
/// "not_reportable" or "undetermined".
|
||||
pub decision_1099: String,
|
||||
/// The decision itself, with its reason when not reportable.
|
||||
pub decision: Decision1099,
|
||||
/// Who decided it and when (none: never decided, or decided before
|
||||
/// decisions were kept).
|
||||
pub decided: Option<ledger_core::settings::Source>,
|
||||
/// The date it took effect (none: from the beginning).
|
||||
pub effective: Option<NaiveDate>,
|
||||
/// Every decision in force during the year, in date order.
|
||||
pub decisions: Vec<ledger_core::Dated1099>,
|
||||
/// W-9 on file.
|
||||
pub tin_on_file: bool,
|
||||
/// Cash paid in the year.
|
||||
/// What counts toward its 1099.
|
||||
pub paid: Decimal,
|
||||
/// Marked for a 1099 and paid at least the threshold.
|
||||
/// Paid by card or payment network: reported on Form 1099-K.
|
||||
pub excluded_card: Decimal,
|
||||
/// Paid with no payment method recorded where it matters.
|
||||
pub undetermined: Decimal,
|
||||
/// Paid while it was decided not reportable.
|
||||
pub not_reportable: Decimal,
|
||||
/// A decision is needed for some of the year's payments.
|
||||
pub decision_needed: bool,
|
||||
/// Where it stands.
|
||||
pub status: Status1099,
|
||||
/// File a 1099 (status reportable).
|
||||
pub reportable: bool,
|
||||
/// The vendor's party.
|
||||
pub party_id: Option<PartyId>,
|
||||
/// The TIN's last four, from the party's W-9 facts, else its TIN
|
||||
/// facts.
|
||||
/// The TIN's last four ([`ledger_core::Party::tin_last4`]: its TIN
|
||||
/// facts, else its W-9 facts).
|
||||
pub tin_last4: Option<String>,
|
||||
/// The full TIN, formatted (123-45-6789, 12-3456789): only with a TIN
|
||||
/// source, and only when it has one.
|
||||
@@ -99,6 +132,17 @@ pub struct Vendor1099WorksheetLine {
|
||||
pub tin_note: Option<String>,
|
||||
}
|
||||
|
||||
impl Vendor1099WorksheetLine {
|
||||
/// Whether a 1099 may be due: reportable, or undetermined while decided
|
||||
/// for one some time in the year (P3 review R16: its W-9 and TIN
|
||||
/// matter as much).
|
||||
pub fn may_file(&self) -> bool {
|
||||
self.reportable
|
||||
|| (self.status == Status1099::Undetermined
|
||||
&& self.decisions.iter().any(|d| d.decision.form().is_some()))
|
||||
}
|
||||
}
|
||||
|
||||
/// The 1099 worksheet for a calendar year.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Vendor1099Worksheet {
|
||||
@@ -106,58 +150,145 @@ pub struct Vendor1099Worksheet {
|
||||
pub year: i32,
|
||||
/// The threshold applied.
|
||||
pub threshold: Decimal,
|
||||
/// One line per vendor marked for a 1099 or paid in the year, by name.
|
||||
/// The last day counted, when it stops before December 31.
|
||||
pub through: Option<NaiveDate>,
|
||||
/// One line per vendor decided for a 1099 or paid in the year, by name.
|
||||
pub lines: Vec<Vendor1099WorksheetLine>,
|
||||
/// Reportable vendors with no W-9 on file.
|
||||
/// Vendors a 1099 is or may be due for with no W-9 on file (" (may be
|
||||
/// reportable)" after those undetermined).
|
||||
pub missing_w9: Vec<String>,
|
||||
/// Entries naming several parties whose shares don't resolve (each
|
||||
/// counted in full toward its vendor).
|
||||
pub unresolved: Vec<EntryId>,
|
||||
/// Payments by card or payment network, excluded.
|
||||
pub excluded_card_payments: Vec<Payment1099>,
|
||||
/// Payments whose method must be recorded before they can be
|
||||
/// classified.
|
||||
pub undetermined_payments: Vec<Payment1099>,
|
||||
/// Money received from a vendor for something else, not netted.
|
||||
pub other_receipts: Vec<Payment1099>,
|
||||
/// Posted entries that paid money out with no vendor tag.
|
||||
pub untagged_payments: Vec<EntryId>,
|
||||
/// What to check: what counts, exemptions, the threshold's indexing,
|
||||
/// untagged entries, unresolved shares.
|
||||
/// untagged entries, card payments, unknown methods, decisions needed,
|
||||
/// unresolved shares.
|
||||
pub notes: Vec<String>,
|
||||
/// Whether the lines carry TINs from a TIN source.
|
||||
pub tins_included: bool,
|
||||
}
|
||||
|
||||
/// The 1099 worksheet for `year` (threshold: the IRS figure to use when
|
||||
/// the year's isn't known, else none), each line with its full TIN from
|
||||
/// `tins` when given (INVARIANT 3).
|
||||
pub fn vendor_1099_worksheet(
|
||||
ledger: &Ledger,
|
||||
settings: &Settings<'_>,
|
||||
year: i32,
|
||||
threshold: Option<Decimal>,
|
||||
tins: Option<TinSource<'_>>,
|
||||
) -> Result<Vendor1099Worksheet, String> {
|
||||
// A year past what wanderbooks knows is refused with guidance
|
||||
// (research or ask, pass `threshold`, then update the rule).
|
||||
let r =
|
||||
vendor_1099_report(ledger, settings, year, None, threshold).map_err(|e| e.to_string())?;
|
||||
let missing_w9 = r
|
||||
.vendors
|
||||
/// "entry 4" or "entries 4, 9".
|
||||
fn entries_text(ids: &[EntryId]) -> String {
|
||||
let list = ids
|
||||
.iter()
|
||||
.filter(|v| v.reportable && !v.tin_on_file)
|
||||
.map(|v| v.name.clone())
|
||||
.collect();
|
||||
let untagged = ledger
|
||||
.posted_entries()
|
||||
.filter(|e| {
|
||||
chrono::Datelike::year(&e.date()) == year && ledger.entry_vendor(e.id()).is_none()
|
||||
})
|
||||
.count();
|
||||
.map(|id| id.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
if ids.len() == 1 {
|
||||
format!("entry {list}")
|
||||
} else {
|
||||
format!("entries {list}")
|
||||
}
|
||||
}
|
||||
|
||||
/// "1 payment" or "3 payments", with their total.
|
||||
fn payments_text(list: &[Payment1099]) -> String {
|
||||
let total: Decimal = list.iter().map(|p| p.amount).sum();
|
||||
let n = list.len();
|
||||
format!(
|
||||
"{n} payment{} totaling {}",
|
||||
if n == 1 { "" } else { "s" },
|
||||
money(total)
|
||||
)
|
||||
}
|
||||
|
||||
/// The worksheet's notes (INVARIANT 5).
|
||||
fn notes_for(ledger: &Ledger, r: &ledger_core::Vendor1099Report) -> Vec<String> {
|
||||
let year = r.year;
|
||||
let name = |id: VendorId| {
|
||||
ledger
|
||||
.get_vendor(id)
|
||||
.map_or_else(|| format!("vendor {id}"), |v| v.name.clone())
|
||||
};
|
||||
let mut notes = vec![
|
||||
"Only entries tagged with a vendor count (books.set_entry_vendor; books.pay_bill tags automatically).".to_string(),
|
||||
"Corporations are generally exempt, except attorneys' fees and medical payments; royalties report at $10. Confirm with the CPA.".to_string(),
|
||||
"Each vendor's 1099 decision says why when none is due (a corporation, the United States or a state: Treas. Reg. §1.6041-3); payments to a corporation for legal or medical services stay reportable, and royalties report at $10. Confirm with the CPA.".to_string(),
|
||||
];
|
||||
if let Some(t) = r.through {
|
||||
notes.push(format!(
|
||||
"Payments from {year}-01-01 through {t} only: later payments in {year} aren't counted."
|
||||
));
|
||||
}
|
||||
if year > 2026 {
|
||||
notes.push(format!(
|
||||
"The $2,000 threshold is indexed for inflation after 2026; confirm the {year} amount and pass threshold to override."
|
||||
));
|
||||
}
|
||||
if untagged > 0 {
|
||||
if !r.excluded_card_payments.is_empty() {
|
||||
notes.push(format!(
|
||||
"{untagged} posted entries in {year} have no vendor tag; review any that paid contractors or rent."
|
||||
"{} by card or payment network are excluded: the processor reports them on Form 1099-K, whoever's card it was (Treas. Reg. §1.6041-1(a)(1)(iv)).",
|
||||
payments_text(&r.excluded_card_payments)
|
||||
));
|
||||
}
|
||||
if !r.undetermined_payments.is_empty() {
|
||||
let ids: Vec<EntryId> = r.undetermined_payments.iter().map(|p| p.entry_id).collect();
|
||||
notes.push(format!(
|
||||
"{} through a financing account (or a member's or a payment platform's) have no payment method recorded ({}), so whether they count is undetermined: a card or the platform's goods-and-services payment is excluded, cash, a check or a friends-and-family transfer counts. Record how each was paid with books.set_entry_instrument (books.instruments lists the methods).",
|
||||
payments_text(&r.undetermined_payments),
|
||||
entries_text(&ids)
|
||||
));
|
||||
}
|
||||
let undecided: Vec<&str> = r
|
||||
.vendors
|
||||
.iter()
|
||||
.filter(|v| v.decision_needed)
|
||||
.map(|v| v.name.as_str())
|
||||
.collect();
|
||||
if !undecided.is_empty() {
|
||||
notes.push(format!(
|
||||
"No 1099 decision for payments that could count: {}. Record one with books.set_vendor_1099.",
|
||||
undecided.join(", ")
|
||||
));
|
||||
}
|
||||
for v in &r.vendors {
|
||||
let mut forms: Vec<&'static str> = v
|
||||
.decisions
|
||||
.iter()
|
||||
.filter_map(|d| d.decision.form().map(|f| f.label()))
|
||||
.collect();
|
||||
forms.dedup();
|
||||
if forms.len() > 1 {
|
||||
notes.push(format!(
|
||||
"{}'s 1099 form changed during {year} ({}): each payment goes on the form for what it paid for. Confirm with the CPA.",
|
||||
v.name,
|
||||
forms.join(", then ")
|
||||
));
|
||||
}
|
||||
}
|
||||
for p in &r.other_receipts {
|
||||
// Money in is negative; its reversal gives it back.
|
||||
let what = if p.amount < Decimal::ZERO {
|
||||
format!("received {} from", money(-p.amount))
|
||||
} else {
|
||||
format!("reversed {} received from", money(p.amount))
|
||||
};
|
||||
notes.push(format!(
|
||||
"Entry {} {what} {} for something else (a sale, its payment of an invoice, a disposal, a loan): not netted against its payments.",
|
||||
p.entry_id,
|
||||
name(p.vendor_id)
|
||||
));
|
||||
}
|
||||
let untagged = &r.untagged_payments;
|
||||
if !untagged.is_empty() {
|
||||
let n = untagged.len();
|
||||
let which = if n <= 10 {
|
||||
format!(" ({})", entries_text(untagged))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
notes.push(format!(
|
||||
"{n} posted entr{} in {year} paid money out with no vendor tag{which}; tag any that paid a contractor or landlord with books.set_entry_vendor.",
|
||||
if n == 1 { "y" } else { "ies" }
|
||||
));
|
||||
}
|
||||
if !r.unresolved.is_empty() {
|
||||
@@ -170,8 +301,28 @@ pub fn vendor_1099_worksheet(
|
||||
.join(", ")
|
||||
));
|
||||
}
|
||||
notes
|
||||
}
|
||||
|
||||
/// The 1099 worksheet for `year` (threshold: the IRS figure to use when
|
||||
/// the year's isn't known, else none), entries dated after `through`
|
||||
/// left out, each line with its full TIN from `tins` when given
|
||||
/// (INVARIANT 3).
|
||||
pub fn vendor_1099_worksheet(
|
||||
ledger: &Ledger,
|
||||
settings: &Settings<'_>,
|
||||
year: i32,
|
||||
threshold: Option<Decimal>,
|
||||
through: Option<NaiveDate>,
|
||||
tins: Option<TinSource<'_>>,
|
||||
) -> Result<Vendor1099Worksheet, String> {
|
||||
// A year past what wanderbooks knows is refused with guidance
|
||||
// (research or ask, pass `threshold`, then update the rule).
|
||||
let r = vendor_1099_report(ledger, settings, year, threshold, through)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let notes = notes_for(ledger, &r);
|
||||
let mut lines = Vec::new();
|
||||
for v in r.vendors {
|
||||
for v in &r.vendors {
|
||||
let party_id = ledger.party_of_vendor(v.vendor_id);
|
||||
let tin_last4 = party_id
|
||||
.and_then(|id| ledger.get_party(id))
|
||||
@@ -179,10 +330,20 @@ pub fn vendor_1099_worksheet(
|
||||
.map(str::to_string);
|
||||
let mut line = Vendor1099WorksheetLine {
|
||||
vendor_id: v.vendor_id,
|
||||
name: v.name,
|
||||
form_1099: form_1099_label(v.form),
|
||||
name: v.name.clone(),
|
||||
form_1099: form_1099_label(v.form()),
|
||||
decision_1099: v.decision.key().to_string(),
|
||||
decision: v.decision.clone(),
|
||||
decided: v.decided.clone(),
|
||||
effective: v.effective,
|
||||
decisions: v.decisions.clone(),
|
||||
tin_on_file: v.tin_on_file,
|
||||
paid: v.paid,
|
||||
excluded_card: v.excluded_card,
|
||||
undetermined: v.undetermined,
|
||||
not_reportable: v.not_reportable,
|
||||
decision_needed: v.decision_needed,
|
||||
status: v.status,
|
||||
reportable: v.reportable,
|
||||
party_id,
|
||||
tin_last4,
|
||||
@@ -202,32 +363,94 @@ pub fn vendor_1099_worksheet(
|
||||
}
|
||||
lines.push(line);
|
||||
}
|
||||
let missing_w9 = lines
|
||||
.iter()
|
||||
.filter(|l| l.may_file() && !l.tin_on_file)
|
||||
.map(|l| {
|
||||
if l.reportable {
|
||||
l.name.clone()
|
||||
} else {
|
||||
format!("{} (may be reportable)", l.name)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(Vendor1099Worksheet {
|
||||
year: r.year,
|
||||
threshold: r.threshold,
|
||||
through: r.through,
|
||||
lines,
|
||||
missing_w9,
|
||||
unresolved: r.unresolved,
|
||||
excluded_card_payments: r.excluded_card_payments,
|
||||
undetermined_payments: r.undetermined_payments,
|
||||
other_receipts: r.other_receipts,
|
||||
untagged_payments: r.untagged_payments,
|
||||
notes,
|
||||
tins_included: tins.is_some(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The worksheet as CSV (INVARIANT 4): vendor id, name, form, paid,
|
||||
/// reportable, W-9 on file, and last four; `with_tins` adds the TIN, its
|
||||
/// type, and the note why it's missing.
|
||||
/// A dated decision for the CSV: its key, with its kind when not
|
||||
/// reportable, and its date when it has one ("nec",
|
||||
/// "not_reportable:corporation from 2026-12-30").
|
||||
fn decision_cell(d: &ledger_core::Dated1099) -> String {
|
||||
let mut s = d.decision.key().to_string();
|
||||
if let Decision1099::NotReportable { kind, .. } = &d.decision {
|
||||
s.push(':');
|
||||
s.push_str(kind.key());
|
||||
}
|
||||
if let Some(e) = d.effective {
|
||||
s.push_str(&format!(" from {e}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// The worksheet's CSV header (INVARIANT 4).
|
||||
pub const VENDOR_1099_CSV_HEADER: &str = "vendor_id,name,decision_1099,form_1099,reason_kind,reason,decided_by,decided_on,effective,decisions_in_year,paid,excluded_card,undetermined,not_reportable,status,reportable,w9_on_file,tin_last4";
|
||||
|
||||
/// The worksheet as CSV (INVARIANT 4): per vendor its id and name, its
|
||||
/// year-end 1099 decision (key, form, reason kind and words, who decided,
|
||||
/// when, the date it took effect) and every decision in force during the
|
||||
/// year, what counts, what was paid by card or network, what is
|
||||
/// undetermined, what was paid while not reportable, its status, whether
|
||||
/// it is reportable, its W-9 flag and last four; `with_tins` adds the TIN,
|
||||
/// its type, and the note why it's missing.
|
||||
pub fn vendor_1099_csv(worksheet: &Vendor1099Worksheet, with_tins: bool) -> String {
|
||||
let mut csv = String::from("vendor_id,name,form_1099,paid,reportable,w9_on_file,tin_last4");
|
||||
let mut csv = String::from(VENDOR_1099_CSV_HEADER);
|
||||
if with_tins {
|
||||
csv.push_str(",tin,tin_type,tin_note");
|
||||
}
|
||||
csv.push('\n');
|
||||
for l in &worksheet.lines {
|
||||
let (kind, reason) = match &l.decision {
|
||||
Decision1099::NotReportable { kind, reason } => (kind.key(), reason.as_str()),
|
||||
_ => ("", ""),
|
||||
};
|
||||
let mut cells = vec![
|
||||
l.vendor_id.to_string(),
|
||||
csv_escape(&l.name),
|
||||
l.decision_1099.clone(),
|
||||
l.form_1099.clone().unwrap_or_default(),
|
||||
kind.to_string(),
|
||||
csv_escape(reason),
|
||||
csv_escape(l.decided.as_ref().map_or("", |s| s.decided_by.as_str())),
|
||||
l.decided
|
||||
.as_ref()
|
||||
.map(|s| s.decided_on.to_string())
|
||||
.unwrap_or_default(),
|
||||
l.effective.map(|e| e.to_string()).unwrap_or_default(),
|
||||
csv_escape(
|
||||
&l.decisions
|
||||
.iter()
|
||||
.map(decision_cell)
|
||||
.collect::<Vec<_>>()
|
||||
.join("; "),
|
||||
),
|
||||
money(l.paid),
|
||||
money(l.excluded_card),
|
||||
money(l.undetermined),
|
||||
money(l.not_reportable),
|
||||
l.status.key().to_string(),
|
||||
l.reportable.to_string(),
|
||||
l.tin_on_file.to_string(),
|
||||
l.tin_last4.clone().unwrap_or_default(),
|
||||
@@ -252,3 +475,135 @@ pub fn vendor_1099_csv_name(year: i32, with_tins: bool) -> String {
|
||||
if with_tins { "-with-tins" } else { "" }
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ledger_core::{AccountType, Currency, Money, Side};
|
||||
|
||||
fn dec(s: &str) -> Decimal {
|
||||
s.parse().unwrap()
|
||||
}
|
||||
|
||||
fn date(s: &str) -> NaiveDate {
|
||||
s.parse().unwrap()
|
||||
}
|
||||
|
||||
/// The untagged note (P3 review R7), by hand. Bank 1000 (cash), Due to
|
||||
/// Members 2100 (financing), card 2050 (a business card's), capital
|
||||
/// 3000, labor 6000, prepaid 1300, equipment 1500. Untagged, in 2026:
|
||||
/// 1. a contribution, Dr 1000 / Cr 3000 500: money in, not counted;
|
||||
/// 2. a member reimbursed, Dr 2100 / Cr 1000 80: nets to zero;
|
||||
/// 3. a property contribution, Dr 1500 / Cr 3000: no payment line;
|
||||
/// 4. the card bill, Dr 2050 / Cr 1000 300: nets to zero;
|
||||
/// 5. a distribution, Dr 3000 / Cr 1000 200: paid out, but for no
|
||||
/// expense or asset: not counted;
|
||||
/// 6. a fee a member paid, Dr 6000 / Cr 2100 25: counted;
|
||||
/// 7. a prepayment, Dr 1300 / Cr 1000 120: counted;
|
||||
/// 8. a payment, Dr 6000 / Cr 1000 40, and 9 its reversal: both in
|
||||
/// the year, so neither counts.
|
||||
///
|
||||
/// Two entries: 6 and 7.
|
||||
#[test]
|
||||
fn the_untagged_note_counts_only_money_out() {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let mut acct = |code: &str, t| l.add_account(code, code, t, None, false).unwrap();
|
||||
let bank = acct("1000", AccountType::Asset);
|
||||
let prepaid = acct("1300", AccountType::Asset);
|
||||
let equipment = acct("1500", AccountType::Asset);
|
||||
let card = acct("2050", AccountType::Liability);
|
||||
let due = acct("2100", AccountType::Liability);
|
||||
let capital = acct("3000", AccountType::Equity);
|
||||
let labor = acct("6000", AccountType::Expense);
|
||||
let post = |l: &mut Ledger, dr, cr, amt: &str| {
|
||||
let e = l.create_entry(date("2026-05-01"), "x");
|
||||
let m = Money::new(dec(amt), Currency::USD);
|
||||
l.add_line(e, dr, Side::Debit, m).unwrap();
|
||||
l.add_line(e, cr, Side::Credit, m).unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
e
|
||||
};
|
||||
post(&mut l, bank, capital, "500");
|
||||
post(&mut l, due, bank, "80");
|
||||
post(&mut l, equipment, capital, "900");
|
||||
post(&mut l, card, bank, "300");
|
||||
post(&mut l, capital, bank, "200");
|
||||
let fee = post(&mut l, labor, due, "25");
|
||||
let pre = post(&mut l, prepaid, bank, "120");
|
||||
let wrong = post(&mut l, labor, bank, "40");
|
||||
l.reverse_entry(wrong, date("2026-05-02"), "oops").unwrap();
|
||||
l.add_instrument(
|
||||
ledger_core::InstrumentKind::BusinessCard,
|
||||
"Card",
|
||||
None,
|
||||
None,
|
||||
card,
|
||||
)
|
||||
.unwrap();
|
||||
let r = ledger_core::vendor_1099_report(
|
||||
&l,
|
||||
2026,
|
||||
None,
|
||||
&ledger_core::PaymentAccounts {
|
||||
cash: vec![bank],
|
||||
financing: vec![due],
|
||||
},
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r.untagged_payments, vec![fee, pre]);
|
||||
let notes = notes_for(&l, &r);
|
||||
assert!(
|
||||
notes.contains(&format!(
|
||||
"2 posted entries in 2026 paid money out with no vendor tag (entries {fee}, {pre}); tag any that paid a contractor or landlord with books.set_entry_vendor."
|
||||
)),
|
||||
"{notes:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The note on money received from a vendor for something else says
|
||||
/// what each entry did: the sale received the money, its reversal
|
||||
/// gave it back (never "received -500.00").
|
||||
#[test]
|
||||
fn the_other_receipts_note_says_what_each_entry_did() {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let bank = l
|
||||
.add_account("1000", "Bank", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let sales = l
|
||||
.add_account("4000", "Sales", AccountType::Revenue, None, false)
|
||||
.unwrap();
|
||||
let dana = l.add_vendor("Dana").unwrap();
|
||||
let e = l.create_entry(date("2026-04-01"), "laptop sold to Dana");
|
||||
let m = Money::new(dec("500.00"), Currency::USD);
|
||||
l.add_line(e, bank, Side::Debit, m).unwrap();
|
||||
l.add_line(e, sales, Side::Credit, m).unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
l.set_entry_vendor(e, Some(dana)).unwrap();
|
||||
let undo = l.reverse_entry(e, date("2026-04-02"), "not Dana").unwrap();
|
||||
let r = ledger_core::vendor_1099_report(
|
||||
&l,
|
||||
2026,
|
||||
None,
|
||||
&ledger_core::PaymentAccounts {
|
||||
cash: vec![bank],
|
||||
financing: vec![],
|
||||
},
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let notes = notes_for(&l, &r);
|
||||
for want in [
|
||||
format!(
|
||||
"Entry {e} received 500.00 from Dana for something else (a sale, its payment of an invoice, a disposal, a loan): not netted against its payments."
|
||||
),
|
||||
format!(
|
||||
"Entry {undo} reversed 500.00 received from Dana for something else (a sale, its payment of an invoice, a disposal, a loan): not netted against its payments."
|
||||
),
|
||||
] {
|
||||
assert!(notes.contains(&want), "{want} not in {notes:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,8 +83,7 @@ fn books(dir: &Path) -> Books {
|
||||
.unwrap();
|
||||
let vendor = s.add_vendor("Acme, \"Design\"").unwrap();
|
||||
let acme = s.ledger().party_of_vendor(vendor).unwrap();
|
||||
s.set_vendor_1099(vendor, Some(Form1099::Nec), true)
|
||||
.unwrap();
|
||||
s.set_vendor_1099(vendor, &nec(), Some(true)).unwrap();
|
||||
s.set_party_tin(
|
||||
acme,
|
||||
Some(TinFacts {
|
||||
@@ -205,6 +204,26 @@ fn company(members: bool) -> Company {
|
||||
}
|
||||
}
|
||||
|
||||
/// A 1099-NEC decision from the beginning, decided by a member.
|
||||
fn nec() -> ledger_core::Dated1099 {
|
||||
ledger_core::Dated1099::from_beginning(
|
||||
ledger_core::Decision1099::Files(Form1099::Nec),
|
||||
Some(ledger_core::settings::Source {
|
||||
decided_by: "Tobin Example".into(),
|
||||
decided_on: d("2026-01-02"),
|
||||
citation: None,
|
||||
note: None,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// The worksheet CSV's row for Acme in `books`, without TINs.
|
||||
fn acme_row(vendor: u64) -> String {
|
||||
format!(
|
||||
"{vendor},\"Acme, \"\"Design\"\"\",nec,1099-NEC,,,Tobin Example,2026-01-02,,nec,2500.00,0.00,0.00,0.00,reportable,true,true,9105"
|
||||
)
|
||||
}
|
||||
|
||||
fn cash() -> Vec<String> {
|
||||
vec!["1000".to_string()]
|
||||
}
|
||||
@@ -353,9 +372,28 @@ fn a_single_member_package_holds_the_documented_files_in_order() {
|
||||
sale = b.sale,
|
||||
)
|
||||
);
|
||||
// The package's 1099 worksheet is books.vendor_1099's CSV, byte for
|
||||
// byte (one builder: P3 brief 5).
|
||||
let w = vendor_1099_worksheet(
|
||||
b.store.ledger(),
|
||||
&settings(&company, &cash),
|
||||
2026,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
text(&p, "1099-worksheet-2026.csv"),
|
||||
"vendor,form,w9_on_file,paid,reportable\n\"Acme, \"\"Design\"\"\",1099-NEC,true,2500.00,true\n"
|
||||
vendor_1099_csv(&w, false)
|
||||
);
|
||||
assert_eq!(
|
||||
text(&p, "1099-worksheet-2026.csv"),
|
||||
format!(
|
||||
"{}\n{}\n",
|
||||
ledger_package::VENDOR_1099_CSV_HEADER,
|
||||
acme_row(b.vendor)
|
||||
)
|
||||
);
|
||||
let readme = text(&p, "README.txt");
|
||||
assert!(readme.starts_with("Example Labs LLC — books package for the CPA\n\nFiscal year to date: 2026-01-01 through 2026-12-31. Cash basis.\n"), "{readme}");
|
||||
@@ -364,6 +402,12 @@ fn a_single_member_package_holds_the_documented_files_in_order() {
|
||||
"{readme}"
|
||||
);
|
||||
assert!(readme.contains("receipts/: the 2 receipts behind the period's entries"));
|
||||
// Defect 12's wording: what the worksheet holds, every vendor paid in
|
||||
// the year (decided for a 1099 or not), not "marked for a 1099".
|
||||
assert!(
|
||||
readme.contains("1099-worksheet-2026.csv: each vendor paid in 2026 or decided for a 1099: what counts toward its 1099 against the year's threshold, what was paid by card or payment network (reported by the processor on Form 1099-K, Treas. Reg. §1.6041-1(a)(1)(iv)), what can't be classified until its payment method is recorded, and its 1099 decision with the reason, who made it, and when it took effect.\n"),
|
||||
"{readme}"
|
||||
);
|
||||
assert!(readme.contains("cash flow, tax summary, trial balance."));
|
||||
assert!(!readme.contains("k1-members.csv") && !readme.contains("full taxpayer"));
|
||||
assert!(!names(&p).contains(&"k1-members.csv"));
|
||||
@@ -487,9 +531,9 @@ fn members_get_the_k1_worksheet_with_full_tins_only_from_a_source() {
|
||||
assert_eq!(
|
||||
text(&with, "1099-worksheet-2026.csv"),
|
||||
format!(
|
||||
"vendor,form,w9_on_file,paid,reportable,party_id,tin_last4,tin,tin_type,tin_note\n\
|
||||
\"Acme, \"\"Design\"\"\",1099-NEC,true,2500.00,true,{},9105,{EIN},ein,\n",
|
||||
b.acme
|
||||
"{},tin,tin_type,tin_note\n{},{EIN},ein,\n",
|
||||
ledger_package::VENDOR_1099_CSV_HEADER,
|
||||
acme_row(b.vendor)
|
||||
)
|
||||
);
|
||||
assert!(!text(&without, "1099-worksheet-2026.csv").contains(EIN));
|
||||
@@ -585,7 +629,7 @@ fn the_1099_worksheet_carries_tins_only_from_a_source() {
|
||||
let cash = cash();
|
||||
let s = settings(&company, &cash);
|
||||
let ledger = b.store.ledger();
|
||||
let plain = vendor_1099_worksheet(ledger, &s, 2026, None, None).unwrap();
|
||||
let plain = vendor_1099_worksheet(ledger, &s, 2026, None, None, None).unwrap();
|
||||
assert_eq!(plain.year, 2026);
|
||||
assert_eq!(plain.threshold, Decimal::from(2000));
|
||||
assert!(!plain.tins_included);
|
||||
@@ -598,44 +642,49 @@ fn the_1099_worksheet_carries_tins_only_from_a_source() {
|
||||
assert_eq!(l.tin_last4.as_deref(), Some("9105"));
|
||||
assert_eq!((&l.tin, &l.tin_type, &l.tin_note), (&None, &None, &None));
|
||||
assert!(plain.missing_w9.is_empty());
|
||||
// Five posted entries in 2026, one tagged.
|
||||
// Of 2026's untagged entries only the rent paid money out for
|
||||
// something: the contributions and the sale brought money in (P3
|
||||
// review R7, the quieter note).
|
||||
assert_eq!(plain.untagged_payments, vec![b.rent]);
|
||||
assert_eq!(
|
||||
plain.notes,
|
||||
[
|
||||
"Only entries tagged with a vendor count (books.set_entry_vendor; books.pay_bill tags automatically).",
|
||||
"Corporations are generally exempt, except attorneys' fees and medical payments; royalties report at $10. Confirm with the CPA.",
|
||||
"3 posted entries in 2026 have no vendor tag; review any that paid contractors or rent.",
|
||||
"Only entries tagged with a vendor count (books.set_entry_vendor; books.pay_bill tags automatically).".to_string(),
|
||||
"Each vendor's 1099 decision says why when none is due (a corporation, the United States or a state: Treas. Reg. §1.6041-3); payments to a corporation for legal or medical services stay reportable, and royalties report at $10. Confirm with the CPA.".to_string(),
|
||||
format!("1 posted entry in 2026 paid money out with no vendor tag (entry {}); tag any that paid a contractor or landlord with books.set_entry_vendor.", b.rent),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
vendor_1099_csv(&plain, false),
|
||||
format!(
|
||||
"vendor_id,name,form_1099,paid,reportable,w9_on_file,tin_last4\n{},\"Acme, \"\"Design\"\"\",1099-NEC,2500.00,true,true,9105\n",
|
||||
b.vendor
|
||||
"{}\n{}\n",
|
||||
ledger_package::VENDOR_1099_CSV_HEADER,
|
||||
acme_row(b.vendor)
|
||||
)
|
||||
);
|
||||
let source = tin_source(&b);
|
||||
let full = vendor_1099_worksheet(ledger, &s, 2026, None, Some(&source)).unwrap();
|
||||
let full = vendor_1099_worksheet(ledger, &s, 2026, None, None, Some(&source)).unwrap();
|
||||
assert!(full.tins_included);
|
||||
assert_eq!(full.lines[0].tin.as_deref(), Some(EIN));
|
||||
assert_eq!(full.lines[0].tin_type.as_deref(), Some("ein"));
|
||||
assert_eq!(
|
||||
vendor_1099_csv(&full, true),
|
||||
format!(
|
||||
"vendor_id,name,form_1099,paid,reportable,w9_on_file,tin_last4,tin,tin_type,tin_note\n{},\"Acme, \"\"Design\"\"\",1099-NEC,2500.00,true,true,9105,{EIN},ein,\n",
|
||||
b.vendor
|
||||
"{},tin,tin_type,tin_note\n{},{EIN},ein,\n",
|
||||
ledger_package::VENDOR_1099_CSV_HEADER,
|
||||
acme_row(b.vendor)
|
||||
)
|
||||
);
|
||||
// A source without the payee's TIN: the note says why.
|
||||
let none = |_: PartyId| -> Result<ledger_vault::Tin, String> {
|
||||
Err("the TIN vault is locked, \"for now\"".into())
|
||||
};
|
||||
let locked = vendor_1099_worksheet(ledger, &s, 2026, None, Some(&none)).unwrap();
|
||||
let locked = vendor_1099_worksheet(ledger, &s, 2026, None, None, Some(&none)).unwrap();
|
||||
assert_eq!(
|
||||
vendor_1099_csv(&locked, true).lines().nth(1).unwrap(),
|
||||
format!(
|
||||
"{},\"Acme, \"\"Design\"\"\",1099-NEC,2500.00,true,true,9105,,,\"the TIN vault is locked, \"\"for now\"\"\"",
|
||||
b.vendor
|
||||
"{},,,\"the TIN vault is locked, \"\"for now\"\"\"",
|
||||
acme_row(b.vendor)
|
||||
)
|
||||
);
|
||||
assert_eq!(vendor_1099_csv_name(2026, false), "1099-worksheet-2026.csv");
|
||||
@@ -645,9 +694,10 @@ fn the_1099_worksheet_carries_tins_only_from_a_source() {
|
||||
);
|
||||
// A year whose threshold isn't known is refused, never guessed; given
|
||||
// one, it is used.
|
||||
let far = vendor_1099_worksheet(ledger, &s, 2031, None, None);
|
||||
let far = vendor_1099_worksheet(ledger, &s, 2031, None, None, None);
|
||||
assert!(far.is_err(), "{far:?}");
|
||||
let given = vendor_1099_worksheet(ledger, &s, 2031, Some(Decimal::from(2100)), None).unwrap();
|
||||
let given =
|
||||
vendor_1099_worksheet(ledger, &s, 2031, Some(Decimal::from(2100)), None, None).unwrap();
|
||||
assert_eq!(given.threshold, Decimal::from(2100));
|
||||
assert!(
|
||||
given
|
||||
@@ -918,9 +968,13 @@ fn the_years_first_and_last_days_receipts_are_in_and_the_days_beyond_are_not() {
|
||||
}
|
||||
|
||||
/// Contract labor paid three ways: from checking (cash), from a prepaid
|
||||
/// asset that isn't cash, and by a member's advance (financing).
|
||||
/// asset that isn't cash, and by a member's advance (financing). The
|
||||
/// worksheet counts payments out of the accounts it is given; the CPA
|
||||
/// package gives it its own (the request's, else the settings': one list
|
||||
/// of each per package, P3 review R18), so the package's 1099 worksheet
|
||||
/// is books.vendor_1099's for those accounts, byte for byte.
|
||||
#[test]
|
||||
fn payments_to_a_vendor_come_from_the_settings_accounts_alone() {
|
||||
fn payments_to_a_vendor_come_from_the_packages_accounts() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mut s, _) = plain_books(dir.path());
|
||||
for (code, name, ty) in [
|
||||
@@ -931,8 +985,7 @@ fn payments_to_a_vendor_come_from_the_settings_accounts_alone() {
|
||||
s.add_account(code, name, ty, None, false).unwrap();
|
||||
}
|
||||
let vendor = s.add_vendor("Acme Design").unwrap();
|
||||
s.set_vendor_1099(vendor, Some(Form1099::Nec), true)
|
||||
.unwrap();
|
||||
s.set_vendor_1099(vendor, &nec(), Some(true)).unwrap();
|
||||
for (from, amount) in [("1000", "1000.00"), ("1300", "400.00"), ("2100", "700.00")] {
|
||||
let e = s.create_entry(d("2026-05-01"), "Acme design work").unwrap();
|
||||
s.add_line(e, id_of(&s, "6100"), Side::Debit, usd(amount))
|
||||
@@ -960,11 +1013,49 @@ fn payments_to_a_vendor_come_from_the_settings_accounts_alone() {
|
||||
financing: vec![id_of(&s, "2100")],
|
||||
}
|
||||
);
|
||||
// Checking and the advance count; the prepaid asset doesn't.
|
||||
let w = vendor_1099_worksheet(ledger, &st, 2026, None, None).unwrap();
|
||||
// Checking counts; the member's advance is undetermined until how
|
||||
// they paid is recorded (their card wouldn't count, their check
|
||||
// would); the prepaid asset is no payment source.
|
||||
let w = vendor_1099_worksheet(ledger, &st, 2026, None, None, None).unwrap();
|
||||
assert_eq!(w.lines.len(), 1);
|
||||
assert_eq!(w.lines[0].vendor_id, vendor);
|
||||
assert_eq!(w.lines[0].paid, Decimal::from(1700));
|
||||
assert_eq!(w.lines[0].paid, Decimal::from(1000));
|
||||
assert_eq!(w.lines[0].undetermined, Decimal::from(700));
|
||||
assert_eq!(w.lines[0].status, ledger_core::Status1099::UnderThreshold);
|
||||
assert_eq!(w.undetermined_payments.len(), 1);
|
||||
// A package naming no accounts carries this worksheet, byte for byte
|
||||
// (its settings name checking alone: the cash flow refuses a code the
|
||||
// chart lacks).
|
||||
let checking = vec!["1000".to_string()];
|
||||
let st = Settings {
|
||||
cash_accounts: &checking,
|
||||
..st
|
||||
};
|
||||
let plain = build_package(&s, &st, &request(false, None)).unwrap();
|
||||
assert_eq!(
|
||||
text(&plain, "1099-worksheet-2026.csv"),
|
||||
vendor_1099_csv(&w, false)
|
||||
);
|
||||
// One naming the prepaid asset as cash carries the worksheet for its
|
||||
// accounts (the 400.00 out of the prepaid asset counts, and the
|
||||
// settings' financing stays), the one books.vendor_1099 gives for
|
||||
// them through the package's as_of; the cash flow, the K-1 and the
|
||||
// IIF read the same list.
|
||||
let wider = vec!["1000".to_string(), "1300".to_string()];
|
||||
let req = PackageRequest {
|
||||
cash_accounts: &wider,
|
||||
..request(false, None)
|
||||
};
|
||||
let p = build_package(&s, &st, &req).unwrap();
|
||||
let theirs = st.with_accounts(&wider, &[]);
|
||||
assert_eq!(theirs.financing_accounts, financing.as_slice());
|
||||
let w = vendor_1099_worksheet(ledger, &theirs, 2026, None, Some(req.as_of), None).unwrap();
|
||||
assert_eq!(w.lines[0].paid, Decimal::from(1400));
|
||||
assert_eq!(w.lines[0].undetermined, Decimal::from(700));
|
||||
assert_eq!(
|
||||
text(&p, "1099-worksheet-2026.csv"),
|
||||
vendor_1099_csv(&w, false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1380,8 +1471,7 @@ fn the_1099_worksheet_stops_at_as_of() {
|
||||
s.add_account("6100", "Contract labor", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let vendor = s.add_vendor("Contractor Example").unwrap();
|
||||
s.set_vendor_1099(vendor, Some(Form1099::Nec), false)
|
||||
.unwrap();
|
||||
s.set_vendor_1099(vendor, &nec(), Some(false)).unwrap();
|
||||
for (date, amount) in [("2026-06-30", "1500.00"), ("2026-07-01", "1000.00")] {
|
||||
let e = s.create_entry(d(date), "Contract work").unwrap();
|
||||
s.add_line(e, id_of(&s, "6100"), Side::Debit, usd(amount))
|
||||
@@ -1400,25 +1490,52 @@ fn the_1099_worksheet_stops_at_as_of() {
|
||||
};
|
||||
build_package(&s, &settings(&company, &cash), &req).unwrap()
|
||||
};
|
||||
// The worksheet the tools make through a day (books.vendor_1099's
|
||||
// `through`), as CSV.
|
||||
let through = |day: Option<&str>| {
|
||||
let w = vendor_1099_worksheet(
|
||||
s.ledger(),
|
||||
&settings(&company, &cash),
|
||||
2026,
|
||||
None,
|
||||
day.map(d),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
vendor_1099_csv(&w, false)
|
||||
};
|
||||
let row = |csv: &str| csv.lines().nth(1).unwrap().to_string();
|
||||
let mid = at("2026-06-30");
|
||||
assert_eq!(
|
||||
text(&mid, "1099-worksheet-2026.csv"),
|
||||
"vendor,form,w9_on_file,paid,reportable\n\"Contractor Example\",1099-NEC,false,1500.00,false\n"
|
||||
let mid_csv = text(&mid, "1099-worksheet-2026.csv");
|
||||
assert_eq!(mid_csv, through(Some("2026-06-30")));
|
||||
// paid 1,500.00, under the $2,000 threshold: not yet reportable.
|
||||
assert!(
|
||||
row(mid_csv).ends_with(",1500.00,0.00,0.00,0.00,under_threshold,false,false,"),
|
||||
"{mid_csv}"
|
||||
);
|
||||
assert!(text(&mid, "README.txt").contains(
|
||||
"\n1099-worksheet-2026.csv: cash paid per vendor marked for a 1099 from January 1 through 2026-06-30, against the year's threshold.\n"
|
||||
"\n1099-worksheet-2026.csv: each vendor paid in 2026 from January 1 through 2026-06-30 or decided for a 1099: what counts toward its 1099 against the year's threshold, "
|
||||
));
|
||||
let year_end = at("2026-12-31");
|
||||
assert_eq!(
|
||||
text(&year_end, "1099-worksheet-2026.csv"),
|
||||
"vendor,form,w9_on_file,paid,reportable\n\"Contractor Example\",1099-NEC,false,2500.00,true\n"
|
||||
let year_csv = text(&year_end, "1099-worksheet-2026.csv");
|
||||
assert_eq!(year_csv, through(None));
|
||||
assert!(
|
||||
row(year_csv).ends_with(",2500.00,0.00,0.00,0.00,reportable,true,false,"),
|
||||
"{year_csv}"
|
||||
);
|
||||
assert!(text(&year_end, "README.txt").contains(
|
||||
"\n1099-worksheet-2026.csv: cash paid per vendor marked for a 1099, against the year's threshold.\n"
|
||||
"\n1099-worksheet-2026.csv: each vendor paid in 2026 or decided for a 1099: what counts toward its 1099 against the year's threshold, "
|
||||
));
|
||||
// The worksheet the tools make for the whole year is unchanged.
|
||||
let w =
|
||||
vendor_1099_worksheet(s.ledger(), &settings(&company, &cash), 2026, None, None).unwrap();
|
||||
let w = vendor_1099_worksheet(
|
||||
s.ledger(),
|
||||
&settings(&company, &cash),
|
||||
2026,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(w.lines[0].paid, Decimal::from(2500));
|
||||
}
|
||||
|
||||
@@ -1526,8 +1643,7 @@ fn one_list_of_cash_accounts_per_package() {
|
||||
],
|
||||
);
|
||||
let vendor = s.add_vendor("Contractor Example").unwrap();
|
||||
s.set_vendor_1099(vendor, Some(Form1099::Nec), false)
|
||||
.unwrap();
|
||||
s.set_vendor_1099(vendor, &nec(), Some(false)).unwrap();
|
||||
let e = s.create_entry(d("2026-05-01"), "Contract work").unwrap();
|
||||
s.add_line(e, id_of(&s, "6100"), Side::Debit, usd("2100.00"))
|
||||
.unwrap();
|
||||
@@ -1551,8 +1667,29 @@ fn one_list_of_cash_accounts_per_package() {
|
||||
};
|
||||
assert_eq!(savings(&given), "ACCNT\tSavings\tBANK\t1010");
|
||||
assert_eq!(savings(&plain), "ACCNT\tSavings\tOCASSET\t1010");
|
||||
assert!(text(&given, "1099-worksheet-2026.csv").contains(",2100.00,true\n"));
|
||||
assert!(text(&plain, "1099-worksheet-2026.csv").contains(",0.00,false\n"));
|
||||
assert!(
|
||||
text(&given, "1099-worksheet-2026.csv")
|
||||
.contains(",2100.00,0.00,0.00,0.00,reportable,true,false,\n")
|
||||
);
|
||||
assert!(
|
||||
text(&plain, "1099-worksheet-2026.csv")
|
||||
.contains(",0.00,0.00,0.00,0.00,under_threshold,false,false,\n")
|
||||
);
|
||||
// Each is books.vendor_1099's CSV for the same accounts.
|
||||
let tool = |codes: &[String]| {
|
||||
let w = vendor_1099_worksheet(
|
||||
s.ledger(),
|
||||
&settings(&company, codes),
|
||||
2026,
|
||||
None,
|
||||
Some(d("2026-12-31")),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
vendor_1099_csv(&w, false)
|
||||
};
|
||||
assert_eq!(text(&given, "1099-worksheet-2026.csv"), tool(&both));
|
||||
assert_eq!(text(&plain, "1099-worksheet-2026.csv"), tool(&cash));
|
||||
}
|
||||
|
||||
/// Plan D-36 (P3, defect 7): general-journal.iif is plain ASCII, and
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
//! Journal payloads of 1099 decisions and payment methods (plan P3,
|
||||
//! defects 12, 18 and 23; D-40). One encoder and one decoder per event,
|
||||
//! so the writer and every reader (replay, the projection, the viewer)
|
||||
//! read a line the same way.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: every event round-trips: decode(encode(x)) == x, and every line
|
||||
//! written before decisions were kept reads as it always meant.
|
||||
//! SOURCE: plan/served.md 5.2, 5.5 (a legacy null is undetermined);
|
||||
//! rules.md B-27 (an older reader says "newer", never "corrupt").
|
||||
//! INVARIANTS:
|
||||
//! 1. vendor.1099_set {vendor_id, form_1099: "nec"|"misc"|
|
||||
//! "not_reportable"|null, reason_kind?, reason?, effective?,
|
||||
//! tin_on_file, source?}. A line with no source is one written before
|
||||
//! decisions were kept: undated, read as its form or undetermined.
|
||||
//! A line with a source is the vendor_1099_decisions feature's.
|
||||
//! 2. not_reportable needs a source, a reason_kind and a reason; a date
|
||||
//! needs a source; a reason or reason_kind beside anything else is
|
||||
//! corrupt. A form or reason kind this version doesn't know is newer
|
||||
//! (format::newer_content), never corrupt.
|
||||
//! 3. vendor.w9_flag_set {vendor_id, tin_on_file}.
|
||||
//! 4. instrument.added {instrument_id, kind, label, last4?,
|
||||
//! owner_party_id?, account_id}; entry.instrument_set {entry_id,
|
||||
//! instrument_id|null}. Optional keys are left out when absent.
|
||||
//! 5. Decoding checks shape only (rules.md B-28): what a command checks
|
||||
//! (limits, the chart) stays with the command.
|
||||
//! ```
|
||||
|
||||
use crate::error::StoreError;
|
||||
use crate::journal::JournalLine;
|
||||
use crate::meta::{source_json, source_of};
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::form1099::{Dated1099, Decision1099, ExemptReason, Form1099};
|
||||
use ledger_core::{Instrument, InstrumentId, InstrumentKind, VendorId};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
const SET: &str = "vendor.1099_set";
|
||||
|
||||
fn corrupt(event: &str, what: &str) -> StoreError {
|
||||
StoreError::JournalCorrupt(format!("{event}: missing or invalid '{what}'"))
|
||||
}
|
||||
|
||||
fn u64_of(p: &Value, event: &str, k: &str) -> Result<u64, StoreError> {
|
||||
p.get(k)
|
||||
.and_then(Value::as_u64)
|
||||
.ok_or_else(|| corrupt(event, k))
|
||||
}
|
||||
|
||||
fn opt_u64(p: &Value, event: &str, k: &str) -> Result<Option<u64>, StoreError> {
|
||||
match p.get(k) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v.as_u64().map(Some).ok_or_else(|| corrupt(event, k)),
|
||||
}
|
||||
}
|
||||
|
||||
fn opt_str<'a>(p: &'a Value, event: &str, k: &str) -> Result<Option<&'a str>, StoreError> {
|
||||
match p.get(k) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(s)) => Ok(Some(s)),
|
||||
Some(_) => Err(corrupt(event, k)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `vendor.1099_set` for a decision with its source (INVARIANT 1).
|
||||
pub fn decision_set(vendor: VendorId, d: &Dated1099, tin_on_file: bool) -> Value {
|
||||
let mut o = Map::new();
|
||||
o.insert("vendor_id".into(), json!(vendor));
|
||||
match &d.decision {
|
||||
Decision1099::Undetermined => {
|
||||
o.insert("form_1099".into(), Value::Null);
|
||||
}
|
||||
Decision1099::Files(f) => {
|
||||
o.insert("form_1099".into(), json!(f.key()));
|
||||
}
|
||||
Decision1099::NotReportable { kind, reason } => {
|
||||
o.insert("form_1099".into(), json!("not_reportable"));
|
||||
o.insert("reason_kind".into(), json!(kind.key()));
|
||||
o.insert("reason".into(), json!(reason));
|
||||
}
|
||||
}
|
||||
if let Some(e) = d.effective {
|
||||
o.insert("effective".into(), json!(e.to_string()));
|
||||
}
|
||||
o.insert("tin_on_file".into(), json!(tin_on_file));
|
||||
if let Some(s) = &d.source {
|
||||
o.insert("source".into(), source_json(s));
|
||||
}
|
||||
Value::Object(o)
|
||||
}
|
||||
|
||||
/// Read a `vendor.1099_set` (INVARIANTS 1-2): the vendor, its decision,
|
||||
/// and its W-9 flag.
|
||||
pub fn decision_of(line: &JournalLine) -> Result<(VendorId, Dated1099, bool), StoreError> {
|
||||
let p = &line.payload;
|
||||
let vendor = u64_of(p, SET, "vendor_id")?;
|
||||
// Every line has carried the flag; a missing one was false.
|
||||
let tin_on_file = match p.get("tin_on_file") {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(v) => v.as_bool().ok_or_else(|| corrupt(SET, "tin_on_file"))?,
|
||||
};
|
||||
let source = match p.get("source") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(_) => Some(source_of(p, SET, "source")?),
|
||||
};
|
||||
let effective = match opt_str(p, SET, "effective")? {
|
||||
None => None,
|
||||
Some(s) => {
|
||||
Some(NaiveDate::parse_from_str(s, "%Y-%m-%d").map_err(|_| corrupt(SET, "effective"))?)
|
||||
}
|
||||
};
|
||||
let kind = opt_str(p, SET, "reason_kind")?;
|
||||
let reason = opt_str(p, SET, "reason")?;
|
||||
let decision = match opt_str(p, SET, "form_1099")? {
|
||||
None => Decision1099::Undetermined,
|
||||
Some("not_reportable") => {
|
||||
let kind = kind.ok_or_else(|| corrupt(SET, "reason_kind"))?;
|
||||
let reason = reason
|
||||
.filter(|r| !r.trim().is_empty())
|
||||
.ok_or_else(|| corrupt(SET, "reason"))?;
|
||||
Decision1099::NotReportable {
|
||||
kind: ExemptReason::parse(kind).ok_or_else(|| {
|
||||
crate::format::newer_content(line, format!("the 1099 reason kind {kind:?}"))
|
||||
})?,
|
||||
reason: reason.to_string(),
|
||||
}
|
||||
}
|
||||
Some(s) => Decision1099::Files(Form1099::parse(s).ok_or_else(|| {
|
||||
crate::format::newer_content(line, format!("the 1099 decision {s:?}"))
|
||||
})?),
|
||||
};
|
||||
let not_reportable = matches!(decision, Decision1099::NotReportable { .. });
|
||||
if !not_reportable && (kind.is_some() || reason.is_some()) {
|
||||
return Err(corrupt(SET, "reason"));
|
||||
}
|
||||
if source.is_none() && (not_reportable || effective.is_some()) {
|
||||
return Err(corrupt(SET, "source"));
|
||||
}
|
||||
Ok((
|
||||
vendor,
|
||||
Dated1099 {
|
||||
effective,
|
||||
decision,
|
||||
source,
|
||||
},
|
||||
tin_on_file,
|
||||
))
|
||||
}
|
||||
|
||||
/// `vendor.w9_flag_set {vendor_id, tin_on_file}` (INVARIANT 3).
|
||||
pub fn w9_flag_set(vendor: VendorId, tin_on_file: bool) -> Value {
|
||||
json!({ "vendor_id": vendor, "tin_on_file": tin_on_file })
|
||||
}
|
||||
|
||||
/// Read a `vendor.w9_flag_set`.
|
||||
pub fn w9_flag_of(p: &Value) -> Result<(VendorId, bool), StoreError> {
|
||||
const E: &str = "vendor.w9_flag_set";
|
||||
Ok((
|
||||
u64_of(p, E, "vendor_id")?,
|
||||
p.get("tin_on_file")
|
||||
.and_then(Value::as_bool)
|
||||
.ok_or_else(|| corrupt(E, "tin_on_file"))?,
|
||||
))
|
||||
}
|
||||
|
||||
/// `instrument.added` (INVARIANT 4).
|
||||
pub fn instrument_added(i: &Instrument) -> Value {
|
||||
let mut o = Map::new();
|
||||
o.insert("instrument_id".into(), json!(i.id));
|
||||
o.insert("kind".into(), json!(i.kind.key()));
|
||||
o.insert("label".into(), json!(i.label));
|
||||
if let Some(l) = &i.last4 {
|
||||
o.insert("last4".into(), json!(l));
|
||||
}
|
||||
if let Some(p) = i.owner_party {
|
||||
o.insert("owner_party_id".into(), json!(p));
|
||||
}
|
||||
o.insert("account_id".into(), json!(i.account));
|
||||
Value::Object(o)
|
||||
}
|
||||
|
||||
/// Read an `instrument.added`.
|
||||
pub fn instrument_of(line: &JournalLine) -> Result<Instrument, StoreError> {
|
||||
const E: &str = "instrument.added";
|
||||
let p = &line.payload;
|
||||
let kind = opt_str(p, E, "kind")?.ok_or_else(|| corrupt(E, "kind"))?;
|
||||
Ok(Instrument {
|
||||
id: u64_of(p, E, "instrument_id")?,
|
||||
kind: InstrumentKind::parse(kind).ok_or_else(|| {
|
||||
crate::format::newer_content(line, format!("the payment method kind {kind:?}"))
|
||||
})?,
|
||||
label: opt_str(p, E, "label")?
|
||||
.ok_or_else(|| corrupt(E, "label"))?
|
||||
.to_string(),
|
||||
last4: opt_str(p, E, "last4")?.map(str::to_string),
|
||||
owner_party: opt_u64(p, E, "owner_party_id")?,
|
||||
account: u64_of(p, E, "account_id")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// `entry.instrument_set {entry_id, instrument_id|null}` (INVARIANT 4).
|
||||
pub fn entry_instrument_set(
|
||||
entry: ledger_core::EntryId,
|
||||
instrument: Option<InstrumentId>,
|
||||
) -> Value {
|
||||
json!({ "entry_id": entry, "instrument_id": instrument })
|
||||
}
|
||||
|
||||
/// Read an `entry.instrument_set`.
|
||||
pub fn entry_instrument_of(
|
||||
p: &Value,
|
||||
) -> Result<(ledger_core::EntryId, Option<InstrumentId>), StoreError> {
|
||||
const E: &str = "entry.instrument_set";
|
||||
Ok((u64_of(p, E, "entry_id")?, opt_u64(p, E, "instrument_id")?))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ledger_core::settings::Source;
|
||||
|
||||
fn line(line_type: &str, payload: Value) -> JournalLine {
|
||||
JournalLine {
|
||||
seq: 7,
|
||||
ts: "2026-10-20T00:00:00Z".into(),
|
||||
line_type: line_type.into(),
|
||||
payload,
|
||||
prev: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn source() -> Source {
|
||||
Source {
|
||||
decided_by: "Riley Chen".into(),
|
||||
decided_on: "2026-10-16".parse().unwrap(),
|
||||
citation: Some("Treas. Reg. §1.6041-3(p)(1)".into()),
|
||||
note: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// INVARIANT 1: every decision round-trips.
|
||||
#[test]
|
||||
fn decisions_round_trip() {
|
||||
for (effective, decision) in [
|
||||
(None, Decision1099::Undetermined),
|
||||
(None, Decision1099::Files(Form1099::Nec)),
|
||||
(
|
||||
Some("2026-12-30".parse().unwrap()),
|
||||
Decision1099::not_reportable(ExemptReason::Corporation, "a corporation by name")
|
||||
.unwrap(),
|
||||
),
|
||||
] {
|
||||
let d = Dated1099 {
|
||||
effective,
|
||||
decision,
|
||||
source: Some(source()),
|
||||
};
|
||||
let got = decision_of(&line(SET, decision_set(4, &d, true))).unwrap();
|
||||
assert_eq!(got, (4, d, true));
|
||||
}
|
||||
}
|
||||
|
||||
/// INVARIANT 1, plan 5.5: lines written before decisions were kept
|
||||
/// read as their form, undated, with no source; a null (or no form)
|
||||
/// as undetermined.
|
||||
#[test]
|
||||
fn legacy_lines_read_as_they_always_meant() {
|
||||
let legacy = |form: Value| {
|
||||
decision_of(&line(
|
||||
SET,
|
||||
json!({"vendor_id": 2, "form_1099": form, "tin_on_file": true}),
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
let nec = legacy(json!("nec"));
|
||||
assert_eq!(
|
||||
nec,
|
||||
(
|
||||
2,
|
||||
Dated1099::from_beginning(Decision1099::Files(Form1099::Nec), None),
|
||||
true
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
legacy(Value::Null).1,
|
||||
Dated1099::from_beginning(Decision1099::Undetermined, None)
|
||||
);
|
||||
let bare = decision_of(&line(SET, json!({"vendor_id": 2}))).unwrap();
|
||||
assert_eq!(
|
||||
bare,
|
||||
(
|
||||
2,
|
||||
Dated1099::from_beginning(Decision1099::Undetermined, None),
|
||||
false
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// INVARIANT 2.
|
||||
#[test]
|
||||
fn malformed_decisions_are_corrupt_and_newer_ones_newer() {
|
||||
let src = source_json(&source());
|
||||
let bad = [
|
||||
// not_reportable without a reason, its kind, or a source.
|
||||
json!({"vendor_id": 1, "form_1099": "not_reportable", "reason_kind": "corporation", "tin_on_file": false, "source": src}),
|
||||
json!({"vendor_id": 1, "form_1099": "not_reportable", "reason": "x", "tin_on_file": false, "source": src}),
|
||||
json!({"vendor_id": 1, "form_1099": "not_reportable", "reason_kind": "corporation", "reason": "x", "tin_on_file": false}),
|
||||
// A date without a source; a reason beside a form.
|
||||
json!({"vendor_id": 1, "form_1099": "nec", "effective": "2026-01-01", "tin_on_file": false}),
|
||||
json!({"vendor_id": 1, "form_1099": "nec", "reason": "x", "tin_on_file": false, "source": src}),
|
||||
json!({"vendor_id": 1, "form_1099": 7, "tin_on_file": false}),
|
||||
json!({"vendor_id": 1, "form_1099": "nec", "effective": "2026-13-01", "tin_on_file": false, "source": src}),
|
||||
];
|
||||
for p in bad {
|
||||
let e = decision_of(&line(SET, p.clone())).unwrap_err();
|
||||
assert!(matches!(e, StoreError::JournalCorrupt(_)), "{p}: {e:?}");
|
||||
}
|
||||
let newer = [
|
||||
json!({"vendor_id": 1, "form_1099": "k", "tin_on_file": false, "source": src}),
|
||||
json!({"vendor_id": 1, "form_1099": "not_reportable", "reason_kind": "martian", "reason": "x", "tin_on_file": false, "source": src}),
|
||||
];
|
||||
for p in newer {
|
||||
let e = decision_of(&line(SET, p.clone())).unwrap_err();
|
||||
assert!(matches!(e, StoreError::NewerJournal { .. }), "{p}: {e:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// INVARIANT 4.
|
||||
#[test]
|
||||
fn instruments_round_trip() {
|
||||
for i in [
|
||||
Instrument {
|
||||
id: 3,
|
||||
kind: InstrumentKind::BusinessCard,
|
||||
label: "Harbor Visa".into(),
|
||||
last4: Some("0042".into()),
|
||||
owner_party: None,
|
||||
account: 14,
|
||||
},
|
||||
Instrument {
|
||||
id: 4,
|
||||
kind: InstrumentKind::MemberCashOrCheck,
|
||||
label: "Riley's checks".into(),
|
||||
last4: None,
|
||||
owner_party: Some(2),
|
||||
account: 9,
|
||||
},
|
||||
] {
|
||||
let p = instrument_added(&i);
|
||||
assert!(p.get("last4").is_none() || i.last4.is_some());
|
||||
assert_eq!(instrument_of(&line("instrument.added", p)).unwrap(), i);
|
||||
}
|
||||
let e = instrument_of(&line(
|
||||
"instrument.added",
|
||||
json!({"instrument_id": 1, "kind": "crypto", "label": "x", "account_id": 1}),
|
||||
))
|
||||
.unwrap_err();
|
||||
assert!(matches!(e, StoreError::NewerJournal { .. }), "{e:?}");
|
||||
assert_eq!(
|
||||
entry_instrument_of(&entry_instrument_set(5, None)).unwrap(),
|
||||
(5, None)
|
||||
);
|
||||
assert_eq!(
|
||||
entry_instrument_of(&entry_instrument_set(5, Some(2))).unwrap(),
|
||||
(5, Some(2))
|
||||
);
|
||||
assert_eq!(w9_flag_of(&w9_flag_set(3, true)).unwrap(), (3, true));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -13,7 +13,10 @@
|
||||
//! cash accounts: `[books] cash_accounts`, resolved; when none are set,
|
||||
//! the asset accounts (not placeholders) whose names say they hold
|
||||
//! cash ("cash", "bank", "checking", "savings", "money market").
|
||||
//! card accounts: liability accounts whose names contain "card".
|
||||
//! card accounts: `ledger_core::parties::card_accounts`: the
|
||||
//! liabilities of the business-card payment methods the books record,
|
||||
//! else (none recorded) the liabilities whose names say "card" but
|
||||
//! not "gift"; never a member's card or a platform's account.
|
||||
//! INVARIANTS:
|
||||
//! 1. reconcilable_accounts: the cash accounts, then the card accounts,
|
||||
//! then every other liability with a recorded statement, each once.
|
||||
@@ -55,9 +58,10 @@ pub fn cash_accounts(ledger: &Ledger, configured: &[String]) -> Vec<AccountId> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The card accounts: liabilities whose names contain "card"
|
||||
/// (`ledger_core::parties::card_accounts`, the same rule party money
|
||||
/// reads, so the two never disagree on what a card is).
|
||||
/// The card accounts (`ledger_core::parties::card_accounts`: the
|
||||
/// business-card payment methods' liabilities, else the liabilities named
|
||||
/// "card", not "gift"), the same rule party money and the viewer read, so
|
||||
/// none disagrees on what a card is.
|
||||
pub fn card_accounts(ledger: &Ledger) -> Vec<AccountId> {
|
||||
ledger_core::parties::card_accounts(ledger)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
"#,
|
||||
];
|
||||
@@ -587,16 +587,25 @@ impl Store {
|
||||
/// first line that needs it: a new event type, or a field or value on
|
||||
/// a known event type that an older version would otherwise misread
|
||||
/// silently. Declared with the format in force (at least format 2,
|
||||
/// crate::meta::FORMAT) and every feature already in force; nothing is
|
||||
/// crate::meta::FORMAT) and every feature already in force, so an
|
||||
/// older reader stops at the declaration (rules.md B-27); nothing is
|
||||
/// journaled when it is already declared. Books that never use the
|
||||
/// feature stay readable by older versions.
|
||||
///
|
||||
/// Each command here that writes a feature's line declares it first.
|
||||
/// A caller that journals several commands as one (a tool drafting an
|
||||
/// entry and tagging it) calls this before the first of them, once
|
||||
/// every check has passed: a declaration never lands after another
|
||||
/// line of the same command (where a refusal to upgrade the format
|
||||
/// would leave the command half done), and a refused command leaves
|
||||
/// none.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: an older wanderbooks stops at the declaration (NewerJournal)
|
||||
/// instead of misreading, or failing on, a line it can't read.
|
||||
/// SOURCE: rules.md B-27; plan/served.md 5.1 (format gate).
|
||||
/// ```
|
||||
fn ensure_feature(&mut self, feature: &str) -> Result<(), StoreError> {
|
||||
pub fn ensure_feature(&mut self, feature: &str) -> Result<(), StoreError> {
|
||||
self.ensure_features(&[feature])
|
||||
}
|
||||
|
||||
@@ -3079,25 +3088,149 @@ impl Store {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set which 1099 a vendor gets (or none) and whether its W-9 is on
|
||||
/// file. Journaled as `vendor.1099_set`.
|
||||
/// Record a vendor's 1099 decision, from the date it takes effect (or
|
||||
/// from the beginning), with who decided; and its W-9 flag when given
|
||||
/// (`None` keeps it). Journaled as `vendor.1099_set` under the
|
||||
/// `vendor_1099_decisions` feature. Returns what it did to the
|
||||
/// vendor's decisions.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: the decision ("no 1099" included) is kept as a decision, with
|
||||
/// who made it and when (plan defects 18, 23; D-40).
|
||||
/// SOURCE: ledger_core::form1099 (plan_decision, check_new_decision);
|
||||
/// P3 review R8.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the decision has no source: SHOULD refuse
|
||||
/// BECAUSE every new decision names who made it.
|
||||
/// 2. IF the core refuses it: SHOULD refuse, journaling nothing.
|
||||
/// 3. IF it changes no decision: SHOULD journal nothing but a
|
||||
/// changed W-9 flag (Store::set_vendor_w9_flag).
|
||||
/// 4. Otherwise: SHOULD declare the feature once, then journal it.
|
||||
/// ```
|
||||
pub fn set_vendor_1099(
|
||||
&mut self,
|
||||
vendor: VendorId,
|
||||
form: Option<ledger_core::Form1099>,
|
||||
tin_on_file: bool,
|
||||
) -> Result<(), StoreError> {
|
||||
self.rehearse(|l| l.set_vendor_1099(vendor, form, tin_on_file))?;
|
||||
self.ledger
|
||||
decision: &ledger_core::Dated1099,
|
||||
tin_on_file: Option<bool>,
|
||||
) -> Result<ledger_core::form1099::Applied, StoreError> {
|
||||
if decision.source.is_none() {
|
||||
return Err(LedgerError::Invalid1099(
|
||||
"a 1099 decision names who made it (decided_by)".into(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let applied = self.ledger.check_vendor_1099(vendor, decision)?;
|
||||
let current = self
|
||||
.ledger
|
||||
.get_vendor(vendor)
|
||||
.ok_or(LedgerError::UnknownVendor(vendor))?;
|
||||
.ok_or(LedgerError::UnknownVendor(vendor))?
|
||||
.tin_on_file;
|
||||
let flag = tin_on_file.unwrap_or(current);
|
||||
if applied == ledger_core::form1099::Applied::Unchanged {
|
||||
self.set_vendor_w9_flag(vendor, flag)?;
|
||||
return Ok(applied);
|
||||
}
|
||||
self.ensure_feature(crate::format::VENDOR_1099_DECISIONS)?;
|
||||
let line = self.append_journal(
|
||||
"vendor.1099_set",
|
||||
json!({ "vendor_id": vendor, "form_1099": form, "tin_on_file": tin_on_file }),
|
||||
crate::form1099::decision_set(vendor, decision, flag),
|
||||
)?;
|
||||
self.ledger.set_vendor_1099(vendor, form, tin_on_file)?;
|
||||
let got = self
|
||||
.ledger
|
||||
.set_vendor_1099(vendor, decision.clone(), flag)?;
|
||||
debug_assert_eq!(got, applied, "check and record diverged");
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(())
|
||||
Ok(applied)
|
||||
}
|
||||
|
||||
/// Mark whether a vendor's W-9 is on file, keeping its 1099 decisions
|
||||
/// and who made them (P3 review R8). Journaled as
|
||||
/// `vendor.w9_flag_set` under the `vendor_1099_decisions` feature;
|
||||
/// nothing when the flag is already so. Returns whether it changed.
|
||||
pub fn set_vendor_w9_flag(
|
||||
&mut self,
|
||||
vendor: VendorId,
|
||||
on_file: bool,
|
||||
) -> Result<bool, StoreError> {
|
||||
let v = self
|
||||
.ledger
|
||||
.get_vendor(vendor)
|
||||
.ok_or(LedgerError::UnknownVendor(vendor))?;
|
||||
if v.tin_on_file == on_file {
|
||||
return Ok(false);
|
||||
}
|
||||
self.ensure_feature(crate::format::VENDOR_1099_DECISIONS)?;
|
||||
let line = self.append_journal(
|
||||
"vendor.w9_flag_set",
|
||||
crate::form1099::w9_flag_set(vendor, on_file),
|
||||
)?;
|
||||
self.ledger.set_vendor_w9_flag(vendor, on_file)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Add a payment method (ledger_core::instruments), journaled as
|
||||
/// `instrument.added` under the `payment_instruments` feature. Returns
|
||||
/// its id.
|
||||
pub fn add_instrument(
|
||||
&mut self,
|
||||
kind: ledger_core::InstrumentKind,
|
||||
label: &str,
|
||||
last4: Option<&str>,
|
||||
owner_party: Option<ledger_core::PartyId>,
|
||||
account: AccountId,
|
||||
) -> Result<ledger_core::InstrumentId, StoreError> {
|
||||
let label = self
|
||||
.ledger
|
||||
.check_add_instrument(label, last4, owner_party, account)?;
|
||||
let id = self.ledger.next_instrument_id();
|
||||
let instrument = ledger_core::Instrument {
|
||||
id,
|
||||
kind,
|
||||
label,
|
||||
last4: last4.map(str::to_string),
|
||||
owner_party,
|
||||
account,
|
||||
};
|
||||
self.ensure_feature(crate::format::PAYMENT_INSTRUMENTS)?;
|
||||
let line = self.append_journal(
|
||||
"instrument.added",
|
||||
crate::form1099::instrument_added(&instrument),
|
||||
)?;
|
||||
let live =
|
||||
self.ledger
|
||||
.add_instrument(kind, &instrument.label, last4, owner_party, account)?;
|
||||
debug_assert_eq!(live, id, "id allocator diverged from journal");
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(live)
|
||||
}
|
||||
|
||||
/// Tag an entry (and its reversal chain) with the payment method it
|
||||
/// was paid with, or clear it. Journaled as `entry.instrument_set`
|
||||
/// under the `payment_instruments` feature; nothing when every entry
|
||||
/// of the chain already says so. Returns whether it changed.
|
||||
pub fn set_entry_instrument(
|
||||
&mut self,
|
||||
entry: EntryId,
|
||||
instrument: Option<ledger_core::InstrumentId>,
|
||||
) -> Result<bool, StoreError> {
|
||||
self.rehearse(|l| l.set_entry_instrument(entry, instrument))?;
|
||||
if self
|
||||
.ledger
|
||||
.reversal_chain(entry)
|
||||
.into_iter()
|
||||
.all(|e| self.ledger.entry_instrument(e) == instrument)
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
self.ensure_feature(crate::format::PAYMENT_INSTRUMENTS)?;
|
||||
let line = self.append_journal(
|
||||
"entry.instrument_set",
|
||||
crate::form1099::entry_instrument_set(entry, instrument),
|
||||
)?;
|
||||
self.ledger.set_entry_instrument(entry, instrument)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Tag an entry with the vendor it paid, or clear the tag. Journaled as
|
||||
@@ -4743,19 +4876,32 @@ pub(crate) fn replay_line_into_ledger(
|
||||
}
|
||||
}
|
||||
"vendor.1099_set" => {
|
||||
let form = match p.get("form_1099").and_then(|v| v.as_str()) {
|
||||
Some(s) => Some(ledger_core::Form1099::parse(s).ok_or_else(|| {
|
||||
StoreError::JournalCorrupt("vendor.1099_set form_1099".into())
|
||||
})?),
|
||||
None => None,
|
||||
};
|
||||
ledger.set_vendor_1099(
|
||||
get_u64(p, "vendor_id")?,
|
||||
form,
|
||||
p.get("tin_on_file")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false),
|
||||
let (vendor, decision, tin_on_file) = crate::form1099::decision_of(line)?;
|
||||
ledger.replay_vendor_1099(vendor, decision, tin_on_file)?;
|
||||
}
|
||||
"vendor.w9_flag_set" => {
|
||||
let (vendor, on_file) = crate::form1099::w9_flag_of(p)?;
|
||||
ledger.set_vendor_w9_flag(vendor, on_file)?;
|
||||
}
|
||||
"instrument.added" => {
|
||||
let i = crate::form1099::instrument_of(line)?;
|
||||
let got = ledger.add_instrument(
|
||||
i.kind,
|
||||
&i.label,
|
||||
i.last4.as_deref(),
|
||||
i.owner_party,
|
||||
i.account,
|
||||
)?;
|
||||
if got != i.id {
|
||||
return Err(StoreError::ReplayMismatch(format!(
|
||||
"payment method id: journal says {}, replay got {got}",
|
||||
i.id
|
||||
)));
|
||||
}
|
||||
}
|
||||
"entry.instrument_set" => {
|
||||
let (entry, instrument) = crate::form1099::entry_instrument_of(p)?;
|
||||
ledger.set_entry_instrument(entry, instrument)?;
|
||||
}
|
||||
"entry.vendor_set" => {
|
||||
ledger.set_entry_vendor(
|
||||
@@ -5488,6 +5634,12 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
|
||||
SELECT ?, vendor_id FROM entry_vendors WHERE entry_id = ?",
|
||||
params![get_u64(p, "id")? as i64, get_u64(p, "reverses")? as i64],
|
||||
)?;
|
||||
// And the way it was paid.
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO entry_instruments(entry_id, instrument_id) \
|
||||
SELECT ?, instrument_id FROM entry_instruments WHERE entry_id = ?",
|
||||
params![get_u64(p, "id")? as i64, get_u64(p, "reverses")? as i64],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO entries(id, date, description, status, reversal_of) VALUES (?,?,?,'posted',?)",
|
||||
params![
|
||||
@@ -5698,17 +5850,81 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
|
||||
)?;
|
||||
}
|
||||
"vendor.1099_set" => {
|
||||
let (vendor, d, tin_on_file) = crate::form1099::decision_of(line)?;
|
||||
let key = match &d.decision {
|
||||
ledger_core::Decision1099::Undetermined => None,
|
||||
other => Some(other.key()),
|
||||
};
|
||||
// A decision from the beginning is the vendor's form, as every
|
||||
// line written before decisions were dated was.
|
||||
if d.effective.is_none() {
|
||||
conn.execute(
|
||||
"UPDATE vendors SET form_1099 = ? WHERE id = ?",
|
||||
params![key, vendor as i64],
|
||||
)?;
|
||||
}
|
||||
conn.execute(
|
||||
"UPDATE vendors SET form_1099 = ?, tin_on_file = ? WHERE id = ?",
|
||||
"UPDATE vendors SET tin_on_file = ? WHERE id = ?",
|
||||
params![tin_on_file, vendor as i64],
|
||||
)?;
|
||||
if let Some(s) = &d.source {
|
||||
let (kind, reason) = match &d.decision {
|
||||
ledger_core::Decision1099::NotReportable { kind, reason } => {
|
||||
(Some(kind.key()), Some(reason.as_str()))
|
||||
}
|
||||
_ => (None, None),
|
||||
};
|
||||
conn.execute(
|
||||
"INSERT INTO vendor_1099_decisions(seq, vendor_id, effective, form_1099, reason_kind, reason, source_json) VALUES (?,?,?,?,?,?,?)",
|
||||
params![
|
||||
line.seq as i64,
|
||||
vendor as i64,
|
||||
d.effective.map(|e| e.to_string()),
|
||||
key,
|
||||
kind,
|
||||
reason,
|
||||
crate::meta::source_json(s).to_string(),
|
||||
],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
"vendor.w9_flag_set" => {
|
||||
let (vendor, on_file) = crate::form1099::w9_flag_of(p)?;
|
||||
conn.execute(
|
||||
"UPDATE vendors SET tin_on_file = ? WHERE id = ?",
|
||||
params![on_file, vendor as i64],
|
||||
)?;
|
||||
}
|
||||
"instrument.added" => {
|
||||
let i = crate::form1099::instrument_of(line)?;
|
||||
conn.execute(
|
||||
"INSERT INTO instruments(id, kind, label, last4, owner_party, account_id) VALUES (?,?,?,?,?,?)",
|
||||
params![
|
||||
p.get("form_1099").and_then(|v| v.as_str()),
|
||||
p.get("tin_on_file")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false),
|
||||
get_u64(p, "vendor_id")? as i64,
|
||||
i.id as i64,
|
||||
i.kind.key(),
|
||||
i.label,
|
||||
i.last4,
|
||||
i.owner_party.map(|p| p as i64),
|
||||
i.account as i64,
|
||||
],
|
||||
)?;
|
||||
}
|
||||
"entry.instrument_set" => {
|
||||
let (entry, instrument) = crate::form1099::entry_instrument_of(p)?;
|
||||
// The whole reversal chain, as the ledger tags it.
|
||||
for e in sqlite_reversal_chain(conn, entry as i64)? {
|
||||
match instrument {
|
||||
Some(i) => conn.execute(
|
||||
"INSERT OR REPLACE INTO entry_instruments(entry_id, instrument_id) VALUES (?, ?)",
|
||||
params![e, i as i64],
|
||||
)?,
|
||||
None => conn.execute(
|
||||
"DELETE FROM entry_instruments WHERE entry_id = ?",
|
||||
params![e],
|
||||
)?,
|
||||
};
|
||||
}
|
||||
}
|
||||
"entry.vendor_set" => match p.get("vendor_id").and_then(|v| v.as_u64()) {
|
||||
Some(v) => {
|
||||
conn.execute(
|
||||
@@ -6108,6 +6324,46 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An entry's reversal chain in the projection, as
|
||||
/// `Ledger::reversal_chain` walks it: up to the original, then down
|
||||
/// through each posted reversal. A draft (not projected) is its own chain.
|
||||
fn sqlite_reversal_chain(conn: &Connection, entry: i64) -> Result<Vec<i64>, StoreError> {
|
||||
let up = |e: i64| -> Result<Option<i64>, StoreError> {
|
||||
Ok(conn
|
||||
.query_row(
|
||||
"SELECT reversal_of FROM entries WHERE id = ?",
|
||||
params![e],
|
||||
|r| r.get::<_, Option<i64>>(0),
|
||||
)
|
||||
.optional()?
|
||||
.flatten())
|
||||
};
|
||||
let mut top = entry;
|
||||
while let Some(o) = up(top)? {
|
||||
if o == entry {
|
||||
break;
|
||||
}
|
||||
top = o;
|
||||
}
|
||||
let mut chain = vec![top];
|
||||
let mut cur = top;
|
||||
while let Some(r) = conn
|
||||
.query_row(
|
||||
"SELECT id FROM entries WHERE reversal_of = ? ORDER BY id LIMIT 1",
|
||||
params![cur],
|
||||
|r| r.get::<_, i64>(0),
|
||||
)
|
||||
.optional()?
|
||||
{
|
||||
if chain.contains(&r) {
|
||||
break;
|
||||
}
|
||||
chain.push(r);
|
||||
cur = r;
|
||||
}
|
||||
Ok(chain)
|
||||
}
|
||||
|
||||
/// What [`Store::verify_and_heal`] found.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct VerifyReport {
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
# cargo run --bin extras -- OUT (the edges of chrono and Decimal)
|
||||
# cargo run --bin extras2 -- OUT (depreciation for 10000-01; 1,044 drafts)
|
||||
# cargo run --bin older -- OUT (x_older_rules, with its JournalWriter)
|
||||
# cargo run --bin p2_1099 -- OUT (p2_vendor_1099: P2's 1099 marks, run
|
||||
# against 9c99a0cf, the revision before P3)
|
||||
# cargo run --bin expect -- OUT (expected.json beside each journal)
|
||||
# and copy each journal.jsonl, blobs/ and expected.json into old-books/.
|
||||
# x_member_closes was written later, the same way, by main 00226a4c
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
//! The P2 books of 1099 marks: a vendor marked 1099-NEC, two marked
|
||||
//! "none" (books.set_vendor_1099 "none" and ""), one undecided whose W-9
|
||||
//! sync wrote a null, and payments out of the bank (1000), the members'
|
||||
//! financing (2100) and a card (2050). Every name is fictional.
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::{AccountType, Currency, Form1099, Money, Side};
|
||||
use ledger_store::Store;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
fn main() {
|
||||
let out = std::path::PathBuf::from(std::env::args().nth(1).unwrap()).join("p2_vendor_1099");
|
||||
let _ = std::fs::remove_dir_all(&out);
|
||||
let mut s = Store::create(&out, Currency::USD).unwrap();
|
||||
let a = |s: &mut Store, c: &str, n: &str, t| s.add_account(c, n, t, None, false).unwrap();
|
||||
let bank = a(&mut s, "1000", "Bank", AccountType::Asset);
|
||||
let card = a(&mut s, "2050", "Company card", AccountType::Liability);
|
||||
let due = a(&mut s, "2100", "Due to Members", AccountType::Liability);
|
||||
let labor = a(&mut s, "6000", "Contract Labor", AccountType::Expense);
|
||||
let harbor = s.add_vendor("Harbor Design LLC").unwrap();
|
||||
let bigco = s.add_vendor("Bigco Supply").unwrap();
|
||||
let cedar = s.add_vendor("Cedar Studio").unwrap();
|
||||
let dana = s.add_vendor("Dana Studio").unwrap();
|
||||
s.set_vendor_1099(harbor, Some(Form1099::Nec), false).unwrap(); // "nec"
|
||||
s.set_vendor_1099(bigco, None, false).unwrap(); // "none"
|
||||
s.set_vendor_1099(cedar, None, false).unwrap(); // ""
|
||||
s.set_vendor_1099(dana, None, true).unwrap(); // set_w9's sync, undecided
|
||||
let d = |y, m, day| NaiveDate::from_ymd_opt(y, m, day).unwrap();
|
||||
let mut pay = |date, vendor, cr, cents: i64| {
|
||||
let e = s.create_entry(date, "work").unwrap();
|
||||
let m = Money::new(Decimal::new(cents, 2), Currency::USD);
|
||||
s.add_line(e, labor, Side::Debit, m).unwrap();
|
||||
s.add_line(e, cr, Side::Credit, m).unwrap();
|
||||
s.post_entry(e).unwrap();
|
||||
s.set_entry_vendor(e, Some(vendor)).unwrap();
|
||||
};
|
||||
pay(d(2025, 6, 1), dana, due, 65_000);
|
||||
pay(d(2026, 3, 1), harbor, bank, 250_000);
|
||||
pay(d(2026, 4, 1), harbor, due, 70_000);
|
||||
pay(d(2026, 5, 1), harbor, card, 40_000);
|
||||
pay(d(2026, 6, 1), bigco, bank, 90_000);
|
||||
println!("WROTE {}", out.display());
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
{
|
||||
"assets": [],
|
||||
"bills": [],
|
||||
"entries": [
|
||||
{
|
||||
"date": "2025-06-01",
|
||||
"description": "work",
|
||||
"id": 1,
|
||||
"lines": [
|
||||
{
|
||||
"account": 4,
|
||||
"amount": {
|
||||
"amount": "650.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Debit"
|
||||
},
|
||||
{
|
||||
"account": 3,
|
||||
"amount": {
|
||||
"amount": "650.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Credit"
|
||||
}
|
||||
],
|
||||
"reversal_of": null,
|
||||
"status": "Posted"
|
||||
},
|
||||
{
|
||||
"date": "2026-03-01",
|
||||
"description": "work",
|
||||
"id": 2,
|
||||
"lines": [
|
||||
{
|
||||
"account": 4,
|
||||
"amount": {
|
||||
"amount": "2500.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Debit"
|
||||
},
|
||||
{
|
||||
"account": 1,
|
||||
"amount": {
|
||||
"amount": "2500.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Credit"
|
||||
}
|
||||
],
|
||||
"reversal_of": null,
|
||||
"status": "Posted"
|
||||
},
|
||||
{
|
||||
"date": "2026-04-01",
|
||||
"description": "work",
|
||||
"id": 3,
|
||||
"lines": [
|
||||
{
|
||||
"account": 4,
|
||||
"amount": {
|
||||
"amount": "700.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Debit"
|
||||
},
|
||||
{
|
||||
"account": 3,
|
||||
"amount": {
|
||||
"amount": "700.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Credit"
|
||||
}
|
||||
],
|
||||
"reversal_of": null,
|
||||
"status": "Posted"
|
||||
},
|
||||
{
|
||||
"date": "2026-05-01",
|
||||
"description": "work",
|
||||
"id": 4,
|
||||
"lines": [
|
||||
{
|
||||
"account": 4,
|
||||
"amount": {
|
||||
"amount": "400.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Debit"
|
||||
},
|
||||
{
|
||||
"account": 2,
|
||||
"amount": {
|
||||
"amount": "400.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Credit"
|
||||
}
|
||||
],
|
||||
"reversal_of": null,
|
||||
"status": "Posted"
|
||||
},
|
||||
{
|
||||
"date": "2026-06-01",
|
||||
"description": "work",
|
||||
"id": 5,
|
||||
"lines": [
|
||||
{
|
||||
"account": 4,
|
||||
"amount": {
|
||||
"amount": "900.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Debit"
|
||||
},
|
||||
{
|
||||
"account": 1,
|
||||
"amount": {
|
||||
"amount": "900.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Credit"
|
||||
}
|
||||
],
|
||||
"reversal_of": null,
|
||||
"status": "Posted"
|
||||
}
|
||||
],
|
||||
"entry_parties": [
|
||||
[
|
||||
1,
|
||||
[
|
||||
{
|
||||
"party_id": 4,
|
||||
"share": "derived"
|
||||
}
|
||||
]
|
||||
],
|
||||
[
|
||||
2,
|
||||
[
|
||||
{
|
||||
"party_id": 1,
|
||||
"share": "derived"
|
||||
}
|
||||
]
|
||||
],
|
||||
[
|
||||
3,
|
||||
[
|
||||
{
|
||||
"party_id": 1,
|
||||
"share": "derived"
|
||||
}
|
||||
]
|
||||
],
|
||||
[
|
||||
4,
|
||||
[
|
||||
{
|
||||
"party_id": 1,
|
||||
"share": "derived"
|
||||
}
|
||||
]
|
||||
],
|
||||
[
|
||||
5,
|
||||
[
|
||||
{
|
||||
"party_id": 2,
|
||||
"share": "derived"
|
||||
}
|
||||
]
|
||||
]
|
||||
],
|
||||
"invoices": [],
|
||||
"payroll_runs": [],
|
||||
"periods": [],
|
||||
"recurring": [],
|
||||
"schedules": [],
|
||||
"snapshot": {
|
||||
"Ok": {
|
||||
"balance_sheet": {
|
||||
"assets": "-3400",
|
||||
"balanced": true,
|
||||
"current_earnings": "-5150",
|
||||
"equity": "-5150",
|
||||
"liabilities": "1750"
|
||||
},
|
||||
"profit_and_loss": {
|
||||
"expenses": "5150",
|
||||
"net_income": "-5150",
|
||||
"revenue": "0"
|
||||
},
|
||||
"trial_balance": {
|
||||
"lines": [
|
||||
{
|
||||
"code": "1000",
|
||||
"credit": "3400",
|
||||
"debit": "0",
|
||||
"name": "Bank"
|
||||
},
|
||||
{
|
||||
"code": "2050",
|
||||
"credit": "400",
|
||||
"debit": "0",
|
||||
"name": "Company card"
|
||||
},
|
||||
{
|
||||
"code": "2100",
|
||||
"credit": "1350",
|
||||
"debit": "0",
|
||||
"name": "Due to Members"
|
||||
},
|
||||
{
|
||||
"code": "6000",
|
||||
"credit": "0",
|
||||
"debit": "5150",
|
||||
"name": "Contract Labor"
|
||||
}
|
||||
],
|
||||
"total_credits": "5150",
|
||||
"total_debits": "5150"
|
||||
}
|
||||
}
|
||||
},
|
||||
"verify": {
|
||||
"Ok": null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{"payload":{"base_currency":"USD"},"prev":"0000000000000000000000000000000000000000000000000000000000000000","seq":1,"ts":"2026-10-21T03:02:53Z","type":"store.created"}
|
||||
{"payload":{"account_type":"Asset","code":"1000","currency":"USD","id":1,"name":"Bank","parent":null,"placeholder":false},"prev":"faa687f5a03c5dd12cd38d92bb550079de448ca93b0277fd87734c450f762985","seq":2,"ts":"2026-10-21T03:02:53Z","type":"account.added"}
|
||||
{"payload":{"account_type":"Liability","code":"2050","currency":"USD","id":2,"name":"Company card","parent":null,"placeholder":false},"prev":"35820b1110264a25167acc22ae911dfef3d362c497b17acca930ee921b97b7d6","seq":3,"ts":"2026-10-21T03:02:53Z","type":"account.added"}
|
||||
{"payload":{"account_type":"Liability","code":"2100","currency":"USD","id":3,"name":"Due to Members","parent":null,"placeholder":false},"prev":"f6431387d04e613df69f33702293c8c23f902f15e555b5d4c2aca49e545842bf","seq":4,"ts":"2026-10-21T03:02:53Z","type":"account.added"}
|
||||
{"payload":{"account_type":"Expense","code":"6000","currency":"USD","id":4,"name":"Contract Labor","parent":null,"placeholder":false},"prev":"bb8832a8ca94611084d543b5ba32e0343944e55440b2849c2c8e1d2b6a8d2b99","seq":5,"ts":"2026-10-21T03:02:53Z","type":"account.added"}
|
||||
{"payload":{"id":1,"name":"Harbor Design LLC"},"prev":"2dcde6845ee05cfb4ef2f23194a161986fe52b246c928e76745990e97409e091","seq":6,"ts":"2026-10-21T03:02:53Z","type":"vendor.added"}
|
||||
{"payload":{"id":2,"name":"Bigco Supply"},"prev":"0721eef61ec9b3ed1ead9282f481e8a67191183bfd00d51184a8262a849de4b6","seq":7,"ts":"2026-10-21T03:02:53Z","type":"vendor.added"}
|
||||
{"payload":{"id":3,"name":"Cedar Studio"},"prev":"4c6928038c741dcd6cd7c5dea501b3a7fbed9227002baec525323689097bb447","seq":8,"ts":"2026-10-21T03:02:53Z","type":"vendor.added"}
|
||||
{"payload":{"id":4,"name":"Dana Studio"},"prev":"1f5a42c9b9512441793dd94dfefc80316b656af83a521c9e9c5f0fa339cd383f","seq":9,"ts":"2026-10-21T03:02:53Z","type":"vendor.added"}
|
||||
{"payload":{"form_1099":"nec","tin_on_file":false,"vendor_id":1},"prev":"28175697977371c53fd918f0b858ba22f03eb7f8698e0ac4d94d4b08da26a96c","seq":10,"ts":"2026-10-21T03:02:53Z","type":"vendor.1099_set"}
|
||||
{"payload":{"form_1099":null,"tin_on_file":false,"vendor_id":2},"prev":"22f935f5a504501919d62eeaab1c109d9878adf718b698fb77d947e33ebf8cf8","seq":11,"ts":"2026-10-21T03:02:53Z","type":"vendor.1099_set"}
|
||||
{"payload":{"form_1099":null,"tin_on_file":false,"vendor_id":3},"prev":"1b4cdc7b0207eef330446cfafbb85acffe255268bba7a525c9a2281cb9491cd1","seq":12,"ts":"2026-10-21T03:02:53Z","type":"vendor.1099_set"}
|
||||
{"payload":{"form_1099":null,"tin_on_file":true,"vendor_id":4},"prev":"fa22b64dc51595facd1613fd593971999599200ce695d5136645baf429f57c5c","seq":13,"ts":"2026-10-21T03:02:53Z","type":"vendor.1099_set"}
|
||||
{"payload":{"date":"2025-06-01","description":"work","id":1},"prev":"aada8778c28f9bd17317f894db215169cbd033feb73f9024683c9a87034ecbe6","seq":14,"ts":"2026-10-21T03:02:53Z","type":"entry.created"}
|
||||
{"payload":{"account":4,"amount":"650.00","currency":"USD","id":1,"side":"debit"},"prev":"a6c85a896b3525734f943e9e6c1d802f0dbb81da242af57160ecd1fb4a6994ba","seq":15,"ts":"2026-10-21T03:02:53Z","type":"entry.line_added"}
|
||||
{"payload":{"account":3,"amount":"650.00","currency":"USD","id":1,"side":"credit"},"prev":"65f150b229ce65ce0fa00be0db83cc9b48a2b7bbf9b66f977186ca180661f517","seq":16,"ts":"2026-10-21T03:02:53Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2025-06-01","description":"work","id":1,"lines":[{"account":4,"amount":"650.00","currency":"USD","side":"debit"},{"account":3,"amount":"650.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"15cab2a9658cdcbffd3f754d0dfb8bf50915aeeff62ad86b29dfeae085243d1a","seq":17,"ts":"2026-10-21T03:02:53Z","type":"entry.posted"}
|
||||
{"payload":{"entry_id":1,"vendor_id":4},"prev":"75a257d69a1e96e999076f04a75caf1011ca684aa1eb4e0c35ab19d2a30d4715","seq":18,"ts":"2026-10-21T03:02:53Z","type":"entry.vendor_set"}
|
||||
{"payload":{"date":"2026-03-01","description":"work","id":2},"prev":"965a3395b67428dc095d1d6ed8ed75873474bef20b49a976cfa088f3676d024a","seq":19,"ts":"2026-10-21T03:02:53Z","type":"entry.created"}
|
||||
{"payload":{"account":4,"amount":"2500.00","currency":"USD","id":2,"side":"debit"},"prev":"c7ecf610d918f3b346db3239226a9d4ac586dea59a45f627519fcbb4eb11f466","seq":20,"ts":"2026-10-21T03:02:53Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"2500.00","currency":"USD","id":2,"side":"credit"},"prev":"4a25b78987fff2213ade495a096a9cda705c5a6c44c285b065794b5e268d2765","seq":21,"ts":"2026-10-21T03:02:53Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2026-03-01","description":"work","id":2,"lines":[{"account":4,"amount":"2500.00","currency":"USD","side":"debit"},{"account":1,"amount":"2500.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"8d16aeafb1d1fe79d085cd574c0bd6bc5167807a462c7994ddf8625bb03fc92f","seq":22,"ts":"2026-10-21T03:02:53Z","type":"entry.posted"}
|
||||
{"payload":{"entry_id":2,"vendor_id":1},"prev":"bce9b391e47b12594c9ae5302b9b5e4c75e0eef0d0e92dd5c78e3a8e0de8bc51","seq":23,"ts":"2026-10-21T03:02:53Z","type":"entry.vendor_set"}
|
||||
{"payload":{"date":"2026-04-01","description":"work","id":3},"prev":"cfa11ea24eef3250c1c490d5a7c7748c5fd9c5d92d657bbd67b3115859c00546","seq":24,"ts":"2026-10-21T03:02:53Z","type":"entry.created"}
|
||||
{"payload":{"account":4,"amount":"700.00","currency":"USD","id":3,"side":"debit"},"prev":"645f86b699b6d72c89a6396a2ce7151a633e05a160586a1d7e91ff9638752067","seq":25,"ts":"2026-10-21T03:02:53Z","type":"entry.line_added"}
|
||||
{"payload":{"account":3,"amount":"700.00","currency":"USD","id":3,"side":"credit"},"prev":"3da5a99cfa096a7de27f8f8e117aafafde22c8f8516d8753235e688c18566ab6","seq":26,"ts":"2026-10-21T03:02:53Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2026-04-01","description":"work","id":3,"lines":[{"account":4,"amount":"700.00","currency":"USD","side":"debit"},{"account":3,"amount":"700.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"7e9c4bce2009f0dc611e45aaa173dc2c973f999a06da1a6ef498be0beae9ad45","seq":27,"ts":"2026-10-21T03:02:53Z","type":"entry.posted"}
|
||||
{"payload":{"entry_id":3,"vendor_id":1},"prev":"6357688b27ccbbb24d545fbe1bc454d3c73fa0aef4daf5d3ac666cfe7666d066","seq":28,"ts":"2026-10-21T03:02:53Z","type":"entry.vendor_set"}
|
||||
{"payload":{"date":"2026-05-01","description":"work","id":4},"prev":"29b5ac4f6c46158b22f5cb6a18c2a9f88a386637d8424a283a587a17dbc56569","seq":29,"ts":"2026-10-21T03:02:53Z","type":"entry.created"}
|
||||
{"payload":{"account":4,"amount":"400.00","currency":"USD","id":4,"side":"debit"},"prev":"f8eef20eca997c3a82f6963177f5f3482230852d2fa7abc965581d50e6ba0886","seq":30,"ts":"2026-10-21T03:02:53Z","type":"entry.line_added"}
|
||||
{"payload":{"account":2,"amount":"400.00","currency":"USD","id":4,"side":"credit"},"prev":"622cac7da511368e75195f733595a7da35d35ba2e70594e8b54aba20e888584e","seq":31,"ts":"2026-10-21T03:02:53Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2026-05-01","description":"work","id":4,"lines":[{"account":4,"amount":"400.00","currency":"USD","side":"debit"},{"account":2,"amount":"400.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"4541dabebf1bb9f268645a4398980e9542de4ea14b47d906a22dc42920579cc8","seq":32,"ts":"2026-10-21T03:02:53Z","type":"entry.posted"}
|
||||
{"payload":{"entry_id":4,"vendor_id":1},"prev":"ccfdcf25dde07c0cfd75f5fb30ab42761fe2f3a901e89b63fb4836bf851702a1","seq":33,"ts":"2026-10-21T03:02:53Z","type":"entry.vendor_set"}
|
||||
{"payload":{"date":"2026-06-01","description":"work","id":5},"prev":"52a15b817fa526b761c3f3ec4e9d2c8684a42f0ed7dd2fa6c96fd8b378063d57","seq":34,"ts":"2026-10-21T03:02:53Z","type":"entry.created"}
|
||||
{"payload":{"account":4,"amount":"900.00","currency":"USD","id":5,"side":"debit"},"prev":"4e20498034f7dc588e1515af8a32a8df74009203bd1a78b27f2e1ba5d7cf9666","seq":35,"ts":"2026-10-21T03:02:53Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"900.00","currency":"USD","id":5,"side":"credit"},"prev":"199142dd14c4e3096c81a6426ae9e94e3e920b846656ac8eb0ee1fab322ba3a6","seq":36,"ts":"2026-10-21T03:02:53Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2026-06-01","description":"work","id":5,"lines":[{"account":4,"amount":"900.00","currency":"USD","side":"debit"},{"account":1,"amount":"900.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"7c071ebfd5242722f07a46d87fecdf5549125894e4085b509c4789c7ca403bc9","seq":37,"ts":"2026-10-21T03:02:53Z","type":"entry.posted"}
|
||||
{"payload":{"entry_id":5,"vendor_id":2},"prev":"fb00c3db22192ed708b2db0838b423a990d57560e43f30d62e150c815434e1d7","seq":38,"ts":"2026-10-21T03:02:53Z","type":"entry.vendor_set"}
|
||||
@@ -0,0 +1,690 @@
|
||||
//! 1099 decisions and payment methods in the journal (plan P3, defects
|
||||
//! 12, 18 and 23; D-40): declared as features before their first line,
|
||||
//! replayed and projected exactly, refused without a trace, and books
|
||||
//! written before them read as they always meant. Every name is
|
||||
//! fictional.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: the decision ("no 1099" included) and how each payment was made
|
||||
//! are kept as records, and no version ever misreads them.
|
||||
//! SOURCE: rules.md B-19, B-27; ledger_store::form1099 INVARIANTS 1-5.
|
||||
//! ```
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::form1099::Applied;
|
||||
use ledger_core::settings::Source;
|
||||
use ledger_core::{
|
||||
AccountType, Currency, Dated1099, Decision1099, ExemptReason, Form1099, InstrumentKind, Money,
|
||||
Side,
|
||||
};
|
||||
use ledger_store::format::{FORMAT_SET, PAYMENT_INSTRUMENTS, VENDOR_1099_DECISIONS};
|
||||
use ledger_store::journal::read_and_verify_journal;
|
||||
use ledger_store::{DB_FILE, JOURNAL_FILE, JournalWriter, Snapshot, Store, StoreError};
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::json;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A test's dir, removed when the test ends, pass or panic.
|
||||
struct TestDir(PathBuf);
|
||||
|
||||
impl std::ops::Deref for TestDir {
|
||||
type Target = Path;
|
||||
fn deref(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn test_dir(name: &str) -> TestDir {
|
||||
let dir = std::env::temp_dir().join(format!("wanderbooks-1099-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
TestDir(dir)
|
||||
}
|
||||
|
||||
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
||||
}
|
||||
|
||||
fn src() -> Source {
|
||||
Source {
|
||||
decided_by: "Riley Chen".into(),
|
||||
decided_on: d(2026, 10, 16),
|
||||
citation: Some("Treas. Reg. §1.6041-3(p)(1)".into()),
|
||||
note: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_beginning(decision: Decision1099) -> Dated1099 {
|
||||
Dated1099::from_beginning(decision, Some(src()))
|
||||
}
|
||||
|
||||
/// Books: bank 1000, Due to Members 2100, contract labor 6000; Harbor
|
||||
/// Design LLC paid 2,500.00 by a member (Dr 6000 / Cr 2100) and reversed,
|
||||
/// then paid again.
|
||||
struct Books {
|
||||
store: Store,
|
||||
due: u64,
|
||||
harbor: u64,
|
||||
paid: u64,
|
||||
reversal: u64,
|
||||
}
|
||||
|
||||
fn books(dir: &Path) -> Books {
|
||||
let mut store = Store::create(dir, Currency::USD).unwrap();
|
||||
store
|
||||
.add_account("1000", "Bank", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let due = store
|
||||
.add_account(
|
||||
"2100",
|
||||
"Due to Members",
|
||||
AccountType::Liability,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let labor = store
|
||||
.add_account("6000", "Contract Labor", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let harbor = store.add_vendor("Harbor Design LLC").unwrap();
|
||||
let pay = |store: &mut Store, day| {
|
||||
let e = store.create_entry(d(2026, 5, day), "Design work").unwrap();
|
||||
let m = Money::new(Decimal::new(250_000, 2), Currency::USD);
|
||||
store.add_line(e, labor, Side::Debit, m).unwrap();
|
||||
store.add_line(e, due, Side::Credit, m).unwrap();
|
||||
store.post_entry(e).unwrap();
|
||||
store.set_entry_vendor(e, Some(harbor)).unwrap();
|
||||
e
|
||||
};
|
||||
let paid = pay(&mut store, 1);
|
||||
let reversal = store.reverse_entry(paid, d(2026, 5, 2), "twice").unwrap();
|
||||
pay(&mut store, 3);
|
||||
Books {
|
||||
store,
|
||||
due,
|
||||
harbor,
|
||||
paid,
|
||||
reversal,
|
||||
}
|
||||
}
|
||||
|
||||
/// A worksheet line: name, paid, undetermined, excluded card, status,
|
||||
/// decision needed.
|
||||
type Row1099 = (String, Decimal, Decimal, Decimal, &'static str, bool);
|
||||
|
||||
/// A row of the projection's vendor_1099_decisions: effective, form,
|
||||
/// reason kind, source.
|
||||
type DecisionRow = (Option<String>, Option<String>, Option<String>, String);
|
||||
|
||||
fn types(dir: &Path) -> Vec<(String, serde_json::Value)> {
|
||||
read_and_verify_journal(&dir.join(JOURNAL_FILE))
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|l| (l.line_type, l.payload))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// B-27: the first line that needs a feature comes after a declaration of
|
||||
/// it (with every feature already in force), once. A version that doesn't
|
||||
/// know the feature stops at the declaration (`tests/format.rs`
|
||||
/// old_reader_gets_newer_journal, for any feature it doesn't know).
|
||||
#[test]
|
||||
fn each_feature_is_declared_once_before_its_first_line() {
|
||||
let dir = test_dir("declare");
|
||||
let mut b = books(&dir);
|
||||
let before = types(&dir).len();
|
||||
assert!(
|
||||
!types(&dir).iter().any(|(t, _)| t == FORMAT_SET),
|
||||
"books without either feature declare nothing"
|
||||
);
|
||||
b.store
|
||||
.set_vendor_1099(
|
||||
b.harbor,
|
||||
&from_beginning(Decision1099::Files(Form1099::Nec)),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
b.store.set_vendor_w9_flag(b.harbor, true).unwrap();
|
||||
let card = b
|
||||
.store
|
||||
.add_instrument(
|
||||
InstrumentKind::MemberCard,
|
||||
"Riley's Visa",
|
||||
Some("4242"),
|
||||
None,
|
||||
b.due,
|
||||
)
|
||||
.unwrap();
|
||||
b.store.set_entry_instrument(b.paid, Some(card)).unwrap();
|
||||
b.store
|
||||
.add_instrument(
|
||||
InstrumentKind::MemberCashOrCheck,
|
||||
"Riley's checks",
|
||||
None,
|
||||
None,
|
||||
b.due,
|
||||
)
|
||||
.unwrap();
|
||||
let added: Vec<String> = types(&dir)[before..]
|
||||
.iter()
|
||||
.map(|(t, p)| match t.as_str() {
|
||||
FORMAT_SET => format!("{t} {}", p["features"]),
|
||||
_ => t.clone(),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
added,
|
||||
[
|
||||
format!("{FORMAT_SET} [\"{VENDOR_1099_DECISIONS}\"]"),
|
||||
"vendor.1099_set".to_string(),
|
||||
"vendor.w9_flag_set".to_string(),
|
||||
format!("{FORMAT_SET} [\"{PAYMENT_INSTRUMENTS}\",\"{VENDOR_1099_DECISIONS}\"]"),
|
||||
"instrument.added".to_string(),
|
||||
"entry.instrument_set".to_string(),
|
||||
"instrument.added".to_string(),
|
||||
]
|
||||
);
|
||||
assert_eq!(b.store.journal_format().format, 2);
|
||||
}
|
||||
|
||||
/// Every record replays and projects exactly: reopening gives back the
|
||||
/// same ledger, part for part; a projection rebuilt from the journal
|
||||
/// equals the one the writer kept, table by table (verify); the viewer's
|
||||
/// snapshot reads the same; and the projection holds each decision with
|
||||
/// who made it, each method, and the tag on the whole reversal chain.
|
||||
#[test]
|
||||
fn decisions_and_methods_replay_and_project() {
|
||||
let dir = test_dir("replay");
|
||||
let mut b = books(&dir);
|
||||
let corp = Decision1099::not_reportable(
|
||||
ExemptReason::Corporation,
|
||||
"Delaware public benefit corporation, from the public record",
|
||||
)
|
||||
.unwrap();
|
||||
b.store
|
||||
.set_vendor_1099(
|
||||
b.harbor,
|
||||
&from_beginning(Decision1099::Files(Form1099::Nec)),
|
||||
Some(true),
|
||||
)
|
||||
.unwrap();
|
||||
b.store
|
||||
.set_vendor_1099(
|
||||
b.harbor,
|
||||
&Dated1099 {
|
||||
effective: Some(d(2026, 12, 30)),
|
||||
decision: corp.clone(),
|
||||
source: Some(src()),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let card = b
|
||||
.store
|
||||
.add_instrument(
|
||||
InstrumentKind::MemberCard,
|
||||
"Riley's Visa",
|
||||
Some("4242"),
|
||||
None,
|
||||
b.due,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
b.store
|
||||
.set_entry_instrument(b.reversal, Some(card))
|
||||
.unwrap()
|
||||
);
|
||||
let chain = [b.paid as i64, b.reversal as i64];
|
||||
let live = b.store.ledger().clone();
|
||||
let v = live.get_vendor(b.harbor).unwrap();
|
||||
assert!(v.tin_on_file);
|
||||
assert_eq!(v.decisions_1099.len(), 2);
|
||||
assert_eq!(v.decision_on(d(2026, 12, 31)), Some(&corp));
|
||||
assert_eq!(live.entry_instrument(b.paid), Some(card));
|
||||
drop(b);
|
||||
let store = Store::open(&dir).unwrap();
|
||||
assert!(live.differs_from(store.ledger()).is_empty());
|
||||
store.verify().unwrap();
|
||||
drop(store);
|
||||
std::fs::remove_file(dir.join(DB_FILE)).unwrap();
|
||||
let store = Store::open(&dir).unwrap();
|
||||
store.verify().unwrap();
|
||||
assert!(live.differs_from(store.ledger()).is_empty());
|
||||
drop(store);
|
||||
let snap = Snapshot::load(&dir).unwrap();
|
||||
assert!(live.differs_from(snap.ledger()).is_empty());
|
||||
let conn = rusqlite::Connection::open(dir.join(DB_FILE)).unwrap();
|
||||
let decisions: Vec<DecisionRow> = conn
|
||||
.prepare("SELECT effective, form_1099, reason_kind, source_json FROM vendor_1099_decisions ORDER BY seq")
|
||||
.unwrap()
|
||||
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))
|
||||
.unwrap()
|
||||
.collect::<Result<_, _>>()
|
||||
.unwrap();
|
||||
assert_eq!(decisions.len(), 2);
|
||||
assert_eq!(
|
||||
(decisions[0].0.as_deref(), decisions[0].1.as_deref()),
|
||||
(None, Some("nec"))
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
decisions[1].0.as_deref(),
|
||||
decisions[1].1.as_deref(),
|
||||
decisions[1].2.as_deref()
|
||||
),
|
||||
(
|
||||
Some("2026-12-30"),
|
||||
Some("not_reportable"),
|
||||
Some("corporation")
|
||||
)
|
||||
);
|
||||
assert!(decisions[1].3.contains("Riley Chen"));
|
||||
let (form, on_file): (String, bool) = conn
|
||||
.query_row("SELECT form_1099, tin_on_file FROM vendors", [], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?))
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(form.as_str(), on_file),
|
||||
("nec", true),
|
||||
"the decision from the beginning"
|
||||
);
|
||||
let tagged: Vec<i64> = conn
|
||||
.prepare("SELECT entry_id FROM entry_instruments ORDER BY entry_id")
|
||||
.unwrap()
|
||||
.query_map([], |r| r.get(0))
|
||||
.unwrap()
|
||||
.collect::<Result<_, _>>()
|
||||
.unwrap();
|
||||
assert_eq!(tagged, chain);
|
||||
}
|
||||
|
||||
/// The engine-side half of verification item 13a (P3 review R14): a 1099
|
||||
/// decision, a W-9 flag, a payment method and a payment-method tag change
|
||||
/// no entry, no balance and no period.
|
||||
#[test]
|
||||
fn decision_and_method_records_change_no_entry() {
|
||||
let dir = test_dir("no-entry");
|
||||
let mut b = books(&dir);
|
||||
let figures = |s: &Store| {
|
||||
let l = s.ledger();
|
||||
(
|
||||
ledger_core::reports::snapshot(l).unwrap(),
|
||||
l.posted_entries().cloned().collect::<Vec<_>>(),
|
||||
l.list_periods().into_iter().cloned().collect::<Vec<_>>(),
|
||||
)
|
||||
};
|
||||
let start = figures(&b.store);
|
||||
b.store
|
||||
.set_vendor_1099(
|
||||
b.harbor,
|
||||
&from_beginning(Decision1099::Files(Form1099::Misc)),
|
||||
Some(false),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(figures(&b.store), start);
|
||||
b.store.set_vendor_w9_flag(b.harbor, true).unwrap();
|
||||
assert_eq!(figures(&b.store), start);
|
||||
let i = b
|
||||
.store
|
||||
.add_instrument(InstrumentKind::Platform, "Harbor Pay", None, None, b.due)
|
||||
.unwrap();
|
||||
assert_eq!(figures(&b.store), start);
|
||||
b.store.set_entry_instrument(b.paid, Some(i)).unwrap();
|
||||
assert_eq!(figures(&b.store), start);
|
||||
b.store.set_entry_instrument(b.paid, None).unwrap();
|
||||
assert_eq!(figures(&b.store), start);
|
||||
}
|
||||
|
||||
/// A refused command journals nothing, not even a feature declaration;
|
||||
/// one that changes nothing journals nothing either (P3 review R8).
|
||||
#[test]
|
||||
fn refusals_and_no_ops_journal_nothing() {
|
||||
let dir = test_dir("refused");
|
||||
let mut b = books(&dir);
|
||||
let journal = || std::fs::read(dir.join(JOURNAL_FILE)).unwrap();
|
||||
let at_start = journal();
|
||||
let nec = Decision1099::Files(Form1099::Nec);
|
||||
// No source.
|
||||
let err = b
|
||||
.store
|
||||
.set_vendor_1099(
|
||||
b.harbor,
|
||||
&Dated1099::from_beginning(nec.clone(), None),
|
||||
None,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("decided_by"), "{err}");
|
||||
// Dated with nothing decided before it.
|
||||
let dated = Dated1099 {
|
||||
effective: Some(d(2026, 7, 1)),
|
||||
decision: nec.clone(),
|
||||
source: Some(src()),
|
||||
};
|
||||
assert!(b.store.set_vendor_1099(b.harbor, &dated, None).is_err());
|
||||
// A blank reason; an unknown vendor.
|
||||
let blank = Dated1099::from_beginning(
|
||||
Decision1099::NotReportable {
|
||||
kind: ExemptReason::Other,
|
||||
reason: " ".into(),
|
||||
},
|
||||
Some(src()),
|
||||
);
|
||||
assert!(b.store.set_vendor_1099(b.harbor, &blank, None).is_err());
|
||||
assert!(
|
||||
b.store
|
||||
.set_vendor_1099(99, &from_beginning(nec.clone()), None)
|
||||
.is_err()
|
||||
);
|
||||
assert!(b.store.set_vendor_w9_flag(99, true).is_err());
|
||||
// A bad method, an unknown entry or method.
|
||||
assert!(
|
||||
b.store
|
||||
.add_instrument(InstrumentKind::Bank, "Bank", Some("12345"), None, b.due)
|
||||
.is_err()
|
||||
);
|
||||
assert!(b.store.set_entry_instrument(999, None).is_err());
|
||||
assert!(b.store.set_entry_instrument(b.paid, Some(7)).is_err());
|
||||
assert_eq!(journal(), at_start, "refusals left a trace");
|
||||
// A flag already so, a tag already so: nothing.
|
||||
assert!(!b.store.set_vendor_w9_flag(b.harbor, false).unwrap());
|
||||
assert!(!b.store.set_entry_instrument(b.paid, None).unwrap());
|
||||
assert_eq!(journal(), at_start);
|
||||
// Recorded once; the same again: unchanged, nothing more.
|
||||
assert_eq!(
|
||||
b.store
|
||||
.set_vendor_1099(b.harbor, &from_beginning(nec.clone()), None)
|
||||
.unwrap(),
|
||||
Applied::Set
|
||||
);
|
||||
let once = journal();
|
||||
assert_eq!(
|
||||
b.store
|
||||
.set_vendor_1099(b.harbor, &from_beginning(nec.clone()), None)
|
||||
.unwrap(),
|
||||
Applied::Unchanged
|
||||
);
|
||||
assert_eq!(journal(), once);
|
||||
// The same decision with a W-9 flag: only the flag is journaled.
|
||||
assert_eq!(
|
||||
b.store
|
||||
.set_vendor_1099(b.harbor, &from_beginning(nec), Some(true))
|
||||
.unwrap(),
|
||||
Applied::Unchanged
|
||||
);
|
||||
let last = types(&dir).pop().unwrap();
|
||||
assert_eq!(last.0, "vendor.w9_flag_set");
|
||||
}
|
||||
|
||||
/// P3 review R8: marking a W-9 on file keeps the 1099 decision and who
|
||||
/// made it, verbatim.
|
||||
#[test]
|
||||
fn marking_a_w9_keeps_who_decided_the_1099() {
|
||||
let dir = test_dir("w9");
|
||||
let mut b = books(&dir);
|
||||
let decision = from_beginning(Decision1099::Files(Form1099::Nec));
|
||||
b.store
|
||||
.set_vendor_1099(b.harbor, &decision, Some(false))
|
||||
.unwrap();
|
||||
assert!(b.store.set_vendor_w9_flag(b.harbor, true).unwrap());
|
||||
let v = b.store.ledger().get_vendor(b.harbor).unwrap();
|
||||
assert!(v.tin_on_file);
|
||||
assert_eq!(v.decisions_1099, [decision]);
|
||||
}
|
||||
|
||||
/// B-27: a value only a newer version writes, in a line of a type this
|
||||
/// version knows (a 1099 decision, a reason kind, a payment method's
|
||||
/// kind), is newer, never corrupt; a malformed one is corrupt.
|
||||
#[test]
|
||||
fn an_unknown_1099_value_is_newer_not_corrupt() {
|
||||
let source = json!({"decided_by": "Riley Chen", "decided_on": "2026-10-16"});
|
||||
let cases = [
|
||||
(
|
||||
"vendor.1099_set",
|
||||
json!({"vendor_id": 1, "form_1099": "1099-k", "tin_on_file": false, "source": source}),
|
||||
Some("the 1099 decision \"1099-k\""),
|
||||
),
|
||||
(
|
||||
"vendor.1099_set",
|
||||
json!({"vendor_id": 1, "form_1099": "not_reportable", "reason_kind": "charity_lottery", "reason": "x", "tin_on_file": false, "source": source}),
|
||||
Some("the 1099 reason kind \"charity_lottery\""),
|
||||
),
|
||||
(
|
||||
"instrument.added",
|
||||
json!({"instrument_id": 1, "kind": "crypto_wallet", "label": "x", "account_id": 1}),
|
||||
Some("the payment method kind \"crypto_wallet\""),
|
||||
),
|
||||
(
|
||||
"vendor.1099_set",
|
||||
json!({"vendor_id": 1, "form_1099": "not_reportable", "reason_kind": "corporation", "tin_on_file": false, "source": source}),
|
||||
None,
|
||||
),
|
||||
];
|
||||
for (i, (t, payload, need)) in cases.into_iter().enumerate() {
|
||||
let dir = test_dir(&format!("newer-{i}"));
|
||||
let b = books(&dir);
|
||||
drop(b);
|
||||
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
|
||||
w.append(
|
||||
FORMAT_SET,
|
||||
json!({"format": 2, "features": [PAYMENT_INSTRUMENTS, VENDOR_1099_DECISIONS]}),
|
||||
)
|
||||
.unwrap();
|
||||
let seq = w.append(t, payload).unwrap().seq;
|
||||
drop(w);
|
||||
std::fs::remove_file(dir.join(DB_FILE)).unwrap();
|
||||
match (Store::open(&dir), need) {
|
||||
(
|
||||
Err(StoreError::NewerJournal {
|
||||
seq: s, need: n, ..
|
||||
}),
|
||||
Some(want),
|
||||
) => {
|
||||
assert_eq!((s, n.as_str()), (seq, want));
|
||||
}
|
||||
(Err(StoreError::JournalCorrupt(_)), None) => {}
|
||||
(got, want) => panic!("case {i}: {:?} for {want:?}", got.err()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Plan 5.5, D-40: books written before decisions were kept (lines with a
|
||||
/// form or null and a W-9 flag, no source, no date) open unchanged, byte
|
||||
/// for byte, with no feature declared: each old line reads as a decision
|
||||
/// from the beginning (a null as undetermined, never "not reportable"),
|
||||
/// and the projection is the one those books always had.
|
||||
#[test]
|
||||
fn books_written_before_decisions_read_as_they_always_meant() {
|
||||
let dir = test_dir("legacy");
|
||||
let b = books(&dir);
|
||||
let (harbor, paid) = (b.harbor, b.paid);
|
||||
drop(b);
|
||||
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
|
||||
for (vendor, name) in [(2u64, "Pinecrest Supply"), (3, "Ledgerline Bookkeeping")] {
|
||||
w.append("vendor.added", json!({"id": vendor, "name": name}))
|
||||
.unwrap();
|
||||
}
|
||||
for p in [
|
||||
json!({"vendor_id": harbor, "form_1099": "nec", "tin_on_file": false}),
|
||||
json!({"vendor_id": 2, "form_1099": null, "tin_on_file": false}),
|
||||
// The old W-9 sync on an undecided vendor.
|
||||
json!({"vendor_id": 3, "form_1099": null, "tin_on_file": true}),
|
||||
] {
|
||||
w.append("vendor.1099_set", p).unwrap();
|
||||
}
|
||||
drop(w);
|
||||
std::fs::remove_file(dir.join(DB_FILE)).unwrap();
|
||||
let journal = std::fs::read(dir.join(JOURNAL_FILE)).unwrap();
|
||||
let store = Store::open(&dir).unwrap();
|
||||
store.verify().unwrap();
|
||||
let l = store.ledger();
|
||||
let h = l.get_vendor(harbor).unwrap();
|
||||
assert_eq!(
|
||||
h.decisions_1099,
|
||||
[Dated1099::from_beginning(
|
||||
Decision1099::Files(Form1099::Nec),
|
||||
None
|
||||
)]
|
||||
);
|
||||
for v in [2, 3] {
|
||||
assert_eq!(
|
||||
l.get_vendor(v).unwrap().decisions_1099,
|
||||
[Dated1099::from_beginning(Decision1099::Undetermined, None)]
|
||||
);
|
||||
}
|
||||
assert!(l.get_vendor(3).unwrap().tin_on_file);
|
||||
assert_eq!(l.entry_instrument(paid), None);
|
||||
assert_eq!(store.journal_format().features, Vec::<String>::new());
|
||||
drop(store);
|
||||
assert_eq!(std::fs::read(dir.join(JOURNAL_FILE)).unwrap(), journal);
|
||||
let conn = rusqlite::Connection::open(dir.join(DB_FILE)).unwrap();
|
||||
let forms: Vec<(i64, Option<String>, bool)> = conn
|
||||
.prepare("SELECT id, form_1099, tin_on_file FROM vendors ORDER BY id")
|
||||
.unwrap()
|
||||
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
|
||||
.unwrap()
|
||||
.collect::<Result<_, _>>()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
forms,
|
||||
[
|
||||
(1, Some("nec".to_string()), false),
|
||||
(2, None, false),
|
||||
(3, None, true)
|
||||
]
|
||||
);
|
||||
let n: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM vendor_1099_decisions", [], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(n, 0, "nothing an old line didn't say");
|
||||
}
|
||||
|
||||
/// P3 brief 8.2, compatibility: tests/fixtures/old-books/p2_vendor_1099,
|
||||
/// written through the Store API of the version before P3 (a vendor
|
||||
/// marked 1099-NEC, two marked "none" and "", an undecided one whose W-9
|
||||
/// sync wrote a null; payments out of the bank 1000, the members' 2100 and
|
||||
/// a card 2050), opens to its record: its journal byte for byte, no
|
||||
/// feature declared, every null undetermined and "nec" a 1099-NEC from
|
||||
/// the beginning that nobody is named for; and the 1099 figures, by hand,
|
||||
/// for cash 1000 and financing 2100:
|
||||
/// - 2026, threshold 2,000.00: Harbor Design LLC paid 2,500.00 from the
|
||||
/// bank (counts), 700.00 by a member (undetermined) and 400.00 on the
|
||||
/// card (no payment method names 2050: no payment line): reportable,
|
||||
/// 2,500.00 counting whatever the 700.00 turns out. Bigco Supply,
|
||||
/// undecided, 900.00 from the bank: under the threshold, a decision
|
||||
/// needed. Cedar Studio and Dana Studio: nothing in 2026, not listed.
|
||||
/// - 2025, threshold 600.00: Dana Studio, undecided, 650.00 by a member
|
||||
/// with no method: undetermined (it could reach 600.00), W-9 on file;
|
||||
/// Harbor Design LLC listed (decided for a 1099), nothing paid.
|
||||
#[test]
|
||||
fn books_written_under_p2_open_to_their_record() {
|
||||
let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests/fixtures/old-books/p2_vendor_1099/journal.jsonl");
|
||||
let dir = test_dir("p2-fixture");
|
||||
std::fs::copy(&fixture, dir.join(JOURNAL_FILE)).unwrap();
|
||||
let journal = std::fs::read(dir.join(JOURNAL_FILE)).unwrap();
|
||||
let store = Store::open(&dir).unwrap();
|
||||
store.verify().unwrap();
|
||||
assert!(store.journal_format().features.is_empty());
|
||||
let l = store.ledger();
|
||||
let by_name = |n: &str| l.list_vendors().into_iter().find(|v| v.name == n).unwrap();
|
||||
assert_eq!(
|
||||
by_name("Harbor Design LLC").decisions_1099,
|
||||
[Dated1099::from_beginning(
|
||||
Decision1099::Files(Form1099::Nec),
|
||||
None
|
||||
)]
|
||||
);
|
||||
for n in ["Bigco Supply", "Cedar Studio", "Dana Studio"] {
|
||||
assert_eq!(
|
||||
by_name(n).decisions_1099,
|
||||
[Dated1099::from_beginning(Decision1099::Undetermined, None)],
|
||||
"{n}"
|
||||
);
|
||||
}
|
||||
assert!(by_name("Dana Studio").tin_on_file);
|
||||
let code = |c: &str| l.chart().find_by_code(c).unwrap().id;
|
||||
let report = |year| {
|
||||
ledger_core::vendor_1099_report(
|
||||
l,
|
||||
year,
|
||||
None,
|
||||
&ledger_core::PaymentAccounts {
|
||||
cash: vec![code("1000")],
|
||||
financing: vec![code("2100")],
|
||||
},
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
let rows = |r: &ledger_core::Vendor1099Report| -> Vec<Row1099> {
|
||||
r.vendors
|
||||
.iter()
|
||||
.map(|v| {
|
||||
(
|
||||
v.name.clone(),
|
||||
v.paid,
|
||||
v.undetermined,
|
||||
v.excluded_card,
|
||||
v.status.key(),
|
||||
v.decision_needed,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let c = |n: i64| Decimal::new(n, 2);
|
||||
assert_eq!(
|
||||
rows(&report(2026)),
|
||||
[
|
||||
(
|
||||
"Bigco Supply".into(),
|
||||
c(90_000),
|
||||
c(0),
|
||||
c(0),
|
||||
"under_threshold",
|
||||
true
|
||||
),
|
||||
(
|
||||
"Harbor Design LLC".into(),
|
||||
c(250_000),
|
||||
c(70_000),
|
||||
c(0),
|
||||
"reportable",
|
||||
false
|
||||
),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
rows(&report(2025)),
|
||||
[
|
||||
(
|
||||
"Dana Studio".into(),
|
||||
c(0),
|
||||
c(65_000),
|
||||
c(0),
|
||||
"undetermined",
|
||||
true
|
||||
),
|
||||
// Decided for a 1099 from the beginning: listed, nothing paid.
|
||||
(
|
||||
"Harbor Design LLC".into(),
|
||||
c(0),
|
||||
c(0),
|
||||
c(0),
|
||||
"under_threshold",
|
||||
false
|
||||
),
|
||||
]
|
||||
);
|
||||
drop(store);
|
||||
assert_eq!(std::fs::read(dir.join(JOURNAL_FILE)).unwrap(), journal);
|
||||
}
|
||||
@@ -764,7 +764,7 @@ fn run_recurring_skips_occurrences_in_a_closed_period() {
|
||||
#[test]
|
||||
fn vendor_1099_tags_replay_and_project() {
|
||||
use ledger_core::arap::DocumentLine;
|
||||
use ledger_core::{Form1099, vendor_1099_report};
|
||||
use ledger_core::{Dated1099, Decision1099, Form1099, PaymentAccounts, vendor_1099_report};
|
||||
let dir = test_dir("form1099");
|
||||
let mut store = Store::create(&dir, Currency::USD).unwrap();
|
||||
let cash = store
|
||||
@@ -774,10 +774,36 @@ fn vendor_1099_tags_replay_and_project() {
|
||||
.add_account("6000", "Contract Labor", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let dana = store.add_vendor("Dana").unwrap();
|
||||
store
|
||||
.set_vendor_1099(dana, Some(Form1099::Nec), true)
|
||||
.unwrap();
|
||||
let d = |m, day| NaiveDate::from_ymd_opt(2026, m, day).unwrap();
|
||||
store
|
||||
.set_vendor_1099(
|
||||
dana,
|
||||
&Dated1099::from_beginning(
|
||||
Decision1099::Files(Form1099::Nec),
|
||||
Some(ledger_core::settings::Source {
|
||||
decided_by: "Riley Chen".into(),
|
||||
decided_on: d(1, 5),
|
||||
citation: None,
|
||||
note: None,
|
||||
}),
|
||||
),
|
||||
Some(true),
|
||||
)
|
||||
.unwrap();
|
||||
let report = |l: &ledger_core::Ledger| {
|
||||
vendor_1099_report(
|
||||
l,
|
||||
2026,
|
||||
None,
|
||||
&PaymentAccounts {
|
||||
cash: vec![cash],
|
||||
financing: vec![],
|
||||
},
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
let bill = store
|
||||
.create_bill(
|
||||
dana,
|
||||
@@ -803,7 +829,7 @@ fn vendor_1099_tags_replay_and_project() {
|
||||
store.post_entry(dup).unwrap();
|
||||
store.set_entry_vendor(dup, Some(dana)).unwrap();
|
||||
store.reverse_entry(dup, d(6, 3), "duplicate").unwrap();
|
||||
let before = vendor_1099_report(store.ledger(), 2026, None).unwrap();
|
||||
let before = report(store.ledger());
|
||||
assert_eq!(before.vendors[0].paid, Decimal::from(2200));
|
||||
assert!(before.vendors[0].reportable);
|
||||
drop(store);
|
||||
@@ -814,10 +840,7 @@ fn vendor_1099_tags_replay_and_project() {
|
||||
std::fs::remove_file(dir.join(ledger_store::DB_FILE)).unwrap();
|
||||
}
|
||||
let store = Store::open(&dir).unwrap();
|
||||
assert_eq!(
|
||||
vendor_1099_report(store.ledger(), 2026, None).unwrap(),
|
||||
before
|
||||
);
|
||||
assert_eq!(report(store.ledger()), before);
|
||||
let conn = rusqlite::Connection::open(dir.join(ledger_store::DB_FILE)).unwrap();
|
||||
let tagged: i64 = conn
|
||||
.query_row(
|
||||
@@ -832,6 +855,16 @@ fn vendor_1099_tags_replay_and_project() {
|
||||
.query_row("SELECT form_1099 FROM vendors", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(form, "nec");
|
||||
// The decision, with who made it, line by line.
|
||||
let (decided, by): (String, String) = conn
|
||||
.query_row(
|
||||
"SELECT form_1099, source_json FROM vendor_1099_decisions",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(decided, "nec");
|
||||
assert!(by.contains("Riley Chen"), "{by}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -972,7 +1005,14 @@ mod refusals {
|
||||
AddClass(u8),
|
||||
SetLineClass(u64, usize, Option<u32>),
|
||||
SetEntryVendor(u64, Option<u64>),
|
||||
SetVendor1099(u64, bool),
|
||||
/// A 1099 decision (0 undetermined, 1 nec, 2 a corporation, 3 a
|
||||
/// blank reason, 4 no source), dated or not, with a W-9 flag or
|
||||
/// none.
|
||||
SetVendor1099(u64, u8, Option<u32>, Option<bool>),
|
||||
SetW9Flag(u64, bool),
|
||||
/// A payment method: kind, label, account.
|
||||
AddInstrument(u8, u8, u64),
|
||||
SetEntryInstrument(u64, Option<u64>),
|
||||
SetAccountTax(u64, u8),
|
||||
Renumber(u64, u8),
|
||||
// P3 (defects 8, 15): renames, accounts added with a category,
|
||||
@@ -1051,7 +1091,16 @@ mod refusals {
|
||||
(id.clone(), 0usize..4, prop::option::of(0u32..4))
|
||||
.prop_map(|(e, i, c)| Op::SetLineClass(e, i, c)),
|
||||
(id.clone(), prop::option::of(0u64..4)).prop_map(|(e, v)| Op::SetEntryVendor(e, v)),
|
||||
(0u64..4, any::<bool>()).prop_map(|(v, t)| Op::SetVendor1099(v, t)),
|
||||
(
|
||||
0u64..4,
|
||||
0u8..5,
|
||||
prop::option::of(0u32..120),
|
||||
prop::option::of(any::<bool>())
|
||||
)
|
||||
.prop_map(|(v, k, d, t)| Op::SetVendor1099(v, k, d, t)),
|
||||
(0u64..4, any::<bool>()).prop_map(|(v, t)| Op::SetW9Flag(v, t)),
|
||||
(0u8..6, 0u8..3, 0u64..12).prop_map(|(k, l, a)| Op::AddInstrument(k, l, a)),
|
||||
(id.clone(), prop::option::of(0u64..4)).prop_map(|(e, i)| Op::SetEntryInstrument(e, i)),
|
||||
(0u64..10, 0u8..4).prop_map(|(a, k)| Op::SetAccountTax(a, k)),
|
||||
(0u64..10, 0u8..9).prop_map(|(a, c)| Op::Renumber(a, c)),
|
||||
(0u64..12, 0u8..8).prop_map(|(a, n)| Op::Rename(a, n)),
|
||||
@@ -1222,7 +1271,51 @@ mod refusals {
|
||||
Op::AddClass(n) => store.add_class(["A", "B", ""][n as usize]).map(drop),
|
||||
Op::SetLineClass(e, i, c) => store.set_line_class(e, i, c),
|
||||
Op::SetEntryVendor(e, v) => store.set_entry_vendor(e, v),
|
||||
Op::SetVendor1099(v, t) => store.set_vendor_1099(v, Some(Form1099::Nec), t),
|
||||
Op::SetVendor1099(v, k, d, t) => {
|
||||
let decision = match k {
|
||||
0 => ledger_core::Decision1099::Undetermined,
|
||||
1 => ledger_core::Decision1099::Files(Form1099::Nec),
|
||||
2 => ledger_core::Decision1099::not_reportable(
|
||||
ledger_core::ExemptReason::Corporation,
|
||||
"a corporation by name",
|
||||
)
|
||||
.unwrap(),
|
||||
_ => ledger_core::Decision1099::NotReportable {
|
||||
kind: ledger_core::ExemptReason::Other,
|
||||
reason: " ".into(),
|
||||
},
|
||||
};
|
||||
let source = (k != 4).then(|| ledger_core::settings::Source {
|
||||
decided_by: "Riley Chen".into(),
|
||||
decided_on: day(0),
|
||||
citation: None,
|
||||
note: None,
|
||||
});
|
||||
let dated = ledger_core::Dated1099 {
|
||||
effective: d.map(day),
|
||||
decision,
|
||||
source,
|
||||
};
|
||||
store.set_vendor_1099(v, &dated, t).map(drop)
|
||||
}
|
||||
Op::SetW9Flag(v, t) => store.set_vendor_w9_flag(v, t).map(drop),
|
||||
Op::AddInstrument(k, l, a) => store
|
||||
.add_instrument(
|
||||
[
|
||||
ledger_core::InstrumentKind::Bank,
|
||||
ledger_core::InstrumentKind::BusinessCard,
|
||||
ledger_core::InstrumentKind::MemberCard,
|
||||
ledger_core::InstrumentKind::MemberCashOrCheck,
|
||||
ledger_core::InstrumentKind::Platform,
|
||||
ledger_core::InstrumentKind::Bank,
|
||||
][k as usize],
|
||||
["Harbor Bank", "Riley's Visa", ""][l as usize],
|
||||
(k == 5).then_some("12345"),
|
||||
None,
|
||||
a,
|
||||
)
|
||||
.map(drop),
|
||||
Op::SetEntryInstrument(e, i) => store.set_entry_instrument(e, i).map(drop),
|
||||
Op::SetAccountTax(a, k) => store.set_account_tax(a, TAX[k as usize], None),
|
||||
// The chart's codes, control codes (payroll's 6100 and 2110,
|
||||
// revenue, receivables), a blank, and fresh ones.
|
||||
@@ -1512,6 +1605,7 @@ mod refusals {
|
||||
let journal = dir.join(ledger_store::JOURNAL_FILE);
|
||||
for op in ops {
|
||||
check_invariants(&store)?;
|
||||
let ledger_before = store.ledger().clone();
|
||||
let mut before = std::fs::read_to_string(&journal).unwrap();
|
||||
let result = apply(&mut store, op, &mut || {
|
||||
before = std::fs::read_to_string(&journal).unwrap();
|
||||
@@ -1524,21 +1618,34 @@ mod refusals {
|
||||
Err(e) => eprintln!("REFUSED {kind} {e}"),
|
||||
}
|
||||
}
|
||||
// The converse law: an accepted command is on the record. Only
|
||||
// a batch run may find nothing to do, and a setting changed to
|
||||
// the value it has changes nothing.
|
||||
if result.is_ok()
|
||||
&& !matches!(
|
||||
op,
|
||||
Op::RunDepreciation(..) | Op::RunRecurring(..) | Op::SetTaxPolicy(..)
|
||||
)
|
||||
{
|
||||
// The converse law: an accepted command is on the record, or
|
||||
// changed nothing at all (a batch run with nothing due; a
|
||||
// setting changed to the value it has; a 1099 decision, W-9
|
||||
// flag or payment method already so).
|
||||
if result.is_ok() {
|
||||
let after = std::fs::read_to_string(&journal).unwrap();
|
||||
prop_assert!(
|
||||
after.len() > before.len(),
|
||||
"accepted {:?} but journaled nothing",
|
||||
op
|
||||
);
|
||||
if after.len() == before.len() {
|
||||
let parts = ledger_before.differs_from(store.ledger());
|
||||
prop_assert!(
|
||||
parts.is_empty(),
|
||||
"accepted {:?}, journaled nothing, but changed {:?}",
|
||||
op,
|
||||
parts
|
||||
);
|
||||
prop_assert!(
|
||||
matches!(
|
||||
op,
|
||||
Op::RunDepreciation(..)
|
||||
| Op::RunRecurring(..)
|
||||
| Op::SetTaxPolicy(..)
|
||||
| Op::SetVendor1099(..)
|
||||
| Op::SetW9Flag(..)
|
||||
| Op::SetEntryInstrument(..)
|
||||
),
|
||||
"accepted {:?} but journaled nothing",
|
||||
op
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Err(e) = result {
|
||||
let after = std::fs::read_to_string(&journal).unwrap();
|
||||
@@ -1929,15 +2036,8 @@ fn every_figure_of(l: &ledger_core::Ledger) -> (usize, usize) {
|
||||
financing: vec![],
|
||||
};
|
||||
tally(
|
||||
ledger_core::form1099::vendor_1099_report_with_members(
|
||||
l,
|
||||
year,
|
||||
None,
|
||||
None,
|
||||
&sources,
|
||||
&[],
|
||||
)
|
||||
.map(|_| ()),
|
||||
ledger_core::form1099::vendor_1099_report(l, year, None, &sources, &[], None)
|
||||
.map(|_| ()),
|
||||
);
|
||||
}
|
||||
let money = ledger_core::parties::money_accounts(l, &cash, &[]);
|
||||
|
||||
+164
-48
@@ -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-<year>.csv`, or
|
||||
`1099-worksheet-<year>-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-<year>.csv`, or `1099-worksheet-<year>-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-<year>.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-<year>.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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+261
-52
@@ -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,
|
||||
|
||||
@@ -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: <https://www.govinfo.gov/content/pkg/PLAW-119publ21/html/PLAW-119publ21.htm>
|
||||
Retrieved: 2026-10-20
|
||||
SHA-256 of the retrieved file (`plaw-119publ21.htm`): `84000b07c382db50ead04ce65b70983e2dcd699a931d80cd3d2edac584e9004d`
|
||||
|
||||
The `<<NOTE: ...>>` 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) <<NOTE: 26 USC 3406 note.>> 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: <https://www.ecfr.gov/api/versioner/v1/full/2026-10-19/title-26.xml?part=1§ion=1.6041-1>
|
||||
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: <https://www.ecfr.gov/api/versioner/v1/full/2026-10-19/title-26.xml?part=1§ion=1.6041-2>
|
||||
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: <https://www.ecfr.gov/api/versioner/v1/full/2026-10-19/title-26.xml?part=1§ion=1.6041-3>
|
||||
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: <https://www.ecfr.gov/api/versioner/v1/full/2026-10-19/title-26.xml?part=1§ion=1.6041-4>
|
||||
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: <https://www.ecfr.gov/api/versioner/v1/full/2026-10-19/title-26.xml?part=1§ion=1.6049-4>
|
||||
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: <https://www.ecfr.gov/api/versioner/v1/full/2026-10-19/title-26.xml?part=1§ion=1.6050W-1>
|
||||
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: <https://www.ecfr.gov/api/versioner/v1/full/2026-10-19/title-26.xml?part=301§ion=301.7701-2>
|
||||
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: <https://www.ecfr.gov/api/versioner/v1/full/2026-10-19/title-26.xml?part=301§ion=301.7701-3>
|
||||
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: <https://www.law.cornell.edu/uscode/text/26/6050N>
|
||||
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: <https://www.ecfr.gov/api/versioner/v1/full/2026-10-19/title-26.xml?part=1§ion=1.6045-5>
|
||||
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: <https://www.irs.gov/instructions/i1099k>
|
||||
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: <https://www.irs.gov/businesses/understanding-your-form-1099-k>
|
||||
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: <https://www.irs.gov/instructions/i1099mec>
|
||||
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)).
|
||||
@@ -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
|
||||
|
||||
+27
-3
@@ -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,
|
||||
|
||||
@@ -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<Value> {
|
||||
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> {
|
||||
Value::Array((0..n).map(|_| object(u, depth + 1)).collect::<arbitrary::Result<_>>()?)
|
||||
}
|
||||
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)?),
|
||||
})
|
||||
}
|
||||
|
||||
+2
-2
@@ -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:<br>• the 15 `[company]` keys; EIN as identity<br>• members 50/50 → 3000/3001 with `files_jointly_with`<br>• `accounting_basis=accrual`, `policy.evidence.threshold=0.00` (the owner, 2026-10-12)<br>• `cash_accounts=["1000"]`, `financing_accounts=["2100"]`; the citation is corrected to ASC 230-10-45-14(b)<br>• `parties=require` (2026-10-15), `timezone=America/Denver` (D-13, decided)<br>• `policy.approval_rules` stays `off`<br><br>Conventions are proposed in one batch preview from `AGENTS.md:74-227`:<br>• 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<br>• policies: numbering, one file per receipt (PDF preferred), email as PDF, capitalization (`policy.capitalization` plus decisions 2026-10-11 and 2026-10-16)<br>• `close_step`: "ask the owner whether a member paid anything personally"<br>• `calendar`: Utah annual report around Oct 4, renewal fee to taxes & licenses<br>• memory-only rules become conventions: post when ready; decisions to the owner go one question at a time; TINs only in the vault<br><br>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).<br><br>Open items:<br>• IRS name control "WALN" discrepancy; letter = attachment 70, mailing pending<br>• deferred revenue to be raised with the CPA before the first subscription<br><br>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:<br>• the 15 `[company]` keys; EIN as identity<br>• members 50/50 → 3000/3001 with `files_jointly_with`<br>• `accounting_basis=accrual`, `policy.evidence.threshold=0.00` (the owner, 2026-10-12)<br>• `cash_accounts=["1000"]`, `financing_accounts=["2100"]`; the citation is corrected to ASC 230-10-45-14(b)<br>• `parties=require` (2026-10-15), `timezone=America/Denver` (D-13, decided)<br>• `policy.approval_rules` stays `off`<br><br>Conventions are proposed in one batch preview from `AGENTS.md:74-227`:<br>• 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<br>• policies: numbering, one file per receipt (PDF preferred), email as PDF, capitalization (`policy.capitalization` plus decisions 2026-10-11 and 2026-10-16)<br>• `close_step`: "ask the owner whether a member paid anything personally"<br>• `calendar`: Utah annual report around Oct 4, renewal fee to taxes & licenses<br>• memory-only rules become conventions: post when ready; decisions to the owner go one question at a time; TINs only in the vault<br><br>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).<br><br>Open items:<br>• IRS name control "WALN" discrepancy; letter = attachment 70, mailing pending<br>• deferred revenue to be raised with the CPA before the first subscription<br><br>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.<br>2. Quit the agent sessions and the GUI.<br>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).<br>4. Nothing to start: each command below opens the books while it runs, and afterwards the app or Claude opens them (D-23).<br>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).<br>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`).<br>7. `wanderbooks connect codex --book fernhill`.<br>8. `wanderbooks gui fernhill`.<br>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").<br>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-<date>/` 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).<br>• Extraction is backfilled for all 66 attachments.<br>• 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.<br>• Instruments: Bluevine, joint Amex, member cards, Chase Ink 2050.<br>• `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.<br>• `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).<br>• Extraction is backfilled for all 66 attachments.<br>• 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.<br>• Instruments: Quillmere, joint Amex, member cards, Chase Ink 2050.<br>• `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.<br>• `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). | — |
|
||||
|
||||
|
||||
@@ -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. -/
|
||||
|
||||
Reference in new issue
Block a user