Files
wanderbooks/crates/ledger-gui/src/pages/cash.rs
T
swaitsandClaude Opus 5.5 d7fa16e974
check / check (push) Canceled after 0s
feat(arap)!: documents read their figures from standing lines; control accounts
refuse hand entries (P4b S1, part A)

Plan P4b (D-45), slice S1, the engine half: rules.md B-38.

- ledger-core: every receivables or payables line carries its document
  (Ledger.line_docs: DocRef, Movement, DocTag), written by the document
  commands in the step that posts it, derived on replay from the existing
  document events (the role's account as it stood then; payments by the
  ordinal rule), copied onto a reversal's mirror. Ledger::stands_on;
  doc_state / invoice_state / bill_state (total, paid, outstanding,
  status Open/Partial/Paid/WrittenOff/Void, overdue days) as of any day;
  ar_aging / ap_aging read them, so reversed payments and voids are right.
  The control guard (add_line, add_line_fx, post_entry,
  register_recurring) refuses untagged lines on receivables/payables,
  exempting a reversal's mirror of a legacy line; replay never refuses.
  Bounds (kernel::running_bounds, bounds_kept) on payments and reversals;
  a document reversal never dated before its entry; deposit/payer accounts
  must be cash-like and hold no role. New API: receive_payment_with /
  pay_bill_with (PaymentDetails: instrument, reference), void_document /
  check_void_document, control_check, payments_recorded,
  standing_payments, replay_reverse_entry. Invoice.amount_paid/status
  remain only as derived caches for the MCP layer until part B.
  cashflow bill_splits reads payments from the tags.
- ledger-store: journal feature document_standing (D-39 gated) for a
  payment accepted only because an earlier one was reversed;
  receive_payment_with / pay_bill_with (instrument journaled as
  entry.instrument_set, reference in the description), void_document;
  replay uses replay_reverse_entry; schema 15 drops the projection's
  amount_paid/status. Fixture p4-arap-before + golden figures written by
  the version before S1, adjudicated in arap_standing.rs.
- ledger-gui: party card, cash "coming up" and company owed totals read
  doc_state as of today (late from the derived state).
- Lean Documents.lean (control_agrees and friends); Kani
  bounds_check_matches_definition,
bounds_kept_is_the_definition_on_books_in_bounds;
  rules B-38, B-18/B-23 amended; bookspec `older = true` for legacy hand
  lines; tests that hand-post to 1200/2000 post as replay or renumbered.

Review fixes (both reviews of part A):
- control_role_of guards only an account the role is assigned to or one
  holding its documents' lines (a chart's own 1200 Equipment or card at
  2000 is free until a document posts there); payment accounts check
  other roles by holds_role (type-checked). The MCP tests that failed on
  the guard (oracle_mcp, spec_projects) pass unchanged.
- standing_payments leaves out reversals (sorted, deduped); replayed
  tags match the movement's side; payments_recorded counts every payment
  event (tagged or not, Ledger.payment_events) and refuses overflow.
- The record caches settled (paid + discounted + written off) and owed
  (Invoice/Bill.owed, never serialized): a void never reads as paid.
- assign_role: receivables/payables refuse moving onto an account with
  untagged lines (RoleAccountHasHandLines) and, on their control account
  holding a balance, say DocumentRoleHoldsBalance (no hand entry named);
  B-23 reworded. The reversal-mirror exemption only takes a hand line
  off (even chain depth); putting it back is refused.
- DocStatus Overpaid and Unattached; aging leaves out credit balances;
  ControlCheck.attention lists out-of-bounds and unattached documents.
- stands_on counts a chain in order; indexes (doc_index, doc_accounts,
  order_pos, reversed_by) keep the derived figures linear.
- Viewer reads a post-dated document as of its own date; one state per
  row. bounds_kept's side-crossing documented as intended.
- Tests: ten new spec_documents scenarios, stronger proptests, store
  proptest arap_props (live = replay = snapshot at every date, closed
  months), fuzz ops void/payments with details, a GUI post-dated test;
  Lean Step.assign / Step.release with unattached_zero kept; model
  limits stated in docs/testing.md and B-38.
