feat(store): journal the document fields and file the issued PDF (P4b S4.3)
S4.1 gave documents a number, terms, line accounts and snapshots, and S4.2
gave parties an email and default terms, but the store journaled none of
them: a document carrying one was refused by a temporary guard, because a
public write that replay loses would break I-3. This slice makes the
journal carry them.
- `invoice.created` and `bill.created` write a number, terms, bill-to and
seller snapshots, references, memo and a duplicate's reason, and each
line its account, quantity and unit price, every key only when set. A
document naming none is journaled byte for byte as every earlier version
wrote it (`no_new_fields_journals_byte_for_byte`), so the owner's live
books keep working.
- All of it is behind `documents_v2` (B-27): a write that names one waits
for the owner's upgrade, refused with `format_upgrade_pending` and
journaling nothing; a write that names none never waits
(`documents_v2_waits_for_the_upgrade`).
- `invoicing.first_number` is settable only while no numbered invoice
exists (A-2, B-40): the series has begun, and a number is never reused or
renumbered. Refused at `set_setting`, before anything is journaled.
- The issued copy is filed in the issuing call (A-7, B-40 item 3):
`create_invoice_issued` journals the entry, the invoice, then
`receipt.attached` on that entry (`INV-0007.pdf`, `application/pdf`,
"Issued invoice") and `attachment.linked` to the invoice. There is no
`issued_pdf` field: the attachment id is allocated after the entry exists
(CD-A6), and the link is how the copy is found and how its SHA-256 is
checked. Every refusal happens before the first journal line; the PDF's
bytes are stored before it, so a refused issue leaves an unreferenced,
deduplicated blob at worst.
- A preview runs the issue on a clone and journals nothing: the posting,
the total, the number it would take and the due date (`preview_invoice`,
`preview_bill`). Books that haven't upgraded keep the legacy shape: the
copy is filed as "Invoice {id}.pdf" and no number is taken (migration
item 5).
- Schema 16 adds the document columns; a line's new fields ride
`lines_json` as its project does. `differs_from` already compares whole
invoices, bills and parties, so it sees every new field.
8.0.6: (1) `books_written_under_older_rules_still_open` hand-writes both
new document shapes through JournalWriter and still opens; (2) a new
fixture `old-books/p4_s43_documents` was written by main d5385a7e (S4.2,
the version before this slice) through the old-books-writer harness, with
its expected.json; this version opens it and reports the same figures;
(3) see the note on R-A step 4 in the PR (the over-the-wire document write
needs S4.4's arguments; S4.2's invoicing-setting entry already covers the
feature's cut-over); (4) `documents_v2_is_its_own_feature` is green; (5) no
figure moves: no golden changed; (6) merge does not need the owner's
go-ahead; the live books refuse the new writes until he gives it.
just check green (no kernel function, Lean theorem or Ledger command, so no
`just prove` change).
This commit is contained in:
1 parent
6be0055927
commit
0e5aebda99
13 files changed
+1721
-224
No files matched your search
@@ -6628,6 +6628,32 @@ impl Ledger {
|
||||
due_date: NaiveDate,
|
||||
lines: Vec<DocumentLine>,
|
||||
entry_id: EntryId,
|
||||
) -> Result<(), LedgerError> {
|
||||
self.replay_invoice_with(
|
||||
id,
|
||||
customer_id,
|
||||
date,
|
||||
due_date,
|
||||
lines,
|
||||
entry_id,
|
||||
arap::InvoiceFields::default(),
|
||||
)
|
||||
}
|
||||
|
||||
/// [`Self::replay_invoice`] with the invoice's new fields (number,
|
||||
/// terms, snapshots, references: plan P4b S4.3, feature
|
||||
/// `documents_v2`). All `None` is exactly [`Self::replay_invoice`].
|
||||
#[doc(hidden)]
|
||||
#[allow(clippy::too_many_arguments)] // one argument per journaled field
|
||||
pub fn replay_invoice_with(
|
||||
&mut self,
|
||||
id: InvoiceId,
|
||||
customer_id: CustomerId,
|
||||
date: NaiveDate,
|
||||
due_date: NaiveDate,
|
||||
lines: Vec<DocumentLine>,
|
||||
entry_id: EntryId,
|
||||
fields: arap::InvoiceFields,
|
||||
) -> Result<(), LedgerError> {
|
||||
if id != self.next_invoice_id {
|
||||
return Err(LedgerError::ReplayIdMismatch(id, self.next_invoice_id));
|
||||
@@ -6648,12 +6674,12 @@ impl Ledger {
|
||||
owed: Decimal::ZERO,
|
||||
status: DocumentStatus::Open,
|
||||
entry_id,
|
||||
number: None,
|
||||
terms: None,
|
||||
bill_to: None,
|
||||
seller: None,
|
||||
customer_ref: None,
|
||||
memo: None,
|
||||
number: fields.number,
|
||||
terms: fields.terms,
|
||||
bill_to: fields.bill_to,
|
||||
seller: fields.seller,
|
||||
customer_ref: fields.customer_ref,
|
||||
memo: fields.memo,
|
||||
sends: Vec::new(),
|
||||
},
|
||||
);
|
||||
@@ -6683,6 +6709,32 @@ impl Ledger {
|
||||
due_date: NaiveDate,
|
||||
lines: Vec<DocumentLine>,
|
||||
entry_id: EntryId,
|
||||
) -> Result<(), LedgerError> {
|
||||
self.replay_bill_with(
|
||||
id,
|
||||
vendor_id,
|
||||
date,
|
||||
due_date,
|
||||
lines,
|
||||
entry_id,
|
||||
arap::BillFields::default(),
|
||||
)
|
||||
}
|
||||
|
||||
/// [`Self::replay_bill`] with the bill's new fields (the vendor's
|
||||
/// invoice number, its receipt date, terms, memo: plan P4b S4.3,
|
||||
/// feature `documents_v2`). All `None` is exactly [`Self::replay_bill`].
|
||||
#[doc(hidden)]
|
||||
#[allow(clippy::too_many_arguments)] // one argument per journaled field
|
||||
pub fn replay_bill_with(
|
||||
&mut self,
|
||||
id: BillId,
|
||||
vendor_id: VendorId,
|
||||
date: NaiveDate,
|
||||
due_date: NaiveDate,
|
||||
lines: Vec<DocumentLine>,
|
||||
entry_id: EntryId,
|
||||
fields: arap::BillFields,
|
||||
) -> Result<(), LedgerError> {
|
||||
if id != self.next_bill_id {
|
||||
return Err(LedgerError::ReplayIdMismatch(id, self.next_bill_id));
|
||||
@@ -6703,10 +6755,10 @@ impl Ledger {
|
||||
owed: Decimal::ZERO,
|
||||
status: DocumentStatus::Open,
|
||||
entry_id,
|
||||
vendor_ref: None,
|
||||
received_on: None,
|
||||
terms: None,
|
||||
memo: None,
|
||||
vendor_ref: fields.vendor_ref,
|
||||
received_on: fields.received_on,
|
||||
terms: fields.terms,
|
||||
memo: fields.memo,
|
||||
},
|
||||
);
|
||||
self.tag_vendor(entry_id, vendor_id);
|
||||
|
||||
@@ -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.
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
"#,
|
||||
];
|
||||
+689
-124
@@ -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<ClassId>)>,
|
||||
/// The document's total.
|
||||
pub total: Decimal,
|
||||
/// The number it would take (A-2), when the books number their
|
||||
/// invoices.
|
||||
pub number: Option<String>,
|
||||
/// 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<ClassId>)>,
|
||||
/// 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<CustomerId, StoreError> {
|
||||
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<DocumentLine>,
|
||||
) -> Result<InvoiceId, StoreError> {
|
||||
// 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<Value> = 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<DocumentLine>,
|
||||
fields: arap::InvoiceFields,
|
||||
) -> Result<InvoiceId, StoreError> {
|
||||
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<JournalLine>), 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<Value> = 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<DocumentLine>,
|
||||
fields: arap::InvoiceFields,
|
||||
pdf_bytes: Vec<u8>,
|
||||
) -> 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<DocumentLine>,
|
||||
fields: arap::InvoiceFields,
|
||||
) -> Result<InvoicePreview, StoreError> {
|
||||
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<DocumentLine>,
|
||||
) -> Result<BillId, StoreError> {
|
||||
// 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<Value> = 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<DocumentLine>,
|
||||
fields: arap::BillFields,
|
||||
) -> Result<BillId, StoreError> {
|
||||
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<JournalLine>), 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<Value> = 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<DocumentLine>,
|
||||
fields: arap::BillFields,
|
||||
) -> Result<BillPreview, StoreError> {
|
||||
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<arap::InvoiceFields, StoreError> {
|
||||
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<arap::BillFields, StoreError> {
|
||||
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<String> {
|
||||
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<Option<&'a str>, 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<Option<arap::Terms>, 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<Option<arap::PartySnapshot>, 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<Vec<DocumentLine>, StoreError> {
|
||||
@@ -5685,17 +6222,32 @@ fn parse_document_lines(p: &Value) -> Result<Vec<DocumentLine>, 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<Option<Decimal>, 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(
|
||||
|
||||
@@ -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"}
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user