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

+51 -4
View File
@@ -711,18 +711,65 @@ fn last_day_of_month(date: NaiveDate) -> Result<NaiveDate, LedgerError> {
}
/// The parties on a document, as they were when it was issued (plan P4b
/// S4.1, A-13): a name, and the address and email that were on the
/// document. Never today's party record when a snapshot exists.
/// S4.1, A-13; the contact block, D-55): a name, and the address and email
/// that were on the document, with the phone, contact person, billing and
/// shipping details when the party had them. Never today's party record
/// when a snapshot exists.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PartySnapshot {
/// The party's name at issue.
pub name: String,
/// Its address at issue, when the document printed one.
/// Its address at issue, as the document printed it: the billing
/// address when the party has one, else its general one (D-55).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub address: Option<String>,
/// Its email at issue, when the document printed one.
/// Its email at issue, as the document printed it: the billing email
/// when the party has one, else its general one (D-55).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
/// Its telephone number at issue (D-55).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub phone: Option<String>,
/// The person there to write to, at issue (D-55).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub contact: Option<String>,
/// Where its bills go, when it said so (D-55). [`Self::address`] is
/// this one whenever it is set.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub billing_address: Option<String>,
/// Where goods go, when it said so (D-55): the issued page prints a
/// Ship-to line for it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shipping_address: Option<String>,
/// Where its bills are emailed, when it said so (D-55). [`Self::email`]
/// is this one whenever it is set.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub billing_email: Option<String>,
}
impl PartySnapshot {
/// The snapshot of `party` at issue (A-13, D-55): its name, and the
/// contact details as they stood, with the billing address and billing
/// email in place of the general ones when it has them. The one place
/// the bill-to rule lives, so every document agrees.
pub fn from_party(party: &crate::Party) -> Self {
PartySnapshot {
name: party
.display_name
.clone()
.unwrap_or_else(|| party.name.clone()),
address: party
.billing_address
.clone()
.or_else(|| party.address.clone()),
email: party.billing_email.clone().or_else(|| party.email.clone()),
phone: party.phone.clone(),
contact: party.contact.clone(),
billing_address: party.billing_address.clone(),
shipping_address: party.shipping_address.clone(),
billing_email: party.billing_email.clone(),
}
}
}
/// How an invoice was sent (plan P4b S5; the type S4.1 adds so
+27
View File
@@ -4453,6 +4453,11 @@ impl Ledger {
email: None,
default_terms_as_customer: None,
default_terms_as_vendor: None,
phone: None,
contact: None,
billing_address: None,
shipping_address: None,
billing_email: None,
address: None,
notes: None,
archived: false,
@@ -4582,6 +4587,11 @@ impl Ledger {
email: None,
default_terms_as_customer: None,
default_terms_as_vendor: None,
phone: None,
contact: None,
billing_address: None,
shipping_address: None,
billing_email: None,
address: None,
notes: None,
archived: false,
@@ -4656,6 +4666,11 @@ impl Ledger {
Email(v) => p.email = v,
DefaultTermsAsCustomer(v) => p.default_terms_as_customer = v,
DefaultTermsAsVendor(v) => p.default_terms_as_vendor = v,
Phone(v) => p.phone = v,
Contact(v) => p.contact = v,
BillingAddress(v) => p.billing_address = v,
ShippingAddress(v) => p.shipping_address = v,
BillingEmail(v) => p.billing_email = v,
}
Ok(())
}
@@ -4707,6 +4722,18 @@ impl Ledger {
crate::parties::check_email("email", e).map_err(LedgerError::InvalidParty)?;
}
}
// The D-55 contact block: two addresses and a phone like any
// other text (no full TIN), the billing email an address.
Phone(v) => text("phone", v)?,
Contact(v) => text("contact", v)?,
BillingAddress(v) => text("billing_address", v)?,
ShippingAddress(v) => text("shipping_address", v)?,
BillingEmail(v) => {
if let Some(e) = v {
crate::parties::check_email("billing_email", e)
.map_err(LedgerError::InvalidParty)?;
}
}
// Terms are typed and already canonical (`crate::Terms`), so
// there is nothing here a string could get wrong.
DefaultTermsAsCustomer(_) | DefaultTermsAsVendor(_) => {}
+41 -3
View File
@@ -234,6 +234,24 @@ pub struct Party {
/// an invoice's bill-to snapshot at issue.
#[serde(default)]
pub email: Option<String>,
/// Telephone number (D-55): printed on the invoice's bill-to.
#[serde(default)]
pub phone: Option<String>,
/// The named person there to write to (D-55).
#[serde(default)]
pub contact: Option<String>,
/// Where its bills go (D-55): the invoice's bill-to address when set,
/// else [`Self::address`].
#[serde(default)]
pub billing_address: Option<String>,
/// Where goods go (D-55): the issued invoice prints a Ship-to line
/// when it is set.
#[serde(default)]
pub shipping_address: Option<String>,
/// Where its bills are emailed (D-55): the bill-to email when set,
/// else [`Self::email`].
#[serde(default)]
pub billing_email: Option<String>,
/// The terms this party grants the company as a customer: an invoice's
/// due date falls back to these (A-13).
#[serde(default)]
@@ -778,11 +796,21 @@ pub enum PartyField {
DefaultTermsAsCustomer(Option<crate::Terms>),
/// The terms it gives the company as a vendor (A-13; `None` clears).
DefaultTermsAsVendor(Option<crate::Terms>),
/// The telephone number (D-55; `None` clears it).
Phone(Option<String>),
/// The named person there to write to (D-55; `None` clears it).
Contact(Option<String>),
/// Where its bills go (D-55; `None` clears it).
BillingAddress(Option<String>),
/// Where goods go (D-55; `None` clears it).
ShippingAddress(Option<String>),
/// Where its bills are emailed (D-55; `None` clears it).
BillingEmail(Option<String>),
}
impl PartyField {
/// Every field's wire name, as `party.updated` journals it.
pub const FIELDS: [&'static str; 9] = [
pub const FIELDS: [&'static str; 14] = [
"name",
"display_name",
"website",
@@ -792,6 +820,11 @@ impl PartyField {
"email",
"default_terms_as_customer",
"default_terms_as_vendor",
"phone",
"contact",
"billing_address",
"shipping_address",
"billing_email",
];
/// Whether `field` is a field this version knows. A `party.updated`
@@ -800,14 +833,19 @@ impl PartyField {
Self::FIELDS.contains(&field)
}
/// Whether the field needs the `documents_v2` feature: the three A-13
/// facts an older version would drop.
/// Whether the field needs the `documents_v2` feature: the A-13 facts
/// and the D-55 contact block, which an older version would drop.
pub fn needs_documents_v2(&self) -> bool {
matches!(
self,
PartyField::Email(_)
| PartyField::DefaultTermsAsCustomer(_)
| PartyField::DefaultTermsAsVendor(_)
| PartyField::Phone(_)
| PartyField::Contact(_)
| PartyField::BillingAddress(_)
| PartyField::ShippingAddress(_)
| PartyField::BillingEmail(_)
)
}
}
+5
View File
@@ -127,6 +127,11 @@ fn bare_party(name: &str) -> Party {
email: None,
default_terms_as_customer: None,
default_terms_as_vendor: None,
phone: None,
contact: None,
billing_address: None,
shipping_address: None,
billing_email: None,
address: None,
notes: None,
archived: false,