Review fixes (CPA and engineering lenses): only an assigned role's
account or one holding document lines is a control account (a chart's
own 1200 asset stays free); a role can't move onto untagged lines or off
document lines (Lean Step.assign); a void's cache never reads as paid;
no Decimal sum panics; per-document indexes keep figures linear; a
replayed tag matches its side; the feature gate counts payment events;
a payment's reversal is never listed as standing; an overpaid document
has its own status; the viewer keeps post-dated documents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-24 09:17:42 -06:00

733 lines
28 KiB
Rust

//! Cash: what's in the bank, where it's been going, how long it lasts, and
//! what's coming in and out next.
use super::{columns, page};
use crate::app::Shell;
use crate::books::Books;
use crate::charts::{self, BarGroup};
use crate::fmt;
use crate::nav::Route;
use crate::theme::{self, pal};
use crate::widgets;
use chrono::{Datelike, Days, NaiveDate};
use egui::{CornerRadius, RichText, Sense, Ui, vec2};
use egui_phosphor::regular as icon;
use ledger_core::analysis::{Interval, balance_history, flows_by_interval, interval_start};
use rust_decimal::Decimal;
pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
page(ui, "cash", |ui| {
let k = &b.kpis;
let since = b.today - Days::new(30);
let flows = flows_by_interval(
&b.ledger,
&b.cash_accounts,
since + Days::new(1),
b.today,
Interval::Year,
);
let (cin, cout): (Decimal, Decimal) =
flows.iter().fold((Decimal::ZERO, Decimal::ZERO), |a, f| {
(a.0 + f.2, a.1 + f.3)
});
widgets::page_header(
ui,
"Cash",
&format!(
"{} across {}. In the last 30 days {} came in and {} went out.",
fmt::dollars(k.cash),
fmt::count(b.cash_accounts.len(), "account", "accounts"),
fmt::dollars(cin),
fmt::dollars(cout)
),
);
if b.cash_accounts.is_empty() {
widgets::card(ui, |ui| {
widgets::empty(
ui,
icon::BANK,
"No cash accounts found",
"Name an asset account \"checking\", \"savings\", or \"cash\", or ask the agent to set the books' cash accounts.",
)
});
return;
}
// One tile per cash and card account.
let mut tiles: Vec<(u64, bool)> = b.cash_accounts.iter().map(|a| (*a, false)).collect();
tiles.extend(b.card_accounts.iter().map(|a| (*a, true)));
// Up to four across, fewer when the window is narrow.
let n = tiles.len().clamp(1, 4);
let n = widgets::fit_columns(ui.available_width(), 200.0, 16.0, n);
for row in tiles.chunks(n) {
columns(ui, 0.0, &vec![1.0; n], |i, ui| {
let Some((id, is_card)) = row.get(i) else {
return;
};
let hist = balance_history(&b.ledger, &[*id]);
let spark: Vec<f64> = hist
.iter()
.rev()
.take(40)
.rev()
.map(|(_, v)| (*v).try_into().unwrap_or(0.0))
.collect();
let bal = b.balance_on(*id, b.today);
let ago = b.balance_on(*id, since);
let delta = bal - ago;
let r = widgets::kpi(
ui,
widgets::Kpi {
label: &b.account_label(*id),
value: fmt::dollars(bal),
sub: Some((
if *is_card {
"owed on the card".to_string()
} else if delta.is_zero() {
"no change in 30 days".to_string()
} else {
format!(
"{}{} in 30 days",
if delta > Decimal::ZERO { "+" } else { "" },
fmt::dollars(delta)
)
},
if *is_card || delta.is_zero() {
p.muted
} else if delta > Decimal::ZERO {
p.positive
} else {
p.negative
},
)),
spark: (spark.len() > 1).then_some(&spark[..]),
how: "",
},
);
if r.interact(Sense::click()).clicked() {
shell.state.accounts.range = crate::pages::accounts::RegisterRange::Default;
shell.go(Route::Account(*id));
}
});
}
let first = k.first_entry.unwrap_or(b.today);
let (start, end) = b.trailing_months(12);
let start = start.max(interval_start(first, Interval::Month));
columns(ui, 980.0, &[3.0, 2.0], |i, ui| match i {
0 => {
widgets::titled_card(
ui,
"Cash balance",
|_| {},
|ui| charts::balance_line(ui, &b.cash_history(), b.today, 240.0),
);
}
_ => {
let months =
flows_by_interval(&b.ledger, &b.cash_accounts, start, end, Interval::Month);
widgets::titled_card(
ui,
"Money in and out by month",
|_| {},
|ui| {
let groups: Vec<BarGroup> = months
.iter()
.map(|(s, _, i, o)| BarGroup {
label: s.format("%b").to_string(),
values: vec![*i, *o],
year: None,
})
.collect();
charts::bar_chart(
ui,
&groups,
&[("In", p.series[1]), ("Out", p.series[2])],
210.0,
);
},
);
}
});
columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i {
0 => runway(ui, b),
_ => coming_up(ui, b, shell),
});
biggest_outflows(ui, b, shell);
});
}
fn runway(ui: &mut Ui, b: &Books) {
let p = pal(ui.ctx());
let ms = b.month_start();
let three = ms - chrono::Months::new(3);
let start = three.max(
b.kpis
.first_entry
.map_or(three, |f| interval_start(f, Interval::Month)),
);
let months = flows_by_interval(
&b.ledger,
&b.cash_accounts,
start,
ms - Days::new(1),
Interval::Month,
);
widgets::titled_card(
ui,
"Burn and runway",
|_| {},
|ui| {
if months.is_empty() {
ui.label(RichText::new("Needs at least one full month of books.").color(p.muted));
return;
}
let net: Decimal = months.iter().map(|(_, _, i, o)| i - o).sum::<Decimal>()
/ Decimal::from(months.len() as u64);
let net = net.round_dp(2);
let big = |ui: &mut Ui, label: &str, value: String, color| {
ui.vertical(|ui| {
ui.label(
RichText::new(label)
.font(theme::regular(12.5))
.color(p.muted),
);
ui.label(
RichText::new(value)
.font(theme::semibold(22.0))
.color(color),
);
});
};
ui.horizontal(|ui| {
big(
ui,
"Average monthly net cash flow",
format!(
"{}{}",
if net > Decimal::ZERO { "+" } else { "" },
fmt::dollars(net)
),
if net >= Decimal::ZERO {
p.positive
} else {
p.negative
},
);
ui.add_space(32.0);
if net < Decimal::ZERO {
// Checked: a sub-cent average (lines an earlier version
// booked) makes a runway past any calendar.
let months_left = b.kpis.cash.checked_div(-net).map(|m| m.round_dp(1));
let out = months_left
.and_then(|m| m.checked_mul(Decimal::from(30)))
.and_then(|d| u64::try_from(d).ok())
.and_then(|d| b.today.checked_add_days(Days::new(d)));
big(
ui,
"Runway",
months_left
.map_or_else(|| fmt::CANT_COMPUTE.into(), |m| format!("{m} months")),
p.text,
);
ui.add_space(32.0);
big(
ui,
"Cash runs out around",
out.map_or_else(|| "beyond any calendar date".into(), fmt::month),
p.warning,
);
} else {
big(ui, "Runway", "Cash is growing".into(), p.positive);
}
});
ui.add_space(8.0);
ui.label(
RichText::new(format!(
"Cash in minus cash out of {}, averaged over the last {} (owner contributions and draws included: this is what the bank sees).",
b.cash_accounts.iter().map(|a| b.account_name(*a)).collect::<Vec<_>>().join(" and "),
fmt::count(months.len(), "full month", "full months")
))
.font(theme::regular(12.0))
.color(p.faint),
);
},
);
}
/// A recurring entry's ungenerated occurrences dated `from..=to` (and on
/// or before its end), found from the occurrence just before `from`
/// rather than walking from its start: a weekly entry an earlier version
/// started at chrono's first day has millions of occurrences before today,
/// and this runs every frame.
fn occurrences_from(
r: &ledger_core::RecurringEntry,
from: NaiveDate,
to: NaiveDate,
) -> Vec<NaiveDate> {
use ledger_core::Frequency;
let last = r.end.map_or(to, |e| e.min(to));
// How many periods from the start to `from`, rounded down, less one
// for a month-end clamp: never past the first occurrence on or after
// `from`.
let months = |d: NaiveDate| {
(i64::from(d.year()) - i64::from(r.start.year())) * 12 + i64::from(d.month())
- i64::from(r.start.month())
};
let k0 = match r.frequency {
Frequency::Weekly => (from - r.start).num_days() / 7,
Frequency::Monthly => months(from),
Frequency::Quarterly => months(from) / 3,
Frequency::Yearly => months(from) / 12,
} - 1;
let Ok(k0) = u32::try_from(k0.max(0)) else {
return Vec::new();
};
(k0..)
.map_while(|k| r.frequency.nth(r.start, k).filter(|d| *d <= last))
.filter(|d| *d >= from && !r.generated.contains(d))
.collect()
}
struct Upcoming {
date: NaiveDate,
label: String,
/// None: it can't be computed exactly (a recurring template or a
/// document an earlier version journaled, past exact arithmetic).
amount: Option<Decimal>,
route: Option<Route>,
}
fn coming_up(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let horizon = b.today + Days::new(45);
let mut items: Vec<Upcoming> = Vec::new();
for r in b.ledger.recurring_entries() {
let cash = r
.lines
.iter()
.filter(|(a, _, _)| b.cash_accounts.contains(a))
.try_fold(Decimal::ZERO, |sum, (_, s, m)| {
if *s == ledger_core::Side::Debit {
sum.checked_add(m.amount)
} else {
sum.checked_sub(m.amount)
}
});
for d in occurrences_from(r, b.today, horizon) {
items.push(Upcoming {
date: d,
label: format!("{} (recurring)", r.description),
amount: cash,
route: Some(Route::Company),
});
}
}
// An open document: its balance, or None when it can't be computed
// (shown as such, never left out).
for id in 1..b.ledger.next_invoice_id() {
let Some(inv) = b.ledger.get_invoice(id) else {
continue;
};
// As of today, from the lines tagged with it (rules.md B-38): a
// reversed payment reopens it, a void closes it.
let state = b.doc_today(ledger_core::DocRef::Invoice(id));
let balance = crate::books::owed_of(&state);
if balance.is_some_and(|x| x.is_zero()) {
continue;
}
let who = b
.ledger
.get_customer(inv.customer_id)
.map(|c| c.name.clone())
.unwrap_or_default();
let late = matches!(state, Ok(Some(st)) if st.overdue_days > 0);
items.push(Upcoming {
date: inv.due_date,
label: format!(
"Invoice {id} from {who}{}",
if late { " — overdue" } else { "" }
),
amount: balance,
route: Some(Route::Entry(inv.entry_id)),
});
}
for id in 1..b.ledger.next_bill_id() {
let Some(bill) = b.ledger.get_bill(id) else {
continue;
};
let balance = crate::books::owed_of(&b.doc_today(ledger_core::DocRef::Bill(id)));
if balance.is_some_and(|x| x.is_zero()) {
continue;
}
let who = b
.ledger
.get_vendor(bill.vendor_id)
.map(|v| v.name.clone())
.unwrap_or_default();
items.push(Upcoming {
date: bill.due_date,
label: format!("Bill {id} to {who}"),
amount: balance.map(|x| -x),
route: Some(Route::Entry(bill.entry_id)),
});
}
items.sort_by_key(|i| i.date);
let mut go = None;
widgets::titled_card(
ui,
"Coming up",
|ui| {
ui.label(
RichText::new("open invoices, bills, recurring")
.font(theme::regular(12.0))
.color(p.muted),
);
},
|ui| {
if items.is_empty() {
widgets::empty(
ui,
icon::CALENDAR_CHECK,
"Nothing scheduled",
"No open invoices, bills, or recurring entries due soon.",
);
return;
}
// None: an item, or the sum, can't be computed exactly.
let (inflow, outflow) = items.iter().fold(
(Some(Decimal::ZERO), Some(Decimal::ZERO)),
|(inflow, outflow), i| match i.amount {
Some(a) if a > Decimal::ZERO => {
(inflow.and_then(|x| x.checked_add(a)), outflow)
}
Some(a) => (inflow, outflow.and_then(|x| x.checked_sub(a))),
None => (None, None),
},
);
ui.label(
RichText::new(format!(
"Expected in {} · expected out {}",
fmt::dollars_or(inflow),
fmt::dollars_or(outflow)
))
.font(theme::semibold(13.0)),
);
ui.add_space(6.0);
egui::ScrollArea::vertical()
.id_salt("upcoming")
.max_height(220.0)
.show(ui, |ui| {
for it in &items {
let (r, resp) = ui
.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(6), p.hover);
}
let late = it.date < b.today;
ui.painter().text(
r.left_center() + vec2(6.0, 0.0),
egui::Align2::LEFT_CENTER,
fmt::date_short(it.date),
theme::regular(12.5),
if late { p.negative } else { p.muted },
);
widgets::clipped_text(
ui.painter(),
r.left_center() + vec2(70.0, 0.0),
&it.label,
theme::regular(13.0),
p.text,
r.width() - 70.0 - 130.0,
);
let (txt, c) = match it.amount {
Some(a) if a > Decimal::ZERO => {
(format!("+{}", fmt::money(a)), p.positive)
}
a => (fmt::money_or(a), p.text),
};
ui.painter().text(
r.right_center() - vec2(6.0, 0.0),
egui::Align2::RIGHT_CENTER,
txt,
theme::semibold(13.0),
c,
);
if resp.clicked() {
go = it.route.clone();
}
}
});
},
);
if let Some(r) = go {
shell.go(r);
}
}
fn biggest_outflows(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let since = b.today - Days::new(30);
let mut out: Vec<&crate::books::EntryInfo> = b
.entries
.iter()
.filter(|e| {
e.status == ledger_core::EntryStatus::Posted
&& e.date > since
&& e.cash_effect.is_some_and(|c| c < Decimal::ZERO)
})
.collect();
out.sort_by_key(|e| e.cash_effect);
out.truncate(8);
let mut go = None;
widgets::titled_card(
ui,
"Biggest payments, last 30 days",
|_| {},
|ui| {
if out.is_empty() {
widgets::empty(ui, icon::ARROW_UP_RIGHT, "No payments", "");
}
for e in &out {
let (r, resp) =
ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(6), p.hover);
}
ui.painter().text(
r.left_center() + vec2(6.0, 0.0),
egui::Align2::LEFT_CENTER,
fmt::date_short(e.date),
theme::regular(12.5),
p.muted,
);
widgets::clipped_text(
ui.painter(),
r.left_center() + vec2(70.0, 0.0),
&e.description,
theme::regular(13.0),
p.text,
r.width() - 70.0 - 130.0,
);
ui.painter().text(
r.right_center() - vec2(6.0, 0.0),
egui::Align2::RIGHT_CENTER,
fmt::money_or(e.cash_effect),
theme::semibold(13.0),
p.text,
);
if resp.clicked() {
go = Some(e.id);
}
}
},
);
if let Some(id) = go {
shell.go(Route::Entry(id));
}
}
#[cfg(test)]
mod tests {
use super::*;
use ledger_core::{Frequency, RecurringEntry};
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
/// occurrences_from is the recurring entry's due dates in the window,
/// exactly, without walking from the start: month ends clamped, ends
/// and generated dates honored, a start after the window, and a start
/// at chrono's first day.
#[test]
fn occurrences_in_a_window_are_the_due_dates_in_it() {
for frequency in [
Frequency::Weekly,
Frequency::Monthly,
Frequency::Quarterly,
Frequency::Yearly,
] {
for start in [
d(2020, 1, 31),
d(2024, 2, 29),
d(2026, 1, 10),
d(2026, 10, 20),
d(2027, 1, 1),
d(2027, 1, 15),
] {
for (end, (from, to)) in [None, Some(d(2026, 11, 2))].into_iter().flat_map(|end| {
// Windows a few weeks long, years long, and one
// opening before the entry starts.
[
(d(2026, 10, 17), d(2026, 12, 1)),
(d(2026, 6, 1), d(2028, 12, 31)),
(d(2025, 12, 20), d(2027, 12, 31)),
]
.map(|w| (end, w))
}) {
let mut r = RecurringEntry {
id: 1,
description: "x".into(),
lines: vec![],
frequency,
start,
end,
generated: Default::default(),
};
if let Some(first) = frequency.nth(start, 3) {
r.generated.insert(first);
}
let want: Vec<NaiveDate> =
r.due(to).into_iter().filter(|x| *x >= from).collect();
assert_eq!(
occurrences_from(&r, from, to),
want,
"{frequency:?} {start} {end:?} {from}..{to}"
);
}
}
}
let far = RecurringEntry {
id: 1,
description: "weekly since chrono's first day".into(),
lines: vec![],
frequency: Frequency::Weekly,
start: NaiveDate::MIN,
end: None,
generated: Default::default(),
};
let got = occurrences_from(&far, d(2026, 10, 17), d(2026, 11, 30));
assert!((6..=7).contains(&got.len()), "{got:?}");
assert!(got.iter().all(|x| x.weekday() == NaiveDate::MIN.weekday()));
}
/// The cash page's burn and runway, from the last three full months:
/// burning $1,000.00 a month on $7,000.00 is a 7-month runway, out
/// around May 2027 (210 days from Oct 15); an average of zero is cash
/// growing, not a runway past any date.
#[test]
fn runway_is_cash_over_the_average_burn() {
use crate::test_support::{Build, find, render};
let mut b = Build::new("runway");
let (cash, capital, rent) = (b.cash, b.capital, b.rent);
b.post(d(2026, 1, 2), cash, capital, 1_000_000);
for m in [7, 8, 9] {
b.post(d(2026, m, 10), rent, cash, 100_000);
}
let (_dir, books) = b.books();
let (texts, p) = render(books, Route::Cash, |_| {});
let net = find(&texts, &fmt::dollars(Decimal::new(-100_000, 2)));
assert_eq!(net.color, p.negative);
find(&texts, "Runway");
find(&texts, "7 months");
find(&texts, "May 2027");
// Zero on average: growing (July out, August in, September quiet).
let mut b = Build::new("runway-flat");
let (cash, capital, rent) = (b.cash, b.capital, b.rent);
b.post(d(2026, 1, 2), cash, capital, 1_000_000);
b.post(d(2026, 7, 10), rent, cash, 100_000);
b.post(d(2026, 8, 10), cash, capital, 100_000);
let (_dir, books) = b.books();
let (texts, p) = render(books, Route::Cash, |_| {});
assert_eq!(find(&texts, "Cash is growing").color, p.positive);
assert!(!crate::test_support::has(
&texts,
"beyond any calendar date"
));
}
/// "Coming up" lists recurring cash (its cash lines' sign: rent paid
/// out is negative, a recurring entry moving no cash zero, never
/// "+0.00"), open invoices (overdue only when due before today) and
/// open bills (negative), sorted by date, with the expected in and out.
#[test]
fn coming_up_is_what_cash_expects() {
use crate::test_support::{Build, cents, find, has, render};
use ledger_core::arap::DocumentLine;
use ledger_core::{AccountType, Currency, Frequency, Money, NewRecurring, Side};
let mut b = Build::new("coming-up");
let (cash, capital, rent) = (b.cash, b.capital, b.rent);
let accrued =
b.s.add_account("2100", "Accrued", AccountType::Liability, None, false)
.unwrap();
b.post(d(2026, 1, 2), cash, capital, 1_000_000);
let usd = |c: i64| Money::new(cents(c), Currency::USD);
for (what, cr, c, day) in [("Rent", cash, 50_000, 20), ("Accrual", accrued, 5_000, 25)] {
b.s.register_recurring(NewRecurring {
description: what.into(),
lines: vec![(rent, Side::Debit, usd(c)), (cr, Side::Credit, usd(c))],
frequency: Frequency::Monthly,
start: d(2026, 1, day),
end: None,
})
.unwrap();
}
let line = |c: i64| {
vec![DocumentLine {
description: "work".into(),
amount: cents(c),
class_id: None,
}]
};
let acme = b.s.add_customer("Acme").unwrap();
let beta = b.s.add_customer("Beta").unwrap();
b.s.create_invoice(acme, d(2026, 9, 1), d(2026, 10, 1), line(120_000))
.unwrap();
b.s.create_invoice(beta, d(2026, 10, 1), d(2026, 10, 15), line(20_000))
.unwrap();
let supplier = b.s.add_vendor("Supplier").unwrap();
b.s.create_bill(supplier, d(2026, 10, 1), d(2026, 11, 5), line(30_000))
.unwrap();
let (_dir, books) = b.books();
let (texts, p) = render(books, Route::Cash, |_| {});
find(&texts, "Coming up");
find(&texts, "Invoice 1 from Acme \u{2014} overdue");
find(&texts, "Invoice 2 from Beta");
find(&texts, "Bill 1 to Supplier");
find(
&texts,
"Expected in $1,400.00 \u{b7} expected out $1,300.00",
);
let plus = |c: i64| format!("+{}", fmt::money(cents(c)));
assert_eq!(find(&texts, &plus(120_000)).color, p.positive);
assert_eq!(find(&texts, &plus(20_000)).color, p.positive);
assert_eq!(find(&texts, &fmt::money(cents(-50_000))).color, p.text);
assert_eq!(find(&texts, &fmt::money(cents(-30_000))).color, p.text);
assert_eq!(find(&texts, &fmt::money(Decimal::ZERO)).color, p.text);
assert!(!has(&texts, &plus(0)));
assert!(!has(&texts, &plus(50_000)));
assert!(!has(&texts, &plus(30_000)));
}
/// "Biggest payments": posted entries of the last 30 days that took
/// cash out, never older ones, deposits, or drafts.
#[test]
fn biggest_payments_are_recent_posted_outflows() {
use crate::test_support::{Build, find, has, render};
let mut b = Build::new("outflows");
let (cash, capital, rent) = (b.cash, b.capital, b.rent);
b.post_as("Opening", d(2026, 1, 2), cash, capital, 1_000_000);
b.post_as("Old rent", d(2026, 9, 10), rent, cash, 25_000);
b.post_as("October rent", d(2026, 10, 1), rent, cash, 25_000);
b.post_as("Deposit", d(2026, 10, 5), cash, capital, 5_000);
b.draft("Draft pay", d(2026, 10, 10), rent, cash, 7_000);
// Thirty days before today exactly: not in the last 30 days.
b.post_as("Edge payment", d(2026, 9, 15), rent, cash, 9_000);
// An expense the owner paid: no cash out.
b.post_as("Owner paid", d(2026, 10, 12), rent, capital, 4_000);
let (_dir, books) = b.books();
let (texts, _) = render(books, Route::Cash, |_| {});
find(&texts, "Biggest payments, last 30 days");
find(&texts, "October rent");
for gone in [
"Old rent",
"Deposit",
"Draft pay",
"Edge payment",
"Owner paid",
] {
assert!(!has(&texts, gone), "{gone}");
}
}
}