feat: prepaid/deferred amortization schedules and recurring entries
Amortization schedules spread a prepaid expense or deferred revenue over the months it covers, to the cent, posting on month ends; runs preview first, are idempotent, honor closed periods, and are exempt from the receipt policy like depreciation. Recurring entries generate drafts only, never posts. Six new tools (59 total); four new journal events, replayed and checked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
This commit is contained in:
14 files changed
+1691
-27
No files matched your search
@@ -1795,6 +1795,139 @@ impl Store {
|
||||
Ok((id, sha, dedup))
|
||||
}
|
||||
|
||||
/// Register an amortization schedule. Journaled as `schedule.registered`.
|
||||
pub fn register_schedule(
|
||||
&mut self,
|
||||
new: ledger_core::NewSchedule,
|
||||
) -> Result<ledger_core::ScheduleId, StoreError> {
|
||||
ledger_core::schedules::validate_schedule(&new, self.ledger.chart())?;
|
||||
let id = self.ledger.next_schedule_id();
|
||||
let line = self.append_journal(
|
||||
"schedule.registered",
|
||||
json!({
|
||||
"id": id,
|
||||
"description": new.description,
|
||||
"kind": serde_json::to_value(new.kind)?,
|
||||
"total": {"amount": new.total.amount.to_string(), "currency": new.total.currency.code()},
|
||||
"start": new.start.to_string(),
|
||||
"months": new.months,
|
||||
"balance_account": new.balance_account,
|
||||
"pl_account": new.pl_account,
|
||||
}),
|
||||
)?;
|
||||
let got = self.ledger.register_schedule(new)?;
|
||||
debug_assert_eq!(got, id);
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Post one month of a schedule. Journaled as `schedule.posted` with the
|
||||
/// whole entry, so a month is never half-posted or posted twice; replay
|
||||
/// re-derives the entry and checks it line by line.
|
||||
pub fn post_schedule_month(
|
||||
&mut self,
|
||||
id: ledger_core::ScheduleId,
|
||||
month_end: NaiveDate,
|
||||
) -> Result<EntryId, StoreError> {
|
||||
// Pure pre-validation, so the mutation below cannot fail.
|
||||
let (date, _, _) = self.ledger.preview_schedule_month(id, month_end)?;
|
||||
if let Some(p) = self.ledger.closed_period_containing(date) {
|
||||
return Err(LedgerError::ClosedPeriod(p, date.to_string()).into());
|
||||
}
|
||||
let entry = self.ledger.post_schedule_month(id, month_end)?;
|
||||
let line = self.append_journal(
|
||||
"schedule.posted",
|
||||
json!({
|
||||
"schedule_id": id,
|
||||
"month": date.to_string(),
|
||||
"entry": entry_detail_json(&self.ledger, entry),
|
||||
}),
|
||||
)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
/// Post every schedule's due months through `through`, oldest first.
|
||||
/// Returns (schedule, month, entry) for each.
|
||||
pub fn run_schedules(
|
||||
&mut self,
|
||||
through: NaiveDate,
|
||||
) -> Result<Vec<(ledger_core::ScheduleId, NaiveDate, EntryId)>, StoreError> {
|
||||
let mut due: Vec<(NaiveDate, ledger_core::ScheduleId)> = self
|
||||
.ledger
|
||||
.schedules()
|
||||
.flat_map(|s| s.due(through).into_iter().map(move |(d, _)| (d, s.id)))
|
||||
.collect();
|
||||
due.sort();
|
||||
let mut out = Vec::with_capacity(due.len());
|
||||
for (month, id) in due {
|
||||
out.push((id, month, self.post_schedule_month(id, month)?));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Register a recurring entry. Journaled as `recurring.registered`.
|
||||
pub fn register_recurring(
|
||||
&mut self,
|
||||
new: ledger_core::NewRecurring,
|
||||
) -> Result<ledger_core::RecurringId, StoreError> {
|
||||
ledger_core::schedules::validate_recurring(&new, self.ledger.chart())?;
|
||||
let id = self.ledger.next_recurring_id();
|
||||
let lines: Vec<Value> = new
|
||||
.lines
|
||||
.iter()
|
||||
.map(|(account, side, money)| {
|
||||
json!({
|
||||
"account": account,
|
||||
"side": if *side == Side::Debit { "debit" } else { "credit" },
|
||||
"amount": money.amount.to_string(),
|
||||
"currency": money.currency.code(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let line = self.append_journal(
|
||||
"recurring.registered",
|
||||
json!({
|
||||
"id": id,
|
||||
"description": new.description,
|
||||
"frequency": serde_json::to_value(new.frequency)?,
|
||||
"start": new.start.to_string(),
|
||||
"end": new.end.map(|e| e.to_string()),
|
||||
"lines": lines,
|
||||
}),
|
||||
)?;
|
||||
let got = self.ledger.register_recurring(new)?;
|
||||
debug_assert_eq!(got, id);
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Generate every recurring entry's due occurrences through `through`
|
||||
/// as drafts. Each journaled as `recurring.generated`. Returns
|
||||
/// (recurring entry, date, draft) for each.
|
||||
pub fn run_recurring(
|
||||
&mut self,
|
||||
through: NaiveDate,
|
||||
) -> Result<Vec<(ledger_core::RecurringId, NaiveDate, EntryId)>, StoreError> {
|
||||
let mut due: Vec<(NaiveDate, ledger_core::RecurringId)> = self
|
||||
.ledger
|
||||
.recurring_entries()
|
||||
.flat_map(|r| r.due(through).into_iter().map(move |d| (d, r.id)))
|
||||
.collect();
|
||||
due.sort();
|
||||
let mut out = Vec::with_capacity(due.len());
|
||||
for (date, id) in due {
|
||||
let entry = self.ledger.generate_recurring(id, date)?;
|
||||
let line = self.append_journal(
|
||||
"recurring.generated",
|
||||
json!({"recurring_id": id, "date": date.to_string(), "entry_id": entry}),
|
||||
)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
out.push((id, date, entry));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Set (or clear) an account's tax category and deductible percent.
|
||||
/// Journaled as `account.tax_set`.
|
||||
pub fn set_account_tax(
|
||||
@@ -2742,6 +2875,79 @@ fn replay_line_into_ledger(
|
||||
)));
|
||||
}
|
||||
}
|
||||
"schedule.registered" => {
|
||||
let want = get_u64(p, "id")?;
|
||||
let got = ledger.register_schedule(ledger_core::NewSchedule {
|
||||
description: get_str(p, "description")?.to_string(),
|
||||
kind: serde_json::from_value(p["kind"].clone())?,
|
||||
total: parse_money(p, "total")?,
|
||||
start: parse_date(get_str(p, "start")?)?,
|
||||
months: get_u64(p, "months")? as u32,
|
||||
balance_account: get_u64(p, "balance_account")?,
|
||||
pl_account: get_u64(p, "pl_account")?,
|
||||
})?;
|
||||
if got != want {
|
||||
return Err(StoreError::ReplayMismatch(format!(
|
||||
"schedule id: journal says {want}, replay got {got}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
"schedule.posted" => {
|
||||
let got = ledger.post_schedule_month(
|
||||
get_u64(p, "schedule_id")?,
|
||||
parse_date(get_str(p, "month")?)?,
|
||||
)?;
|
||||
check_replayed_entry(ledger, got, &p["entry"])?;
|
||||
}
|
||||
"recurring.registered" => {
|
||||
let want = get_u64(p, "id")?;
|
||||
let lines = p["lines"]
|
||||
.as_array()
|
||||
.ok_or_else(|| StoreError::JournalCorrupt("recurring lines".into()))?
|
||||
.iter()
|
||||
.map(|l| {
|
||||
Ok((
|
||||
get_u64(l, "account")?,
|
||||
parse_side(get_str(l, "side")?)?,
|
||||
Money::new(
|
||||
get_str(l, "amount")?.parse().map_err(|_| {
|
||||
StoreError::JournalCorrupt("recurring amount".into())
|
||||
})?,
|
||||
Currency::new(get_str(l, "currency")?).map_err(|_| {
|
||||
StoreError::JournalCorrupt("recurring currency".into())
|
||||
})?,
|
||||
),
|
||||
))
|
||||
})
|
||||
.collect::<Result<Vec<_>, StoreError>>()?;
|
||||
let got = ledger.register_recurring(ledger_core::NewRecurring {
|
||||
description: get_str(p, "description")?.to_string(),
|
||||
lines,
|
||||
frequency: serde_json::from_value(p["frequency"].clone())?,
|
||||
start: parse_date(get_str(p, "start")?)?,
|
||||
end: match p.get("end").and_then(|v| v.as_str()) {
|
||||
Some(e) => Some(parse_date(e)?),
|
||||
None => None,
|
||||
},
|
||||
})?;
|
||||
if got != want {
|
||||
return Err(StoreError::ReplayMismatch(format!(
|
||||
"recurring id: journal says {want}, replay got {got}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
"recurring.generated" => {
|
||||
let want = get_u64(p, "entry_id")?;
|
||||
let got = ledger.generate_recurring(
|
||||
get_u64(p, "recurring_id")?,
|
||||
parse_date(get_str(p, "date")?)?,
|
||||
)?;
|
||||
if got != want {
|
||||
return Err(StoreError::ReplayMismatch(format!(
|
||||
"recurring draft: journal says {want}, replay got {got}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
"account.tax_set" => {
|
||||
let pct = match p.get("deductible_percent").and_then(|v| v.as_str()) {
|
||||
Some(s) => Some(s.parse().map_err(|_| {
|
||||
@@ -3034,6 +3240,76 @@ fn close_to_from_journal(v: Option<&Value>) -> Result<CloseTo, StoreError> {
|
||||
))
|
||||
}
|
||||
|
||||
/// Record a posted entry that rides inside another event's payload (a
|
||||
/// closing entry, a schedule posting) in the projection, like any posted
|
||||
/// entry.
|
||||
fn project_posted_entry(conn: &Connection, e: &Value) -> Result<(), StoreError> {
|
||||
let eid = get_u64(e, "id")? as i64;
|
||||
conn.execute(
|
||||
"INSERT INTO entries(id, date, description, status, reversal_of) VALUES (?,?,?,'posted',NULL)",
|
||||
params![eid, get_str(e, "date")?, get_str(e, "description")?],
|
||||
)?;
|
||||
for l in e
|
||||
.get("lines")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| StoreError::JournalCorrupt("posted entry without lines".into()))?
|
||||
{
|
||||
conn.execute(
|
||||
"INSERT INTO lines(entry_id, account_id, side, amount, currency, foreign_currency, foreign_amount, class_id) VALUES (?,?,?,?,?,?,?,?)",
|
||||
params![
|
||||
eid,
|
||||
get_u64(l, "account")? as i64,
|
||||
get_str(l, "side")?,
|
||||
get_str(l, "amount")?,
|
||||
get_str(l, "currency")?,
|
||||
l.get("foreign_currency").and_then(|v| v.as_str()),
|
||||
l.get("foreign_amount").and_then(|v| v.as_str()),
|
||||
l.get("class_id").and_then(|v| v.as_u64()).map(|v| v as i64),
|
||||
],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The full detail of a posted entry, for events that carry one.
|
||||
fn entry_detail_json(ledger: &Ledger, id: EntryId) -> Value {
|
||||
let e = ledger.get_entry(id).expect("entry just posted");
|
||||
json!({
|
||||
"id": id,
|
||||
"date": e.date().to_string(),
|
||||
"description": e.description(),
|
||||
"lines": e.lines().iter().map(journal_line_json).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
/// On replay, an entry re-derived from master data must match the journal
|
||||
/// line for line.
|
||||
fn check_replayed_entry(
|
||||
ledger: &Ledger,
|
||||
got: EntryId,
|
||||
journaled: &Value,
|
||||
) -> Result<(), StoreError> {
|
||||
let want = get_u64(journaled, "id")?;
|
||||
if got != want {
|
||||
return Err(StoreError::ReplayMismatch(format!(
|
||||
"entry id: journal says {want}, replay got {got}"
|
||||
)));
|
||||
}
|
||||
let lines: Vec<Value> = ledger
|
||||
.get_entry(got)
|
||||
.expect("replay posted it")
|
||||
.lines()
|
||||
.iter()
|
||||
.map(journal_line_json)
|
||||
.collect();
|
||||
if journaled["lines"].as_array() != Some(&lines) {
|
||||
return Err(StoreError::ReplayMismatch(format!(
|
||||
"entry {got}: replayed lines differ from the journal"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), StoreError> {
|
||||
let p = &line.payload;
|
||||
match line.line_type.as_str() {
|
||||
@@ -3158,6 +3434,10 @@ fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), Sto
|
||||
],
|
||||
)?;
|
||||
}
|
||||
"schedule.registered" | "recurring.registered" | "recurring.generated" => {
|
||||
// Master data and drafts: the ledger holds them; no report rows.
|
||||
}
|
||||
"schedule.posted" => project_posted_entry(conn, &p["entry"])?,
|
||||
"receipt.unlinked" => {
|
||||
conn.execute(
|
||||
"UPDATE attachments SET entry_id = NULL WHERE id = ?",
|
||||
@@ -3237,29 +3517,7 @@ fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), Sto
|
||||
// posted entry so the projection stays complete, then mark the
|
||||
// period row closed.
|
||||
if !p.get("entry").map(|v| v.is_null()).unwrap_or(true) {
|
||||
let e = &p["entry"];
|
||||
let eid = get_u64(e, "id")? as i64;
|
||||
conn.execute(
|
||||
"INSERT INTO entries(id, date, description, status, reversal_of) VALUES (?,?,?,'posted',NULL)",
|
||||
params![eid, get_str(e, "date")?, get_str(e, "description")?],
|
||||
)?;
|
||||
for l in e.get("lines").and_then(|v| v.as_array()).ok_or_else(|| {
|
||||
StoreError::JournalCorrupt("period.closed entry without lines".into())
|
||||
})? {
|
||||
conn.execute(
|
||||
"INSERT INTO lines(entry_id, account_id, side, amount, currency, foreign_currency, foreign_amount, class_id) VALUES (?,?,?,?,?,?,?,?)",
|
||||
params![
|
||||
eid,
|
||||
get_u64(l, "account")? as i64,
|
||||
get_str(l, "side")?,
|
||||
get_str(l, "amount")?,
|
||||
get_str(l, "currency")?,
|
||||
l.get("foreign_currency").and_then(|v| v.as_str()),
|
||||
l.get("foreign_amount").and_then(|v| v.as_str()),
|
||||
l.get("class_id").and_then(|v| v.as_u64()).map(|v| v as i64),
|
||||
],
|
||||
)?;
|
||||
}
|
||||
project_posted_entry(conn, &p["entry"])?;
|
||||
}
|
||||
conn.execute(
|
||||
"UPDATE periods SET status = 'closed', closing_entry = ? WHERE id = ?",
|
||||
|
||||
@@ -537,3 +537,78 @@ fn a_close_into_members_capital_replays_exactly() {
|
||||
assert_eq!(balances(&store), want);
|
||||
store.verify().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedules_and_recurring_entries_replay_exactly() {
|
||||
let dir = test_dir("schedules");
|
||||
let mut store = Store::create(&dir, Currency::USD).unwrap();
|
||||
let cash = store
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let prepaid = store
|
||||
.add_account("1300", "Prepaid", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let ins = store
|
||||
.add_account("6300", "Insurance", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let rent = store
|
||||
.add_account("6600", "Rent", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let date = |m, d| NaiveDate::from_ymd_opt(2026, m, d).unwrap();
|
||||
let usd_s = |s: &str| Money::new(s.parse().unwrap(), Currency::USD);
|
||||
store
|
||||
.register_schedule(ledger_core::NewSchedule {
|
||||
description: "Insurance".into(),
|
||||
kind: ledger_core::ScheduleKind::PrepaidExpense,
|
||||
total: usd_s("1000"),
|
||||
start: date(1, 1),
|
||||
months: 3,
|
||||
balance_account: prepaid,
|
||||
pl_account: ins,
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
.register_recurring(ledger_core::NewRecurring {
|
||||
description: "Rent".into(),
|
||||
lines: vec![
|
||||
(rent, Side::Debit, usd_s("500")),
|
||||
(cash, Side::Credit, usd_s("500")),
|
||||
],
|
||||
frequency: ledger_core::Frequency::Monthly,
|
||||
start: date(1, 1),
|
||||
end: None,
|
||||
})
|
||||
.unwrap();
|
||||
// 333.33, 333.33 through February; rent drafts for Jan 1 and Feb 1.
|
||||
let posted = store.run_schedules(date(2, 28)).unwrap();
|
||||
assert_eq!(posted.len(), 2);
|
||||
let drafts = store.run_recurring(date(2, 28)).unwrap();
|
||||
assert_eq!(
|
||||
drafts.iter().map(|d| d.1).collect::<Vec<_>>(),
|
||||
[date(1, 1), date(2, 1)]
|
||||
);
|
||||
store.post_entry(drafts[0].2).unwrap();
|
||||
let state = |s: &Store| {
|
||||
(
|
||||
s.ledger().account_balance(ins).unwrap(),
|
||||
s.ledger().account_balance(rent).unwrap(),
|
||||
s.ledger().get_entry(drafts[1].2).unwrap().status(),
|
||||
)
|
||||
};
|
||||
let want = (
|
||||
Decimal::new(66666, 2),
|
||||
Decimal::new(500, 0),
|
||||
ledger_core::EntryStatus::Draft,
|
||||
);
|
||||
assert_eq!(state(&store), want);
|
||||
|
||||
drop(store);
|
||||
let mut store = Store::open(&dir).unwrap();
|
||||
assert_eq!(state(&store), want);
|
||||
store.rebuild().unwrap();
|
||||
assert_eq!(state(&store), want);
|
||||
store.verify().unwrap();
|
||||
// Idempotent after replay: nothing new is due through February.
|
||||
assert!(store.run_schedules(date(2, 28)).unwrap().is_empty());
|
||||
assert!(store.run_recurring(date(2, 28)).unwrap().is_empty());
|
||||
}
|
||||
Reference in new issue
Block a user