diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index 4fd384f..a24a24c 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -6628,6 +6628,32 @@ impl Ledger { due_date: NaiveDate, lines: Vec, 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, + 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, 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, + 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); diff --git a/crates/ledger-mcp/handbook/format-upgrade.md b/crates/ledger-mcp/handbook/format-upgrade.md index e5594b3..36007b5 100644 --- a/crates/ledger-mcp/handbook/format-upgrade.md +++ b/crates/ledger-mcp/handbook/format-upgrade.md @@ -2,6 +2,7 @@ - A new version of wanderbooks can keep the books in a newer journal format than the version that wrote them. Books an older version wrote wait for a format upgrade: books.start says so (format_upgrade) and puts it first among its next steps, in every session until it is done. - Until then they work on their old format: everything that needs nothing new works as before, and whatever would need the new format is refused with format_upgrade_pending, changing nothing. Never retry it, work around it, or post it another way. +- An invoice's or bill's number, payment terms, seller and bill-to, references and memo, a line's account, quantity and unit price, the copy of an invoice kept exactly as it was issued, a party's email and default terms, and the invoicing and bills settings all wait for the documents_v2 upgrade. A document that names none of them is written exactly as before and is never refused; the work that needs one waits, and the books say so. - Only the owner can agree to the upgrade, with the owner passphrase: in the app, which asks each time it opens the books, or with `wanderbooks books upgrade ID` in a terminal. No tool can, and you never ask for, take or type the passphrase. Tell the human, in plain words: the books wait for their go-ahead, what was refused, and that saying no keeps the books working as they are. - Before it upgrades, the server makes a backup of the books, their vault and keys included, and proves it whole; a backup that can't be made or proven upgrades nothing. Then the books move to the new format in one step, on the record. - After the upgrade an older version of wanderbooks can't open these books; the backup made first keeps them as they were. Going back is the owner's choice, never yours. diff --git a/crates/ledger-store/src/error.rs b/crates/ledger-store/src/error.rs index 09881aa..5affe43 100644 --- a/crates/ledger-store/src/error.rs +++ b/crates/ledger-store/src/error.rs @@ -47,17 +47,6 @@ pub enum StoreError { #[error("json error: {0}")] Json(#[from] serde_json::Error), - /// A document line carries a field this version's journal cannot write - /// yet: line accounts, quantities and unit prices are journaled from - /// S4.3 (`documents_v2`) on. Refused before anything is journaled. - #[error( - "this document line names {field}, which these books cannot journal yet: line accounts, quantities and unit prices wait for the documents_v2 upgrade (S4.3). Nothing was changed; give the line one amount for now" - )] - DocumentFieldsAwaitUpgrade { - /// The field it names: "an account", "a quantity and unit price". - field: &'static str, - }, - /// The engine rejected the operation. The journal is untouched. #[error("ledger error: {0}")] Ledger(#[from] ledger_core::LedgerError), diff --git a/crates/ledger-store/src/lib.rs b/crates/ledger-store/src/lib.rs index dd2422d..593156c 100644 --- a/crates/ledger-store/src/lib.rs +++ b/crates/ledger-store/src/lib.rs @@ -47,7 +47,8 @@ pub use format::{FormatUpgrade, JournalFormat, SUPPORTED_JOURNAL_FORMAT}; pub use journal::{GENESIS_PREV, JournalLine, JournalWriter}; pub use snapshot::{DiskCheck, Snapshot}; pub use store::{ - Attachment, DepreciationRun, DisposalPreview, EntryRow, EventRow, JOURNAL_FILE, LEGACY_DB_FILE, - LOCK_FILE, MAX_RECURRING_RUN, PayrollLineInput, Reconciliation, Statement, Store, Subject, - VerifyReport, database_in_use, db_file, db_file_for, is_database_file, + Attachment, BillPreview, DepreciationRun, DisposalPreview, EntryRow, EventRow, InvoicePreview, + JOURNAL_FILE, LEGACY_DB_FILE, LOCK_FILE, MAX_RECURRING_RUN, PayrollLineInput, Reconciliation, + Statement, Store, Subject, VerifyReport, database_in_use, db_file, db_file_for, + is_database_file, }; diff --git a/crates/ledger-store/src/schema.rs b/crates/ledger-store/src/schema.rs index 2c34633..c885425 100644 --- a/crates/ledger-store/src/schema.rs +++ b/crates/ledger-store/src/schema.rs @@ -5,7 +5,7 @@ //! is the current version. /// Current schema version. -pub const SCHEMA_VERSION: u32 = 15; +pub const SCHEMA_VERSION: u32 = 16; /// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`. pub const MIGRATIONS: &[&str] = &[ @@ -396,5 +396,26 @@ ALTER TABLE invoices DROP COLUMN amount_paid; ALTER TABLE invoices DROP COLUMN status; ALTER TABLE bills DROP COLUMN amount_paid; ALTER TABLE bills DROP COLUMN status; +"#, + r#" +-- v16: a document's new fields (plan P4b S4.3, feature documents_v2): the +-- number it was issued under, its terms, the bill-to and seller snapshots, +-- the customer's reference and its memo; the bill's vendor reference, its +-- receipt date, terms and memo, and why a duplicate was entered anyway. +-- Terms and snapshots are canonical JSON TEXT; a document written before +-- this schema names none and its columns stay NULL. A line's account, +-- quantity and unit price ride its entry in lines_json, as its project +-- does. +ALTER TABLE invoices ADD COLUMN number TEXT; +ALTER TABLE invoices ADD COLUMN terms TEXT; +ALTER TABLE invoices ADD COLUMN bill_to TEXT; +ALTER TABLE invoices ADD COLUMN seller TEXT; +ALTER TABLE invoices ADD COLUMN customer_ref TEXT; +ALTER TABLE invoices ADD COLUMN memo TEXT; +ALTER TABLE bills ADD COLUMN vendor_ref TEXT; +ALTER TABLE bills ADD COLUMN received_on TEXT; +ALTER TABLE bills ADD COLUMN terms TEXT; +ALTER TABLE bills ADD COLUMN memo TEXT; +ALTER TABLE bills ADD COLUMN duplicate_reason TEXT; "#, ]; diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 0e01fd8..055ff0b 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -322,6 +322,34 @@ pub struct Attachment { /// write-path signature reads as input rather than stored state. pub type PayrollLineInput = PayrollLine; +/// What issuing an invoice would post and take, journaling nothing +/// ([`Store::preview_invoice`]; plan P4b S4.3 item 12). The posting is the +/// entry's lines in order: account, side, amount, project. +#[derive(Clone, Debug, PartialEq)] +pub struct InvoicePreview { + /// The entry it would post, line by line. + pub posting: Vec<(AccountId, Side, Decimal, Option)>, + /// The document's total. + pub total: Decimal, + /// The number it would take (A-2), when the books number their + /// invoices. + pub number: Option, + /// Its due date. + pub due_date: NaiveDate, +} + +/// What recording a bill would post, journaling nothing +/// ([`Store::preview_bill`]; plan P4b S4.3 item 12). +#[derive(Clone, Debug, PartialEq)] +pub struct BillPreview { + /// The entry it would post, line by line. + pub posting: Vec<(AccountId, Side, Decimal, Option)>, + /// The document's total. + pub total: Decimal, + /// Its due date. + pub due_date: NaiveDate, +} + /// The most recurring drafts one [`Store::run_recurring`] creates: more is /// refused, so a far-off date can't make one request write without end. pub const MAX_RECURRING_RUN: usize = 1000; @@ -897,6 +925,15 @@ impl Store { .collect(); self.ensure_features(&needed)?; self.ensure_format()?; + // invoicing.first_number is settable only while no numbered invoice + // exists (A-2, rules.md B-40): the series has begun, and a number is + // never reused or renumbered. Changing or clearing it is refused + // before anything is journaled. + if changes.iter().any(|c| c.key == "invoicing.first_number") + && self.ledger.invoices().iter().any(|i| i.number.is_some()) + { + return Err(LedgerError::FirstNumberLocked.into()); + } let line = self.append_journal("settings.set", crate::meta::settings_set(&changes, source))?; self.ledger.apply_settings(&changes, source, line.seq)?; @@ -2926,6 +2963,19 @@ impl Store { Ok(()) } + /// The feature a document's new fields need (rules.md B-27): an + /// invoice or bill naming a number, terms, a snapshot, a reference, a + /// memo or a duplicate's reason, or a line naming its account, quantity + /// or unit price, needs `documents_v2` (plan P4b S4.3; D-52). A + /// document naming none of them is written byte for byte as before and + /// is never refused. + fn ensure_document_fields(&self, fields: &DocumentFields<'_>) -> Result<(), StoreError> { + if fields.any_new() { + self.ensure_feature(crate::format::DOCUMENTS_V2)?; + } + Ok(()) + } + /// Add a customer. Journaled as `customer.added`. pub fn add_customer(&mut self, name: &str) -> Result { self.rehearse(|l| l.add_customer(name))?; @@ -2959,6 +3009,9 @@ impl Store { /// Create a sales invoice. The auto-posted entry (Dr A/R, Cr Revenue) /// is journaled first through the standard entry flow, then the /// invoice itself as `invoice.created`. + /// + /// [`Self::create_invoice_with`] with no new fields: the invoice + /// every version before `documents_v2` wrote. pub fn create_invoice( &mut self, customer_id: CustomerId, @@ -2966,57 +3019,50 @@ impl Store { due_date: NaiveDate, lines: Vec, ) -> Result { - // S4.1's line fields are not journaled yet (S4.3's `documents_v2` - // writes them); refuse before `rehearse`, so replay can never lose - // one. - check_journaled_document_lines(&lines)?; - self.rehearse(|l| l.create_invoice(customer_id, date, due_date, lines.clone()))?; - self.approval_gate( - |l| l.create_invoice(customer_id, date, due_date, lines.clone()), - false, - )?; - // Pure pre-validation mirroring Ledger::create_invoice, so the - // journal writes below cannot be followed by a failed mutation. - // (The auto-posted entry balances by construction and its accounts - // are ensured below.) - if self.ledger.get_customer(customer_id).is_none() { - return Err(LedgerError::UnknownCustomer(customer_id).into()); - } - arap::validate_lines(&lines)?; - arap::validate_due_date(date, due_date)?; - if let Some(pid) = self.ledger.closed_period_containing(date) { - return Err(LedgerError::ClosedPeriod(pid, date.to_string()).into()); - } - self.ensure_document_lines(&lines)?; - let ar = self.ensure_control_account(&ledger_core::RECEIVABLES)?; - let revenue = self.ensure_control_account(&ledger_core::REVENUE)?; - let invoice_id = self.ledger.next_invoice_id(); - let total: Decimal = lines.iter().map(|l| l.amount).sum(); - let description = format!("Invoice {invoice_id}"); - let mut posting = vec![(ar, Side::Debit, total, None)]; - posting.extend( - arap::document_parts(&lines, "the invoice")? - .into_iter() - .map(|((account, class), amount)| { - (account.unwrap_or(revenue), Side::Credit, amount, class) - }), - ); - let (entry_id, mut journaled) = self.journal_auto_lines(date, &description, &posting)?; - let doc_lines: Vec = lines.iter().map(document_line_json).collect(); - journaled.push(self.append_journal( - "invoice.created", - json!({ - "id": invoice_id, - "customer_id": customer_id, - "date": date.to_string(), - "due_date": due_date.to_string(), - "lines": doc_lines, - "entry_id": entry_id, - }), - )?); + self.create_invoice_with( + customer_id, + date, + due_date, + lines, + arap::InvoiceFields::default(), + ) + } + + /// Create a sales invoice with its new fields (number, terms, snapshots, + /// references: plan P4b S4.3). The fields are journaled on + /// `invoice.created` and restored by replay; a document whose fields are + /// all `None` is journaled exactly as before + /// ([`Self::create_invoice`]). + /// + /// ```text + /// GOAL: a document's new fields are journaled with it, so replay can + /// never lose them, and a document naming none is written byte + /// for byte as an earlier version wrote it. + /// SOURCE: docs/spec/rules.md B-27, B-40 (plan P4b S4.3); A-2, A-13. + /// INVARIANTS: + /// 1. A write naming a number, terms, a snapshot, a reference, a memo, + /// a line account, quantity or unit price MUST need journal feature + /// documents_v2: refused (FormatUpgradePending), journaling + /// nothing, in books that haven't declared it. + /// 2. A refusal MUST leave the journal, the ledger and the projection + /// unchanged. + /// 3. The event's fields MUST be exactly the ones given: no key is + /// written for a field that is None. + /// ``` + pub fn create_invoice_with( + &mut self, + customer_id: CustomerId, + date: NaiveDate, + due_date: NaiveDate, + lines: Vec, + fields: arap::InvoiceFields, + ) -> Result { + self.check_invoice_create(customer_id, date, due_date, &lines, &fields)?; + let (invoice_id, entry_id, journaled) = + self.journal_invoice(customer_id, date, due_date, &lines, &fields)?; let live = self .ledger - .create_invoice(customer_id, date, due_date, lines)?; + .create_invoice_with(customer_id, date, due_date, lines, fields)?; debug_assert_eq!(live, invoice_id, "id allocator diverged from journal"); debug_assert_eq!( self.ledger @@ -3032,9 +3078,257 @@ impl Store { Ok(live) } + /// Everything [`Self::create_invoice_with`] refuses, before anything is + /// journaled: the engine's whole check on a clone, its approval rules, + /// and the journal format the fields need. + fn check_invoice_create( + &self, + customer_id: CustomerId, + date: NaiveDate, + due_date: NaiveDate, + lines: &[DocumentLine], + fields: &arap::InvoiceFields, + ) -> Result<(), StoreError> { + self.rehearse(|l| { + l.create_invoice_with(customer_id, date, due_date, lines.to_vec(), fields.clone()) + })?; + self.approval_gate( + |l| { + l.create_invoice_with(customer_id, date, due_date, lines.to_vec(), fields.clone())?; + Ok(()) + }, + false, + )?; + // Pure pre-validation mirroring create_invoice_steps, so the + // journal writes below cannot be followed by a failed mutation. + // (The auto-posted entry balances by construction and its accounts + // are ensured below.) + if self.ledger.get_customer(customer_id).is_none() { + return Err(LedgerError::UnknownCustomer(customer_id).into()); + } + arap::validate_lines(lines)?; + arap::validate_due_date(date, due_date)?; + if let Some(pid) = self.ledger.closed_period_containing(date) { + return Err(LedgerError::ClosedPeriod(pid, date.to_string()).into()); + } + if let Some(number) = &fields.number + && self + .ledger + .invoices() + .iter() + .any(|i| i.number.as_deref() == Some(number.as_str())) + { + return Err(LedgerError::DuplicateInvoiceNumber(number.clone()).into()); + } + self.ensure_document_lines(lines)?; + self.ensure_document_fields(&DocumentFields { + invoice: Some(fields), + bill: None, + lines, + })?; + Ok(()) + } + + /// Journal an invoice's auto-posted entry and its `invoice.created` + /// event, without touching the live ledger. [`Self::check_invoice_create`] + /// has run. + fn journal_invoice( + &mut self, + customer_id: CustomerId, + date: NaiveDate, + due_date: NaiveDate, + lines: &[DocumentLine], + fields: &arap::InvoiceFields, + ) -> Result<(InvoiceId, EntryId, Vec), StoreError> { + let ar = self.ensure_control_account(&ledger_core::RECEIVABLES)?; + let revenue = self.ensure_control_account(&ledger_core::REVENUE)?; + let invoice_id = self.ledger.next_invoice_id(); + let total: Decimal = lines.iter().map(|l| l.amount).sum(); + let description = format!("Invoice {invoice_id}"); + let mut posting = vec![(ar, Side::Debit, total, None)]; + posting.extend(arap::document_parts(lines, "the invoice")?.into_iter().map( + |((account, class), amount)| (account.unwrap_or(revenue), Side::Credit, amount, class), + )); + let (entry_id, mut journaled) = self.journal_auto_lines(date, &description, &posting)?; + let doc_lines: Vec = lines.iter().map(document_line_json).collect(); + let mut payload = json!({ + "id": invoice_id, + "customer_id": customer_id, + "date": date.to_string(), + "due_date": due_date.to_string(), + "lines": doc_lines, + "entry_id": entry_id, + }); + invoice_fields_json(&mut payload, fields); + journaled.push(self.append_journal("invoice.created", payload)?); + Ok((invoice_id, entry_id, journaled)) + } + + /// The issued copy of an invoice, filed in the call that issues it + /// (plan P4b S4.3 item 11, A-7; rules.md B-40 item 3): the entry, then + /// `invoice.created`, then `receipt.attached` on the entry + /// (`INV-0007.pdf`, `application/pdf`, "Issued invoice"), then + /// `attachment.linked` to the invoice. The server renders the PDF + /// first, so a rendering failure never reaches the store. + /// + /// The copy is found through the link, never by guessing + /// ([`Self::attachments_for`] with [`Subject::Invoice`]); its SHA-256 + /// is the `blob_sha` in `receipt.attached`. There is no `issued_pdf` + /// field on the event: the attachment id is allocated after the entry + /// exists (CD-A6). + /// + /// ```text + /// GOAL: the copy sent is kept with the invoice, or nothing is. + /// SOURCE: IRS Pub. 583 (keep invoices); Rev. Proc. 98-25 (the audit + /// trail keeps the record as issued); plan P4b S4.3, 8.3.11; + /// rules.md B-40 item 3. + /// INVARIANTS: + /// 1. Every refusal (an unknown customer, an invalid line or due + /// date, a duplicate number, a closed period, a feature the books + /// haven't declared) MUST happen before the first journal line. + /// 2. The issued copy MUST be a `receipt.attached` on the invoice's + /// own entry, linked to the invoice, with the PDF's SHA-256. + /// 3. The invoice is numbered: the number given, or the next from + /// invoicing.number_prefix and invoicing.first_number (A-2). + /// ``` + pub fn create_invoice_issued( + &mut self, + customer_id: CustomerId, + date: NaiveDate, + due_date: NaiveDate, + lines: Vec, + fields: arap::InvoiceFields, + pdf_bytes: Vec, + ) -> Result<(InvoiceId, EntryId, u64), StoreError> { + let fields = self.numbered(fields); + self.check_invoice_create(customer_id, date, due_date, &lines, &fields)?; + // The bytes hit the content-addressed store before anything is + // journaled: an orphan blob from a later refusal is harmless + // (unreferenced, deduplicated), a journal line naming a blob that + // failed to store would not be. + self.blobs.put(&pdf_bytes)?; + let (invoice_id, entry_id, journaled) = + self.journal_invoice(customer_id, date, due_date, &lines, &fields)?; + let live = + self.ledger + .create_invoice_with(customer_id, date, due_date, lines, fields.clone())?; + debug_assert_eq!(live, invoice_id, "id allocator diverged from journal"); + debug_assert_eq!( + self.ledger + .get_invoice(live) + .expect("invoice exists") + .entry_id, + entry_id, + "invoice entry diverged from journal" + ); + let file_name = match &fields.number { + Some(n) => format!("{n}.pdf"), + None => format!("Invoice {live}.pdf"), + }; + // The entry's and the invoice's lines apply first: they were + // journaled before the attachment's, and the projection's + // `last_applied_seq` follows the last line applied. + for line in &journaled { + apply_line_to_sqlite(&self.conn, line)?; + } + let (attachment_id, _, _) = self.attach_receipt( + Some(entry_id), + &file_name, + "application/pdf", + &pdf_bytes, + Some("Issued invoice"), + )?; + self.link_attachment(attachment_id, Subject::Invoice(live))?; + Ok((live, entry_id, attachment_id)) + } + + /// `fields` with a number when the books number their invoices: the one + /// given, or the next from the books' invoicing settings (A-2, + /// [`Ledger::next_invoice_number`]). Books that haven't declared + /// `documents_v2` keep the legacy shape (migration item 5: "Invoice + /// {id}", uncounted), so their issue files the copy and takes no number. + fn numbered(&self, fields: arap::InvoiceFields) -> arap::InvoiceFields { + if fields.number.is_some() + || !self + .journal_format() + .has_feature(crate::format::DOCUMENTS_V2) + { + return fields; + } + let setting = |key: &str| self.ledger.settings().get(key).map(|r| r.value.clone()); + let prefix = setting("invoicing.number_prefix") + .and_then(|v| v.as_str().map(str::to_string)) + .unwrap_or_else(|| "INV-".to_string()); + let first = setting("invoicing.first_number") + .and_then(|v| v.as_u64()) + .unwrap_or(1); + arap::InvoiceFields { + number: Some(self.ledger.next_invoice_number(&prefix, first)), + ..fields + } + } + + /// What issuing an invoice would post and take, journaling nothing + /// (plan P4b S4.3 item 12): the engine runs on a clone (the + /// rehearsal pattern: the engine runs on a clone), so every refusal is + /// the issue's own. + /// + /// ```text + /// GOAL: the confirm is never a surprise: the posting, the total, the + /// number it would take and the due date are shown first. + /// SOURCE: plan P4b S4.3 item 12; A-1 (an abandoned preview uses up no + /// number). + /// INVARIANTS: + /// 1. MUST journal nothing and change nothing, allocators included. + /// 2. The posting MUST be the entry [`Self::create_invoice_issued`] + /// would post, and the number the one it would take. + /// ``` + pub fn preview_invoice( + &self, + customer_id: CustomerId, + date: NaiveDate, + due_date: NaiveDate, + lines: Vec, + fields: arap::InvoiceFields, + ) -> Result { + let fields = self.numbered(fields); + // The same checks the issue makes, so a preview refuses exactly what + // the confirm will. + self.ensure_document_lines(&lines)?; + self.ensure_document_fields(&DocumentFields { + invoice: Some(&fields), + bill: None, + lines: &lines, + })?; + self.approval_gate( + |l| { + l.create_invoice_with(customer_id, date, due_date, lines.clone(), fields.clone())?; + Ok(()) + }, + false, + )?; + let mut probe = self.ledger.clone(); + let id = probe.create_invoice_with(customer_id, date, due_date, lines, fields.clone())?; + let inv = probe.get_invoice(id).expect("the probe made the invoice"); + let entry = probe.get_entry(inv.entry_id).expect("its entry"); + Ok(InvoicePreview { + posting: entry + .lines() + .iter() + .map(|l| (l.account, l.side, l.amount.amount, l.class_id)) + .collect(), + total: inv.total()?, + number: fields.number, + due_date: inv.due_date, + }) + } + /// Create a vendor bill. The auto-posted entry (Dr General Expenses, Cr /// A/P) is journaled first through the standard entry flow, then the /// bill itself as `bill.created`. + /// + /// [`Self::create_bill_with`] with no new fields: the bill every + /// version before `documents_v2` wrote. pub fn create_bill( &mut self, vendor_id: VendorId, @@ -3042,49 +3336,33 @@ impl Store { due_date: NaiveDate, lines: Vec, ) -> Result { - // See create_invoice: the line's account, quantity and unit price - // wait for S4.3's journaling. - check_journaled_document_lines(&lines)?; - self.rehearse(|l| l.create_bill(vendor_id, date, due_date, lines.clone()))?; - self.approval_gate( - |l| l.create_bill(vendor_id, date, due_date, lines.clone()), - false, - )?; - if self.ledger.get_vendor(vendor_id).is_none() { - return Err(LedgerError::UnknownVendor(vendor_id).into()); - } - arap::validate_lines(&lines)?; - arap::validate_due_date(date, due_date)?; - if let Some(pid) = self.ledger.closed_period_containing(date) { - return Err(LedgerError::ClosedPeriod(pid, date.to_string()).into()); - } - self.ensure_document_lines(&lines)?; - let expense = self.ensure_control_account(&ledger_core::GENERAL_EXPENSES)?; - let ap = self.ensure_control_account(&ledger_core::PAYABLES)?; - let bill_id = self.ledger.next_bill_id(); - let total: Decimal = lines.iter().map(|l| l.amount).sum(); - let description = format!("Bill {bill_id}"); - let mut posting: Vec<_> = arap::document_parts(&lines, "the bill")? - .into_iter() - .map(|((account, class), amount)| { - (account.unwrap_or(expense), Side::Debit, amount, class) - }) - .collect(); - posting.push((ap, Side::Credit, total, None)); - let (entry_id, mut journaled) = self.journal_auto_lines(date, &description, &posting)?; - let doc_lines: Vec = lines.iter().map(document_line_json).collect(); - journaled.push(self.append_journal( - "bill.created", - json!({ - "id": bill_id, - "vendor_id": vendor_id, - "date": date.to_string(), - "due_date": due_date.to_string(), - "lines": doc_lines, - "entry_id": entry_id, - }), - )?); - let live = self.ledger.create_bill(vendor_id, date, due_date, lines)?; + self.create_bill_with( + vendor_id, + date, + due_date, + lines, + arap::BillFields::default(), + ) + } + + /// Create a vendor bill with its new fields (the vendor's invoice + /// number, receipt date, terms, memo, a duplicate's reason: plan P4b + /// S4.3). [`Self::create_invoice_with`]'s INVARIANTS hold here too; a + /// bill whose fields are all `None` is journaled exactly as before. + pub fn create_bill_with( + &mut self, + vendor_id: VendorId, + date: NaiveDate, + due_date: NaiveDate, + lines: Vec, + fields: arap::BillFields, + ) -> Result { + self.check_bill_create(vendor_id, date, due_date, &lines, &fields)?; + let (bill_id, entry_id, journaled) = + self.journal_bill(vendor_id, date, due_date, &lines, &fields)?; + let live = self + .ledger + .create_bill_with(vendor_id, date, due_date, lines, fields)?; debug_assert_eq!(live, bill_id, "id allocator diverged from journal"); debug_assert_eq!( self.ledger.get_bill(live).expect("bill exists").entry_id, @@ -3097,6 +3375,129 @@ impl Store { Ok(live) } + /// Everything [`Self::create_bill_with`] refuses, before anything is + /// journaled; [`Self::check_invoice_create`]'s comments apply. + fn check_bill_create( + &self, + vendor_id: VendorId, + date: NaiveDate, + due_date: NaiveDate, + lines: &[DocumentLine], + fields: &arap::BillFields, + ) -> Result<(), StoreError> { + self.rehearse(|l| { + l.create_bill_with(vendor_id, date, due_date, lines.to_vec(), fields.clone()) + })?; + self.approval_gate( + |l| { + l.create_bill_with(vendor_id, date, due_date, lines.to_vec(), fields.clone())?; + Ok(()) + }, + false, + )?; + if self.ledger.get_vendor(vendor_id).is_none() { + return Err(LedgerError::UnknownVendor(vendor_id).into()); + } + arap::validate_lines(lines)?; + arap::validate_due_date(date, due_date)?; + if let Some(received_on) = fields.received_on { + ledger_core::calendar::check(received_on)?; + } + if let Some(vendor_ref) = fields.vendor_ref.as_deref() { + self.ledger.check_vendor_ref( + vendor_id, + vendor_ref, + fields.duplicate_reason.as_deref(), + )?; + } + if let Some(pid) = self.ledger.closed_period_containing(date) { + return Err(LedgerError::ClosedPeriod(pid, date.to_string()).into()); + } + self.ensure_document_lines(lines)?; + self.ensure_document_fields(&DocumentFields { + invoice: None, + bill: Some(fields), + lines, + })?; + Ok(()) + } + + /// Journal a bill's auto-posted entry and its `bill.created` event, + /// without touching the live ledger; [`Self::check_bill_create`] has + /// run. + fn journal_bill( + &mut self, + vendor_id: VendorId, + date: NaiveDate, + due_date: NaiveDate, + lines: &[DocumentLine], + fields: &arap::BillFields, + ) -> Result<(BillId, EntryId, Vec), StoreError> { + let expense = self.ensure_control_account(&ledger_core::GENERAL_EXPENSES)?; + let ap = self.ensure_control_account(&ledger_core::PAYABLES)?; + let bill_id = self.ledger.next_bill_id(); + let total: Decimal = lines.iter().map(|l| l.amount).sum(); + let description = format!("Bill {bill_id}"); + let mut posting: Vec<_> = arap::document_parts(lines, "the bill")? + .into_iter() + .map(|((account, class), amount)| { + (account.unwrap_or(expense), Side::Debit, amount, class) + }) + .collect(); + posting.push((ap, Side::Credit, total, None)); + let (entry_id, mut journaled) = self.journal_auto_lines(date, &description, &posting)?; + let doc_lines: Vec = lines.iter().map(document_line_json).collect(); + let mut payload = json!({ + "id": bill_id, + "vendor_id": vendor_id, + "date": date.to_string(), + "due_date": due_date.to_string(), + "lines": doc_lines, + "entry_id": entry_id, + }); + bill_fields_json(&mut payload, fields); + journaled.push(self.append_journal("bill.created", payload)?); + Ok((bill_id, entry_id, journaled)) + } + + /// What recording a bill would post, journaling nothing (plan P4b S4.3 + /// item 12); [`Self::preview_invoice`]'s INVARIANTS hold here too. + pub fn preview_bill( + &self, + vendor_id: VendorId, + date: NaiveDate, + due_date: NaiveDate, + lines: Vec, + fields: arap::BillFields, + ) -> Result { + self.ensure_document_lines(&lines)?; + self.ensure_document_fields(&DocumentFields { + invoice: None, + bill: Some(&fields), + lines: &lines, + })?; + self.approval_gate( + |l| { + l.create_bill_with(vendor_id, date, due_date, lines.clone(), fields.clone())?; + Ok(()) + }, + false, + )?; + let mut probe = self.ledger.clone(); + let id = probe.create_bill_with(vendor_id, date, due_date, lines, fields)?; + let bill = probe.get_bill(id).expect("the probe made the bill"); + let entry = probe.get_entry(bill.entry_id).expect("its entry"); + Ok(BillPreview { + posting: entry + .lines() + .iter() + .map(|l| (l.account, l.side, l.amount.amount, l.class_id)) + .collect(), + total: bill.total()?, + due_date: bill.due_date, + }) + } + /// Record a customer payment against an invoice /// ([`Self::receive_payment_with`], with no payment method or /// reference). @@ -5624,28 +6025,8 @@ fn plain_line_json(account: AccountId, side: Side, amount: Money) -> Value { } /// One invoice/bill document line as journal JSON. Amounts are decimal -/// strings, never floats. -/// Refuse a document line carrying a field the journal does not write yet -/// (S4.1 through S4.2): S4.3's `documents_v2` journals the line's account, -/// quantity and unit price. Called before `rehearse`, so a refusal leaves -/// the journal and the books exactly as they were, and replay never loses -/// one. S4.3 removes this guard when it journals the fields. -fn check_journaled_document_lines(lines: &[DocumentLine]) -> Result<(), StoreError> { - for line in lines { - if line.account.is_some() { - return Err(StoreError::DocumentFieldsAwaitUpgrade { - field: "an account", - }); - } - if line.quantity.is_some() || line.unit_price.is_some() { - return Err(StoreError::DocumentFieldsAwaitUpgrade { - field: "a quantity and unit price", - }); - } - } - Ok(()) -} - +/// strings, never floats; a new field is written only when set, so a line +/// naming none is byte for byte the line every earlier version wrote. fn document_line_json(l: &DocumentLine) -> Value { let mut v = json!({ "description": l.description, @@ -5656,9 +6037,165 @@ fn document_line_json(l: &DocumentLine) -> Value { if let Some(c) = l.class_id { v["class_id"] = json!(c); } + // Its account, quantity and unit price, behind documents_v2 (plan P4b + // S4.3); a line naming none is written as before it. + if let Some(a) = l.account { + v["account"] = json!(a); + } + if let Some(q) = l.quantity { + v["quantity"] = json!(q.to_string()); + } + if let Some(u) = l.unit_price { + v["unit_price"] = json!(u.to_string()); + } v } +/// The new fields a document write names, for +/// [`Store::ensure_document_fields`]: one of the two record shapes, and the +/// lines (whose account, quantity and unit price also need `documents_v2`). +struct DocumentFields<'a> { + invoice: Option<&'a arap::InvoiceFields>, + bill: Option<&'a arap::BillFields>, + lines: &'a [DocumentLine], +} + +impl DocumentFields<'_> { + /// Whether any field the journal couldn't write before `documents_v2` + /// is some. + fn any_new(&self) -> bool { + let invoice = self.invoice.is_some_and(|f| { + f.number.is_some() + || f.terms.is_some() + || f.bill_to.is_some() + || f.seller.is_some() + || f.customer_ref.is_some() + || f.memo.is_some() + }); + let bill = self.bill.is_some_and(|f| { + f.vendor_ref.is_some() + || f.received_on.is_some() + || f.terms.is_some() + || f.memo.is_some() + || f.duplicate_reason.is_some() + }); + invoice + || bill + || self + .lines + .iter() + .any(|l| l.account.is_some() || l.quantity.is_some() || l.unit_price.is_some()) + } +} + +/// Add an invoice's new fields to its `invoice.created` payload, each key +/// only when set (plan P4b S4.3, feature `documents_v2`). +fn invoice_fields_json(payload: &mut Value, fields: &arap::InvoiceFields) { + if let Some(n) = &fields.number { + payload["number"] = json!(n); + } + if let Some(t) = &fields.terms { + payload["terms"] = json!(t); + } + if let Some(s) = &fields.bill_to { + payload["bill_to"] = json!(s); + } + if let Some(s) = &fields.seller { + payload["seller"] = json!(s); + } + if let Some(r) = &fields.customer_ref { + payload["customer_ref"] = json!(r); + } + if let Some(m) = &fields.memo { + payload["memo"] = json!(m); + } +} + +/// Add a bill's new fields to its `bill.created` payload, each key only when +/// set. A duplicate's reason is journaled for the record (rules.md B-40 item +/// 4); the bill itself keeps none. +fn bill_fields_json(payload: &mut Value, fields: &arap::BillFields) { + if let Some(r) = &fields.vendor_ref { + payload["vendor_ref"] = json!(r); + } + if let Some(on) = fields.received_on { + payload["received_on"] = json!(on.to_string()); + } + if let Some(t) = &fields.terms { + payload["terms"] = json!(t); + } + if let Some(m) = &fields.memo { + payload["memo"] = json!(m); + } + if let Some(r) = &fields.duplicate_reason { + payload["duplicate_reason"] = json!(r); + } +} + +/// The invoice's new fields from an `invoice.created` payload; absent keys +/// are the invoice every earlier version wrote. +fn parse_invoice_fields(p: &Value) -> Result { + Ok(arap::InvoiceFields { + number: opt_str(p, "number")?.map(str::to_string), + terms: parse_terms(p.get("terms"))?, + bill_to: parse_snapshot(p.get("bill_to"))?, + seller: parse_snapshot(p.get("seller"))?, + customer_ref: opt_str(p, "customer_ref")?.map(str::to_string), + memo: opt_str(p, "memo")?.map(str::to_string), + }) +} + +/// The bill's new fields from a `bill.created` payload; absent keys are the +/// bill every earlier version wrote. +fn parse_bill_fields(p: &Value) -> Result { + Ok(arap::BillFields { + vendor_ref: opt_str(p, "vendor_ref")?.map(str::to_string), + received_on: match opt_str(p, "received_on")? { + Some(d) => Some(parse_date(d)?), + None => None, + }, + terms: parse_terms(p.get("terms"))?, + memo: opt_str(p, "memo")?.map(str::to_string), + duplicate_reason: opt_str(p, "duplicate_reason")?.map(str::to_string), + }) +} + +/// A payload's optional value kept as its canonical JSON text (terms, a +/// party snapshot): absent or null is SQL NULL. +fn json_column(p: &Value, key: &str) -> Option { + p.get(key).filter(|v| !v.is_null()).map(|v| v.to_string()) +} + +/// A payload's optional string: absent or null is none, anything but a +/// string is damage. +fn opt_str<'a>(p: &'a Value, key: &str) -> Result, StoreError> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(s)) => Ok(Some(s)), + Some(_) => Err(StoreError::JournalCorrupt(format!("bad {key}"))), + } +} + +/// A payload's optional payment terms, as journaled. +fn parse_terms(v: Option<&Value>) -> Result, StoreError> { + match v { + None | Some(Value::Null) => Ok(None), + Some(v) => serde_json::from_value(v.clone()) + .map(Some) + .map_err(|_| StoreError::JournalCorrupt("bad terms".into())), + } +} + +/// A payload's optional party snapshot, as journaled. +fn parse_snapshot(v: Option<&Value>) -> Result, StoreError> { + match v { + None | Some(Value::Null) => Ok(None), + Some(v) => serde_json::from_value(v.clone()) + .map(Some) + .map_err(|_| StoreError::JournalCorrupt("bad party snapshot".into())), + } +} + /// Parse the `lines` array of an invoice/bill journal payload back into /// document lines. fn parse_document_lines(p: &Value) -> Result, StoreError> { @@ -5685,17 +6222,32 @@ fn parse_document_lines(p: &Value) -> Result, StoreError> { })?, ), }, - // The new line fields are read in S4.3; an older version - // never wrote them, and replay of a document written by - // this one leaves the S4.1 shape. - account: None, - quantity: None, - unit_price: None, + // Absent (every document before documents_v2) or null: + // the line names none, exactly the line it was written as. + account: match l.get("account") { + None | Some(Value::Null) => None, + Some(a) => Some(a.as_u64().ok_or_else(|| { + StoreError::JournalCorrupt("bad document line account".into()) + })?), + }, + quantity: opt_decimal(l, "quantity")?, + unit_price: opt_decimal(l, "unit_price")?, }) }) .collect() } +/// A payload's optional exact amount, journaled as a decimal string. +fn opt_decimal(p: &Value, key: &str) -> Result, StoreError> { + match opt_str(p, key)? { + None => Ok(None), + Some(s) => s + .parse() + .map(Some) + .map_err(|_| StoreError::JournalCorrupt(format!("bad {key}"))), + } +} + fn flip_line_json(l: &ledger_core::JournalLine) -> Value { let side = match l.side { Side::Debit => "credit", @@ -6406,23 +6958,25 @@ fn replay_line( "invoice.created" => { // The entry was already rebuilt by the standard entry handlers; // this only restores the invoice record, without re-posting. - ledger.replay_invoice( + ledger.replay_invoice_with( get_u64(p, "id")?, get_u64(p, "customer_id")?, parse_date(get_str(p, "date")?)?, parse_date(get_str(p, "due_date")?)?, parse_document_lines(p)?, get_u64(p, "entry_id")?, + parse_invoice_fields(p)?, )?; } "bill.created" => { - ledger.replay_bill( + ledger.replay_bill_with( get_u64(p, "id")?, get_u64(p, "vendor_id")?, parse_date(get_str(p, "date")?)?, parse_date(get_str(p, "due_date")?)?, parse_document_lines(p)?, get_u64(p, "entry_id")?, + parse_bill_fields(p)?, )?; } "invoice.payment_received" => { @@ -7362,7 +7916,7 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr StoreError::JournalCorrupt("invoice.created without lines".into()) })?; conn.execute( - "INSERT INTO invoices(id, customer_id, date, due_date, lines_json, entry_id) VALUES (?,?,?,?,?,?)", + "INSERT INTO invoices(id, customer_id, date, due_date, lines_json, entry_id, number, terms, bill_to, seller, customer_ref, memo) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", params![ get_u64(p, "id")? as i64, get_u64(p, "customer_id")? as i64, @@ -7370,6 +7924,12 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr get_str(p, "due_date")?, lines.to_string(), get_u64(p, "entry_id")? as i64, + opt_str(p, "number")?, + json_column(p, "terms"), + json_column(p, "bill_to"), + json_column(p, "seller"), + opt_str(p, "customer_ref")?, + opt_str(p, "memo")?, ], )?; } @@ -7378,7 +7938,7 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr .get("lines") .ok_or_else(|| StoreError::JournalCorrupt("bill.created without lines".into()))?; conn.execute( - "INSERT INTO bills(id, vendor_id, date, due_date, lines_json, entry_id) VALUES (?,?,?,?,?,?)", + "INSERT INTO bills(id, vendor_id, date, due_date, lines_json, entry_id, vendor_ref, received_on, terms, memo, duplicate_reason) VALUES (?,?,?,?,?,?,?,?,?,?,?)", params![ get_u64(p, "id")? as i64, get_u64(p, "vendor_id")? as i64, @@ -7386,6 +7946,11 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr get_str(p, "due_date")?, lines.to_string(), get_u64(p, "entry_id")? as i64, + opt_str(p, "vendor_ref")?, + opt_str(p, "received_on")?, + json_column(p, "terms"), + opt_str(p, "memo")?, + opt_str(p, "duplicate_reason")?, ], )?; conn.execute( diff --git a/crates/ledger-store/tests/arap_standing.rs b/crates/ledger-store/tests/arap_standing.rs index b2b9723..eec3711 100644 --- a/crates/ledger-store/tests/arap_standing.rs +++ b/crates/ledger-store/tests/arap_standing.rs @@ -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(); } diff --git a/crates/ledger-store/tests/fixtures/old-books/p4_s43_documents/expected.json b/crates/ledger-store/tests/fixtures/old-books/p4_s43_documents/expected.json new file mode 100644 index 0000000..693d55c --- /dev/null +++ b/crates/ledger-store/tests/fixtures/old-books/p4_s43_documents/expected.json @@ -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 + } +} diff --git a/crates/ledger-store/tests/fixtures/old-books/p4_s43_documents/journal.jsonl b/crates/ledger-store/tests/fixtures/old-books/p4_s43_documents/journal.jsonl new file mode 100644 index 0000000..a26f34b --- /dev/null +++ b/crates/ledger-store/tests/fixtures/old-books/p4_s43_documents/journal.jsonl @@ -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"} diff --git a/crates/ledger-store/tests/settings.rs b/crates/ledger-store/tests/settings.rs index 973dbef..c21b473 100644 --- a/crates/ledger-store/tests/settings.rs +++ b/crates/ledger-store/tests/settings.rs @@ -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"); diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index 50afafe..412fecc 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -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 diff --git a/docs/receipt-store.md b/docs/receipt-store.md index e80c453..bde7404 100644 --- a/docs/receipt-store.md +++ b/docs/receipt-store.md @@ -56,12 +56,25 @@ apply is safe — on startup the applier replays every journal line with - `seq`: 1-based, gapless, assigned by the writer. - `ts`: UTC, RFC 3339. Recorded, never trusted for ordering (`seq` orders). -- `type`: `account.added`, `entry.posted`, `entry.reversed`, - `receipt.attached`, `receipt.linked`, `receipt.unlinked`, +- `type`: `account.added`, `entry.posted`, `entry.reversed`, `invoice.created`, + `bill.created`, `receipt.attached`, `receipt.linked`, `receipt.unlinked`, `document.filed`, `attachment.withdrawn` (plus the A/R, A/P, asset, period, payroll, and class events listed on `JournalLine`). - `payload`: the full domain facts (account fields, entry with lines, blob sha, ...). Everything the applier needs; nothing it must look up elsewhere. + An `invoice.created` or `bill.created` written after the `documents_v2` + upgrade (plan P4b S4.3) carries only the new fields it names, so a + document naming none is byte for byte the payload every earlier version + wrote: the invoice's `number`, `terms`, `bill_to` and `seller` snapshots + (as they were at issue), `customer_ref` and `memo`; the bill's + `vendor_ref`, `received_on`, `terms`, `memo` and the `duplicate_reason` + that allowed a duplicate of a standing vendor invoice; and each line's + `account`, `quantity` and `unit_price`. The issued copy of an invoice is + **not** a field on the event (its attachment id is allocated after the + entry exists): it is a `receipt.attached` on the invoice's own entry + (`INV-0007.pdf`, `application/pdf`, "Issued invoice"), its SHA-256 the + `blob_sha`, linked to the invoice with `attachment.linked` and found + through that link (plan 8.3.11). - `prev`: hex SHA-256 of the previous line's canonical JSON (keys sorted, no whitespace). The genesis line uses `"prev": "000...0"` (64 zeros). @@ -149,6 +162,14 @@ Suspected corruption is fixed by rebuild, never by forensics. tails. (Poll `WHERE seq > ?`; later SSE over the HTTP transport. This is the correct answer to "watch its files to update live" — watch the event stream, not the files.) +- **Document fields.** Since schema v16 (plan P4b S4.3) the documents' + new fields are columns: `invoices(number, terms, bill_to, seller, + customer_ref, memo)` and `bills(vendor_ref, received_on, terms, memo, + duplicate_reason)`, with terms and snapshots as canonical JSON TEXT and a + document written before them NULL there. A line's `account`, `quantity` + and `unit_price` ride its document's `lines_json`, as its project does. + The party's email and default terms are journaled and replayed into the + ledger; the projection keeps no party table to hold them. - **Amounts as TEXT, never REAL.** Unchanged and still load-bearing: a REAL column would silently reintroduce binary floats into the money path. - **Append-only, three layers.** (1) The engine's API never mutates posted diff --git a/docs/spec/rules.md b/docs/spec/rules.md index 827695a..161e093 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -1737,7 +1737,18 @@ version would drop silently: B-37). After P3: `document_line_projects` project, B-37 item 8; an older version would restore the document without it, silently). It is a feature of its own, not part of `projects`: books that declared `projects` before it existed wait for their owner's next -upgrade before a document's line names a project. A command asks for +upgrade before a document's line names a project. `documents_v2` (plan +P4b S4, one feature for S4 to S7: D-52) is the next: a `settings.set` of +the `invoicing.*`, `bills.*`, `company.email` or `company.website` keys, +which an earlier version refuses (S4.2); a `party.updated` with an email +or a default terms fact (S4.2); an `invoice.created` or `bill.created` +carrying a number, terms, a bill-to or seller snapshot, a customer +reference or memo, a vendor reference, a receipt date or a duplicate's +reason, or a line naming its account, quantity or unit price (S4.3); and +S5 to S7's sends, settlements and write-offs. An older version would +restore the document without them silently: every invoice line would post +to the revenue role and every bill line to general expenses, and every new +event type would be refused. A command asks for the feature it needs (`Store::ensure_feature`) before its first journaled line, and only when the line it is about to write uses it, so a write refused for waiting on the upgrade journals nothing, never an entry