asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs
This commit is contained in:
1 parent
5054268474
commit
3790b4e0b6
1 file changed
+617
-2
@@ -10,11 +10,13 @@ use crate::blob::BlobStore;
|
||||
use crate::error::StoreError;
|
||||
use crate::journal::{JournalLine, JournalWriter, read_and_verify_journal};
|
||||
use crate::schema;
|
||||
use chrono::{Months, NaiveDate};
|
||||
use ledger_core::{
|
||||
AccountId, AccountType, Currency, EntryId, EntryStatus, Ledger, LedgerError, Money, Side,
|
||||
reports,
|
||||
AccountId, AccountType, AssetId, AssetStatus, Currency, EntryId, EntryStatus, Ledger,
|
||||
LedgerError, Money, NewAsset, Side, reports,
|
||||
};
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -50,6 +52,45 @@ pub struct EventRow {
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// The outcome of posting one month of depreciation for one asset.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DepreciationRun {
|
||||
/// Asset id.
|
||||
pub asset_id: u64,
|
||||
/// Calendar month, "YYYY-MM".
|
||||
pub period: String,
|
||||
/// Amount posted, exact cents.
|
||||
pub amount: Decimal,
|
||||
/// The posted entry's id.
|
||||
pub entry_id: u64,
|
||||
/// True when this period was already posted (idempotent no-op).
|
||||
pub already_posted: bool,
|
||||
}
|
||||
|
||||
/// The outcome of disposing an asset, computed without posting anything.
|
||||
/// The MCP layer previews this; [`Store::dispose_asset`] posts it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DisposalPreview {
|
||||
/// Asset id.
|
||||
pub asset_id: u64,
|
||||
/// Disposal date.
|
||||
pub date: chrono::NaiveDate,
|
||||
/// Proceeds.
|
||||
pub proceeds: Money,
|
||||
/// Accumulated depreciation that will be cleared: already posted plus
|
||||
/// the final partial period.
|
||||
pub accumulated: Decimal,
|
||||
/// Depreciation auto-posted for the final partial period. Zero when the
|
||||
/// disposal month was already posted.
|
||||
pub final_depreciation: Decimal,
|
||||
/// Book value at disposal.
|
||||
pub book_value: Decimal,
|
||||
/// proceeds − book_value. Positive is a gain.
|
||||
pub gain_loss: Decimal,
|
||||
/// The disposal month, "YYYY-MM".
|
||||
pub period: String,
|
||||
}
|
||||
|
||||
/// Attachment metadata (bytes live in the blob store).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Attachment {
|
||||
@@ -449,6 +490,377 @@ impl Store {
|
||||
Ok(live)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Fixed assets
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// Register a fixed asset. Journaled as `asset.registered`.
|
||||
pub fn register_asset(&mut self, new: NewAsset) -> Result<AssetId, StoreError> {
|
||||
// Pure pre-validation so the journal write cannot be followed by a
|
||||
// failed mutation.
|
||||
self.ledger.validate_new_asset(&new)?;
|
||||
let id = self.ledger.next_asset_id();
|
||||
let line = self.append_journal(
|
||||
"asset.registered",
|
||||
json!({
|
||||
"id": id,
|
||||
"description": new.description,
|
||||
"purchase_date": new.purchase_date.to_string(),
|
||||
"cost": { "amount": new.cost.amount.to_string(), "currency": new.cost.currency.code() },
|
||||
"salvage_value": { "amount": new.salvage_value.amount.to_string(), "currency": new.salvage_value.currency.code() },
|
||||
"useful_life_months": new.useful_life_months,
|
||||
"method": "straight_line",
|
||||
"cost_account": new.cost_account,
|
||||
"accum_depr_account": new.accum_depr_account,
|
||||
"expense_account": new.expense_account,
|
||||
}),
|
||||
)?;
|
||||
let live = self.ledger.register_asset(new)?;
|
||||
debug_assert_eq!(live, id, "id allocator diverged from journal");
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(live)
|
||||
}
|
||||
|
||||
/// Was depreciation already posted for this asset and period?
|
||||
fn depreciation_posted_entry(
|
||||
&self,
|
||||
asset_id: AssetId,
|
||||
period: &str,
|
||||
) -> Result<Option<(u64, Decimal)>, StoreError> {
|
||||
let row: Option<(i64, String)> = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT entry_id, amount FROM asset_depreciation WHERE asset_id = ? AND period = ?",
|
||||
params![asset_id as i64, period],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.optional()?;
|
||||
row.map(|(eid, amount)| {
|
||||
let amount = amount
|
||||
.parse()
|
||||
.map_err(|_| StoreError::JournalCorrupt(format!("bad amount {amount}")))?;
|
||||
Ok((eid as u64, amount))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// Sum of depreciation amounts posted for an asset. Summed in Rust with
|
||||
/// exact Decimal math: amounts are TEXT in SQLite, so SQLite must not
|
||||
/// sum them (REAL would lose pennies).
|
||||
fn asset_depreciation_total(&self, asset_id: AssetId) -> Result<Decimal, StoreError> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT amount FROM asset_depreciation WHERE asset_id = ?")?;
|
||||
let amounts = stmt.query_map(params![asset_id as i64], |r| r.get::<_, String>(0))?;
|
||||
let mut total = Decimal::ZERO;
|
||||
for a in amounts {
|
||||
let s: String = a?;
|
||||
total += s
|
||||
.parse::<Decimal>()
|
||||
.map_err(|_| StoreError::JournalCorrupt(format!("bad amount {s}")))?;
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// Post one month of depreciation for one asset. Idempotent: running it
|
||||
/// twice for the same period returns the original entry.
|
||||
///
|
||||
/// Depreciation is an allocation, not money movement, so it is exempt
|
||||
/// from the receipt policy by construction: the registered asset and its
|
||||
/// schedule are the supporting documentation.
|
||||
pub fn run_depreciation(
|
||||
&mut self,
|
||||
asset_id: AssetId,
|
||||
period: &str,
|
||||
) -> Result<DepreciationRun, StoreError> {
|
||||
if let Some((entry_id, amount)) = self.depreciation_posted_entry(asset_id, period)? {
|
||||
return Ok(DepreciationRun {
|
||||
asset_id,
|
||||
period: period.to_string(),
|
||||
amount,
|
||||
entry_id,
|
||||
already_posted: true,
|
||||
});
|
||||
}
|
||||
let asset = self
|
||||
.ledger
|
||||
.get_asset(asset_id)
|
||||
.ok_or(LedgerError::UnknownAsset(asset_id))?
|
||||
.clone();
|
||||
if asset.status != AssetStatus::Active {
|
||||
return Err(LedgerError::AssetAlreadyDisposed(asset_id).into());
|
||||
}
|
||||
let (year, month) = parse_period(period)?;
|
||||
let period_end = last_day_of_month(year, month);
|
||||
let sched = ledger_core::depreciation_schedule(&asset, period_end);
|
||||
let line = sched.iter().find(|l| l.period == period).ok_or_else(|| {
|
||||
StoreError::JournalCorrupt(format!(
|
||||
"period {period} is outside asset {asset_id}'s depreciable life"
|
||||
))
|
||||
})?;
|
||||
let amount = line.amount;
|
||||
debug_assert!(amount > Decimal::ZERO);
|
||||
|
||||
let entry_id = self.create_entry(
|
||||
period_end,
|
||||
&format!("Depreciation — {} — {period}", asset.description),
|
||||
)?;
|
||||
self.add_line(
|
||||
entry_id,
|
||||
asset.expense_account,
|
||||
Side::Debit,
|
||||
Money::new(amount, self.base_currency),
|
||||
)?;
|
||||
self.add_line(
|
||||
entry_id,
|
||||
asset.accum_depr_account,
|
||||
Side::Credit,
|
||||
Money::new(amount, self.base_currency),
|
||||
)?;
|
||||
self.post_entry(entry_id)?;
|
||||
let dline = self.append_journal(
|
||||
"asset.depreciation_posted",
|
||||
json!({
|
||||
"asset_id": asset_id,
|
||||
"period": period,
|
||||
"entry_id": entry_id,
|
||||
"amount": amount.to_string(),
|
||||
}),
|
||||
)?;
|
||||
apply_line_to_sqlite(&self.conn, &dline)?;
|
||||
Ok(DepreciationRun {
|
||||
asset_id,
|
||||
period: period.to_string(),
|
||||
amount,
|
||||
entry_id,
|
||||
already_posted: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Compute a disposal without posting. Pure reads plus the posted-vs-
|
||||
/// scheduled reconciliation that [`Store::dispose_asset`] performs.
|
||||
pub fn dispose_preview(
|
||||
&self,
|
||||
asset_id: AssetId,
|
||||
date: chrono::NaiveDate,
|
||||
proceeds: Money,
|
||||
) -> Result<DisposalPreview, StoreError> {
|
||||
let asset = self
|
||||
.ledger
|
||||
.get_asset(asset_id)
|
||||
.ok_or(LedgerError::UnknownAsset(asset_id))?;
|
||||
if asset.status != AssetStatus::Active {
|
||||
return Err(LedgerError::AssetAlreadyDisposed(asset_id).into());
|
||||
}
|
||||
if proceeds.amount < Decimal::ZERO {
|
||||
return Err(LedgerError::NegativeProceeds(proceeds.amount).into());
|
||||
}
|
||||
if proceeds.currency != self.base_currency {
|
||||
return Err(LedgerError::CurrencyMismatch {
|
||||
expected: self.base_currency.code().to_string(),
|
||||
found: proceeds.currency.code().to_string(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
if date < asset.purchase_date {
|
||||
return Err(LedgerError::DisposalBeforePurchase(date.to_string()).into());
|
||||
}
|
||||
let period = date.format("%Y-%m").to_string();
|
||||
let posted = self.asset_depreciation_total(asset_id)?;
|
||||
let target = ledger_core::accumulated_depreciation(asset, date);
|
||||
let already = self.depreciation_posted_entry(asset_id, &period)?.is_some();
|
||||
let final_depreciation = if already {
|
||||
Decimal::ZERO
|
||||
} else {
|
||||
(target - posted).max(Decimal::ZERO)
|
||||
};
|
||||
let accumulated = posted + final_depreciation;
|
||||
let book_value = asset.cost.amount - accumulated;
|
||||
Ok(DisposalPreview {
|
||||
asset_id,
|
||||
date,
|
||||
proceeds,
|
||||
accumulated,
|
||||
final_depreciation,
|
||||
book_value,
|
||||
gain_loss: proceeds.amount - book_value,
|
||||
period,
|
||||
})
|
||||
}
|
||||
|
||||
/// Dispose of an asset: auto-post any unposted depreciation through the
|
||||
/// disposal date, then post the disposal entry (clear cost and
|
||||
/// accumulated depreciation, record proceeds and gain/loss) and mark
|
||||
/// the asset disposed. Returns the disposal entry's id.
|
||||
///
|
||||
/// The auto-post matters: the disposal entry must clear exactly the
|
||||
/// accumulated depreciation the books actually carry, not the schedule's
|
||||
/// theoretical number. If the agent already ran depreciation for the
|
||||
/// disposal month, that posted amount is the books' truth.
|
||||
pub fn dispose_asset(
|
||||
&mut self,
|
||||
asset_id: AssetId,
|
||||
date: chrono::NaiveDate,
|
||||
proceeds: Money,
|
||||
proceeds_account: AccountId,
|
||||
gain_loss_account: AccountId,
|
||||
) -> Result<EntryId, StoreError> {
|
||||
let asset = self
|
||||
.ledger
|
||||
.get_asset(asset_id)
|
||||
.ok_or(LedgerError::UnknownAsset(asset_id))?
|
||||
.clone();
|
||||
// The receiving account holds cash (or a receivable): an asset.
|
||||
// The gain/loss account is a revenue or expense account.
|
||||
for (id, role) in [
|
||||
(proceeds_account, "proceeds"),
|
||||
(gain_loss_account, "gain/loss"),
|
||||
] {
|
||||
let a = self
|
||||
.ledger
|
||||
.chart()
|
||||
.get(id)
|
||||
.ok_or(LedgerError::UnknownAccount(id))?;
|
||||
if a.placeholder {
|
||||
return Err(LedgerError::PlaceholderAccount(a.code.clone()).into());
|
||||
}
|
||||
let ok = match role {
|
||||
"proceeds" => a.account_type == AccountType::Asset,
|
||||
_ => {
|
||||
a.account_type == AccountType::Revenue || a.account_type == AccountType::Expense
|
||||
}
|
||||
};
|
||||
if !ok {
|
||||
return Err(LedgerError::AssetAccountWrongType {
|
||||
code: a.code.clone(),
|
||||
role: "disposal",
|
||||
expected: if role == "proceeds" {
|
||||
"asset"
|
||||
} else {
|
||||
"revenue or expense"
|
||||
},
|
||||
found: match a.account_type {
|
||||
AccountType::Asset => "asset",
|
||||
AccountType::Liability => "liability",
|
||||
AccountType::Equity => "equity",
|
||||
AccountType::Revenue => "revenue",
|
||||
AccountType::Expense => "expense",
|
||||
},
|
||||
}
|
||||
.into());
|
||||
}
|
||||
}
|
||||
if proceeds.amount < Decimal::ZERO {
|
||||
return Err(LedgerError::NegativeProceeds(proceeds.amount).into());
|
||||
}
|
||||
if proceeds.currency != self.base_currency {
|
||||
return Err(LedgerError::CurrencyMismatch {
|
||||
expected: self.base_currency.code().to_string(),
|
||||
found: proceeds.currency.code().to_string(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
if asset.status != AssetStatus::Active {
|
||||
return Err(LedgerError::AssetAlreadyDisposed(asset_id).into());
|
||||
}
|
||||
if date < asset.purchase_date {
|
||||
return Err(LedgerError::DisposalBeforePurchase(date.to_string()).into());
|
||||
}
|
||||
let base = self.base_currency;
|
||||
|
||||
// One source of truth for the math: the preview computes the
|
||||
// posted-vs-scheduled reconciliation; posting follows it.
|
||||
let preview = self.dispose_preview(asset_id, date, proceeds)?;
|
||||
let accumulated = preview.accumulated;
|
||||
let gain_loss = preview.gain_loss;
|
||||
if preview.final_depreciation > Decimal::ZERO {
|
||||
let diff = preview.final_depreciation;
|
||||
let eid = self.create_entry(
|
||||
date,
|
||||
&format!(
|
||||
"Depreciation — {} — {} (final)",
|
||||
asset.description, preview.period
|
||||
),
|
||||
)?;
|
||||
self.add_line(
|
||||
eid,
|
||||
asset.expense_account,
|
||||
Side::Debit,
|
||||
Money::new(diff, base),
|
||||
)?;
|
||||
self.add_line(
|
||||
eid,
|
||||
asset.accum_depr_account,
|
||||
Side::Credit,
|
||||
Money::new(diff, base),
|
||||
)?;
|
||||
self.post_entry(eid)?;
|
||||
let dline = self.append_journal(
|
||||
"asset.depreciation_posted",
|
||||
json!({
|
||||
"asset_id": asset_id,
|
||||
"period": preview.period,
|
||||
"entry_id": eid,
|
||||
"amount": diff.to_string(),
|
||||
}),
|
||||
)?;
|
||||
apply_line_to_sqlite(&self.conn, &dline)?;
|
||||
}
|
||||
|
||||
let entry_id = self.create_entry(
|
||||
date,
|
||||
&format!("Disposal — {} — {}", asset.description, date),
|
||||
)?;
|
||||
self.add_line(
|
||||
entry_id,
|
||||
asset.accum_depr_account,
|
||||
Side::Debit,
|
||||
Money::new(accumulated, base),
|
||||
)?;
|
||||
self.add_line(
|
||||
entry_id,
|
||||
proceeds_account,
|
||||
Side::Debit,
|
||||
Money::new(proceeds.amount, base),
|
||||
)?;
|
||||
self.add_line(
|
||||
entry_id,
|
||||
asset.cost_account,
|
||||
Side::Credit,
|
||||
Money::new(asset.cost.amount, base),
|
||||
)?;
|
||||
if gain_loss > Decimal::ZERO {
|
||||
self.add_line(
|
||||
entry_id,
|
||||
gain_loss_account,
|
||||
Side::Credit,
|
||||
Money::new(gain_loss, base),
|
||||
)?;
|
||||
} else if gain_loss < Decimal::ZERO {
|
||||
self.add_line(
|
||||
entry_id,
|
||||
gain_loss_account,
|
||||
Side::Debit,
|
||||
Money::new(-gain_loss, base),
|
||||
)?;
|
||||
}
|
||||
self.post_entry(entry_id)?;
|
||||
let line = self.append_journal(
|
||||
"asset.disposed",
|
||||
json!({
|
||||
"asset_id": asset_id,
|
||||
"date": date.to_string(),
|
||||
"proceeds": { "amount": proceeds.amount.to_string(), "currency": proceeds.currency.code() },
|
||||
"proceeds_account": proceeds_account,
|
||||
"gain_loss_account": gain_loss_account,
|
||||
"entry_id": entry_id,
|
||||
}),
|
||||
)?;
|
||||
self.ledger.dispose_asset(asset_id, date, proceeds)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(entry_id)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Blobs and attachments
|
||||
// ------------------------------------------------------------------
|
||||
@@ -690,6 +1102,16 @@ impl Store {
|
||||
"sqlite projection diverged from journal".into(),
|
||||
));
|
||||
}
|
||||
// The accounting snapshot ignores the asset register, so compare it
|
||||
// explicitly: journal replay, live ledger, and projection must agree.
|
||||
let assets_of = |l: &Ledger| l.registered_assets().cloned().collect::<Vec<_>>();
|
||||
let (ra, rb, rc) = (assets_of(&replayed), assets_of(&self.ledger), assets_of(&c));
|
||||
if ra != rb || ra != rc {
|
||||
return Err(StoreError::VerifyMismatch(
|
||||
"asset register diverged between journal, live ledger, and sqlite projection"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -834,6 +1256,89 @@ impl Store {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Assets: registration order is id order; disposal is a per-row flag.
|
||||
let mut astmt = self.conn.prepare(
|
||||
"SELECT id, description, purchase_date, cost_amount, cost_currency, salvage_amount, salvage_currency, useful_life_months, method, cost_account, accum_depr_account, expense_account, status, disposed_date, proceeds_amount FROM assets ORDER BY id",
|
||||
)?;
|
||||
let assets = astmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
r.get::<_, String>(3)?,
|
||||
r.get::<_, String>(4)?,
|
||||
r.get::<_, String>(5)?,
|
||||
r.get::<_, String>(6)?,
|
||||
r.get::<_, i64>(7)?,
|
||||
r.get::<_, String>(8)?,
|
||||
r.get::<_, i64>(9)?,
|
||||
r.get::<_, i64>(10)?,
|
||||
r.get::<_, i64>(11)?,
|
||||
r.get::<_, String>(12)?,
|
||||
r.get::<_, Option<String>>(13)?,
|
||||
r.get::<_, Option<String>>(14)?,
|
||||
))
|
||||
})?;
|
||||
for a in assets {
|
||||
let (
|
||||
id,
|
||||
description,
|
||||
purchase_date,
|
||||
cost_amount,
|
||||
cost_currency,
|
||||
salvage_amount,
|
||||
salvage_currency,
|
||||
life_months,
|
||||
method,
|
||||
cost_account,
|
||||
accum_depr_account,
|
||||
expense_account,
|
||||
status,
|
||||
disposed_date,
|
||||
proceeds_amount,
|
||||
) = a?;
|
||||
if method != "straight_line" {
|
||||
return Err(StoreError::JournalCorrupt(format!(
|
||||
"unknown method {method}"
|
||||
)));
|
||||
}
|
||||
let parse_amt = |s: &str, c: &str| -> Result<Money, StoreError> {
|
||||
Ok(Money::new(
|
||||
s.parse()
|
||||
.map_err(|_| StoreError::JournalCorrupt(format!("bad amount {s}")))?,
|
||||
Currency::new(c)
|
||||
.map_err(|_| StoreError::JournalCorrupt(format!("bad currency {c}")))?,
|
||||
))
|
||||
};
|
||||
let got = ledger.register_asset(NewAsset {
|
||||
description,
|
||||
purchase_date: parse_date(&purchase_date)?,
|
||||
cost: parse_amt(&cost_amount, &cost_currency)?,
|
||||
salvage_value: parse_amt(&salvage_amount, &salvage_currency)?,
|
||||
useful_life_months: life_months as u32,
|
||||
cost_account: cost_account as u64,
|
||||
accum_depr_account: accum_depr_account as u64,
|
||||
expense_account: expense_account as u64,
|
||||
})?;
|
||||
if got != id as u64 {
|
||||
return Err(StoreError::ReplayMismatch(format!(
|
||||
"asset id: sqlite says {id}, replay got {got}"
|
||||
)));
|
||||
}
|
||||
if status == "disposed" {
|
||||
let date = disposed_date
|
||||
.as_deref()
|
||||
.ok_or_else(|| StoreError::JournalCorrupt("disposed without date".into()))?;
|
||||
let proceeds = proceeds_amount.as_deref().ok_or_else(|| {
|
||||
StoreError::JournalCorrupt("disposed without proceeds".into())
|
||||
})?;
|
||||
ledger.dispose_asset(
|
||||
got,
|
||||
parse_date(date)?,
|
||||
parse_amt(proceeds, &cost_currency)?,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(ledger)
|
||||
}
|
||||
}
|
||||
@@ -881,6 +1386,42 @@ fn parse_date(s: &str) -> Result<chrono::NaiveDate, StoreError> {
|
||||
.map_err(|_| StoreError::JournalCorrupt(format!("bad date {s}")))
|
||||
}
|
||||
|
||||
/// Parse a nested {"amount","currency"} money object.
|
||||
fn parse_money(p: &Value, key: &str) -> Result<Money, StoreError> {
|
||||
let o = p
|
||||
.get(key)
|
||||
.ok_or_else(|| StoreError::JournalCorrupt(format!("missing '{key}'")))?;
|
||||
Ok(Money::new(
|
||||
get_str(o, "amount")?
|
||||
.parse()
|
||||
.map_err(|_| StoreError::JournalCorrupt("bad amount".into()))?,
|
||||
Currency::new(get_str(o, "currency")?)
|
||||
.map_err(|_| StoreError::JournalCorrupt("bad currency".into()))?,
|
||||
))
|
||||
}
|
||||
|
||||
/// Parse a calendar-month period "YYYY-MM".
|
||||
fn parse_period(period: &str) -> Result<(i32, u32), StoreError> {
|
||||
let err = || StoreError::JournalCorrupt(format!("bad period '{period}': want YYYY-MM"));
|
||||
let mut parts = period.split('-');
|
||||
let year: i32 = parts.next().ok_or_else(err)?.parse().map_err(|_| err())?;
|
||||
let month: u32 = parts.next().ok_or_else(err)?.parse().map_err(|_| err())?;
|
||||
if parts.next().is_some() || year < 1 || !(1..=12).contains(&month) {
|
||||
return Err(err());
|
||||
}
|
||||
Ok((year, month))
|
||||
}
|
||||
|
||||
/// Last calendar day of a month.
|
||||
fn last_day_of_month(year: i32, month: u32) -> NaiveDate {
|
||||
let first = NaiveDate::from_ymd_opt(year, month, 1).expect("valid period");
|
||||
first
|
||||
.checked_add_months(Months::new(1))
|
||||
.expect("month arithmetic")
|
||||
.pred_opt()
|
||||
.expect("month arithmetic")
|
||||
}
|
||||
|
||||
fn get_u64(p: &Value, key: &str) -> Result<u64, StoreError> {
|
||||
p.get(key)
|
||||
.and_then(|v| v.as_u64())
|
||||
@@ -982,6 +1523,36 @@ fn replay_line_into_ledger(
|
||||
"receipt.attached" | "receipt.linked" | "document.filed" => {
|
||||
// Not accounting state; the projection records them.
|
||||
}
|
||||
"asset.registered" => {
|
||||
let want = get_u64(p, "id")?;
|
||||
let got = ledger.register_asset(NewAsset {
|
||||
description: get_str(p, "description")?.to_string(),
|
||||
purchase_date: parse_date(get_str(p, "purchase_date")?)?,
|
||||
cost: parse_money(p, "cost")?,
|
||||
salvage_value: parse_money(p, "salvage_value")?,
|
||||
useful_life_months: get_u64(p, "useful_life_months")? as u32,
|
||||
cost_account: get_u64(p, "cost_account")?,
|
||||
accum_depr_account: get_u64(p, "accum_depr_account")?,
|
||||
expense_account: get_u64(p, "expense_account")?,
|
||||
})?;
|
||||
if got != want {
|
||||
return Err(StoreError::ReplayMismatch(format!(
|
||||
"asset id: journal says {want}, replay got {got}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
"asset.depreciation_posted" => {
|
||||
// The entry itself was journaled as entry.created/entry.posted;
|
||||
// this line is idempotency bookkeeping for the projection.
|
||||
}
|
||||
"asset.disposed" => {
|
||||
let id = get_u64(p, "asset_id")?;
|
||||
ledger.dispose_asset(
|
||||
id,
|
||||
parse_date(get_str(p, "date")?)?,
|
||||
parse_money(p, "proceeds")?,
|
||||
)?;
|
||||
}
|
||||
other => {
|
||||
return Err(StoreError::JournalCorrupt(format!(
|
||||
"unknown line type: {other}"
|
||||
@@ -1099,6 +1670,50 @@ fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), Sto
|
||||
],
|
||||
)?;
|
||||
}
|
||||
"asset.registered" => {
|
||||
let cost = &p["cost"];
|
||||
let salvage = &p["salvage_value"];
|
||||
conn.execute(
|
||||
"INSERT INTO assets(id, description, purchase_date, cost_amount, cost_currency, salvage_amount, salvage_currency, useful_life_months, method, cost_account, accum_depr_account, expense_account, status) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
params![
|
||||
get_u64(p, "id")? as i64,
|
||||
get_str(p, "description")?,
|
||||
get_str(p, "purchase_date")?,
|
||||
get_str(cost, "amount")?,
|
||||
get_str(cost, "currency")?,
|
||||
get_str(salvage, "amount")?,
|
||||
get_str(salvage, "currency")?,
|
||||
get_u64(p, "useful_life_months")? as i64,
|
||||
get_str(p, "method")?,
|
||||
get_u64(p, "cost_account")? as i64,
|
||||
get_u64(p, "accum_depr_account")? as i64,
|
||||
get_u64(p, "expense_account")? as i64,
|
||||
"active",
|
||||
],
|
||||
)?;
|
||||
}
|
||||
"asset.depreciation_posted" => {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO asset_depreciation(asset_id, period, entry_id, amount) VALUES (?,?,?,?)",
|
||||
params![
|
||||
get_u64(p, "asset_id")? as i64,
|
||||
get_str(p, "period")?,
|
||||
get_u64(p, "entry_id")? as i64,
|
||||
get_str(p, "amount")?,
|
||||
],
|
||||
)?;
|
||||
}
|
||||
"asset.disposed" => {
|
||||
let proceeds = &p["proceeds"];
|
||||
conn.execute(
|
||||
"UPDATE assets SET status = 'disposed', disposed_date = ?, proceeds_amount = ? WHERE id = ?",
|
||||
params![
|
||||
get_str(p, "date")?,
|
||||
get_str(proceeds, "amount")?,
|
||||
get_u64(p, "asset_id")? as i64,
|
||||
],
|
||||
)?;
|
||||
}
|
||||
other => {
|
||||
return Err(StoreError::JournalCorrupt(format!(
|
||||
"unknown line type: {other}"
|
||||
|
||||
Reference in new issue
Block a user