feat(arap): terms, numbers and line accounts in the core (P4b S4.1)

Documents could name only one revenue or general-expenses account per
project, had no number of their own, and took a due date the caller had
to work out. This slice gives the core the pieces the rest of S4 needs,
and journals nothing: an old-shape document still writes byte for byte,
so the owner's books need no upgrade for it.

- Payment terms (B-39): Net n, Net n EOM, due on receipt and a custom
  date, with the early-payment discount and its exact bound
  (kernel::discount_max, Kani + bridge). A due date past the calendar's
  end is refused, never a panic, and a discount is refused with terms
  that have no net days.
- Invoice numbers (B-40, A-2): first_number plus the count of numbered
  invoices, zero-padded to four digits and wider past 9,999, stored at
  issue, kept by a void; legacy invoices are uncounted.
- Line accounts (A-10): a document line may name its account, and the
  entry posts one line per (account, project) part. An invoice line takes
  revenue or a liability holding no role (deferred revenue); a bill line
  an expense or a non-cash asset holding no role. Roles, cash accounts
  and headers are refused.
- A line's amount is exactly its quantity times its unit price
  (kernel::line_amount, Kani + bridge): both or neither, never rounded.
- A vendor's invoice number names one standing bill per vendor, with
  case, spaces and hyphens ignored, unless a duplicate reason says why
  (B-40).
- The record fields S4.3 will journal: number, terms, bill-to and seller
  snapshots, customer reference, memo, the empty sends list S5 fills, and
  the bill's received_on, all optional and defaulted.

The store refuses a line carrying an account, quantity or unit price
before it rehearses or journals anything: those fields are journaled from
S4.3's documents_v2 on, and I-3 will not let a public write replay
without them. `document_line_fields_wait_for_documents_v2` proves the
refusal, the unchanged journal and allocators, and that an ordinary line
journals exactly as before. S4.3 removes this guard in the same change
that journals the fields and adds its byte-for-byte test.

The document commands keep their signatures; create_invoice_with and
create_bill_with take the optional fields for S4.3 and for tests. They
are the existing issue commands with more fields, not a new state
transition, so no Lean Step is added. Kani: line_amount_is_exact runs in
`just prove`; discount_max_is_bounded relates a product to a quotient and
runs in `prove-slow` (163 s), as the proration charge harnesses do.

just check green; just prove green.
This commit is contained in:
swaits committed 2026-10-27 00:02:51 -06:00
1 parent 58724bb007
commit 13282cf80e
44 files changed
+1986 -67

No files matched your search

