P3: depreciation straight-line by days through the final month, schedules by day (defects 22, 9, D-37)
The final month takes what the months before leave, spread over its days; a disposed asset stops on its disposal day. A skipped month is caught up in the month the run is made; a reversed run un-posts its month. New schedules spread by day; equal-months schedules must start on the 1st. Amounts round to the currency's own minor unit. Kani and Lean proofs; an exhaustive search over runs, reversals and disposals. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
69 files changed
+10529
-841
No files matched your search
+116
-11
@@ -93,10 +93,28 @@ pub struct EntryParty {
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct AssetTrail {
|
||||
/// Depreciation the books carry, by month ("YYYY-MM"): the entry and
|
||||
/// its amount, from `asset.depreciation_posted`.
|
||||
/// its amount, from `asset.depreciation_posted`, each entry in effect
|
||||
/// (a reversed run is left out: its month is due again, as the store
|
||||
/// reads it).
|
||||
pub depreciation: BTreeMap<String, (EntryId, Decimal)>,
|
||||
/// Depreciation runs reversed since (their months un-posted).
|
||||
pub reversed_runs: Vec<EntryId>,
|
||||
/// Every run journaled, reversed or not, with its amount.
|
||||
pub runs: Vec<(EntryId, Decimal)>,
|
||||
/// Each of [`Self::runs`]' month ("YYYY-MM"), in the same order.
|
||||
pub periods: Vec<String>,
|
||||
/// What the books carry for it, entry by entry, each in its own month,
|
||||
/// as the catch-up rule reads it: every run, and every reversal of one
|
||||
/// (ledger_core::depreciation_effects of [`Self::runs`], computed once
|
||||
/// at load).
|
||||
pub effects: Vec<ledger_core::PostedRun>,
|
||||
/// The disposal entry, from `asset.disposed`.
|
||||
pub disposal_entry: Option<EntryId>,
|
||||
/// Depreciation the disposal entry itself charged (positive: the
|
||||
/// store's catch_up_depreciation) or credited back (negative: its
|
||||
/// excess_depreciation), so a disposed asset's depreciation is what
|
||||
/// the disposal took.
|
||||
pub disposal_true_up: Decimal,
|
||||
/// The entry that put it on the books, when one can be told: posted,
|
||||
/// debiting its cost account exactly its cost, dated its in-service
|
||||
/// date (else the nearest such entry within 31 days), and not already
|
||||
@@ -108,20 +126,31 @@ pub struct AssetTrail {
|
||||
|
||||
impl AssetTrail {
|
||||
/// Every entry that touches the asset: purchase, depreciation (by
|
||||
/// month), disposal.
|
||||
/// month, then any run reversed), disposal.
|
||||
pub fn entries(&self) -> Vec<EntryId> {
|
||||
let mut out: Vec<EntryId> = self.purchase_entry.into_iter().collect();
|
||||
out.extend(self.depreciation.values().map(|(e, _)| *e));
|
||||
out.extend(&self.reversed_runs);
|
||||
out.extend(self.disposal_entry);
|
||||
out
|
||||
}
|
||||
|
||||
/// Depreciation the books carry for it, summed exactly.
|
||||
/// Depreciation the books carry for it, summed exactly: its runs in
|
||||
/// effect, and what its disposal trued up (so a disposed asset's is
|
||||
/// what the disposal took, ledger_store's dispose_preview).
|
||||
pub fn posted(&self) -> Decimal {
|
||||
self.depreciation.values().map(|(_, a)| *a).sum()
|
||||
self.depreciation.values().map(|(_, a)| *a).sum::<Decimal>() + self.disposal_true_up
|
||||
}
|
||||
}
|
||||
|
||||
/// A "YYYY-MM" period as (year, month), read leniently (an earlier version
|
||||
/// posted "10000-01").
|
||||
pub fn period_month(period: &str) -> Option<(i32, u32)> {
|
||||
let (y, m) = period.split_once('-')?;
|
||||
let (y, m) = (y.parse().ok()?, m.parse().ok()?);
|
||||
(1..=12).contains(&m).then_some((y, m))
|
||||
}
|
||||
|
||||
/// One party's dealings with the company: its entries, documents, and
|
||||
/// this year's totals, computed once per refresh.
|
||||
///
|
||||
@@ -201,8 +230,11 @@ pub struct PartyInfo {
|
||||
/// 2. amount is fixed_asset_register(ledger, through)
|
||||
/// .unposted_depreciation(), exactly.
|
||||
/// 3. periods: the months ("YYYY-MM", ascending, distinct) that some
|
||||
/// asset held on `through` has in its schedule through `through`
|
||||
/// and no depreciation posted for.
|
||||
/// asset held on `through` has in its schedule through `through`,
|
||||
/// no run in effect for, and something due for (ledger_core::
|
||||
/// run_amount): a month a later run caught up, or that rounds to
|
||||
/// nothing, is never due (P3: a month skipped is caught up in the
|
||||
/// month the run is made).
|
||||
/// ```
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DepreciationDue {
|
||||
@@ -1210,9 +1242,22 @@ impl Books {
|
||||
if matches!(a.status, AssetStatus::Disposed { date, .. } if date <= through) {
|
||||
continue;
|
||||
}
|
||||
let posted = self.asset_trails.get(&a.id);
|
||||
for l in ledger_core::depreciation_schedule(a, through) {
|
||||
if !posted.is_some_and(|t| t.depreciation.contains_key(&l.period)) {
|
||||
let trail = self.asset_trails.get(&a.id).cloned().unwrap_or_default();
|
||||
// Due when a run for the month would post something: not when a
|
||||
// later run caught it up, or it rounds to nothing (one pass).
|
||||
let due: HashMap<(i32, u32), Decimal> =
|
||||
ledger_core::run_amounts(a, (through.year(), through.month()), &trail.effects)?
|
||||
.into_iter()
|
||||
.map(|(y, m, r)| ((y, m), r.amount))
|
||||
.collect();
|
||||
for l in ledger_core::depreciation_schedule(a, through)? {
|
||||
if trail.depreciation.contains_key(&l.period) {
|
||||
continue;
|
||||
}
|
||||
if period_month(&l.period)
|
||||
.and_then(|ym| due.get(&ym))
|
||||
.is_some_and(|a| !a.is_zero())
|
||||
{
|
||||
periods.insert(l.period);
|
||||
}
|
||||
}
|
||||
@@ -2030,6 +2075,7 @@ fn asset_trails(ledger: &Ledger, journal: &[JournalLine]) -> HashMap<AssetId, As
|
||||
.map(|a| (a.id, AssetTrail::default()))
|
||||
.collect();
|
||||
let u = |p: &Value, k: &str| p.get(k).and_then(|v| v.as_u64());
|
||||
let mut gain_loss_accounts: HashMap<AssetId, Option<u64>> = HashMap::new();
|
||||
for (i, line) in journal.iter().enumerate() {
|
||||
let p = &line.payload;
|
||||
let asset = match line.line_type.as_str() {
|
||||
@@ -2050,13 +2096,46 @@ fn asset_trails(ledger: &Ledger, journal: &[JournalLine]) -> HashMap<AssetId, As
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<Decimal>().ok()),
|
||||
) {
|
||||
t.depreciation.insert(period.to_string(), (entry, amount));
|
||||
t.runs.push((entry, amount));
|
||||
t.periods.push(period.to_string());
|
||||
}
|
||||
}
|
||||
"asset.disposed" => {
|
||||
t.disposal_entry = u(p, "entry_id");
|
||||
if let Some(a) = asset {
|
||||
gain_loss_accounts.insert(a, u(p, "gain_loss_account"));
|
||||
}
|
||||
}
|
||||
"asset.disposed" => t.disposal_entry = u(p, "entry_id"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// The runs in effect, by month; a run reversed (and not re-instated)
|
||||
// no longer counts. Every run is read, not a month's latest: one
|
||||
// re-instated after its month was run again and that run reversed
|
||||
// counts (P3 review round 2, as the store reads it).
|
||||
for t in out.values_mut() {
|
||||
for (&(e, amount), period) in t.runs.iter().zip(&t.periods) {
|
||||
if ledger.in_effect(e) {
|
||||
t.depreciation.insert(period.clone(), (e, amount));
|
||||
} else {
|
||||
t.reversed_runs.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
for t in out.values_mut() {
|
||||
t.effects = ledger_core::depreciation_effects(ledger, &t.runs);
|
||||
}
|
||||
// What each disposal trued up, from its entry.
|
||||
for (id, t) in out.iter_mut() {
|
||||
if let (Some(a), Some(e)) = (
|
||||
ledger.get_asset(*id),
|
||||
t.disposal_entry.and_then(|e| ledger.get_entry(e)),
|
||||
) {
|
||||
let runs: Decimal = t.depreciation.values().map(|(_, a)| *a).sum();
|
||||
let gain_loss = gain_loss_accounts.get(id).copied().flatten();
|
||||
t.disposal_true_up = disposal_true_up(a, e, gain_loss, runs);
|
||||
}
|
||||
}
|
||||
// The purchase: an entry that debits the cost account exactly the cost,
|
||||
// on the in-service date or as near it as can be (within 31 days).
|
||||
// A linked acquisition is the purchase; otherwise match it.
|
||||
@@ -2108,6 +2187,32 @@ fn asset_trails(ledger: &Ledger, journal: &[JournalLine]) -> HashMap<AssetId, As
|
||||
out
|
||||
}
|
||||
|
||||
/// Depreciation a disposal entry itself charged (positive) or credited
|
||||
/// back (negative): its lines on the asset's depreciation expense account
|
||||
/// (ledger_store's disposal_lines: catch_up_depreciation debited,
|
||||
/// excess_depreciation credited). When the disposal's gain or loss went to
|
||||
/// that same account the lines can't be told apart, and the disposal took
|
||||
/// the schedule through its day: that, less the runs in effect.
|
||||
fn disposal_true_up(
|
||||
asset: &ledger_core::Asset,
|
||||
entry: &ledger_core::JournalEntry,
|
||||
gain_loss_account: Option<u64>,
|
||||
runs: Decimal,
|
||||
) -> Decimal {
|
||||
if gain_loss_account.is_some_and(|g| g != asset.expense_account) {
|
||||
return entry
|
||||
.lines()
|
||||
.iter()
|
||||
.filter(|l| l.account == asset.expense_account)
|
||||
.map(|l| match l.side {
|
||||
Side::Debit => l.amount.amount,
|
||||
Side::Credit => -l.amount.amount,
|
||||
})
|
||||
.sum();
|
||||
}
|
||||
ledger_core::accumulated_depreciation(asset, NaiveDate::MAX).map_or(Decimal::ZERO, |t| t - runs)
|
||||
}
|
||||
|
||||
/// Entry ids a journal line refers to.
|
||||
pub fn entry_refs(line: &JournalLine) -> Vec<EntryId> {
|
||||
let p = &line.payload;
|
||||
|
||||
@@ -539,6 +539,9 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
|
||||
months: 12,
|
||||
balance_account: prepaid,
|
||||
pl_account: insurance,
|
||||
// A new schedule spreads by the day (D-11, D-37): a small first
|
||||
// and last month around eleven full ones.
|
||||
convention: ledger_core::ScheduleConvention::Daily,
|
||||
})?;
|
||||
|
||||
let mut open_invoices: Vec<(u64, NaiveDate, Decimal)> = Vec::new();
|
||||
|
||||
@@ -15,7 +15,7 @@ use crate::fmt;
|
||||
use crate::nav::{ReportKind, Route};
|
||||
use crate::theme::{self, pal};
|
||||
use crate::widgets;
|
||||
use chrono::NaiveDate;
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use egui::{Color32, CornerRadius, Margin, RichText, Sense, Ui, vec2};
|
||||
use egui_phosphor::regular as icon;
|
||||
use ledger_core::{Asset, AssetId, AssetRegister, AssetRegisterRow, AssetStatus, TaxBasisRule};
|
||||
@@ -774,8 +774,14 @@ fn reconciliation(ui: &mut Ui, b: &Books, r: &AssetRegister, shell: &mut Shell<'
|
||||
pub enum MonthStatus {
|
||||
/// Posted, by this entry.
|
||||
Posted(ledger_core::EntryId),
|
||||
/// The month has ended and nothing is posted for it.
|
||||
/// The month has ended, nothing is posted for it, and a run for it
|
||||
/// would post something.
|
||||
Due,
|
||||
/// The month has ended with no run of its own, and nothing is due for
|
||||
/// it: a later run caught it up, its run was reversed after it ended
|
||||
/// (the reversal's month catches it up), it rounds to nothing, or the
|
||||
/// asset's disposal took it.
|
||||
Covered,
|
||||
/// The month under way.
|
||||
ThisMonth,
|
||||
/// A month still to come.
|
||||
@@ -788,29 +794,91 @@ pub enum MonthStatus {
|
||||
/// ```text
|
||||
/// INVARIANTS:
|
||||
/// 1. The lines are ledger_core::depreciation_schedule(asset, the end of
|
||||
/// time), unchanged and in order.
|
||||
/// 2. A month with depreciation posted (asset.depreciation_posted) is
|
||||
/// Posted(its entry); otherwise Due when it ended before today,
|
||||
/// ThisMonth when today is in it, Scheduled after.
|
||||
/// time), unchanged and in order: for a disposed asset, through its
|
||||
/// disposal day.
|
||||
/// 2. A month with a run in effect (asset.depreciation_posted, not
|
||||
/// reversed) is Posted(its entry); otherwise, once it has ended, Due
|
||||
/// when a run for it would post something (ledger_core::run_amount,
|
||||
/// over what the books carry, every run and reversal in its own
|
||||
/// month) and Covered when not (a later run caught it up, its run was
|
||||
/// reversed after it ended, or it rounds to nothing); ThisMonth when
|
||||
/// today is in it, Scheduled after. A disposed asset's months are
|
||||
/// never Due: its disposal took the schedule through its day.
|
||||
/// 3. A schedule the books can't compute (an asset an earlier version
|
||||
/// took) is refused, never a panic: the page says why.
|
||||
/// ```
|
||||
pub fn schedule(b: &Books, id: AssetId) -> Vec<(ledger_core::ScheduleLine, MonthStatus)> {
|
||||
pub fn schedule(
|
||||
b: &Books,
|
||||
id: AssetId,
|
||||
) -> Result<Vec<(ledger_core::ScheduleLine, MonthStatus)>, ledger_core::LedgerError> {
|
||||
let Some(a) = b.ledger.get_asset(id) else {
|
||||
return Vec::new();
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let trail = b.asset_trail(id);
|
||||
let this_month = b.today.format("%Y-%m").to_string();
|
||||
ledger_core::depreciation_schedule(a, NaiveDate::MAX)
|
||||
// What a run would post for each month ended before this one, in one
|
||||
// pass (a long life has many).
|
||||
let ended = b
|
||||
.today
|
||||
.with_day(1)
|
||||
.and_then(|d| d.pred_opt())
|
||||
.map_or((i32::MIN, 1), |d| (d.year(), d.month()));
|
||||
let disposed = matches!(a.status, AssetStatus::Disposed { .. });
|
||||
let due: std::collections::HashMap<(i32, u32), Decimal> = if disposed {
|
||||
Default::default()
|
||||
} else {
|
||||
ledger_core::run_amounts(a, ended, &trail.effects)?
|
||||
.into_iter()
|
||||
.map(|(y, m, r)| ((y, m), r.amount))
|
||||
.collect()
|
||||
};
|
||||
Ok(ledger_core::depreciation_schedule(a, NaiveDate::MAX)?
|
||||
.into_iter()
|
||||
.map(|l| {
|
||||
let st = match trail.depreciation.get(&l.period) {
|
||||
Some((e, _)) => MonthStatus::Posted(*e),
|
||||
None if l.period < this_month => MonthStatus::Due,
|
||||
None if disposed => MonthStatus::Covered,
|
||||
None if l.period < this_month => {
|
||||
let owed = crate::books::period_month(&l.period)
|
||||
.and_then(|ym| due.get(&ym).copied())
|
||||
.unwrap_or(l.amount);
|
||||
if owed.is_zero() {
|
||||
MonthStatus::Covered
|
||||
} else {
|
||||
MonthStatus::Due
|
||||
}
|
||||
}
|
||||
None if l.period == this_month => MonthStatus::ThisMonth,
|
||||
None => MonthStatus::Scheduled,
|
||||
};
|
||||
(l, st)
|
||||
})
|
||||
.collect()
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Depreciation for finished months that the books don't carry yet: what
|
||||
/// a run of the last month before today's would post
|
||||
/// (ledger_core::run_amounts), every skipped or reversed month caught up.
|
||||
/// Zero for a disposed asset (its disposal took the schedule through its
|
||||
/// day) or one with nothing due.
|
||||
pub fn unposted(b: &Books, id: AssetId) -> Result<Decimal, ledger_core::LedgerError> {
|
||||
let Some(a) = b
|
||||
.ledger
|
||||
.get_asset(id)
|
||||
.filter(|a| a.status == AssetStatus::Active)
|
||||
else {
|
||||
return Ok(Decimal::ZERO);
|
||||
};
|
||||
let ended = b
|
||||
.today
|
||||
.with_day(1)
|
||||
.and_then(|d| d.pred_opt())
|
||||
.map_or((i32::MIN, 1), |d| (d.year(), d.month()));
|
||||
Ok(
|
||||
ledger_core::run_amounts(a, ended, &b.asset_trail(id).effects)?
|
||||
.last()
|
||||
.map_or(Decimal::ZERO, |(_, _, r)| r.amount),
|
||||
)
|
||||
}
|
||||
|
||||
/// One asset, completely.
|
||||
@@ -1014,12 +1082,20 @@ pub fn show_asset(ui: &mut Ui, b: &Books, id: AssetId, shell: &mut Shell<'_>) {
|
||||
ui.add_space(14.0);
|
||||
|
||||
// The schedule.
|
||||
let lines = schedule(b, id);
|
||||
let due: Decimal = lines
|
||||
.iter()
|
||||
.filter(|(_, s)| *s == MonthStatus::Due)
|
||||
.map(|(l, _)| l.amount)
|
||||
.sum();
|
||||
let (lines, schedule_error) = match schedule(b, id) {
|
||||
Ok(lines) => (lines, None),
|
||||
Err(e) => (Vec::new(), Some(e)),
|
||||
};
|
||||
if let Some(e) = &schedule_error {
|
||||
notice_box(
|
||||
ui,
|
||||
&Notice {
|
||||
problem: true,
|
||||
text: format!("Its depreciation schedule can't be computed: {e}."),
|
||||
},
|
||||
);
|
||||
}
|
||||
let due = unposted(b, id).unwrap_or(Decimal::ZERO);
|
||||
if due > Decimal::ZERO {
|
||||
notice_box(
|
||||
ui,
|
||||
@@ -1051,10 +1127,12 @@ pub fn show_asset(ui: &mut Ui, b: &Books, id: AssetId, shell: &mut Shell<'_>) {
|
||||
},
|
||||
|ui| {
|
||||
if lines.is_empty() {
|
||||
ui.label(
|
||||
RichText::new("Nothing to depreciate: its salvage value is its cost.")
|
||||
.color(p.muted),
|
||||
);
|
||||
let why = if schedule_error.is_some() {
|
||||
"The schedule can't be computed (above)."
|
||||
} else {
|
||||
"Nothing to depreciate: its salvage value is its cost."
|
||||
};
|
||||
ui.label(RichText::new(why).color(p.muted));
|
||||
return;
|
||||
}
|
||||
let amt = widgets::AMOUNT_MIN + 16.0;
|
||||
@@ -1085,6 +1163,13 @@ pub fn show_asset(ui: &mut Ui, b: &Books, id: AssetId, shell: &mut Shell<'_>) {
|
||||
let (status, color) = match st {
|
||||
MonthStatus::Posted(e) => (format!("Posted · entry #{e}"), p.positive),
|
||||
MonthStatus::Due => ("Not posted".to_string(), p.warning),
|
||||
MonthStatus::Covered if l.amount.is_zero() => {
|
||||
("Rounds to nothing".to_string(), p.muted)
|
||||
}
|
||||
MonthStatus::Covered if a.status != AssetStatus::Active => {
|
||||
("Taken by the disposal".to_string(), p.muted)
|
||||
}
|
||||
MonthStatus::Covered => ("Nothing due".to_string(), p.muted),
|
||||
MonthStatus::ThisMonth => ("This month".to_string(), p.muted),
|
||||
MonthStatus::Scheduled => ("Scheduled".to_string(), p.faint),
|
||||
};
|
||||
@@ -1422,7 +1507,7 @@ mod tests {
|
||||
for i in 0..2 {
|
||||
let (_dir, books, ids) = two_assets(&format!("asset-page-{i}"));
|
||||
let a = books.ledger.get_asset(ids[i]).unwrap().clone();
|
||||
let want = ledger_core::book_value(&a, today());
|
||||
let want = ledger_core::book_value(&a, today()).unwrap();
|
||||
let (texts, _) = render(books, Route::Asset(ids[i]), |_| {});
|
||||
assert_eq!(
|
||||
beside(&texts, "Book value today").text,
|
||||
|
||||
@@ -11,6 +11,7 @@ use crate::theme::{self, pal};
|
||||
use crate::widgets;
|
||||
use egui::{CornerRadius, RichText, Sense, Ui, vec2};
|
||||
use egui_phosphor::regular as icon;
|
||||
use ledger_core::AssetStatus;
|
||||
use ledger_core::PeriodStatus;
|
||||
use ledger_core::assets::{accumulated_depreciation, book_value};
|
||||
use rust_decimal::Decimal;
|
||||
@@ -355,22 +356,9 @@ fn assets(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
},
|
||||
|ui| {
|
||||
for a in &assets {
|
||||
// What has actually been posted, so this ties to the balance
|
||||
// sheet; the schedule may run ahead of the books.
|
||||
let acc: Decimal = b
|
||||
.journal
|
||||
.iter()
|
||||
.filter(|l| {
|
||||
l.line_type == "asset.depreciation_posted"
|
||||
&& l.payload.get("asset_id").and_then(|v| v.as_u64()) == Some(a.id)
|
||||
})
|
||||
.filter_map(|l| {
|
||||
l.payload
|
||||
.get("amount")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<Decimal>().ok())
|
||||
})
|
||||
.sum();
|
||||
// What the books carry (runs in effect), so this ties to the
|
||||
// balance sheet; the schedule may run ahead of the books.
|
||||
let acc: Decimal = b.asset_trail(a.id).posted();
|
||||
let bv = a.cost.amount - acc;
|
||||
let scheduled = accumulated_depreciation(a, b.today);
|
||||
let _ = book_value;
|
||||
@@ -394,7 +382,11 @@ fn assets(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
"bought {} · {} months · {}",
|
||||
fmt::date(a.purchase_date),
|
||||
a.useful_life_months,
|
||||
fmt::words(&format!("{:?}", a.status)).to_lowercase()
|
||||
match &a.status {
|
||||
AssetStatus::Active => "active".to_string(),
|
||||
AssetStatus::Disposed { date, .. } =>
|
||||
format!("disposed {}", fmt::date(*date)),
|
||||
}
|
||||
))
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.muted),
|
||||
@@ -406,11 +398,13 @@ fn assets(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
);
|
||||
});
|
||||
});
|
||||
let behind = scheduled - acc;
|
||||
let note = if behind > Decimal::ZERO {
|
||||
format!(" · {} more scheduled through today", fmt::dollars(behind))
|
||||
} else {
|
||||
String::new()
|
||||
let note = match scheduled {
|
||||
Ok(s) if s > acc => {
|
||||
format!(" · {} more scheduled through today", fmt::dollars(s - acc))
|
||||
}
|
||||
Ok(_) => String::new(),
|
||||
// An earlier version's asset: said, never guessed.
|
||||
Err(_) => " · its schedule can't be computed".to_string(),
|
||||
};
|
||||
progress(
|
||||
ui,
|
||||
@@ -489,12 +483,20 @@ fn schedules(ui: &mut Ui, b: &Books) {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(RichText::new(&s.description).font(theme::semibold(13.5)));
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"{} · {} months from {}",
|
||||
fmt::words(&format!("{:?}", s.kind)),
|
||||
s.months,
|
||||
fmt::month(s.start)
|
||||
))
|
||||
RichText::new(match s.convention {
|
||||
ledger_core::ScheduleConvention::Daily => format!(
|
||||
"{} · {} months from {}, by the day",
|
||||
fmt::words(&format!("{:?}", s.kind)),
|
||||
s.months,
|
||||
fmt::date(s.start)
|
||||
),
|
||||
ledger_core::ScheduleConvention::MonthlyEven => format!(
|
||||
"{} · {} months from {}",
|
||||
fmt::words(&format!("{:?}", s.kind)),
|
||||
s.months,
|
||||
fmt::month(s.start)
|
||||
),
|
||||
})
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
@@ -729,6 +731,7 @@ mod tests {
|
||||
months: 12,
|
||||
balance_account: prepaid,
|
||||
pl_account: insurance,
|
||||
convention: ledger_core::ScheduleConvention::MonthlyEven,
|
||||
})
|
||||
.unwrap();
|
||||
b.s.run_schedules(d(2026, 3, 31)).unwrap();
|
||||
|
||||
@@ -856,7 +856,15 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
|
||||
),
|
||||
"schedule.registered" => ev(
|
||||
icon::CALENDAR,
|
||||
format!("Schedule: {}", s(p, "description")),
|
||||
format!(
|
||||
"Schedule: {} ({})",
|
||||
s(p, "description"),
|
||||
// Journals before P3 carry no convention: equal months.
|
||||
match s(p, "convention") {
|
||||
"daily" => "by the day",
|
||||
_ => "equal months",
|
||||
}
|
||||
),
|
||||
Some(Route::Company),
|
||||
),
|
||||
"schedule.posted" => ev(
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Seeds for failure cases proptest has generated in the past. It is
|
||||
# automatically read and these particular cases re-run before any
|
||||
# novel cases are generated.
|
||||
#
|
||||
# It is recommended to check this file in to source control so that
|
||||
# everyone who runs the test benefits from these saved cases.
|
||||
cc 99b9f006f95ef07a893337dc3c44f78dd1b4dc3a75da413f2e5b9b428ce21797 # shrinks to ops = [Run(6), Dispose(2026-06-01)]
|
||||
@@ -228,7 +228,8 @@ fn depreciation_due_is_the_register_through_the_last_finished_month() {
|
||||
assert_eq!(due.periods, vec!["2026-08", "2026-09"]);
|
||||
// Independently: the laptop's August and September, the monitor's
|
||||
// September, straight from the schedules.
|
||||
let sched = |id| depreciation_schedule(f.oracle.get_asset(id).unwrap(), d(2026, 9, 30));
|
||||
let sched =
|
||||
|id| depreciation_schedule(f.oracle.get_asset(id).unwrap(), d(2026, 9, 30)).unwrap();
|
||||
let month = |id, p: &str| {
|
||||
sched(id)
|
||||
.into_iter()
|
||||
@@ -273,9 +274,9 @@ fn depreciation_due_is_the_register_through_the_last_finished_month() {
|
||||
fn an_assets_schedule_and_trail() {
|
||||
let f = fixture("schedule");
|
||||
let b = &f.books;
|
||||
let lines = schedule(b, f.laptop);
|
||||
let lines = schedule(b, f.laptop).unwrap();
|
||||
let asset = f.oracle.get_asset(f.laptop).unwrap();
|
||||
let oracle = depreciation_schedule(asset, NaiveDate::MAX);
|
||||
let oracle = depreciation_schedule(asset, NaiveDate::MAX).unwrap();
|
||||
assert_eq!(
|
||||
oracle.len(),
|
||||
37,
|
||||
@@ -793,7 +794,7 @@ proptest! {
|
||||
// Every period listed is unposted and in some schedule.
|
||||
for p in &due.periods {
|
||||
let found = b.ledger.registered_assets().any(|a| {
|
||||
depreciation_schedule(a, due.through).iter().any(|l| &l.period == p)
|
||||
depreciation_schedule(a, due.through).unwrap().iter().any(|l| &l.period == p)
|
||||
&& !b.asset_trail(a.id).depreciation.contains_key(p)
|
||||
});
|
||||
prop_assert!(found, "{} listed but not due", p);
|
||||
@@ -839,3 +840,346 @@ fn every_journal_event_has_plain_language_text() {
|
||||
assert_ne!(e.text, kind, "{kind} has no journal text");
|
||||
}
|
||||
}
|
||||
|
||||
/// Books holding asset X of the P3 depreciation brief (12,000.00 over 12
|
||||
/// months from 2026-01-01, 1,000.00 a month), with the store to post to.
|
||||
fn asset_x(tag: &str) -> (TempDir, PathBuf, Store, AssetId, (u64, u64)) {
|
||||
let dir = TempDir::new(tag);
|
||||
let data = dir.0.join("books-data");
|
||||
let mut s = Store::create(&data, Currency::USD).unwrap();
|
||||
let mut add = |code: &str, t| s.add_account(code, code, t, None, false).unwrap();
|
||||
let cash = add("1000", AccountType::Asset);
|
||||
let equip = add("1500", AccountType::Asset);
|
||||
let accum = add("1510", AccountType::Asset);
|
||||
let dep = add("6500", AccountType::Expense);
|
||||
let gain = add("7000", AccountType::Revenue);
|
||||
let x = s
|
||||
.register_asset(NewAsset {
|
||||
description: "Asset X".into(),
|
||||
purchase_date: d(2026, 1, 1),
|
||||
cost: usd(1_200_000),
|
||||
salvage_value: usd(0),
|
||||
useful_life_months: 12,
|
||||
cost_account: equip,
|
||||
accum_depr_account: accum,
|
||||
expense_account: dep,
|
||||
})
|
||||
.unwrap();
|
||||
(dir, data, s, x, (cash, gain))
|
||||
}
|
||||
|
||||
/// Defect 22 in the viewer: a disposed asset's page shows its schedule
|
||||
/// through the disposal day, held, as the store posted it. Hand check:
|
||||
/// January through May run, disposed of 2026-06-15: June is 1,000.00 ×
|
||||
/// 15/30 = 500.00 (the final depreciation, posted), accumulated 5,500.00,
|
||||
/// book value 6,500.00; every month posted.
|
||||
#[test]
|
||||
fn a_disposed_assets_page_shows_what_it_depreciated_through_its_disposal() {
|
||||
let (dir, _data, mut s, x, (cash, gain)) = asset_x("disposed-page");
|
||||
for m in 1..=5 {
|
||||
s.run_depreciation(x, &format!("2026-{m:02}")).unwrap();
|
||||
}
|
||||
s.dispose_asset(x, d(2026, 6, 15), usd(700_000), cash, gain)
|
||||
.unwrap();
|
||||
drop(s);
|
||||
let b = load(&dir.0, today());
|
||||
let lines = schedule(&b, x).unwrap();
|
||||
assert_eq!(lines.len(), 6);
|
||||
let (jun, st) = lines.last().unwrap();
|
||||
assert_eq!(jun.period, "2026-06");
|
||||
assert_eq!(jun.amount, Decimal::new(50_000, 2));
|
||||
assert_eq!(jun.accumulated, Decimal::new(550_000, 2));
|
||||
assert_eq!(jun.book_value, Decimal::new(650_000, 2));
|
||||
assert!(matches!(st, MonthStatus::Posted(_)));
|
||||
assert!(
|
||||
lines
|
||||
.iter()
|
||||
.all(|(_, st)| matches!(st, MonthStatus::Posted(_)))
|
||||
);
|
||||
assert_eq!(b.asset_trail(x).posted(), Decimal::new(550_000, 2));
|
||||
}
|
||||
|
||||
/// The catch-up rule in the viewer (plan/served.md P3, settled in design):
|
||||
/// a month a later run caught up is Covered, never Due forever; a run
|
||||
/// reversed within its month is no longer the books', so its month (and
|
||||
/// one it covered) is Due again, and the books carry what is in effect.
|
||||
/// Hand check: January and March run (March 2,000.00), February skipped:
|
||||
/// February Covered; March's run reversed on Mar 31: February and March
|
||||
/// Due, 1,000.00 carried.
|
||||
#[test]
|
||||
fn a_month_a_later_run_caught_up_is_covered_and_a_reversed_one_due() {
|
||||
let (dir, data, mut s, x, _) = asset_x("caught-up-page");
|
||||
s.run_depreciation(x, "2026-01").unwrap();
|
||||
let mar = s.run_depreciation(x, "2026-03").unwrap();
|
||||
assert_eq!(mar.amount, Decimal::new(200_000, 2));
|
||||
drop(s);
|
||||
let b = load(&dir.0, today());
|
||||
let st: Vec<MonthStatus> = schedule(&b, x)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|(_, s)| s)
|
||||
.collect();
|
||||
assert!(matches!(st[0], MonthStatus::Posted(_)));
|
||||
assert_eq!(st[1], MonthStatus::Covered);
|
||||
assert!(matches!(st[2], MonthStatus::Posted(_)));
|
||||
assert_eq!(st[3], MonthStatus::Due);
|
||||
let due = b.depreciation_due(d(2026, 4, 30)).unwrap();
|
||||
assert_eq!(due.periods, vec!["2026-04"]);
|
||||
let mut s = Store::open(&data).unwrap();
|
||||
s.reverse_entry(mar.entry_id.unwrap(), d(2026, 3, 31), "reversed")
|
||||
.unwrap();
|
||||
drop(s);
|
||||
let b = load(&dir.0, today());
|
||||
let st: Vec<MonthStatus> = schedule(&b, x)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|(_, s)| s)
|
||||
.collect();
|
||||
assert_eq!((st[1], st[2]), (MonthStatus::Due, MonthStatus::Due));
|
||||
assert_eq!(b.asset_trail(x).posted(), Decimal::new(100_000, 2));
|
||||
let due = b.depreciation_due(d(2026, 4, 30)).unwrap();
|
||||
assert_eq!(due.periods, vec!["2026-02", "2026-03", "2026-04"]);
|
||||
}
|
||||
|
||||
/// P3 review of the catch-up rule, in the viewer: a run reversed after its
|
||||
/// month ended carried that month at its end, so the month isn't Due (a
|
||||
/// run of it would post nothing); the month the reversal is in is, with
|
||||
/// everything since caught up. Hand check: January and March run (March
|
||||
/// 2,000.00); March's run reversed on Apr 2: February and March Covered,
|
||||
/// April Due; through September (today is in October) 9,000.00 is due,
|
||||
/// less the 1,000.00 carried: 8,000.00 unposted.
|
||||
#[test]
|
||||
fn a_run_reversed_after_its_month_leaves_the_reversals_month_due() {
|
||||
let (dir, data, mut s, x, _) = asset_x("reversed-later-page");
|
||||
s.run_depreciation(x, "2026-01").unwrap();
|
||||
let mar = s.run_depreciation(x, "2026-03").unwrap();
|
||||
s.reverse_entry(mar.entry_id.unwrap(), d(2026, 4, 2), "reversed")
|
||||
.unwrap();
|
||||
drop(s);
|
||||
let b = load(&dir.0, today());
|
||||
let st: Vec<MonthStatus> = schedule(&b, x)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|(_, s)| s)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
(st[1], st[2], st[3]),
|
||||
(MonthStatus::Covered, MonthStatus::Covered, MonthStatus::Due)
|
||||
);
|
||||
assert_eq!(
|
||||
ledger_gui::pages::assets::unposted(&b, x).unwrap(),
|
||||
Decimal::new(800_000, 2)
|
||||
);
|
||||
let due = b.depreciation_due(d(2026, 4, 30)).unwrap();
|
||||
assert_eq!(due.periods, vec!["2026-04"]);
|
||||
// The store agrees: March again posts nothing, April 3,000.00.
|
||||
let mut s = Store::open(&data).unwrap();
|
||||
assert_eq!(s.run_depreciation(x, "2026-03").unwrap().entry_id, None);
|
||||
assert_eq!(
|
||||
s.run_depreciation(x, "2026-04").unwrap().amount,
|
||||
Decimal::new(300_000, 2)
|
||||
);
|
||||
}
|
||||
|
||||
/// P3 review: a disposed asset's figures in the viewer are what its
|
||||
/// disposal took. Hand check: January through June run (6,000.00, June's
|
||||
/// dated Jun 30), disposed of Jun 15 for 7,000.00: the disposal credits
|
||||
/// June's last 15 days back (500.00), so the books' depreciation for it is
|
||||
/// 5,500.00 and its book value at disposal 6,500.00, as books.dispose_asset
|
||||
/// and books.list_assets say. Disposed the same way after March's run was
|
||||
/// reversed (on Jun 20), the disposal entry itself charges 500.00 (March's
|
||||
/// 1,000.00, less June's last 15 days already in June's run): again
|
||||
/// 5,500.00, and March isn't Due, the disposal took it.
|
||||
#[test]
|
||||
fn a_disposed_assets_figures_are_what_its_disposal_took() {
|
||||
let (dir, _data, mut s, x, (cash, gain)) = asset_x("disposed-true-up");
|
||||
for m in 1..=6 {
|
||||
s.run_depreciation(x, &format!("2026-{m:02}")).unwrap();
|
||||
}
|
||||
s.dispose_asset(x, d(2026, 6, 15), usd(700_000), cash, gain)
|
||||
.unwrap();
|
||||
drop(s);
|
||||
let b = load(&dir.0, today());
|
||||
assert_eq!(b.asset_trail(x).posted(), Decimal::new(550_000, 2));
|
||||
assert_eq!(b.asset_trail(x).disposal_true_up, Decimal::new(-50_000, 2));
|
||||
assert_eq!(
|
||||
ledger_gui::pages::assets::unposted(&b, x).unwrap(),
|
||||
Decimal::ZERO
|
||||
);
|
||||
|
||||
let (dir, _data, mut s, x, (cash, gain)) = asset_x("disposed-catch-up");
|
||||
let mut mar = 0;
|
||||
for m in 1..=6 {
|
||||
let r = s.run_depreciation(x, &format!("2026-{m:02}")).unwrap();
|
||||
if m == 3 {
|
||||
mar = r.entry_id.unwrap();
|
||||
}
|
||||
}
|
||||
s.reverse_entry(mar, d(2026, 6, 20), "reversed").unwrap();
|
||||
let p = s.dispose_preview(x, d(2026, 6, 15), usd(700_000)).unwrap();
|
||||
assert_eq!(p.catch_up_depreciation, Decimal::new(50_000, 2));
|
||||
s.dispose_asset(x, d(2026, 6, 15), usd(700_000), cash, gain)
|
||||
.unwrap();
|
||||
drop(s);
|
||||
let b = load(&dir.0, today());
|
||||
assert_eq!(b.asset_trail(x).posted(), Decimal::new(550_000, 2));
|
||||
let st: Vec<MonthStatus> = schedule(&b, x)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|(_, s)| s)
|
||||
.collect();
|
||||
assert_eq!(st[2], MonthStatus::Covered);
|
||||
assert!(!st.contains(&MonthStatus::Due));
|
||||
}
|
||||
|
||||
/// A command for [`prop_the_viewer_reads_an_asset_as_the_store_does`].
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum AssetOp {
|
||||
Run(u32),
|
||||
Reverse(usize, NaiveDate),
|
||||
Dispose(NaiveDate),
|
||||
}
|
||||
|
||||
/// The entries asset commands made, in journal order: runs (the disposal's
|
||||
/// final depreciation too), reversals, disposals.
|
||||
fn asset_entries(data: &Path) -> Vec<EntryId> {
|
||||
let text = std::fs::read_to_string(data.join(ledger_store::JOURNAL_FILE)).unwrap();
|
||||
text.lines()
|
||||
.filter_map(|l| {
|
||||
let v: serde_json::Value = serde_json::from_str(l).unwrap();
|
||||
let p = &v["payload"];
|
||||
match v["type"].as_str()? {
|
||||
"asset.depreciation_posted" | "asset.disposed" => p["entry_id"].as_u64(),
|
||||
"entry.reversed" => p["id"].as_u64(),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// An account's balance (debit positive) at the end of a day.
|
||||
fn balance_on(store: &Store, account: u64, on: NaiveDate) -> Decimal {
|
||||
let code = &store.ledger().chart().get(account).unwrap().code;
|
||||
ledger_core::reports::trial_balance_as_of(store.ledger(), on)
|
||||
.lines
|
||||
.iter()
|
||||
.find(|l| &l.code == code)
|
||||
.map_or(Decimal::ZERO, |l| l.debit - l.credit)
|
||||
}
|
||||
|
||||
fn arb_asset_op() -> impl Strategy<Value = AssetOp> {
|
||||
let date = (1u32..=6, 1u32..=31).prop_map(|(m, day)| {
|
||||
NaiveDate::from_ymd_opt(2026, m, day).unwrap_or_else(|| {
|
||||
ledger_core::schedules::month_end(NaiveDate::from_ymd_opt(2026, m, 1).unwrap())
|
||||
})
|
||||
});
|
||||
prop_oneof![
|
||||
4 => (1u32..=6).prop_map(AssetOp::Run),
|
||||
4 => (0usize..8, date.clone()).prop_map(|(n, on)| AssetOp::Reverse(n, on)),
|
||||
1 => date.prop_map(AssetOp::Dispose),
|
||||
]
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(48))]
|
||||
|
||||
/// P3 review round 2: whatever runs, reversals, re-runs,
|
||||
/// re-instatements and disposals the books hold, the viewer reads the
|
||||
/// asset as the store does. On July 15: what it says the books carry
|
||||
/// is the accumulated depreciation account (a disposed asset's, what
|
||||
/// its disposal took: the schedule through its day); each finished
|
||||
/// month is Posted by the entry a run of it says is already posted,
|
||||
/// Due exactly when a run of it would post something, and Covered
|
||||
/// otherwise; and what it says is unposted is what the schedule says
|
||||
/// through June less what the books carry then.
|
||||
#[test]
|
||||
fn prop_the_viewer_reads_an_asset_as_the_store_does(
|
||||
ops in prop::collection::vec(arb_asset_op(), 1..10)
|
||||
) {
|
||||
let (dir, data, mut s, x, (cash, gain)) = asset_x("viewer-orders");
|
||||
for op in &ops {
|
||||
let _ = match *op {
|
||||
AssetOp::Run(m) => s.run_depreciation(x, &format!("2026-{m:02}")).map(|_| ()),
|
||||
AssetOp::Reverse(n, on) => match asset_entries(&data).get(n) {
|
||||
Some(&e) => s.reverse_entry(e, on, "reversed").map(|_| ()),
|
||||
None => Ok(()),
|
||||
},
|
||||
AssetOp::Dispose(on) => s.dispose_asset(x, on, usd(100_000), cash, gain).map(|_| ()),
|
||||
};
|
||||
}
|
||||
let a = s.ledger().get_asset(x).unwrap().clone();
|
||||
let accum = a.accum_depr_account;
|
||||
let expense = a.expense_account;
|
||||
drop(s);
|
||||
let today = d(2026, 7, 15);
|
||||
let b = load(&dir.0, today);
|
||||
let trail = b.asset_trail(x);
|
||||
let s = Store::open(&data).unwrap();
|
||||
let disposed = a.status != ledger_core::AssetStatus::Active;
|
||||
if disposed {
|
||||
prop_assert_eq!(
|
||||
trail.posted(),
|
||||
ledger_core::accumulated_depreciation(&a, NaiveDate::MAX).unwrap()
|
||||
);
|
||||
prop_assert_eq!(balance_on(&s, accum, NaiveDate::MAX), Decimal::ZERO);
|
||||
} else {
|
||||
prop_assert_eq!(trail.posted(), -balance_on(&s, accum, NaiveDate::MAX));
|
||||
let june = d(2026, 6, 30);
|
||||
let owed = ledger_core::accumulated_depreciation(&a, june).unwrap()
|
||||
- balance_on(&s, expense, june);
|
||||
prop_assert_eq!(
|
||||
ledger_gui::pages::assets::unposted(&b, x).unwrap(),
|
||||
owed.max(Decimal::ZERO)
|
||||
);
|
||||
}
|
||||
drop(s);
|
||||
let lines = schedule(&b, x).unwrap();
|
||||
for (line, st) in lines.iter().filter(|(l, _)| l.period.as_str() < "2026-07") {
|
||||
// A run of the month, on a copy of the books.
|
||||
let copy = TempDir::new("viewer-orders-copy");
|
||||
let to = copy.0.join("books-data");
|
||||
std::fs::create_dir_all(&to).unwrap();
|
||||
std::fs::copy(data.join(ledger_store::JOURNAL_FILE), to.join(ledger_store::JOURNAL_FILE)).unwrap();
|
||||
let mut c = Store::open(&to).unwrap();
|
||||
let run = c.run_depreciation(x, &line.period);
|
||||
let want = match run {
|
||||
Ok(r) if r.already_posted => MonthStatus::Posted(r.entry_id.unwrap()),
|
||||
Ok(r) if r.entry_id.is_some() => MonthStatus::Due,
|
||||
Ok(_) => MonthStatus::Covered,
|
||||
Err(_) => {
|
||||
prop_assert!(disposed, "{} refused: {:?}", line.period, run);
|
||||
MonthStatus::Covered
|
||||
}
|
||||
};
|
||||
prop_assert_eq!(*st, want, "{} ({:?})", line.period, ops);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// P3 review round 2, in the viewer: a run re-instated after its month was
|
||||
/// run again and that second run reversed is the month's run, as the store
|
||||
/// reads it. Hand check: January run (A), reversed on Jan 31; run again
|
||||
/// (A2), reversed on Jan 31; A's reversal reversed on Jan 31: A stands.
|
||||
/// January is Posted(A), the books carry 1,000.00, and nothing is unposted
|
||||
/// but February through June (5,000.00). Before this review the viewer read
|
||||
/// January's latest run (A2, reversed): January Due, 0.00 carried.
|
||||
#[test]
|
||||
fn the_viewer_counts_a_run_reinstated_after_its_rerun_was_reversed() {
|
||||
let (dir, _data, mut s, x, _) = asset_x("reinstated-page");
|
||||
let jan31 = d(2026, 1, 31);
|
||||
let a = s.run_depreciation(x, "2026-01").unwrap().entry_id.unwrap();
|
||||
let b_rev = s.reverse_entry(a, jan31, "first doubt").unwrap();
|
||||
let a2 = s.run_depreciation(x, "2026-01").unwrap().entry_id.unwrap();
|
||||
s.reverse_entry(a2, jan31, "second doubt").unwrap();
|
||||
s.reverse_entry(b_rev, jan31, "the first was right")
|
||||
.unwrap();
|
||||
drop(s);
|
||||
let b = load(&dir.0, d(2026, 7, 15));
|
||||
assert_eq!(b.asset_trail(x).posted(), Decimal::new(100_000, 2));
|
||||
let st = schedule(&b, x).unwrap();
|
||||
assert_eq!(st[0].1, MonthStatus::Posted(a));
|
||||
assert_eq!(
|
||||
ledger_gui::pages::assets::unposted(&b, x).unwrap(),
|
||||
Decimal::new(500_000, 2)
|
||||
);
|
||||
}
|
||||
@@ -77,6 +77,7 @@ fn fixture(tag: &str) -> Fixture {
|
||||
months: 12,
|
||||
balance_account: prepaid,
|
||||
pl_account: insurance,
|
||||
convention: ledger_core::ScheduleConvention::MonthlyEven,
|
||||
})
|
||||
.unwrap();
|
||||
let sam = s.add_party("Sam", &[Role::Contractor]).unwrap();
|
||||
|
||||
Reference in new issue
Block a user