check / check (push) Canceled after 0s
- core: asset tags (generated PREFIX-0001 or the human's own; unique ignoring case; never reused by another asset) and serial numbers (rules.md B-24); a journaled link from an asset to the entry that acquired it (posted, unreversed, debiting the cost account); fixed_asset_register(as_of), reconciled to the GL (unposted depreciation, unregistered cost) - store: asset.tagged, asset.serial_set, asset.acquisition_linked journaled, replayed, projected (schema v9); differs_from now also compares control roles and tag history - config: [books] asset_tag_prefix, default the company's initials without legal-form words - import: Avery 5160/8160 label sheets with QR codes (geometry vendored from Avery, docs/spec/sources/avery-5160.md); the register as XLSX and PDF - mcp: register_asset tags (and links) automatically; books.label_asset, books.asset_labels; asset tools take a tag; books.show asset / assets page / register report; export fixed_asset_register (json, csv, xlsx, pdf) and in the CPA package - viewer: Assets page (register, reconciliation), asset page (identity, schedule posted vs not, entries, documents), Fixed asset register report, global search by tag/serial/description, journal text, needs-attention items - fix: verify compared snapshot amounts as strings; a zero total summed in hash-map order could print "0" or "0.00", so agreeing books failed verify at random. Now by value (regression test) - tests: spec-blind suites for tags, register, prefix, store, MCP; label and register PDF/XLSX tests; viewer tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
813 lines
30 KiB
Rust
813 lines
30 KiB
Rust
//! The view model: one immutable picture of the books, rebuilt whenever the
|
|
//! journal grows. Built off the UI thread from a [`Snapshot`]; the UI only
|
|
//! reads it. Every number here comes from `ledger-core`; this module only
|
|
//! indexes and arranges.
|
|
|
|
use chrono::{Datelike, NaiveDate};
|
|
use ledger_config::BooksSettings;
|
|
use ledger_core::analysis::{balance_history, balance_series};
|
|
use ledger_core::reports::{balance_sheet_as_of, profit_and_loss_for_period};
|
|
use ledger_core::{
|
|
AccountId, AccountType, AssetId, AssetRegister, AssetStatus, EntryId, EntryStatus, Ledger,
|
|
Side, fixed_asset_register,
|
|
};
|
|
use ledger_store::{Attachment, JournalLine, Snapshot};
|
|
use rust_decimal::Decimal;
|
|
use serde_json::Value;
|
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
|
use std::path::PathBuf;
|
|
|
|
/// One entry, indexed for listing and detail.
|
|
#[derive(Clone, Debug)]
|
|
pub struct EntryInfo {
|
|
pub id: EntryId,
|
|
pub date: NaiveDate,
|
|
pub description: String,
|
|
pub status: EntryStatus,
|
|
/// Total debits.
|
|
pub amount: Decimal,
|
|
/// Accounts touched, debits first: "6300 Software".
|
|
pub accounts: Vec<AccountId>,
|
|
/// Indexes into [`Books::attachments`] of linked, current receipts.
|
|
pub receipts: Vec<usize>,
|
|
/// How the entry came to be, in words.
|
|
pub origin: &'static str,
|
|
/// Indexes into [`Books::journal`] of every event that touched it.
|
|
pub events: Vec<usize>,
|
|
pub vendor: Option<String>,
|
|
pub reversal_of: Option<EntryId>,
|
|
pub reversed_by: Option<EntryId>,
|
|
/// Covered by the receipt policy (the server's rule) and has no receipt.
|
|
pub missing_receipt: bool,
|
|
/// Net effect on cash accounts: positive in, negative out, zero if the
|
|
/// entry doesn't touch cash.
|
|
pub cash_effect: Decimal,
|
|
/// The customer or vendor, from invoices, bills, and vendor tags.
|
|
pub party: Option<String>,
|
|
/// Lower-cased text for search: description, accounts, vendor, amount.
|
|
pub haystack: String,
|
|
}
|
|
|
|
/// One fixed asset's trail through the journal and the entries.
|
|
#[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`.
|
|
pub depreciation: BTreeMap<String, (EntryId, Decimal)>,
|
|
/// The disposal entry, from `asset.disposed`.
|
|
pub disposal_entry: Option<EntryId>,
|
|
/// 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
|
|
/// claimed by an earlier asset.
|
|
pub purchase_entry: Option<EntryId>,
|
|
/// Indexes into [`Books::journal`] of every `asset.*` event about it.
|
|
pub events: Vec<usize>,
|
|
}
|
|
|
|
impl AssetTrail {
|
|
/// Every entry that touches the asset: purchase, depreciation (by
|
|
/// month), 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.disposal_entry);
|
|
out
|
|
}
|
|
|
|
/// Depreciation the books carry for it, summed exactly.
|
|
pub fn posted(&self) -> Decimal {
|
|
self.depreciation.values().map(|(_, a)| *a).sum()
|
|
}
|
|
}
|
|
|
|
/// Depreciation due and not yet in the books.
|
|
///
|
|
/// ```text
|
|
/// GOAL: say when monthly depreciation hasn't been run, without crying
|
|
/// wolf over the month still under way.
|
|
/// SOURCE: docs/spec/rules.md B-15 (monthly straight-line depreciation);
|
|
/// CONVENTION (a month's depreciation is posted when the month
|
|
/// ends: ledger-store dates it the month's last day).
|
|
/// INVARIANTS:
|
|
/// 1. through is as_of when as_of is a month's last day, else the last
|
|
/// day of the month before as_of's.
|
|
/// 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.
|
|
/// ```
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct DepreciationDue {
|
|
pub through: NaiveDate,
|
|
pub amount: Decimal,
|
|
pub periods: Vec<String>,
|
|
}
|
|
|
|
/// Something the books need, with how to fix it.
|
|
#[derive(Clone, Debug)]
|
|
pub struct Attention {
|
|
pub severity: Severity,
|
|
pub title: String,
|
|
pub detail: String,
|
|
pub target: Option<crate::nav::Route>,
|
|
/// A prompt to paste into the agent session that fixes it.
|
|
pub prompt: String,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
|
pub enum Severity {
|
|
Problem,
|
|
Todo,
|
|
Info,
|
|
}
|
|
|
|
/// The whole picture.
|
|
pub struct Books {
|
|
pub ledger: Ledger,
|
|
pub settings: BooksSettings,
|
|
pub data_dir: PathBuf,
|
|
pub attachments: Vec<Attachment>,
|
|
pub journal: Vec<JournalLine>,
|
|
pub head: String,
|
|
pub today: NaiveDate,
|
|
pub loaded_at: chrono::DateTime<chrono::Utc>,
|
|
/// Newest first (date, then id).
|
|
pub entries: Vec<EntryInfo>,
|
|
pub entry_index: HashMap<EntryId, usize>,
|
|
pub cash_accounts: Vec<AccountId>,
|
|
/// `[books] financing_accounts`, resolved (empty when not set).
|
|
pub financing_accounts: Vec<AccountId>,
|
|
/// `[books] investing_accounts`, resolved (empty when not set).
|
|
pub investing_accounts: Vec<AccountId>,
|
|
pub card_accounts: Vec<AccountId>,
|
|
/// Entries created or changed by the most recent refresh.
|
|
pub fresh: HashSet<EntryId>,
|
|
/// Journal seq before the most recent refresh.
|
|
pub fresh_from_seq: u64,
|
|
pub receipt_threshold: Decimal,
|
|
pub attention: Vec<Attention>,
|
|
pub kpis: Kpis,
|
|
pub integrity: Integrity,
|
|
/// Each registered asset's trail, by asset id.
|
|
pub asset_trails: HashMap<AssetId, AssetTrail>,
|
|
/// The asset each depreciation, disposal, or purchase entry is about.
|
|
pub entry_assets: HashMap<EntryId, AssetId>,
|
|
}
|
|
|
|
/// Whether what's on disk is what the journal says.
|
|
#[derive(Clone, Debug)]
|
|
pub struct Integrity {
|
|
/// The writer's SQLite database against the journal.
|
|
pub disk: ledger_store::DiskCheck,
|
|
/// Stored files whose bytes match their hash.
|
|
pub files_ok: u64,
|
|
/// Files that are missing or damaged: (attachment id, why).
|
|
pub file_failures: Vec<(u64, String)>,
|
|
}
|
|
|
|
impl Integrity {
|
|
/// Check the database and every file not already verified (by hash, so
|
|
/// a file is read once however often the books change).
|
|
pub fn check(snap: &Snapshot, verified: &mut HashSet<String>) -> Integrity {
|
|
let store = ledger_store::BlobStore::open_read_only(&snap.data_dir().join("blobs"));
|
|
let mut ok = 0;
|
|
let mut failures = Vec::new();
|
|
for a in snap.attachments(None).unwrap_or_default() {
|
|
if verified.contains(&a.blob_sha) {
|
|
ok += 1;
|
|
continue;
|
|
}
|
|
match store.get(&a.blob_sha) {
|
|
Ok(_) => {
|
|
verified.insert(a.blob_sha.clone());
|
|
ok += 1;
|
|
}
|
|
Err(e) => failures.push((a.id, e.to_string())),
|
|
}
|
|
}
|
|
Integrity {
|
|
disk: snap.check_disk(),
|
|
files_ok: ok,
|
|
file_failures: failures,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The headline numbers.
|
|
///
|
|
/// ```text
|
|
/// GOAL: the overview's numbers are the books' numbers.
|
|
/// SOURCE: the module's rule ("every number here comes from ledger-core;
|
|
/// this module only indexes and arranges"); DECISION (the
|
|
/// comparisons and the burn estimate, as defined here).
|
|
/// INVARIANTS (all at `today`; m0 = the first of today's month):
|
|
/// 1. cash, cash_30d_ago: the cash accounts' balances (normal side) at
|
|
/// the end of today and of today - 30 days.
|
|
/// 2. liabilities: the balance sheet's total liabilities at today.
|
|
/// 3. receivables: the balances of the asset accounts whose name says
|
|
/// "receivable".
|
|
/// 4. revenue_mtd, expenses_mtd: the P&L from m0 through today.
|
|
/// revenue_prev_month, expenses_prev_month: the P&L from the first
|
|
/// of last month through the same day of it (its last day, if
|
|
/// shorter).
|
|
/// 5. net_ytd: revenue minus expenses from the fiscal year's first day
|
|
/// (ledger_config) through today.
|
|
/// 6. monthly_burn: over the three full months before m0, expenses
|
|
/// minus revenue, divided by the months of those the books cover
|
|
/// (from the first posted entry's month); Some only when positive,
|
|
/// rounded to the cent half away from zero.
|
|
/// 7. first_entry: the earliest posted entry's date.
|
|
/// ```
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct Kpis {
|
|
pub cash: Decimal,
|
|
pub cash_30d_ago: Decimal,
|
|
pub liabilities: Decimal,
|
|
pub receivables: Decimal,
|
|
pub revenue_mtd: Decimal,
|
|
pub expenses_mtd: Decimal,
|
|
/// Last month over the same days as this month so far.
|
|
pub revenue_prev_month: Decimal,
|
|
pub expenses_prev_month: Decimal,
|
|
pub net_ytd: Decimal,
|
|
/// Average monthly net loss over the last three full months, if losing.
|
|
pub monthly_burn: Option<Decimal>,
|
|
pub first_entry: Option<NaiveDate>,
|
|
}
|
|
|
|
impl Books {
|
|
/// Build the picture from a snapshot. `prev` is the previous picture,
|
|
/// to mark what's new.
|
|
pub fn build(
|
|
snap: &Snapshot,
|
|
settings: BooksSettings,
|
|
today: NaiveDate,
|
|
prev_seq: Option<u64>,
|
|
integrity: Integrity,
|
|
) -> Result<Books, String> {
|
|
let ledger = snap.ledger().clone();
|
|
let attachments = snap.attachments(None).map_err(|e| e.to_string())?;
|
|
let journal = snap.journal().to_vec();
|
|
// The server's own rule (config.json, else wanderbooks.toml), so
|
|
// the viewer flags exactly what the server would refuse.
|
|
let policy = ledger_config::effective_receipt_policy(
|
|
&snap.data_dir().join("config.json"),
|
|
&settings,
|
|
)?;
|
|
let receipt_threshold = policy.threshold();
|
|
let cash_accounts = cash_accounts(&ledger, &settings);
|
|
let codes = |cs: &[String]| -> Vec<AccountId> {
|
|
cs.iter()
|
|
.filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id))
|
|
.collect()
|
|
};
|
|
let financing_accounts = codes(&settings.financing_accounts);
|
|
let investing_accounts = codes(&settings.investing_accounts);
|
|
let card_accounts: Vec<AccountId> = ledger
|
|
.chart()
|
|
.all()
|
|
.filter(|a| {
|
|
a.account_type == AccountType::Liability && a.name.to_lowercase().contains("card")
|
|
})
|
|
.map(|a| a.id)
|
|
.collect();
|
|
|
|
// Journal events per entry.
|
|
let mut events: HashMap<EntryId, Vec<usize>> = HashMap::new();
|
|
for (i, line) in journal.iter().enumerate() {
|
|
for id in entry_refs(line) {
|
|
events.entry(id).or_default().push(i);
|
|
}
|
|
}
|
|
let mut receipts: HashMap<EntryId, Vec<usize>> = HashMap::new();
|
|
for (i, a) in attachments.iter().enumerate() {
|
|
if let (Some(e), None) = (a.entry_id, &a.withdrawn_at) {
|
|
receipts.entry(e).or_default().push(i);
|
|
}
|
|
}
|
|
let fresh_from_seq = prev_seq.unwrap_or(journal.len() as u64);
|
|
let parties = parties(&ledger, &journal);
|
|
let mut fresh = HashSet::new();
|
|
let closings = ledger.closing_entry_ids();
|
|
let mut entries = Vec::new();
|
|
let mut reversed_by: HashMap<EntryId, EntryId> = HashMap::new();
|
|
for id in 1..ledger.next_entry_id() {
|
|
if let Some(e) = ledger.get_entry(id)
|
|
&& let Some(orig) = e.reversal_of()
|
|
{
|
|
reversed_by.insert(orig, id);
|
|
}
|
|
}
|
|
for id in 1..ledger.next_entry_id() {
|
|
let Some(e) = ledger.get_entry(id) else {
|
|
continue;
|
|
};
|
|
let evs = events.remove(&id).unwrap_or_default();
|
|
if evs.iter().any(|&i| journal[i].seq > fresh_from_seq) {
|
|
fresh.insert(id);
|
|
}
|
|
let origin = origin(&evs, &journal, closings.contains(&id));
|
|
let mut accounts: Vec<AccountId> = Vec::new();
|
|
for side in [Side::Debit, Side::Credit] {
|
|
for l in e.lines().iter().filter(|l| l.side == side) {
|
|
if !accounts.contains(&l.account) {
|
|
accounts.push(l.account);
|
|
}
|
|
}
|
|
}
|
|
let amount: Decimal = e
|
|
.lines()
|
|
.iter()
|
|
.filter(|l| l.side == Side::Debit)
|
|
.map(|l| l.amount.amount)
|
|
.sum();
|
|
let rec = receipts.remove(&id).unwrap_or_default();
|
|
let vendor = ledger
|
|
.entry_vendor(id)
|
|
.and_then(|v| ledger.get_vendor(v))
|
|
.map(|v| v.name.clone());
|
|
// The receipt policy covers what is posted by hand (journal
|
|
// entries and recurring drafts); invoices, payments, closes,
|
|
// depreciation, and schedules carry their own evidence, and a
|
|
// reversal leans on its original's.
|
|
let missing_receipt = matches!(origin, "Journal entry" | "Recurring")
|
|
&& e.reversal_of().is_none()
|
|
&& rec.is_empty()
|
|
&& policy.receipt_required(&ledger, e);
|
|
let cash_effect: Decimal = e
|
|
.lines()
|
|
.iter()
|
|
.filter(|l| cash_accounts.contains(&l.account))
|
|
.map(|l| match l.side {
|
|
Side::Debit => l.amount.amount,
|
|
Side::Credit => -l.amount.amount,
|
|
})
|
|
.sum();
|
|
let party = vendor.clone().or_else(|| parties.get(&id).cloned());
|
|
let mut haystack = format!(
|
|
"{} {} #{} {}",
|
|
e.description(),
|
|
crate::fmt::money(amount),
|
|
id,
|
|
amount
|
|
);
|
|
for a in &accounts {
|
|
if let Some(acct) = ledger.chart().get(*a) {
|
|
haystack.push_str(&format!(" {} {}", acct.code, acct.name));
|
|
}
|
|
}
|
|
if let Some(v) = &party {
|
|
haystack.push(' ');
|
|
haystack.push_str(v);
|
|
}
|
|
entries.push(EntryInfo {
|
|
id,
|
|
date: e.date(),
|
|
description: e.description().to_string(),
|
|
status: e.status(),
|
|
amount,
|
|
accounts,
|
|
receipts: rec,
|
|
origin,
|
|
events: evs,
|
|
vendor,
|
|
reversal_of: e.reversal_of(),
|
|
reversed_by: reversed_by.get(&id).copied(),
|
|
missing_receipt,
|
|
cash_effect,
|
|
party,
|
|
haystack: haystack.to_lowercase(),
|
|
});
|
|
}
|
|
entries.sort_by(|a, b| b.date.cmp(&a.date).then(b.id.cmp(&a.id)));
|
|
let entry_index = entries.iter().enumerate().map(|(i, e)| (e.id, i)).collect();
|
|
let asset_trails = asset_trails(&ledger, &journal);
|
|
let mut entry_assets = HashMap::new();
|
|
for (&a, t) in &asset_trails {
|
|
for e in t.entries() {
|
|
entry_assets.insert(e, a);
|
|
}
|
|
}
|
|
let mut books = Books {
|
|
ledger,
|
|
settings,
|
|
data_dir: snap.data_dir().to_path_buf(),
|
|
attachments,
|
|
journal,
|
|
head: snap.head_hash().to_string(),
|
|
today,
|
|
loaded_at: chrono::Utc::now(),
|
|
entries,
|
|
entry_index,
|
|
cash_accounts,
|
|
financing_accounts,
|
|
investing_accounts,
|
|
card_accounts,
|
|
fresh,
|
|
fresh_from_seq,
|
|
receipt_threshold,
|
|
attention: Vec::new(),
|
|
kpis: Kpis::default(),
|
|
integrity,
|
|
asset_trails,
|
|
entry_assets,
|
|
};
|
|
books.kpis = books.compute_kpis();
|
|
books.attention = crate::attention::find(&books);
|
|
Ok(books)
|
|
}
|
|
|
|
/// Why an entry needs a receipt, in words: "every entry needs a
|
|
/// receipt" or "over the $25.00 receipt threshold".
|
|
pub fn receipt_rule(&self) -> String {
|
|
if self.receipt_threshold.is_zero() {
|
|
"every entry needs a receipt".to_string()
|
|
} else {
|
|
format!(
|
|
"over the {} receipt threshold",
|
|
crate::fmt::dollars(self.receipt_threshold)
|
|
)
|
|
}
|
|
}
|
|
|
|
pub fn seq(&self) -> u64 {
|
|
self.journal.len() as u64
|
|
}
|
|
|
|
pub fn entry(&self, id: EntryId) -> Option<&EntryInfo> {
|
|
self.entry_index.get(&id).map(|&i| &self.entries[i])
|
|
}
|
|
|
|
pub fn account_label(&self, id: AccountId) -> String {
|
|
self.ledger
|
|
.chart()
|
|
.get(id)
|
|
.map(|a| format!("{} {}", a.code, a.name))
|
|
.unwrap_or_else(|| format!("account {id}"))
|
|
}
|
|
|
|
pub fn account_name(&self, id: AccountId) -> String {
|
|
self.ledger
|
|
.chart()
|
|
.get(id)
|
|
.map(|a| a.name.clone())
|
|
.unwrap_or_else(|| format!("account {id}"))
|
|
}
|
|
|
|
pub fn company_name(&self) -> &str {
|
|
&self.settings.company.name
|
|
}
|
|
|
|
/// Cash balance at the end of each day a cash account moved.
|
|
pub fn cash_history(&self) -> Vec<(NaiveDate, Decimal)> {
|
|
balance_history(&self.ledger, &self.cash_accounts)
|
|
}
|
|
|
|
/// Normal-side balance of one account at the end of `date`.
|
|
pub fn balance_on(&self, account: AccountId, date: NaiveDate) -> Decimal {
|
|
balance_series(&self.ledger, &[account], &[date])[0]
|
|
}
|
|
|
|
/// First day of the current month.
|
|
pub fn month_start(&self) -> NaiveDate {
|
|
NaiveDate::from_ymd_opt(self.today.year(), self.today.month(), 1).expect("valid")
|
|
}
|
|
|
|
/// Range of the last `n` months, ending with the current one.
|
|
pub fn trailing_months(&self, n: u32) -> (NaiveDate, NaiveDate) {
|
|
let start = self.month_start() - chrono::Months::new(n.saturating_sub(1));
|
|
(start, self.today)
|
|
}
|
|
|
|
/// The fiscal year to date.
|
|
pub fn fiscal_ytd(&self) -> (NaiveDate, NaiveDate) {
|
|
let (start, _) = self.settings.company.fiscal_year_containing(self.today);
|
|
(start, self.today)
|
|
}
|
|
|
|
fn compute_kpis(&self) -> Kpis {
|
|
let l = &self.ledger;
|
|
let today = self.today;
|
|
let cash = balance_series(
|
|
l,
|
|
&self.cash_accounts,
|
|
&[today - chrono::Days::new(30), today],
|
|
);
|
|
let bs = balance_sheet_as_of(l, today).ok();
|
|
let receivables: Decimal = l
|
|
.chart()
|
|
.all()
|
|
.filter(|a| {
|
|
a.account_type == AccountType::Asset && a.name.to_lowercase().contains("receivable")
|
|
})
|
|
.map(|a| self.balance_on(a.id, today))
|
|
.sum();
|
|
let ms = self.month_start();
|
|
let prev_start = ms - chrono::Months::new(1);
|
|
let prev_end = ms - chrono::Days::new(1);
|
|
let pl = |s, e| profit_and_loss_for_period(l, s, e, None).ok();
|
|
let mtd = pl(ms, today);
|
|
// Same days of last month: a fair comparison mid-month.
|
|
let same_day = (prev_start + chrono::Days::new(u64::from(today.day() - 1))).min(prev_end);
|
|
let prev = pl(prev_start, same_day);
|
|
let (fy_start, _) = self.fiscal_ytd();
|
|
let ytd = pl(fy_start, today);
|
|
let three = pl(ms - chrono::Months::new(3), prev_end);
|
|
let first_entry = self
|
|
.entries
|
|
.iter()
|
|
.filter(|e| e.status == EntryStatus::Posted)
|
|
.map(|e| e.date)
|
|
.min();
|
|
let monthly_burn = three.and_then(|p| {
|
|
let loss = p.total_expenses - p.total_revenue;
|
|
// Only months the books cover count toward the average.
|
|
let months = match first_entry {
|
|
Some(f) if f > ms - chrono::Months::new(3) => {
|
|
let m = (prev_end.year() * 12 + prev_end.month() as i32)
|
|
- (f.year() * 12 + f.month() as i32)
|
|
+ 1;
|
|
m.max(0)
|
|
}
|
|
Some(_) => 3,
|
|
None => 0,
|
|
};
|
|
(loss > Decimal::ZERO && months > 0).then(|| {
|
|
(loss / Decimal::from(months))
|
|
.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero)
|
|
})
|
|
});
|
|
Kpis {
|
|
cash: cash[1],
|
|
cash_30d_ago: cash[0],
|
|
liabilities: bs.as_ref().map(|b| b.total_liabilities).unwrap_or_default(),
|
|
receivables,
|
|
revenue_mtd: mtd.as_ref().map(|p| p.total_revenue).unwrap_or_default(),
|
|
expenses_mtd: mtd.as_ref().map(|p| p.total_expenses).unwrap_or_default(),
|
|
revenue_prev_month: prev.as_ref().map(|p| p.total_revenue).unwrap_or_default(),
|
|
expenses_prev_month: prev.as_ref().map(|p| p.total_expenses).unwrap_or_default(),
|
|
net_ytd: ytd
|
|
.map(|p| p.total_revenue - p.total_expenses)
|
|
.unwrap_or_default(),
|
|
monthly_burn,
|
|
first_entry,
|
|
}
|
|
}
|
|
|
|
/// The fixed asset register on `as_of`, exactly as ledger-core
|
|
/// computes it.
|
|
pub fn asset_register(&self, as_of: NaiveDate) -> AssetRegister {
|
|
fixed_asset_register(&self.ledger, as_of)
|
|
}
|
|
|
|
/// An asset's trail (empty for an unknown asset).
|
|
pub fn asset_trail(&self, id: AssetId) -> AssetTrail {
|
|
self.asset_trails.get(&id).cloned().unwrap_or_default()
|
|
}
|
|
|
|
/// "WL-0001 MacBook Pro" (or just the description, untagged).
|
|
pub fn asset_label(&self, id: AssetId) -> String {
|
|
match self.ledger.get_asset(id) {
|
|
Some(a) => match &a.tag {
|
|
Some(t) => format!("{t} {}", a.description),
|
|
None => a.description.clone(),
|
|
},
|
|
None => format!("asset {id}"),
|
|
}
|
|
}
|
|
|
|
/// Depreciation due through the last finished month on or before
|
|
/// `as_of` and not yet posted (see [`DepreciationDue`]).
|
|
pub fn depreciation_due(&self, as_of: NaiveDate) -> DepreciationDue {
|
|
let through = if as_of.succ_opt().is_some_and(|d| d.day() == 1) {
|
|
as_of
|
|
} else {
|
|
NaiveDate::from_ymd_opt(as_of.year(), as_of.month(), 1).expect("valid")
|
|
- chrono::Days::new(1)
|
|
};
|
|
let amount = self.asset_register(through).unposted_depreciation();
|
|
let mut periods = std::collections::BTreeSet::new();
|
|
for a in self
|
|
.ledger
|
|
.registered_assets()
|
|
.filter(|a| a.purchase_date <= through)
|
|
{
|
|
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)) {
|
|
periods.insert(l.period);
|
|
}
|
|
}
|
|
}
|
|
DepreciationDue {
|
|
through,
|
|
amount,
|
|
periods: periods.into_iter().collect(),
|
|
}
|
|
}
|
|
|
|
/// Blob store path for an attachment.
|
|
pub fn blob_root(&self) -> PathBuf {
|
|
self.data_dir.join("blobs")
|
|
}
|
|
}
|
|
|
|
/// Customer names for entries created by invoices and their payments,
|
|
/// from the journal. (Bills and bill payments need nothing here: the
|
|
/// ledger tags their entries with the vendor, which takes precedence.)
|
|
fn parties(ledger: &Ledger, journal: &[JournalLine]) -> HashMap<EntryId, String> {
|
|
let mut out = HashMap::new();
|
|
let customer = |id: Option<u64>| {
|
|
id.and_then(|c| ledger.get_customer(c))
|
|
.map(|c| c.name.clone())
|
|
};
|
|
let u = |p: &Value, k: &str| p.get(k).and_then(|v| v.as_u64());
|
|
for line in journal {
|
|
let p = &line.payload;
|
|
let Some(entry) = u(p, "entry_id") else {
|
|
continue;
|
|
};
|
|
let name = match line.line_type.as_str() {
|
|
"invoice.created" => customer(u(p, "customer_id")),
|
|
"invoice.payment_received" => customer(
|
|
u(p, "invoice_id")
|
|
.and_then(|i| ledger.get_invoice(i))
|
|
.map(|i| i.customer_id),
|
|
),
|
|
_ => None,
|
|
};
|
|
if let Some(n) = name {
|
|
out.insert(entry, n);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Every registered asset's trail, from the journal (depreciation,
|
|
/// disposal, its own events) and the entries (the purchase).
|
|
fn asset_trails(ledger: &Ledger, journal: &[JournalLine]) -> HashMap<AssetId, AssetTrail> {
|
|
let mut out: HashMap<AssetId, AssetTrail> = ledger
|
|
.registered_assets()
|
|
.map(|a| (a.id, AssetTrail::default()))
|
|
.collect();
|
|
let u = |p: &Value, k: &str| p.get(k).and_then(|v| v.as_u64());
|
|
for (i, line) in journal.iter().enumerate() {
|
|
let p = &line.payload;
|
|
let asset = match line.line_type.as_str() {
|
|
"asset.registered" => u(p, "id"),
|
|
t if t.starts_with("asset.") => u(p, "asset_id"),
|
|
_ => None,
|
|
};
|
|
let Some(t) = asset.and_then(|a| out.get_mut(&a)) else {
|
|
continue;
|
|
};
|
|
t.events.push(i);
|
|
match line.line_type.as_str() {
|
|
"asset.depreciation_posted" => {
|
|
if let (Some(period), Some(entry), Some(amount)) = (
|
|
p.get("period").and_then(|v| v.as_str()),
|
|
u(p, "entry_id"),
|
|
p.get("amount")
|
|
.and_then(|v| v.as_str())
|
|
.and_then(|s| s.parse::<Decimal>().ok()),
|
|
) {
|
|
t.depreciation.insert(period.to_string(), (entry, amount));
|
|
}
|
|
}
|
|
"asset.disposed" => t.disposal_entry = u(p, "entry_id"),
|
|
_ => {}
|
|
}
|
|
}
|
|
// 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.
|
|
for a in ledger.registered_assets() {
|
|
if let (Some(id), Some(t)) = (a.acquired_by, out.get_mut(&a.id)) {
|
|
t.purchase_entry = Some(id);
|
|
}
|
|
}
|
|
let reversed: HashSet<EntryId> = (1..ledger.next_entry_id())
|
|
.filter_map(|id| ledger.get_entry(id).and_then(|e| e.reversal_of()))
|
|
.collect();
|
|
let mut claimed: HashSet<EntryId> = out.values().flat_map(|t| t.entries()).collect();
|
|
for a in ledger
|
|
.registered_assets()
|
|
.filter(|a| a.acquired_by.is_none())
|
|
{
|
|
let mut best: Option<(i64, EntryId)> = None;
|
|
for id in 1..ledger.next_entry_id() {
|
|
let Some(e) = ledger.get_entry(id) else {
|
|
continue;
|
|
};
|
|
if e.status() != EntryStatus::Posted
|
|
|| e.reversal_of().is_some()
|
|
|| reversed.contains(&id)
|
|
|| claimed.contains(&id)
|
|
{
|
|
continue;
|
|
}
|
|
let days = (e.date() - a.purchase_date).num_days().abs();
|
|
if days > 31 {
|
|
continue;
|
|
}
|
|
let buys = e.lines().iter().any(|l| {
|
|
l.account == a.cost_account
|
|
&& l.side == Side::Debit
|
|
&& l.amount.amount == a.cost.amount
|
|
});
|
|
if buys && best.is_none_or(|(d, _)| days < d) {
|
|
best = Some((days, id));
|
|
}
|
|
}
|
|
if let Some((_, id)) = best {
|
|
claimed.insert(id);
|
|
if let Some(t) = out.get_mut(&a.id) {
|
|
t.purchase_entry = Some(id);
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Entry ids a journal line refers to.
|
|
pub fn entry_refs(line: &JournalLine) -> Vec<EntryId> {
|
|
let p = &line.payload;
|
|
let mut out = Vec::new();
|
|
let mut push = |v: Option<&Value>| {
|
|
if let Some(id) = v.and_then(|v| v.as_u64())
|
|
&& !out.contains(&id)
|
|
{
|
|
out.push(id);
|
|
}
|
|
};
|
|
push(p.get("entry_id"));
|
|
push(p.get("closing_entry"));
|
|
push(p.get("entry").and_then(|e| e.get("id")));
|
|
if line.line_type.starts_with("entry.") {
|
|
push(p.get("id"));
|
|
}
|
|
out
|
|
}
|
|
|
|
fn origin(events: &[usize], journal: &[JournalLine], closing: bool) -> &'static str {
|
|
if closing {
|
|
return "Period close";
|
|
}
|
|
let has = |t: &str| events.iter().any(|&i| journal[i].line_type == t);
|
|
if has("entry.reversed") {
|
|
"Reversal"
|
|
} else if has("schedule.posted") {
|
|
"Amortization"
|
|
} else if has("asset.depreciation_posted") {
|
|
"Depreciation"
|
|
} else if has("asset.disposed") {
|
|
"Asset disposal"
|
|
} else if has("bill.created") {
|
|
"Bill"
|
|
} else if has("bill.paid") {
|
|
"Bill payment"
|
|
} else if has("invoice.created") {
|
|
"Invoice"
|
|
} else if has("invoice.payment_received") {
|
|
"Customer payment"
|
|
} else if has("payroll.run") {
|
|
"Payroll"
|
|
} else if has("payroll.remitted") || has("payroll.taxes_remitted") {
|
|
"Payroll taxes"
|
|
} else if has("recurring.generated") {
|
|
"Recurring"
|
|
} else {
|
|
"Journal entry"
|
|
}
|
|
}
|
|
|
|
/// Cash accounts: from `[books] cash_accounts`, else asset accounts whose
|
|
/// names say they hold cash.
|
|
fn cash_accounts(ledger: &Ledger, settings: &BooksSettings) -> Vec<AccountId> {
|
|
if !settings.cash_accounts.is_empty() {
|
|
return settings
|
|
.cash_accounts
|
|
.iter()
|
|
.filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id))
|
|
.collect();
|
|
}
|
|
ledger
|
|
.chart()
|
|
.all()
|
|
.filter(|a| {
|
|
let n = a.name.to_lowercase();
|
|
a.account_type == AccountType::Asset
|
|
&& !a.placeholder
|
|
&& ["cash", "bank", "checking", "savings", "money market"]
|
|
.iter()
|
|
.any(|w| n.contains(w))
|
|
})
|
|
.map(|a| a.id)
|
|
.collect()
|
|
}
|