feat(pdf): the issued invoice carries what the customer pays from (P4b S4.5)
The page a customer pays from must show what the books issued, and only
that. InvoiceDoc gains each line's quantity and unit price ("3 x $33.33"
beside the line's own amount, never recomputed), the customer's PO, memo,
footer, the ways to pay (a QR code through labels::qr_svg for a method
with a link that asks for one), the seller's email and website, and a
Ship-to block. InvoiceVersion separates the issued copy (no stamp, no
settled rows, no "re-created" note) from the current copy, whose
"Re-created on" note prints the as_of it was given, never the wall clock,
so the bytes stay deterministic (B-40 item 3). Every amount reaches the
page through report::amount_parts; every string is data.
ledger_package builds all three copies (issued, DRAFT, current) from one
function over the invoice's own snapshots (A-13, D-55: number, terms,
bill-to contact block, shipping address), falling back to the party record
only for a document made before snapshots, so the three cannot disagree.
invoice_document drops its now-unused `today` argument.
rules.md gains B-39 (terms) and B-40 (numbers and the issued copy) from
the 8.3.13 spec blocks, CONVENTION until their sources are vendored.
No golden moved: the render test never held a hash, it compares two
renders for equal bytes, and still does.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
1 parent
ce69d9d7bc
commit
400d0cdcab
8 files changed
+706
-169
No files matched your search
@@ -3124,6 +3124,112 @@ coding of `DocRef`), and an account becoming a control account by its
|
||||
first document, holding lines from before, is outside it (those lines are
|
||||
listed as unattached).
|
||||
|
||||
## B-39 Terms give one due date and one discount date, exactly
|
||||
|
||||
**bookspec:** `BS-arap-3`
|
||||
|
||||
**Status:** CONVENTION (standard trade-terms notation: "Net 30", "Net 15
|
||||
EOM", "2/10 net 30", "Due on receipt"). Sources cited and not yet
|
||||
vendored, so this rule keeps the CONVENTION label (the Open list in
|
||||
`README.md`): UCC §2-310(a) (for goods with no agreed terms, payment is
|
||||
due when the buyer receives them); for services, the contract sets the
|
||||
terms. Sending an invoice late never moves its due date.
|
||||
|
||||
```text
|
||||
GOAL: terms give one due date and one discount date, exactly.
|
||||
SOURCE: CONVENTION: standard trade-terms notation; UCC §2-310(a) for
|
||||
goods; for services, the contract.
|
||||
INVARIANTS:
|
||||
1. Net n = date + n; Net n EOM = last day of date's month + n; due on
|
||||
receipt = date; Custom = the given date. Always at least date, in the
|
||||
calendar (calendar::check); n in 0..=3650.
|
||||
2. discount_by = date + d, d below the net days (none for due on
|
||||
receipt or EOM); a taken discount is at most
|
||||
kernel::discount_max(total, percent_bp), the caller's exact cents;
|
||||
after discount_by only with a reason.
|
||||
3. The canonical spellings read back as they print.
|
||||
4. A line given as a quantity and a unit price has
|
||||
quantity x unit price = amount exactly (kernel::line_amount, neither
|
||||
rounded); a line's account is a revenue or deferred-revenue account
|
||||
on an invoice, an expense or a non-cash asset on a bill, never a role
|
||||
account, cash or a placeholder.
|
||||
SCENARIOS:
|
||||
1. 2026-01-31 net 30 -> 2026-03-02; 2026-10-23 net 15 EOM ->
|
||||
2026-11-15; a leap day; due on receipt = the day.
|
||||
2. The 9999-12 bound is refused, not a panic.
|
||||
```
|
||||
|
||||
**Enforced:** `ledger_core::Terms` (`new`, `parse`, `due_date`,
|
||||
`discount_by`, `describe`), `kernel::discount_max`, `kernel::line_amount`,
|
||||
`Ledger::check_document_line_account`; the settings
|
||||
`invoicing.default_terms` and a party's terms per side; in the store
|
||||
behind the feature `documents_v2`.
|
||||
**Checked:** `ledger-core` `tests/spec_documents.rs`
|
||||
(`terms_derive_due_dates`, `discount_bounds_and_late_reason`,
|
||||
`terms_describe_and_parse_round_trip`, `qty_times_price_must_equal_amount`,
|
||||
`document_line_account_refuses_roles_cash_and_placeholders`,
|
||||
`bill_lines_post_to_their_accounts`); Kani `discount_max_is_bounded`,
|
||||
`line_amount_is_exact` and their bridge properties; over the wire
|
||||
`crates/ledger-cli/tests/documents.rs` (`terms_fall_back_in_order`,
|
||||
`a_bill_with_its_vendor_invoice_and_terms`, `a_line_account_must_be_allowed`).
|
||||
|
||||
## B-40 An invoice number names one invoice forever; the copy sent is kept; a vendor invoice is entered once
|
||||
|
||||
**bookspec:** `BS-arap-4`
|
||||
|
||||
**Status:** CONVENTION (sequential invoice numbers with voids kept, and a
|
||||
duplicate-payment control). Sources cited and not yet vendored, so this
|
||||
rule keeps the CONVENTION label (the Open list in `README.md`): IRS Pub.
|
||||
583 (invoices are among the records to keep); Rev. Proc. 98-25 (an
|
||||
electronic record keeps its audit trail: why the PDF as issued is filed
|
||||
with its hash and a void keeps its number).
|
||||
|
||||
```text
|
||||
GOAL: an invoice number names one invoice forever; the copy sent is kept;
|
||||
a vendor invoice is entered once.
|
||||
SOURCE: Pub. 583; Rev. Proc. 98-25; CONVENTION: sequential numbers, voids
|
||||
kept; duplicate-payment control.
|
||||
INVARIANTS:
|
||||
1. Numbers are unique, in issue order, never reused; a void keeps its
|
||||
number. Four digits, wider past 9,999, never truncated.
|
||||
2. Number, snapshots, terms and lines never change after issue.
|
||||
3. The issued PDF is filed in the issuing call as receipt.attached on
|
||||
the entry and linked to the invoice; its sha256 is in that event. The
|
||||
copy a later look prints is re-created from the books as of a day,
|
||||
says so (that day, never the wall clock), and never replaces it.
|
||||
4. (vendor, normalized vendor_ref) is unique among standing bills unless
|
||||
a duplicate_reason is journaled; the normalization drops case and
|
||||
spaces.
|
||||
5. Sends never change a figure.
|
||||
SCENARIOS:
|
||||
1. Three invoices, the second voided: INV-0001, INV-0002 (void), INV-0003.
|
||||
2. A second bill from the same vendor with the same number: refused
|
||||
unless a reason says why.
|
||||
3. The page prints the number the preview showed; a number another
|
||||
session took in between is refused as a duplicate.
|
||||
```
|
||||
|
||||
**Enforced:** `Ledger::next_invoice_number`, `Ledger::check_vendor_ref`,
|
||||
`PartySnapshot::from_party` (the snapshots at issue, D-55);
|
||||
`Store::create_invoice_issued` (feature `documents_v2`; the PDF is
|
||||
rendered before anything is journaled, so a Typst failure changes
|
||||
nothing); `ledger_package::{issued_invoice_document,
|
||||
draft_invoice_document, invoice_document}` and
|
||||
`ledger_import::invoice_pdf` (deterministic bytes, every string data,
|
||||
every amount through `amount_parts`).
|
||||
**Checked:** `ledger-core` `tests/spec_documents.rs`
|
||||
(`numbers_run_and_voids_keep_theirs`, `number_widens_past_9999`,
|
||||
`duplicate_vendor_ref_refused_unless_a_reason_is_given`); `ledger-store`
|
||||
`tests/arap_standing.rs` (`documents_v2_waits_for_the_upgrade`,
|
||||
`issued_pdf_is_filed_with_the_invoice_or_nothing_is`); over the wire
|
||||
`crates/ledger-cli/tests/documents.rs` (`an_invoice_from_issue_to_paid`,
|
||||
`duplicate_vendor_invoice_refused`); the page `ledger-import`
|
||||
`tests/spec_import.rs::invoice_pdf_prints_what_it_should` and the unit tests
|
||||
`labels_are_data`, `an_invoice_renders_deterministically`,
|
||||
`two_hundred_lines_paginate`; the viewer
|
||||
`a_numbered_invoice_shows_its_number_terms_bill_to_and_po` and
|
||||
`the_viewer_holds_a_numbered_invoice_as_the_store_does`.
|
||||
|
||||
## B-41 A debt that will not be collected is written off, and a recovery is income
|
||||
|
||||
**bookspec:** `BS-arap-5`
|
||||
|
||||
Reference in new issue
Block a user