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:
1 parent
6be0055927
commit
0e5aebda99
13 files changed
+1721
-224
No files matched your search
@@ -116,6 +116,78 @@ fn assert_every_reader_agrees(dir: &std::path::Path, s: &Store) {
|
||||
/// older version wrote in format 1 refuse the change, journaling nothing,
|
||||
/// until their format upgrade (the owner's go-ahead, D-39); then it is
|
||||
/// made, after the one declaration.
|
||||
/// `invoicing.first_number` is settable only while no numbered invoice
|
||||
/// exists (A-2, rules.md B-40): once a number has been issued the series
|
||||
/// has begun, so changing or clearing the starting number is refused before
|
||||
/// anything is journaled, and a legacy invoice (no number) never locks it.
|
||||
#[test]
|
||||
fn first_number_locks_once_a_numbered_invoice_exists() {
|
||||
use ledger_core::LedgerError;
|
||||
let (dir, mut s) = books("first-number");
|
||||
s.change_settings(&[set("invoicing.first_number", json!(5))], &src("Avery"))
|
||||
.unwrap();
|
||||
let first = |s: &Store| {
|
||||
s.ledger()
|
||||
.settings()
|
||||
.get("invoicing.first_number")
|
||||
.map(|r| r.value.clone())
|
||||
};
|
||||
assert_eq!(first(&s), Some(json!(5)));
|
||||
let c = s.add_customer("Fernhill Labs").unwrap();
|
||||
let line = || ledger_core::arap::DocumentLine {
|
||||
description: "Design".into(),
|
||||
amount: Decimal::from(500),
|
||||
class_id: None,
|
||||
account: None,
|
||||
quantity: None,
|
||||
unit_price: None,
|
||||
};
|
||||
// An invoice with no number is the legacy shape and does not count.
|
||||
s.create_invoice(c, d(2026, 4, 1), d(2026, 5, 1), vec![line()])
|
||||
.unwrap();
|
||||
s.change_settings(&[set("invoicing.first_number", json!(6))], &src("Avery"))
|
||||
.unwrap();
|
||||
// The first numbered invoice begins the series.
|
||||
s.create_invoice_with(
|
||||
c,
|
||||
d(2026, 4, 2),
|
||||
d(2026, 5, 2),
|
||||
vec![line()],
|
||||
ledger_core::arap::InvoiceFields {
|
||||
number: Some("INV-0006".into()),
|
||||
..ledger_core::arap::InvoiceFields::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let head = s.head();
|
||||
let refused = s.change_settings(&[set("invoicing.first_number", json!(7))], &src("Avery"));
|
||||
assert!(
|
||||
matches!(
|
||||
refused,
|
||||
Err(StoreError::Ledger(LedgerError::FirstNumberLocked))
|
||||
),
|
||||
"{refused:?}"
|
||||
);
|
||||
// Clearing it would move it to the default: refused too.
|
||||
assert!(matches!(
|
||||
s.change_settings(
|
||||
&[Change {
|
||||
key: "invoicing.first_number".into(),
|
||||
value: None,
|
||||
}],
|
||||
&src("Avery"),
|
||||
),
|
||||
Err(StoreError::Ledger(LedgerError::FirstNumberLocked))
|
||||
));
|
||||
assert_eq!(s.head(), head, "nothing journaled");
|
||||
assert_eq!(first(&s), Some(json!(6)), "the setting did not move");
|
||||
s.verify().unwrap();
|
||||
drop(s);
|
||||
let s = Store::open(&dir).unwrap();
|
||||
assert_eq!(first(&s), Some(json!(6)));
|
||||
s.verify().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_change_needs_journal_format_2() {
|
||||
let (dir, mut s) = books("format");
|
||||
|
||||
Reference in new issue
Block a user