feat(store): journal the document fields and file the issued PDF (P4b S4.3)

S4.1 gave documents a number, terms, line accounts and snapshots, and S4.2
gave parties an email and default terms, but the store journaled none of
them: a document carrying one was refused by a temporary guard, because a
public write that replay loses would break I-3. This slice makes the
journal carry them.

- `invoice.created` and `bill.created` write a number, terms, bill-to and
  seller snapshots, references, memo and a duplicate's reason, and each
  line its account, quantity and unit price, every key only when set. A
  document naming none is journaled byte for byte as every earlier version
  wrote it (`no_new_fields_journals_byte_for_byte`), so the owner's live
  books keep working.
- All of it is behind `documents_v2` (B-27): a write that names one waits
  for the owner's upgrade, refused with `format_upgrade_pending` and
  journaling nothing; a write that names none never waits
  (`documents_v2_waits_for_the_upgrade`).
- `invoicing.first_number` is settable only while no numbered invoice
  exists (A-2, B-40): the series has begun, and a number is never reused or
  renumbered. Refused at `set_setting`, before anything is journaled.
- The issued copy is filed in the issuing call (A-7, B-40 item 3):
  `create_invoice_issued` journals the entry, the invoice, then
  `receipt.attached` on that entry (`INV-0007.pdf`, `application/pdf`,
  "Issued invoice") and `attachment.linked` to the invoice. There is no
  `issued_pdf` field: the attachment id is allocated after the entry exists
  (CD-A6), and the link is how the copy is found and how its SHA-256 is
  checked. Every refusal happens before the first journal line; the PDF's
  bytes are stored before it, so a refused issue leaves an unreferenced,
  deduplicated blob at worst.
- A preview runs the issue on a clone and journals nothing: the posting,
  the total, the number it would take and the due date (`preview_invoice`,
  `preview_bill`). Books that haven't upgraded keep the legacy shape: the
  copy is filed as "Invoice {id}.pdf" and no number is taken (migration
  item 5).
- Schema 16 adds the document columns; a line's new fields ride
  `lines_json` as its project does. `differs_from` already compares whole
  invoices, bills and parties, so it sees every new field.

8.0.6: (1) `books_written_under_older_rules_still_open` hand-writes both
new document shapes through JournalWriter and still opens; (2) a new
fixture `old-books/p4_s43_documents` was written by main d5385a7e (S4.2,
the version before this slice) through the old-books-writer harness, with
its expected.json; this version opens it and reports the same figures;
(3) see the note on R-A step 4 in the PR (the over-the-wire document write
needs S4.4's arguments; S4.2's invoicing-setting entry already covers the
feature's cut-over); (4) `documents_v2_is_its_own_feature` is green; (5) no
figure moves: no golden changed; (6) merge does not need the owner's
go-ahead; the live books refuse the new writes until he gives it.

just check green (no kernel function, Lean theorem or Ledger command, so no
`just prove` change).
This commit is contained in:
swaits committed 2026-10-27 10:37:03 -06:00
1 parent 6be0055927
commit 0e5aebda99
13 files changed
+1721 -224

No files matched your search

+62 -10
View File
@@ -6628,6 +6628,32 @@ impl Ledger {
due_date: NaiveDate,
lines: Vec<DocumentLine>,
entry_id: EntryId,
) -> Result<(), LedgerError> {
self.replay_invoice_with(
id,
customer_id,
date,
due_date,
lines,
entry_id,
arap::InvoiceFields::default(),
)
}
/// [`Self::replay_invoice`] with the invoice's new fields (number,
/// terms, snapshots, references: plan P4b S4.3, feature
/// `documents_v2`). All `None` is exactly [`Self::replay_invoice`].
#[doc(hidden)]
#[allow(clippy::too_many_arguments)] // one argument per journaled field
pub fn replay_invoice_with(
&mut self,
id: InvoiceId,
customer_id: CustomerId,
date: NaiveDate,
due_date: NaiveDate,
lines: Vec<DocumentLine>,
entry_id: EntryId,
fields: arap::InvoiceFields,
) -> Result<(), LedgerError> {
if id != self.next_invoice_id {
return Err(LedgerError::ReplayIdMismatch(id, self.next_invoice_id));
@@ -6648,12 +6674,12 @@ impl Ledger {
owed: Decimal::ZERO,
status: DocumentStatus::Open,
entry_id,
number: None,
terms: None,
bill_to: None,
seller: None,
customer_ref: None,
memo: None,
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(),
},
);
@@ -6683,6 +6709,32 @@ impl Ledger {
due_date: NaiveDate,
lines: Vec<DocumentLine>,
entry_id: EntryId,
) -> Result<(), LedgerError> {
self.replay_bill_with(
id,
vendor_id,
date,
due_date,
lines,
entry_id,
arap::BillFields::default(),
)
}
/// [`Self::replay_bill`] with the bill's new fields (the vendor's
/// invoice number, its receipt date, terms, memo: plan P4b S4.3,
/// feature `documents_v2`). All `None` is exactly [`Self::replay_bill`].
#[doc(hidden)]
#[allow(clippy::too_many_arguments)] // one argument per journaled field
pub fn replay_bill_with(
&mut self,
id: BillId,
vendor_id: VendorId,
date: NaiveDate,
due_date: NaiveDate,
lines: Vec<DocumentLine>,
entry_id: EntryId,
fields: arap::BillFields,
) -> Result<(), LedgerError> {
if id != self.next_bill_id {
return Err(LedgerError::ReplayIdMismatch(id, self.next_bill_id));
@@ -6703,10 +6755,10 @@ impl Ledger {
owed: Decimal::ZERO,
status: DocumentStatus::Open,
entry_id,
vendor_ref: None,
received_on: None,
terms: None,
memo: None,
vendor_ref: fields.vendor_ref,
received_on: fields.received_on,
terms: fields.terms,
memo: fields.memo,
},
);
self.tag_vendor(entry_id, vendor_id);