Files
wanderbooks/crates/ledger-core/src/form1099.rs
T
swaitsandClaude Opus 5.5 1304b9c0ad
check / check (push) Canceled after 0s
P3: dated 1099 decisions, payment methods and the card exception (defects 23, 12, 18, D-40)
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
2026-10-21 12:12:26 -06:00

1470 lines
59 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 1099 tracking: what each vendor was paid in a calendar year, and what
//! of it counts toward the information return the company files for it.
//!
//! **The decision.** A vendor's 1099 decision ([`Decision1099`]) says
//! whether it gets one: a 1099-NEC (contractors, attorneys), a 1099-MISC
//! (rent and the like), *not reportable* with its reason (a corporation,
//! the United States or a state, a payee that sells only merchandise:
//! Treas. Reg. §1.6041-3), or *undetermined*, which is what a vendor
//! nobody has decided on reads as. "Not reportable" and "undetermined" are
//! never the same thing (plan defects 18 and 23). Each decision carries
//! who made it and when, and the date it takes effect ([`Dated1099`],
//! D-40): a payee's status can change (an LLC electing to be taxed as a
//! corporation keeps its EIN and changes status on the election's date),
//! so the worksheet applies the decision in force on each payment's date.
//! A payee that incorporates is a new legal person: a new vendor record
//! with its own W-9. A decision recorded without a date (every one written
//! before decisions were dated) is in force from the beginning.
//!
//! **The payments.** Entries are tagged with the vendor they paid
//! (`pay_bill` tags automatically) and, where it matters, with the
//! payment method ([`crate::instruments`]). Each line of a tagged entry
//! goes in a bucket ([`crate::kernel::payment_bucket`]): what *counts*
//! toward the vendor's 1099, what a *card* or payment network paid (the
//! processor reports it on Form 1099-K, never the company:
//! §1.6041-1(a)(1)(iv), whoever's card it was), and what is
//! *undetermined* until the payment method is recorded (a member paying
//! through the financing liability: their card is excluded, their check
//! counts, since the company is still the payor, §1.6041-1(e)(1)). A
//! payment out of the bank with no method recorded counts (presumed check,
//! ACH or transfer, D-40); tag a debit-card payment with the card. Money
//! moved between the company's own sources (the card bill, a member
//! reimbursed) pays no one ([`crate::kernel::net_buckets`]). A bill is not
//! a payment until it is paid, and a prepayment counts when made: §6041
//! reports payments made in the year.
//!
//! Thresholds are set by law and change; [`default_threshold`] encodes
//! them and the report takes an override. Confirm with the CPA.
use crate::kernel::{self, Bucket, PayRole, Tag};
use crate::settings::Source;
use crate::{AccountId, AccountType, EntryId, Ledger, LedgerError, VendorId};
use chrono::{Datelike, NaiveDate};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap, HashSet};
/// The information return a vendor gets.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Form1099 {
/// 1099-NEC: nonemployee compensation (contractors, attorneys' fees).
Nec,
/// 1099-MISC: rents, royalties, prizes, other income.
Misc,
}
impl Form1099 {
/// Parse "nec", "1099-NEC", "misc", "1099-MISC".
pub fn parse(s: &str) -> Option<Form1099> {
let s = s.trim().to_lowercase();
match s.strip_prefix("1099-").unwrap_or(&s) {
"nec" => Some(Form1099::Nec),
"misc" => Some(Form1099::Misc),
_ => None,
}
}
/// "1099-NEC" or "1099-MISC".
pub fn label(self) -> &'static str {
match self {
Form1099::Nec => "1099-NEC",
Form1099::Misc => "1099-MISC",
}
}
/// "nec" or "misc": the journal's and the tools' key.
pub fn key(self) -> &'static str {
match self {
Form1099::Nec => "nec",
Form1099::Misc => "misc",
}
}
}
/// Why a vendor gets no 1099: the kind of payee no return is required
/// for. Each has its authority ([`ExemptReason::citation`],
/// sources/irs-1099-payment-method.md); a decision adds the facts in
/// words.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExemptReason {
/// A corporation (Treas. Reg. §1.6041-3(p)(1)), an LLC taxed as one
/// included, shown by its name ("Inc.", "Corp.", never "Co.":
/// §1.6049-4(c)(1)(ii)(A)(1)), a W-9, a Form 8832, or the public
/// record. Payments to a corporation for legal services or for medical
/// and health care stay reportable (§1.6041-3(p)(1); Instructions for
/// Forms 1099-MISC and 1099-NEC): never this reason for those.
Corporation,
/// An organization exempt from tax under §501(a), or an individual
/// retirement plan (§1.6041-3(p)(2)).
TaxExempt,
/// The United States (§1.6041-3(p)(3)).
UnitedStates,
/// A State, the District of Columbia, a U.S. possession, or a
/// political subdivision of any of them (§1.6041-3(p)(4)).
State,
/// A foreign government or a political subdivision of one
/// (§1.6041-3(p)(5)).
ForeignGovernment,
/// A foreign payee documented before payment (§1.6041-4(a)(1)): its
/// payments go on Form 1042-S, if anything.
ForeignPerson,
/// A payee that sells only merchandise, telephone, freight, storage
/// and the like (§1.6041-3(c)). A vendor selling services too stays a
/// 1099 vendor.
Merchandise,
/// An employee: wages go on Form W-2 (§1.6041-2(a)(1)).
Employee,
/// Another reason, which the decision's words and citation give.
Other,
}
impl ExemptReason {
/// Every reason, in order.
pub const ALL: [ExemptReason; 9] = [
ExemptReason::Corporation,
ExemptReason::TaxExempt,
ExemptReason::UnitedStates,
ExemptReason::State,
ExemptReason::ForeignGovernment,
ExemptReason::ForeignPerson,
ExemptReason::Merchandise,
ExemptReason::Employee,
ExemptReason::Other,
];
/// Parse a key ("corporation", "united_states", ...), ignoring case and
/// surrounding space.
pub fn parse(s: &str) -> Option<ExemptReason> {
let s = s.trim().to_ascii_lowercase();
ExemptReason::ALL.into_iter().find(|r| r.key() == s)
}
/// Its key, as journaled.
pub fn key(self) -> &'static str {
match self {
ExemptReason::Corporation => "corporation",
ExemptReason::TaxExempt => "tax_exempt",
ExemptReason::UnitedStates => "united_states",
ExemptReason::State => "state",
ExemptReason::ForeignGovernment => "foreign_government",
ExemptReason::ForeignPerson => "foreign_person",
ExemptReason::Merchandise => "merchandise",
ExemptReason::Employee => "employee",
ExemptReason::Other => "other",
}
}
/// In words: "a corporation", "the United States", ...
pub fn label(self) -> &'static str {
match self {
ExemptReason::Corporation => "a corporation",
ExemptReason::TaxExempt => "a tax-exempt organization",
ExemptReason::UnitedStates => "the United States",
ExemptReason::State => "a state or local government",
ExemptReason::ForeignGovernment => "a foreign government",
ExemptReason::ForeignPerson => "a foreign person",
ExemptReason::Merchandise => "sells only merchandise",
ExemptReason::Employee => "an employee",
ExemptReason::Other => "another reason",
}
}
/// The authority that exempts it; none for [`ExemptReason::Other`],
/// whose decision cites its own.
pub fn citation(self) -> Option<&'static str> {
Some(match self {
ExemptReason::Corporation => "Treas. Reg. §1.6041-3(p)(1)",
ExemptReason::TaxExempt => "Treas. Reg. §1.6041-3(p)(2)",
ExemptReason::UnitedStates => "Treas. Reg. §1.6041-3(p)(3)",
ExemptReason::State => "Treas. Reg. §1.6041-3(p)(4)",
ExemptReason::ForeignGovernment => "Treas. Reg. §1.6041-3(p)(5)",
ExemptReason::ForeignPerson => "Treas. Reg. §1.6041-4(a)(1)",
ExemptReason::Merchandise => "Treas. Reg. §1.6041-3(c)",
ExemptReason::Employee => "Treas. Reg. §1.6041-2(a)(1)",
ExemptReason::Other => return None,
})
}
}
/// A vendor's 1099 decision.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Decision1099 {
/// Nobody has decided (or a line from before decisions were kept,
/// which can't say: plan 5.5).
#[default]
Undetermined,
/// It gets this return when paid at least the threshold.
Files(Form1099),
/// No return is required for it, and why.
NotReportable {
/// The kind of payee.
kind: ExemptReason,
/// The facts, in words ("a corporation by name", "Delaware public
/// benefit corporation, from the public record").
reason: String,
},
}
impl Decision1099 {
/// A not-reportable decision, its reason trimmed and checked
/// ([`Decision1099::check`]).
pub fn not_reportable(kind: ExemptReason, reason: &str) -> Result<Decision1099, String> {
let d = Decision1099::NotReportable {
kind,
reason: reason.trim().to_string(),
};
d.check()?;
Ok(d)
}
/// The return it files, if it files one.
pub fn form(&self) -> Option<Form1099> {
match self {
Decision1099::Files(f) => Some(*f),
_ => None,
}
}
/// "nec", "misc", "not_reportable" or "undetermined".
pub fn key(&self) -> &'static str {
match self {
Decision1099::Undetermined => "undetermined",
Decision1099::Files(f) => f.key(),
Decision1099::NotReportable { .. } => "not_reportable",
}
}
/// The decision in words: "1099-NEC", "not reportable: a corporation
/// (by name)", "undetermined".
pub fn describe(&self) -> String {
match self {
Decision1099::Undetermined => "undetermined".into(),
Decision1099::Files(f) => f.label().into(),
Decision1099::NotReportable { kind, reason } => {
format!("not reportable: {} ({reason})", kind.label())
}
}
}
/// Check a decision before it is journaled: a not-reportable reason is
/// one line of at most `settings::MAX_TEXT` characters, trimmed, with
/// no full TIN (P3 review R16).
pub fn check(&self) -> Result<(), String> {
if let Decision1099::NotReportable { reason, .. } = self {
let t = crate::settings::line_text("reason", reason, crate::settings::MAX_TEXT)?;
if t != *reason {
return Err("reason has space around it".into());
}
}
Ok(())
}
}
/// A 1099 decision with the date it takes effect and who made it (D-40).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Dated1099 {
/// The day it takes effect: a W-9's date, or a tax election's
/// effective date. `None`: from the beginning (the vendor's status all
/// along, and every decision written before decisions were dated).
pub effective: Option<NaiveDate>,
/// The decision.
pub decision: Decision1099,
/// Who decided it and when; `None` only on lines written before
/// decisions were kept (the store requires one on every new line).
pub source: Option<Source>,
}
impl Dated1099 {
/// A decision in force from the beginning.
pub fn from_beginning(decision: Decision1099, source: Option<Source>) -> Dated1099 {
Dated1099 {
effective: None,
decision,
source,
}
}
}
/// What recording a dated decision does to a vendor's decisions
/// ([`plan_decision`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Applied {
/// Nothing: it is already recorded exactly so, or a dated decision the
/// same as the one in force before its date (no change on that date).
Unchanged,
/// Recorded: added, or replacing the one recorded for that date.
Set,
/// The decision recorded for that date is taken out: the new one is
/// the same as the one in force before it, so nothing changes there.
Removed,
}
/// The decision in force on `date`: the last one taking effect on or
/// before it (one from the beginning counts). `decisions` are in date
/// order, the undated first ([`apply_decision`] keeps them so).
pub fn in_force(decisions: &[Dated1099], date: NaiveDate) -> Option<&Dated1099> {
decisions
.iter()
.rev()
.find(|d| d.effective.is_none_or(|e| e <= date))
}
/// The decision in force the day before `date`, whatever is recorded for
/// `date` itself.
fn in_force_before(decisions: &[Dated1099], date: NaiveDate) -> Option<&Dated1099> {
decisions
.iter()
.rev()
.find(|d| d.effective.is_none_or(|e| e < date))
}
/// What recording `new` would do to `decisions` (one per effective date,
/// in date order).
///
/// ```text
/// GOAL: each payment takes the vendor's 1099 decision in force on its
/// date (D-40), and recording one never changes a figure it
/// doesn't mean to.
/// SOURCE: DECISION D-40 (owner, 2026-10-20: dated statuses); P3 review
/// R8 (an identical re-record journals nothing).
/// SCENARIOS:
/// 1. IF `new` is recorded exactly so already (decision, date, source):
/// SHOULD be Unchanged.
/// 2. IF `new` is undated: SHOULD be Set (the decision from the
/// beginning, replacing any).
/// 3. IF `new` is dated and its decision is the one in force the day
/// before: SHOULD be Removed when a decision is recorded for that
/// date (taking it out changes nothing before or after), else
/// Unchanged.
/// 4. Otherwise: SHOULD be Set (added for that date, or replacing the
/// one recorded for it).
/// ```
pub fn plan_decision(decisions: &[Dated1099], new: &Dated1099) -> Applied {
let at = decisions.iter().find(|d| d.effective == new.effective);
if at == Some(new) {
return Applied::Unchanged;
}
let Some(date) = new.effective else {
return Applied::Set;
};
let before = in_force_before(decisions, date)
.map(|d| &d.decision)
.unwrap_or(&Decision1099::Undetermined);
if *before == new.decision {
if at.is_some() {
Applied::Removed
} else {
Applied::Unchanged
}
} else {
Applied::Set
}
}
/// Record `new` in `decisions` ([`plan_decision`]), keeping them one per
/// effective date, in date order. Returns what it did.
pub fn apply_decision(decisions: &mut Vec<Dated1099>, new: Dated1099) -> Applied {
let applied = plan_decision(decisions, &new);
match applied {
Applied::Unchanged => {}
Applied::Removed => decisions.retain(|d| d.effective != new.effective),
Applied::Set => {
match decisions.iter().position(|d| d.effective >= new.effective) {
Some(i) if decisions[i].effective == new.effective => decisions[i] = new,
Some(i) => decisions.insert(i, new),
None => decisions.push(new),
};
}
}
applied
}
/// The checks a new 1099 decision passes before it is recorded (replay
/// skips them: rules.md B-28): the decision ([`Decision1099::check`]),
/// its source, its date in the books' calendar, and that a dated decision
/// records a change. Returns what recording it would do.
///
/// ```text
/// GOAL: no dated decision silently leaves the vendor's earlier payments
/// undecided.
/// SOURCE: DECISION D-40 (owner, 2026-10-20); docs/spec/rules.md B-19.
/// SCENARIOS:
/// 1. IF `new` is dated, would be recorded, and no decision other than
/// undetermined is in force at any time before its date: SHOULD
/// refuse
/// BECAUSE a dated decision records a change, and recording only
/// the change would leave every earlier payment undetermined:
/// record the vendor's status from the beginning (no date) first.
/// 2. IF it would only take out the decision recorded for that date
/// (plan_decision SCENARIO 3): SHOULD be allowed.
/// ```
pub fn check_new_decision(decisions: &[Dated1099], new: &Dated1099) -> Result<Applied, String> {
new.decision.check()?;
if let Some(s) = &new.source {
s.check()?;
}
if let Some(d) = new.effective {
crate::calendar::check(d).map_err(|e| e.to_string())?;
}
let applied = plan_decision(decisions, new);
if let (Some(date), Applied::Set) = (new.effective, applied) {
let decided_before = decisions
.iter()
.filter(|d| d.effective.is_none_or(|e| e < date))
.any(|d| d.decision != Decision1099::Undetermined);
if !decided_before {
return Err(format!(
"a dated 1099 decision records a change, and nothing is decided before {date}: record the vendor's status from the beginning first (no effective date), then the change"
));
}
}
Ok(applied)
}
/// The reporting threshold for payments made in `year`.
///
/// ```text
/// GOAL: the dollar amount at which a vendor's payments become reportable.
/// SOURCE: docs/spec/rules.md B-19 (26 U.S.C. §6041(a), (h), §6041A(a)(2),
/// Pub. L. 119-21 §70433(f): payments made after December 31,
/// 2025; sources/irs-1099-thresholds.md,
/// sources/irs-1099-payment-method.md).
/// SCENARIOS:
/// 1. IF `year` <= 2025: SHOULD be $600.
/// 2. IF `year` == 2026: SHOULD be $2,000.
/// 3. IF `year` > rules::THRESHOLD_1099.known_through:
/// - SHOULD refuse with RuleNotKnown
/// - NEVER guess $2,000
/// BECAUSE the amount is indexed for inflation after 2026 and not
/// yet known (owner, 2026-10-13: never apply stale rules).
/// ```
///
/// Royalties (1099-MISC, $10) and attorneys' gross proceeds ($600) are not
/// modeled. Pass an explicit threshold to [`vendor_1099_report`] to
/// override.
pub fn default_threshold(year: i32) -> Result<Decimal, LedgerError> {
let rule = crate::rules::THRESHOLD_1099;
if year > rule.known_through {
return Err(LedgerError::RuleNotKnown {
rule: rule.name.to_string(),
year,
known_through: rule.known_through,
authority: rule.authority.to_string(),
});
}
Ok(if year <= 2025 {
Decimal::new(600, 0)
} else {
Decimal::new(2000, 0)
})
}
/// Where a vendor stands for the year.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Status1099 {
/// Decided for a 1099, and what counts reaches the threshold however
/// the unknown payments turn out: file one.
Reportable,
/// What could count stays under the threshold, however the unknowns
/// are settled. (Royalties at $10 and attorneys' gross proceeds at $600
/// are not modeled: confirm those with the CPA.)
UnderThreshold,
/// Decided not reportable for every payment that could count: no
/// 1099, and the decision says why.
NotReportable,
/// A 1099 depends on something not yet known: the vendor's decision
/// for some of the year's payments, or how some were paid.
Undetermined,
}
impl Status1099 {
/// "reportable", "under_threshold", "not_reportable", "undetermined".
pub fn key(self) -> &'static str {
match self {
Status1099::Reportable => "reportable",
Status1099::UnderThreshold => "under_threshold",
Status1099::NotReportable => "not_reportable",
Status1099::Undetermined => "undetermined",
}
}
}
/// One vendor's year.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Vendor1099Line {
/// The vendor.
pub vendor_id: VendorId,
/// Name.
pub name: String,
/// The decision in force at the end of the year (or of `through`).
pub decision: Decision1099,
/// Who made it and when (none for a line from before decisions were
/// kept, or a vendor never decided).
pub decided: Option<Source>,
/// The date it took effect (none: from the beginning).
pub effective: Option<NaiveDate>,
/// Every decision in force during the year, in date order (more than
/// one when the vendor's status changed in the year; none when the
/// vendor was never decided).
pub decisions: Vec<Dated1099>,
/// W-9 on file.
pub tin_on_file: bool,
/// What counts toward its 1099: paid by check, ACH, transfer or cash
/// (net of refunds and reversals) while it wasn't decided not
/// reportable.
pub paid: Decimal,
/// Paid by card or payment network: the processor's Form 1099-K, never
/// the company's 1099.
pub excluded_card: Decimal,
/// Paid through a financing liability with no payment method
/// recorded: counts or not once its method is known (net).
pub undetermined: Decimal,
/// Paid while the vendor was decided not reportable (not by card).
pub not_reportable: Decimal,
/// Some of the year's payments that could count were made while the
/// vendor's decision was undetermined: a decision is needed.
pub decision_needed: bool,
/// Where it stands ([`Status1099`]).
pub status: Status1099,
/// `status == Reportable`: file a 1099.
pub reportable: bool,
}
impl Vendor1099Line {
/// The return it gets, as decided at the end of the year.
pub fn form(&self) -> Option<Form1099> {
self.decision.form()
}
}
/// One payment on a vendor's year, for the worksheet's lists.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Payment1099 {
/// The entry.
pub entry_id: EntryId,
/// Its date.
pub date: NaiveDate,
/// The vendor it paid.
pub vendor_id: VendorId,
/// The vendor's part of it the list is about.
pub amount: Decimal,
/// The payment method it is tagged with, if any.
pub instrument: Option<crate::InstrumentId>,
}
/// The year's 1099 report.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Vendor1099Report {
/// Calendar year.
pub year: i32,
/// The threshold applied.
pub threshold: Decimal,
/// The last day counted, when the report stops before December 31.
pub through: Option<NaiveDate>,
/// Every vendor decided for a 1099 during the year, or with any figure
/// in it, by name.
pub vendors: Vec<Vendor1099Line>,
/// Entries naming several parties whose shares didn't resolve, in
/// posting order: each counted in full toward its vendor until its
/// shares are fixed ([`vendor_1099_report`] INVARIANT 5).
pub unresolved: Vec<EntryId>,
/// Payments by card or payment network, excluded (posting order).
pub excluded_card_payments: Vec<Payment1099>,
/// Payments whose method must be recorded before they can be
/// classified (posting order); none for a vendor decided not
/// reportable then.
pub undetermined_payments: Vec<Payment1099>,
/// Money received from a vendor for something else (a sale to it, its
/// payment of an invoice, an asset disposed of to it, a loan from it),
/// not netted against its payments, with the reversals of each.
pub other_receipts: Vec<Payment1099>,
/// Posted entries in the year that paid money out for something, with
/// no vendor tag and no payee that rules out a 1099 (the quieter note).
pub untagged_payments: Vec<EntryId>,
}
/// The accounts payments to vendors come out of: the books'
/// `books.cash_accounts` and `books.financing_accounts`.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PaymentAccounts {
/// Cash accounts: bank, savings, cash on hand. Required.
pub cash: Vec<AccountId>,
/// Financing liabilities: what members or lenders paid on the
/// company's behalf (Due to Members, loans).
pub financing: Vec<AccountId>,
}
/// Each account's payment role ([`PayRole`]) for one report.
///
/// ```text
/// GOAL: a payment line's role comes from what the books record, and a
/// line whose method isn't recorded is never presumed a card's
/// unless only a card pays through its account.
/// SOURCE: docs/spec/rules.md B-19 (P3 review R2); DECISION D-40 (a bank
/// payment with no method recorded counts).
/// INVARIANTS (first match wins):
/// 1. Cash: one of the books' cash accounts.
/// 2. Financing: one of the books' financing accounts, or the account of
/// a member's card, a member's cash or check, or a payment platform
/// (a members' account pays by card or by check, a platform's balance
/// by the network or friends-and-family: which, only the entry's
/// method says).
/// 3. CardAccount: the account of a company card (business_card), the
/// card's liability or a card's own balance.
/// 4. Other: anything else (an expense, payables, prepaid).
/// ```
struct Roles {
cash: HashSet<AccountId>,
financing: HashSet<AccountId>,
card: HashSet<AccountId>,
/// Every instrument's account.
instrument: HashSet<AccountId>,
}
impl Roles {
fn new(ledger: &Ledger, sources: &PaymentAccounts) -> Roles {
use crate::InstrumentKind as K;
let named = |kinds: &[K]| -> HashSet<AccountId> {
ledger
.instruments()
.filter(|i| kinds.contains(&i.kind))
.map(|i| i.account)
.collect()
};
Roles {
cash: sources.cash.iter().copied().collect(),
financing: sources
.financing
.iter()
.copied()
.chain(named(&[K::MemberCard, K::MemberCashOrCheck, K::Platform]))
.collect(),
card: named(&[K::BusinessCard]),
instrument: ledger.instruments().map(|i| i.account).collect(),
}
}
fn role(&self, account: AccountId) -> PayRole {
if self.cash.contains(&account) {
PayRole::Cash
} else if self.financing.contains(&account) {
PayRole::Financing
} else if self.card.contains(&account) {
PayRole::CardAccount
} else {
PayRole::Other
}
}
/// Whether money is paid out of `account`.
fn pays(&self, account: AccountId) -> bool {
self.role(account) != PayRole::Other || self.instrument.contains(&account)
}
}
/// An entry's payment, per bucket ([`kernel::split_payment`]), netted
/// ([`kernel::net_buckets`]): `[counts, card, undetermined]`.
fn entry_buckets(
ledger: &Ledger,
entry: &crate::JournalEntry,
roles: &Roles,
) -> Result<[Decimal; 3], LedgerError> {
let tag = ledger
.entry_instrument(entry.id())
.and_then(|i| ledger.get_instrument(i));
let lines: Vec<(Option<Bucket>, crate::Side, Decimal)> = entry
.lines()
.iter()
.map(|l| {
let t = match tag {
None => Tag::None,
Some(i) if i.account == l.account => {
if i.kind.is_card_or_network() {
Tag::ThisCard
} else {
Tag::ThisOther
}
}
Some(_) => Tag::Elsewhere,
};
(
kernel::payment_bucket(roles.role(l.account), t),
l.side,
l.amount.amount,
)
})
.collect();
kernel::split_payment(&lines)
.and_then(kernel::net_buckets)
.ok_or_else(|| LedgerError::TooLargeToCompute(format!("entry {}'s payment", entry.id())))
}
fn sum3(b: [Decimal; 3]) -> Result<Decimal, LedgerError> {
b[0].checked_add(b[1])
.and_then(|s| s.checked_add(b[2]))
.ok_or_else(|| LedgerError::TooLargeToCompute("a payment's buckets".into()))
}
/// The vendor's part of `payment` when the entry names several parties:
/// its resolved share scaled from the share target to the payment (the
/// SCENARIOS of [`vendor_1099_report`], INVARIANT 4). Refused, never
/// wrapped or rounded early, when payment × share is beyond exact
/// arithmetic (both near the line limit; P0 review round 3, S1).
fn vendor_part(payment: Decimal, share: Decimal, target: Decimal) -> Result<Decimal, LedgerError> {
if payment == target {
Ok(share)
} else if target.is_zero() {
Ok(Decimal::ZERO)
} else {
let scaled = payment
.checked_mul(share)
.and_then(|x| x.checked_div(target))
.ok_or_else(|| {
LedgerError::TooLargeToCompute(format!(
"a vendor's share ({share}) of a payment of {payment}"
))
})?;
Ok(scaled.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero))
}
}
/// Split the vendor's part `v` of a netted payment `b` across its buckets
/// in proportion, exactly: each bucket's exact share rounded down to the
/// cent, the cents left over handed out by largest remainder
/// ([`kernel::apportion`], P3 review R12), so the buckets sum to `v` to
/// the last cent. The netted buckets share one sign, so every part takes
/// `v`'s.
fn split_share(b: [Decimal; 3], v: Decimal) -> Result<[Decimal; 3], LedgerError> {
let too_large =
|| LedgerError::TooLargeToCompute(format!("a vendor's share ({v}) of a payment"));
let p = sum3(b)?;
if v == p {
return Ok(b);
}
if p.is_zero() || v.is_zero() {
return Ok([Decimal::ZERO; 3]);
}
let (av, ap) = (v.abs(), p.abs());
let cent = Decimal::new(1, 2);
let times = |x: Decimal| x.checked_mul(ap).ok_or_else(too_large);
let mut parts = [Decimal::ZERO; 3];
let mut rems = [Decimal::ZERO; 3];
for i in 0..3 {
if b[i].is_zero() {
continue;
}
// The exact share is num / ap: rounded down to the cent, checked
// exactly (the division alone rounds past 28 digits).
let num = b[i].abs().checked_mul(av).ok_or_else(too_large)?;
let q = num.checked_div(ap).ok_or_else(too_large)?;
let mut part = q.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::ToZero);
while times(part)? > num {
part -= cent;
}
while times(part + cent)? <= num {
part += cent;
}
// The remainder, over the common denominator ap.
rems[i] = num - times(part)?;
parts[i] = part;
}
kernel::apportion(av, &mut parts, &rems, cent).ok_or_else(too_large)?;
if v.is_sign_negative() {
for x in parts.iter_mut().filter(|x| !x.is_zero()) {
*x = -*x;
}
}
Ok(parts)
}
/// Each posted entry's reversal chain's first entry (the payment itself),
/// from one pass in posting order: a reversal is posted after what it
/// reverses.
fn chain_roots(ledger: &Ledger) -> HashMap<EntryId, EntryId> {
let mut roots: HashMap<EntryId, EntryId> = HashMap::new();
for e in ledger.posted_entries() {
let root = e
.reversal_of()
.and_then(|o| roots.get(&o).copied())
.unwrap_or(e.id());
roots.insert(e.id(), root);
}
roots
}
/// The accounts a refund of a payment credits, giving back what the
/// payment paid for: an expense, payables (a bill's payment refunded), or
/// an asset no money is paid out of that no one owes the company and that
/// is no registered asset's cost (prepaid, a deposit). Revenue,
/// receivables (a customer paying its invoice), a registered asset's cost
/// (a disposal), equity and other liabilities (a loan) are money received
/// for something else (P3 review R6).
fn refund_accounts(ledger: &Ledger, roles: &Roles) -> HashSet<AccountId> {
let payables = ledger.role_account(&crate::PAYABLES);
let receivables = ledger.role_account(&crate::RECEIVABLES);
let costs: HashSet<AccountId> = ledger.registered_assets().map(|a| a.cost_account).collect();
ledger
.chart()
.all()
.filter(|a| match a.account_type {
AccountType::Expense => true,
AccountType::Liability => Some(a.id) == payables,
AccountType::Asset => {
!roles.pays(a.id) && Some(a.id) != receivables && !costs.contains(&a.id)
}
AccountType::Revenue | AccountType::Equity => false,
})
.map(|a| a.id)
.collect()
}
/// What each vendor's year adds up to, before its status is worked out.
#[derive(Default)]
struct Tally {
/// Counts while decided for a 1099, and while undetermined.
paid_files: Decimal,
paid_undecided: Decimal,
card: Decimal,
not_reportable: Decimal,
/// Undetermined amounts per reversal chain (by its first entry), while
/// decided for a 1099, and while undetermined.
unknown_files: BTreeMap<EntryId, Decimal>,
unknown_undecided: BTreeMap<EntryId, Decimal>,
}
fn add(to: &mut Decimal, x: Decimal) -> Result<(), LedgerError> {
*to = to
.checked_add(x)
.ok_or_else(|| LedgerError::TooLargeToCompute("a vendor's payments for the year".into()))?;
Ok(())
}
/// The lowest and highest totals `paid` could reach once every chain's
/// unknown amount is settled one way or the other (a chain's unknowns
/// settle together: its entries share their tag; P3 review R3).
fn bounds(
paid: Decimal,
unknown: &BTreeMap<EntryId, Decimal>,
) -> Result<(Decimal, Decimal), LedgerError> {
let (mut lo, mut hi) = (paid, paid);
for &u in unknown.values() {
add(&mut lo, u.min(Decimal::ZERO))?;
add(&mut hi, u.max(Decimal::ZERO))?;
}
Ok((lo, hi))
}
/// The 1099 report for calendar `year`: per vendor, what counts toward
/// its 1099, what a card or payment network paid, what is unknown, and
/// where it stands; payments out of `sources`, each entry's parties'
/// shares resolved with `members` (the company's members, from the
/// settings), entries dated after `through` left out.
///
/// ```text
/// SOURCE: docs/spec/rules.md B-19 (26 U.S.C. §6041(a): payments made in
/// the year; Treas. Reg. §1.6041-1(a)(1)(iv): card and network
/// payments go on Form 1099-K; §1.6041-1(e)(1): the company is the
/// payor when a member pays its bill; §1.6041-3: payees no return
/// is required for; sources/irs-1099-payment-method.md);
/// crate::shares (an entry with several parties, each with their
/// share; DECISION, owner, 2026-10-15); DECISION D-40 (owner,
/// 2026-10-20: dated decisions; a bank payment with no method
/// recorded counts); B-33 (a worksheet as of a day).
/// DEFINITIONS:
/// buckets(e): kernel::payment_bucket per line (role from `sources` and
/// the instruments' accounts; tag from Ledger::entry_instrument),
/// summed by kernel::split_payment and netted by kernel::net_buckets:
/// [counts, card, undetermined], each credits minus debits.
/// root(e): the first entry of e's reversal chain (the payment itself).
/// decision(e): the vendor's decision in force on root(e)'s date.
/// money: crate::parties::money_accounts(ledger, sources.cash,
/// sources.financing), what the shares resolve against (the MCP server
/// checks them with the same).
/// target(e): crate::shares::target(e, money).amount: what the shares
/// split (rules.md B-35); a reversal's is its original's negated.
/// INVARIANTS:
/// 1. Refuses when `sources.cash` is empty (name the books' cash
/// accounts; never every asset: P3 review R5), and, with no
/// threshold given, when the books' currency isn't USD (the
/// thresholds are in dollars).
/// 2. Only posted entries dated in `year` (and on or before `through`)
/// and tagged with a vendor count, toward that vendor. `through`
/// makes a worksheet as of a day: the payments from January 1
/// through it (§6041(a) and §6041A(a)(2) count what is paid "in any
/// calendar year"; a CPA package as of June 30 shows the year to
/// date, rules.md B-33).
/// 3. An entry naming at most one party: the vendor's amounts are
/// buckets(e).
/// 4. An entry naming several parties (one of them the vendor's party,
/// crate::shares INVARIANT 5): the vendor's part V of the payment P
/// (the buckets' sum) is its resolved share s scaled to it: s when
/// P = target(e), else P × s / target(e) rounded to the nearest cent,
/// half away from zero (0 when the target is 0). V is split across
/// the buckets in proportion, exactly (largest remainder, to the
/// cent), so they sum to V.
/// 5. Several parties whose shares don't resolve (the members changed
/// in the settings, say): buckets(e) in full, the conservative
/// answer, and the entry is listed in `unresolved`.
/// 6. A reversal chain whose payment took money in for something else
/// (root(e) nets money in and credits, beside its payment lines,
/// anything a refund doesn't: revenue, receivables, a registered
/// asset's cost account, equity, a liability other than payables; a
/// sale, a customer's payment, a disposal, a loan from the vendor) is
/// left out of the vendor's figures and listed in `other_receipts`
/// (P3 review R6). A refund (money in crediting only an expense,
/// payables, or an asset no money is paid out of and no one owes, as
/// prepaid or a deposit) and a reversal net.
/// 7. Card goes to excluded_card whatever the decision. Counts and
/// undetermined go to not_reportable when decision(e) is not
/// reportable, else to paid and undetermined.
/// 8. Status, with T the threshold, F the payments decided for a 1099,
/// U those made while undetermined, and lo/hi each part's total with
/// every chain's unknown settled low/high: nothing in F or U is
/// NotReportable when anything was paid while not reportable or the
/// year-end decision is, else UnderThreshold; anything in U is
/// Undetermined when hi(F) + max(hi(U), 0) >= T, else
/// UnderThreshold; otherwise Reportable when lo(F) >= T,
/// UnderThreshold when hi(F) < T, else Undetermined.
/// 9. Listed: every vendor with a decision for a 1099 in force during
/// the year, and every vendor with any figure in it.
/// SCENARIOS:
/// 1. Paid from the bank (no method recorded, or the bank's): counts.
/// Charged to a card, or tagged with a card or network: excluded,
/// listed. Paid by a member through the financing liability:
/// undetermined until tagged; their card excluded, their cash or
/// check counts.
/// 2. A prepayment (Dr prepaid, Cr cash) counts in full; a refund to
/// cash and a reversal subtract; the card bill and a member
/// reimbursed pay no one.
/// 3. $1,000.00 from the bank (Dr contract labor / Cr 1000) shared 60/40
/// by amount between a contractor and a member: the contractor's
/// 1099 counts 600.00.
/// 4. $100.01 from the bank, 50% / 50% contractor first: the
/// contractor gets 50.01 (the leftover cent goes to the first party,
/// crate::shares INVARIANT 8); named second, 50.00.
/// 5. $1,000.00 split 60/40, 600.00 of it from the bank and 400.00 on
/// a card nobody recorded as an instrument: payment 600.00, target
/// 1,000.00, the contractor counts 600.00 × 600.00 / 1,000.00 =
/// 360.00.
/// 6. Reversing scenario 3: the reversal carries the shares, resolves
/// to −600.00, and the year nets to 0.00.
/// 7. A contractor decided 1099-NEC from the beginning and not
/// reportable from December 30 (a corporate election): payments
/// before December 30 count, payments from it on are not
/// reportable.
/// ```
pub fn vendor_1099_report(
ledger: &Ledger,
year: i32,
threshold: Option<Decimal>,
sources: &PaymentAccounts,
members: &[crate::PartyMember],
through: Option<NaiveDate>,
) -> Result<Vendor1099Report, LedgerError> {
let threshold = match threshold {
Some(t) => t,
None => {
let currency = ledger.base_currency();
if currency != crate::Currency::USD {
return Err(LedgerError::Invalid1099(format!(
"the 1099 thresholds are in US dollars and these books keep {}: pass the threshold to use, in {}",
currency.code(),
currency.code()
)));
}
default_threshold(year)?
}
};
if sources.cash.is_empty() {
return Err(LedgerError::Invalid1099(
"the 1099 worksheet counts payments out of the books' cash accounts, and none are named: set them (the books.cash_accounts setting; books.settings shows it)".into(),
));
}
let start = NaiveDate::from_ymd_opt(year, 1, 1)
.ok_or_else(|| LedgerError::Invalid1099(format!("year {year} is outside the calendar")))?;
let year_end = NaiveDate::from_ymd_opt(year, 12, 31).unwrap_or(start);
let end = through.map_or(year_end, |t| t.min(year_end));
let in_window = |d: NaiveDate| d.year() == year && d <= end;
let roles = Roles::new(ledger, sources);
let money = crate::parties::money_accounts(ledger, &sources.cash, &sources.financing);
let roots = chain_roots(ledger);
let refunds = refund_accounts(ledger, &roles);
let mut tallies: HashMap<VendorId, Tally> = HashMap::new();
let mut unresolved = Vec::new();
let mut excluded_card_payments = Vec::new();
let mut undetermined_payments = Vec::new();
let mut other_receipts = Vec::new();
// Each chain's verdict on INVARIANT 6, by its first entry.
let mut other: HashMap<EntryId, bool> = HashMap::new();
for entry in ledger.posted_entries() {
if !in_window(entry.date()) {
continue;
}
let Some(vendor) = ledger.entry_vendor(entry.id()) else {
continue;
};
let Some(v) = ledger.get_vendor(vendor) else {
continue;
};
let root = roots.get(&entry.id()).copied().unwrap_or(entry.id());
let root_entry = ledger.get_entry(root).unwrap_or(entry);
let netted = entry_buckets(ledger, entry, &roles)?;
// The vendor's part of each bucket.
let shares = ledger.entry_parties(entry.id());
let parts = if shares.len() < 2 {
netted
} else {
let party = ledger.party_of_vendor(vendor);
match crate::shares::resolve(ledger, entry.id(), shares, &money, members) {
Ok(resolved) => match resolved.parts.iter().find(|p| Some(p.party) == party) {
Some(share) => split_share(
netted,
vendor_part(sum3(netted)?, share.amount, resolved.target.amount)?,
)?,
// Tagged by hand with a vendor none of the parties
// is: nothing to scale by, so the whole payment.
None => {
unresolved.push(entry.id());
netted
}
},
Err(_) => {
unresolved.push(entry.id());
netted
}
}
};
let [counts, card, unknown] = parts;
let instrument = ledger.entry_instrument(entry.id());
let payment = |amount| Payment1099 {
entry_id: entry.id(),
date: entry.date(),
vendor_id: vendor,
amount,
instrument,
};
// INVARIANT 6: money received from the vendor for something else,
// and its chain.
let is_other = match other.get(&root) {
Some(&o) => o,
None => {
let root_paid = sum3(entry_buckets(ledger, root_entry, &roles)?)?;
let o = root_paid < Decimal::ZERO
&& root_entry.lines().iter().any(|l| {
l.side == crate::Side::Credit
&& !roles.pays(l.account)
&& !refunds.contains(&l.account)
});
other.insert(root, o);
o
}
};
if is_other {
let net = sum3(parts)?;
if !net.is_zero() {
other_receipts.push(payment(net));
}
continue;
}
let t = tallies.entry(vendor).or_default();
if !card.is_zero() {
add(&mut t.card, card)?;
excluded_card_payments.push(payment(card));
}
let decision = v
.decision_on(root_entry.date())
.cloned()
.unwrap_or_default();
match decision {
Decision1099::NotReportable { .. } => {
add(&mut t.not_reportable, counts)?;
add(&mut t.not_reportable, unknown)?;
}
Decision1099::Files(_) => {
add(&mut t.paid_files, counts)?;
if !unknown.is_zero() {
add(t.unknown_files.entry(root).or_default(), unknown)?;
undetermined_payments.push(payment(unknown));
}
}
Decision1099::Undetermined => {
add(&mut t.paid_undecided, counts)?;
if !unknown.is_zero() {
add(t.unknown_undecided.entry(root).or_default(), unknown)?;
undetermined_payments.push(payment(unknown));
}
}
}
}
let empty = Tally::default();
let mut vendors = Vec::new();
for v in ledger.list_vendors() {
let t = tallies.get(&v.id).unwrap_or(&empty);
let decisions = decisions_during(&v.decisions_1099, start, end);
let last = in_force(&v.decisions_1099, end);
let decision = last.map(|d| d.decision.clone()).unwrap_or_default();
let nonzero = |m: &BTreeMap<EntryId, Decimal>| m.values().any(|u| !u.is_zero());
let has_files = !t.paid_files.is_zero() || nonzero(&t.unknown_files);
let has_undecided = !t.paid_undecided.is_zero() || nonzero(&t.unknown_undecided);
let has_exempt = !t.not_reportable.is_zero();
let files_in_year = decisions.iter().any(|d| d.decision.form().is_some());
if !(files_in_year || has_files || has_undecided || has_exempt || !t.card.is_zero()) {
continue;
}
let (lo_f, hi_f) = bounds(t.paid_files, &t.unknown_files)?;
let (_, hi_u) = bounds(t.paid_undecided, &t.unknown_undecided)?;
let status = if !has_files && !has_undecided {
if has_exempt || matches!(decision, Decision1099::NotReportable { .. }) {
Status1099::NotReportable
} else {
Status1099::UnderThreshold
}
} else if has_undecided {
let mut hi = hi_f;
add(&mut hi, hi_u.max(Decimal::ZERO))?;
if hi >= threshold {
Status1099::Undetermined
} else {
Status1099::UnderThreshold
}
} else if lo_f >= threshold {
Status1099::Reportable
} else if hi_f < threshold {
Status1099::UnderThreshold
} else {
Status1099::Undetermined
};
let mut paid = t.paid_files;
add(&mut paid, t.paid_undecided)?;
let mut undetermined = Decimal::ZERO;
for &u in t.unknown_files.values().chain(t.unknown_undecided.values()) {
add(&mut undetermined, u)?;
}
vendors.push(Vendor1099Line {
vendor_id: v.id,
name: v.name.clone(),
decision,
decided: last.and_then(|d| d.source.clone()),
effective: last.and_then(|d| d.effective),
decisions,
// On file by the vendor's flag or its party's W-9 facts.
tin_on_file: v.tin_on_file
|| ledger
.party_of_vendor(v.id)
.and_then(|p| ledger.get_party(p))
.is_some_and(|p| p.w9.is_some()),
paid,
excluded_card: t.card,
undetermined,
not_reportable: t.not_reportable,
decision_needed: has_undecided && hi_u > Decimal::ZERO,
status,
reportable: status == Status1099::Reportable,
});
}
vendors.sort_by(|a, b| a.name.cmp(&b.name).then(a.vendor_id.cmp(&b.vendor_id)));
let untagged_payments = untagged_payments(ledger, &roles, &in_window)?;
Ok(Vendor1099Report {
year,
threshold,
through: (end < year_end).then_some(end),
vendors,
unresolved,
excluded_card_payments,
undetermined_payments,
other_receipts,
untagged_payments,
})
}
/// The decisions in force at some time from `start` through `end`, in
/// date order, a decision the same as the one before it left out.
fn decisions_during(decisions: &[Dated1099], start: NaiveDate, end: NaiveDate) -> Vec<Dated1099> {
let mut out: Vec<Dated1099> = Vec::new();
let first = in_force(decisions, start);
let later = decisions
.iter()
.filter(|d| d.effective.is_some_and(|e| e > start && e <= end));
for d in first.into_iter().chain(later) {
if out.last().map(|l| &l.decision) != Some(&d.decision) {
out.push(d.clone());
}
}
out
}
/// The quieter untagged note's entries (P3 review R7): posted entries in
/// the window with no vendor tag that paid money out (their netted counts
/// and undetermined buckets, as if tagged, above zero) for something (a
/// debit to an expense, to payables, or to an asset no money is paid out
/// of and no one owes), skipping payroll runs, entries marked as having
/// no counterparty, entries naming only parties with no 1099 role, and
/// entries reversed within the window.
fn untagged_payments(
ledger: &Ledger,
roles: &Roles,
in_window: &impl Fn(NaiveDate) -> bool,
) -> Result<Vec<EntryId>, LedgerError> {
let payroll: HashSet<EntryId> = ledger
.list_payroll_runs()
.into_iter()
.map(|r| r.entry_id)
.collect();
let payables = ledger.role_account(&crate::PAYABLES);
let receivables = ledger.role_account(&crate::RECEIVABLES);
let reversed_in_window: HashSet<EntryId> = ledger
.posted_entries()
.filter(|e| in_window(e.date()))
.filter_map(|e| e.reversal_of())
.collect();
let mut out = Vec::new();
for e in ledger.posted_entries() {
if !in_window(e.date())
|| ledger.entry_vendor(e.id()).is_some()
|| payroll.contains(&e.id())
|| ledger.entry_no_party(e.id()).is_some()
|| reversed_in_window.contains(&e.id())
{
continue;
}
let named = ledger.entry_parties(e.id());
if !named.is_empty()
&& !named.iter().any(|s| {
ledger
.get_party(s.party)
.is_some_and(|p| p.roles.iter().any(|r| r.gets_1099()))
})
{
continue;
}
let [counts, _, unknown] = entry_buckets(ledger, e, roles)?;
let out_of_pocket = counts
.checked_add(unknown)
.ok_or_else(|| LedgerError::TooLargeToCompute("a payment".into()))?;
if out_of_pocket <= Decimal::ZERO {
continue;
}
let pays_for_something = e.lines().iter().any(|l| {
l.side == crate::Side::Debit
&& match ledger.chart().get(l.account).map(|a| a.account_type) {
Some(AccountType::Expense) => true,
Some(AccountType::Asset) => {
!roles.pays(l.account) && Some(l.account) != receivables
}
Some(AccountType::Liability) => Some(l.account) == payables,
_ => false,
}
});
if pays_for_something {
out.push(e.id());
}
}
Ok(out)
}
/// The posted entries tagged with `vendor` whose decision in force would
/// change if `new` were recorded, each with its date (the year its 1099
/// is in): for the warning when a decision reaches a closed year, or one
/// whose 1099s were due (D-40, P3 review R15).
pub fn decision_changes(
ledger: &Ledger,
vendor: VendorId,
new: &Dated1099,
) -> Result<Vec<(EntryId, NaiveDate)>, LedgerError> {
let v = ledger
.get_vendor(vendor)
.ok_or(LedgerError::UnknownVendor(vendor))?;
let mut after = v.decisions_1099.clone();
apply_decision(&mut after, new.clone());
let roots = chain_roots(ledger);
let undecided = Decision1099::Undetermined;
let mut out = Vec::new();
for e in ledger.posted_entries() {
if ledger.entry_vendor(e.id()) != Some(vendor) {
continue;
}
let root = roots.get(&e.id()).copied().unwrap_or(e.id());
let on = ledger.get_entry(root).map_or(e.date(), |r| r.date());
let was = in_force(&v.decisions_1099, on).map_or(&undecided, |d| &d.decision);
let will = in_force(&after, on).map_or(&undecided, |d| &d.decision);
if was != will {
out.push((e.id(), e.date()));
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn d(s: &str) -> Decimal {
s.parse().unwrap()
}
/// vendor_part: the vendor's share scaled from the share target to the
/// payment, exactly, rounded to the cent; refused, never a panic, when
/// payment x share is past exact arithmetic (P0 review round 3, S1).
#[test]
fn a_vendor_part_is_scaled_exactly_or_refused() {
assert_eq!(vendor_part(d("100"), d("40"), d("100")), Ok(d("40")));
assert_eq!(vendor_part(d("50"), d("40"), d("100")), Ok(d("20")));
assert_eq!(vendor_part(d("1"), d("1"), d("3")), Ok(d("0.33")));
assert_eq!(vendor_part(d("2"), d("1"), d("3")), Ok(d("0.67")));
assert_eq!(vendor_part(d("5"), d("1"), d("0")), Ok(Decimal::ZERO));
let near = d("450000000000000");
assert!(matches!(
vendor_part(near * d("2"), near, near - d("1")),
Err(LedgerError::TooLargeToCompute(_))
));
assert!(matches!(
vendor_part(Decimal::MAX, Decimal::MAX, d("1")),
Err(LedgerError::TooLargeToCompute(_))
));
}
/// split_share (P3 review R12), by hand. 100.00 netted as 60.00 counts
/// and 40.00 card, the vendor's part 33.33: exactly 19.998 and 13.332,
/// down to the cent 19.99 and 13.33, one cent short of 33.33, which
/// goes to the larger remainder (0.008 against 0.002): 20.00 and
/// 13.33. The parts always sum to the vendor's part, sign included.
#[test]
fn a_vendor_share_splits_across_buckets_to_the_cent() {
let z = Decimal::ZERO;
assert_eq!(
split_share([d("60.00"), d("40.00"), z], d("33.33")),
Ok([d("20.00"), d("13.33"), z])
);
assert_eq!(
split_share([d("-60.00"), d("-40.00"), z], d("-33.33")),
Ok([d("-20.00"), d("-13.33"), z])
);
// The whole payment: unchanged.
assert_eq!(
split_share([d("1"), d("2"), d("3")], d("6")),
Ok([d("1"), d("2"), d("3")])
);
// A cent in thirds: the cent to the first of equal remainders.
assert_eq!(
split_share([d("1.00"), d("1.00"), d("1.00")], d("0.01")),
Ok([d("0.01"), z, z])
);
// Nothing paid, or no share: nothing.
assert_eq!(split_share([z, z, z], d("5")), Ok([z, z, z]));
assert_eq!(split_share([d("5"), z, z], z), Ok([z, z, z]));
// Past exact arithmetic: refused, never wrapped.
let big = d("900000000000000");
assert!(matches!(
split_share([big, big, z], big),
Err(LedgerError::TooLargeToCompute(_))
));
}
fn dated(effective: Option<&str>, decision: Decision1099) -> Dated1099 {
Dated1099 {
effective: effective.map(|s| s.parse().unwrap()),
decision,
source: None,
}
}
/// plan_decision SCENARIOS 1-4, in_force and check_new_decision
/// SCENARIO 1, by hand.
#[test]
fn dated_decisions_take_effect_on_their_dates() {
let nec = Decision1099::Files(Form1099::Nec);
let corp = Decision1099::not_reportable(ExemptReason::Corporation, "elected").unwrap();
let mut ds = Vec::new();
assert_eq!(
apply_decision(&mut ds, dated(None, nec.clone())),
Applied::Set
);
assert_eq!(
apply_decision(&mut ds, dated(Some("2026-12-30"), corp.clone())),
Applied::Set
);
let on = |ds: &[Dated1099], s: &str| {
in_force(ds, s.parse().unwrap()).map(|d| d.decision.clone())
};
assert_eq!(on(&ds, "2026-12-29"), Some(nec.clone()));
assert_eq!(on(&ds, "2026-12-30"), Some(corp.clone()));
assert_eq!(on(&ds, "2030-01-01"), Some(corp.clone()));
// Recorded exactly so: unchanged.
assert_eq!(
plan_decision(&ds, &dated(Some("2026-12-30"), corp.clone())),
Applied::Unchanged
);
// A dated decision the same as before its date: no change there.
assert_eq!(
plan_decision(&ds, &dated(Some("2026-06-01"), nec.clone())),
Applied::Unchanged
);
// Undoing the change: taken out.
assert_eq!(
apply_decision(&mut ds, dated(Some("2026-12-30"), nec.clone())),
Applied::Removed
);
assert_eq!(ds, vec![dated(None, nec.clone())]);
// Kept in date order, the undated first, one per date.
apply_decision(&mut ds, dated(Some("2027-03-01"), corp.clone()));
apply_decision(&mut ds, dated(Some("2026-03-01"), corp.clone()));
let dates: Vec<_> = ds.iter().map(|d| d.effective).collect();
assert_eq!(
dates,
[
None,
Some("2026-03-01".parse().unwrap()),
Some("2027-03-01".parse().unwrap())
]
);
// Nothing decided before a dated decision: refused.
let fresh: Vec<Dated1099> = vec![dated(None, Decision1099::Undetermined)];
assert!(check_new_decision(&fresh, &dated(Some("2026-05-01"), nec.clone())).is_err());
assert!(check_new_decision(&[], &dated(Some("2026-05-01"), nec.clone())).is_err());
assert_eq!(
check_new_decision(&fresh, &dated(None, nec.clone())),
Ok(Applied::Set)
);
}
#[test]
fn decisions_say_what_they_are() {
let corp =
Decision1099::not_reportable(ExemptReason::Corporation, " a corporation by name ")
.unwrap();
assert_eq!(corp.key(), "not_reportable");
assert_eq!(corp.form(), None);
assert_eq!(
corp.describe(),
"not reportable: a corporation (a corporation by name)"
);
assert_eq!(Decision1099::Files(Form1099::Misc).key(), "misc");
assert_eq!(Decision1099::Undetermined.key(), "undetermined");
for bad in ["", "two\nlines", "TIN 123-45-6789"] {
assert!(
Decision1099::not_reportable(ExemptReason::Other, bad).is_err(),
"{bad:?}"
);
}
let long = "x".repeat(crate::settings::MAX_TEXT + 1);
assert!(Decision1099::not_reportable(ExemptReason::Other, &long).is_err());
assert!(
Decision1099::NotReportable {
kind: ExemptReason::Other,
reason: " padded".into()
}
.check()
.is_err()
);
for r in ExemptReason::ALL {
assert_eq!(ExemptReason::parse(r.key()), Some(r));
assert_eq!(r.citation().is_none(), r == ExemptReason::Other);
}
assert_eq!(ExemptReason::parse("card"), None);
}
}