diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b087f4..139b847 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 bridge with three dots instead of a cloud, redrawn to stay legible at this size. While an alert is open, an exclamation mark replaces the dots. ### Added +- **Reconciliation.** A new page holds each invoice's final total against the + ledger's authoritative billed cost, or a frozen snapshot, in the invoice's own + currency. Enter a total or import a CSV, and each month reads as Matched, + Difference, Cannot verify or Missing evidence, with the breakdown by charge + category and what to check next. Invoices are stored apart from charges and + never change them. - **Security configuration on Insights.** A new read-only section flags AWS security groups that open SSH or RDP to the whole internet, with the rule it saw and what to check by hand. A group whose inbound rules could not be read diff --git a/locales/ui.yml b/locales/ui.yml index 5e57c9c..f7527ab 100644 --- a/locales/ui.yml +++ b/locales/ui.yml @@ -217,6 +217,9 @@ report_ui: en: "Could not export the report: %{error}" zh-CN: "无法导出报告:%{error}" nav: + reconciliation: + en: Reconciliation + zh-CN: 账单核对 workspace: en: COSTS zh-CN: 成本 @@ -972,6 +975,223 @@ models: en: ", and " zh-CN: ",以及 " +reconciliation: + title: + en: "Reconciliation" + zh-CN: "账单核对" + subtitle: + en: "Does each provider's final bill match what was imported? Only authoritative billed cost in the invoice's currency is compared." + zh-CN: "各服务商的最终账单与导入的数据是否一致?只比较发票币种下的权威账单金额。" + filter_placeholder: + en: "Filter by source, account, month or invoice" + zh-CN: "按来源、账户、月份或发票筛选" + all: + en: "All" + zh-CN: "全部" + status_matched: + en: "Matched" + zh-CN: "一致" + status_difference: + en: "Difference" + zh-CN: "有差异" + status_cannot_verify: + en: "Cannot verify" + zh-CN: "无法核实" + status_missing: + en: "Missing evidence" + zh-CN: "缺少凭据" + import_csv: + en: "Import invoice CSV" + zh-CN: "导入发票 CSV" + enter_total: + en: "Enter total" + zh-CN: "录入总额" + col_account: + en: "Account" + zh-CN: "账户" + col_month: + en: "Month" + zh-CN: "月份" + col_invoice_id: + en: "Invoice ID" + zh-CN: "发票编号" + col_invoice: + en: "Invoice" + zh-CN: "发票" + col_ledger: + en: "Ledger" + zh-CN: "账本" + col_difference: + en: "Difference" + zh-CN: "差额" + col_status: + en: "Status" + zh-CN: "状态" + col_currency: + en: "Currency" + zh-CN: "币种" + col_category: + en: "Category" + zh-CN: "类别" + total: + en: "Total" + zh-CN: "合计" + category_rows: + en: "%{category} (%{rows} rows)" + zh-CN: "%{category}(%{rows} 行)" + empty: + en: "No invoices or billed months yet. Refresh an account, then enter an invoice total or import a CSV." + zh-CN: "还没有发票或已出账的月份。请先刷新账户,再录入发票总额或导入 CSV。" + no_match: + en: "No rows match this filter." + zh-CN: "没有符合筛选条件的行。" + load_failed: + en: "Could not load reconciliation: %{error}" + zh-CN: "无法加载账单核对:%{error}" + save_failed: + en: "Could not save the invoice: %{error}" + zh-CN: "无法保存发票:%{error}" + saved: + en: "Saved invoice %{reference}." + zh-CN: "已保存发票 %{reference}。" + imported: + en: "Imported %{n} invoices." + zh-CN: "已导入 %{n} 张发票。" + missing_body: + en: "This month has billed charges and no invoice to compare them with, so it has not been checked." + zh-CN: "本月已有账单费用,但没有可比对的发票,因此尚未核对。" + compare_with: + en: "Compare with" + zh-CN: "比对对象" + basis_current: + en: "Current ledger" + zh-CN: "当前账本" + basis_line: + en: "Basis: authoritative billed cost in %{currency}, tolerance %{tolerance}. %{authoritative} of %{rows} rows carry it, dated %{from} to %{through}." + zh-CN: "依据:%{currency} 的权威账单金额,容差 %{tolerance}。%{rows} 行中有 %{authoritative} 行计入,日期从 %{from} 到 %{through}。" + evidence: + en: "Invoice evidence" + zh-CN: "发票凭据" + evidence_line: + en: "%{reference}: %{amount}, %{source}, recorded %{at}" + zh-CN: "%{reference}:%{amount},%{source},记录于 %{at}" + source_manual: + en: "entered by hand" + zh-CN: "手动录入" + source_csv: + en: "from a CSV" + zh-CN: "来自 CSV" + source_csv_file: + en: "from %{file}" + zh-CN: "来自 %{file}" + delete_title: + en: "Delete invoice %{reference}?" + zh-CN: "删除发票 %{reference}?" + delete_body: + en: "Only this evidence is removed. The ledger and any snapshots stay as they are." + zh-CN: "只会删除这份凭据,账本和快照都不受影响。" + form_title: + en: "Enter invoice total" + zh-CN: "录入发票总额" + form_hint: + en: "Total is the invoice's final amount. Tax, credits and adjustments are what it states, are not added to the total, and only help explain a difference." + zh-CN: "总额是发票的最终金额。税费、抵扣和调整是发票上列明的内容,不会计入总额,只用于解释差异。" + field_total: + en: "Total" + zh-CN: "总额" + field_billing_account: + en: "Billing account ID" + zh-CN: "结算账户 ID" + no_accounts: + en: "Add an account first." + zh-CN: "请先添加账户。" + save: + en: "Save invoice" + zh-CN: "保存发票" + ph_reference: + en: "e.g. 2041-7733" + zh-CN: "例如 2041-7733" + ph_optional: + en: "Optional" + zh-CN: "可选" + ph_credits: + en: "Optional, zero or negative" + zh-CN: "可选,零或负数" + reason_no_rows: + en: "The ledger has no charges for this month. Refresh the account or import the month's export." + zh-CN: "账本中没有本月费用。请刷新账户或导入当月账单。" + reason_currency: + en: "The ledger has no charges in the invoice's currency, and amounts are not converted to compare them. Check the invoice's currency." + zh-CN: "账本中没有发票币种的费用,比对时也不会换算金额。请检查发票币种。" + reason_no_amounts: + en: "Every charge in this currency is usage with no amount, so there is no billed figure to compare." + zh-CN: "此币种的费用都是没有金额的用量记录,因此没有账单金额可比对。" + reason_open: + en: "This month has not ended, so its charges are not final. Check again after the bill closes." + zh-CN: "本月尚未结束,费用还不是最终结果。请在出账后再次核对。" + reason_non_authoritative: + en: "%{rows} rows carry a derived or estimated amount and are left out of the ledger figure. Import the provider's billed export for this month." + zh-CN: "%{rows} 行是推算或估算金额,未计入账本金额。请导入服务商当月的账单导出。" + reason_billing_accounts: + en: "The charges span %{accounts} billing accounts. Set the invoice's billing account ID so it is compared with the right one." + zh-CN: "这些费用涉及 %{accounts} 个结算账户。请填写发票的结算账户 ID,以便与正确的账户比对。" + reason_partial: + en: "The charges only run from %{from} to %{through}, so the export looks partial. Import the full month." + zh-CN: "费用只覆盖 %{from} 到 %{through},账单导出可能不完整。请导入完整月份。" + reason_tax: + en: "Tax differs between the invoice and the ledger, or sits on one side only. Check whether the export includes tax." + zh-CN: "发票与账本的税费不一致,或只出现在一侧。请检查账单导出是否包含税费。" + reason_credits: + en: "Credits differ between the invoice and the ledger, or sit on one side only. Look for a credit or refund applied late." + zh-CN: "发票与账本的抵扣不一致,或只出现在一侧。请查看是否有延后入账的抵扣或退款。" + reason_adjustments: + en: "Adjustments differ between the invoice and the ledger, or sit on one side only. Look for a late adjustment or a revised bill." + zh-CN: "发票与账本的调整不一致,或只出现在一侧。请查看是否有延后调整或修订账单。" + reason_month: + en: "The invoice's total fits the ledger's %{period}. Check which billing month it belongs to." + zh-CN: "发票总额与账本 %{period} 的金额相符。请确认它属于哪个账单月份。" + reason_usage: + en: "%{rows} usage rows carry no amount. They are not in the figure and do not change it." + zh-CN: "%{rows} 行用量记录没有金额,未计入也不影响比对结果。" + reason_other_currency: + en: "%{rows} charges in %{currency} are not on this invoice's side of the comparison." + zh-CN: "%{currency} 的 %{rows} 条费用不在这张发票的比对范围内。" + error_missing: + en: "%{field} is required." + zh-CN: "必须填写 %{field}。" + error_period: + en: "%{value} is not a month. Use YYYY-MM." + zh-CN: "%{value} 不是有效月份。请使用 YYYY-MM 格式。" + error_currency: + en: "%{value} is not a currency code. Use three letters, such as USD." + zh-CN: "%{value} 不是币种代码。请使用三个字母,例如 USD。" + error_amount: + en: "%{field} %{value} is not a plain number. Use digits and a decimal point, with no thousands separator." + zh-CN: "%{field} %{value} 不是普通数字。请只使用数字和小数点,不要加千位分隔符。" + error_credit: + en: "Credits of %{value} would add to the bill. Enter credits as zero or negative." + zh-CN: "抵扣 %{value} 会增加账单金额。请填写零或负数。" + csv_format: + en: "A CSV with a header row: %{columns}. Columns from tax on are optional." + zh-CN: "带表头的 CSV:%{columns}。tax 及之后的列可选。" + csv_empty: + en: "The file has no invoices." + zh-CN: "文件中没有发票。" + csv_column: + en: "The file has no %{column} column." + zh-CN: "文件缺少 %{column} 列。" + csv_fields: + en: "Line %{line} does not have the header's number of fields. Nothing was imported." + zh-CN: "第 %{line} 行的字段数与表头不一致,未导入任何内容。" + csv_row: + en: "Line %{line}: %{error} Nothing was imported." + zh-CN: "第 %{line} 行:%{error} 未导入任何内容。" + csv_unreadable: + en: "Could not read the file: %{error}" + zh-CN: "无法读取文件:%{error}" + csv_unknown_account: + en: "The file names %{provider} %{account}, which is not one of your accounts. Nothing was imported." + zh-CN: "文件中的 %{provider} %{account} 不是你的账户,未导入任何内容。" snapshots: title: en: "Month-end snapshots" diff --git a/src/analytics.rs b/src/analytics.rs index d73d121..355989e 100644 --- a/src/analytics.rs +++ b/src/analytics.rs @@ -17,6 +17,7 @@ pub mod breakdown; pub mod insights; +pub mod reconciliation; pub mod security; pub mod snapshot; diff --git a/src/analytics/reconciliation.rs b/src/analytics/reconciliation.rs new file mode 100644 index 0000000..fc5a4ae --- /dev/null +++ b/src/analytics/reconciliation.rs @@ -0,0 +1,1011 @@ +//! Invoice-to-ledger reconciliation: does the provider's final bill match +//! what was imported? +//! +//! Only authoritative billed cost in the invoice's own currency is compared. +//! Effective cost, derived and estimated amounts, usage with no amount and FX +//! conversions are never part of the figure, and nothing here writes a +//! charge. The same lines come from the ledger or a frozen snapshot, so the +//! basis of a comparison is the caller's choice. +//! +//! The rule that shapes the rest: `Matched` is earned. Anything that keeps the +//! two figures from being comparable is `CannotVerify`, even when they agree +//! by accident. + +use std::collections::{BTreeMap, BTreeSet}; + +use chrono::{DateTime, Duration, NaiveDate, Utc}; + +use super::{midnight, period_of}; +use crate::model::{ + ChargeCategory, CostBasis, Invoice, InvoiceSource, PeriodKey, ReconcileComponent, + ReconcileCoverage, ReconcileReason, ReconcileStatus, Reconciliation, SnapshotLine, +}; + +/// How far a ledger's rows may stop short of the month's edges before the +/// export counts as partial. A day either side allows for a provider's rows +/// settling late and a quiet account's last day. +const COVERAGE_SLACK_DAYS: i64 = 2; + +/// Floating-point residue from summing, on top of a currency's tolerance. +const RESIDUE: f64 = 1e-9; + +/// The columns an invoice CSV may carry, in any order. Those from `tax` on are +/// optional. +pub const CSV_COLUMNS: [&str; 10] = [ + "provider", + "account_id", + "billing_period", + "invoice_id", + "currency", + "amount", + "tax", + "credits", + "adjustments", + "billing_account_id", +]; + +/// Everything a comparison reads besides the invoices. +pub struct Evidence<'a> { + /// The period's charges, from the ledger or a snapshot. + pub lines: &'a [SnapshotLine], + /// The month had not ended when these lines were read or frozen. + pub period_open: bool, + /// Authoritative totals of the months either side, in the invoice's + /// currency, for spotting an invoice filed under the wrong month. + pub neighbours: &'a [(String, f64)], +} + +/// Why an invoice's fields were refused. +#[derive(Debug, Clone, PartialEq)] +pub enum InvoiceError { + MissingField(&'static str), + BadPeriod(String), + BadCurrency(String), + BadAmount { field: &'static str, value: String }, + PositiveCredit(String), +} + +/// An invoice as typed or read from a CSV, not yet validated. +#[derive(Debug, Clone, Default)] +pub struct InvoiceDraft { + pub provider: String, + pub account_id: String, + pub billing_period: String, + pub invoice_ref: String, + pub currency: String, + pub amount: String, + pub tax: String, + pub credits: String, + pub adjustments: String, + pub billing_account_id: String, +} + +/// A rejected CSV: the 1-based line and what was wrong with it. +#[derive(Debug, Clone, PartialEq)] +pub enum CsvError { + Empty, + MissingColumn(&'static str), + Row { line: usize, error: InvoiceError }, + WrongFieldCount { line: usize }, +} + +/// How far apart two totals may be and still match: one minor unit. +pub fn tolerance(currency: &str) -> f64 { + match currency { + "JPY" | "KRW" => 1.0, + _ => 0.01, + } +} + +/// A plain decimal. A thousands separator is refused rather than guessed at: +/// `1,234` is a thousand in one locale and one in another. +fn parse_amount(field: &'static str, value: &str) -> Result { + let bad = || InvoiceError::BadAmount { + field, + value: value.to_string(), + }; + let trimmed = value.trim(); + if !trimmed + .chars() + .all(|c| c.is_ascii_digit() || c == '.' || c == '-') + { + return Err(bad()); + } + let amount: f64 = trimmed.parse().map_err(|_| bad())?; + amount.is_finite().then_some(amount).ok_or_else(bad) +} + +fn optional_amount(field: &'static str, value: &str) -> Result, InvoiceError> { + if value.trim().is_empty() { + return Ok(None); + } + parse_amount(field, value).map(Some) +} + +fn required<'a>(field: &'static str, value: &'a str) -> Result<&'a str, InvoiceError> { + let trimmed = value.trim(); + if trimmed.is_empty() { + Err(InvoiceError::MissingField(field)) + } else { + Ok(trimmed) + } +} + +/// Validate a draft into evidence that can be stored. +pub fn invoice_from( + draft: &InvoiceDraft, + source: InvoiceSource, + source_file: Option<&str>, + now: DateTime, +) -> Result { + let provider = required("provider", &draft.provider)?; + let account_id = required("account_id", &draft.account_id)?; + let period = required("billing_period", &draft.billing_period)?; + period_of(period).map_err(|_| InvoiceError::BadPeriod(period.to_string()))?; + let invoice_ref = required("invoice_id", &draft.invoice_ref)?; + let currency = required("currency", &draft.currency)?.to_ascii_uppercase(); + if currency.len() != 3 || !currency.chars().all(|c| c.is_ascii_alphabetic()) { + return Err(InvoiceError::BadCurrency(draft.currency.trim().to_string())); + } + let amount = parse_amount("amount", required("amount", &draft.amount)?)?; + let credits = optional_amount("credits", &draft.credits)?; + // A credit reduces a bill. A positive one is a sign slip that would + // otherwise read as a gap of twice its size. + if credits.is_some_and(|credits| credits > 0.0) { + return Err(InvoiceError::PositiveCredit( + draft.credits.trim().to_string(), + )); + } + let billing_account = draft.billing_account_id.trim(); + Ok(Invoice { + invoice_id: uuid::Uuid::new_v4().to_string(), + key: PeriodKey::new(provider, account_id, period), + invoice_ref: invoice_ref.to_string(), + currency, + amount, + tax: optional_amount("tax", &draft.tax)?, + credits, + adjustments: optional_amount("adjustments", &draft.adjustments)?, + billing_account_id: (!billing_account.is_empty()).then(|| billing_account.to_string()), + source, + source_file: source_file.map(str::to_string), + entered_at: now, + }) +} + +/// One CSV line's fields, honoring quotes. `None` for an unterminated quote. +fn csv_fields(line: &str) -> Option> { + let mut fields = Vec::new(); + let mut field = String::new(); + let mut quoted = false; + let mut chars = line.chars().peekable(); + while let Some(c) = chars.next() { + match (c, quoted) { + ('"', true) if chars.peek() == Some(&'"') => { + field.push('"'); + chars.next(); + } + ('"', _) => quoted = !quoted, + (',', false) => fields.push(std::mem::take(&mut field)), + _ => field.push(c), + } + } + fields.push(field); + (!quoted).then_some(fields) +} + +/// Read an invoice CSV. All or nothing: a file with one bad line is refused +/// whole, so a half-read file cannot read as a complete set of invoices. +pub fn invoices_from_csv( + text: &str, + source_file: Option<&str>, + now: DateTime, +) -> Result, CsvError> { + let mut lines = text + .trim_start_matches('\u{feff}') + .lines() + .enumerate() + .filter(|(_, line)| !line.trim().is_empty()); + let (_, header) = lines.next().ok_or(CsvError::Empty)?; + let header: Vec = csv_fields(header) + .ok_or(CsvError::WrongFieldCount { line: 1 })? + .into_iter() + .map(|name| name.trim().to_ascii_lowercase()) + .collect(); + let column = |name: &'static str| header.iter().position(|field| field == name); + for name in [ + "provider", + "account_id", + "billing_period", + "invoice_id", + "currency", + "amount", + ] { + column(name).ok_or(CsvError::MissingColumn(name))?; + } + + let mut invoices = Vec::new(); + for (index, line) in lines { + let number = index + 1; + let fields = csv_fields(line) + .filter(|fields| fields.len() == header.len()) + .ok_or(CsvError::WrongFieldCount { line: number })?; + let value = |name: &'static str| { + column(name) + .map(|at| fields[at].clone()) + .unwrap_or_default() + }; + let draft = InvoiceDraft { + provider: value("provider"), + account_id: value("account_id"), + billing_period: value("billing_period"), + invoice_ref: value("invoice_id"), + currency: value("currency"), + amount: value("amount"), + tax: value("tax"), + credits: value("credits"), + adjustments: value("adjustments"), + billing_account_id: value("billing_account_id"), + }; + invoices.push( + invoice_from(&draft, InvoiceSource::Csv, source_file, now).map_err(|error| { + CsvError::Row { + line: number, + error, + } + })?, + ); + } + if invoices.is_empty() { + return Err(CsvError::Empty); + } + Ok(invoices) +} + +/// Invoices that answer for one figure together: a provider may issue several +/// for one month, and each set is compared as a sum, not one at a time. +pub fn group_invoices(invoices: Vec) -> Vec> { + let mut groups: BTreeMap<(String, String, String, String, String), Vec> = + BTreeMap::new(); + for invoice in invoices { + groups + .entry(( + invoice.key.provider.clone(), + invoice.key.account_id.clone(), + invoice.key.billing_period.clone(), + invoice.currency.clone(), + invoice.billing_account_id.clone().unwrap_or_default(), + )) + .or_default() + .push(invoice); + } + groups.into_values().collect() +} + +/// The months either side of a `YYYY-MM` month. +pub fn neighbouring_periods(billing_period: &str) -> Vec { + let Ok(period) = period_of(billing_period) else { + return Vec::new(); + }; + let label = |date: NaiveDate| date.format("%Y-%m").to_string(); + vec![ + label(period.start() - Duration::days(1)), + label(period.end_exclusive()), + ] +} + +/// The sum of a line set's authoritative billed amounts, per currency. This is +/// the only figure an invoice is ever held against. +pub fn authoritative_totals(lines: &[SnapshotLine]) -> Vec<(String, f64)> { + let mut totals: BTreeMap = BTreeMap::new(); + for line in lines { + if let (CostBasis::Authoritative, Some(amount)) = + (line.charge.cost_basis, line.charge.billed_cost) + { + *totals + .entry(line.charge.billing_currency.clone()) + .or_default() += amount; + } + } + totals.into_iter().collect() +} + +/// Compare one group of invoices, all of one scope and currency, with a line +/// set. +pub fn reconcile(invoices: &[Invoice], evidence: &Evidence<'_>) -> Reconciliation { + let first = invoices + .first() + .expect("a reconciliation compares at least one invoice"); + let currency = first.currency.as_str(); + let tolerance = tolerance(currency); + let close = |a: f64, b: f64| (a - b).abs() <= tolerance + RESIDUE; + let invoice_total: f64 = invoices.iter().map(|invoice| invoice.amount).sum(); + + let in_scope: Vec<&SnapshotLine> = evidence + .lines + .iter() + .filter(|line| match &first.billing_account_id { + Some(scope) => line.charge.billing_account_id.as_deref() == Some(scope.as_str()), + None => true, + }) + .collect(); + let lines: Vec<&SnapshotLine> = in_scope + .iter() + .filter(|line| line.charge.billing_currency == currency) + .copied() + .collect(); + + let authoritative: Vec<&SnapshotLine> = lines + .iter() + .filter(|line| { + line.charge.cost_basis == CostBasis::Authoritative && line.charge.billed_cost.is_some() + }) + .copied() + .collect(); + let without_amount = lines + .iter() + .filter(|line| line.charge.billed_cost.is_none()) + .count(); + let other_basis = lines.len() - authoritative.len() - without_amount; + let ledger_total: f64 = authoritative + .iter() + .filter_map(|line| line.charge.billed_cost) + .sum(); + + // Reasons that stop the figures being comparable at all. + let mut blocking = Vec::new(); + let billing_accounts: BTreeSet<&str> = in_scope + .iter() + .filter_map(|line| line.charge.billing_account_id.as_deref()) + .collect(); + if in_scope.is_empty() { + blocking.push(ReconcileReason::NoLedgerRows); + } else if lines.is_empty() { + blocking.push(ReconcileReason::CurrencyMismatch); + } else if authoritative.is_empty() && other_basis == 0 { + blocking.push(ReconcileReason::NoAmounts); + } + if evidence.period_open { + blocking.push(ReconcileReason::PeriodOpen); + } + if other_basis > 0 { + blocking.push(ReconcileReason::NonAuthoritative { rows: other_basis }); + } + if first.billing_account_id.is_none() && billing_accounts.len() > 1 { + blocking.push(ReconcileReason::MixedBillingAccounts { + accounts: billing_accounts.len(), + }); + } + + let coverage = coverage_of(&lines, &first.key.billing_period); + let figures_agree = !lines.is_empty() && close(ledger_total, invoice_total); + + let status = if !blocking.is_empty() { + ReconcileStatus::CannotVerify + } else if figures_agree { + ReconcileStatus::Matched + } else if coverage.partial { + ReconcileStatus::CannotVerify + } else { + ReconcileStatus::Difference + }; + + let components = components_of(invoices, &authoritative); + let mut reasons = blocking; + if !figures_agree { + if coverage.partial { + reasons.push(ReconcileReason::PartialCoverage { + from: coverage.from, + through: coverage.through, + }); + } + let gap = invoice_total - ledger_total; + for component in &components { + let ledger = component.ledger; + let reason = |invoice: Option| match invoice { + // The invoice states this category and the ledger disagrees. + Some(invoice) => !close(invoice, ledger), + // It does not, but leaving it out explains the whole gap. + None => ledger.abs() > tolerance && close(gap, -ledger), + }; + match component.category { + ChargeCategory::Tax if reason(component.invoice) => { + reasons.push(ReconcileReason::TaxDiffers) + } + ChargeCategory::Credit if reason(component.invoice) => { + reasons.push(ReconcileReason::CreditsDiffer) + } + ChargeCategory::Adjustment if reason(component.invoice) => { + reasons.push(ReconcileReason::AdjustmentsDiffer) + } + _ => {} + } + } + for (period, total) in evidence.neighbours { + if close(*total, invoice_total) { + reasons.push(ReconcileReason::MonthMismatch { + period: period.clone(), + }); + } + } + } + + // Worth saying whatever the result, because neither changes the figure. + if without_amount > 0 { + reasons.push(ReconcileReason::UsageWithoutAmount { + rows: without_amount, + }); + } + let mut others: BTreeMap<&str, usize> = BTreeMap::new(); + for line in &in_scope { + if line.charge.billing_currency != currency { + *others.entry(&line.charge.billing_currency).or_default() += 1; + } + } + for (currency, rows) in others { + reasons.push(ReconcileReason::OtherCurrency { + currency: currency.to_string(), + rows, + }); + } + + Reconciliation { + status, + currency: currency.to_string(), + invoice_total, + ledger_total: (!authoritative.is_empty()).then_some(ledger_total), + difference: (!authoritative.is_empty()).then_some(invoice_total - ledger_total), + tolerance, + components, + reasons, + coverage: ReconcileCoverage { + rows: lines.len(), + authoritative_rows: authoritative.len(), + from: coverage.from, + through: coverage.through, + }, + } +} + +struct Span { + from: Option, + through: Option, + partial: bool, +} + +/// The days a line set covers, and whether they fall short of the month. +fn coverage_of(lines: &[&SnapshotLine], billing_period: &str) -> Span { + let start = lines + .iter() + .map(|line| line.charge.charge_period_start) + .min(); + let end = lines.iter().map(|line| line.charge.charge_period_end).max(); + let (Some(start), Some(end), Ok(period)) = (start, end, period_of(billing_period)) else { + return Span { + from: None, + through: None, + partial: false, + }; + }; + let slack = Duration::days(COVERAGE_SLACK_DAYS); + Span { + from: Some(start.date_naive()), + // An end is exclusive, so the last day covered is the one before it. + through: Some((end - Duration::seconds(1)).date_naive()), + partial: start - midnight(period.start()) > slack + || midnight(period.end_exclusive()) - end > slack, + } +} + +/// The figure by charge category. The components add up to the ledger total: +/// every category is listed, so a credit or a tax cannot fall out of sight. +fn components_of(invoices: &[Invoice], authoritative: &[&SnapshotLine]) -> Vec { + let stated = |pick: fn(&Invoice) -> Option| -> Option { + invoices + .iter() + .filter_map(pick) + .fold(None, |sum, value| Some(sum.unwrap_or(0.0) + value)) + }; + [ + (ChargeCategory::Usage, None), + (ChargeCategory::Purchase, None), + (ChargeCategory::Credit, stated(|invoice| invoice.credits)), + (ChargeCategory::Tax, stated(|invoice| invoice.tax)), + ( + ChargeCategory::Adjustment, + stated(|invoice| invoice.adjustments), + ), + ] + .into_iter() + .filter_map(|(category, invoice)| { + let mut rows = 0; + let mut ledger = 0.0; + for line in authoritative { + if line.charge.charge_category == category { + rows += 1; + ledger += line.charge.billed_cost.unwrap_or(0.0); + } + } + (rows > 0 || invoice.is_some()).then_some(ReconcileComponent { + category, + invoice, + ledger, + rows, + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analytics::snapshot; + use crate::model::Charge; + use chrono::TimeZone; + + fn now() -> DateTime { + Utc.with_ymd_and_hms(2026, 10, 5, 0, 0, 0).unwrap() + } + + fn key() -> PeriodKey { + PeriodKey::new("AWS", "payer", "2026-09") + } + + fn charge(day: u32, category: ChargeCategory, amount: f64, currency: &str) -> Charge { + Charge { + service_name: Some("EC2".to_string()), + charge_category: category, + billed_cost: Some(amount), + effective_cost: Some(amount), + billing_account_id: Some("payer".to_string()), + ..Charge::new( + Utc.with_ymd_and_hms(2026, 9, day, 0, 0, 0).unwrap(), + Utc.with_ymd_and_hms(2026, 9, day, 23, 59, 59).unwrap(), + currency, + ) + } + } + + /// A month's usage on its first and last days, so it covers the month. + fn month(amount: f64) -> Vec { + vec![ + charge(1, ChargeCategory::Usage, amount / 2.0, "USD"), + charge(30, ChargeCategory::Usage, amount / 2.0, "USD"), + ] + } + + fn lines(charges: Vec) -> Vec { + snapshot::charge_ids(&key(), &charges) + .into_iter() + .zip(charges) + .map(|(charge_id, charge)| SnapshotLine { + charge_id, + charge, + fx_rate: None, + }) + .collect() + } + + fn invoice(amount: f64) -> Invoice { + Invoice { + invoice_id: "i-1".to_string(), + key: key(), + invoice_ref: "INV-1".to_string(), + currency: "USD".to_string(), + amount, + tax: None, + credits: None, + adjustments: None, + billing_account_id: None, + source: InvoiceSource::Manual, + source_file: None, + entered_at: now(), + } + } + + fn check(invoices: &[Invoice], charges: Vec) -> Reconciliation { + reconcile( + invoices, + &Evidence { + lines: &lines(charges), + period_open: false, + neighbours: &[], + }, + ) + } + + #[test] + fn an_equal_amount_matches_and_shows_its_basis() { + let result = check(&[invoice(100.0)], month(100.0)); + assert_eq!(result.status, ReconcileStatus::Matched); + assert_eq!(result.difference, Some(0.0)); + assert_eq!(result.ledger_total, Some(100.0)); + assert_eq!(result.coverage.authoritative_rows, 2); + assert!(result.reasons.is_empty()); + } + + #[test] + fn a_difference_inside_one_minor_unit_is_tolerated_and_still_stated() { + let result = check(&[invoice(100.01)], month(100.0)); + assert_eq!(result.status, ReconcileStatus::Matched); + assert!((result.difference.unwrap() - 0.01).abs() < 1e-9); + let result = check(&[invoice(100.02)], month(100.0)); + assert_eq!(result.status, ReconcileStatus::Difference); + } + + #[test] + fn a_mismatch_gives_the_exact_original_currency_delta_and_a_breakdown() { + let mut charges = month(100.0); + charges.push(charge(15, ChargeCategory::Tax, 8.0, "USD")); + let result = check(&[invoice(120.5)], charges); + assert_eq!(result.status, ReconcileStatus::Difference); + assert_eq!(result.currency, "USD"); + assert_eq!(result.difference, Some(12.5)); + let summed: f64 = result.components.iter().map(|c| c.ledger).sum(); + assert_eq!(summed, 108.0); + assert_eq!(result.components[0].category, ChargeCategory::Usage); + assert_eq!(result.components[1].category, ChargeCategory::Tax); + } + + #[test] + fn a_consolidated_payer_invoice_matches_its_linked_accounts_together() { + let linked = |sub: &str, day: u32, amount: f64| Charge { + sub_account_id: Some(sub.to_string()), + ..charge(day, ChargeCategory::Usage, amount, "USD") + }; + let charges = vec![ + linked("111111111111", 1, 60.0), + linked("222222222222", 30, 40.0), + ]; + let result = check(&[invoice(100.0)], charges); + assert_eq!(result.status, ReconcileStatus::Matched); + } + + #[test] + fn two_billing_accounts_need_a_scope_before_one_invoice_can_answer() { + let mut other = month(50.0); + for charge in &mut other { + charge.billing_account_id = Some("other".to_string()); + } + let mut charges = month(100.0); + charges.extend(other); + + let unscoped = check(&[invoice(150.0)], charges.clone()); + assert_eq!(unscoped.status, ReconcileStatus::CannotVerify); + assert!(unscoped + .reasons + .contains(&ReconcileReason::MixedBillingAccounts { accounts: 2 })); + + let scoped = Invoice { + billing_account_id: Some("payer".to_string()), + ..invoice(100.0) + }; + assert_eq!(check(&[scoped], charges).status, ReconcileStatus::Matched); + } + + #[test] + fn several_invoices_of_one_scope_answer_together() { + let second = Invoice { + invoice_id: "i-2".to_string(), + invoice_ref: "INV-2".to_string(), + ..invoice(40.0) + }; + let result = check(&[invoice(60.0), second], month(100.0)); + assert_eq!(result.status, ReconcileStatus::Matched); + assert_eq!(result.invoice_total, 100.0); + } + + #[test] + fn a_revised_bill_matches_the_frozen_copy_and_differs_from_the_ledger() { + let frozen = month(100.0); + let revised = month(90.0); + assert_eq!( + check(&[invoice(100.0)], frozen).status, + ReconcileStatus::Matched + ); + let against_current = check(&[invoice(100.0)], revised); + assert_eq!(against_current.status, ReconcileStatus::Difference); + assert_eq!(against_current.difference, Some(10.0)); + } + + #[test] + fn no_rows_cannot_verify_rather_than_differing_from_zero() { + let result = check(&[invoice(100.0)], Vec::new()); + assert_eq!(result.status, ReconcileStatus::CannotVerify); + assert_eq!(result.reasons, vec![ReconcileReason::NoLedgerRows]); + assert_eq!(result.ledger_total, None); + assert_eq!(result.difference, None); + } + + #[test] + fn a_zero_invoice_against_no_rows_is_never_a_match() { + let result = check(&[invoice(0.0)], Vec::new()); + assert_eq!(result.status, ReconcileStatus::CannotVerify); + } + + #[test] + fn an_incompatible_currency_cannot_verify_and_is_not_converted() { + let charges = vec![ + charge(1, ChargeCategory::Usage, 100.0, "EUR"), + charge(30, ChargeCategory::Usage, 100.0, "EUR"), + ]; + let result = check(&[invoice(200.0)], charges); + assert_eq!(result.status, ReconcileStatus::CannotVerify); + assert!(result.reasons.contains(&ReconcileReason::CurrencyMismatch)); + assert!(result.reasons.contains(&ReconcileReason::OtherCurrency { + currency: "EUR".to_string(), + rows: 2 + })); + } + + #[test] + fn another_currency_stays_out_of_the_figure_and_is_named() { + let mut charges = month(100.0); + charges.push(charge(9, ChargeCategory::Usage, 70.0, "CNY")); + let result = check(&[invoice(100.0)], charges); + assert_eq!(result.status, ReconcileStatus::Matched); + assert_eq!( + result.reasons, + vec![ReconcileReason::OtherCurrency { + currency: "CNY".to_string(), + rows: 1 + }] + ); + } + + #[test] + fn an_open_month_cannot_verify_even_when_the_figures_agree() { + let result = reconcile( + &[invoice(100.0)], + &Evidence { + lines: &lines(month(100.0)), + period_open: true, + neighbours: &[], + }, + ); + assert_eq!(result.status, ReconcileStatus::CannotVerify); + assert!(result.reasons.contains(&ReconcileReason::PeriodOpen)); + } + + #[test] + fn a_partial_export_that_differs_cannot_verify_and_says_where_it_stops() { + let charges = vec![ + charge(1, ChargeCategory::Usage, 40.0, "USD"), + charge(20, ChargeCategory::Usage, 40.0, "USD"), + ]; + let result = check(&[invoice(100.0)], charges); + assert_eq!(result.status, ReconcileStatus::CannotVerify); + assert!(result.reasons.iter().any(|reason| matches!( + reason, + ReconcileReason::PartialCoverage { through: Some(through), .. } + if *through == NaiveDate::from_ymd_opt(2026, 9, 20).unwrap() + ))); + } + + #[test] + fn a_short_month_that_agrees_exactly_still_matches() { + let charges = vec![charge(1, ChargeCategory::Usage, 100.0, "USD")]; + assert_eq!( + check(&[invoice(100.0)], charges).status, + ReconcileStatus::Matched + ); + } + + #[test] + fn derived_and_estimated_amounts_are_not_compared() { + let mut charges = month(100.0); + charges[0].cost_basis = CostBasis::Estimated; + let result = check(&[invoice(100.0)], charges); + assert_eq!(result.status, ReconcileStatus::CannotVerify); + assert_eq!(result.ledger_total, Some(50.0)); + assert!(result + .reasons + .contains(&ReconcileReason::NonAuthoritative { rows: 1 })); + } + + #[test] + fn effective_cost_is_never_the_figure() { + let mut charges = month(100.0); + for charge in &mut charges { + charge.effective_cost = Some(10.0); + } + assert_eq!( + check(&[invoice(100.0)], charges).status, + ReconcileStatus::Matched + ); + } + + #[test] + fn usage_rows_alone_cannot_verify_and_beside_a_bill_are_only_noted() { + let tokens = |day: u32| Charge { + cost_basis: CostBasis::Absent, + billed_cost: None, + effective_cost: None, + ..charge(day, ChargeCategory::Usage, 0.0, "USD") + }; + let alone = check(&[invoice(0.0)], vec![tokens(1), tokens(30)]); + assert_eq!(alone.status, ReconcileStatus::CannotVerify); + assert!(alone.reasons.contains(&ReconcileReason::NoAmounts)); + + let mut beside = month(100.0); + beside.push(tokens(5)); + let result = check(&[invoice(100.0)], beside); + assert_eq!(result.status, ReconcileStatus::Matched); + assert_eq!( + result.reasons, + vec![ReconcileReason::UsageWithoutAmount { rows: 1 }] + ); + } + + #[test] + fn credits_and_tax_count_once_on_the_ledger_side() { + let mut charges = month(100.0); + charges.push(charge(15, ChargeCategory::Credit, -20.0, "USD")); + charges.push(charge(15, ChargeCategory::Tax, 8.0, "USD")); + let stated = Invoice { + tax: Some(8.0), + credits: Some(-20.0), + ..invoice(88.0) + }; + let result = check(&[stated], charges); + assert_eq!(result.status, ReconcileStatus::Matched); + assert_eq!(result.ledger_total, Some(88.0)); + let credit = result + .components + .iter() + .find(|c| c.category == ChargeCategory::Credit) + .unwrap(); + assert_eq!(credit.ledger, -20.0); + assert_eq!(credit.invoice, Some(-20.0)); + } + + #[test] + fn tax_on_one_side_is_named_as_the_likely_gap() { + let mut charges = month(100.0); + charges.push(charge(15, ChargeCategory::Tax, 8.0, "USD")); + let result = check(&[invoice(100.0)], charges); + assert_eq!(result.status, ReconcileStatus::Difference); + assert!(result.reasons.contains(&ReconcileReason::TaxDiffers)); + + let stated = Invoice { + tax: Some(8.0), + ..invoice(108.0) + }; + let result = check(&[stated], month(100.0)); + assert!(result.reasons.contains(&ReconcileReason::TaxDiffers)); + assert_eq!(result.components.last().unwrap().ledger, 0.0); + } + + #[test] + fn a_late_credit_or_adjustment_is_named() { + let mut charges = month(100.0); + charges.push(charge(30, ChargeCategory::Credit, -5.0, "USD")); + let result = check(&[invoice(100.0)], charges); + assert!(result.reasons.contains(&ReconcileReason::CreditsDiffer)); + + let mut charges = month(100.0); + charges.push(charge(30, ChargeCategory::Adjustment, 3.0, "USD")); + let result = check(&[invoice(100.0)], charges); + assert!(result.reasons.contains(&ReconcileReason::AdjustmentsDiffer)); + } + + #[test] + fn an_invoice_that_fits_the_next_month_says_so() { + let result = reconcile( + &[invoice(250.0)], + &Evidence { + lines: &lines(month(100.0)), + period_open: false, + neighbours: &[("2026-10".to_string(), 250.0)], + }, + ); + assert_eq!(result.status, ReconcileStatus::Difference); + assert!(result.reasons.contains(&ReconcileReason::MonthMismatch { + period: "2026-10".to_string() + })); + } + + fn draft() -> InvoiceDraft { + InvoiceDraft { + provider: "AWS".to_string(), + account_id: "payer".to_string(), + billing_period: "2026-09".to_string(), + invoice_ref: "INV-1".to_string(), + currency: "usd".to_string(), + amount: "100.50".to_string(), + ..InvoiceDraft::default() + } + } + + #[test] + fn a_draft_is_validated_into_an_invoice() { + let invoice = invoice_from(&draft(), InvoiceSource::Manual, None, now()).unwrap(); + assert_eq!(invoice.currency, "USD"); + assert_eq!(invoice.amount, 100.5); + assert_eq!(invoice.tax, None); + assert_eq!(invoice.key, key()); + + let refused = |edit: fn(&mut InvoiceDraft)| { + let mut draft = draft(); + edit(&mut draft); + invoice_from(&draft, InvoiceSource::Manual, None, now()).unwrap_err() + }; + assert_eq!( + refused(|d| d.amount = String::new()), + InvoiceError::MissingField("amount") + ); + assert!(matches!( + refused(|d| d.amount = "1,234.50".to_string()), + InvoiceError::BadAmount { .. } + )); + assert!(matches!( + refused(|d| d.amount = "NaN".to_string()), + InvoiceError::BadAmount { .. } + )); + assert!(matches!( + refused(|d| d.billing_period = "2026-13".to_string()), + InvoiceError::BadPeriod(_) + )); + assert!(matches!( + refused(|d| d.currency = "US".to_string()), + InvoiceError::BadCurrency(_) + )); + assert!(matches!( + refused(|d| d.credits = "5".to_string()), + InvoiceError::PositiveCredit(_) + )); + } + + #[test] + fn a_csv_reads_with_optional_columns_quotes_and_a_bom() { + let text = "\u{feff}provider,account_id,billing_period,invoice_id,currency,amount,tax\n\ + AWS,payer,2026-09,\"INV,1\",USD,100.50,8\n\ + \n\ + AWS,payer,2026-08,INV-2,usd,90,\n"; + let invoices = invoices_from_csv(text, Some("bills.csv"), now()).unwrap(); + assert_eq!(invoices.len(), 2); + assert_eq!(invoices[0].invoice_ref, "INV,1"); + assert_eq!(invoices[0].tax, Some(8.0)); + assert_eq!(invoices[0].source, InvoiceSource::Csv); + assert_eq!(invoices[0].source_file.as_deref(), Some("bills.csv")); + assert_eq!(invoices[1].tax, None); + } + + #[test] + fn a_csv_with_one_bad_line_is_refused_whole_and_names_it() { + let text = "provider,account_id,billing_period,invoice_id,currency,amount\n\ + AWS,payer,2026-09,INV-1,USD,100\n\ + AWS,payer,2026-08,INV-2,USD,oops\n"; + assert!(matches!( + invoices_from_csv(text, None, now()), + Err(CsvError::Row { line: 3, .. }) + )); + assert_eq!( + invoices_from_csv("provider,amount\nAWS,1\n", None, now()), + Err(CsvError::MissingColumn("account_id")) + ); + assert_eq!(invoices_from_csv("", None, now()), Err(CsvError::Empty)); + let short = "provider,account_id,billing_period,invoice_id,currency,amount\nAWS,payer\n"; + assert_eq!( + invoices_from_csv(short, None, now()), + Err(CsvError::WrongFieldCount { line: 2 }) + ); + } + + #[test] + fn invoices_group_by_scope_and_currency() { + let other_currency = Invoice { + currency: "EUR".to_string(), + ..invoice(1.0) + }; + let groups = group_invoices(vec![invoice(1.0), invoice(2.0), other_currency]); + assert_eq!(groups.len(), 2); + assert_eq!(groups.iter().map(Vec::len).sum::(), 3); + } + + #[test] + fn neighbours_cross_a_year() { + assert_eq!(neighbouring_periods("2026-01"), vec!["2025-12", "2026-02"]); + assert_eq!(neighbouring_periods("2026-12"), vec!["2026-11", "2027-01"]); + } +} diff --git a/src/app.rs b/src/app.rs index 53eb46c..b69acb2 100644 --- a/src/app.rs +++ b/src/app.rs @@ -13,7 +13,8 @@ use crate::ui::data::SyncStatus; use crate::ui::{ account_detail::AccountDetailView, accounts::AccountsView, alerts::AlertsView, attribution::AttributionView, insights::InsightsView, models::ModelsView, - overview::OverviewView, query::QueryView, rules::RulesView, settings::SettingsView, + overview::OverviewView, query::QueryView, reconciliation::ReconciliationView, rules::RulesView, + settings::SettingsView, }; use crate::ui::{fmt, platform_key_binding, theme}; @@ -25,6 +26,7 @@ actions!( SwitchToAttribution, SwitchToModels, SwitchToInsights, + SwitchToReconciliation, SwitchToQuery, SwitchToAccounts, SwitchToRules, @@ -198,6 +200,7 @@ pub struct CloudBridgeApp { models_view: Entity, /// Insights view insights_view: Entity, + reconciliation_view: Entity, /// Query view query_view: Entity, /// Accounts view @@ -235,6 +238,7 @@ pub enum CurrentView { Attribution, Models, Insights, + Reconciliation, Query, Accounts, /// Per-account drill-down; not a sidebar entry — the Accounts nav item @@ -255,6 +259,7 @@ impl CloudBridgeApp { let attribution_view = cx.new(|cx| AttributionView::new(window, cx)); let models_view = cx.new(|cx| ModelsView::new(window, cx)); let insights_view = cx.new(|cx| InsightsView::new(window, cx)); + let reconciliation_view = cx.new(|cx| ReconciliationView::new(window, cx)); let query_view = cx.new(|cx| QueryView::new(window, cx)); let accounts_view = cx.new(|cx| AccountsView::new(window, cx)); let account_detail_view = cx.new(|cx| AccountDetailView::new(window, cx)); @@ -353,6 +358,7 @@ impl CloudBridgeApp { attribution_view, models_view, insights_view, + reconciliation_view, query_view, accounts_view, account_detail_view, @@ -425,6 +431,9 @@ impl CloudBridgeApp { v.reload(cx); } }), + CurrentView::Reconciliation => { + self.reconciliation_view.update(cx, |v, cx| v.reload(cx)) + } CurrentView::Query => self.query_view.update(cx, |v, cx| v.reload(cx)), CurrentView::Accounts => self.accounts_view.update(cx, |v, cx| { if first_visit { @@ -644,6 +653,14 @@ impl CloudBridgeApp { None, cx, )) + .child(self.nav_item( + tr!("nav.reconciliation"), + IconName::CircleCheck, + CurrentView::Reconciliation, + current == CurrentView::Reconciliation, + None, + cx, + )) .child( div() .px_3() @@ -881,6 +898,9 @@ impl CloudBridgeApp { CurrentView::Attribution => div().size_full().child(self.attribution_view.clone()), CurrentView::Models => div().size_full().child(self.models_view.clone()), CurrentView::Insights => div().size_full().child(self.insights_view.clone()), + CurrentView::Reconciliation => { + div().size_full().child(self.reconciliation_view.clone()) + } CurrentView::Query => div().size_full().child(self.query_view.clone()), CurrentView::Accounts => div().size_full().child(self.accounts_view.clone()), CurrentView::AccountDetail => div().size_full().child(self.account_detail_view.clone()), @@ -913,6 +933,9 @@ impl Render for CloudBridgeApp { .on_action(cx.listener(|this, _: &SwitchToInsights, _, cx| { this.switch_to(CurrentView::Insights, cx); })) + .on_action(cx.listener(|this, _: &SwitchToReconciliation, _, cx| { + this.switch_to(CurrentView::Reconciliation, cx); + })) .on_action(cx.listener(|this, _: &SwitchToQuery, _, cx| { this.switch_to(CurrentView::Query, cx); })) diff --git a/src/demo_data.rs b/src/demo_data.rs index 67a2db0..8903144 100644 --- a/src/demo_data.rs +++ b/src/demo_data.rs @@ -22,7 +22,8 @@ pub mod inventory; use chrono::{DateTime, NaiveDate, Utc}; use crate::model::{ - BalanceSnapshot, BillingPeriod, Charge, ChargeCategory, CloudAccount, CostBasis, + BalanceSnapshot, BillingPeriod, Charge, ChargeCategory, CloudAccount, CostBasis, Invoice, + InvoiceSource, PeriodKey, }; /// Account-id prefix marking every demo row; also the marker `ingest` uses to @@ -220,6 +221,70 @@ fn token_quantities( } } +/// A few invoices for the Reconciliation page: one that matches, one that does +/// not, and one in a currency the account is not billed in. The other months +/// are left without an invoice, which is a state of their own. +pub fn invoices(periods: &[BillingPeriod], now: DateTime) -> Vec { + let total = |provider: &str, currency: &str, index: usize| -> f64 { + period_charges( + provider, + services_of(provider), + currency, + periods[index], + index, + now, + ) + .iter() + .filter(|charge| charge.cost_basis == CostBasis::Authoritative) + .filter_map(|charge| charge.billed_cost) + .sum() + }; + let invoice = |provider: &str, account: &str, index: usize, currency: &str, amount: f64| { + let period = periods[index].label(); + Invoice { + invoice_id: format!("{DEMO_PREFIX}invoice-{account}-{period}"), + invoice_ref: format!("INV-{}", period.replace('-', "")), + key: PeriodKey::new(provider, account, period), + currency: currency.to_string(), + amount, + tax: None, + credits: None, + adjustments: None, + billing_account_id: None, + source: InvoiceSource::Manual, + source_file: None, + entered_at: now, + } + }; + let Some(last_ended) = periods.len().checked_sub(2) else { + return Vec::new(); + }; + let mut invoices = vec![invoice( + "AWS", + "demo-aws", + last_ended, + "USD", + total("AWS", "USD", last_ended), + )]; + if let Some(earlier) = last_ended.checked_sub(1) { + invoices.push(invoice( + "AWS", + "demo-aws", + earlier, + "USD", + total("AWS", "USD", earlier) + 42.5, + )); + } + invoices.push(invoice( + "OpenAI", + "demo-openai", + last_ended, + "EUR", + total("OpenAI", "USD", last_ended), + )); + invoices +} + /// One demo account, as [`DEMO_SOURCES`] describes it. /// /// It carries no credentials: a demo account never signs a request, so diff --git a/src/ledger/demo.rs b/src/ledger/demo.rs index 8442c93..8541dfd 100644 --- a/src/ledger/demo.rs +++ b/src/ledger/demo.rs @@ -59,6 +59,8 @@ pub fn seed_demo() -> Result { record_balance(&snapshot)?; } + super::invoice::save_invoices(&demo_data::invoices(&periods, now))?; + // An imported scan is real data: the demo does not replace it. let (scope, resources) = demo_data::inventory::inventory(now); with_connection(|conn| { @@ -112,6 +114,10 @@ pub fn clear_demo() -> Result { "DELETE FROM billing_snapshot WHERE account_id LIKE 'demo-%'", [], )?; + tx.execute( + "DELETE FROM invoice_evidence WHERE account_id LIKE 'demo-%'", + [], + )?; tx.execute( "DELETE FROM fct_balance_snapshot WHERE account_id LIKE 'demo-%'", [], diff --git a/src/ledger/invoice.rs b/src/ledger/invoice.rs new file mode 100644 index 0000000..7309230 --- /dev/null +++ b/src/ledger/invoice.rs @@ -0,0 +1,371 @@ +//! Invoice evidence in DuckDB. Kept apart from `fct_charge`, which nothing +//! here reads from or writes to. The comparison is +//! [`crate::analytics::reconciliation`]. + +use anyhow::{anyhow, Result}; +use duckdb::{params, Connection, Row}; + +use super::schema::{NORMALIZED_VIEW, TIMESTAMP_FORMAT}; +use super::{parse_timestamp, with_connection, with_connection_ref}; +use crate::model::{Invoice, InvoiceSource, PeriodKey}; + +const INVOICE_SELECT: &str = "invoice_id, provider, account_id, billing_period, invoice_ref, + currency, amount, tax, credits, adjustments, billing_account_id, source, source_file, + CAST(entered_at AS VARCHAR)"; + +/// Store invoices. One carrying the same reference for the same month and +/// account replaces the earlier entry, so importing a corrected file does not +/// count a bill twice. All or nothing. Returns how many were stored. +pub fn save_invoices(invoices: &[Invoice]) -> Result { + with_connection(|conn| save_invoices_in(conn, invoices)) +} + +/// Every invoice on record. +pub fn all_invoices() -> Result> { + with_connection_ref(all_invoices_in) +} + +/// Remove one invoice from the evidence. +pub fn delete_invoice(invoice_id: &str) -> Result<()> { + with_connection_ref(|conn| { + conn.execute( + "DELETE FROM invoice_evidence WHERE invoice_id = ?", + params![invoice_id], + )?; + Ok(()) + }) +} + +/// A period's authoritative billed total per currency, without reading its +/// lines. +pub fn billed_totals(key: &PeriodKey) -> Result> { + with_connection_ref(|conn| billed_totals_in(conn, key)) +} + +pub(crate) fn save_invoices_in(conn: &mut Connection, invoices: &[Invoice]) -> Result { + let tx = conn.transaction()?; + for invoice in invoices { + tx.execute( + "DELETE FROM invoice_evidence + WHERE provider = ? AND account_id = ? AND billing_period = ? AND invoice_ref = ?", + params![ + invoice.key.provider, + invoice.key.account_id, + invoice.key.billing_period, + invoice.invoice_ref + ], + )?; + tx.execute( + "INSERT INTO invoice_evidence + (invoice_id, provider, account_id, billing_period, invoice_ref, currency, amount, + tax, credits, adjustments, billing_account_id, source, source_file, entered_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS TIMESTAMP))", + params![ + invoice.invoice_id, + invoice.key.provider, + invoice.key.account_id, + invoice.key.billing_period, + invoice.invoice_ref, + invoice.currency, + invoice.amount, + invoice.tax, + invoice.credits, + invoice.adjustments, + invoice.billing_account_id, + invoice.source.as_str(), + invoice.source_file, + invoice.entered_at.format(TIMESTAMP_FORMAT).to_string(), + ], + )?; + } + tx.commit()?; + Ok(invoices.len()) +} + +pub(crate) fn all_invoices_in(conn: &Connection) -> Result> { + let mut stmt = conn.prepare(&format!( + "SELECT {INVOICE_SELECT} FROM invoice_evidence + ORDER BY billing_period DESC, provider, account_id, invoice_ref" + ))?; + let mut rows = stmt.query([])?; + let mut invoices = Vec::new(); + while let Some(row) = rows.next()? { + invoices.push(invoice_from(row)?); + } + Ok(invoices) +} + +pub(crate) fn billed_totals_in(conn: &Connection, key: &PeriodKey) -> Result> { + let mut stmt = conn.prepare(&format!( + "SELECT billing_currency, sum(billed_cost) FROM {NORMALIZED_VIEW} + WHERE provider = ? AND account_id = ? AND billing_period = ? + AND cost_basis = 'authoritative' AND billed_cost IS NOT NULL + GROUP BY billing_currency ORDER BY billing_currency" + ))?; + let totals = stmt + .query_map( + params![key.provider, key.account_id, key.billing_period], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, f64>(1)?)), + )? + .collect::, _>>()?; + Ok(totals) +} + +fn invoice_from(row: &Row<'_>) -> Result { + let source: String = row.get(11)?; + Ok(Invoice { + invoice_id: row.get(0)?, + key: PeriodKey::new( + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + ), + invoice_ref: row.get(4)?, + currency: row.get(5)?, + amount: row.get(6)?, + tax: row.get(7)?, + credits: row.get(8)?, + adjustments: row.get(9)?, + billing_account_id: row.get(10)?, + source: InvoiceSource::from_stored(&source) + .ok_or_else(|| anyhow!("Unknown invoice source {source:?} in the ledger"))?, + source_file: row.get(12)?, + entered_at: parse_timestamp(&row.get::<_, String>(13)?)?, + }) +} + +#[cfg(test)] +mod tests { + use super::super::snapshot::{freeze_of, read_current_for_tests, stored_lines_for_tests}; + use super::super::{delete_account_history_of, schema, write_period}; + use super::*; + use crate::analytics::reconciliation::{reconcile, Evidence}; + use crate::model::{ + Channel, Charge, ChargeCategory, FreezeOutcome, InvoiceSource, ReconcileStatus, + }; + use chrono::{DateTime, TimeZone, Utc}; + + fn conn() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + schema::apply(&conn).unwrap(); + schema::apply_reporting_currency(&conn, "USD").unwrap(); + conn + } + + fn key() -> PeriodKey { + PeriodKey::new("AWS", "acct-1", "2026-08") + } + + fn at(day: u32) -> DateTime { + Utc.with_ymd_and_hms(2026, 8, day, 0, 0, 0).unwrap() + } + + fn invoice(reference: &str, amount: f64) -> Invoice { + Invoice { + invoice_id: uuid::Uuid::new_v4().to_string(), + key: key(), + invoice_ref: reference.to_string(), + currency: "USD".to_string(), + amount, + tax: Some(1.5), + credits: None, + adjustments: None, + billing_account_id: Some("payer".to_string()), + source: InvoiceSource::Csv, + source_file: Some("bills.csv".to_string()), + entered_at: at(31), + } + } + + fn usage(day: u32, amount: f64) -> Charge { + Charge { + service_name: Some("EC2".to_string()), + billed_cost: Some(amount), + effective_cost: Some(amount), + ..Charge::new(at(day), at(day + 1), "USD") + } + } + + fn charge_count(conn: &Connection) -> i64 { + conn.query_row("SELECT count(*) FROM fct_charge", [], |row| row.get(0)) + .unwrap() + } + + #[test] + fn an_invoice_reads_back_as_it_was_stored() { + let mut conn = conn(); + let stored = invoice("INV-1", 100.25); + save_invoices_in(&mut conn, std::slice::from_ref(&stored)).unwrap(); + assert_eq!(all_invoices_in(&conn).unwrap(), vec![stored]); + } + + #[test] + fn the_same_reference_replaces_and_a_new_one_adds() { + let mut conn = conn(); + save_invoices_in(&mut conn, &[invoice("INV-1", 100.0)]).unwrap(); + save_invoices_in(&mut conn, &[invoice("INV-1", 110.0)]).unwrap(); + save_invoices_in(&mut conn, &[invoice("INV-2", 5.0)]).unwrap(); + let stored = all_invoices_in(&conn).unwrap(); + assert_eq!(stored.len(), 2); + assert_eq!(stored[0].amount, 110.0); + } + + #[test] + fn evidence_never_touches_charges_and_charges_never_touch_evidence() { + let mut conn = conn(); + write_period( + &mut conn, + &key(), + "b-1", + &[usage(1, 10.0)], + None, + Channel::Api, + ) + .unwrap(); + let before = read_current_for_tests(&conn, &key()); + save_invoices_in(&mut conn, &[invoice("INV-1", 999.0)]).unwrap(); + assert_eq!(charge_count(&conn), 1); + assert_eq!( + crate::analytics::snapshot::checksum(&before), + crate::analytics::snapshot::checksum(&read_current_for_tests(&conn, &key())) + ); + + write_period( + &mut conn, + &key(), + "b-2", + &[usage(1, 20.0)], + None, + Channel::Api, + ) + .unwrap(); + assert_eq!(all_invoices_in(&conn).unwrap().len(), 1); + } + + #[test] + fn deleting_one_invoice_and_deleting_an_account_remove_the_evidence() { + let mut conn = conn(); + let first = invoice("INV-1", 1.0); + save_invoices_in(&mut conn, &[first.clone(), invoice("INV-2", 2.0)]).unwrap(); + conn.execute( + "DELETE FROM invoice_evidence WHERE invoice_id = ?", + params![first.invoice_id], + ) + .unwrap(); + assert_eq!(all_invoices_in(&conn).unwrap().len(), 1); + + delete_account_history_of(&mut conn, "acct-1").unwrap(); + assert!(all_invoices_in(&conn).unwrap().is_empty()); + } + + #[test] + fn billed_totals_hold_only_authoritative_amounts_per_currency() { + let mut conn = conn(); + let estimated = Charge { + cost_basis: crate::model::CostBasis::Estimated, + ..usage(2, 100.0) + }; + let credit = Charge { + charge_category: ChargeCategory::Credit, + ..usage(3, -4.0) + }; + let eur = Charge { + billing_currency: "EUR".to_string(), + ..usage(4, 7.0) + }; + write_period( + &mut conn, + &key(), + "b-1", + &[usage(1, 10.0), estimated, credit, eur], + None, + Channel::Api, + ) + .unwrap(); + assert_eq!( + billed_totals_in(&conn, &key()).unwrap(), + vec![("EUR".to_string(), 7.0), ("USD".to_string(), 6.0)] + ); + } + + #[test] + fn the_demo_invoices_show_a_match_a_difference_and_a_currency_gap() { + use crate::analytics::reconciliation::reconcile; + use crate::demo_data; + + let now = Utc.with_ymd_and_hms(2026, 10, 15, 0, 0, 0).unwrap(); + let periods = demo_data::periods(now); + let mut conn = conn(); + for (provider, account, _, currency) in demo_data::DEMO_SOURCES { + for (index, period) in periods.iter().enumerate() { + let charges = demo_data::period_charges( + provider, + demo_data::services_of(provider), + currency, + *period, + index, + now, + ); + let key = PeriodKey::new(*provider, *account, period.label()); + let batch = format!("{account}-{}", period.label()); + write_period(&mut conn, &key, &batch, &charges, None, Channel::Api).unwrap(); + } + } + + let invoices = demo_data::invoices(&periods, now); + let status: Vec<_> = invoices + .iter() + .map(|invoice| { + let lines = read_current_for_tests(&conn, &invoice.key); + let evidence = Evidence { + lines: &lines, + period_open: false, + neighbours: &[], + }; + reconcile(std::slice::from_ref(invoice), &evidence).status + }) + .collect(); + assert_eq!( + status, + vec![ + ReconcileStatus::Matched, + ReconcileStatus::Difference, + ReconcileStatus::CannotVerify, + ] + ); + } + + #[test] + fn a_revised_bill_is_compared_with_the_ledger_or_the_frozen_copy() { + let mut conn = conn(); + let bill = [usage(1, 50.0), usage(29, 50.0)]; + write_period(&mut conn, &key(), "b-1", &bill, None, Channel::File).unwrap(); + let later = Utc.with_ymd_and_hms(2026, 9, 2, 0, 0, 0).unwrap(); + let FreezeOutcome::Frozen(frozen) = freeze_of(&mut conn, &key(), later).unwrap() else { + panic!("expected a new revision"); + }; + let revised = [usage(1, 50.0), usage(29, 40.0)]; + write_period(&mut conn, &key(), "b-2", &revised, None, Channel::File).unwrap(); + + let invoices = [Invoice { + billing_account_id: None, + tax: None, + ..invoice("INV-1", 100.0) + }]; + let against = |lines: &[crate::model::SnapshotLine]| { + reconcile( + &invoices, + &Evidence { + lines, + period_open: false, + neighbours: &[], + }, + ) + }; + let current = against(&read_current_for_tests(&conn, &key())); + assert_eq!(current.status, ReconcileStatus::Difference); + assert_eq!(current.difference, Some(10.0)); + let snapshot = against(&stored_lines_for_tests(&conn, &frozen.snapshot_id)); + assert_eq!(snapshot.status, ReconcileStatus::Matched); + } +} diff --git a/src/ledger/mod.rs b/src/ledger/mod.rs index fbe773e..5efc3a2 100644 --- a/src/ledger/mod.rs +++ b/src/ledger/mod.rs @@ -16,6 +16,7 @@ pub mod demo; pub mod focus_export; pub mod inventory; +pub mod invoice; pub mod query; pub mod rollup; pub mod schema; @@ -120,8 +121,8 @@ pub(crate) fn confirm_period_of(conn: &mut Connection, key: &PeriodKey) -> Resul } /// Remove everything the ledger holds for one account — charges, ingest -/// batches, balance snapshots, the day rollup and month-end snapshots — in -/// one transaction. +/// batches, balance snapshots, the day rollup, month-end snapshots and invoice +/// evidence — in one transaction. /// Returns how many charges went. The raw payloads are the caller's: see /// [`crate::ingest::delete_account_history`]. pub fn delete_account_history(account_id: &str) -> Result { @@ -144,6 +145,7 @@ pub(crate) fn delete_account_history_of(conn: &mut Connection, account_id: &str) "fct_balance_snapshot", "daily_cost_rollup", "billing_snapshot", + "invoice_evidence", ] { tx.execute( &format!("DELETE FROM {table} WHERE account_id = ?"), diff --git a/src/ledger/schema.rs b/src/ledger/schema.rs index 03187f9..1e4a0ac 100644 --- a/src/ledger/schema.rs +++ b/src/ledger/schema.rs @@ -66,7 +66,11 @@ use duckdb::{params, Connection}; /// v10 adds `billing_snapshot` and `billing_snapshot_charge`: frozen copies /// of a period's bill, which whole-period replacement never touches. New /// tables only, so the CREATEs below are the migration. -pub const SCHEMA_VERSION: i32 = 10; +/// +/// v11 adds `invoice_evidence`: invoices recorded to compare the ledger with, +/// never read as charges. A new table only, so the CREATE below is the +/// migration. +pub const SCHEMA_VERSION: i32 = 11; /// The view the application reads: every charge with its amount also /// expressed in the reporting currency. @@ -243,6 +247,26 @@ pub fn apply(conn: &Connection) -> Result<()> { fx_rate DOUBLE ); + -- An invoice the user recorded. Evidence to hold the ledger against, + -- apart from fct_charge and never summed with it. + CREATE TABLE IF NOT EXISTS invoice_evidence ( + invoice_id VARCHAR PRIMARY KEY, + provider VARCHAR NOT NULL, + account_id VARCHAR NOT NULL, + billing_period VARCHAR NOT NULL, -- YYYY-MM + invoice_ref VARCHAR NOT NULL, + currency VARCHAR NOT NULL, + amount DOUBLE NOT NULL, + tax DOUBLE, + credits DOUBLE, + adjustments DOUBLE, + billing_account_id VARCHAR, + source VARCHAR NOT NULL, -- manual | csv + source_file VARCHAR, + entered_at TIMESTAMP NOT NULL, + UNIQUE (provider, account_id, billing_period, invoice_ref) + ); + -- A balance is state, not a charge: sources that only report one -- (DeepSeek today) land here, and only their top-ups become charges. CREATE TABLE IF NOT EXISTS fct_balance_snapshot ( diff --git a/src/ledger/snapshot.rs b/src/ledger/snapshot.rs index 049031c..bfc8fa5 100644 --- a/src/ledger/snapshot.rs +++ b/src/ledger/snapshot.rs @@ -336,6 +336,18 @@ fn line_from(row: &Row<'_>) -> Result { }) } +/// The lines of a period, for another module's tests. +#[cfg(test)] +pub(crate) fn read_current_for_tests(conn: &Connection, key: &PeriodKey) -> Vec { + read_current(conn, key).unwrap() +} + +/// The lines of a snapshot, for another module's tests. +#[cfg(test)] +pub(crate) fn stored_lines_for_tests(conn: &Connection, snapshot_id: &str) -> Vec { + stored_lines(conn, snapshot_id).unwrap() +} + #[cfg(test)] mod tests { use super::super::{delete_account_history_of, schema, write_period}; diff --git a/src/model.rs b/src/model.rs index 6def450..4640774 100644 --- a/src/model.rs +++ b/src/model.rs @@ -340,6 +340,137 @@ pub struct AmountDelta { pub delta: f64, } +/// How an invoice reached the evidence. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InvoiceSource { + Manual, + Csv, +} + +impl InvoiceSource { + pub fn as_str(self) -> &'static str { + match self { + Self::Manual => "manual", + Self::Csv => "csv", + } + } + + /// The source a stored value names. + pub fn from_stored(value: &str) -> Option { + match value { + "manual" => Some(Self::Manual), + "csv" => Some(Self::Csv), + _ => None, + } + } +} + +/// A provider's final bill for a period, as the user recorded it. Evidence to +/// compare the ledger with, never a charge. The breakdown is what the invoice +/// states and is not added to `amount`, which is its final total. +#[derive(Debug, Clone, PartialEq)] +pub struct Invoice { + pub invoice_id: String, + pub key: PeriodKey, + /// The provider's own reference for the invoice. + pub invoice_ref: String, + pub currency: String, + pub amount: f64, + pub tax: Option, + /// Zero or negative. + pub credits: Option, + pub adjustments: Option, + /// Narrows the comparison to one billing account's charges. + pub billing_account_id: Option, + pub source: InvoiceSource, + pub source_file: Option, + pub entered_at: DateTime, +} + +/// What comparing an invoice with the ledger found. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReconcileStatus { + Matched, + Difference, + /// The figures cannot be compared, whatever they say. + CannotVerify, + /// The month has charges and no invoice to compare them with. + MissingEvidence, +} + +/// Why a comparison came out as it did, or what it leaves out. +#[derive(Debug, Clone, PartialEq)] +pub enum ReconcileReason { + NoLedgerRows, + /// The scope has charges, none in the invoice's currency. + CurrencyMismatch, + /// Every row in the currency is usage with no amount. + NoAmounts, + PeriodOpen, + /// Rows with a derived or estimated amount, which are not in the figure. + NonAuthoritative { + rows: usize, + }, + MixedBillingAccounts { + accounts: usize, + }, + PartialCoverage { + from: Option, + through: Option, + }, + TaxDiffers, + CreditsDiffer, + AdjustmentsDiffer, + /// The invoice's total fits this other month's ledger. + MonthMismatch { + period: String, + }, + /// Usage rows with no amount, beside a bill that has them. + UsageWithoutAmount { + rows: usize, + }, + /// Rows in a currency this invoice does not answer for. + OtherCurrency { + currency: String, + rows: usize, + }, +} + +/// One charge category of a comparison. +#[derive(Debug, Clone, PartialEq)] +pub struct ReconcileComponent { + pub category: ChargeCategory, + /// What the invoice states, if it breaks this category out. + pub invoice: Option, + pub ledger: f64, + pub rows: usize, +} + +/// What the ledger side of a comparison covers. +#[derive(Debug, Clone, PartialEq)] +pub struct ReconcileCoverage { + pub rows: usize, + pub authoritative_rows: usize, + pub from: Option, + pub through: Option, +} + +/// One group of invoices held against a set of charges. +#[derive(Debug, Clone, PartialEq)] +pub struct Reconciliation { + pub status: ReconcileStatus, + pub currency: String, + pub invoice_total: f64, + /// `None` when no row carries an authoritative amount. + pub ledger_total: Option, + /// Invoice minus ledger, in `currency`. + pub difference: Option, + pub tolerance: f64, + pub components: Vec, + pub reasons: Vec, + pub coverage: ReconcileCoverage, +} + /// How one charge differs between a frozen month and the ledger. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LineChangeKind { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index d8e8a1f..097721c 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -16,6 +16,7 @@ pub mod models; pub mod overview; pub mod palette; pub mod query; +pub mod reconciliation; mod report; pub mod rules; mod security; diff --git a/src/ui/palette.rs b/src/ui/palette.rs index f5be19b..abe86a8 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -85,6 +85,13 @@ fn pages() -> Vec { action: || Box::new(app::SwitchToInsights), keywords: &["idle", "savings", "resources"], }, + Page { + label: "Reconciliation", + key: "nav.reconciliation", + icon: IconName::CircleCheck, + action: || Box::new(app::SwitchToReconciliation), + keywords: &["invoice", "bill", "match"], + }, ]; #[cfg(not(target_family = "wasm"))] pages.push(Page { @@ -277,6 +284,7 @@ fn page_views() -> Vec { CurrentView::Attribution, CurrentView::Models, CurrentView::Insights, + CurrentView::Reconciliation, ]; #[cfg(not(target_family = "wasm"))] views.push(CurrentView::Query); diff --git a/src/ui/reconciliation.rs b/src/ui/reconciliation.rs new file mode 100644 index 0000000..6085bb5 --- /dev/null +++ b/src/ui/reconciliation.rs @@ -0,0 +1,1565 @@ +//! The Reconciliation page: does each provider's final bill match what was +//! imported? One table across accounts, problems first, with a row opening in +//! place onto why. +//! +//! A row is never blank and never reads as a success by default: a month with +//! charges and no invoice is `Missing evidence`, not an empty line. + +use std::collections::HashMap; + +use anyhow::Result; +use chrono::{NaiveDate, Utc}; +use gpui_kit::component::dialog::{AlertDialog, Cancel, Dialog, DialogButtonProps, DialogFooter}; +use gpui_kit::component::input::{Input, InputEvent, InputState}; +use gpui_kit::component::{button::*, *}; +use gpui_kit::prelude::FluentBuilder; +use gpui_kit::*; + +use super::{dialogs, fmt, theme}; +#[cfg(not(target_family = "wasm"))] +use crate::analytics::reconciliation::{self, CsvError}; +use crate::analytics::reconciliation::{ + group_invoices, invoice_from, neighbouring_periods, reconcile, Evidence, InvoiceDraft, + InvoiceError, +}; +use crate::analytics::snapshot; +use crate::ledger::invoice::{all_invoices, billed_totals, delete_invoice, save_invoices}; +use crate::ledger::snapshot::{current_lines, periods_of, snapshot_lines, snapshots_of}; +use crate::model::{ + BillingSnapshot, ChargeCategory, Invoice, InvoiceSource, PeriodKey, ReconcileReason, + ReconcileStatus, Reconciliation, +}; +use crate::ui::theme::CardOutline as _; + +/// How many of an account's ended months can show as missing evidence. An +/// older gap is not something anyone is about to act on, and it would bury +/// the rows that are. +const MISSING_MONTHS: usize = 3; + +/// How wide the amount columns are, so they share a spine down the page. +const AMOUNT_WIDTH: f32 = 7.5; + +/// An account the page can record an invoice against. +#[derive(Clone)] +struct AccountChoice { + provider: String, + account_id: String, + name: String, +} + +/// One line of the table: a group of invoices and its comparison with the +/// ledger, or a month with charges and no invoice. +#[derive(Clone)] +struct Row { + key: PeriodKey, + account_name: String, + currency: String, + invoices: Vec, + ledger_total: Option, + invoice_total: Option, + difference: Option, + status: ReconcileStatus, +} + +impl Row { + /// What this row is, whatever order it sorts into. + fn id(&self) -> String { + let scope = self + .invoices + .first() + .and_then(|invoice| invoice.billing_account_id.as_deref()) + .unwrap_or(""); + format!( + "{}/{}/{}/{}/{}", + self.key.provider, self.key.account_id, self.key.billing_period, self.currency, scope + ) + } + + fn reference(&self) -> String { + self.invoices + .iter() + .map(|invoice| invoice.invoice_ref.as_str()) + .collect::>() + .join(", ") + } +} + +/// A sortable column of the table. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Column { + Month, + Account, + Invoice, + Ledger, + Difference, +} + +/// The row a selected row opens onto. +struct OpenRow { + id: String, + /// The snapshot compared with; `None` is the current ledger. + basis: Option, + revisions: Vec, + result: Option, +} + +struct PageData { + accounts: Vec, + rows: Vec, +} + +/// Compare a group of invoices with the ledger or one of its snapshots. +/// Blocking. +fn compare(invoices: &[Invoice], basis: Option<&BillingSnapshot>) -> Result { + let first = &invoices[0]; + let (lines, period_open) = match basis { + Some(frozen) => (snapshot_lines(&frozen.snapshot_id)?, frozen.period_open), + None => ( + current_lines(&first.key)?, + snapshot::period_is_open(&first.key.billing_period, Utc::now())?, + ), + }; + let mut neighbours = Vec::new(); + for period in neighbouring_periods(&first.key.billing_period) { + let key = PeriodKey::new( + first.key.provider.clone(), + first.key.account_id.clone(), + period, + ); + for (currency, total) in billed_totals(&key)? { + if currency == first.currency { + neighbours.push((key.billing_period.clone(), total)); + } + } + } + Ok(reconcile( + invoices, + &Evidence { + lines: &lines, + period_open, + neighbours: &neighbours, + }, + )) +} + +/// Read the page. Blocking; it runs in `smol::unblock`. +fn load_page() -> Result { + let accounts: Vec = crate::db::get_all_accounts()? + .into_iter() + .map(|account| AccountChoice { + provider: account.source_id.as_str().to_string(), + account_id: account.id, + name: account.name, + }) + .collect(); + let names: HashMap<&str, &str> = accounts + .iter() + .map(|account| (account.account_id.as_str(), account.name.as_str())) + .collect(); + let name_of = |id: &str| { + names + .get(id) + .map_or_else(|| id.to_string(), |n| n.to_string()) + }; + + let mut rows = Vec::new(); + for group in group_invoices(all_invoices()?) { + let result = compare(&group, None)?; + rows.push(Row { + key: group[0].key.clone(), + account_name: name_of(&group[0].key.account_id), + currency: result.currency, + ledger_total: result.ledger_total, + invoice_total: Some(result.invoice_total), + difference: result.difference, + status: result.status, + invoices: group, + }); + } + + let now = Utc::now(); + let mut missing = Vec::new(); + for account in &accounts { + let ended = periods_of(&account.account_id)? + .into_iter() + .filter(|key| !snapshot::period_is_open(&key.billing_period, now).unwrap_or(true)) + .take(MISSING_MONTHS); + for key in ended { + for (currency, total) in billed_totals(&key)? { + let covered = rows + .iter() + .any(|row: &Row| row.key == key && row.currency == currency); + if !covered { + missing.push(Row { + key: key.clone(), + account_name: account.name.clone(), + currency, + invoices: Vec::new(), + ledger_total: Some(total), + invoice_total: None, + difference: None, + status: ReconcileStatus::MissingEvidence, + }); + } + } + } + } + rows.extend(missing); + Ok(PageData { accounts, rows }) +} + +/// Problems first: what is wrong, then what cannot be checked, then what has +/// not been entered, and only then what is fine. +fn status_rank(status: ReconcileStatus) -> u8 { + match status { + ReconcileStatus::Difference => 0, + ReconcileStatus::CannotVerify => 1, + ReconcileStatus::MissingEvidence => 2, + ReconcileStatus::Matched => 3, + } +} + +fn row_matches(row: &Row, text: &str, status: Option) -> bool { + if status.is_some_and(|status| status != row.status) { + return false; + } + let text = text.trim().to_lowercase(); + text.is_empty() + || [ + row.key.provider.as_str(), + row.key.account_id.as_str(), + row.account_name.as_str(), + row.key.billing_period.as_str(), + row.currency.as_str(), + row.reference().as_str(), + ] + .iter() + .any(|field| field.to_lowercase().contains(&text)) +} + +/// Order the table. `None` is the default: status, then newest month. An +/// absent figure sorts last either way, because it is not a small one. +fn sort_rows(rows: &mut [Row], sort: Option<(Column, bool)>) { + let identity = |a: &Row, b: &Row| a.id().cmp(&b.id()); + let Some((column, descending)) = sort else { + rows.sort_by(|a, b| { + status_rank(a.status) + .cmp(&status_rank(b.status)) + .then_with(|| b.key.billing_period.cmp(&a.key.billing_period)) + .then_with(|| identity(a, b)) + }); + return; + }; + let direct = |order: std::cmp::Ordering| if descending { order.reverse() } else { order }; + let figure = |a: Option, b: Option| match (a, b) { + (Some(a), Some(b)) => direct(a.total_cmp(&b)), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + }; + rows.sort_by(|a, b| { + let order = match column { + Column::Month => direct(a.key.billing_period.cmp(&b.key.billing_period)), + Column::Account => direct(a.account_name.cmp(&b.account_name)), + Column::Invoice => figure(a.invoice_total, b.invoice_total), + Column::Ledger => figure(a.ledger_total, b.ledger_total), + Column::Difference => figure(a.difference, b.difference), + }; + order.then_with(|| identity(a, b)) + }); +} + +fn status_label(status: ReconcileStatus) -> SharedString { + match status { + ReconcileStatus::Matched => tr!("reconciliation.status_matched"), + ReconcileStatus::Difference => tr!("reconciliation.status_difference"), + ReconcileStatus::CannotVerify => tr!("reconciliation.status_cannot_verify"), + ReconcileStatus::MissingEvidence => tr!("reconciliation.status_missing"), + } +} + +/// Text and icon carry the status; color only repeats it. +fn status_badge(status: ReconcileStatus, cx: &App) -> Div { + let (icon, bg, fg) = match status { + ReconcileStatus::Matched => ( + IconName::CircleCheck, + theme::success_bg(cx), + theme::success(cx), + ), + ReconcileStatus::Difference => ( + IconName::TriangleAlert, + theme::danger_bg(cx), + theme::danger(cx), + ), + ReconcileStatus::CannotVerify => ( + IconName::CircleX, + theme::warning_bg(cx), + theme::warning_text(cx), + ), + ReconcileStatus::MissingEvidence => { + (IconName::Info, theme::sidebar_bg(cx), theme::text_muted(cx)) + } + }; + div() + .h_flex() + .items_center() + .gap_1() + .child(Icon::new(icon).small().text_color(fg)) + .child(theme::pill(status_label(status), bg, fg)) +} + +fn date_text(date: Option) -> String { + date.map_or_else( + || "—".to_string(), + |date| date.format("%Y-%m-%d").to_string(), + ) +} + +/// What a reason means and what to do about it. +fn reason_text(reason: &ReconcileReason) -> SharedString { + match reason { + ReconcileReason::NoLedgerRows => tr!("reconciliation.reason_no_rows"), + ReconcileReason::CurrencyMismatch => tr!("reconciliation.reason_currency"), + ReconcileReason::NoAmounts => tr!("reconciliation.reason_no_amounts"), + ReconcileReason::PeriodOpen => tr!("reconciliation.reason_open"), + ReconcileReason::NonAuthoritative { rows } => { + tr!("reconciliation.reason_non_authoritative", rows = rows) + } + ReconcileReason::MixedBillingAccounts { accounts } => { + tr!( + "reconciliation.reason_billing_accounts", + accounts = accounts + ) + } + ReconcileReason::PartialCoverage { from, through } => tr!( + "reconciliation.reason_partial", + from = date_text(*from), + through = date_text(*through) + ), + ReconcileReason::TaxDiffers => tr!("reconciliation.reason_tax"), + ReconcileReason::CreditsDiffer => tr!("reconciliation.reason_credits"), + ReconcileReason::AdjustmentsDiffer => tr!("reconciliation.reason_adjustments"), + ReconcileReason::MonthMismatch { period } => { + tr!("reconciliation.reason_month", period = period) + } + ReconcileReason::UsageWithoutAmount { rows } => { + tr!("reconciliation.reason_usage", rows = rows) + } + ReconcileReason::OtherCurrency { currency, rows } => tr!( + "reconciliation.reason_other_currency", + currency = currency, + rows = rows + ), + } +} + +fn invoice_error_text(error: &InvoiceError) -> SharedString { + match error { + InvoiceError::MissingField(field) => tr!("reconciliation.error_missing", field = field), + InvoiceError::BadPeriod(value) => tr!("reconciliation.error_period", value = value), + InvoiceError::BadCurrency(value) => tr!("reconciliation.error_currency", value = value), + InvoiceError::BadAmount { field, value } => { + tr!("reconciliation.error_amount", field = field, value = value) + } + InvoiceError::PositiveCredit(value) => tr!("reconciliation.error_credit", value = value), + } +} + +#[cfg(not(target_family = "wasm"))] +fn csv_error_text(error: &CsvError) -> SharedString { + match error { + CsvError::Empty => tr!("reconciliation.csv_empty"), + CsvError::MissingColumn(column) => tr!("reconciliation.csv_column", column = column), + CsvError::WrongFieldCount { line } => tr!("reconciliation.csv_fields", line = line), + CsvError::Row { line, error } => tr!( + "reconciliation.csv_row", + line = line, + error = invoice_error_text(error) + ), + } +} + +fn category_label(category: ChargeCategory) -> SharedString { + match category { + ChargeCategory::Usage => tr!("snapshots.category_usage"), + ChargeCategory::Purchase => tr!("snapshots.category_purchase"), + ChargeCategory::Credit => tr!("snapshots.category_credit"), + ChargeCategory::Tax => tr!("snapshots.category_tax"), + ChargeCategory::Adjustment => tr!("snapshots.category_adjustment"), + } +} + +fn amount_text(value: Option, currency: &str) -> SharedString { + value.map_or_else( + || "—".into(), + |value| fmt::exact_amount(value, currency).into(), + ) +} + +fn signed_text(value: Option, currency: &str) -> SharedString { + value.map_or_else( + || "—".into(), + |value| fmt::signed_exact_amount(value, currency).into(), + ) +} + +/// The page. +pub struct ReconciliationView { + accounts: Vec, + rows: Vec, + loaded: bool, + loading: bool, + error: Option, + notice: Option, + generation: u64, + + filter: Entity, + status_filter: Option, + sort: Option<(Column, bool)>, + open: Option, + detail_generation: u64, + + form_account: Option, + form_period: Entity, + form_reference: Entity, + form_currency: Entity, + form_amount: Entity, + form_tax: Entity, + form_credits: Entity, + form_adjustments: Entity, + form_billing_account: Entity, + form_error: Option, + saving: bool, + pending_delete: Option, + #[cfg(not(target_family = "wasm"))] + importing: bool, + + _subscriptions: Vec, +} + +impl ReconciliationView { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let filter = cx.new(|cx| { + InputState::new(window, cx).placeholder(tr!("reconciliation.filter_placeholder")) + }); + let subscription = cx.subscribe(&filter, |_, _, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + cx.notify(); + } + }); + let mut input = |placeholder: SharedString| { + cx.new(|cx| InputState::new(window, cx).placeholder(placeholder)) + }; + let form_period = input("YYYY-MM".into()); + let form_reference = input(tr!("reconciliation.ph_reference")); + let form_currency = input("USD".into()); + let form_amount = input("0.00".into()); + let form_tax = input(tr!("reconciliation.ph_optional")); + let form_credits = input(tr!("reconciliation.ph_credits")); + let form_adjustments = input(tr!("reconciliation.ph_optional")); + let form_billing_account = input(tr!("reconciliation.ph_optional")); + Self { + accounts: Vec::new(), + rows: Vec::new(), + loaded: false, + loading: false, + error: None, + notice: None, + generation: 0, + filter, + status_filter: None, + sort: None, + open: None, + detail_generation: 0, + form_account: None, + form_period, + form_reference, + form_currency, + form_amount, + form_tax, + form_credits, + form_adjustments, + form_billing_account, + form_error: None, + saving: false, + pending_delete: None, + #[cfg(not(target_family = "wasm"))] + importing: false, + _subscriptions: vec![subscription], + } + } + + /// Reload the page. Called by the shell when it is navigated to. + pub fn reload(&mut self, cx: &mut Context) { + if !self.loading { + self.load(cx); + } + } + + fn load(&mut self, cx: &mut Context) { + self.loading = true; + self.generation += 1; + let generation = self.generation; + cx.spawn(async move |this, cx| { + let result = smol::unblock(load_page).await; + this.update(cx, |this, cx| { + if this.generation != generation { + return; + } + this.loading = false; + this.loaded = true; + match result { + Ok(data) => { + this.accounts = data.accounts; + this.rows = data.rows; + this.error = None; + this.refresh_open(cx); + } + Err(e) => this.error = Some(tr!("reconciliation.load_failed", error = e)), + } + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + fn toggle_sort(&mut self, column: Column, cx: &mut Context) { + let first_descending = !matches!(column, Column::Account); + self.sort = match self.sort { + Some((current, descending)) if current == column => { + (descending == first_descending).then_some((column, !descending)) + } + _ => Some((column, first_descending)), + }; + cx.notify(); + } + + fn toggle_row(&mut self, id: String, cx: &mut Context) { + if self.open.as_ref().is_some_and(|open| open.id == id) { + self.open = None; + cx.notify(); + return; + } + self.open = Some(OpenRow { + id, + basis: None, + revisions: Vec::new(), + result: None, + }); + self.load_open(cx); + } + + fn select_basis(&mut self, basis: Option, cx: &mut Context) { + if let Some(open) = &mut self.open { + open.basis = basis; + open.result = None; + } + self.load_open(cx); + } + + /// After the rows reload, the open row reads its figures again, or closes + /// if its evidence went. + fn refresh_open(&mut self, cx: &mut Context) { + let Some(open) = &self.open else { + return; + }; + if self.rows.iter().any(|row| row.id() == open.id) { + self.load_open(cx); + } else { + self.open = None; + } + } + + fn load_open(&mut self, cx: &mut Context) { + let Some(open) = &self.open else { + return; + }; + let Some(row) = self.rows.iter().find(|row| row.id() == open.id) else { + return; + }; + let id = open.id.clone(); + let basis = open.basis.clone(); + let key = row.key.clone(); + let invoices = row.invoices.clone(); + self.detail_generation += 1; + let generation = self.detail_generation; + cx.spawn(async move |this, cx| { + let result = smol::unblock(move || -> Result<_> { + let revisions = snapshots_of(&key)?; + let chosen = basis + .as_ref() + .and_then(|id| revisions.iter().find(|s| s.snapshot_id == *id)); + let result = if invoices.is_empty() { + None + } else { + Some(compare(&invoices, chosen)?) + }; + Ok((revisions, result)) + }) + .await; + this.update(cx, |this, cx| { + if this.detail_generation != generation { + return; + } + match result { + Ok((revisions, result)) => { + if let Some(open) = this.open.as_mut().filter(|open| open.id == id) { + open.revisions = revisions; + open.result = result; + } + } + Err(e) => this.error = Some(tr!("reconciliation.load_failed", error = e)), + } + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + /// Open the entry form, already filled in when it comes from a month with + /// no invoice. + fn open_form(&mut self, prefill: Option, window: &mut Window, cx: &mut Context) { + self.form_error = None; + self.form_account = prefill + .as_ref() + .map(|row| row.key.account_id.clone()) + .or_else(|| self.accounts.first().map(|a| a.account_id.clone())); + let period = prefill + .as_ref() + .map(|row| row.key.billing_period.clone()) + .unwrap_or_default(); + let currency = prefill + .as_ref() + .map(|row| row.currency.clone()) + .unwrap_or_default(); + for (input, value) in [ + (&self.form_period, period), + (&self.form_currency, currency), + (&self.form_reference, String::new()), + (&self.form_amount, String::new()), + (&self.form_tax, String::new()), + (&self.form_credits, String::new()), + (&self.form_adjustments, String::new()), + (&self.form_billing_account, String::new()), + ] { + input.update(cx, |state, cx| state.set_value(value, window, cx)); + } + dialogs::open(window, cx, |this, dialog, window, cx| { + this.render_form(dialog, window, cx) + }); + } + + fn submit_form(&mut self, window: &mut Window, cx: &mut Context) { + if self.saving { + return; + } + let account = self + .accounts + .iter() + .find(|account| Some(&account.account_id) == self.form_account.as_ref()); + let value = |input: &Entity| input.read(cx).value().to_string(); + let draft = InvoiceDraft { + provider: account.map(|a| a.provider.clone()).unwrap_or_default(), + account_id: account.map(|a| a.account_id.clone()).unwrap_or_default(), + billing_period: value(&self.form_period), + invoice_ref: value(&self.form_reference), + currency: value(&self.form_currency), + amount: value(&self.form_amount), + tax: value(&self.form_tax), + credits: value(&self.form_credits), + adjustments: value(&self.form_adjustments), + billing_account_id: value(&self.form_billing_account), + }; + let invoice = match invoice_from(&draft, InvoiceSource::Manual, None, Utc::now()) { + Ok(invoice) => invoice, + Err(error) => { + self.form_error = Some(invoice_error_text(&error)); + cx.notify(); + return; + } + }; + self.saving = true; + self.form_error = None; + cx.notify(); + let reference = invoice.invoice_ref.clone(); + cx.spawn_in(window, async move |this, cx| { + let result = smol::unblock(move || save_invoices(&[invoice])).await; + this.update_in(cx, |this, window, cx| { + this.saving = false; + match result { + Ok(_) => { + window.close_dialog(cx); + this.notice = Some(tr!("reconciliation.saved", reference = reference)); + this.load(cx); + } + Err(e) => { + this.form_error = Some(tr!("reconciliation.save_failed", error = e)); + cx.notify(); + } + } + }) + .ok(); + }) + .detach(); + } + + #[cfg(not(target_family = "wasm"))] + fn import_csv(&mut self, cx: &mut Context) { + if self.importing { + return; + } + self.importing = true; + self.notice = None; + self.error = None; + let chosen = cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: Some(tr!("reconciliation.import_csv")), + }); + cx.notify(); + let known: Vec<(String, String)> = self + .accounts + .iter() + .map(|account| (account.provider.clone(), account.account_id.clone())) + .collect(); + cx.spawn(async move |this, cx| { + let path = match chosen.await { + Ok(Ok(Some(paths))) => paths.into_iter().next(), + _ => None, + }; + let outcome: Option> = match path { + None => None, + Some(path) => Some( + smol::unblock(move || -> Result { + let text = std::fs::read_to_string(&path) + .map_err(|e| tr!("reconciliation.csv_unreadable", error = e))?; + let name = path.file_name().map(|name| name.to_string_lossy()); + let invoices = + reconciliation::invoices_from_csv(&text, name.as_deref(), Utc::now()) + .map_err(|error| csv_error_text(&error))?; + if let Some(stray) = invoices.iter().find(|invoice| { + !known.iter().any(|(provider, account)| { + *provider == invoice.key.provider + && *account == invoice.key.account_id + }) + }) { + return Err(tr!( + "reconciliation.csv_unknown_account", + provider = stray.key.provider, + account = stray.key.account_id + )); + } + save_invoices(&invoices) + .map_err(|e| tr!("reconciliation.save_failed", error = e)) + }) + .await, + ), + }; + this.update(cx, |this, cx| { + this.importing = false; + match outcome { + None => {} + Some(Ok(count)) => { + this.notice = Some(tr!("reconciliation.imported", n = count)); + this.load(cx); + } + Some(Err(error)) => this.error = Some(error), + } + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + fn confirm_delete(&mut self, invoice: Invoice, window: &mut Window, cx: &mut Context) { + self.pending_delete = Some(invoice); + dialogs::open_alert(window, cx, |this, alert, _, cx| { + this.render_delete(alert, cx) + }); + } + + fn render_delete(&mut self, alert: AlertDialog, cx: &mut Context) -> AlertDialog { + let view = cx.entity().downgrade(); + let Some(invoice) = self.pending_delete.clone() else { + return alert; + }; + alert + .title(tr!( + "reconciliation.delete_title", + reference = invoice.invoice_ref + )) + .description(tr!("reconciliation.delete_body")) + .button_props( + DialogButtonProps::default() + .ok_text(tr!("common.delete")) + .ok_variant(ButtonVariant::Danger) + .show_cancel(true), + ) + .on_ok(move |_, _, cx| { + view.update(cx, |this, cx| { + if let Some(invoice) = this.pending_delete.take() { + this.delete(invoice, cx); + } + }) + .ok(); + true + }) + .on_close(cx.listener(|this, _, _, cx| { + this.pending_delete = None; + cx.notify(); + })) + } + + fn delete(&mut self, invoice: Invoice, cx: &mut Context) { + cx.spawn(async move |this, cx| { + let result = smol::unblock(move || delete_invoice(&invoice.invoice_id)).await; + this.update(cx, |this, cx| { + match result { + Ok(()) => this.load(cx), + Err(e) => this.error = Some(tr!("reconciliation.save_failed", error = e)), + } + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + fn render_form( + &mut self, + dialog: Dialog, + window: &mut Window, + cx: &mut Context, + ) -> Dialog { + let field = |label: SharedString, input: &Entity| { + div() + .flex_1() + .min_w_0() + .v_flex() + .gap_1() + .child(div().text_sm().child(label)) + .child(Input::new(input)) + }; + let accounts = div() + .h_flex() + .flex_wrap() + .gap_2() + .children(self.accounts.iter().map(|account| { + let id = account.account_id.clone(); + let selected = self.form_account.as_ref() == Some(&id); + Button::new(SharedString::from(format!("invoice-account-{id}"))) + .label(account.name.clone()) + .when(selected, |button| button.primary()) + .when(!selected, |button| { + button.custom(theme::outline_variant(cx)).card_outline(cx) + }) + .on_click(cx.listener(move |this, _, _, cx| { + this.form_account = Some(id.clone()); + cx.notify(); + })) + })); + dialog + .title(tr!("reconciliation.form_title")) + .w(rems(34.).to_pixels(window.rem_size())) + .child( + div() + .v_flex() + .gap_4() + .child( + div() + .v_flex() + .gap_1() + .child(div().text_sm().child(tr!("reconciliation.col_account"))) + .when(self.accounts.is_empty(), |el| { + el.child(theme::caption(cx, tr!("reconciliation.no_accounts"))) + }) + .child(accounts), + ) + .child( + div() + .h_flex() + .gap_4() + .child(field(tr!("reconciliation.col_month"), &self.form_period)) + .child(field( + tr!("reconciliation.col_invoice_id"), + &self.form_reference, + )), + ) + .child( + div() + .h_flex() + .gap_4() + .child(field( + tr!("reconciliation.col_currency"), + &self.form_currency, + )) + .child(field(tr!("reconciliation.field_total"), &self.form_amount)), + ) + .child( + div() + .h_flex() + .gap_4() + .child(field(tr!("snapshots.category_tax"), &self.form_tax)) + .child(field(tr!("snapshots.category_credit"), &self.form_credits)) + .child(field( + tr!("snapshots.category_adjustment"), + &self.form_adjustments, + )), + ) + .child(field( + tr!("reconciliation.field_billing_account"), + &self.form_billing_account, + )) + .child(theme::caption(cx, tr!("reconciliation.form_hint"))), + ) + .footer( + DialogFooter::new() + .when_some(self.form_error.clone(), |el, error| { + el.child( + div() + .flex_1() + .min_w_0() + .text_sm() + .text_color(theme::danger(cx)) + .child(error), + ) + }) + .child( + Button::new("invoice-cancel") + .label(tr!("common.cancel")) + .custom(theme::outline_variant(cx)) + .card_outline(cx) + .on_click(|_, window, cx| window.dispatch_action(Box::new(Cancel), cx)), + ) + .child( + Button::new("invoice-save") + .label(tr!("reconciliation.save")) + .primary() + .loading(self.saving) + .disabled(self.saving || self.accounts.is_empty()) + .on_click(cx.listener(|this, _, window, cx| { + this.submit_form(window, cx); + })), + ), + ) + } + + fn render_toolbar(&self, cx: &mut Context) -> Div { + let pills = [ + None, + Some(ReconcileStatus::Difference), + Some(ReconcileStatus::CannotVerify), + Some(ReconcileStatus::MissingEvidence), + Some(ReconcileStatus::Matched), + ]; + div() + .w_full() + .h_flex() + .flex_wrap() + .items_center() + .gap_3() + .child(div().w(rems(18.)).child(Input::new(&self.filter))) + .child( + div() + .h_flex() + .flex_wrap() + .gap_1() + .children(pills.into_iter().enumerate().map(|(index, status)| { + let active = self.status_filter == status; + let count = self + .rows + .iter() + .filter(|row| status.is_none_or(|status| row.status == status)) + .count(); + let label = status.map_or_else(|| tr!("reconciliation.all"), status_label); + let button = Button::new(("reconciliation-status", index)) + .label(SharedString::from(format!("{label} {count}"))) + .small() + .rounded_full() + .custom(theme::range_pill(cx, active)) + .on_click(cx.listener(move |this, _, _, cx| { + this.status_filter = status; + cx.notify(); + })); + if active { + button.card_outline(cx).font_weight(FontWeight::MEDIUM) + } else { + button + } + })), + ) + .child(div().flex_1()) + .when(cfg!(not(target_family = "wasm")), |el| { + #[cfg(not(target_family = "wasm"))] + let el = el.child( + Button::new("reconciliation-import") + .label(tr!("reconciliation.import_csv")) + .tooltip(tr!( + "reconciliation.csv_format", + columns = reconciliation::CSV_COLUMNS.join(", ") + )) + .custom(theme::outline_variant(cx)) + .card_outline(cx) + .loading(self.importing) + .disabled(self.importing) + .on_click(cx.listener(|this, _, _, cx| this.import_csv(cx))), + ); + el + }) + .child( + Button::new("reconciliation-enter") + .label(tr!("reconciliation.enter_total")) + .custom(theme::outline_variant(cx)) + .card_outline(cx) + .on_click(cx.listener(|this, _, window, cx| { + this.open_form(None, window, cx); + })), + ) + } + + fn sort_header(&self, column: Column, label: SharedString, cx: &mut Context) -> Button { + let direction = self + .sort + .filter(|(sorted, _)| *sorted == column) + .map(|(_, descending)| descending); + Button::new(SharedString::from(format!( + "reconciliation-sort-{column:?}" + ))) + .label(label) + .ghost() + .small() + .compact() + .when_some(direction, |button, descending| { + button.icon(if descending { + IconName::ChevronDown + } else { + IconName::ChevronUp + }) + }) + .on_click(cx.listener(move |this, _, _, cx| this.toggle_sort(column, cx))) + } + + fn render_table(&self, cx: &mut Context) -> Div { + let text = self.filter.read(cx).value().to_string(); + let mut rows: Vec = self + .rows + .iter() + .filter(|row| row_matches(row, &text, self.status_filter)) + .cloned() + .collect(); + sort_rows(&mut rows, self.sort); + + let header = div() + .w_full() + .h_flex() + .gap_3() + .pb_2() + .child(div().flex_1().min_w_0().child(self.sort_header( + Column::Account, + tr!("reconciliation.col_account"), + cx, + ))) + .child(div().w(rems(6.)).flex_shrink_0().child(self.sort_header( + Column::Month, + tr!("reconciliation.col_month"), + cx, + ))) + .child( + div() + .w(rems(8.)) + .flex_shrink_0() + .child(theme::header_cell(cx, tr!("reconciliation.col_invoice_id"))), + ) + .child(amount_header(self.sort_header( + Column::Invoice, + tr!("reconciliation.col_invoice"), + cx, + ))) + .child(amount_header(self.sort_header( + Column::Ledger, + tr!("reconciliation.col_ledger"), + cx, + ))) + .child(amount_header(self.sort_header( + Column::Difference, + tr!("reconciliation.col_difference"), + cx, + ))) + .child( + div() + .w(rems(9.)) + .flex_shrink_0() + .child(theme::header_cell(cx, tr!("reconciliation.col_status"))), + ); + + let mut table = div().v_flex().child(header); + if rows.is_empty() { + let message = if self.rows.is_empty() { + tr!("reconciliation.empty") + } else { + tr!("reconciliation.no_match") + }; + return table.child( + div() + .py_6() + .border_t_1() + .border_color(theme::card_border(cx)) + .child(theme::caption(cx, message)), + ); + } + for row in rows { + let is_open = self.open.as_ref().is_some_and(|open| open.id == row.id()); + table = table.child(self.render_row(&row, is_open, cx)); + if is_open { + table = table.child(self.render_detail(&row, cx)); + } + } + table + } + + fn render_row(&self, row: &Row, is_open: bool, cx: &mut Context) -> impl IntoElement { + let id = row.id(); + let keyboard_id = id.clone(); + let hover = theme::sidebar_bg(cx); + let focus_ring = theme::sidebar_bg(cx); + let accessible_name = SharedString::from(format!( + "{} {} {}", + row.account_name, + row.key.billing_period, + status_label(row.status) + )); + let reference = if row.invoices.is_empty() { + "—".to_string() + } else { + row.reference() + }; + div() + .id(SharedString::from(format!("reconciliation-row-{id}"))) + .role(Role::Button) + .aria_label(accessible_name) + .aria_expanded(is_open) + .tab_index(0) + .focus_visible(move |style| style.bg(focus_ring)) + .w_full() + .h_flex() + .items_center() + .gap_3() + .py_2() + .border_t_1() + .border_color(theme::card_border(cx)) + .cursor_pointer() + .hover(move |style| style.bg(hover)) + .when(is_open, |el| el.bg(hover)) + .text_sm() + .text_color(theme::text_primary(cx)) + .child( + div() + .flex_1() + .min_w_0() + .h_flex() + .items_center() + .gap_2() + .child( + Icon::new(if is_open { + IconName::ChevronDown + } else { + IconName::ChevronRight + }) + .small() + .text_color(theme::text_muted(cx)), + ) + .child( + div() + .min_w_0() + .whitespace_nowrap() + .text_ellipsis() + .child(format!("{} ({})", row.account_name, row.key.provider)), + ), + ) + .child( + div() + .w(rems(6.)) + .flex_shrink_0() + .child(row.key.billing_period.clone()), + ) + .child( + div() + .w(rems(8.)) + .flex_shrink_0() + .whitespace_nowrap() + .text_ellipsis() + .child(reference), + ) + .child(amount_cell(amount_text(row.invoice_total, &row.currency))) + .child(amount_cell(amount_text(row.ledger_total, &row.currency))) + .child(amount_cell(signed_text(row.difference, &row.currency))) + .child( + div() + .w(rems(9.)) + .flex_shrink_0() + .child(status_badge(row.status, cx)), + ) + .on_click(cx.listener(move |this, _, _, cx| this.toggle_row(id.clone(), cx))) + .on_key_down(cx.listener(move |this, event: &KeyDownEvent, _, cx| { + if matches!(event.keystroke.key.as_str(), "enter" | "space") { + this.toggle_row(keyboard_id.clone(), cx); + } + })) + } + + fn render_detail(&self, row: &Row, cx: &mut Context) -> Div { + let details = div() + .v_flex() + .gap_3() + .py_3() + .pl_6() + .border_t_1() + .border_color(theme::card_border(cx)); + if row.invoices.is_empty() { + let prefill = row.clone(); + return details + .child(theme::caption(cx, tr!("reconciliation.missing_body"))) + .child( + div().h_flex().child( + Button::new("reconciliation-enter-for-row") + .label(tr!("reconciliation.enter_total")) + .custom(theme::outline_variant(cx)) + .card_outline(cx) + .on_click(cx.listener(move |this, _, window, cx| { + this.open_form(Some(prefill.clone()), window, cx); + })), + ), + ); + } + let Some(open) = &self.open else { + return details; + }; + let details = details.child(self.render_basis(open, cx)); + let Some(result) = &open.result else { + return details.child(theme::caption(cx, tr!("common.loading"))); + }; + details + .child( + div() + .h_flex() + .flex_wrap() + .items_center() + .gap_2() + .child(status_badge(result.status, cx)) + .child(theme::caption( + cx, + tr!( + "reconciliation.basis_line", + currency = result.currency, + tolerance = fmt::exact_amount(result.tolerance, &result.currency), + authoritative = result.coverage.authoritative_rows, + rows = result.coverage.rows, + from = date_text(result.coverage.from), + through = date_text(result.coverage.through) + ), + )), + ) + .when(!result.reasons.is_empty(), |el| { + el.child( + div() + .v_flex() + .gap_1() + .children(result.reasons.iter().map(|reason| { + div() + .text_sm() + .text_color(theme::text_primary(cx)) + .child(reason_text(reason)) + })), + ) + }) + .child(components_table(result, cx)) + .child(self.render_evidence(row, cx)) + } + + /// What the invoice is compared with: the ledger, or a frozen revision. + fn render_basis(&self, open: &OpenRow, cx: &mut Context) -> Div { + let pill = |id: SharedString, + label: SharedString, + basis: Option, + cx: &mut Context| { + let active = open.basis == basis; + let button = Button::new(id) + .label(label) + .small() + .rounded_full() + .custom(theme::range_pill(cx, active)) + .on_click(cx.listener(move |this, _, _, cx| { + this.select_basis(basis.clone(), cx); + })); + if active { + button.card_outline(cx).font_weight(FontWeight::MEDIUM) + } else { + button + } + }; + let mut basis = div() + .h_flex() + .flex_wrap() + .items_center() + .gap_1() + .child(theme::caption(cx, tr!("reconciliation.compare_with"))) + .child(pill( + "reconciliation-basis-current".into(), + tr!("reconciliation.basis_current"), + None, + cx, + )); + for revision in &open.revisions { + basis = basis.child(pill( + SharedString::from(format!("reconciliation-basis-{}", revision.snapshot_id)), + tr!("snapshots.revision_short", n = revision.revision), + Some(revision.snapshot_id.clone()), + cx, + )); + } + basis + } + + /// The invoices themselves: where each came from, and the way to withdraw + /// one entered by mistake. + fn render_evidence(&self, row: &Row, cx: &mut Context) -> Div { + let mut evidence = div() + .v_flex() + .gap_1() + .child(theme::header_cell(cx, tr!("reconciliation.evidence"))); + for invoice in &row.invoices { + let source: SharedString = match (invoice.source, &invoice.source_file) { + (InvoiceSource::Csv, Some(file)) => { + tr!("reconciliation.source_csv_file", file = file) + } + (InvoiceSource::Csv, None) => tr!("reconciliation.source_csv"), + (InvoiceSource::Manual, _) => tr!("reconciliation.source_manual"), + }; + let target = invoice.clone(); + evidence = evidence.child( + div() + .h_flex() + .items_center() + .justify_between() + .gap_3() + .child(theme::caption( + cx, + tr!( + "reconciliation.evidence_line", + reference = invoice.invoice_ref, + amount = fmt::exact_amount(invoice.amount, &invoice.currency), + source = source, + at = invoice.entered_at.format("%Y-%m-%d %H:%M UTC").to_string() + ), + )) + .child( + Button::new(SharedString::from(format!( + "reconciliation-delete-{}", + invoice.invoice_id + ))) + .label(tr!("common.delete")) + .ghost() + .small() + .on_click(cx.listener( + move |this, _, window, cx| { + this.confirm_delete(target.clone(), window, cx); + }, + )), + ), + ); + } + evidence + } +} + +fn amount_header(button: Button) -> Div { + div() + .w(rems(AMOUNT_WIDTH)) + .flex_shrink_0() + .h_flex() + .justify_end() + .child(button) +} + +fn amount_cell(text: SharedString) -> Div { + div() + .w(rems(AMOUNT_WIDTH)) + .flex_shrink_0() + .text_right() + .child(text) +} + +/// The figure by charge category, on both sides. +fn components_table(result: &Reconciliation, cx: &App) -> Div { + let currency = result.currency.as_str(); + let heading = |text: SharedString| { + div() + .w(rems(AMOUNT_WIDTH)) + .flex_shrink_0() + .text_right() + .child(theme::header_cell(cx, text)) + }; + div() + .v_flex() + .child( + div() + .w_full() + .h_flex() + .gap_3() + .pb_1() + .child( + div() + .flex_1() + .child(theme::header_cell(cx, tr!("reconciliation.col_category"))), + ) + .child(heading(tr!("reconciliation.col_invoice"))) + .child(heading(tr!("reconciliation.col_ledger"))) + .child(heading(tr!("reconciliation.col_difference"))), + ) + .children(result.components.iter().map(|component| { + let difference = component.invoice.map(|invoice| invoice - component.ledger); + div() + .w_full() + .h_flex() + .gap_3() + .py_1() + .border_t_1() + .border_color(theme::card_border(cx)) + .text_sm() + .text_color(theme::text_primary(cx)) + .child(div().flex_1().child(tr!( + "reconciliation.category_rows", + category = category_label(component.category), + rows = component.rows + ))) + .child(amount_cell(amount_text(component.invoice, currency))) + .child(amount_cell(amount_text(Some(component.ledger), currency))) + .child(amount_cell(signed_text(difference, currency))) + })) + .child( + div() + .w_full() + .h_flex() + .gap_3() + .py_1() + .border_t_1() + .border_color(theme::card_border(cx)) + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(theme::text_primary(cx)) + .child(div().flex_1().child(tr!("reconciliation.total"))) + .child(amount_cell(amount_text( + Some(result.invoice_total), + currency, + ))) + .child(amount_cell(amount_text(result.ledger_total, currency))) + .child(amount_cell(signed_text(result.difference, currency))), + ) +} + +impl Render for ReconciliationView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let body = if !self.loaded && self.loading { + theme::caption(cx, tr!("common.loading")) + } else { + self.render_table(cx) + }; + div() + .id("reconciliation") + .size_full() + .v_flex() + .gap_4() + .p_8() + .bg(theme::app_bg(cx)) + .overflow_y_scroll() + .child( + div() + .v_flex() + .gap_1() + .child(theme::page_title(cx, tr!("reconciliation.title"))) + .child(theme::caption(cx, tr!("reconciliation.subtitle"))), + ) + .child(self.render_toolbar(cx)) + .when_some(self.error.clone(), |el, error| { + el.child(theme::caption(cx, error).text_color(theme::danger(cx))) + }) + .when_some(self.notice.clone(), |el, notice| { + el.child(theme::caption(cx, notice)) + }) + .child(theme::card(cx).w_full().p_5().child(body)) + } +} + +#[cfg(test)] +mod tests { + // Not `super::*`: that brings gpui_kit's own `test` attribute, which + // shadows the standard one. + use super::{row_matches, sort_rows, Column, Row}; + use crate::model::{PeriodKey, ReconcileStatus}; + + fn row(period: &str, status: ReconcileStatus, difference: Option) -> Row { + Row { + key: PeriodKey::new("AWS", "acct", period), + account_name: "Prod".to_string(), + currency: "USD".to_string(), + invoices: Vec::new(), + ledger_total: None, + invoice_total: None, + difference, + status, + } + } + + fn months(rows: &[Row]) -> Vec<&str> { + rows.iter().map(|r| r.key.billing_period.as_str()).collect() + } + + #[test] + fn the_default_order_puts_problems_first_and_new_months_above_old() { + let mut rows = vec![ + row("2026-09", ReconcileStatus::Matched, Some(0.0)), + row("2026-08", ReconcileStatus::MissingEvidence, None), + row("2026-07", ReconcileStatus::Difference, Some(5.0)), + row("2026-09", ReconcileStatus::Difference, Some(1.0)), + row("2026-06", ReconcileStatus::CannotVerify, None), + ]; + sort_rows(&mut rows, None); + assert_eq!( + months(&rows), + vec!["2026-09", "2026-07", "2026-06", "2026-08", "2026-09"] + ); + assert_eq!(rows[4].status, ReconcileStatus::Matched); + } + + #[test] + fn an_absent_figure_sorts_last_in_either_direction() { + let mut rows = vec![ + row("2026-01", ReconcileStatus::CannotVerify, None), + row("2026-02", ReconcileStatus::Difference, Some(5.0)), + row("2026-03", ReconcileStatus::Difference, Some(9.0)), + ]; + sort_rows(&mut rows, Some((Column::Difference, true))); + assert_eq!(months(&rows), vec!["2026-03", "2026-02", "2026-01"]); + sort_rows(&mut rows, Some((Column::Difference, false))); + assert_eq!(months(&rows), vec!["2026-02", "2026-03", "2026-01"]); + } + + #[test] + fn the_filter_reads_text_and_status_together() { + let r = row("2026-09", ReconcileStatus::Difference, Some(1.0)); + assert!(row_matches(&r, "", None)); + assert!(row_matches(&r, "aws", None)); + assert!(row_matches(&r, "PROD", None)); + assert!(row_matches( + &r, + "2026-09", + Some(ReconcileStatus::Difference) + )); + assert!(!row_matches(&r, "2026-08", None)); + assert!(!row_matches(&r, "", Some(ReconcileStatus::Matched))); + } +} diff --git a/src/web/ledger/demo.rs b/src/web/ledger/demo.rs index 24a0a50..ef256b8 100644 --- a/src/web/ledger/demo.rs +++ b/src/web/ledger/demo.rs @@ -59,6 +59,8 @@ pub fn seed_demo() -> Result { record_balance(&snapshot)?; } + super::invoice::save_invoices(&demo_data::invoices(&periods, now))?; + // An imported scan is real data: the demo does not replace it. (The // browser imports none, but the rule is the desktop's.) let imported = memory::with_store(|store| { @@ -111,6 +113,10 @@ pub fn clear_demo() -> Result { .snapshots .borrow_mut() .retain(|stored| !stored.snapshot.key.account_id.starts_with(DEMO_PREFIX)); + store + .invoices + .borrow_mut() + .retain(|invoice| !invoice.key.account_id.starts_with(DEMO_PREFIX)); store .balances .borrow_mut() diff --git a/src/web/ledger/invoice.rs b/src/web/ledger/invoice.rs new file mode 100644 index 0000000..210a19b --- /dev/null +++ b/src/web/ledger/invoice.rs @@ -0,0 +1,67 @@ +//! Invoice evidence, held in memory. The same surface as the desktop's +//! `ledger::invoice`; the comparison is shared, in +//! [`crate::analytics::reconciliation`]. + +use anyhow::Result; + +use crate::memory; +use crate::model::{CostBasis, Invoice, PeriodKey}; + +/// Store invoices. One carrying the same reference for the same month and +/// account replaces the earlier entry. Returns how many were stored. +pub fn save_invoices(invoices: &[Invoice]) -> Result { + memory::with_store(|store| { + let mut stored = store.invoices.borrow_mut(); + for invoice in invoices { + stored.retain(|existing| { + existing.key != invoice.key || existing.invoice_ref != invoice.invoice_ref + }); + stored.push(invoice.clone()); + } + Ok(invoices.len()) + }) +} + +/// Every invoice on record. +pub fn all_invoices() -> Result> { + memory::with_store(|store| { + let mut invoices = store.invoices.borrow().clone(); + invoices.sort_by(|a, b| { + b.key + .billing_period + .cmp(&a.key.billing_period) + .then_with(|| a.key.provider.cmp(&b.key.provider)) + .then_with(|| a.key.account_id.cmp(&b.key.account_id)) + .then_with(|| a.invoice_ref.cmp(&b.invoice_ref)) + }); + Ok(invoices) + }) +} + +/// Remove one invoice from the evidence. +pub fn delete_invoice(invoice_id: &str) -> Result<()> { + memory::with_store(|store| { + store + .invoices + .borrow_mut() + .retain(|invoice| invoice.invoice_id != invoice_id); + Ok(()) + }) +} + +/// A period's authoritative billed total per currency. +pub fn billed_totals(key: &PeriodKey) -> Result> { + memory::with_store(|store| { + let mut totals = std::collections::BTreeMap::::new(); + for period in store.periods.borrow().iter().filter(|p| p.key == *key) { + for charge in &period.charges { + if let (CostBasis::Authoritative, Some(amount)) = + (charge.cost_basis, charge.billed_cost) + { + *totals.entry(charge.billing_currency.clone()).or_default() += amount; + } + } + } + Ok(totals.into_iter().collect()) + }) +} diff --git a/src/web/ledger/mod.rs b/src/web/ledger/mod.rs index c9b6631..e9ef7b8 100644 --- a/src/web/ledger/mod.rs +++ b/src/web/ledger/mod.rs @@ -7,6 +7,7 @@ pub mod demo; pub mod inventory; +pub mod invoice; pub mod query; pub mod snapshot; @@ -116,6 +117,10 @@ pub fn delete_account_history(account_id: &str) -> Result { .snapshots .borrow_mut() .retain(|stored| stored.snapshot.key.account_id != account_id); + store + .invoices + .borrow_mut() + .retain(|invoice| invoice.key.account_id != account_id); store .balances .borrow_mut() diff --git a/src/web/memory.rs b/src/web/memory.rs index 76a93a0..06fa5a4 100644 --- a/src/web/memory.rs +++ b/src/web/memory.rs @@ -19,7 +19,7 @@ use chrono::{DateTime, NaiveDate, Utc}; use crate::alerts::{AlertEvent, AlertRule}; use crate::model::{ BalanceSnapshot, BillingPeriod, BillingSnapshot, BudgetInfo, Channel, Charge, ChargeCategory, - CloudAccount, InventoryResource, InventoryScope, PeriodKey, SnapshotLine, + CloudAccount, InventoryResource, InventoryScope, Invoice, PeriodKey, SnapshotLine, }; /// Rates shipped with the build, as `(from, to, date, rate)`. @@ -70,6 +70,8 @@ pub struct Store { pub inventory_scope: RefCell>, /// The desktop's `billing_snapshot` and its lines. pub snapshots: RefCell>, + /// The desktop's `invoice_evidence`. + pub invoices: RefCell>, } impl Store { @@ -86,6 +88,7 @@ impl Store { resources: RefCell::new(Vec::new()), inventory_scope: RefCell::new(None), snapshots: RefCell::new(Vec::new()), + invoices: RefCell::new(Vec::new()), } }