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.
348 lines
13 KiB
Rust
348 lines
13 KiB
Rust
//! A project's snapshot (rules.md B-37 item 7) in the viewer and from the
|
|
//! books server, on the same books: `books.project`'s strings are the
|
|
//! figures the viewer's page draws (its `PageFigures` and `Snap`), to the
|
|
//! cent, to date, by account, by month with the running net, and for the
|
|
//! fiscal year. Both read ledger-core's `project_figures`; this checks
|
|
//! that neither side rearranges them.
|
|
#![cfg(feature = "gui")]
|
|
|
|
use chrono::NaiveDate;
|
|
use ledger_core::{AccountType, Currency, Money, ProjectFacts, ProjectKind, Side};
|
|
use ledger_gui::pages::projects::{Snap, page_figures};
|
|
use ledger_gui::{Source, data};
|
|
use rust_decimal::Decimal;
|
|
use serde_json::{Value, json};
|
|
|
|
mod common;
|
|
|
|
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
|
|
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
|
}
|
|
|
|
fn usd(c: i64) -> Money {
|
|
Money::new(Decimal::new(c, 2), Currency::USD)
|
|
}
|
|
|
|
/// A decimal as the tools print an amount: two places.
|
|
fn s(v: Decimal) -> String {
|
|
format!("{v:.2}")
|
|
}
|
|
|
|
/// The settings both tests' books keep.
|
|
const SETTINGS: &str = "[company]\nname = \"Fernhill Labs\"\nfiscal_year_start = \"01-01\"\n\
|
|
[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n";
|
|
|
|
/// A session with the books server (common.rs), one request at a time.
|
|
struct Server {
|
|
wire: common::Wire,
|
|
}
|
|
|
|
impl Server {
|
|
/// Start `place`'s server on the books the test wrote in its root,
|
|
/// with [`SETTINGS`], and a session with them.
|
|
fn start(place: &common::Place) -> Self {
|
|
let mut wire = place.start(Some(SETTINGS));
|
|
wire.initialize("projects-parity");
|
|
Server { wire }
|
|
}
|
|
|
|
/// A tool's structured result.
|
|
fn tool(&mut self, name: &str, args: Value) -> Value {
|
|
let r = self.wire.call(name, args);
|
|
let out = &r["result"];
|
|
assert_ne!(out["isError"], true, "{name}: {r}");
|
|
out["structuredContent"].clone()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_projects_snapshot_is_the_same_in_the_viewer_and_from_the_server() {
|
|
// The books written in a data root by the store itself, then served
|
|
// there.
|
|
let place = common::Place::new("projects-parity");
|
|
let data = place.books_data();
|
|
let today = d(2026, 10, 15);
|
|
let id = {
|
|
let mut st = ledger_store::Store::create(&data, Currency::USD).unwrap();
|
|
for (code, name, ty) in [
|
|
("1000", "Cash", AccountType::Asset),
|
|
("4000", "Design Services", AccountType::Revenue),
|
|
("6000", "Contractors", AccountType::Expense),
|
|
("6100", "Software", AccountType::Expense),
|
|
("6200", "Rent", AccountType::Expense),
|
|
] {
|
|
st.add_account(code, name, ty, None, false).unwrap();
|
|
}
|
|
let id = st
|
|
.add_project(
|
|
"Kestrel",
|
|
&ProjectFacts {
|
|
kind: Some(ProjectKind::OnSpec),
|
|
started_on: Some(d(2025, 11, 1)),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.unwrap();
|
|
let code =
|
|
|st: &ledger_store::Store, c: &str| st.ledger().chart().find_by_code(c).unwrap().id;
|
|
let mut posted = Vec::new();
|
|
// (date, debit, credit, cents, tagged): last year's sale, this
|
|
// year's costs (one reversed), a refund, and rent (overhead).
|
|
for (date, dr, cr, c, tagged) in [
|
|
(d(2025, 11, 3), "1000", "4000", 100_000, true),
|
|
(d(2026, 2, 9), "6000", "1000", 40_000, true),
|
|
(d(2026, 3, 9), "6100", "1000", 12_345, true),
|
|
(d(2026, 5, 1), "4000", "1000", 10_000, true),
|
|
(d(2026, 6, 30), "6200", "1000", 150_000, false),
|
|
(d(2026, 8, 4), "6000", "1000", 22_222, true),
|
|
] {
|
|
let e = st.create_entry(date, "Line").unwrap();
|
|
let (a, b) = (code(&st, dr), code(&st, cr));
|
|
st.add_line(e, a, Side::Debit, usd(c)).unwrap();
|
|
st.add_line(e, b, Side::Credit, usd(c)).unwrap();
|
|
if tagged {
|
|
st.set_line_class(e, if dr == "1000" { 1 } else { 0 }, Some(id))
|
|
.unwrap();
|
|
}
|
|
st.post_entry(e).unwrap();
|
|
posted.push(e);
|
|
}
|
|
// The software, reversed in September.
|
|
st.reverse_entry(posted[2], d(2026, 9, 2), "Reverse software")
|
|
.unwrap();
|
|
id
|
|
};
|
|
let mut server = Server::start(&place);
|
|
let b = data::load_once(&Source {
|
|
data_dir: data.clone(),
|
|
today: Some(today),
|
|
})
|
|
.unwrap();
|
|
|
|
// To date, and by month over the viewer's chart span.
|
|
let page = page_figures(&b, id, Some(d(2025, 11, 1)), false);
|
|
let t = page.to_date.as_ref().unwrap();
|
|
let snap = Snap::to_date(t);
|
|
let ((start, end), months) = page.months.as_ref().unwrap();
|
|
assert_eq!((*start, *end), (d(2025, 11, 1), today));
|
|
let p = server.tool(
|
|
"books.project",
|
|
json!({"project": "Kestrel", "start": start.to_string(), "end": end.to_string()}),
|
|
);
|
|
let to = &p["to_date"];
|
|
assert_eq!(to["revenue"], s(snap.revenue));
|
|
assert_eq!(to["expenses"], s(snap.expenses));
|
|
assert_eq!(to["net"], s(snap.contribution));
|
|
assert_eq!(
|
|
to["margin_pct"],
|
|
snap.margin_pct
|
|
.map_or(Value::Null, |m| json!(m.to_string()))
|
|
);
|
|
assert_eq!(
|
|
to["unrecovered_cost"],
|
|
t.unrecovered_cost.map_or(Value::Null, |u| json!(s(u)))
|
|
);
|
|
// The figures themselves, so a slip on both sides can't pass: $900
|
|
// earned net of the refund, $622.22 of contractors (the software
|
|
// reversed), $277.78, 30.9% of revenue.
|
|
assert_eq!(
|
|
(to["net"].as_str(), to["margin_pct"].as_str()),
|
|
(Some("277.78"), Some("30.9"))
|
|
);
|
|
let accounts: Vec<(String, String, String)> = t
|
|
.revenue_by_account
|
|
.iter()
|
|
.map(|l| (l.code.clone(), "revenue".to_string(), s(l.amount)))
|
|
.chain(
|
|
t.expenses_by_account
|
|
.iter()
|
|
.map(|l| (l.code.clone(), "expense".to_string(), s(l.amount))),
|
|
)
|
|
.collect();
|
|
let served: Vec<(String, String, String)> = to["by_account"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|a| {
|
|
(
|
|
a["code"].as_str().unwrap().to_string(),
|
|
a["type"].as_str().unwrap().to_string(),
|
|
a["amount"].as_str().unwrap().to_string(),
|
|
)
|
|
})
|
|
.collect();
|
|
assert_eq!(served, accounts);
|
|
let by_month = p["period"]["by_month"].as_array().unwrap();
|
|
assert_eq!(by_month.len(), months.months.len());
|
|
for ((m, ours), net) in by_month
|
|
.iter()
|
|
.zip(&months.months)
|
|
.zip(&months.months_net_to_date)
|
|
{
|
|
assert_eq!(m["month"], ours.start.format("%Y-%m").to_string());
|
|
assert_eq!(m["revenue"], s(ours.revenue), "{m}");
|
|
assert_eq!(m["expenses"], s(ours.expenses), "{m}");
|
|
assert_eq!(m["net_to_date"], s(*net), "{m}");
|
|
}
|
|
assert_eq!(by_month.last().unwrap()["net_to_date"], to["net"]);
|
|
|
|
// This fiscal year, the snapshot's other view: books.project's
|
|
// default period.
|
|
let page = page_figures(&b, id, Some(d(2025, 11, 1)), true);
|
|
let y = page.year.as_ref().unwrap();
|
|
let fy = Snap::period(y);
|
|
let (fs, fe) = b.periods().statement(None, None).unwrap();
|
|
let p = server.tool(
|
|
"books.project",
|
|
json!({"project": "Kestrel", "start": fs.to_string(), "end": fe.to_string()}),
|
|
);
|
|
let period = &p["period"];
|
|
assert_eq!(period["start"], fs.to_string());
|
|
assert_eq!(period["revenue"], s(fy.revenue));
|
|
assert_eq!(period["expenses"], s(fy.expenses));
|
|
assert_eq!(period["net"], s(fy.contribution));
|
|
assert_eq!(
|
|
period["margin_pct"],
|
|
fy.margin_pct.map_or(Value::Null, |m| json!(m.to_string()))
|
|
);
|
|
assert_eq!(period["net"], "-722.22", "a refund and costs, no sale");
|
|
}
|
|
|
|
/// rules.md B-37 item 8: a project's invoiced revenue and billed costs,
|
|
/// made through the books server's own tools, read the same in the viewer
|
|
/// (its snapshot, and the invoice's and bill's pages: each line's
|
|
/// project) and from the server (books.project, books.get_entry).
|
|
#[test]
|
|
fn invoiced_and_billed_project_figures_are_the_same_in_the_viewer_and_from_the_server() {
|
|
let place = common::Place::new("projects-parity-docs");
|
|
let data = place.books_data();
|
|
let today = d(2026, 10, 15);
|
|
{
|
|
let mut st = ledger_store::Store::create(&data, Currency::USD).unwrap();
|
|
st.add_account("1000", "Cash", AccountType::Asset, None, false)
|
|
.unwrap();
|
|
}
|
|
let mut server = Server::start(&place);
|
|
server.tool(
|
|
"books.add_project",
|
|
json!({"name": "Kestrel", "kind": "client", "started_on": "2026-01-05"}),
|
|
);
|
|
server.tool("books.add_project", json!({"name": "Heron"}));
|
|
let cedar = server.tool("books.add_customer", json!({"name": "Cedar Client"}))["id"].clone();
|
|
let dana = server.tool("books.add_vendor", json!({"name": "Dana Illustration"}))["id"].clone();
|
|
let doc = |party: &Value, date: &str, lines: Value| {
|
|
json!({"party_id": party, "date": date, "due_date": date, "lines": lines,
|
|
"confirmed": true})
|
|
};
|
|
server.tool(
|
|
"books.add_account",
|
|
json!({"code": "6500", "name": "Supplies", "account_type": "expense"}),
|
|
);
|
|
let inv = server.tool(
|
|
"books.create_invoice",
|
|
doc(
|
|
&cedar,
|
|
"2026-02-02",
|
|
json!([
|
|
{"description": "Discovery", "amount": "1500.00", "class": "Kestrel"},
|
|
{"description": "Heron sketches", "amount": "250.00", "class": "Heron"},
|
|
{"description": "Travel", "amount": "80.00"},
|
|
]),
|
|
),
|
|
);
|
|
let inv2 = server.tool(
|
|
"books.create_invoice",
|
|
doc(
|
|
&cedar,
|
|
"2026-05-04",
|
|
json!([{"description": "Build", "amount": "4321.09", "class": "kestrel"}]),
|
|
),
|
|
);
|
|
let bill_file = server
|
|
.wire
|
|
.upload(b"Dana's invoice", "dana.pdf", "application/pdf", "receipt");
|
|
let mut bill_args = doc(
|
|
&dana,
|
|
"2026-03-09",
|
|
json!([
|
|
{"description": "Illustration", "amount": "987.65", "account": "6500", "class": "Kestrel"},
|
|
{"description": "Postage", "amount": "12.00", "account": "6500"},
|
|
]),
|
|
);
|
|
bill_args["vendor_ref"] = json!("DANA-9");
|
|
bill_args["upload_id"] = json!(bill_file);
|
|
let bill = server.tool("books.create_bill", bill_args);
|
|
server.tool(
|
|
"books.receive_payment",
|
|
json!({"document_id": inv["id"], "date": "2026-02-20", "amount": "1830.00", "cash_account": "1000"}),
|
|
);
|
|
let b = data::load_once(&Source {
|
|
data_dir: data.clone(),
|
|
today: Some(today),
|
|
})
|
|
.unwrap();
|
|
let id = b.ledger.find_class_by_name("Kestrel").unwrap().id;
|
|
let page = page_figures(&b, id, Some(d(2026, 1, 5)), false);
|
|
let t = page.to_date.as_ref().unwrap();
|
|
let snap = Snap::to_date(t);
|
|
let p = server.tool(
|
|
"books.project",
|
|
json!({"project": "Kestrel", "start": "2026-01-01", "end": today.to_string()}),
|
|
);
|
|
let to = &p["to_date"];
|
|
assert_eq!(to["revenue"], s(snap.revenue));
|
|
assert_eq!(to["expenses"], s(snap.expenses));
|
|
assert_eq!(to["net"], s(snap.contribution));
|
|
assert_eq!(
|
|
to["margin_pct"],
|
|
snap.margin_pct
|
|
.map_or(Value::Null, |m| json!(m.to_string()))
|
|
);
|
|
// By hand: 1,500.00 + 4,321.09 invoiced, 987.65 billed.
|
|
assert_eq!(
|
|
(
|
|
to["revenue"].as_str(),
|
|
to["expenses"].as_str(),
|
|
to["net"].as_str()
|
|
),
|
|
(Some("5821.09"), Some("987.65"), Some("4833.44"))
|
|
);
|
|
let ((_, _), months) = page.months.as_ref().unwrap();
|
|
let by_month = p["period"]["by_month"].as_array().unwrap();
|
|
assert!(!by_month.is_empty(), "{p}");
|
|
assert_eq!(by_month.len(), months.months.len());
|
|
for (m, ours) in by_month.iter().zip(&months.months) {
|
|
assert_eq!(m["month"], ours.start.format("%Y-%m").to_string());
|
|
assert_eq!(m["revenue"], s(ours.revenue), "{m}");
|
|
assert_eq!(m["expenses"], s(ours.expenses), "{m}");
|
|
}
|
|
// Each document's lines: the viewer's page names the project the
|
|
// server's entry line carries.
|
|
for out in [&inv, &inv2, &bill] {
|
|
let entry = out["entry_id"].as_u64().unwrap();
|
|
let doc = ledger_gui::pages::entry::document_of(&b, entry).unwrap();
|
|
let viewer: Vec<Option<String>> = {
|
|
let mut seen: Vec<Option<String>> = Vec::new();
|
|
for l in doc.lines {
|
|
let name = l
|
|
.class_id
|
|
.and_then(|c| b.ledger.get_class(c))
|
|
.map(|c| c.name.clone());
|
|
if !seen.contains(&name) {
|
|
seen.push(name);
|
|
}
|
|
}
|
|
seen
|
|
};
|
|
let served = server.tool("books.get_entry", json!({"entry_id": entry}));
|
|
let served: Vec<Option<String>> = served["lines"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.filter(|l| matches!(l["code"].as_str(), Some("4000" | "6500" | "6999")))
|
|
.map(|l| l["project"].as_str().map(str::to_string))
|
|
.collect();
|
|
assert_eq!(viewer, served, "entry {entry}");
|
|
}
|
|
}
|