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

+417 -71
View File
@@ -10,9 +10,11 @@
//! change, and the general ledger doesn't move at all.
use chrono::NaiveDate;
use ledger_core::LedgerError;
use ledger_core::arap;
use ledger_core::arap::DocumentLine;
use ledger_core::{AccountType, Currency, Money, Side};
use ledger_store::{JOURNAL_FILE, Store, StoreError};
use ledger_store::{JOURNAL_FILE, Store, StoreError, Subject};
use rust_decimal::Decimal;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
@@ -615,96 +617,440 @@ fn legacy_documents_replay_with_tags() {
assert!((0..2).all(|i| l.line_doc(hand, i).is_none()));
}
/// S4.1's line fields (the account, the quantity and unit price) are not
/// journaled yet: S4.3's `documents_v2` writes them. The store refuses
/// such a line before it rehearses or journals anything, so replay can
/// never lose one; an ordinary line still posts exactly as before, with
/// no new key in its journal payload.
/// A document's new fields (its number, terms, snapshots, references, a
/// line's account, quantity or price) wait for the journal feature
/// `documents_v2` (plan P4b S4.3; rules.md B-27): refused,
/// FormatUpgradePending, journaling nothing, in books that haven't declared
/// it; after the owner's upgrade the same writes are taken, and the books
/// replay to what was live.
#[test]
fn document_line_fields_wait_for_documents_v2() {
let dir = test_dir("unjournaled-fields");
let mut s = Store::create(&dir.0, Currency::USD).unwrap();
let bank = s
.add_account("1000", "Bank", AccountType::Asset, None, false)
fn documents_v2_waits_for_the_upgrade() {
let dir = fixture("documents-v2");
let mut s = Store::open(&dir.0).unwrap();
assert!(
!s.journal_format()
.has_feature(ledger_store::format::DOCUMENTS_V2)
);
// A revenue account for a line to name, created before the head we
// compare (it needs nothing new).
let revenue = s
.add_account("4100", "Consulting", AccountType::Revenue, None, false)
.unwrap();
let head = s.head();
let terms = ledger_core::Terms::new(ledger_core::TermsKind::Net(30), None).unwrap();
let invoice_fields = arap::InvoiceFields {
number: Some("INV-0001".into()),
terms: Some(terms.clone()),
..arap::InvoiceFields::default()
};
let bill_fields = arap::BillFields {
vendor_ref: Some("TOBIN-77".into()),
received_on: Some(d(2026, 3, 2)),
..arap::BillFields::default()
};
let with_account = DocumentLine {
description: "Consulting".into(),
amount: cents(3_000),
class_id: None,
account: Some(revenue),
quantity: None,
unit_price: None,
};
for refused in [
s.create_invoice_with(
1,
d(2026, 3, 1),
d(2026, 3, 31),
vec![line("Work", 3_000)],
invoice_fields.clone(),
),
s.create_bill_with(
1,
d(2026, 3, 1),
d(2026, 3, 31),
vec![line("Paper", 2_000)],
bill_fields.clone(),
),
s.create_invoice_with(
1,
d(2026, 3, 1),
d(2026, 3, 31),
vec![with_account.clone()],
arap::InvoiceFields::default(),
),
] {
match refused {
Err(StoreError::FormatUpgradePending { need }) => {
assert_eq!(need, "the journal feature documents_v2")
}
other => panic!("expected FormatUpgradePending, got {other:?}"),
}
}
assert_eq!(s.head(), head, "nothing journaled");
// Filing the issued copy needs nothing new: an old-shape invoice keeps
// its "Invoice {id}" (no number) and the copy is filed and linked.
let unnumbered = s
.create_invoice_issued(
1,
d(2026, 3, 2),
d(2026, 3, 31),
vec![line("Old shape", 1_000)],
arap::InvoiceFields::default(),
b"%PDF-1.4 old\n%%EOF\n".to_vec(),
)
.unwrap();
assert_eq!(s.ledger().get_invoice(unnumbered.0).unwrap().number, None);
assert_eq!(
s.attachments_for(Subject::Invoice(unnumbered.0)).unwrap()[0]
.file_name
.as_str(),
format!("Invoice {}.pdf", unnumbered.0)
);
assert!(s.upgrade_format().unwrap().is_some());
let inv = s
.create_invoice_with(
1,
d(2026, 3, 1),
d(2026, 3, 31),
vec![line("Work", 3_000)],
invoice_fields,
)
.unwrap();
let bill = s
.create_bill_with(
1,
d(2026, 3, 1),
d(2026, 3, 31),
vec![line("Paper", 2_000)],
bill_fields,
)
.unwrap();
let lined = s
.create_invoice_with(
1,
d(2026, 3, 1),
d(2026, 3, 31),
vec![with_account],
arap::InvoiceFields::default(),
)
.unwrap();
let live = s.ledger().clone();
// The fields are journaled, not just held live.
let replay = live
.invoices()
.into_iter()
.find(|i| i.id == inv)
.expect("invoice 4");
assert_eq!(replay.number.as_deref(), Some("INV-0001"));
assert_eq!(
replay.terms.as_ref().map(|t| t.describe()),
Some("Net 30".into())
);
assert_eq!(replay.bill_to, None);
let lined_lines = live.get_invoice(lined).unwrap().lines.clone();
assert_eq!(lined_lines[0].account, Some(revenue));
let replayed_bill = live.bills().into_iter().find(|b| b.id == bill).unwrap();
assert_eq!(replayed_bill.vendor_ref.as_deref(), Some("TOBIN-77"));
assert_eq!(replayed_bill.received_on, Some(d(2026, 3, 2)));
drop(s);
let s = Store::open(&dir.0).unwrap();
assert!(
live.differs_from(s.ledger()).is_empty(),
"{:?}",
live.differs_from(s.ledger())
);
s.verify().unwrap();
}
/// A document naming none of the new fields is journaled byte for byte as
/// every version before `documents_v2` wrote it (plan P4b S4.3; the
/// `document_line_projects` pattern): the same five events, the same
/// payload keys, no key for a field that is None.
#[test]
fn no_new_fields_journals_byte_for_byte() {
let dir = test_dir("old-shape-documents");
let mut s = Store::create(&dir.0, Currency::USD).unwrap();
let c = s.add_customer("Fernhill Labs").unwrap();
let v = s.add_vendor("Tobin Harlow Supply").unwrap();
// A first invoice creates the control accounts, so the assertions
// below see only the document's own events.
s.create_invoice(c, d(2026, 3, 1), d(2026, 4, 1), vec![line("Seed", 1_000)])
.unwrap();
s.create_bill(v, d(2026, 3, 1), d(2026, 4, 1), vec![line("Seed", 1_000)])
.unwrap();
let from = journal_len(&dir.0);
let next_invoice = s.ledger().next_invoice_id();
let next_bill = s.ledger().next_bill_id();
let with_account = DocumentLine {
description: "Design".into(),
amount: cents(5_000),
class_id: None,
account: Some(bank),
quantity: None,
unit_price: None,
};
for refused in [
s.create_invoice(c, d(2026, 4, 1), d(2026, 5, 1), vec![with_account.clone()]),
s.create_bill(v, d(2026, 4, 1), d(2026, 5, 1), vec![with_account]),
] {
assert!(
matches!(
refused,
Err(StoreError::DocumentFieldsAwaitUpgrade {
field: "an account"
})
),
"{refused:?}"
);
}
let with_price = DocumentLine {
description: "Hours".into(),
amount: cents(9_999),
class_id: None,
account: None,
quantity: Some(Decimal::from(3)),
unit_price: Some(cents(3_333)),
};
assert!(matches!(
s.create_invoice(c, d(2026, 4, 1), d(2026, 5, 1), vec![with_price.clone()]),
Err(StoreError::DocumentFieldsAwaitUpgrade {
field: "a quantity and unit price"
})
));
assert!(matches!(
s.create_bill(v, d(2026, 4, 1), d(2026, 5, 1), vec![with_price]),
Err(StoreError::DocumentFieldsAwaitUpgrade {
field: "a quantity and unit price"
})
));
// Every refusal left the journal and the allocators exactly as they
// were.
assert_eq!(journal_len(&dir.0), from);
assert_eq!(s.ledger().next_invoice_id(), next_invoice);
assert_eq!(s.ledger().next_bill_id(), next_bill);
s.verify().unwrap();
// An ordinary line posts exactly as before: the same five events, and
// the line carries no key the older journal did not have.
let inv = s
.create_invoice(c, d(2026, 4, 1), d(2026, 5, 1), vec![line("Design", 5_000)])
.unwrap();
let got = since(&dir.0, from);
assert_eq!(got.len(), 5, "{got:?}");
assert_eq!(got[4].0, "invoice.created");
let keys: Vec<&String> = got[4].1["lines"][0].as_object().unwrap().keys().collect();
assert_eq!(keys, ["amount", "description"]);
let entry = s.ledger().get_invoice(inv).unwrap().entry_id;
assert_eq!(
got[4].1,
json!({
"id": inv,
"customer_id": c,
"date": "2026-04-01",
"due_date": "2026-05-01",
"lines": [{"description": "Design", "amount": "50.00"}],
"entry_id": entry,
}),
"the invoice event is the shape every earlier version wrote"
);
let from = journal_len(&dir.0);
let bill = s
.create_bill(v, d(2026, 4, 1), d(2026, 5, 1), vec![line("Paper", 2_000)])
.unwrap();
let got = since(&dir.0, from);
assert_eq!(got.len(), 5, "{got:?}");
assert_eq!(got[4].0, "bill.created");
let entry = s.ledger().get_bill(bill).unwrap().entry_id;
assert_eq!(
got[4].1,
json!({
"id": bill,
"vendor_id": v,
"date": "2026-04-01",
"due_date": "2026-05-01",
"lines": [{"description": "Paper", "amount": "20.00"}],
"entry_id": entry,
}),
"the bill event is the shape every earlier version wrote"
);
let live = s.ledger().clone();
drop(s);
let s = Store::open(&dir.0).unwrap();
assert!(live.differs_from(s.ledger()).is_empty());
assert_eq!(s.ledger().get_invoice(inv).unwrap().number, None);
s.verify().unwrap();
}
/// The issued copy is filed with the invoice or nothing is (plan P4b S4.3
/// item 11, A-7; rules.md B-40 item 3): a refusal leaves the journal, the
/// ledger and the allocators exactly as they were; an issue journals the
/// entry, `invoice.created`, `receipt.attached` on that entry
/// (`INV-0007.pdf`, `application/pdf`, "Issued invoice") and
/// `attachment.linked` to the invoice, and the copy found through the link
/// is the bytes given, by their SHA-256.
#[test]
fn issued_pdf_is_filed_with_the_invoice_or_nothing_is() {
let dir = test_dir("issued-pdf");
let mut s = Store::create(&dir.0, Currency::USD).unwrap();
let c = s.add_customer("Fernhill Labs").unwrap();
// The invoice's control accounts exist already, so the issue journals
// only its own lines.
s.add_account(
"1200",
"Accounts Receivable",
AccountType::Asset,
None,
false,
)
.unwrap();
s.add_account("4000", "Revenue", AccountType::Revenue, None, false)
.unwrap();
let head = s.head();
let next_invoice = s.ledger().next_invoice_id();
let pdf = b"%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF\n".to_vec();
let fields = |number: &str| arap::InvoiceFields {
number: Some(number.into()),
..arap::InvoiceFields::default()
};
// An unknown customer: refused before anything is journaled.
assert!(matches!(
s.create_invoice_issued(
999,
d(2026, 4, 1),
d(2026, 5, 1),
vec![line("Design", 5_000)],
fields("INV-0007"),
pdf.clone(),
),
Err(StoreError::Ledger(LedgerError::UnknownCustomer(999)))
));
// A line that is not a document line: refused too.
assert!(
s.create_invoice_issued(
c,
d(2026, 4, 1),
d(2026, 5, 1),
vec![line("Design", -1)],
fields("INV-0007"),
pdf.clone(),
)
.is_err()
);
assert_eq!(s.head(), head, "nothing journaled");
assert_eq!(s.ledger().next_invoice_id(), next_invoice);
assert!(s.list_attachments(None).unwrap().is_empty());
// The issue itself, in one call.
let from = journal_len(&dir.0);
let (inv, entry, attachment) = s
.create_invoice_issued(
c,
d(2026, 4, 1),
d(2026, 5, 1),
vec![line("Design", 5_000)],
fields("INV-0007"),
pdf.clone(),
)
.unwrap();
let got = since(&dir.0, from);
let types: Vec<&str> = got.iter().map(|(t, _)| t.as_str()).collect();
assert_eq!(
s.ledger().get_invoice(inv).unwrap().total(),
Ok(cents(5_000))
types,
[
"entry.created",
"entry.line_added",
"entry.line_added",
"entry.posted",
"invoice.created",
"receipt.attached",
"attachment.linked",
]
);
let sha = ledger_store::journal::sha256_hex(&pdf);
assert_eq!(got[5].1["attachment_id"], json!(attachment));
assert_eq!(got[5].1["entry_id"], json!(entry));
assert_eq!(got[5].1["blob_sha"], json!(sha));
assert_eq!(got[5].1["category"], json!("receipts"));
assert_eq!(got[5].1["file_name"], json!("INV-0007.pdf"));
assert_eq!(got[5].1["mime_type"], json!("application/pdf"));
assert_eq!(got[5].1["description"], json!("Issued invoice"));
assert_eq!(got[5].1["size_bytes"], json!(pdf.len() as u64));
assert_eq!(
got[6].1["subject"],
json!({"kind": "invoice", "id": inv}),
"the copy is linked to the invoice"
);
// Found through the link, never by guessing; and it is the entry's
// evidence too.
let linked = s.attachments_for(Subject::Invoice(inv)).unwrap();
assert_eq!(linked.len(), 1);
assert_eq!(linked[0].id, attachment);
assert_eq!(linked[0].blob_sha, sha);
assert_eq!(s.attachment_bytes(attachment).unwrap(), pdf);
assert!(
s.list_attachments(Some(entry))
.unwrap()
.iter()
.any(|a| a.id == attachment)
);
let live = s.ledger().clone();
drop(s);
let s = Store::open(&dir.0).unwrap();
assert!(live.differs_from(s.ledger()).is_empty());
assert_eq!(s.attachments_for(Subject::Invoice(inv)).unwrap().len(), 1);
s.verify().unwrap();
}
/// Previews journal nothing (plan P4b S4.3 item 12): the invoice preview
/// is the posting, total, number and due date the issue would take (the
/// account each line posts to and the number from the books' settings),
/// and a refusal in it is the issue's own.
#[test]
fn preview_invoice_and_bill_journal_nothing() {
let dir = test_dir("preview");
let mut s = Store::create(&dir.0, Currency::USD).unwrap();
let c = s.add_customer("Fernhill Labs").unwrap();
let v = s.add_vendor("Tobin Harlow Supply").unwrap();
let revenue = s
.add_account("4100", "Consulting", AccountType::Revenue, None, false)
.unwrap();
let expenses = s
.add_account("6500", "Supplies", AccountType::Expense, None, false)
.unwrap();
// The preview's posting names the control accounts the issue would
// use; they exist in the books because a document made them before.
s.add_account(
"1200",
"Accounts Receivable",
AccountType::Asset,
None,
false,
)
.unwrap();
s.add_account(
"2000",
"Accounts Payable",
AccountType::Liability,
None,
false,
)
.unwrap();
let head = s.head();
let next_invoice = s.ledger().next_invoice_id();
let preview = s
.preview_invoice(
c,
d(2026, 4, 1),
d(2026, 5, 1),
vec![DocumentLine {
description: "Consulting".into(),
amount: cents(6_000),
class_id: None,
account: Some(revenue),
quantity: None,
unit_price: None,
}],
arap::InvoiceFields::default(),
)
.unwrap();
assert_eq!(preview.number.as_deref(), Some("INV-0001"));
assert_eq!(preview.due_date, d(2026, 5, 1));
assert_eq!(preview.total, cents(6_000));
let ar = s.ledger().chart().find_by_code("1200").unwrap().id;
assert_eq!(
preview.posting,
vec![
(ar, Side::Debit, cents(6_000), None),
(revenue, Side::Credit, cents(6_000), None),
]
);
let bill = s
.preview_bill(
v,
d(2026, 4, 1),
d(2026, 5, 1),
vec![DocumentLine {
description: "Supplies".into(),
amount: cents(2_000),
class_id: None,
account: Some(expenses),
quantity: None,
unit_price: None,
}],
arap::BillFields::default(),
)
.unwrap();
let ap = s.ledger().chart().find_by_code("2000").unwrap().id;
assert_eq!(
bill.posting,
vec![
(expenses, Side::Debit, cents(2_000), None),
(ap, Side::Credit, cents(2_000), None),
]
);
assert_eq!(bill.total, cents(2_000));
assert_eq!(s.head(), head, "a preview journals nothing");
assert_eq!(s.ledger().next_invoice_id(), next_invoice);
assert!(s.ledger().invoices().is_empty());
// What a preview refuses, the issue refuses too.
assert!(
s.preview_invoice(
999,
d(2026, 4, 1),
d(2026, 5, 1),
vec![line("Design", 5_000)],
arap::InvoiceFields::default(),
)
.is_err()
);
s.verify().unwrap();
}
@@ -0,0 +1,265 @@
{
"assets": [],
"bills": [
{
"amount_paid": "0",
"date": "2026-02-05",
"due_date": "2026-03-07",
"entry_id": 4,
"id": 1,
"lines": [
{
"amount": "80.00",
"description": "Paper"
}
],
"status": "Open",
"vendor_id": 1
}
],
"entries": [
{
"date": "2026-01-01",
"description": "Opening capital",
"id": 1,
"lines": [
{
"account": 1,
"amount": {
"amount": "1000.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 2,
"amount": {
"amount": "1000.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-02-01",
"description": "Invoice 1",
"id": 2,
"lines": [
{
"account": 3,
"amount": {
"amount": "100.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 4,
"amount": {
"amount": "100.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-02-20",
"description": "Payment on invoice 1",
"id": 3,
"lines": [
{
"account": 1,
"amount": {
"amount": "40.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 3,
"amount": {
"amount": "40.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-02-05",
"description": "Bill 1",
"id": 4,
"lines": [
{
"account": 5,
"amount": {
"amount": "80.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 6,
"amount": {
"amount": "80.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
}
],
"entry_parties": [
[
2,
[
{
"party_id": 1,
"share": "derived"
}
]
],
[
3,
[
{
"party_id": 1,
"share": "derived"
}
]
],
[
4,
[
{
"party_id": 2,
"share": "derived"
}
]
]
],
"invoices": [
{
"amount_paid": "40.00",
"customer_id": 1,
"date": "2026-02-01",
"due_date": "2026-03-03",
"entry_id": 2,
"id": 1,
"lines": [
{
"amount": "100.00",
"description": "Design"
}
],
"status": "Partial"
}
],
"payroll_runs": [],
"periods": [],
"recurring": [],
"schedules": [],
"snapshot": {
"Ok": {
"balance_sheet": {
"assets": "1100",
"balanced": true,
"current_earnings": "20",
"equity": "1020",
"liabilities": "80"
},
"profit_and_loss": {
"expenses": "80",
"net_income": "20",
"revenue": "100"
},
"trial_balance": {
"lines": [
{
"code": "1000",
"credit": "0",
"debit": "1040",
"name": "Bank"
},
{
"code": "1200",
"credit": "0",
"debit": "60",
"name": "Accounts Receivable"
},
{
"code": "2000",
"credit": "80",
"debit": "0",
"name": "Accounts Payable"
},
{
"code": "3000",
"credit": "1000",
"debit": "0",
"name": "Capital"
},
{
"code": "4000",
"credit": "100",
"debit": "0",
"name": "Revenue"
},
{
"code": "6999",
"credit": "0",
"debit": "80",
"name": "General Expenses"
}
],
"total_credits": "1180",
"total_debits": "1180"
}
}
},
"verify": {
"Ok": null
}
}
@@ -0,0 +1,33 @@
{"payload":{"base_currency":"USD"},"prev":"0000000000000000000000000000000000000000000000000000000000000000","seq":1,"ts":"2026-10-27T10:05:35Z","type":"store.created"}
{"payload":{"features":["account_renamed","asset_basis","depreciation_catch_up","disposal_before_service","document_line_projects","document_standing","documents_v2","members_cumulative","month_reposting","party_creditors","payment_instruments","projects","require_tax_category","schedule_convention","tax_categories_gain_exempt","vendor_1099_decisions"],"format":2},"prev":"a5bda5ebc50445e043401e7d4eca41f5a952ce0eca31e96ab219cd0c97a2d78e","seq":2,"ts":"2026-10-27T10:05:35Z","type":"journal.format_set"}
{"payload":{"account_type":"Asset","code":"1000","currency":"USD","id":1,"name":"Bank","parent":null,"placeholder":false},"prev":"1fcfbf8c42fcb55f9b993f982272af69186f6f74b392bc07f6f381850737a7ef","seq":3,"ts":"2026-10-27T10:05:35Z","type":"account.added"}
{"payload":{"account_type":"Equity","code":"3000","currency":"USD","id":2,"name":"Capital","parent":null,"placeholder":false},"prev":"a1df6eddd876e9a0ad6bc16bb1313eba210b50c99eb69404da78384af6501a0d","seq":4,"ts":"2026-10-27T10:05:35Z","type":"account.added"}
{"payload":{"date":"2026-01-01","description":"Opening capital","id":1},"prev":"ee64fc19e7bc187997ff388f5ed450272012f3212db8bf31bec5284cb482a309","seq":5,"ts":"2026-10-27T10:05:35Z","type":"entry.created"}
{"payload":{"account":1,"amount":"1000.00","currency":"USD","id":1,"side":"debit"},"prev":"a94ba3cf73b371457fd2df9eede6881be9f604b52572574a5218af18ba0524e5","seq":6,"ts":"2026-10-27T10:05:35Z","type":"entry.line_added"}
{"payload":{"account":2,"amount":"1000.00","currency":"USD","id":1,"side":"credit"},"prev":"9f181cadbc27d4c621614b913e55d3caa823d9608c7dc4cc0240b04c2320ce42","seq":7,"ts":"2026-10-27T10:05:35Z","type":"entry.line_added"}
{"payload":{"date":"2026-01-01","description":"Opening capital","id":1,"lines":[{"account":1,"amount":"1000.00","currency":"USD","side":"debit"},{"account":2,"amount":"1000.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"01d4037388ff0a14b015fa7a8e63308f2dcde48933850f74815f23baf01d4fb1","seq":8,"ts":"2026-10-27T10:05:35Z","type":"entry.posted"}
{"payload":{"id":1,"name":"Fernhill Labs"},"prev":"a97e58dc7d09b974b9fba27e61482a72ff1b7634d1eacff1ce86324c049150be","seq":9,"ts":"2026-10-27T10:05:35Z","type":"customer.added"}
{"payload":{"id":1,"name":"Tobin Harlow Supply"},"prev":"36adb1c1e5e6e91c62b71b2aa02d03b89106b796236e0dac0c2ef2f4dbda98cf","seq":10,"ts":"2026-10-27T10:05:35Z","type":"vendor.added"}
{"payload":{"account_type":"Asset","code":"1200","currency":"USD","id":3,"name":"Accounts Receivable","parent":null,"placeholder":false},"prev":"41668aee0e8c90a460c0255b76ecc7116d4e0884096c28f0d44ec58300b1c08a","seq":11,"ts":"2026-10-27T10:05:35Z","type":"account.added"}
{"payload":{"account_type":"Revenue","code":"4000","currency":"USD","id":4,"name":"Revenue","parent":null,"placeholder":false},"prev":"95ec779a5c6e75702e0220fd63fe77b1fac4f8225c84fb288b48fa80a46eb87b","seq":12,"ts":"2026-10-27T10:05:35Z","type":"account.added"}
{"payload":{"date":"2026-02-01","description":"Invoice 1","id":2},"prev":"008dafc140ceaced1176b0c0b304b8e8f6f03ebc32b7bf886af05087b385d239","seq":13,"ts":"2026-10-27T10:05:35Z","type":"entry.created"}
{"payload":{"account":3,"amount":"100.00","currency":"USD","id":2,"side":"debit"},"prev":"b831d4b02d0595b88b17e66f834dcb2f82bf34bd16642ca2b648ba564cd57c76","seq":14,"ts":"2026-10-27T10:05:35Z","type":"entry.line_added"}
{"payload":{"account":4,"amount":"100.00","currency":"USD","id":2,"side":"credit"},"prev":"9ac5ebb94ec4f0d0cae2a2d6cf8891ede2d1f88e5681dda0bb08dc825211ed17","seq":15,"ts":"2026-10-27T10:05:35Z","type":"entry.line_added"}
{"payload":{"date":"2026-02-01","description":"Invoice 1","id":2,"lines":[{"account":3,"amount":"100.00","currency":"USD","side":"debit"},{"account":4,"amount":"100.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"281fefb129d147bd4d8e2c7400f5de8c18c9d353ab5d35d70128e7bda6823af4","seq":16,"ts":"2026-10-27T10:05:35Z","type":"entry.posted"}
{"payload":{"customer_id":1,"date":"2026-02-01","due_date":"2026-03-03","entry_id":2,"id":1,"lines":[{"amount":"100.00","description":"Design"}]},"prev":"968344c97883fed97e1f6270da8715ef94776d4a4c99eef1273c55e95f6aba9c","seq":17,"ts":"2026-10-27T10:05:35Z","type":"invoice.created"}
{"payload":{"date":"2026-02-20","description":"Payment on invoice 1","id":3},"prev":"31d6018e811feac96b0fd9a171cf1d931aefab064750192bef91441f53791437","seq":18,"ts":"2026-10-27T10:05:35Z","type":"entry.created"}
{"payload":{"account":1,"amount":"40.00","currency":"USD","id":3,"side":"debit"},"prev":"fcbb56661d4ddc912ad2ab09badb3c2a8bd95919cfc2a42f17862b74b2e3af93","seq":19,"ts":"2026-10-27T10:05:35Z","type":"entry.line_added"}
{"payload":{"account":3,"amount":"40.00","currency":"USD","id":3,"side":"credit"},"prev":"c2635b69e09523474d7a62556df3b3c44bf6732ef7918a1ab988291757d916c9","seq":20,"ts":"2026-10-27T10:05:35Z","type":"entry.line_added"}
{"payload":{"date":"2026-02-20","description":"Payment on invoice 1","id":3,"lines":[{"account":1,"amount":"40.00","currency":"USD","side":"debit"},{"account":3,"amount":"40.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"bba5c27982f933e44e00b50601f14c754ab8050a5ab602655e1ac46f1f2e76fd","seq":21,"ts":"2026-10-27T10:05:35Z","type":"entry.posted"}
{"payload":{"amount":"40.00","cash_account":1,"date":"2026-02-20","entry_id":3,"invoice_id":1},"prev":"2a36c0bba5c9c87cfdf7a4d311ad9c459bbdb758f29b68de966baec8e52ac0af","seq":22,"ts":"2026-10-27T10:05:35Z","type":"invoice.payment_received"}
{"payload":{"account_type":"Expense","code":"6999","currency":"USD","id":5,"name":"General Expenses","parent":null,"placeholder":false},"prev":"27a91b52e654fc0095464ebe5ba3ebbdcb6c5ab7fec68c686af7c4ed8c79c052","seq":23,"ts":"2026-10-27T10:05:35Z","type":"account.added"}
{"payload":{"account_type":"Liability","code":"2000","currency":"USD","id":6,"name":"Accounts Payable","parent":null,"placeholder":false},"prev":"c6e3cb6713211338e5772699d220e9b9539297f93a861609df9f14cbd0443e6c","seq":24,"ts":"2026-10-27T10:05:35Z","type":"account.added"}
{"payload":{"date":"2026-02-05","description":"Bill 1","id":4},"prev":"c353df71bd868c67f7e65c3bea31ab60fd379f4f62a4a4d5ea87770cb2dca2cc","seq":25,"ts":"2026-10-27T10:05:35Z","type":"entry.created"}
{"payload":{"account":5,"amount":"80.00","currency":"USD","id":4,"side":"debit"},"prev":"2070852a5425af68a5cc2eac9bc8da4cbc7ded979c4984baf9dbc303aa39a5ca","seq":26,"ts":"2026-10-27T10:05:35Z","type":"entry.line_added"}
{"payload":{"account":6,"amount":"80.00","currency":"USD","id":4,"side":"credit"},"prev":"1f437a1e3f7f16fa8b48ff50dbe8a7c0c5baff9de4e69ab47b048d6a0ebe30ba","seq":27,"ts":"2026-10-27T10:05:35Z","type":"entry.line_added"}
{"payload":{"date":"2026-02-05","description":"Bill 1","id":4,"lines":[{"account":5,"amount":"80.00","currency":"USD","side":"debit"},{"account":6,"amount":"80.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"c9c3750dc017acd6c3f228a7cc6bc1aa756d30b2c00cc1592d904cb0f4de9b5c","seq":28,"ts":"2026-10-27T10:05:35Z","type":"entry.posted"}
{"payload":{"date":"2026-02-05","due_date":"2026-03-07","entry_id":4,"id":1,"lines":[{"amount":"80.00","description":"Paper"}],"vendor_id":1},"prev":"773f411c11ff7dd2f22dd8836ec7c619b6301cacb925dfcc81efbe51ffa330ee","seq":29,"ts":"2026-10-27T10:05:35Z","type":"bill.created"}
{"payload":{"id":3,"name":"Avery Quill","roles":["member"]},"prev":"80b465670d96f7f7d82f047ac24ccd79dd7f6824b17dea607bef320208f55073","seq":30,"ts":"2026-10-27T10:05:35Z","type":"party.added"}
{"payload":{"change":{"field":"email","value":"avery@quill.example"},"id":3},"prev":"ba2139f78a88c2ed7fc90820e17b6a87cf29d368e4c3f99c446eb49233742795","seq":31,"ts":"2026-10-27T10:05:35Z","type":"party.updated"}
{"payload":{"change":{"field":"default_terms_as_customer","value":{"kind":{"net":15},"text":"Net 15"}},"id":3},"prev":"9721f78fbb621c5bd1bf3e93cb998bfd4b5fc093a2c8fabc95d604242da1812b","seq":32,"ts":"2026-10-27T10:05:35Z","type":"party.updated"}
{"payload":{"changes":[{"key":"invoicing.number_prefix","value":"INV-"},{"key":"invoicing.first_number","value":7},{"key":"invoicing.default_terms","value":"Net 30"}],"source":{"decided_by":"Avery Quill","decided_on":"2026-02-01"}},"prev":"dd3fc95f2e5be0fdddabc795fa8658b06b9e557128218d99c089d52b2db83323","seq":33,"ts":"2026-10-27T10:05:35Z","type":"settings.set"}
+72
View File
@@ -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");
+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