The project page opens on a snapshot: revenue, direct costs, the contribution before overhead and its margin of revenue (a segment margin: revenue less every cost traced to the project, fixed and variable; B-37 item 7), in the black or red, and for on-spec work the cost unrecovered if repaid. To date or this fiscal year. By month, revenue and costs with the net to date in its own strip; costs by category; the list colors each net by its sign. books.project carries the same figures, and a parity test runs the viewer and the MCP on the same books to the cent. Tests honor TMPDIR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
23 files changed
+2231
-246
No files matched your search
@@ -89,9 +89,9 @@ pub use reports::{
|
||||
AssetRegister, AssetRegisterRow, BalanceSheet, BalanceSheetStatement, ClassProfitAndLoss,
|
||||
GeneralLedger, LedgerAccount, LedgerPosting, MemberCapital, MemberEquityColumn,
|
||||
MembersEquityStatement, ProfitAndLoss, ProfitAndLossStatement, ProjectFigures, ProjectToDate,
|
||||
StatementLine, TrialBalance, TrialBalanceLine, balance_sheet_as_of, fixed_asset_register,
|
||||
general_ledger, members_equity, profit_and_loss_for_period, project_entries, project_figures,
|
||||
project_to_date, trial_balance_as_of,
|
||||
StatementLine, TrialBalance, TrialBalanceLine, balance_sheet_as_of, contribution_margin_pct,
|
||||
fixed_asset_register, general_ledger, members_equity, profit_and_loss_for_period,
|
||||
project_entries, project_figures, project_to_date, trial_balance_as_of,
|
||||
};
|
||||
pub use schedules::{
|
||||
AmortizationSchedule, Frequency, NewRecurring, NewSchedule, RecurringEntry, RecurringId,
|
||||
|
||||
@@ -328,7 +328,7 @@ pub fn balance_sheet(ledger: &Ledger) -> Result<BalanceSheet, LedgerError> {
|
||||
}
|
||||
|
||||
/// One account's line on a financial statement.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct StatementLine {
|
||||
/// Account code, e.g. `"6000"`.
|
||||
pub code: String,
|
||||
@@ -470,6 +470,18 @@ pub fn profit_and_loss_for_period(
|
||||
end: NaiveDate,
|
||||
class_id: Option<ClassId>,
|
||||
) -> Result<ProfitAndLossStatement, LedgerError> {
|
||||
Ok(profit_and_loss_counted(ledger, start, end, class_id)?.0)
|
||||
}
|
||||
|
||||
/// [`profit_and_loss_for_period`], and with a class how many revenue and
|
||||
/// expense lines it counted, (revenue, expense): lines netting to zero
|
||||
/// leave no statement line but are counted (with no class: (0, 0)).
|
||||
fn profit_and_loss_counted(
|
||||
ledger: &Ledger,
|
||||
start: NaiveDate,
|
||||
end: NaiveDate,
|
||||
class_id: Option<ClassId>,
|
||||
) -> Result<(ProfitAndLossStatement, (usize, usize)), LedgerError> {
|
||||
if end < start {
|
||||
return Err(LedgerError::InvalidDateRange {
|
||||
start: start.to_string(),
|
||||
@@ -477,11 +489,21 @@ pub fn profit_and_loss_for_period(
|
||||
});
|
||||
}
|
||||
let closings = ledger.closing_entry_ids();
|
||||
let counts = std::cell::Cell::new((0usize, 0usize));
|
||||
let lines = statement_lines(ledger, &|entry, line| {
|
||||
entry.date() >= start
|
||||
let counted = entry.date() >= start
|
||||
&& entry.date() <= end
|
||||
&& !closings.contains(&entry.id())
|
||||
&& class_id.is_none_or(|c| line.class_id == Some(c))
|
||||
&& class_id.is_none_or(|c| line.class_id == Some(c));
|
||||
if counted && class_id.is_some() {
|
||||
let (r, x) = counts.get();
|
||||
match ledger.chart().get(line.account).map(|a| a.account_type) {
|
||||
Some(AccountType::Revenue) => counts.set((r + 1, x)),
|
||||
Some(AccountType::Expense) => counts.set((r, x + 1)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
counted
|
||||
})?;
|
||||
let mut revenue = Vec::new();
|
||||
let mut expenses = Vec::new();
|
||||
@@ -494,14 +516,17 @@ pub fn profit_and_loss_for_period(
|
||||
}
|
||||
let total_revenue = revenue.iter().map(|l| l.amount).sum();
|
||||
let total_expenses = expenses.iter().map(|l| l.amount).sum();
|
||||
Ok(ProfitAndLossStatement {
|
||||
start,
|
||||
end,
|
||||
revenue,
|
||||
expenses,
|
||||
total_revenue,
|
||||
total_expenses,
|
||||
})
|
||||
Ok((
|
||||
ProfitAndLossStatement {
|
||||
start,
|
||||
end,
|
||||
revenue,
|
||||
expenses,
|
||||
total_revenue,
|
||||
total_expenses,
|
||||
},
|
||||
counts.get(),
|
||||
))
|
||||
}
|
||||
|
||||
/// A project's figures (rules.md B-37 item 3): the period's statement and
|
||||
@@ -512,10 +537,27 @@ pub struct ProjectFigures {
|
||||
pub period: ProfitAndLossStatement,
|
||||
/// The period by calendar month: its last [`PROJECT_MONTHS`] at most.
|
||||
pub months: Vec<crate::analysis::IntervalPnl>,
|
||||
/// Each month's net to date: every tagged line from the first day
|
||||
/// the books keep through that month's last day in the period (the
|
||||
/// running net the months move toward or away from break-even; the
|
||||
/// last is `to_date`'s net). One per month in `months`.
|
||||
pub months_net_to_date: Vec<Decimal>,
|
||||
/// How many posted revenue and expense lines tagged to it fall in
|
||||
/// the period (closing entries left out): whether anything is tagged
|
||||
/// there, even lines that net to zero (a cost and its reversal).
|
||||
pub period_lines: usize,
|
||||
/// Every tagged line through the period's end.
|
||||
pub to_date: ProjectToDate,
|
||||
}
|
||||
|
||||
impl ProjectFigures {
|
||||
/// The period's contribution margin: [`contribution_margin_pct`] of
|
||||
/// its net and revenue.
|
||||
pub fn period_margin_pct(&self) -> Option<Decimal> {
|
||||
contribution_margin_pct(self.period.net_income(), self.period.total_revenue)
|
||||
}
|
||||
}
|
||||
|
||||
/// A project's figures to date: every posted revenue and expense line
|
||||
/// tagged to it through `through`, whatever its dates (rules.md B-37).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -529,13 +571,67 @@ pub struct ProjectToDate {
|
||||
/// On spec only: expenses less revenue, never below zero (the "if
|
||||
/// repaid" figure, never a receivable). `None` for any other kind.
|
||||
pub unrecovered_cost: Option<Decimal>,
|
||||
/// Its tagged revenue by account, by code.
|
||||
pub revenue_by_account: Vec<StatementLine>,
|
||||
/// Its tagged expenses by account, by code: its direct costs by
|
||||
/// category (an account credited above its cost, a reimbursement,
|
||||
/// is negative).
|
||||
pub expenses_by_account: Vec<StatementLine>,
|
||||
/// How many posted revenue lines are tagged to it through `through`
|
||||
/// (closing entries left out), even ones netting to zero: an account
|
||||
/// whose lines cancel out isn't in `revenue_by_account`.
|
||||
pub revenue_lines: usize,
|
||||
/// How many posted expense lines, likewise (a cost and its reversal,
|
||||
/// or a cost reimbursed exactly, are two lines and no account).
|
||||
pub expenses_lines: usize,
|
||||
}
|
||||
|
||||
impl ProjectToDate {
|
||||
/// Revenue less expenses.
|
||||
/// Revenue less expenses: the project's contribution before overhead
|
||||
/// (rules.md B-37 item 7), since only its own tagged lines count.
|
||||
pub fn net(&self) -> Decimal {
|
||||
self.revenue - self.expenses
|
||||
}
|
||||
|
||||
/// Its contribution margin to date: [`contribution_margin_pct`] of
|
||||
/// its net and revenue.
|
||||
pub fn margin_pct(&self) -> Option<Decimal> {
|
||||
contribution_margin_pct(self.net(), self.revenue)
|
||||
}
|
||||
|
||||
/// Whether any revenue or expense line is tagged to it, netting to
|
||||
/// zero or not.
|
||||
pub fn any_tagged(&self) -> bool {
|
||||
self.revenue_lines + self.expenses_lines > 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Contribution as a percent of revenue, to one place (half to even), or
|
||||
/// `None` without revenue to divide by (rules.md B-37 item 7).
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: one margin the tools and the viewer print alike.
|
||||
/// SOURCE: docs/spec/rules.md B-37 item 7 (revenue less the costs traced
|
||||
/// to the project, fixed and variable alike: Garrison's segment
|
||||
/// margin, as a ratio; not Horngren's variable-cost contribution
|
||||
/// margin; a presentation choice, not GAAP).
|
||||
/// INVARIANTS:
|
||||
/// 1. None when revenue <= 0: a margin on no (or negative) revenue
|
||||
/// means nothing.
|
||||
/// 2. Otherwise contribution / revenue * 100, rounded to one place,
|
||||
/// half to even, always printed with that place ("100.0").
|
||||
/// 3. NEVER panics: None when the quotient can't be represented.
|
||||
/// ```
|
||||
pub fn contribution_margin_pct(contribution: Decimal, revenue: Decimal) -> Option<Decimal> {
|
||||
if revenue <= Decimal::ZERO {
|
||||
return None;
|
||||
}
|
||||
let mut x = contribution
|
||||
.checked_div(revenue)?
|
||||
.checked_mul(Decimal::ONE_HUNDRED)?
|
||||
.round_dp_with_strategy(1, rust_decimal::RoundingStrategy::MidpointNearestEven);
|
||||
x.rescale(1);
|
||||
Some(x)
|
||||
}
|
||||
|
||||
/// The most months [`ProjectFigures::months`] holds: the period's last.
|
||||
@@ -555,6 +651,9 @@ pub const PROJECT_MONTHS: u32 = 240;
|
||||
/// months are its last PROJECT_MONTHS.
|
||||
/// 3. to_date counts every tagged line through `end`, from the first
|
||||
/// day the books keep, whatever the project's own dates.
|
||||
/// 4. months_net_to_date[i] is the net to date through months[i].end:
|
||||
/// the net before the first month plus the months' nets so far; the
|
||||
/// last equals to_date's net.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the class doesn't exist: SHOULD refuse (UnknownClass).
|
||||
/// 2. IF `start` is after `end`: SHOULD refuse.
|
||||
@@ -568,7 +667,7 @@ pub fn project_figures(
|
||||
if ledger.get_class(id).is_none() {
|
||||
return Err(LedgerError::UnknownClass(id));
|
||||
}
|
||||
let period = profit_and_loss_for_period(ledger, start, end, Some(id))?;
|
||||
let (period, (r, x)) = profit_and_loss_counted(ledger, start, end, Some(id))?;
|
||||
// The last PROJECT_MONTHS months at most: a span across the calendar
|
||||
// is a hundred thousand months, each a statement.
|
||||
let months_from = chrono::Datelike::with_day(&end, 1)
|
||||
@@ -582,9 +681,25 @@ pub fn project_figures(
|
||||
Some(id),
|
||||
)?;
|
||||
let to_date = project_to_date(ledger, id, end)?;
|
||||
// The net before the first month: to date less the months, which run
|
||||
// through `end` too (one scan fewer than a to date through the day
|
||||
// before them).
|
||||
let mut running = months
|
||||
.iter()
|
||||
.try_fold(to_date.net(), |t, m| t.checked_sub(m.net()))
|
||||
.ok_or(LedgerError::AmountOutOfRange)?;
|
||||
let mut months_net_to_date = Vec::with_capacity(months.len());
|
||||
for m in &months {
|
||||
running = running
|
||||
.checked_add(m.net())
|
||||
.ok_or(LedgerError::AmountOutOfRange)?;
|
||||
months_net_to_date.push(running);
|
||||
}
|
||||
Ok(ProjectFigures {
|
||||
period,
|
||||
months,
|
||||
months_net_to_date,
|
||||
period_lines: r + x,
|
||||
to_date,
|
||||
})
|
||||
}
|
||||
@@ -601,7 +716,8 @@ pub fn project_to_date(
|
||||
.project_facts(id)
|
||||
.ok_or(LedgerError::UnknownClass(id))?;
|
||||
let start = crate::calendar::FIRST_DAY.min(through);
|
||||
let pl = profit_and_loss_for_period(ledger, start, through, Some(id))?;
|
||||
let (pl, (revenue_lines, expenses_lines)) =
|
||||
profit_and_loss_counted(ledger, start, through, Some(id))?;
|
||||
let unrecovered_cost = match facts.kind {
|
||||
Some(crate::classes::ProjectKind::OnSpec) => Some(
|
||||
crate::kernel::unrecovered_cost(pl.total_revenue, pl.total_expenses)
|
||||
@@ -614,6 +730,10 @@ pub fn project_to_date(
|
||||
revenue: pl.total_revenue,
|
||||
expenses: pl.total_expenses,
|
||||
unrecovered_cost,
|
||||
revenue_by_account: pl.revenue,
|
||||
expenses_by_account: pl.expenses,
|
||||
revenue_lines,
|
||||
expenses_lines,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1002,6 +1002,95 @@ proptest! {
|
||||
prop_assert_eq!(exp_sum + untagged_exp, all.total_expenses);
|
||||
}
|
||||
|
||||
/// rules.md B-37 item 7: the snapshot's contribution is
|
||||
/// project_to_date's net, revenue less direct costs, whatever the
|
||||
/// tags and sides (a refund debits revenue, a reimbursement credits an
|
||||
/// expense below zero): the accounts sum to the totals, each month's
|
||||
/// net to date is project_to_date through its end (the last is the
|
||||
/// net to date), and a margin exists exactly when revenue is above
|
||||
/// zero, with the contribution's sign.
|
||||
#[test]
|
||||
fn prop_project_contribution_is_the_net_to_date(
|
||||
entries in prop::collection::vec(
|
||||
(0u32..400, 1i64..1_000_000, 0u32..4, any::<bool>(), 0u32..3, 0u32..3),
|
||||
0..20,
|
||||
),
|
||||
from in 0u32..400,
|
||||
len in 0u32..400,
|
||||
) {
|
||||
let base = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let cash = l.add_account("1000", "Cash", AccountType::Asset, None, false).unwrap();
|
||||
let rev = l.add_account("4000", "Revenue", AccountType::Revenue, None, false).unwrap();
|
||||
let exp = l.add_account("6000", "Costs", AccountType::Expense, None, false).unwrap();
|
||||
let soft = l.add_account("6100", "Software", AccountType::Expense, None, false).unwrap();
|
||||
let classes: Vec<u32> = (0..2).map(|i| l.add_class(&format!("P{i}")).unwrap()).collect();
|
||||
let accounts = [cash, rev, exp, soft];
|
||||
// Each tagged revenue or expense line: (date, class).
|
||||
let mut tagged = Vec::new();
|
||||
for (day, c, other, debit_first, tag_a, tag_b) in entries {
|
||||
let date = base + chrono::Days::new(u64::from(day));
|
||||
let e = l.create_entry(date, "e");
|
||||
let amt = Money::new(Decimal::new(c, 2), Currency::USD);
|
||||
let other = accounts[other as usize];
|
||||
let (dr, cr) = if debit_first { (other, cash) } else { (cash, other) };
|
||||
l.add_line(e, dr, Side::Debit, amt).unwrap();
|
||||
l.add_line(e, cr, Side::Credit, amt).unwrap();
|
||||
for (i, t) in [tag_a, tag_b].into_iter().enumerate() {
|
||||
if let Some(&k) = classes.get(t as usize) {
|
||||
l.set_line_class(e, i, Some(k)).unwrap();
|
||||
if [dr, cr][i] != cash {
|
||||
tagged.push((date, k));
|
||||
}
|
||||
}
|
||||
}
|
||||
l.post_entry(e).unwrap();
|
||||
}
|
||||
let start = base + chrono::Days::new(u64::from(from));
|
||||
let end = start + chrono::Days::new(u64::from(len));
|
||||
for &k in &classes {
|
||||
let t = ledger_core::project_to_date(&l, k, end).unwrap();
|
||||
let lines = |v: &[ledger_core::StatementLine]| v.iter().map(|x| x.amount).sum::<Decimal>();
|
||||
prop_assert_eq!(lines(&t.revenue_by_account), t.revenue);
|
||||
prop_assert_eq!(lines(&t.expenses_by_account), t.expenses);
|
||||
prop_assert_eq!(t.net(), t.revenue - t.expenses);
|
||||
match t.margin_pct() {
|
||||
None => prop_assert!(t.revenue <= Decimal::ZERO),
|
||||
Some(m) => {
|
||||
prop_assert!(t.revenue > Decimal::ZERO);
|
||||
prop_assert_eq!(m.scale(), 1);
|
||||
// Its sign is the contribution's (a tiny one rounds to 0.0).
|
||||
if t.net() >= Decimal::ZERO {
|
||||
prop_assert!(m >= Decimal::ZERO);
|
||||
} else {
|
||||
prop_assert!(m <= Decimal::ZERO);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Whether anything is tagged counts lines, even ones netting
|
||||
// to zero, never accounts left with a balance.
|
||||
let count = |from: NaiveDate| {
|
||||
tagged.iter().filter(|(d, c)| *c == k && *d >= from && *d <= end).count()
|
||||
};
|
||||
prop_assert_eq!(t.revenue_lines + t.expenses_lines, count(NaiveDate::MIN));
|
||||
let f = ledger_core::project_figures(&l, k, start, end).unwrap();
|
||||
prop_assert_eq!(f.period_lines, count(start));
|
||||
prop_assert_eq!(&f.to_date, &t);
|
||||
prop_assert_eq!(f.months_net_to_date.len(), f.months.len());
|
||||
for (m, running) in f.months.iter().zip(&f.months_net_to_date) {
|
||||
let at = ledger_core::project_to_date(&l, k, m.end).unwrap();
|
||||
prop_assert_eq!(*running, at.net());
|
||||
}
|
||||
if let Some(last) = f.months_net_to_date.last() {
|
||||
prop_assert_eq!(*last, t.net());
|
||||
}
|
||||
prop_assert_eq!(
|
||||
f.period_margin_pct(),
|
||||
ledger_core::contribution_margin_pct(f.period.net_income(), f.period.total_revenue)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// rules.md B-37 item 3: project_entries codes its own signs, so the
|
||||
/// posted entries' project amounts must sum to project_to_date's net
|
||||
/// (with no entry dated after the day), whatever the tags, the sides,
|
||||
|
||||
@@ -510,6 +510,9 @@ fn closing_entries_are_left_out_of_project_figures() {
|
||||
let f = project_figures(&b.l, c, d(2025, 1, 1), d(2025, 12, 31)).unwrap();
|
||||
assert_eq!(f.period.total_revenue, cents(40_000));
|
||||
assert_eq!(f.to_date.revenue, cents(40_000));
|
||||
// The close's lines aren't counted as tagged ones either.
|
||||
assert_eq!((f.to_date.revenue_lines, f.to_date.expenses_lines), (1, 0));
|
||||
assert_eq!(f.period_lines, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -530,6 +533,23 @@ fn a_reversal_nets_a_project_to_zero() {
|
||||
assert!(t.expenses.is_zero());
|
||||
let entries = project_entries(&b.l, c);
|
||||
assert_eq!(entries, vec![(r, cents(180_000)), (e, cents(-180_000))]);
|
||||
// Nothing left on an account, but two lines tagged: it breaks even,
|
||||
// it isn't untagged (rules.md B-37 item 7).
|
||||
assert!(t.expenses_by_account.is_empty());
|
||||
assert_eq!((t.revenue_lines, t.expenses_lines), (0, 2));
|
||||
assert!(t.any_tagged());
|
||||
let f = project_figures(&b.l, c, d(2026, 1, 1), d(2026, 12, 31)).unwrap();
|
||||
assert_eq!(f.period_lines, 2);
|
||||
// A period before both: nothing there, both still to date.
|
||||
let f = project_figures(&b.l, c, d(2026, 1, 1), d(2026, 2, 28)).unwrap();
|
||||
assert_eq!(f.period_lines, 0);
|
||||
assert!(!f.to_date.any_tagged(), "to date through the period's end");
|
||||
let bare = b.l.add_class("Bare").unwrap();
|
||||
assert!(
|
||||
!project_to_date(&b.l, bare, d(2026, 12, 31))
|
||||
.unwrap()
|
||||
.any_tagged()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -631,3 +651,64 @@ fn differs_from_names_projects() {
|
||||
a.l.set_project(c, "Harbor", on_spec()).unwrap();
|
||||
assert_eq!(a.l.differs_from(&before), vec!["projects"]);
|
||||
}
|
||||
|
||||
/// rules.md B-37 item 7: the margin is the contribution's share of
|
||||
/// revenue, one place, half to even, and none without revenue.
|
||||
#[test]
|
||||
fn contribution_margin_is_one_place_and_none_without_revenue() {
|
||||
use ledger_core::contribution_margin_pct as pct;
|
||||
assert_eq!(
|
||||
pct(cents(320_000), cents(500_000)).unwrap().to_string(),
|
||||
"64.0"
|
||||
);
|
||||
assert_eq!(pct(cents(100), cents(100)).unwrap().to_string(), "100.0");
|
||||
assert_eq!(pct(cents(-300), cents(200)).unwrap().to_string(), "-150.0");
|
||||
// Half to even: 1.25% to 1.2, 1.35% to 1.4.
|
||||
assert_eq!(pct(cents(125), cents(10_000)).unwrap().to_string(), "1.2");
|
||||
assert_eq!(pct(cents(135), cents(10_000)).unwrap().to_string(), "1.4");
|
||||
assert_eq!(pct(cents(-500), Decimal::ZERO), None);
|
||||
assert_eq!(pct(cents(500), cents(-500)), None, "refunds only");
|
||||
assert_eq!(pct(Decimal::MAX, cents(1)), None, "never panics");
|
||||
}
|
||||
|
||||
/// rules.md B-37 item 7: each month's net to date opens on the lines
|
||||
/// before the months (a deposit last year) and ends on the net to date;
|
||||
/// a reimbursement credited above its cost leaves the expense below zero.
|
||||
#[test]
|
||||
fn months_net_to_date_runs_from_before_the_months() {
|
||||
let mut b = books();
|
||||
let c = b.l.add_class("Zeta site").unwrap();
|
||||
b.post(d(2025, 12, 10), b.cash, b.revenue, 50_000, Some((c, false)));
|
||||
b.post(
|
||||
d(2026, 2, 9),
|
||||
b.contractors,
|
||||
b.cash,
|
||||
20_000,
|
||||
Some((c, true)),
|
||||
);
|
||||
// The client reimburses more than the cost, credited to it.
|
||||
b.post(
|
||||
d(2026, 3, 1),
|
||||
b.cash,
|
||||
b.contractors,
|
||||
30_000,
|
||||
Some((c, false)),
|
||||
);
|
||||
let f = project_figures(&b.l, c, d(2026, 1, 1), d(2026, 3, 31)).unwrap();
|
||||
assert_eq!(
|
||||
f.months_net_to_date,
|
||||
vec![cents(50_000), cents(30_000), cents(60_000)]
|
||||
);
|
||||
assert_eq!(f.to_date.net(), cents(60_000));
|
||||
assert_eq!(f.to_date.expenses, cents(-10_000));
|
||||
assert_eq!(f.to_date.expenses_by_account.len(), 1);
|
||||
assert_eq!(f.to_date.expenses_by_account[0].amount, cents(-10_000));
|
||||
assert_eq!(f.to_date.margin_pct().unwrap().to_string(), "120.0");
|
||||
assert_eq!(f.period_margin_pct(), None, "no revenue in the period");
|
||||
// No entries at all: nothing to date, no margin, every month zero.
|
||||
let empty = b.l.add_class("Empty").unwrap();
|
||||
let f = project_figures(&b.l, empty, d(2026, 1, 1), d(2026, 3, 31)).unwrap();
|
||||
assert!(f.to_date.revenue_by_account.is_empty() && f.to_date.expenses_by_account.is_empty());
|
||||
assert_eq!(f.to_date.margin_pct(), None);
|
||||
assert_eq!(f.months_net_to_date, vec![Decimal::ZERO; 3]);
|
||||
}
|
||||
Reference in new issue
Block a user