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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user