feat(mcp): the create tools preview and confirm, and the party keeps its contact block (P4b S4.4)

An invoice is issued, not edited. Without `confirmed` the create tools now
preview: the posting (with the accounts' codes, which the live chart may
not hold yet), the number it will take, its due date, terms and early
discount, warnings, and for an invoice the DRAFT copy of the PDF, handed
over as a file. Nothing is written and no number is used up. With
`confirmed` the invoice posts, takes the number the preview showed, and
files the issued copy with itself in the same call: `receipt.attached` on
its entry plus the link to the invoice, its SHA-256 in the event (A-1,
A-7, B-40). The copy the customer received is therefore the copy in the
books, and `books.document`'s re-created page can never be mistaken for
it. The result also carries a `mailto` link for a human to send it; the
books never send (A-4).

A bill keeps what the plan says a bill is: the vendor's own invoice
number, the vendor's invoice filed on its entry as its evidence, and, in
books that have declared `documents_v2`, an account on every line, so no
bill can land in General Expenses by default any more (A-9, A-10, A-13).
A vendor number entered twice is refused unless the caller says why, and
the reason is journaled with the second bill (B-40 item 4). Books that
have not declared the feature keep recording bills exactly as before, so
the owner's books keep working between units (D-39, 8.0.4).

Terms are resolved in one place and in one order: the due date given,
else the terms given, else the customer's or vendor's own default, else
the books' `invoicing.default_terms`; a document given only a due date
stores no terms, so an old-shape invoice is still written byte for byte
as before (B-39, A-13).

D-55: a party carries the owner's full contact block (phone, contact
person, billing and shipping addresses, billing email). It rides the same
`party.updated` event behind `documents_v2`, and an invoice's bill-to is
snapshotted at issue with the billing address and billing email in place
of the general ones when the party has them — `PartySnapshot::from_party`
is the one place that rule lives, so a later edit cannot change what was
sent, and the next invoice takes today's details.

Tests: the wire tests are the new `crates/ledger-cli/tests/documents.rs`
(preview writes nothing, the DRAFT copy, the issued copy's SHA-256 equals
`books.get_receipt`'s, numbers run, a bill's file and terms, the duplicate
refusal, the line-account rules, the terms fallback, the `party_id` alias
and the ambiguity it refuses, the contact block's set and clear, and the
bill-to at issue); `format_upgrade.rs` gains
`documents_v2_refused_until_consent` and the cut-over test now refuses and
then makes a document carrying a new field (8.0.6 item 3); the store
tests are `the_contact_block_round_trips_and_replays` and
`an_invoice_keeps_the_billing_details_it_was_issued_with`, and
`books_written_under_older_rules_still_open` gains a hand-written
contact-block event (8.0.6 item 1).

Merge does not need the owner's go-ahead: the live books refuse a write
that needs `documents_v2` until he gives it, and take every other write as
before (8.0.6 item 6). No figure moved: S4 moves none.
This commit is contained in:
swaits committed 2026-10-27 12:23:32 -06:00
1 parent ed637f0e27
commit d5b9e5cf99
28 files changed
+2604 -190

No files matched your search

+2 -2
View File
@@ -48,7 +48,7 @@ pub use journal::{GENESIS_PREV, JournalLine, JournalWriter};
pub use snapshot::{DiskCheck, Snapshot};
pub use store::{
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,
JOURNAL_FILE, LEGACY_DB_FILE, LOCK_FILE, MAX_RECURRING_RUN, PayrollLineInput, PreviewPosting,
Reconciliation, Statement, Store, Subject, VerifyReport, database_in_use, db_file, db_file_for,
is_database_file,
};
+58
View File
@@ -322,6 +322,24 @@ pub struct Attachment {
/// write-path signature reads as input rather than stored state.
pub type PayrollLineInput = PayrollLine;
/// One line of a previewed posting, the way the page shows it: the
/// account's code and name (resolved in the probe, so an account the
/// preview would create is named even though the live books don't have it
/// yet), its side, amount and project (plan P4b S4.4).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PreviewPosting {
/// The account's code ("1200").
pub account: String,
/// Its name ("Accounts Receivable").
pub name: String,
/// Debit or credit.
pub side: Side,
/// The amount.
pub amount: Decimal,
/// The project it is tagged with, if any.
pub class_id: Option<ClassId>,
}
/// 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.
@@ -329,6 +347,10 @@ pub type PayrollLineInput = PayrollLine;
pub struct InvoicePreview {
/// The entry it would post, line by line.
pub posting: Vec<(AccountId, Side, Decimal, Option<ClassId>)>,
/// The same lines with their accounts' codes and names (plan P4b
/// S4.4: the preview page shows them, and an account the issue would
/// create has no id the live books know yet).
pub lines: Vec<PreviewPosting>,
/// The document's total.
pub total: Decimal,
/// The number it would take (A-2), when the books number their
@@ -336,6 +358,10 @@ pub struct InvoicePreview {
pub number: Option<String>,
/// Its due date.
pub due_date: NaiveDate,
/// The invoice the probe wrote: its lines, terms, snapshots and
/// dates, exactly as the issue would store them. What the DRAFT copy
/// prints (plan P4b S4.4).
pub invoice: arap::Invoice,
}
/// What recording a bill would post, journaling nothing
@@ -344,6 +370,9 @@ pub struct InvoicePreview {
pub struct BillPreview {
/// The entry it would post, line by line.
pub posting: Vec<(AccountId, Side, Decimal, Option<ClassId>)>,
/// The same lines with their accounts' codes and names
/// ([`InvoicePreview::lines`]).
pub lines: Vec<PreviewPosting>,
/// The document's total.
pub total: Decimal,
/// Its due date.
@@ -354,6 +383,32 @@ pub struct BillPreview {
/// refused, so a far-off date can't make one request write without end.
pub const MAX_RECURRING_RUN: usize = 1000;
/// A probe entry's lines as a preview shows them: each account's code and
/// name, read from the probe (plan P4b S4.4). The probe always holds the
/// accounts its posting names, so a missing one is a bug, reported as an
/// error rather than a panic.
fn preview_lines(
probe: &Ledger,
lines: &[ledger_core::journal::JournalLine],
) -> Result<Vec<PreviewPosting>, StoreError> {
lines
.iter()
.map(|l| {
let account = probe
.chart()
.get(l.account)
.ok_or(LedgerError::UnknownAccount(l.account))?;
Ok(PreviewPosting {
account: account.code.clone(),
name: account.name.clone(),
side: l.side,
amount: l.amount.amount,
class_id: l.class_id,
})
})
.collect()
}
/// The store: one writer, one data directory.
///
/// `Store` is `Send` but not `Sync` (it owns a `rusqlite::Connection`).
@@ -3317,9 +3372,11 @@ impl Store {
.iter()
.map(|l| (l.account, l.side, l.amount.amount, l.class_id))
.collect(),
lines: preview_lines(&probe, entry.lines())?,
total: inv.total()?,
number: fields.number,
due_date: inv.due_date,
invoice: inv.clone(),
})
}
@@ -3493,6 +3550,7 @@ impl Store {
.iter()
.map(|l| (l.account, l.side, l.amount.amount, l.class_id))
.collect(),
lines: preview_lines(&probe, entry.lines())?,
total: bill.total()?,
due_date: bill.due_date,
})