+541 -21
View File
@@ -2,8 +2,10 @@
//!
//! Invoices and bills are business documents that auto-post journal entries:
//!
//! * `create_invoice`: Dr `1200` Accounts Receivable, Cr `4000` Revenue.
//! * `create_bill`: Dr `6999` General Expenses, Cr `2000` Accounts Payable.
//! * `create_invoice`: Dr `1200` Accounts Receivable, Cr each line's account
//! (the revenue role, `4000`, when a line names none).
//! * `create_bill`: Dr each line's account (the general-expenses role,
//! `6999`, when a line names none), Cr `2000` Accounts Payable.
//! * `receive_payment`: Dr Cash, Cr A/R.
//! * `pay_bill`: Dr A/P, Cr Cash.
//!
@@ -21,10 +23,15 @@
//! closed period.
use crate::LedgerError;
use chrono::NaiveDate;
use chrono::{Datelike, Days, NaiveDate};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
/// The most days a payment term may run: ten years, far beyond any trade
/// term, and short of the calendar's end for every document date the
/// books can hold (B-39).
pub const MAX_TERMS_DAYS: u16 = 3_650;
/// Opaque customer handle.
pub type CustomerId = u64;
/// Opaque vendor handle.
@@ -93,6 +100,17 @@ pub enum DocumentStatus {
Paid,
}
/// The two kinds of document (an invoice or a bill), without an id: what
/// a line's account rule and the journal's event names key on.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DocumentKind {
/// A sales invoice (receivables).
Invoice,
/// A vendor bill (payables).
Bill,
}
/// A document: an invoice or a bill, the two kinds whose ledger lines
/// carry their document ([`DocTag`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
@@ -407,6 +425,393 @@ impl PaymentDetails {
}
}
/// How a document's payment terms count their days (rules.md B-39; plan
/// P4b S4.1): from the document's date, from the end of its month, or a
/// date the parties agreed.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TermsKind {
/// Due on the document's own date.
DueOnReceipt,
/// Due `n` days after the document's date (Net n).
Net(u16),
/// Due `n` days after the last day of the document's month (Net n
/// EOM).
NetEom(u16),
/// Due on a date the parties agreed; the caller passes it to
/// [`Terms::due_date`], and [`Terms::describe`] carries it for
/// people.
Custom,
}
/// An early-payment discount: take `percent_bp` off if paid within
/// `days` of the document's date ("2/10 net 30").
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct EarlyDiscount {
/// The discount in basis points: 1 to 9,999 (0.01% to 99.99%).
pub percent_bp: u32,
/// The days after the document's date the discount is open.
pub days: u16,
}
/// Payment terms: one due date, and the early-payment discount open
/// before it (rules.md B-39; plan P4b S4.1, A-2, A-8).
///
/// ```text
/// GOAL: terms give one due date and one discount date, exactly.
/// SOURCE: docs/spec/rules.md B-39; plan/served.md 8.3.13 (B-39);
/// CONVENTION: standard trade-terms notation; UCC §2-310(a) for
/// goods; for services, the contract.
/// INVARIANTS:
/// 1. Net n = date + n; Net n EOM = the last day of date's month + n;
/// due on receipt = date; Custom = the given date. Every result is
/// >= the document date and in the calendar (calendar::check); n is
/// 0..=3650.
/// 2. discount_by = date + d, d < the net days; no discount with due on
/// receipt, EOM or custom terms.
/// 3. describe() is the canonical text; parse() accepts it and the
/// canonical spellings, case-insensitively, and nothing else.
/// ```
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Terms {
/// How the due date counts.
kind: TermsKind,
/// The early-payment discount, when there is one.
#[serde(default, skip_serializing_if = "Option::is_none")]
discount: Option<EarlyDiscount>,
/// The canonical text: "Net 30", "Net 15 EOM", "2/10 net 30", "Due on
/// receipt", "Due 2026-11-15".
text: String,
}
impl Terms {
/// Terms for `kind`, with `discount` when given, canonical text
/// composed. Refused when the days are past [`MAX_TERMS_DAYS`], the
/// discount is outside 1..=9,999 basis points, or a discount is asked
/// for terms that count no net days (due on receipt, EOM or custom).
pub fn new(kind: TermsKind, discount: Option<EarlyDiscount>) -> Result<Self, LedgerError> {
if let TermsKind::Net(n) | TermsKind::NetEom(n) = kind
&& n > MAX_TERMS_DAYS
{
return Err(LedgerError::TermsUnreadable(format!(
"net days {n} is outside 0..={MAX_TERMS_DAYS}"
)));
}
if let Some(d) = discount {
if !(1..=9_999).contains(&d.percent_bp) {
return Err(LedgerError::TermsUnreadable(format!(
"discount {} basis points is outside 1..=9999",
d.percent_bp
)));
}
match kind {
TermsKind::Net(n) if d.days < n => {}
_ => return Err(LedgerError::TermsDiscountNeedsNetDays),
}
}
// Custom terms are a date; without one there is nothing to print
// or derive (Terms::custom is the way to give it).
if kind == TermsKind::Custom {
return Err(LedgerError::TermsUnreadable(
"custom terms need their date: use Terms::custom".into(),
));
}
Ok(Terms {
kind,
discount,
text: compose_terms_text(kind, discount),
})
}
/// Custom terms due on `due`: the parties agreed a date rather than a
/// count of days. Its text carries the date for people; the document
/// stores the due date itself.
pub fn custom(due: NaiveDate) -> Result<Self, LedgerError> {
crate::calendar::check(due)?;
Ok(Terms {
kind: TermsKind::Custom,
discount: None,
text: format!("Due {due}"),
})
}
/// How the due date counts.
pub fn kind(&self) -> TermsKind {
self.kind
}
/// The early-payment discount, when there is one.
pub fn discount(&self) -> Option<EarlyDiscount> {
self.discount
}
/// The canonical text ("Net 30", "2/10 net 30", "Due 2026-11-15").
pub fn describe(&self) -> String {
self.text.clone()
}
/// The date payment is due for a document dated `date`.
///
/// For [`TermsKind::Custom`] the caller passes the agreed date itself
/// (there is no count to derive it from); for every other kind `date`
/// is the document's date. The result is never before `date` and
/// always in the calendar: a due date past 9999-12-31 is refused
/// ([`LedgerError::DateOverflow`]), never a panic.
pub fn due_date(&self, date: NaiveDate) -> Result<NaiveDate, LedgerError> {
crate::calendar::check(date)?;
let due = match self.kind {
TermsKind::DueOnReceipt | TermsKind::Custom => date,
TermsKind::Net(n) => add_days(date, n)?,
TermsKind::NetEom(n) => add_days(last_day_of_month(date)?, n)?,
};
validate_due_date(date, due)?;
Ok(due)
}
/// The last date the early-payment discount is open for a document
/// dated `date`: `date + discount.days`, or `None` when there is no
/// discount or the date is outside the calendar.
pub fn discount_by(&self, date: NaiveDate) -> Option<NaiveDate> {
let d = self.discount?;
let by = date.checked_add_days(Days::new(u64::from(d.days)))?;
crate::calendar::check(by).ok()
}
/// Read the canonical spellings, case-insensitively: "Net 30", "Net 15
/// EOM", "2/10 net 30", "Due on receipt", "Due 2026-11-15". Anything
/// else is [`LedgerError::TermsUnreadable`].
pub fn parse(text: &str) -> Result<Self, LedgerError> {
let raw = text.trim();
let lower = raw.to_lowercase();
let unreadable = || LedgerError::TermsUnreadable(raw.to_string());
if lower == "due on receipt" {
return Terms::new(TermsKind::DueOnReceipt, None);
}
if let Some(rest) = lower.strip_prefix("due ") {
let due =
NaiveDate::parse_from_str(rest.trim(), "%Y-%m-%d").map_err(|_| unreadable())?;
return Terms::custom(due).map_err(|_| unreadable());
}
// "2/10 net 30" (an optional decimal percentage), or "net 30" /
// "net 15 eom".
let (percent, days, net, eom) = match lower.split_once('/') {
Some((pct, rest)) => {
let (days, net_part) = rest.trim().split_once(' ').ok_or_else(unreadable)?;
let after = net_part
.trim()
.strip_prefix("net ")
.ok_or_else(unreadable)?
.trim();
let (net, eom) = match after.strip_suffix(" eom") {
Some(n) => (n, true),
None => (after, false),
};
(
Some(parse_percent(pct).ok_or_else(unreadable)?),
days,
net,
eom,
)
}
None => {
let rest = lower.strip_prefix("net ").ok_or_else(unreadable)?;
let (net, eom) = match rest.trim().strip_suffix(" eom") {
Some(n) => (n, true),
None => (rest.trim(), false),
};
(None, "0", net, eom)
}
};
let net: u16 = net.trim().parse().map_err(|_| unreadable())?;
let discount = match (percent, days) {
(Some(percent_bp), days_raw) => {
let days: u16 = days_raw.parse().map_err(|_| unreadable())?;
Some(EarlyDiscount { percent_bp, days })
}
(None, _) => None,
};
let kind = if eom {
TermsKind::NetEom(net)
} else {
TermsKind::Net(net)
};
Terms::new(kind, discount).map_err(|_| unreadable())
}
}
/// The canonical text of terms: `"2/10 net 30"` with a discount, else
/// `"Net 30"`, `"Net 15 EOM"` or `"Due on receipt"`.
fn compose_terms_text(kind: TermsKind, discount: Option<EarlyDiscount>) -> String {
match (discount, kind) {
(Some(EarlyDiscount { percent_bp, days }), TermsKind::Net(n)) => {
format!("{}/{} net {n}", percent_text(percent_bp), days)
}
(_, TermsKind::Net(n)) => format!("Net {n}"),
(_, TermsKind::NetEom(n)) => format!("Net {n} EOM"),
(_, TermsKind::DueOnReceipt) => "Due on receipt".to_string(),
(_, TermsKind::Custom) => "Due".to_string(),
}
}
/// Basis points as the percentage trade terms print: 200 is "2", 250 is
/// "2.5", 205 is "2.05".
fn percent_text(percent_bp: u32) -> String {
let whole = percent_bp / 100;
let frac = percent_bp % 100;
match (whole, frac) {
(w, 0) => w.to_string(),
(w, f) if f % 10 == 0 => format!("{w}.{}", f / 10),
(w, f) => format!("{w}.{f:02}"),
}
}
/// A percentage in trade terms as basis points: "2" is 200, "2.5" is 250,
/// "2.05" is 205. None when it isn't a percentage with at most two
/// decimal places.
fn parse_percent(s: &str) -> Option<u32> {
let (whole, frac) = match s.trim().split_once('.') {
Some((w, f)) => (w, f),
None => (s.trim(), ""),
};
if frac.len() > 2 || !frac.chars().all(|c| c.is_ascii_digit()) {
return None;
}
let whole: u32 = whole.parse().ok()?;
let frac: u32 = if frac.is_empty() {
0
} else {
format!("{frac:0<2}").parse().ok()?
};
whole.checked_mul(100)?.checked_add(frac)
}
/// `date` plus `n` days, or [`LedgerError::DateOverflow`] past the
/// calendar's end.
fn add_days(date: NaiveDate, n: u16) -> Result<NaiveDate, LedgerError> {
date.checked_add_days(Days::new(u64::from(n)))
.ok_or(LedgerError::DateOverflow(date))
}
/// The last day of `date`'s month, or [`LedgerError::DateOverflow`] when
/// there is no such date in the calendar.
fn last_day_of_month(date: NaiveDate) -> Result<NaiveDate, LedgerError> {
let (year, month) = if date.month() == 12 {
(
date.year()
.checked_add(1)
.ok_or(LedgerError::DateOverflow(date))?,
1,
)
} else {
(date.year(), date.month() + 1)
};
NaiveDate::from_ymd_opt(year, month, 1)
.and_then(|first| first.pred_opt())
.ok_or(LedgerError::DateOverflow(date))
}
/// The parties on a document, as they were when it was issued (plan P4b
/// S4.1, A-13): a name, and the address and email that were on the
/// document. Never today's party record when a snapshot exists.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PartySnapshot {
/// The party's name at issue.
pub name: String,
/// Its address at issue, when the document printed one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub address: Option<String>,
/// Its email at issue, when the document printed one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
}
/// How an invoice was sent (plan P4b S5; the type S4.1 adds so
/// [`Invoice::sends`] has one from the start).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SentVia {
/// Email.
Email,
/// Paper mail.
Mail,
/// A customer or vendor portal.
Portal,
/// Handed over.
Hand,
/// Another channel, named.
Other(String),
}
/// Whether a send was the original or a reminder (plan P4b S5).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SendKind {
/// The document first went out.
Original,
/// It went out again.
Reminder,
}
/// One recorded send of an invoice (plan P4b S5): who sent it, how, to
/// whom, and the channel's own reference. A send changes no figure.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Sent {
/// The day it went out.
pub on: NaiveDate,
/// Who sent it: the owner, or a named delegate such as an agent (A-4).
pub by: String,
/// The channel.
pub via: SentVia,
/// The recipients.
pub to: Vec<String>,
/// Original or reminder.
pub kind: SendKind,
/// The channel's own id (a message id), when there is one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reference: Option<String>,
/// A note about the send, when there is one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
/// The fields a new invoice carries beyond its figures (plan P4b S4.1).
/// Every one is optional: all `None` is the invoice every earlier version
/// wrote, posted and journaled exactly as before.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct InvoiceFields {
/// The number to issue it under ([`crate::Ledger::next_invoice_number`]).
pub number: Option<String>,
/// Its payment terms.
pub terms: Option<Terms>,
/// The customer as the document prints it.
pub bill_to: Option<PartySnapshot>,
/// Us as the document prints us.
pub seller: Option<PartySnapshot>,
/// The customer's own reference (their purchase order).
pub customer_ref: Option<String>,
/// A memo printed on the document.
pub memo: Option<String>,
}
/// The fields a new bill carries beyond its figures (plan P4b S4.1).
/// Every one is optional: all `None` is the bill every earlier version
/// wrote, posted and journaled exactly as before.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct BillFields {
/// The vendor's own invoice number.
pub vendor_ref: Option<String>,
/// The day we received the bill, when it differs from its date.
pub received_on: Option<NaiveDate>,
/// Its payment terms.
pub terms: Option<Terms>,
/// A memo about the bill.
pub memo: Option<String>,
/// Why a vendor invoice number that already names a standing bill is
/// entered again anyway. Journaled with the bill (S4.3); never part of
/// the record itself.
pub duplicate_reason: Option<String>,
}
/// One line on an invoice or bill.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct DocumentLine {
@@ -421,6 +826,23 @@ pub struct DocumentLine {
/// have none.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub class_id: Option<crate::ClassId>,
/// The account the line's revenue, expense or asset goes to (plan P4b
/// S4.1, A-10): an invoice line's revenue or a liability holding no
/// role; a bill line's expense or a non-cash asset holding no role.
/// None is the kind's role account (revenue for an invoice, general
/// expenses for a bill), which is also what every legacy line means.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub account: Option<crate::AccountId>,
/// How many units the line bills, when it gives a quantity and price
/// rather than one amount. It and `unit_price` are both given or
/// neither, and `quantity × unit_price` must equal `amount` exactly
/// (plan P4b S4.1).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quantity: Option<Decimal>,
/// The price of one unit, when the line gives a quantity. See
/// [`Self::quantity`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unit_price: Option<Decimal>,
}
/// A sales invoice: we billed a customer.
@@ -455,6 +877,30 @@ pub struct Invoice {
pub status: DocumentStatus,
/// The auto-posted journal entry.
pub entry_id: u64,
/// The number it was issued under ("INV-0007"), from
/// [`crate::Ledger::next_invoice_number`]; stored at issue, never
/// recomputed, kept by a void (A-2, B-40). None on a legacy invoice:
/// it is shown as "Invoice {id}" and never counted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub number: Option<String>,
/// Its payment terms, when it has them.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub terms: Option<Terms>,
/// The customer as the document printed it, when it printed one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bill_to: Option<PartySnapshot>,
/// Us as the document printed us, when it printed us.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seller: Option<PartySnapshot>,
/// The customer's own reference (their purchase order), when given.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub customer_ref: Option<String>,
/// A memo printed on the document, when given.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memo: Option<String>,
/// Every recorded send, in order (plan P4b S5; empty until then).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sends: Vec<Sent>,
}
/// The sum of a document's lines; refused (TooLargeToCompute), never a
@@ -468,39 +914,51 @@ pub fn document_total(lines: &[DocumentLine], what: &str) -> Result<Decimal, Led
.ok_or_else(|| LedgerError::TooLargeToCompute(format!("{what}'s total")))
}
/// The revenue (an invoice's) or expense (a bill's) side of a document's
/// entry: one part per project the lines name, in the order each first
/// appears, the untagged lines together as one part (`None`), each the
/// sum of its lines ([`document_total`]).
/// The account and project a document's lines group by (plan P4b S4.1):
/// None for either means the kind's role account (an invoice's revenue, a
/// bill's general expenses) and no project.
pub type DocumentPartKey = (Option<crate::AccountId>, Option<crate::ClassId>);
/// The revenue (an invoice's), expense or asset (a bill's) side of a
/// document's entry: one part per (account, project) the lines name, in
/// the order each first appears, the lines naming neither together as one
/// part (`(None, None)`), each the sum of its lines ([`document_total`]).
///
/// ```text
/// GOAL: a document's lines count toward their projects, and a document
/// whose lines name none posts exactly as before.
/// GOAL: a document's lines post to their accounts and count toward their
/// projects, and a document whose lines name neither posts exactly
/// as before.
/// SOURCE: docs/spec/rules.md B-37 item 3 (job costing: the revenue and
/// direct costs identified with one job) and item 8 (an
/// invoice's or bill's line names its project).
/// invoice's or bill's line names its project); B-39 and plan
/// P4b S4.1 A-10 (every bill line names its account).
/// INVARIANTS:
/// 1. The parts sum to the document's total (conservation), and each
/// line is in exactly one part.
/// 2. No two parts name the same project; no part is empty.
/// 3. Lines naming no project make one part, so an untagged document is
/// one part: the one line every document posted before projects.
/// 2. No two parts name the same account and project; no part is empty.
/// 3. Lines naming neither make one part, so a document with no new
/// fields is one part: the one line every document posted before
/// S4.1.
/// 4. NEVER panics: TooLargeToCompute past exact arithmetic.
/// ```
pub fn document_parts(
lines: &[DocumentLine],
what: &str,
) -> Result<Vec<(Option<crate::ClassId>, Decimal)>, LedgerError> {
let mut keys: Vec<Option<crate::ClassId>> = Vec::new();
) -> Result<Vec<(DocumentPartKey, Decimal)>, LedgerError> {
let mut keys: Vec<DocumentPartKey> = Vec::new();
for l in lines {
if !keys.contains(&l.class_id) {
keys.push(l.class_id);
let key = (l.account, l.class_id);
if !keys.contains(&key) {
keys.push(key);
}
}
keys.into_iter()
.map(|k| {
let part: Vec<DocumentLine> =
lines.iter().filter(|l| l.class_id == k).cloned().collect();
let part: Vec<DocumentLine> = lines
.iter()
.filter(|l| (l.account, l.class_id) == k)
.cloned()
.collect();
Ok((k, document_total(&part, what)?))
})
.collect()
@@ -553,6 +1011,19 @@ pub struct Bill {
pub status: DocumentStatus,
/// The auto-posted journal entry.
pub entry_id: u64,
/// The vendor's own invoice number, when given: what a duplicate
/// check reads (B-40; [`crate::Ledger::check_vendor_ref`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vendor_ref: Option<String>,
/// The day we received the bill, when it differs from its date.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub received_on: Option<NaiveDate>,
/// Its payment terms, when it has them.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub terms: Option<Terms>,
/// A memo about the bill, when given.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memo: Option<String>,
}
impl Bill {
@@ -571,8 +1042,9 @@ impl Bill {
}
/// Validate document lines: at least one, all positive amounts, each in a
/// journal line's range. For new documents; replay restores them as
/// journaled (rules.md B-28).
/// journal line's range, and a quantity and unit price that multiply to
/// the amount exactly (plan P4b S4.1). For new documents; replay restores
/// them as journaled (rules.md B-28).
pub fn validate_lines(lines: &[DocumentLine]) -> Result<(), LedgerError> {
if lines.is_empty() {
return Err(LedgerError::EmptyDocumentLines);
@@ -589,6 +1061,36 @@ pub fn validate_lines(lines: &[DocumentLine]) -> Result<(), LedgerError> {
line.amount
)));
}
match (line.quantity, line.unit_price) {
(None, None) => {}
(Some(quantity), Some(unit_price)) => {
if quantity <= Decimal::ZERO || unit_price <= Decimal::ZERO {
return Err(LedgerError::LineArithmetic(format!(
"line '{}': quantity {quantity} and unit price {unit_price} must both be positive",
line.description
)));
}
let product =
crate::kernel::line_amount(quantity, unit_price).ok_or_else(|| {
LedgerError::LineArithmetic(format!(
"line '{}': {quantity} x {unit_price} is too large to compute exactly",
line.description
))
})?;
if product != line.amount {
return Err(LedgerError::LineArithmetic(format!(
"line '{}': {quantity} x {unit_price} = {product}, not the line's amount {}; the engine never rounds a quantity times a price",
line.description, line.amount
)));
}
}
(quantity, unit_price) => {
return Err(LedgerError::LineArithmetic(format!(
"line '{}': a quantity and a unit price are both given or neither (quantity {quantity:?}, unit price {unit_price:?})",
line.description
)));
}
}
}
Ok(())
}
@@ -739,6 +1241,9 @@ mod tests {
description: "Widgets".to_string(),
amount: "1000.00".parse().unwrap(),
class_id: None,
account: None,
quantity: None,
unit_price: None,
}],
)
.unwrap();
@@ -789,6 +1294,9 @@ mod tests {
description: "Paper".to_string(),
amount: "250.00".parse().unwrap(),
class_id: None,
account: None,
quantity: None,
unit_price: None,
}],
)
.unwrap();
@@ -831,6 +1339,9 @@ mod tests {
description: "X".to_string(),
amount: "100.00".parse().unwrap(),
class_id: None,
account: None,
quantity: None,
unit_price: None,
}],
)
.unwrap();
@@ -909,6 +1420,9 @@ mod tests {
description: "A".to_string(),
amount: "100.00".parse().unwrap(),
class_id: None,
account: None,
quantity: None,
unit_price: None,
}],
)
.unwrap();
@@ -921,6 +1435,9 @@ mod tests {
description: "B".to_string(),
amount: "200.00".parse().unwrap(),
class_id: None,
account: None,
quantity: None,
unit_price: None,
}],
)
.unwrap();
@@ -951,6 +1468,9 @@ mod tests {
description: "X".to_string(),
amount: "100.00".parse().unwrap(),
class_id: None,
account: None,
quantity: None,
unit_price: None,
}],
);
assert!(matches!(err, Err(LedgerError::ClosedPeriod(_, _))));
+61
View File
@@ -601,6 +601,67 @@ pub enum LedgerError {
#[error("document line amount must be positive, got {0}")]
InvalidDocumentLineAmount(Decimal),
/// A document line's quantity and unit price don't multiply to its
/// amount, or only one of the two is given: the engine never rounds
/// one (plan P4b S4.1; rules.md B-39).
#[error("{0}")]
LineArithmetic(String),
/// A document line is on an account that kind of document can't post
/// to (plan P4b S4.1, A-10; rules.md B-39): a role account, a cash
/// account, or a type the document's side doesn't take.
#[error("account {code} \"{name}\" cannot take {kind} line: {why}")]
DocumentLineAccount {
/// The account's code.
code: String,
/// Its name.
name: String,
/// "an invoice" or "a bill".
kind: &'static str,
/// What the rule is, in a sentence.
why: String,
},
/// A vendor's invoice number (normalized) already names a standing
/// bill of that vendor, and no duplicate reason was given (rules.md
/// B-40).
#[error(
"vendor invoice number '{vendor_ref}' is already on another standing bill of this vendor; \
give duplicate_reason when it really is a second bill"
)]
DuplicateVendorRef {
/// The vendor the bill is from.
vendor: u64,
/// The number as given.
vendor_ref: String,
},
/// An invoice number already names an invoice: numbers are never
/// reused (rules.md B-40, A-2).
#[error("invoice number '{0}' is already used; numbers are never reused")]
DuplicateInvoiceNumber(String),
/// Payment terms could not be read: the message says what to write.
#[error(
"payment terms '{0}' are not in a form the books read (Net 30, Net 15 EOM, 2/10 net 30, Due on receipt, Due 2026-11-15)"
)]
TermsUnreadable(String),
/// An early-payment discount needs net days to be open before: it
/// can't be given with due-on-receipt, end-of-month or custom terms
/// (rules.md B-39).
#[error(
"an early-payment discount needs net terms with a fixed number of days; it can't be given with due-on-receipt, end-of-month or custom terms"
)]
TermsDiscountNeedsNetDays,
/// A date computed from another (payment terms' due or discount date)
/// falls past the calendar the books keep.
#[error(
"a date computed from {0} is outside the calendar the books keep (0001-01-01 through 9999-12-31)"
)]
DateOverflow(chrono::NaiveDate),
/// Payment amount must be positive.
#[error("payment amount must be positive, got {0}")]
InvalidPaymentAmount(Decimal),
+111
View File
@@ -28,6 +28,9 @@
//! * 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 early-payment discount never exceeds its stated percentage of the
//! total, and a document line's amount is exactly its quantity times its
//! unit price, neither rounded (B-39);
//! * 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
@@ -71,6 +74,20 @@ pub trait Amount: Copy + Ord {
/// True when the amount is small and precise enough for a journal line
/// (see [`MAX_LINE_AMOUNT`] and [`MAX_LINE_SCALE`] for [`Decimal`]).
fn in_line_range(self) -> bool;
/// `n`, exactly, or `None` when it doesn't fit.
///
/// A whole count (a percentage in basis points, ten thousand) as the
/// arithmetic here needs it; the kernel's other functions get their
/// counts as [`crate::kernel::Units`].
fn from_u32(n: u32) -> Option<Self>;
/// `self * rhs`, or `None` when it cannot be represented exactly.
///
/// A quantity times a unit price (a document line, B-39) is the one
/// use: it never rounds.
fn checked_mul(self, rhs: Self) -> Option<Self>;
/// `self / rhs` exactly, or `None` when `rhs` is zero or the quotient
/// is not a whole amount of `Self` (never a rounded share).
fn checked_div_exact(self, rhs: Self) -> Option<Self>;
/// `self - rhs`, or `None` when it cannot be represented exactly.
fn checked_sub(self, rhs: Self) -> Option<Self> {
@@ -99,6 +116,43 @@ impl Amount for Decimal {
fn in_line_range(self) -> bool {
self.abs() <= MAX_LINE_AMOUNT && self.normalize().scale() <= MAX_LINE_SCALE
}
fn from_u32(n: u32) -> Option<Self> {
Some(Decimal::from(n))
}
fn checked_mul(self, rhs: Self) -> Option<Self> {
Decimal::checked_mul(self, rhs)
}
fn checked_div_exact(self, rhs: Self) -> Option<Self> {
let q = self.checked_div(rhs)?;
(q.checked_mul(rhs) == Some(self)).then_some(q)
}
}
impl Amount for i32 {
const ZERO: Self = 0;
fn checked_add(self, rhs: Self) -> Option<Self> {
i32::checked_add(self, rhs)
}
fn checked_neg(self) -> Option<Self> {
i32::checked_neg(self)
}
fn in_line_range(self) -> bool {
// The same headroom, scaled down: a proof model, never money.
self.unsigned_abs() <= (1 << 20)
}
fn from_u32(n: u32) -> Option<Self> {
i32::try_from(n).ok()
}
fn checked_mul(self, rhs: Self) -> Option<Self> {
i32::checked_mul(self, rhs)
}
fn checked_div_exact(self, rhs: Self) -> Option<Self> {
let r = self.checked_rem(rhs)?;
if r != 0 {
return None;
}
self.checked_div(rhs)
}
}
impl Amount for i64 {
@@ -114,6 +168,19 @@ impl Amount for i64 {
// this size can be summed many times over without overflow.
self.unsigned_abs() <= (1 << 40)
}
fn from_u32(n: u32) -> Option<Self> {
Some(i64::from(n))
}
fn checked_mul(self, rhs: Self) -> Option<Self> {
i64::checked_mul(self, rhs)
}
fn checked_div_exact(self, rhs: Self) -> Option<Self> {
let r = self.checked_rem(rhs)?;
if r != 0 {
return None;
}
self.checked_div(rhs)
}
}
/// Why the kernel rejected an entry's lines.
@@ -174,6 +241,50 @@ pub fn net<T: Amount>(lines: impl IntoIterator<Item = (Side, T)>) -> Option<T> {
Some(total)
}
/// The most a stated early-payment discount can take from `total`:
/// `total × percent_bp / 10,000`, exactly. The caller gives the exact
/// cents it takes; the kernel only bounds them, so it never rounds a
/// discount up or down (a discount no amount of `Self` can hold is
/// `None`, not a rounded bound).
///
/// ```text
/// GOAL: an early-payment discount never takes more than its percentage
/// says, in exact money.
/// SOURCE: docs/spec/rules.md B-39 INVARIANT 2, B-42; plan/served.md
/// 8.3.13; CHOICE: the gross method, A-8 (the discount is
/// booked when taken, never estimated).
/// INVARIANTS:
/// 1. 0 <= result <= total for every total >= 0 and 1 <= percent_bp <=
/// 9,999.
/// 2. result x 10,000 = total x percent_bp exactly: no rounding.
/// 3. None when a factor overflows or the quotient is finer than Self;
/// NEVER a rounded or wrapped result, never a panic.
/// ```
pub fn discount_max<T: Amount>(total: T, percent_bp: u32) -> Option<T> {
if total < T::ZERO || percent_bp == 0 || percent_bp > 9_999 {
return None;
}
let bp = T::from_u32(percent_bp)?;
let ten_thousand = T::from_u32(10_000)?;
total.checked_mul(bp)?.checked_div_exact(ten_thousand)
}
/// A document line's amount from its quantity and unit price: the exact
/// product, never rounded.
///
/// ```text
/// GOAL: a line's quantity times its unit price is the amount it posts,
/// to the last decimal place.
/// SOURCE: docs/spec/rules.md B-39; plan/served.md 8.3.4 ("kernel::
/// line_amount(q, u) == amount exactly").
/// INVARIANTS:
/// 1. Some(p) gives p = quantity x unit_price exactly.
/// 2. None on overflow; never a rounded product, never a panic.
/// ```
pub fn line_amount<T: Amount>(quantity: T, unit_price: T) -> Option<T> {
quantity.checked_mul(unit_price)
}
/// Check an entry's lines: two or more, every amount positive and within
/// line range, debits equal to credits. Accounts and currencies are the
/// caller's to check; this is the arithmetic.
+285 -27
View File
@@ -5270,18 +5270,24 @@ impl Ledger {
self.add_control_account(c).expect("fresh code cannot fail")
}
/// A document's revenue or expense side on its draft `entry`: one line
/// on `account` per part ([`arap::document_parts`]), each tagged with
/// its project (a project the books don't have: UnknownClass, and the
/// caller's `atomically` leaves the books unchanged).
/// A document's revenue, expense or asset side on its draft `entry`:
/// one line on each part's account, or on `default_account` when the
/// part names none ([`arap::document_parts`]), each tagged with its
/// project (a project the books don't have: UnknownClass, and the
/// caller's `atomically` leaves the books unchanged). Every account is
/// checked for the document's kind first
/// ([`Self::check_document_line_account`]).
fn add_document_parts(
&mut self,
entry: EntryId,
account: AccountId,
default_account: AccountId,
side: Side,
parts: &[(Option<ClassId>, Decimal)],
kind: arap::DocumentKind,
parts: &[(arap::DocumentPartKey, Decimal)],
) -> Result<(), LedgerError> {
for &(class, amount) in parts {
for &((account, class), amount) in parts {
let account = account.unwrap_or(default_account);
self.check_document_line_account(kind, account)?;
self.add_line(entry, account, side, Money::new(amount, self.base_currency))?;
if class.is_some() {
let last = self
@@ -5294,20 +5300,119 @@ impl Ledger {
Ok(())
}
/// A document line's account must be one its kind can post to (plan
/// P4b S4.1, A-10): an invoice line takes a revenue account, or a
/// liability holding no role (2400 Deferred Revenue, a contract
/// liability); a bill line takes an expense account, or an asset that
/// is not cash and holds no role (1500 Equipment, 1300 Prepaid).
/// Placeholders and the receivables and payables control accounts are
/// refused. Checked when a line is added and when the entry posts.
///
/// ```text
/// GOAL: a document's lines go where its kind can put them, never to a
/// control, a cash account or a header.
/// SOURCE: docs/spec/rules.md B-39; plan P4b S4.1 ("Line accounts"),
/// A-10 (every bill line names its account);
/// ASC 606-10-45-2 (deferred revenue is a liability).
/// INVARIANTS:
/// 1. An invoice line: a revenue account, or a liability that holds
/// no role.
/// 2. A bill line: an expense account, or an asset that is not cash
/// (`Ledger::account_is_cash`) and holds no role.
/// 3. A placeholder, or an account that is a document control
/// (receivables or payables), is refused for both.
/// 4. A refusal names the account, the kind and the rule.
/// ```
pub fn check_document_line_account(
&self,
kind: arap::DocumentKind,
account: AccountId,
) -> Result<(), LedgerError> {
self.check_line_account(account)?;
let a = self.chart.get(account).expect("checked above");
let kind_name = match kind {
arap::DocumentKind::Invoice => "an invoice",
arap::DocumentKind::Bill => "a bill",
};
let refuse = |why: &str| {
Err(LedgerError::DocumentLineAccount {
code: a.code.clone(),
name: a.name.clone(),
kind: kind_name,
why: why.to_string(),
})
};
if self.control_role_of(account).is_some() {
return refuse(
"it is a receivables or payables control account; its balance is its documents'",
);
}
match kind {
arap::DocumentKind::Invoice => match a.account_type {
AccountType::Revenue => Ok(()),
AccountType::Liability if !self.holds_any_role(account) => Ok(()),
_ => refuse(
"an invoice line takes a revenue account, or a liability holding no role (deferred revenue)",
),
},
arap::DocumentKind::Bill => {
if a.account_type == AccountType::Expense {
return Ok(());
}
if a.account_type == AccountType::Asset
&& !self.account_is_cash(account)
&& !self.holds_any_role(account)
{
return Ok(());
}
refuse(
"a bill line takes an expense account, or an asset that is not cash and holds no role (equipment, prepaid)",
)
}
}
}
/// Whether `account` is one of the books' cash accounts: the codes in
/// `books.cash_accounts` when set, else (as `ledger-store`'s
/// reconciliation reads them) the asset accounts whose names say they
/// hold cash.
fn account_is_cash(&self, account: AccountId) -> bool {
let Some(a) = self.chart.get(account) else {
return false;
};
let configured = self.settings.cash_accounts();
if !configured.is_empty() {
return configured.iter().any(|c| c == &a.code);
}
const CASH_WORDS: [&str; 5] = ["cash", "bank", "checking", "savings", "money market"];
let name = a.name.to_lowercase();
a.account_type == AccountType::Asset
&& !a.placeholder
&& CASH_WORDS.iter().any(|w| name.contains(w))
}
/// Whether `account` fills any of the books' control roles (assigned,
/// or the account at the role's code and type).
fn holds_any_role(&self, account: AccountId) -> bool {
control_accounts().any(|c| self.holds_role(c, account))
}
/// Create a sales invoice. Auto-posts: Dr `1200` Accounts Receivable,
/// Cr `4000` Revenue. The invoice date must not fall in a closed period.
/// Cr each line's revenue account (the revenue role, `4000`, when a
/// line names none). The invoice date must not fall in a closed period.
///
/// ```text
/// GOAL: bill a customer and recognize the revenue (accrual basis).
/// SOURCE: IRS Pub. 538, accrual method (docs/spec/sources/irs-periods-and-methods.md:
/// "report income in the year it is earned"); ASC 606-10-25-23;
/// control accounts: Ledger::check_controls; rules.md B-38.
/// control accounts: Ledger::check_controls; rules.md B-38, B-39.
/// INVARIANTS:
/// 1. MUST post one balanced entry: Dr 1200 receivables, Cr 4000 revenue,
/// for the sum of the lines, dated the invoice date: one revenue
/// line per project the lines name, the untagged lines' as one
/// (arap::document_parts), each tagged with its project; the
/// receivable line never (rules.md B-37 item 8).
/// 1. MUST post one balanced entry: Dr 1200 receivables, Cr the lines'
/// accounts, for the sum of the lines, dated the invoice date: one
/// line per (account, project) part the lines name, the parts
/// naming neither as one (arap::document_parts), each revenue line
/// tagged with its project; the receivable line never (rules.md
/// B-37 item 8).
/// 2. The receivable line MUST carry the invoice (Origin), in the
/// step that posts it: the invoice starts Open, owing its total.
/// 3. A refusal MUST leave the books unchanged.
@@ -5324,7 +5429,28 @@ impl Ledger {
due_date: NaiveDate,
lines: Vec<DocumentLine>,
) -> Result<InvoiceId, LedgerError> {
self.atomically(|l| l.create_invoice_steps(customer_id, date, due_date, lines))
self.create_invoice_with(
customer_id,
date,
due_date,
lines,
arap::InvoiceFields::default(),
)
}
/// [`Self::create_invoice`] with the document's new fields (number,
/// terms, snapshots, references: plan P4b S4.1). The fields are stored
/// on the invoice at issue and never change. All `None` is exactly
/// [`Self::create_invoice`].
pub fn create_invoice_with(
&mut self,
customer_id: CustomerId,
date: NaiveDate,
due_date: NaiveDate,
lines: Vec<DocumentLine>,
fields: arap::InvoiceFields,
) -> Result<InvoiceId, LedgerError> {
self.atomically(|l| l.create_invoice_steps(customer_id, date, due_date, lines, fields))
}
/// The steps of [`Self::create_invoice`], run inside [`Self::atomically`].
@@ -5334,12 +5460,21 @@ impl Ledger {
date: NaiveDate,
due_date: NaiveDate,
lines: Vec<DocumentLine>,
fields: arap::InvoiceFields,
) -> Result<InvoiceId, LedgerError> {
if !self.customers.contains_key(&customer_id) {
return Err(LedgerError::UnknownCustomer(customer_id));
}
arap::validate_lines(&lines)?;
arap::validate_due_date(date, due_date)?;
if let Some(number) = &fields.number
&& self
.invoices
.values()
.any(|i| i.number.as_deref() == Some(number.as_str()))
{
return Err(LedgerError::DuplicateInvoiceNumber(number.clone()));
}
if let Some(pid) = self.closed_period_containing(date) {
return Err(LedgerError::ClosedPeriod(pid, date.to_string()));
}
@@ -5360,7 +5495,13 @@ impl Ledger {
movement: arap::Movement::Origin,
},
)?;
self.add_document_parts(entry, revenue, Side::Credit, &parts)?;
self.add_document_parts(
entry,
revenue,
Side::Credit,
arap::DocumentKind::Invoice,
&parts,
)?;
self.post_entry(entry)?;
self.next_invoice_id += 1;
self.invoices.insert(
@@ -5375,6 +5516,13 @@ impl Ledger {
owed: Decimal::ZERO,
status: DocumentStatus::Open,
entry_id: entry,
number: fields.number,
terms: fields.terms,
bill_to: fields.bill_to,
seller: fields.seller,
customer_ref: fields.customer_ref,
memo: fields.memo,
sends: Vec::new(),
},
);
self.tag_customer(entry, customer_id);
@@ -5382,22 +5530,24 @@ impl Ledger {
Ok(id)
}
/// Create a vendor bill. Auto-posts: Dr `6999` General Expenses,
/// Cr `2000` Accounts Payable. The bill date must not fall in a closed
/// Create a vendor bill. Auto-posts: Dr each line's expense or asset
/// account (general expenses, `6999`, when a line names none), Cr
/// `2000` Accounts Payable. The bill date must not fall in a closed
/// period.
///
/// ```text
/// GOAL: record a vendor's bill as an expense owed (accrual basis).
/// GOAL: record a vendor's bill as an expense or asset owed (accrual
/// basis).
/// SOURCE: IRS Pub. 538, accrual method ("deduct ... expenses in the year
/// incurred"); control accounts: Ledger::check_controls;
/// rules.md B-38.
/// rules.md B-38, B-39 (plan P4b S4.1, A-10).
/// INVARIANTS:
/// 1. MUST post one balanced entry: Dr 6999 general expenses, Cr 2000
/// 1. MUST post one balanced entry: Dr the lines' accounts, Cr 2000
/// payables, for the sum of the lines, dated the bill date, tagged
/// with the vendor (1099s): one expense line per project the lines
/// name, the untagged lines' as one (arap::document_parts), each
/// tagged with its project; the payable line never (rules.md B-37
/// item 8).
/// with the vendor (1099s): one line per (account, project) part
/// the lines name, the parts naming neither as one
/// (arap::document_parts), each tagged with its project; the
/// payable line never (rules.md B-37 item 8).
/// 2. The payable line MUST carry the bill (Origin), in the step that
/// posts it.
/// 3. A refusal MUST leave the books unchanged.
@@ -5413,7 +5563,28 @@ impl Ledger {
due_date: NaiveDate,
lines: Vec<DocumentLine>,
) -> Result<BillId, LedgerError> {
self.atomically(|l| l.create_bill_steps(vendor_id, date, due_date, lines))
self.create_bill_with(
vendor_id,
date,
due_date,
lines,
arap::BillFields::default(),
)
}
/// [`Self::create_bill`] with the bill's new fields (the vendor's
/// invoice number, terms, receipt date, memo: plan P4b S4.1). The
/// fields are stored on the bill at issue and never change. All `None`
/// is exactly [`Self::create_bill`].
pub fn create_bill_with(
&mut self,
vendor_id: VendorId,
date: NaiveDate,
due_date: NaiveDate,
lines: Vec<DocumentLine>,
fields: arap::BillFields,
) -> Result<BillId, LedgerError> {
self.atomically(|l| l.create_bill_steps(vendor_id, date, due_date, lines, fields))
}
/// The steps of [`Self::create_bill`], run inside [`Self::atomically`].
@@ -5423,12 +5594,19 @@ impl Ledger {
date: NaiveDate,
due_date: NaiveDate,
lines: Vec<DocumentLine>,
fields: arap::BillFields,
) -> Result<BillId, LedgerError> {
if !self.vendors.contains_key(&vendor_id) {
return Err(LedgerError::UnknownVendor(vendor_id));
}
arap::validate_lines(&lines)?;
arap::validate_due_date(date, due_date)?;
if let Some(received_on) = fields.received_on {
crate::calendar::check(received_on)?;
}
if let Some(vendor_ref) = fields.vendor_ref.as_deref() {
self.check_vendor_ref(vendor_id, vendor_ref, fields.duplicate_reason.as_deref())?;
}
if let Some(pid) = self.closed_period_containing(date) {
return Err(LedgerError::ClosedPeriod(pid, date.to_string()));
}
@@ -5439,7 +5617,13 @@ impl Ledger {
let ap = self.ensure_control(&PAYABLES);
let id = self.next_bill_id;
let entry = self.create_entry(date, &format!("Bill {id}"));
self.add_document_parts(entry, expense, Side::Debit, &parts)?;
self.add_document_parts(
entry,
expense,
Side::Debit,
arap::DocumentKind::Bill,
&parts,
)?;
self.add_tagged_line(
entry,
ap,
@@ -5464,6 +5648,10 @@ impl Ledger {
owed: Decimal::ZERO,
status: DocumentStatus::Open,
entry_id: entry,
vendor_ref: fields.vendor_ref,
received_on: fields.received_on,
terms: fields.terms,
memo: fields.memo,
},
);
self.tag_vendor(entry, vendor_id);
@@ -5471,6 +5659,54 @@ impl Ledger {
Ok(id)
}
/// The number the next invoice is issued under (A-2, B-40): `prefix`,
/// then `first_number` plus the count of numbered invoices issued so
/// far, zero-padded to four digits and wider past 9,999 (never
/// truncated). Stored at issue, never recomputed; a void keeps its
/// number, so the series never reuses one; a legacy invoice
/// (`number: None`) is not counted.
pub fn next_invoice_number(&self, prefix: &str, first_number: u64) -> String {
let issued = self
.invoices
.values()
.filter(|i| i.number.is_some())
.count() as u64;
format!("{prefix}{:04}", first_number.saturating_add(issued))
}
/// Refuse a vendor invoice number that already names a standing bill
/// of the same vendor (B-40), unless `duplicate_reason` says why it is
/// entered again. Case, spaces and hyphens are ignored: "ACME-123",
/// "acme 123" and "Acme123" are the same number.
pub fn check_vendor_ref(
&self,
vendor: VendorId,
vendor_ref: &str,
duplicate_reason: Option<&str>,
) -> Result<(), LedgerError> {
if duplicate_reason.is_some_and(|r| !r.trim().is_empty()) {
return Ok(());
}
let key = normalize_vendor_ref(vendor_ref);
if key.is_empty() {
return Ok(());
}
for bill in self.bills.values() {
if bill.vendor_id != vendor || !self.in_effect(bill.entry_id) {
continue;
}
if let Some(existing) = &bill.vendor_ref
&& normalize_vendor_ref(existing) == key
{
return Err(LedgerError::DuplicateVendorRef {
vendor,
vendor_ref: vendor_ref.to_string(),
});
}
}
Ok(())
}
/// Look up an invoice.
pub fn get_invoice(&self, id: InvoiceId) -> Option<&Invoice> {
self.invoices.get(&id)
@@ -6395,6 +6631,13 @@ impl Ledger {
owed: Decimal::ZERO,
status: DocumentStatus::Open,
entry_id,
number: None,
terms: None,
bill_to: None,
seller: None,
customer_ref: None,
memo: None,
sends: Vec::new(),
},
);
self.tag_customer(entry_id, customer_id);
@@ -6443,6 +6686,10 @@ impl Ledger {
owed: Decimal::ZERO,
status: DocumentStatus::Open,
entry_id,
vendor_ref: None,
received_on: None,
terms: None,
memo: None,
},
);
self.tag_vendor(entry_id, vendor_id);
@@ -7933,6 +8180,17 @@ fn same_member_set(a: &[(AccountId, Decimal)], b: &[(AccountId, Decimal)]) -> bo
a.len() == b.len() && sorted(a) == sorted(b)
}
/// A vendor invoice number as the duplicate check compares it: lower case,
/// with spaces and hyphens removed ("ACME-123" and "acme 123" are the
/// same number; rules.md B-40).
fn normalize_vendor_ref(vendor_ref: &str) -> String {
vendor_ref
.chars()
.filter(|c| !c.is_whitespace() && *c != '-')
.flat_map(char::to_lowercase)
.collect()
}
/// A members regime ([`Ledger::members_regime`]): the members closes with
/// one set of members and shares since the last close with another.
#[derive(Clone, Debug, PartialEq)]
+4 -3
View File
@@ -48,9 +48,10 @@ pub mod tax_capital;
pub use account::{Account, AccountId, AccountType, ChartOfAccounts, Side};
pub use arap::{
AgingRow, Bill, BillId, ControlCheck, Customer, CustomerId, DocLine, DocRef, DocState,
DocStatus, DocTag, DocumentLine, DocumentStatus, Invoice, InvoiceId, Movement, PaymentDetails,
UnattachedLine, Vendor, VendorId,
AgingRow, Bill, BillFields, BillId, ControlCheck, Customer, CustomerId, DocLine, DocRef,
DocState, DocStatus, DocTag, DocumentKind, DocumentLine, DocumentPartKey, DocumentStatus,
EarlyDiscount, Invoice, InvoiceFields, InvoiceId, Movement, PartySnapshot, PaymentDetails,
SendKind, Sent, SentVia, Terms, TermsKind, UnattachedLine, Vendor, VendorId,
};
pub use assets::{
Asset, AssetBasis, AssetCorrection, AssetId, AssetStatus, BasisRule, DepreciationMethod,
+59
View File
@@ -1892,6 +1892,65 @@ fn bounds_kept_is_the_definition_on_books_in_bounds() {
);
}
// ---------------------------------------------------------------------------
// Terms and document lines (P4b S4.1)
// ---------------------------------------------------------------------------
/// `kernel::discount_max` never bounds a discount above the total, and what
/// it gives is exact: result x 10,000 = total x basis points. None is an
/// exact bound no whole cent can hold, not a rounded one.
///
/// At `i32`, like the proration charge harnesses: the law relates a
/// product to a quotient, which the model checker proves far faster on
/// 32-bit arithmetic; the bridge property carries it to `Decimal`.
#[kani::proof]
fn discount_max_is_bounded() {
let total: i32 = kani::any();
kani::assume(total >= 0 && total <= 200_000);
// A u16 stands for the percentage: any 1..=9,999 basis points, and a
// narrower divisor keeps the model checker quick (the kernel takes
// u32; the bridge checks that boundary).
let percent_bp: u16 = kani::any();
kani::assume(percent_bp >= 1 && percent_bp <= 9_999);
let got = kernel::discount_max(total, u32::from(percent_bp));
if let Some(discount) = got {
assert!(discount >= 0);
assert!(discount <= total);
assert_eq!(
discount.checked_mul(10_000),
total.checked_mul(i32::from(percent_bp))
);
}
kani::cover!(
got.is_some_and(|d| d > 0),
"a discount that takes something"
);
kani::cover!(got.is_none(), "an exact bound finer than a whole cent");
}
/// `kernel::line_amount` is the exact product of a quantity and a unit
/// price: never a rounded amount, and None rather than an overflow.
#[kani::proof]
fn line_amount_is_exact() {
let quantity: i64 = kani::any();
let unit_price: i64 = kani::any();
kani::assume(quantity >= 0 && quantity <= 1_000_000);
kani::assume(unit_price >= 0 && unit_price <= 1_000_000);
let got = kernel::line_amount(quantity, unit_price);
assert_eq!(got, quantity.checked_mul(unit_price));
if let Some(amount) = got {
assert!(amount >= 0);
if quantity > 0 && unit_price > 0 {
assert!(amount >= quantity);
assert!(amount >= unit_price);
}
}
kani::cover!(
got.is_some_and(|a| a > 1) && quantity > 1 && unit_price > 1,
"a product both factors grow"
);
}
// ---------------------------------------------------------------------------
// The checker itself
// ---------------------------------------------------------------------------