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

+121 -1
View File
@@ -1855,13 +1855,104 @@ fn books_written_under_older_rules_still_open() {
"source": {"decided_by": "Owner", "decided_on": "2026-10-26"}}),
)
.unwrap();
// P4b S4.3's new shapes, hand-written as this version writes them: a
// document carrying its number, terms, snapshots, references and memo,
// and a line naming its account, quantity and unit price. This reader
// takes them; the version before it stopped at the declaration.
for (id, code, name, t) in [
(3, "1200", "Accounts Receivable", "Asset"),
(4, "4000", "Revenue", "Revenue"),
(5, "2000", "Accounts Payable", "Liability"),
(6, "6500", "Supplies", "Expense"),
] {
w.append(
"account.added",
json!({"id": id, "code": code, "name": name, "account_type": t,
"parent": null, "placeholder": false, "currency": "USD"}),
)
.unwrap();
}
w.append("customer.added", json!({"id": 1, "name": "Fernhill Labs"}))
.unwrap();
w.append(
"vendor.added",
json!({"id": 1, "name": "Tobin Harlow Supply"}),
)
.unwrap();
w.append(
"entry.created",
json!({"id": 2, "date": "2026-04-01", "description": "Invoice 1"}),
)
.unwrap();
w.append(
"entry.line_added",
json!({"id": 2, "account": 3, "side": "debit", "amount": "99.99", "currency": "USD"}),
)
.unwrap();
w.append(
"entry.line_added",
json!({"id": 2, "account": 4, "side": "credit", "amount": "99.99", "currency": "USD"}),
)
.unwrap();
w.append(
"entry.posted",
json!({"id": 2, "date": "2026-04-01", "description": "Invoice 1", "reversal_of": null,
"lines": [
{"account": 3, "side": "debit", "amount": "99.99", "currency": "USD"},
{"account": 4, "side": "credit", "amount": "99.99", "currency": "USD"}]}),
)
.unwrap();
w.append(
"invoice.created",
json!({"id": 1, "customer_id": 1, "date": "2026-04-01", "due_date": "2026-05-01",
"lines": [{"description": "Consulting", "amount": "99.99", "account": 4,
"quantity": "3", "unit_price": "33.33"}],
"entry_id": 2, "number": "INV-0001",
"terms": {"kind": {"net": 30}, "text": "Net 30"},
"bill_to": {"name": "Fernhill Labs", "email": "billing@fernhill.example"},
"seller": {"name": "Acme", "address": "1 Main St"},
"customer_ref": "PO-7", "memo": "Thanks"}),
)
.unwrap();
w.append(
"entry.created",
json!({"id": 3, "date": "2026-04-02", "description": "Bill 1"}),
)
.unwrap();
w.append(
"entry.line_added",
json!({"id": 3, "account": 6, "side": "debit", "amount": "20.00", "currency": "USD"}),
)
.unwrap();
w.append(
"entry.line_added",
json!({"id": 3, "account": 5, "side": "credit", "amount": "20.00", "currency": "USD"}),
)
.unwrap();
w.append(
"entry.posted",
json!({"id": 3, "date": "2026-04-02", "description": "Bill 1", "reversal_of": null,
"lines": [
{"account": 6, "side": "debit", "amount": "20.00", "currency": "USD"},
{"account": 5, "side": "credit", "amount": "20.00", "currency": "USD"}]}),
)
.unwrap();
w.append(
"bill.created",
json!({"id": 1, "vendor_id": 1, "date": "2026-04-02", "due_date": "2026-05-17",
"lines": [{"description": "Paper", "amount": "20.00", "account": 6}],
"entry_id": 3, "vendor_ref": "TOBIN-77", "received_on": "2026-04-03",
"terms": {"kind": {"net_eom": 15}, "text": "Net 15 EOM"},
"memo": "A4 paper", "duplicate_reason": "the vendor sent it twice"}),
)
.unwrap();
drop(w);
// Opening rebuilds from the journal; it must accept all of it.
let _ = std::fs::remove_file(dir.join(ledger_store::db_file()));
let store = Store::open(&dir).expect("older books still open");
store.verify().unwrap();
assert_eq!(store.journal_format().format, 1);
assert_eq!(store.ledger().chart().len(), 2);
assert_eq!(store.ledger().chart().len(), 6);
assert_eq!(store.ledger().list_periods().len(), 2);
let lines = store.ledger().get_entry(1).unwrap().lines();
assert_eq!(lines.len(), 2);
@@ -1887,6 +1978,35 @@ fn books_written_under_older_rules_still_open() {
.and_then(|r| r.value.as_str()),
Some("2/10 net 30")
);
// The documents' new fields replay as written, its lines included.
let inv = store.ledger().get_invoice(1).expect("the invoice");
assert_eq!(inv.number.as_deref(), Some("INV-0001"));
assert_eq!(
inv.terms.as_ref().map(|t| t.describe()),
Some("Net 30".into())
);
assert_eq!(
inv.bill_to.as_ref().and_then(|s| s.email.as_deref()),
Some("billing@fernhill.example")
);
assert_eq!(inv.seller.as_ref().map(|s| s.name.as_str()), Some("Acme"));
assert_eq!(inv.customer_ref.as_deref(), Some("PO-7"));
assert_eq!(inv.memo.as_deref(), Some("Thanks"));
assert_eq!(inv.lines[0].account, Some(4));
assert_eq!(inv.lines[0].quantity, Some("3".parse().unwrap()));
assert_eq!(inv.lines[0].unit_price, Some("33.33".parse().unwrap()));
let bill = store.ledger().get_bill(1).expect("the bill");
assert_eq!(bill.vendor_ref.as_deref(), Some("TOBIN-77"));
assert_eq!(
bill.received_on,
Some(NaiveDate::from_ymd_opt(2026, 4, 3).unwrap())
);
assert_eq!(
bill.terms.as_ref().map(|t| t.describe()),
Some("Net 15 EOM".into())
);
assert_eq!(bill.memo.as_deref(), Some("A4 paper"));
assert_eq!(bill.lines[0].account, Some(6));
}
/// The books an earlier wanderbooks wrote, one directory each under