Attachments, statements, reconciliations, TIN vault, counterparty policy
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
MCP: books.link_attachment, unlink_attachment, attachments; link_to on attach_receipt and file_document; record_statement, statements; books.reconcile previews and, confirmed against a recorded statement, records the reconciliation; void_reconciliation, reconciliations; import_bank_csv links its source file; digest lists unreconciled accounts. TIN vault: books.set_w9 takes the TIN and the form into the encrypted vault (last four in the books); books.vendor_1099 include_tins and save_csv_to for the CPA's copy; books.get_w9; books.purge_tin. A leak test scans every tool output, the journal, the database, and the blobs. [books] parties = off | suggest | require (default suggest): every preview names the entry's party or suggests likely ones; require refuses to post until it names one or is marked as having none (entry.no_party). Viewer: documents on every subject page; statements and reconciliation history per account; reconciliation pages; reconcile and no-counterparty attention items; party money by period (paid out, received in, net, what for, by month); Needs attention gets its own full-width section. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
1 parent
e265ed4339
commit
ae927c3f73
41 files changed
+8463
-465
No files matched your search
@@ -199,6 +199,13 @@ impl App {
|
||||
}
|
||||
Route::Party(party_id)
|
||||
}
|
||||
T::Reconciliation { id } => {
|
||||
// Its own page: statement, balance, the lines it cleared.
|
||||
if b.reconciliation(id).is_none() {
|
||||
return;
|
||||
}
|
||||
Route::Reconciliation(id)
|
||||
}
|
||||
T::Search { query } => {
|
||||
let st = &mut self.state.transactions;
|
||||
st.query = query;
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//! and recurring entries due, open past periods, uncategorized tax lines,
|
||||
//! 1099 vendors without a W-9, overdue bills and invoices, money owed to
|
||||
//! owners, fixed asset depreciation not posted, assets without a tag or a
|
||||
//! tax basis, contractors and landlords paid without W-9 facts, and legal
|
||||
//! tax basis, contractors and landlords paid without W-9 facts, bank and
|
||||
//! card accounts not reconciled through last month, and legal
|
||||
//! rules (like the 1099 threshold) due for an update. Each item says
|
||||
//! what's wrong, where to look, and the prompt that
|
||||
//! asks the agent to fix it (the GUI never changes the books itself).
|
||||
@@ -47,7 +48,19 @@ use rust_decimal::Decimal;
|
||||
/// (PartyInfo::paid_ytd > 0),
|
||||
/// unless its vendor is already a 1099 vendor without a W-9 above
|
||||
/// (Todo, one per party, its prompt from
|
||||
/// pages::parties::collect_w9_prompt, naming books.set_w9).
|
||||
/// pages::parties::collect_w9_prompt, naming books.set_w9); a cash
|
||||
/// or card account (Books::reconcilable_accounts) whose
|
||||
/// Books::reconciliation_due is Some(date) (Todo, one per account,
|
||||
/// titled "Reconcile <account> through <date>", its prompt from
|
||||
/// pages::reconciliation::reconcile_prompt, naming
|
||||
/// books.record_statement and books.reconcile); when `[books]
|
||||
/// parties` isn't off, the posted entries dated this calendar year
|
||||
/// (Jan 1 through today) that name no party and aren't marked as
|
||||
/// having none (Ledger::entry_no_party), leaving out the ones the
|
||||
/// engine makes from a schedule (period closes, depreciation,
|
||||
/// amortization: no counterparty by nature) (one item: Info under
|
||||
/// suggest, Todo under require; its prompt names
|
||||
/// books.set_entry_party).
|
||||
/// 3. Nothing else: books with none of those yield no items.
|
||||
/// 4. Every item's prompt starts "In wanderbooks," or names
|
||||
/// wanderbooks, and its amounts are the books' exact amounts.
|
||||
@@ -356,6 +369,70 @@ pub fn find(b: &Books) -> Vec<Attention> {
|
||||
}
|
||||
}
|
||||
|
||||
for a in b.reconcilable_accounts() {
|
||||
let Some(through) = b.reconciliation_due(a) else {
|
||||
continue;
|
||||
};
|
||||
let name = b.account_name(a);
|
||||
out.push(Attention {
|
||||
severity: Severity::Todo,
|
||||
title: format!("Reconcile {name} through {}", fmt::date(through)),
|
||||
detail: match b.reconciled_through(a) {
|
||||
Some(d) => format!(
|
||||
"Reconciled through {}. The monthly close reconciles it to each statement since.",
|
||||
fmt::date(d)
|
||||
),
|
||||
None => "Never reconciled. The monthly close reconciles it to its statement.".into(),
|
||||
},
|
||||
target: Some(Route::Account(a)),
|
||||
prompt: crate::pages::reconciliation::reconcile_prompt(b, a, through),
|
||||
});
|
||||
}
|
||||
|
||||
let policy = b.settings.parties;
|
||||
if policy != ledger_config::PartyPolicy::Off {
|
||||
let year_start = chrono::NaiveDate::from_ymd_opt(today.year(), 1, 1).expect("valid");
|
||||
let unnamed: Vec<_> = b
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
e.status == EntryStatus::Posted
|
||||
&& e.date >= year_start
|
||||
&& e.date <= today
|
||||
&& e.party_id.is_none()
|
||||
&& b.ledger.entry_no_party(e.id).is_none()
|
||||
&& !matches!(e.origin, "Period close" | "Depreciation" | "Amortization")
|
||||
})
|
||||
.collect();
|
||||
if !unnamed.is_empty() {
|
||||
let list: Vec<String> = unnamed
|
||||
.iter()
|
||||
.map(|e| format!("{} ({}, {})", e.id, e.description, e.date))
|
||||
.collect();
|
||||
out.push(Attention {
|
||||
severity: if policy == ledger_config::PartyPolicy::Require {
|
||||
Severity::Todo
|
||||
} else {
|
||||
Severity::Info
|
||||
},
|
||||
title: format!(
|
||||
"{} no counterparty",
|
||||
fmt::count(unnamed.len(), "entry names", "entries name")
|
||||
),
|
||||
detail: format!(
|
||||
"Posted in {} with no party and not marked as having none. Tagging each entry with who it was with keeps the parties' totals and 1099s complete.",
|
||||
today.year()
|
||||
),
|
||||
target: Some(Route::Transactions),
|
||||
prompt: format!(
|
||||
"In wanderbooks, these posted entries from {} name no counterparty: {}. Propose a party for each, grouped by party (adding any that aren't in the directory yet), and once I agree, tag them with books.set_entry_party. For any with truly no counterparty (like a transfer between the company's own accounts), mark it with books.set_entry_party with party null and a no_party_reason.",
|
||||
today.year(),
|
||||
list.join("; ")
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let due = b.depreciation_due(today);
|
||||
if due.amount > Decimal::ZERO {
|
||||
let months = if due.periods.is_empty() {
|
||||
|
||||
@@ -11,12 +11,14 @@ use ledger_core::{
|
||||
AccountId, AccountType, AssetId, AssetRegister, AssetStatus, BillId, EntryId, EntryStatus,
|
||||
InvoiceId, Ledger, Party, PartyId, PayrollRunId, Side, Vendor1099Line, fixed_asset_register,
|
||||
};
|
||||
use ledger_store::{Attachment, JournalLine, Snapshot};
|
||||
use ledger_store::{Attachment, JournalLine, Reconciliation, Snapshot, Statement, Subject};
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::Value;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::nav::Route;
|
||||
|
||||
/// One entry, indexed for listing and detail.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct EntryInfo {
|
||||
@@ -28,7 +30,9 @@ pub struct EntryInfo {
|
||||
pub amount: Decimal,
|
||||
/// Accounts touched, debits first: "6300 Software".
|
||||
pub accounts: Vec<AccountId>,
|
||||
/// Indexes into [`Books::attachments`] of linked, current receipts.
|
||||
/// Indexes into [`Books::attachments`] of its current receipts: the
|
||||
/// files filed on it and the files linked to it (`attachment.linked`
|
||||
/// with an entry subject), as the server's receipt policy counts them.
|
||||
pub receipts: Vec<usize>,
|
||||
/// How the entry came to be, in words.
|
||||
pub origin: &'static str,
|
||||
@@ -216,6 +220,78 @@ pub struct Books {
|
||||
pub entry_assets: HashMap<EntryId, AssetId>,
|
||||
/// Every party's dealings with the company, by party id.
|
||||
pub parties: BTreeMap<PartyId, PartyInfo>,
|
||||
/// Every attachment's subjects (see [`Books::links_of`]), by
|
||||
/// attachment id; withdrawn attachments keep theirs.
|
||||
pub links: BTreeMap<u64, Vec<Subject>>,
|
||||
/// Recorded bank and card statements, by period end.
|
||||
pub statements: Vec<Statement>,
|
||||
/// Reconciliations, voided ones included, by date then id.
|
||||
pub reconciliations: Vec<Reconciliation>,
|
||||
}
|
||||
|
||||
/// A span of dates a party's money is summed over (the party page's and
|
||||
/// the directory's period picker).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum PartyPeriod {
|
||||
/// January 1 of today's year through today.
|
||||
#[default]
|
||||
ThisYear,
|
||||
/// Last calendar year.
|
||||
LastYear,
|
||||
/// One calendar year (through today, if it is this one).
|
||||
Year(i32),
|
||||
/// The books' first entry through today.
|
||||
AllTime,
|
||||
}
|
||||
|
||||
/// What one party's posted entries moved over a span, and what for.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: answer "how much did we spend with them, and how much did we
|
||||
/// get back?" (Stephen, 2026-10-15) with the books' own numbers.
|
||||
/// SOURCE: ledger_core::parties::activity (paid, received) and
|
||||
/// ledger_core::parties::money_accounts (the money set), with
|
||||
/// Books::cash_accounts, as PartyInfo uses them.
|
||||
/// INVARIANTS:
|
||||
/// 1. paid, received: activity(ledger, party, span, cash accounts)'s,
|
||||
/// exactly.
|
||||
/// 2. net() = paid − received.
|
||||
/// 3. what_for: over the party's posted entries dated in the span, each
|
||||
/// account not in money_accounts with its debits − credits, the
|
||||
/// accounts whose sum is zero left out, largest magnitude first
|
||||
/// (then by account id). Positive: what money paid for; negative:
|
||||
/// where money received came from (or, on payables and
|
||||
/// receivables, what is still owed).
|
||||
/// 4. Σ what_for = net(), exactly: every entry balances, so its
|
||||
/// non-money lines net to what its money lines moved (a reversal
|
||||
/// reverses both).
|
||||
/// 5. entries: how many of the party's entries are posted and dated
|
||||
/// in the span.
|
||||
/// ```
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct PartyFlow {
|
||||
pub paid: Decimal,
|
||||
pub received: Decimal,
|
||||
pub what_for: Vec<(AccountId, Decimal)>,
|
||||
pub entries: usize,
|
||||
}
|
||||
|
||||
impl PartyFlow {
|
||||
/// Paid out less received in.
|
||||
pub fn net(&self) -> Decimal {
|
||||
self.paid - self.received
|
||||
}
|
||||
}
|
||||
|
||||
/// One month of a party's money, for the out-versus-in chart.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PartyMonth {
|
||||
/// The month's first day.
|
||||
pub month: NaiveDate,
|
||||
/// activity's paid over the month (clipped to the span).
|
||||
pub paid: Decimal,
|
||||
/// activity's received over the month (clipped to the span).
|
||||
pub received: Decimal,
|
||||
}
|
||||
|
||||
/// Whether what's on disk is what the journal says.
|
||||
@@ -343,12 +419,34 @@ impl Books {
|
||||
events.entry(id).or_default().push(i);
|
||||
}
|
||||
}
|
||||
let mut receipts: HashMap<EntryId, Vec<usize>> = HashMap::new();
|
||||
for (i, a) in attachments.iter().enumerate() {
|
||||
if let (Some(e), None) = (a.entry_id, &a.withdrawn_at) {
|
||||
receipts.entry(e).or_default().push(i);
|
||||
let link_rows = snap.attachment_links().map_err(|e| e.to_string())?;
|
||||
let mut links: BTreeMap<u64, Vec<Subject>> = BTreeMap::new();
|
||||
for a in &attachments {
|
||||
if let Some(e) = a.entry_id {
|
||||
links.entry(a.id).or_default().push(Subject::Entry(e));
|
||||
}
|
||||
}
|
||||
for (att, sub) in &link_rows {
|
||||
links.entry(*att).or_default().push(*sub);
|
||||
}
|
||||
// An entry's receipts: filed on it, or linked to it (the server's
|
||||
// receipt policy counts both).
|
||||
let mut receipts: HashMap<EntryId, Vec<usize>> = HashMap::new();
|
||||
for (i, a) in attachments.iter().enumerate() {
|
||||
if a.withdrawn_at.is_some() {
|
||||
continue;
|
||||
}
|
||||
for sub in links.get(&a.id).into_iter().flatten() {
|
||||
if let Subject::Entry(e) = sub {
|
||||
let r = receipts.entry(*e).or_default();
|
||||
if !r.contains(&i) {
|
||||
r.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let statements = snap.statements(None).map_err(|e| e.to_string())?;
|
||||
let reconciliations = snap.reconciliations(None).map_err(|e| e.to_string())?;
|
||||
let fresh_from_seq = prev_seq.unwrap_or(journal.len() as u64);
|
||||
let mut fresh = HashSet::new();
|
||||
let closings = ledger.closing_entry_ids();
|
||||
@@ -488,6 +586,9 @@ impl Books {
|
||||
asset_trails,
|
||||
entry_assets,
|
||||
parties: BTreeMap::new(),
|
||||
links,
|
||||
statements,
|
||||
reconciliations,
|
||||
};
|
||||
books.parties = books.party_infos();
|
||||
books.kpis = books.compute_kpis();
|
||||
@@ -821,6 +922,372 @@ impl Books {
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Attachment links, statements, reconciliations
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// One attachment by id.
|
||||
pub fn attachment(&self, id: u64) -> Option<&Attachment> {
|
||||
self.attachments.iter().find(|a| a.id == id)
|
||||
}
|
||||
|
||||
/// Every subject an attachment is linked to: its filing entry (a
|
||||
/// receipt filed on an entry) first, then its `attachment.linked`
|
||||
/// subjects, as `Store::attachment_links` lists them.
|
||||
pub fn links_of(&self, attachment_id: u64) -> &[Subject] {
|
||||
self.links.get(&attachment_id).map_or(&[], |v| v.as_slice())
|
||||
}
|
||||
|
||||
/// The current (not withdrawn) attachments linked to any of
|
||||
/// `subjects`, each once, by id.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: every subject's page shows the files that are evidence of it.
|
||||
/// SOURCE: docs/parties-and-attachments.md (Attachments: an
|
||||
/// attachment links to any number of subjects; an entry's are
|
||||
/// those filed on it and those linked to it);
|
||||
/// Store::attachments_for, which this mirrors.
|
||||
/// INVARIANTS:
|
||||
/// 1. a is listed iff it isn't withdrawn and links_of(a) shares a
|
||||
/// subject with `subjects`.
|
||||
/// 2. No attachment twice; ascending id.
|
||||
/// ```
|
||||
pub fn documents_for(&self, subjects: &[Subject]) -> Vec<&Attachment> {
|
||||
self.attachments
|
||||
.iter()
|
||||
.filter(|a| a.withdrawn_at.is_none())
|
||||
.filter(|a| self.links_of(a.id).iter().any(|s| subjects.contains(s)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The subjects whose documents an entry's page shows: the entry, and
|
||||
/// the invoice, bill, or payroll run it records (those have no page
|
||||
/// of their own).
|
||||
pub fn entry_subjects(&self, id: EntryId) -> Vec<Subject> {
|
||||
let l = &self.ledger;
|
||||
let mut out = vec![Subject::Entry(id)];
|
||||
out.extend(
|
||||
l.invoices()
|
||||
.into_iter()
|
||||
.filter(|i| i.entry_id == id)
|
||||
.map(|i| Subject::Invoice(i.id)),
|
||||
);
|
||||
out.extend(
|
||||
l.bills()
|
||||
.into_iter()
|
||||
.filter(|b| b.entry_id == id)
|
||||
.map(|b| Subject::Bill(b.id)),
|
||||
);
|
||||
out.extend(
|
||||
l.list_payroll_runs()
|
||||
.into_iter()
|
||||
.filter(|r| r.entry_id == id)
|
||||
.map(|r| Subject::PayrollRun(r.id)),
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
/// A subject in words: "#12 Adobe (…)", "1000 Operating Checking",
|
||||
/// "WL-0001 MacBook Pro", a party's label, "Invoice 3", "Period 2025",
|
||||
/// "Reconciliation of 1000 Operating Checking through Sep 30, 2026",
|
||||
/// "the company".
|
||||
pub fn subject_label(&self, s: Subject) -> String {
|
||||
let l = &self.ledger;
|
||||
match s {
|
||||
Subject::Entry(e) => self
|
||||
.entry(e)
|
||||
.map(|x| format!("#{} {}", x.id, x.description))
|
||||
.unwrap_or_else(|| format!("#{e}")),
|
||||
Subject::Account(a) => self.account_label(a),
|
||||
Subject::Asset(a) => self.asset_label(a),
|
||||
Subject::Party(p) => self.party_label(p),
|
||||
Subject::Invoice(i) => format!("Invoice {i}"),
|
||||
Subject::Bill(i) => format!("Bill {i}"),
|
||||
Subject::Period(i) => l
|
||||
.list_periods()
|
||||
.iter()
|
||||
.find(|p| p.id == i)
|
||||
.map(|p| format!("Period {}", p.name))
|
||||
.unwrap_or_else(|| format!("Period {i}")),
|
||||
Subject::PayrollRun(i) => match l.get_payroll_run(i) {
|
||||
Some(r) => format!("Payroll {}", crate::fmt::date(r.date)),
|
||||
None => format!("Payroll run {i}"),
|
||||
},
|
||||
Subject::Schedule(i) => l
|
||||
.get_schedule(i)
|
||||
.map(|x| x.description.clone())
|
||||
.unwrap_or_else(|| format!("Schedule {i}")),
|
||||
Subject::Reconciliation(i) => match self.reconciliation(i) {
|
||||
Some(r) => format!(
|
||||
"Reconciliation of {} through {}",
|
||||
self.account_label(r.account_id),
|
||||
crate::fmt::date(r.statement_date)
|
||||
),
|
||||
None => format!("Reconciliation {i}"),
|
||||
},
|
||||
Subject::Company => "the company".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a subject opens: its page, or (an invoice, bill, or payroll
|
||||
/// run) the entry that records it, or (a period, schedule, the
|
||||
/// company) the company page.
|
||||
pub fn subject_route(&self, s: Subject) -> Option<Route> {
|
||||
let l = &self.ledger;
|
||||
Some(match s {
|
||||
Subject::Entry(e) => Route::Entry(e),
|
||||
Subject::Account(a) => Route::Account(a),
|
||||
Subject::Asset(a) => Route::Asset(a),
|
||||
Subject::Party(p) => Route::Party(p),
|
||||
Subject::Invoice(i) => Route::Entry(l.get_invoice(i)?.entry_id),
|
||||
Subject::Bill(i) => Route::Entry(l.get_bill(i)?.entry_id),
|
||||
Subject::PayrollRun(i) => Route::Entry(l.get_payroll_run(i)?.entry_id),
|
||||
Subject::Reconciliation(i) => Route::Reconciliation(i),
|
||||
Subject::Period(_) | Subject::Schedule(_) | Subject::Company => Route::Company,
|
||||
})
|
||||
}
|
||||
|
||||
/// The statement an attachment records, if any.
|
||||
pub fn statement(&self, attachment_id: u64) -> Option<&Statement> {
|
||||
self.statements
|
||||
.iter()
|
||||
.find(|s| s.attachment_id == attachment_id)
|
||||
}
|
||||
|
||||
/// One account's recorded statements, by period end.
|
||||
pub fn statements_for(&self, account: AccountId) -> Vec<&Statement> {
|
||||
self.statements
|
||||
.iter()
|
||||
.filter(|s| s.account_id == account)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One reconciliation by id.
|
||||
pub fn reconciliation(&self, id: u64) -> Option<&Reconciliation> {
|
||||
self.reconciliations.iter().find(|r| r.id == id)
|
||||
}
|
||||
|
||||
/// One account's reconciliations, voided ones included, by date.
|
||||
pub fn reconciliations_for(&self, account: AccountId) -> Vec<&Reconciliation> {
|
||||
self.reconciliations
|
||||
.iter()
|
||||
.filter(|r| r.account_id == account)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The latest statement date an account is reconciled through: the
|
||||
/// newest reconciliation of it not voided (None: never reconciled).
|
||||
pub fn reconciled_through(&self, account: AccountId) -> Option<NaiveDate> {
|
||||
self.reconciliations
|
||||
.iter()
|
||||
.filter(|r| r.account_id == account && r.voided.is_none())
|
||||
.map(|r| r.statement_date)
|
||||
.max()
|
||||
}
|
||||
|
||||
/// The last day of the month before today's.
|
||||
pub fn last_month_end(&self) -> NaiveDate {
|
||||
self.month_start() - chrono::Days::new(1)
|
||||
}
|
||||
|
||||
/// The accounts the books expect to reconcile monthly: the cash
|
||||
/// accounts, then the card accounts, each once.
|
||||
pub fn reconcilable_accounts(&self) -> Vec<AccountId> {
|
||||
let mut out = self.cash_accounts.clone();
|
||||
for a in &self.card_accounts {
|
||||
if !out.contains(a) {
|
||||
out.push(*a);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// When an account is due a reconciliation: the date it should be
|
||||
/// reconciled through, else None.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: the monthly close leaves every bank and card account
|
||||
/// reconciled through last month's end (docs/parties-and-
|
||||
/// attachments.md, Reconciliations: "an account with activity
|
||||
/// not reconciled through last month's end").
|
||||
/// SOURCE: that rule; CONVENTION (a bank or card statement closes
|
||||
/// monthly; AGENTS.md's monthly close reconciles the bank).
|
||||
/// INVARIANTS:
|
||||
/// 1. Some(last_month_end()) iff the account has a posted line
|
||||
/// dated on or before last_month_end() (activity to reconcile)
|
||||
/// and reconciled_through(account) is None or earlier than
|
||||
/// last_month_end().
|
||||
/// 2. None otherwise (including activity only this month).
|
||||
/// ```
|
||||
pub fn reconciliation_due(&self, account: AccountId) -> Option<NaiveDate> {
|
||||
let through = self.last_month_end();
|
||||
let active = self
|
||||
.ledger
|
||||
.posted_entries()
|
||||
.any(|e| e.date() <= through && e.lines().iter().any(|l| l.account == account));
|
||||
(active && self.reconciled_through(account).is_none_or(|d| d < through)).then_some(through)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// A party's money over a span
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// The dates a [`PartyPeriod`] covers: a year is January 1 to
|
||||
/// December 31, cut at today; all time starts at the earliest entry
|
||||
/// in the books (today when there is none).
|
||||
pub fn party_span(&self, period: PartyPeriod) -> (NaiveDate, NaiveDate) {
|
||||
let year = |y: i32| {
|
||||
(
|
||||
NaiveDate::from_ymd_opt(y, 1, 1).expect("valid"),
|
||||
NaiveDate::from_ymd_opt(y, 12, 31)
|
||||
.expect("valid")
|
||||
.min(self.today),
|
||||
)
|
||||
};
|
||||
match period {
|
||||
PartyPeriod::ThisYear => year(self.today.year()),
|
||||
PartyPeriod::LastYear => year(self.today.year() - 1),
|
||||
PartyPeriod::Year(y) => year(y),
|
||||
PartyPeriod::AllTime => {
|
||||
let first = self
|
||||
.entries
|
||||
.iter()
|
||||
.map(|e| e.date)
|
||||
.min()
|
||||
.unwrap_or(self.today)
|
||||
.min(self.today);
|
||||
(first, self.today)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The calendar year a period's 1099 amount is for (all time: this
|
||||
/// year's).
|
||||
pub fn party_period_year(&self, period: PartyPeriod) -> i32 {
|
||||
match period {
|
||||
PartyPeriod::LastYear => self.today.year() - 1,
|
||||
PartyPeriod::Year(y) => y,
|
||||
PartyPeriod::ThisYear | PartyPeriod::AllTime => self.today.year(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A period in words: "This year (2026)", "Last year (2025)", "2024",
|
||||
/// "All time".
|
||||
pub fn party_period_label(&self, period: PartyPeriod) -> String {
|
||||
let y = self.today.year();
|
||||
match period {
|
||||
PartyPeriod::ThisYear => format!("This year ({y})"),
|
||||
PartyPeriod::LastYear => format!("Last year ({})", y - 1),
|
||||
PartyPeriod::Year(x) => x.to_string(),
|
||||
PartyPeriod::AllTime => "All time".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The periods to offer for a party (None: for the directory): this
|
||||
/// year, last year, every other year with a posted entry (the
|
||||
/// party's, or any for the directory), newest first, then all time.
|
||||
pub fn party_periods(&self, party: Option<PartyId>) -> Vec<PartyPeriod> {
|
||||
let y = self.today.year();
|
||||
let mut years: Vec<i32> = self
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| e.status == EntryStatus::Posted && e.date <= self.today)
|
||||
.filter(|e| party.is_none_or(|p| e.party_id == Some(p)))
|
||||
.map(|e| e.date.year())
|
||||
.filter(|&x| x < y - 1)
|
||||
.collect();
|
||||
years.sort_unstable_by(|a, b| b.cmp(a));
|
||||
years.dedup();
|
||||
let mut out = vec![PartyPeriod::ThisYear, PartyPeriod::LastYear];
|
||||
out.extend(years.into_iter().map(PartyPeriod::Year));
|
||||
out.push(PartyPeriod::AllTime);
|
||||
out
|
||||
}
|
||||
|
||||
/// What a party's posted entries moved over `span` (see
|
||||
/// [`PartyFlow`]). `with_breakdown` false leaves what_for empty (the
|
||||
/// directory needs only the totals).
|
||||
pub fn party_flow(
|
||||
&self,
|
||||
party: PartyId,
|
||||
span: (NaiveDate, NaiveDate),
|
||||
with_breakdown: bool,
|
||||
) -> PartyFlow {
|
||||
let l = &self.ledger;
|
||||
let a = ledger_core::parties::activity(l, party, span, &self.cash_accounts);
|
||||
let posted: Vec<&ledger_core::JournalEntry> = a
|
||||
.entries
|
||||
.iter()
|
||||
.filter_map(|&id| l.get_entry(id))
|
||||
.filter(|e| {
|
||||
e.status() == EntryStatus::Posted && e.date() >= span.0 && e.date() <= span.1
|
||||
})
|
||||
.collect();
|
||||
let mut what_for = Vec::new();
|
||||
if with_breakdown {
|
||||
let money = ledger_core::parties::money_accounts(l, &self.cash_accounts);
|
||||
let mut by: BTreeMap<AccountId, Decimal> = BTreeMap::new();
|
||||
for e in &posted {
|
||||
for line in e.lines().iter().filter(|x| !money.contains(&x.account)) {
|
||||
*by.entry(line.account).or_default() += match line.side {
|
||||
Side::Debit => line.amount.amount,
|
||||
Side::Credit => -line.amount.amount,
|
||||
};
|
||||
}
|
||||
}
|
||||
what_for = by.into_iter().filter(|(_, v)| !v.is_zero()).collect();
|
||||
what_for.sort_by(|x, y| y.1.abs().cmp(&x.1.abs()).then(x.0.cmp(&y.0)));
|
||||
}
|
||||
PartyFlow {
|
||||
paid: a.paid,
|
||||
received: a.received,
|
||||
what_for,
|
||||
entries: posted.len(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A party's 1099 worksheet line for a calendar year and that year's
|
||||
/// threshold: ledger-core's vendor_1099_report_with (cash and
|
||||
/// financing accounts) line for its vendor record; None without a
|
||||
/// vendor record in the report, or when the year's threshold isn't
|
||||
/// known.
|
||||
pub fn party_1099(&self, party: PartyId, year: i32) -> Option<(Vendor1099Line, Decimal)> {
|
||||
let l = &self.ledger;
|
||||
let vendor = l.get_party(party)?.vendor?;
|
||||
let sources = ledger_core::PaymentAccounts {
|
||||
cash: self.cash_accounts.clone(),
|
||||
financing: self.financing_accounts.clone(),
|
||||
};
|
||||
let r = ledger_core::vendor_1099_report_with(l, year, None, &sources).ok()?;
|
||||
let line = r.vendors.iter().find(|v| v.vendor_id == vendor)?.clone();
|
||||
Some((line, r.threshold))
|
||||
}
|
||||
|
||||
/// A party's money month by month over `span`: every month the span
|
||||
/// touches, each month's activity clipped to the span (so the months
|
||||
/// sum to the span's paid and received exactly).
|
||||
pub fn party_months(&self, party: PartyId, span: (NaiveDate, NaiveDate)) -> Vec<PartyMonth> {
|
||||
let mut out = Vec::new();
|
||||
let mut m = NaiveDate::from_ymd_opt(span.0.year(), span.0.month(), 1).expect("valid");
|
||||
while m <= span.1 {
|
||||
let end = (m + chrono::Months::new(1) - chrono::Days::new(1)).min(span.1);
|
||||
let start = m.max(span.0);
|
||||
let a = ledger_core::parties::activity(
|
||||
&self.ledger,
|
||||
party,
|
||||
(start, end),
|
||||
&self.cash_accounts,
|
||||
);
|
||||
out.push(PartyMonth {
|
||||
month: m,
|
||||
paid: a.paid,
|
||||
received: a.received,
|
||||
});
|
||||
m = m + chrono::Months::new(1);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Blob store path for an attachment.
|
||||
pub fn blob_root(&self) -> PathBuf {
|
||||
self.data_dir.join("blobs")
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
//! small design studio (single-member LLC) with client invoices, template
|
||||
//! sales paid out by a platform, contractors (one without a W-9), rent on a
|
||||
//! recurring schedule, a credit card, a laptop on depreciation, prepaid
|
||||
//! insurance, owner draws, a closed year, PDF receipts, and a few loose
|
||||
//! ends for the "needs attention" list. Deterministic for a given `today`.
|
||||
//! insurance, owner draws, a closed year, PDF receipts, bank statements
|
||||
//! with reconciliations (one voided and redone), documents linked to the
|
||||
//! company, an asset, and a party, and a few loose ends for the "needs
|
||||
//! attention" list. Deterministic for a given `today`.
|
||||
|
||||
use chrono::{Datelike, Days, Months, NaiveDate};
|
||||
use ledger_core::{
|
||||
AccountType, CloseTo, Currency, Form1099, Frequency, Money, NewAsset, NewRecurring,
|
||||
NewSchedule, ScheduleKind, Side, arap::DocumentLine,
|
||||
};
|
||||
use ledger_store::{Store, StoreError};
|
||||
use ledger_store::{Statement, Store, StoreError, Subject};
|
||||
use rust_decimal::Decimal;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -339,7 +341,7 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
|
||||
let alex = s.add_party("Alex Rivera", &[ledger_core::Role::Member])?;
|
||||
|
||||
// Formation documents.
|
||||
s.file_document(
|
||||
let (articles, _, _) = s.file_document(
|
||||
"formation",
|
||||
"Articles of Organization.pdf",
|
||||
"application/pdf",
|
||||
@@ -353,7 +355,7 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
|
||||
),
|
||||
Some("Filed with the state"),
|
||||
)?;
|
||||
s.file_document(
|
||||
let (ein_letter, _, _) = s.file_document(
|
||||
"tax",
|
||||
"IRS EIN confirmation.pdf",
|
||||
"application/pdf",
|
||||
@@ -894,5 +896,163 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
|
||||
month_index += 1;
|
||||
}
|
||||
let _ = (bank_fees, client_class);
|
||||
|
||||
// The company's papers, linked to it; the laptop's warranty and
|
||||
// photo, linked to the asset; Sam's contract, linked to Sam and the
|
||||
// company.
|
||||
s.link_attachment(articles, Subject::Company)?;
|
||||
s.link_attachment(ein_letter, Subject::Company)?;
|
||||
let (warranty, _, _) = s.file_document(
|
||||
"warranty",
|
||||
"MacBook Pro AppleCare.pdf",
|
||||
"application/pdf",
|
||||
&receipt_pdf(
|
||||
"AppleCare+ for Mac",
|
||||
&[
|
||||
"Agreement for MacBook Pro 14-inch".into(),
|
||||
format!("Coverage from {}", laptop_date.format("%B %-d, %Y")),
|
||||
"Three years of hardware coverage".into(),
|
||||
],
|
||||
),
|
||||
Some("Warranty, three years"),
|
||||
)?;
|
||||
s.link_attachment(warranty, Subject::Asset(asset))?;
|
||||
let sam_party = s.ledger().party_of_vendor(sam).expect("linked");
|
||||
let (contract_doc, _, _) = s.file_document(
|
||||
"contract",
|
||||
"Sam Okafor services agreement.pdf",
|
||||
"application/pdf",
|
||||
&receipt_pdf(
|
||||
"Independent Contractor Agreement",
|
||||
&[
|
||||
"Juniper Studio LLC and Sam Okafor".into(),
|
||||
"Illustration services, billed per project".into(),
|
||||
format!("Signed {}", (start + Months::new(1)).format("%B %-d, %Y")),
|
||||
],
|
||||
),
|
||||
Some("Signed agreement"),
|
||||
)?;
|
||||
s.link_attachment(contract_doc, Subject::Party(sam_party))?;
|
||||
s.link_attachment(contract_doc, Subject::Company)?;
|
||||
|
||||
// Statements and reconciliations: savings reconciled through last
|
||||
// month; checking through the month before (its first reconciliation
|
||||
// voided and redone against the bank's reissued statement, one check
|
||||
// outstanding at the first statement); the card never.
|
||||
let month_end = |m: NaiveDate| m + Months::new(1) - Days::new(1);
|
||||
let lines_on = |s: &Store, account: u64, through: NaiveDate| -> Vec<(u64, usize, NaiveDate)> {
|
||||
let mut out = Vec::new();
|
||||
for e in s.ledger().posted_entries() {
|
||||
if e.date() <= through {
|
||||
for (i, l) in e.lines().iter().enumerate() {
|
||||
if l.account == account && !s.ledger().is_cleared(e.id(), i) {
|
||||
out.push((e.id(), i, e.date()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_by_key(|x| (x.2, x.0, x.1));
|
||||
out
|
||||
};
|
||||
let statement = |s: &mut Store,
|
||||
account: u64,
|
||||
name: &str,
|
||||
month: NaiveDate,
|
||||
note: &str,
|
||||
opening: Option<Decimal>|
|
||||
-> Result<u64, StoreError> {
|
||||
let end = month_end(month);
|
||||
// The bank's opening is its last closing: what had cleared by then.
|
||||
let opening = match opening {
|
||||
Some(o) => o,
|
||||
None => {
|
||||
s.ledger()
|
||||
.reconciliation_report(account, month - Days::new(1), Decimal::ZERO)?
|
||||
.cleared_balance
|
||||
}
|
||||
};
|
||||
let closing = s
|
||||
.ledger()
|
||||
.reconciliation_report(account, end, Decimal::ZERO)?
|
||||
.cleared_balance;
|
||||
let (att, _, _) = s.file_document(
|
||||
"statement",
|
||||
&format!(
|
||||
"{name} statement {}{}.pdf",
|
||||
month.format("%Y-%m"),
|
||||
if note.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" ({})", note.to_lowercase())
|
||||
}
|
||||
),
|
||||
"application/pdf",
|
||||
&receipt_pdf(
|
||||
&format!("{name} statement"),
|
||||
&[
|
||||
format!(
|
||||
"{} through {}",
|
||||
month.format("%B %-d, %Y"),
|
||||
end.format("%B %-d, %Y")
|
||||
),
|
||||
format!("Opening balance: ${opening}"),
|
||||
format!("Closing balance: ${closing}"),
|
||||
note.to_string(),
|
||||
],
|
||||
),
|
||||
Some(&format!("{name}, {}", month.format("%B %Y"))),
|
||||
)?;
|
||||
s.record_statement(Statement {
|
||||
attachment_id: att,
|
||||
account_id: account,
|
||||
period_start: month,
|
||||
period_end: end,
|
||||
opening_balance: opening,
|
||||
closing_balance: closing,
|
||||
})?;
|
||||
Ok(att)
|
||||
};
|
||||
let last_month = this_month - Months::new(1);
|
||||
// Savings: every line through last month cleared.
|
||||
for (e, i, _) in lines_on(&s, savings, month_end(last_month)) {
|
||||
s.mark_cleared(e, i, true)?;
|
||||
}
|
||||
let st = statement(&mut s, savings, "Savings", last_month, "", None)?;
|
||||
s.complete_reconciliation(st)?;
|
||||
// Checking, three months back: the month's last check still out.
|
||||
let m1 = this_month - Months::new(3);
|
||||
let pending = lines_on(&s, checking, month_end(m1));
|
||||
let outstanding = pending.last().map(|x| (x.0, x.1));
|
||||
for (e, i, _) in pending {
|
||||
if Some((e, i)) != outstanding {
|
||||
s.mark_cleared(e, i, true)?;
|
||||
}
|
||||
}
|
||||
let first = statement(&mut s, checking, "Operating Checking", m1, "", None)?;
|
||||
let voided = s.complete_reconciliation(first)?;
|
||||
s.void_reconciliation(
|
||||
voided.id,
|
||||
"The bank reissued this statement; reconciled again against the corrected copy",
|
||||
)?;
|
||||
let reissued = statement(&mut s, checking, "Operating Checking", m1, "Reissued", None)?;
|
||||
let m1_closing = s
|
||||
.ledger()
|
||||
.reconciliation_report(checking, month_end(m1), Decimal::ZERO)?
|
||||
.cleared_balance;
|
||||
s.complete_reconciliation(reissued)?;
|
||||
// Two months back: everything through it cleared.
|
||||
let m2 = this_month - Months::new(2);
|
||||
for (e, i, _) in lines_on(&s, checking, month_end(m2)) {
|
||||
s.mark_cleared(e, i, true)?;
|
||||
}
|
||||
let second = statement(
|
||||
&mut s,
|
||||
checking,
|
||||
"Operating Checking",
|
||||
m2,
|
||||
"",
|
||||
Some(m1_closing),
|
||||
)?;
|
||||
s.complete_reconciliation(second)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -59,6 +59,9 @@ pub enum Route {
|
||||
Reports(ReportKind),
|
||||
Accounts,
|
||||
Account(AccountId),
|
||||
/// One reconciliation: its statement, balance, and the lines it
|
||||
/// cleared.
|
||||
Reconciliation(u64),
|
||||
/// The fixed asset register.
|
||||
Assets,
|
||||
/// One fixed asset.
|
||||
@@ -83,7 +86,7 @@ impl Route {
|
||||
Route::Profit => Section::Profit,
|
||||
Route::Transactions | Route::Entry(_) => Section::Transactions,
|
||||
Route::Reports(_) => Section::Reports,
|
||||
Route::Accounts | Route::Account(_) => Section::Accounts,
|
||||
Route::Accounts | Route::Account(_) | Route::Reconciliation(_) => Section::Accounts,
|
||||
Route::Assets | Route::Asset(_) => Section::Assets,
|
||||
Route::Parties | Route::Party(_) => Section::Parties,
|
||||
Route::Documents | Route::Document(_) => Section::Documents,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! The chart of accounts with balances, and one account's register: every
|
||||
//! posting with a running balance and the balance over time.
|
||||
//! posting with a running balance and the balance over time, its bank or
|
||||
//! card statements, its reconciliation history, and its documents.
|
||||
|
||||
use super::assets::row;
|
||||
use super::page;
|
||||
use crate::app::Shell;
|
||||
use crate::books::Books;
|
||||
@@ -11,9 +13,11 @@ use crate::widgets;
|
||||
use chrono::NaiveDate;
|
||||
use egui::{CornerRadius, RichText, Sense, Ui, vec2};
|
||||
use egui_phosphor::regular as icon;
|
||||
use ledger_core::AccountId;
|
||||
use ledger_core::AccountType;
|
||||
use ledger_core::analysis::balance_history;
|
||||
use ledger_core::reports::general_ledger;
|
||||
use ledger_store::Subject;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -185,7 +189,10 @@ pub fn show_account(ui: &mut Ui, b: &Books, id: u64, shell: &mut Shell<'_>) {
|
||||
.color(p.muted),
|
||||
&fmt::plain(&fmt::dollars(bal)).unwrap_or_default(),
|
||||
);
|
||||
ui.add_space(14.0);
|
||||
ui.add_space(6.0);
|
||||
let mut go_to: Option<Route> = None;
|
||||
reconciled_badge(ui, b, id);
|
||||
ui.add_space(8.0);
|
||||
let hist = balance_history(&b.ledger, &[id]);
|
||||
if hist.len() > 1 && a.account_type.is_balance_sheet() {
|
||||
widgets::titled_card(
|
||||
@@ -196,6 +203,19 @@ pub fn show_account(ui: &mut Ui, b: &Books, id: u64, shell: &mut Shell<'_>) {
|
||||
);
|
||||
ui.add_space(12.0);
|
||||
}
|
||||
statements_and_reconciliations(ui, b, id, &mut go_to);
|
||||
super::documents::documents_card(
|
||||
ui,
|
||||
b,
|
||||
"Documents",
|
||||
&[Subject::Account(id)],
|
||||
"No documents linked to this account (statements, the agreement that opened it).",
|
||||
&mut go_to,
|
||||
);
|
||||
ui.add_space(14.0);
|
||||
if let Some(r) = go_to.take() {
|
||||
shell.go(r);
|
||||
}
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
@@ -401,3 +421,179 @@ pub fn show_account(ui: &mut Ui, b: &Books, id: u64, shell: &mut Shell<'_>) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Where the account stands on reconciliation: "Reconciled through
|
||||
/// <date>" (in green when that covers last month, else with a notice and
|
||||
/// the agent prompt), or "Never reconciled" for an account the books
|
||||
/// expect to reconcile or one with statements. Nothing for other
|
||||
/// accounts.
|
||||
fn reconciled_badge(ui: &mut Ui, b: &Books, id: AccountId) {
|
||||
let p = pal(ui.ctx());
|
||||
let expected = b.reconcilable_accounts().contains(&id);
|
||||
let through = b.reconciled_through(id);
|
||||
if !expected && through.is_none() && b.statements_for(id).is_empty() {
|
||||
return;
|
||||
}
|
||||
let due = b.reconciliation_due(id);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
match through {
|
||||
Some(d) => {
|
||||
let (fg, bg) = if due.is_some() {
|
||||
(p.warning, p.warning_soft)
|
||||
} else {
|
||||
(p.positive, p.positive_soft)
|
||||
};
|
||||
widgets::pill(
|
||||
ui,
|
||||
&format!("{} Reconciled through {}", icon::CHECK_SQUARE, fmt::date(d)),
|
||||
fg,
|
||||
bg,
|
||||
);
|
||||
}
|
||||
None => {
|
||||
widgets::pill(ui, "Never reconciled", p.warning, p.warning_soft);
|
||||
}
|
||||
}
|
||||
if let Some(d) = due {
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"Due through {}: the monthly close reconciles every bank and card account to its statement.",
|
||||
fmt::date(d)
|
||||
))
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
widgets::copy_prompt(ui, &super::reconciliation::reconcile_prompt(b, id, d));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The account's statements (period, opening, closing, the file) and its
|
||||
/// reconciliations (date, statement balance, cleared lines, standing),
|
||||
/// side by side when there's room. Nothing when it has neither.
|
||||
fn statements_and_reconciliations(ui: &mut Ui, b: &Books, id: AccountId, go: &mut Option<Route>) {
|
||||
let p = pal(ui.ctx());
|
||||
let statements = b.statements_for(id);
|
||||
let recs = b.reconciliations_for(id);
|
||||
if statements.is_empty() && recs.is_empty() {
|
||||
return;
|
||||
}
|
||||
super::columns(ui, 1100.0, &[1.0, 1.0], |col, ui| {
|
||||
if col == 0 {
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Statements",
|
||||
|_| {},
|
||||
|ui| {
|
||||
if statements.is_empty() {
|
||||
ui.label(RichText::new("No statements recorded.").color(p.muted));
|
||||
return;
|
||||
}
|
||||
let amt = widgets::AMOUNT_MIN + 8.0;
|
||||
let w = ui.available_width().max(180.0 + amt * 2.0 + 120.0);
|
||||
widgets::min_width(ui, "statements", w, |ui| {
|
||||
let head =
|
||||
|t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted);
|
||||
row(
|
||||
ui,
|
||||
w,
|
||||
&[
|
||||
head("Period", 0.0, false),
|
||||
head("Opening", amt, true),
|
||||
head("Closing", amt, true),
|
||||
],
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
widgets::rule(ui);
|
||||
for st in statements.iter().rev() {
|
||||
let file = b
|
||||
.attachment(st.attachment_id)
|
||||
.map_or_else(String::new, |a| a.file_name.clone());
|
||||
let resp = row(
|
||||
ui,
|
||||
w,
|
||||
&[
|
||||
(
|
||||
format!(
|
||||
"{} – {}",
|
||||
fmt::date_short(st.period_start),
|
||||
fmt::date(st.period_end)
|
||||
),
|
||||
0.0,
|
||||
false,
|
||||
p.text,
|
||||
),
|
||||
(fmt::money(st.opening_balance), amt, true, p.muted),
|
||||
(fmt::money(st.closing_balance), amt, true, p.text),
|
||||
],
|
||||
Some((&format!("{} {file}", icon::FILE_PDF), p.faint)),
|
||||
false,
|
||||
true,
|
||||
);
|
||||
if resp.on_hover_text("Open the statement").clicked() {
|
||||
*go = Some(Route::Document(st.attachment_id));
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
} else {
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Reconciliations",
|
||||
|_| {},
|
||||
|ui| {
|
||||
if recs.is_empty() {
|
||||
ui.label(RichText::new("Never reconciled.").color(p.muted));
|
||||
return;
|
||||
}
|
||||
let amt = widgets::AMOUNT_MIN + 8.0;
|
||||
let w = ui.available_width().max(130.0 + amt + 70.0 + 110.0);
|
||||
widgets::min_width(ui, "reconciliations", w, |ui| {
|
||||
let head =
|
||||
|t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted);
|
||||
row(
|
||||
ui,
|
||||
w,
|
||||
&[
|
||||
head("Through", 0.0, false),
|
||||
head("Balance", amt, true),
|
||||
head("Lines", 70.0, true),
|
||||
],
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
widgets::rule(ui);
|
||||
for r in recs.iter().rev() {
|
||||
use super::reconciliation::Standing;
|
||||
let (sub, color) = match super::reconciliation::standing(b, r) {
|
||||
Standing::Current => ("Current".to_string(), p.positive),
|
||||
Standing::Superseded => ("Superseded".to_string(), p.faint),
|
||||
Standing::Voided(why) => (format!("Voided: {why}"), p.warning),
|
||||
};
|
||||
let text = if r.voided.is_some() { p.muted } else { p.text };
|
||||
let resp = row(
|
||||
ui,
|
||||
w,
|
||||
&[
|
||||
(fmt::date(r.statement_date), 0.0, false, text),
|
||||
(fmt::money(r.statement_balance), amt, true, text),
|
||||
(r.cleared.len().to_string(), 70.0, true, p.muted),
|
||||
],
|
||||
Some((&sub, color)),
|
||||
false,
|
||||
true,
|
||||
);
|
||||
if resp.on_hover_text("Open this reconciliation").clicked() {
|
||||
*go = Some(Route::Reconciliation(r.id));
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1164,58 +1164,16 @@ pub fn show_asset(ui: &mut Ui, b: &Books, id: AssetId, shell: &mut Shell<'_>) {
|
||||
},
|
||||
);
|
||||
ui.add_space(14.0);
|
||||
let docs: Vec<_> = b
|
||||
.attachments
|
||||
.iter()
|
||||
.filter(|x| {
|
||||
x.withdrawn_at.is_none() && x.entry_id.is_some_and(|e| entries.contains(&e))
|
||||
})
|
||||
.collect();
|
||||
widgets::titled_card(
|
||||
let mut subjects = vec![ledger_store::Subject::Asset(id)];
|
||||
subjects.extend(entries.iter().map(|e| ledger_store::Subject::Entry(*e)));
|
||||
super::documents::documents_card_with(
|
||||
ui,
|
||||
b,
|
||||
"Receipts and documents",
|
||||
|_| {},
|
||||
|ui| {
|
||||
if docs.is_empty() {
|
||||
ui.label(
|
||||
RichText::new("None linked to its entries (depreciation needs none: the schedule is its evidence).")
|
||||
.color(p.muted),
|
||||
);
|
||||
}
|
||||
for d in &docs {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
RichText::new(if d.mime_type == "application/pdf" {
|
||||
icon::FILE_PDF
|
||||
} else {
|
||||
icon::FILE_IMAGE
|
||||
})
|
||||
.color(p.muted),
|
||||
);
|
||||
if widgets::link_truncated(
|
||||
ui,
|
||||
RichText::new(format!(
|
||||
"{} · {} · entry #{}",
|
||||
d.file_name,
|
||||
fmt::label(&d.category),
|
||||
d.entry_id.unwrap_or(0)
|
||||
))
|
||||
.font(theme::regular(13.0)),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
go = Some(Route::Document(d.id));
|
||||
}
|
||||
});
|
||||
if let Some(desc) = &d.description {
|
||||
ui.label(
|
||||
RichText::new(desc)
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.faint),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
&subjects,
|
||||
&[ledger_store::Subject::Asset(id)],
|
||||
"None linked to it or its entries (photos, the warranty, the manual; depreciation needs none: the schedule is its evidence).",
|
||||
&mut go,
|
||||
);
|
||||
ui.add_space(14.0);
|
||||
|
||||
|
||||
@@ -180,6 +180,32 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
);
|
||||
}
|
||||
});
|
||||
let mut subjects = vec![ledger_store::Subject::Company];
|
||||
subjects.extend(
|
||||
b.ledger
|
||||
.list_periods()
|
||||
.iter()
|
||||
.map(|x| ledger_store::Subject::Period(x.id)),
|
||||
);
|
||||
subjects.extend(
|
||||
b.ledger
|
||||
.schedules()
|
||||
.map(|x| ledger_store::Subject::Schedule(x.id)),
|
||||
);
|
||||
let mut go = None;
|
||||
super::documents::documents_card_with(
|
||||
ui,
|
||||
b,
|
||||
"Company documents",
|
||||
&subjects,
|
||||
&[ledger_store::Subject::Company],
|
||||
"No documents linked to the company, its periods, or its schedules yet (formation papers, the operating agreement, tax filings).",
|
||||
&mut go,
|
||||
);
|
||||
ui.add_space(16.0);
|
||||
if let Some(r) = go {
|
||||
shell.go(r);
|
||||
}
|
||||
assets(ui, b, shell);
|
||||
schedules(ui, b);
|
||||
recurring(ui, b);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Every receipt and document the books hold, rendered: what it is, what
|
||||
//! entry it proves, and whether it was withdrawn (and why).
|
||||
//! Every receipt and document the books hold, rendered: what it is,
|
||||
//! everything it is linked to, and whether it was withdrawn (and why).
|
||||
//! Also the compact "Documents" card every subject's page shows
|
||||
//! ([`documents_card`]).
|
||||
|
||||
use super::page;
|
||||
use crate::app::Shell;
|
||||
@@ -11,7 +13,7 @@ use crate::theme::{self, pal};
|
||||
use crate::widgets;
|
||||
use egui::{CornerRadius, RichText, Sense, Stroke, Ui, vec2};
|
||||
use egui_phosphor::regular as icon;
|
||||
use ledger_store::Attachment;
|
||||
use ledger_store::{Attachment, Subject};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
@@ -275,23 +277,36 @@ fn detail(ui: &mut Ui, b: &Books, a: &Attachment, shell: &mut Shell<'_>) {
|
||||
&a.blob_sha,
|
||||
);
|
||||
ui.end_row();
|
||||
if let Some(e) = a.entry_id {
|
||||
k(ui, "Proves");
|
||||
let label = b
|
||||
.entry(e)
|
||||
.map(|x| {
|
||||
format!("#{} {} ({})", x.id, x.description, fmt::dollars(x.amount))
|
||||
})
|
||||
.unwrap_or_else(|| format!("#{e}"));
|
||||
if widgets::link_truncated(ui, RichText::new(label).color(p.accent))
|
||||
.clicked()
|
||||
{
|
||||
shell.go(Route::Entry(e));
|
||||
}
|
||||
if let Some(st) = b.statement(a.id) {
|
||||
k(ui, "Statement");
|
||||
ui.add(
|
||||
egui::Label::new(format!(
|
||||
"{}, {} – {}",
|
||||
b.account_label(st.account_id),
|
||||
fmt::date(st.period_start),
|
||||
fmt::date(st.period_end)
|
||||
))
|
||||
.wrap(),
|
||||
);
|
||||
ui.end_row();
|
||||
k(ui, "Opening");
|
||||
ui.label(fmt::dollars(st.opening_balance));
|
||||
ui.end_row();
|
||||
k(ui, "Closing");
|
||||
ui.label(
|
||||
RichText::new(fmt::dollars(st.closing_balance))
|
||||
.font(theme::semibold(13.5)),
|
||||
);
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
let mut go = None;
|
||||
links_section(ui, b, a, &mut go);
|
||||
if let Some(r) = go {
|
||||
shell.go(r);
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
super::entry::render(ui, b, a, shell, ui.available_width());
|
||||
if let Some(d) = &a.description {
|
||||
ui.add_space(6.0);
|
||||
@@ -308,3 +323,243 @@ fn detail(ui: &mut Ui, b: &Books, a: &Attachment, shell: &mut Shell<'_>) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The icon for a subject's kind.
|
||||
pub fn subject_icon(s: Subject) -> &'static str {
|
||||
match s {
|
||||
Subject::Entry(_) => icon::RECEIPT,
|
||||
Subject::Account(_) => icon::TREE_STRUCTURE,
|
||||
Subject::Asset(_) => icon::PACKAGE,
|
||||
Subject::Party(_) => icon::ADDRESS_BOOK,
|
||||
Subject::Invoice(_) | Subject::Bill(_) => icon::FILE_TEXT,
|
||||
Subject::Period(_) => icon::CALENDAR_BLANK,
|
||||
Subject::PayrollRun(_) => icon::USERS,
|
||||
Subject::Schedule(_) => icon::CALENDAR_CHECK,
|
||||
Subject::Reconciliation(_) => icon::CHECK_SQUARE,
|
||||
Subject::Company => icon::BUILDINGS,
|
||||
}
|
||||
}
|
||||
|
||||
/// A small chip naming a subject, which opens it (see
|
||||
/// [`Books::subject_route`]) when it has somewhere to go.
|
||||
pub fn subject_chip(ui: &mut Ui, b: &Books, s: Subject, go: &mut Option<Route>) {
|
||||
let p = pal(ui.ctx());
|
||||
let route = b.subject_route(s);
|
||||
// A reconciliation's chip names its date before anything is cut.
|
||||
let mut label = match s {
|
||||
Subject::Reconciliation(r) => b.reconciliation(r).map_or_else(
|
||||
|| b.subject_label(s),
|
||||
|r| {
|
||||
format!(
|
||||
"Reconciliation, {} through {}",
|
||||
b.account_name(r.account_id),
|
||||
fmt::date(r.statement_date)
|
||||
)
|
||||
},
|
||||
),
|
||||
_ => b.subject_label(s),
|
||||
};
|
||||
if label.chars().count() > 60 {
|
||||
label = label.chars().take(59).collect::<String>() + "…";
|
||||
}
|
||||
let r = ui.add(
|
||||
egui::Button::new(
|
||||
RichText::new(format!("{} {label}", subject_icon(s)))
|
||||
.font(theme::regular(11.5))
|
||||
.color(if route.is_some() { p.accent } else { p.muted }),
|
||||
)
|
||||
.fill(if route.is_some() {
|
||||
p.accent_soft
|
||||
} else {
|
||||
p.hover
|
||||
})
|
||||
.stroke(Stroke::NONE)
|
||||
.corner_radius(CornerRadius::same(255))
|
||||
.min_size(vec2(0.0, 22.0)),
|
||||
);
|
||||
if let Some(to) = route
|
||||
&& r.on_hover_text(format!("Open {}", b.subject_label(s)))
|
||||
.clicked()
|
||||
{
|
||||
*go = Some(to);
|
||||
}
|
||||
}
|
||||
|
||||
/// The compact list of the documents linked to `subjects`: each file's
|
||||
/// name (opening its document page), category and description, and the
|
||||
/// other places it is linked as chips that open them.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: every subject's page shows the files that are evidence of it,
|
||||
/// and where else each one is evidence.
|
||||
/// SOURCE: docs/parties-and-attachments.md (Attachments, Viewer).
|
||||
/// INVARIANTS:
|
||||
/// 1. Lists exactly Books::documents_for(subjects), in that order.
|
||||
/// 2. A file's chips are links_of(file) less `here` (the page's own
|
||||
/// subject; [`documents_card`] passes `subjects`).
|
||||
/// 3. Empty: `empty` in muted text (nothing else).
|
||||
/// ```
|
||||
pub fn documents_card_with(
|
||||
ui: &mut Ui,
|
||||
b: &Books,
|
||||
title: &str,
|
||||
subjects: &[Subject],
|
||||
here: &[Subject],
|
||||
empty: &str,
|
||||
go: &mut Option<Route>,
|
||||
) {
|
||||
let p = pal(ui.ctx());
|
||||
let docs = b.documents_for(subjects);
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
title,
|
||||
|ui| {
|
||||
if !docs.is_empty() {
|
||||
widgets::pill(ui, &docs.len().to_string(), p.muted, p.hover);
|
||||
}
|
||||
},
|
||||
|ui| {
|
||||
if docs.is_empty() {
|
||||
ui.add(egui::Label::new(RichText::new(empty).color(p.muted)).wrap());
|
||||
return;
|
||||
}
|
||||
for (i, d) in docs.iter().enumerate() {
|
||||
if i > 0 {
|
||||
ui.add_space(4.0);
|
||||
widgets::rule(ui);
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
document_row(ui, b, d, here, go);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// [`documents_card_with`], the chips leaving out `subjects` themselves.
|
||||
pub fn documents_card(
|
||||
ui: &mut Ui,
|
||||
b: &Books,
|
||||
title: &str,
|
||||
subjects: &[Subject],
|
||||
empty: &str,
|
||||
go: &mut Option<Route>,
|
||||
) {
|
||||
documents_card_with(ui, b, title, subjects, subjects, empty, go);
|
||||
}
|
||||
|
||||
/// One file in a [`documents_card_with`].
|
||||
fn document_row(ui: &mut Ui, b: &Books, d: &Attachment, here: &[Subject], go: &mut Option<Route>) {
|
||||
let p = pal(ui.ctx());
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.label(RichText::new(file_icon(d)).size(18.0).color(p.muted));
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 2.0;
|
||||
if widgets::link_truncated(ui, RichText::new(&d.file_name).font(theme::semibold(13.0)))
|
||||
.on_hover_text("Open this document")
|
||||
.clicked()
|
||||
{
|
||||
*go = Some(Route::Document(d.id));
|
||||
}
|
||||
let mut sub = vec![];
|
||||
match b.statement(d.id) {
|
||||
None => sub.push(fmt::label(&d.category)),
|
||||
Some(st) => sub.push(format!(
|
||||
"Statement {} – {}, closing {}",
|
||||
fmt::date_short(st.period_start),
|
||||
fmt::date(st.period_end),
|
||||
fmt::dollars(st.closing_balance)
|
||||
)),
|
||||
}
|
||||
if let Some(desc) = &d.description {
|
||||
sub.push(desc.clone());
|
||||
}
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
RichText::new(sub.join(" · "))
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.muted),
|
||||
)
|
||||
.wrap(),
|
||||
);
|
||||
let elsewhere: Vec<Subject> = b
|
||||
.links_of(d.id)
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|s| !here.contains(s))
|
||||
.collect();
|
||||
if !elsewhere.is_empty() {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(4.0, 4.0);
|
||||
ui.label(
|
||||
RichText::new("Also on")
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.faint),
|
||||
);
|
||||
for s in elsewhere {
|
||||
subject_chip(ui, b, s, go);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// A PDF or an image.
|
||||
pub fn file_icon(a: &Attachment) -> &'static str {
|
||||
if a.mime_type == "application/pdf" {
|
||||
icon::FILE_PDF
|
||||
} else if a.mime_type.starts_with("image/") {
|
||||
icon::FILE_IMAGE
|
||||
} else {
|
||||
icon::FILE
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything a document is linked to: a chip per subject (an entry's
|
||||
/// with its date and amount in the label), opening it; and, for a
|
||||
/// statement, the reconciliations that used it.
|
||||
fn links_section(ui: &mut Ui, b: &Books, a: &Attachment, go: &mut Option<Route>) {
|
||||
let p = pal(ui.ctx());
|
||||
let links = b.links_of(a.id);
|
||||
ui.label(
|
||||
RichText::new("LINKED TO")
|
||||
.font(theme::semibold(11.5))
|
||||
.color(p.faint),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
if links.is_empty() {
|
||||
ui.label(
|
||||
RichText::new("Nothing yet: filed on its own.")
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
}
|
||||
for s in links {
|
||||
ui.horizontal(|ui| {
|
||||
subject_chip(ui, b, *s, go);
|
||||
if let Subject::Entry(e) = s
|
||||
&& let Some(x) = b.entry(*e)
|
||||
{
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"{} · {}",
|
||||
fmt::date(x.date),
|
||||
fmt::dollars(x.amount)
|
||||
))
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
}
|
||||
if let Subject::Reconciliation(r) = s
|
||||
&& let Some(r) = b.reconciliation(*r)
|
||||
&& let Some(why) = &r.voided
|
||||
{
|
||||
ui.label(
|
||||
RichText::new(format!("voided: {why}"))
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.warning),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,19 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
|
||||
shell.go(Route::Party(party.id));
|
||||
}
|
||||
}
|
||||
if e.party_id.is_none()
|
||||
&& let Some(why) = b.ledger.entry_no_party(e.id)
|
||||
{
|
||||
ui.add_space(4.0);
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
RichText::new(format!("{} No counterparty: {why}", icon::USER_MINUS))
|
||||
.color(p.muted),
|
||||
)
|
||||
.wrap(),
|
||||
)
|
||||
.on_hover_text("Marked as having no counterparty (entry.no_party)");
|
||||
}
|
||||
if let Some(&asset) = b.entry_assets.get(&e.id) {
|
||||
ui.add_space(4.0);
|
||||
if widgets::link(
|
||||
@@ -268,18 +281,25 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
|
||||
});
|
||||
ui.add_space(18.0);
|
||||
|
||||
// Receipts.
|
||||
// Receipts and documents: filed on it, linked to it, or linked
|
||||
// to the invoice, bill, or payroll run it records; a withdrawn
|
||||
// receipt filed on it stays, marked.
|
||||
let subjects = b.entry_subjects(e.id);
|
||||
let all_for_entry: Vec<&Attachment> = b
|
||||
.attachments
|
||||
.iter()
|
||||
.filter(|a| a.entry_id == Some(e.id))
|
||||
.filter(|a| {
|
||||
a.entry_id == Some(e.id)
|
||||
|| (a.withdrawn_at.is_none()
|
||||
&& b.links_of(a.id).iter().any(|s| subjects.contains(s)))
|
||||
})
|
||||
.collect();
|
||||
section(
|
||||
ui,
|
||||
if all_for_entry.len() == 1 {
|
||||
"Receipt"
|
||||
} else {
|
||||
"Receipts"
|
||||
"Receipts and documents"
|
||||
},
|
||||
);
|
||||
if all_for_entry.is_empty() {
|
||||
@@ -295,6 +315,29 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
|
||||
}));
|
||||
}
|
||||
for a in all_for_entry {
|
||||
let elsewhere: Vec<ledger_store::Subject> = b
|
||||
.links_of(a.id)
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|s| *s != ledger_store::Subject::Entry(e.id))
|
||||
.collect();
|
||||
if !elsewhere.is_empty() {
|
||||
let mut go = None;
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(4.0, 4.0);
|
||||
ui.label(
|
||||
RichText::new("Linked to")
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.faint),
|
||||
);
|
||||
for s in elsewhere {
|
||||
super::documents::subject_chip(ui, b, s, &mut go);
|
||||
}
|
||||
});
|
||||
if let Some(r) = go {
|
||||
shell.go(r);
|
||||
}
|
||||
}
|
||||
attachment_view(ui, b, a, shell, true);
|
||||
ui.add_space(12.0);
|
||||
}
|
||||
|
||||
@@ -93,25 +93,26 @@ fn tax_basis_text(b: &Books, asset_id: Option<u64>, p: &Value) -> String {
|
||||
}
|
||||
|
||||
/// What an attachment link's subject (`{"kind": .., "id": ..}`) is, in
|
||||
/// words, and where it opens.
|
||||
/// words (Books::subject_label), and where it opens
|
||||
/// (Books::subject_route).
|
||||
fn subject_text(b: &Books, subject: Option<&Value>) -> (String, Option<Route>) {
|
||||
let kind = subject
|
||||
.and_then(|s| s.get("kind"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let id = subject.and_then(|s| s.get("id")).and_then(|v| v.as_u64());
|
||||
match (kind, id) {
|
||||
("entry", Some(e)) => (format!("#{e}"), Some(Route::Entry(e))),
|
||||
("account", Some(a)) => (b.account_label(a), Some(Route::Account(a))),
|
||||
("asset", Some(a)) => (b.asset_label(a), Some(Route::Asset(a))),
|
||||
("party", Some(p)) => (b.party_label(p), Some(Route::Party(p))),
|
||||
("company", _) => ("the company".into(), Some(Route::Company)),
|
||||
("payroll_run", Some(i)) => (format!("payroll run {i}"), None),
|
||||
(k, Some(i)) if !k.is_empty() => (format!("{} {i}", k.replace('_', " ")), None),
|
||||
_ => ("a subject".into(), None),
|
||||
let parsed =
|
||||
subject.and_then(|v| serde_json::from_value::<ledger_store::Subject>(v.clone()).ok());
|
||||
match parsed {
|
||||
Some(ledger_store::Subject::Company) => ("the company".into(), Some(Route::Company)),
|
||||
Some(sub) => (b.subject_label(sub), b.subject_route(sub)),
|
||||
None => ("a subject".into(), None),
|
||||
}
|
||||
}
|
||||
|
||||
/// A date from the payload, in words ("Sep 30, 2026"), or as written.
|
||||
fn date_text(p: &Value, k: &str) -> String {
|
||||
let t = s(p, k);
|
||||
t.parse::<chrono::NaiveDate>()
|
||||
.map(fmt::date)
|
||||
.unwrap_or_else(|_| t.to_string())
|
||||
}
|
||||
|
||||
/// A party's label, or "a party" when the event names none.
|
||||
fn party_name(b: &Books, id: Option<u64>) -> String {
|
||||
match id {
|
||||
@@ -272,10 +273,10 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
|
||||
Some(Event {
|
||||
icon: icon::FILE_TEXT,
|
||||
text: format!(
|
||||
"Statement for {}, {} to {}: closing balance {closing}",
|
||||
"Recorded {}'s statement, {} – {}: closing balance {closing}",
|
||||
account.map_or("an account".into(), |a| b.account_label(a)),
|
||||
s(p, "period_start"),
|
||||
s(p, "period_end")
|
||||
date_text(p, "period_start"),
|
||||
date_text(p, "period_end")
|
||||
),
|
||||
route: Some(Route::Document(att)),
|
||||
also: account.map(Route::Account),
|
||||
@@ -287,25 +288,48 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
|
||||
.parse()
|
||||
.map(fmt::dollars)
|
||||
.unwrap_or_default();
|
||||
let lines = p
|
||||
.get("cleared")
|
||||
.and_then(|v| v.as_array())
|
||||
.map_or(0, |v| v.len());
|
||||
Some(Event {
|
||||
icon: icon::CHECK_SQUARE,
|
||||
text: format!(
|
||||
"Reconciled {} through {} at {balance}",
|
||||
"Reconciled {} through {} at {balance} ({} cleared)",
|
||||
account.map_or("an account".into(), |a| b.account_label(a)),
|
||||
s(p, "statement_date")
|
||||
date_text(p, "statement_date"),
|
||||
fmt::count(lines, "line", "lines")
|
||||
),
|
||||
route: account.map(Route::Account),
|
||||
also: p
|
||||
.get("attachment_id")
|
||||
route: p
|
||||
.get("id")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(Route::Document),
|
||||
.map(Route::Reconciliation),
|
||||
also: account.map(Route::Account),
|
||||
})
|
||||
}
|
||||
"reconciliation.voided" => {
|
||||
let r = p
|
||||
.get("id")
|
||||
.and_then(|v| v.as_u64())
|
||||
.and_then(|i| b.reconciliation(i));
|
||||
Some(Event {
|
||||
icon: icon::PROHIBIT,
|
||||
text: match r {
|
||||
Some(r) => format!(
|
||||
"Voided the reconciliation of {} through {}: {}",
|
||||
b.account_label(r.account_id),
|
||||
fmt::date(r.statement_date),
|
||||
s(p, "reason")
|
||||
),
|
||||
None => format!("Voided reconciliation {id}: {}", s(p, "reason")),
|
||||
},
|
||||
route: p
|
||||
.get("id")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(Route::Reconciliation),
|
||||
also: r.map(|r| Route::Account(r.account_id)),
|
||||
})
|
||||
}
|
||||
"reconciliation.voided" => ev(
|
||||
icon::PROHIBIT,
|
||||
format!("Voided reconciliation {id}: {}", s(p, "reason")),
|
||||
None,
|
||||
),
|
||||
"attachment.withdrawn" => ev(
|
||||
icon::ARCHIVE,
|
||||
format!("Withdrew {}: {}", attachment_name(b, att), s(p, "reason")),
|
||||
@@ -697,6 +721,15 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
|
||||
),
|
||||
entry_id.map(Route::Entry),
|
||||
),
|
||||
"entry.no_party" => ev(
|
||||
icon::USER_MINUS,
|
||||
format!(
|
||||
"Entry #{} marked as having no counterparty: {}",
|
||||
entry_id.unwrap_or(0),
|
||||
s(p, "reason")
|
||||
),
|
||||
entry_id.map(Route::Entry),
|
||||
),
|
||||
"line.cleared" => ev(
|
||||
icon::CHECK_SQUARE,
|
||||
format!("Cleared a line of #{}", entry_id.unwrap_or(0)),
|
||||
@@ -933,11 +966,14 @@ fn detail(ui: &mut Ui, b: &Books, line: &JournalLine, shell: &mut Shell<'_>) {
|
||||
for r in ev.route.iter().chain(&ev.also) {
|
||||
ui.add_space(6.0);
|
||||
if ui
|
||||
.button(format!(
|
||||
"{} Open {}",
|
||||
icon::ARROW_SQUARE_OUT,
|
||||
crate::palette::describe(b, r)
|
||||
))
|
||||
.button(format!("{} Open {}", icon::ARROW_SQUARE_OUT, {
|
||||
let d = crate::palette::describe(b, r);
|
||||
if d.is_empty() {
|
||||
r.section().title().to_string()
|
||||
} else {
|
||||
d
|
||||
}
|
||||
}))
|
||||
.clicked()
|
||||
{
|
||||
shell.go(r.clone());
|
||||
|
||||
@@ -10,6 +10,7 @@ pub mod journal;
|
||||
pub mod overview;
|
||||
pub mod parties;
|
||||
pub mod profit;
|
||||
pub mod reconciliation;
|
||||
pub mod reports;
|
||||
pub mod transactions;
|
||||
|
||||
@@ -54,6 +55,7 @@ pub fn show(ui: &mut Ui, b: &Books, route: &Route, shell: &mut Shell<'_>) {
|
||||
Route::Reports(kind) => reports::show(ui, b, *kind, shell),
|
||||
Route::Accounts => accounts::show_chart(ui, b, shell),
|
||||
Route::Account(id) => accounts::show_account(ui, b, *id, shell),
|
||||
Route::Reconciliation(id) => reconciliation::show(ui, b, *id, shell),
|
||||
Route::Assets => assets::show_register(ui, b, shell),
|
||||
Route::Asset(id) => assets::show_asset(ui, b, *id, shell),
|
||||
Route::Parties => parties::show_list(ui, b, shell),
|
||||
|
||||
@@ -21,28 +21,28 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
widgets::page_header(ui, b.company_name(), &headline(b));
|
||||
kpis(ui, b, shell);
|
||||
let _ = k;
|
||||
columns(ui, 980.0, &[2.0, 1.0], |i, ui| match i {
|
||||
0 => {
|
||||
let (more, _) = widgets::titled_card(
|
||||
// What needs doing gets the page's full width, under the numbers:
|
||||
// long items wrap in their own room instead of squeezing the chart.
|
||||
attention(ui, b, shell);
|
||||
ui.add_space(16.0);
|
||||
let (more, _) = widgets::titled_card(
|
||||
ui,
|
||||
"Cash over time",
|
||||
|ui| {
|
||||
widgets::link(
|
||||
ui,
|
||||
"Cash over time",
|
||||
|ui| {
|
||||
widgets::link(
|
||||
ui,
|
||||
RichText::new("Cash details")
|
||||
.font(theme::regular(12.5))
|
||||
.color(pal(ui.ctx()).accent),
|
||||
)
|
||||
.clicked()
|
||||
},
|
||||
|ui| charts::balance_line(ui, &b.cash_history(), b.today, 230.0),
|
||||
);
|
||||
if more {
|
||||
shell.go(Route::Cash);
|
||||
}
|
||||
}
|
||||
_ => attention(ui, b, shell),
|
||||
});
|
||||
RichText::new("Cash details")
|
||||
.font(theme::regular(12.5))
|
||||
.color(pal(ui.ctx()).accent),
|
||||
)
|
||||
.clicked()
|
||||
},
|
||||
|ui| charts::balance_line(ui, &b.cash_history(), b.today, 230.0),
|
||||
);
|
||||
if more {
|
||||
shell.go(Route::Cash);
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i {
|
||||
0 => income_vs_spending(ui, b, shell),
|
||||
_ => where_it_went(ui, b, shell),
|
||||
@@ -253,33 +253,52 @@ fn color_for(c: egui::Color32, p: &theme::Palette) -> egui::Color32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// "Needs attention", full width: a summary of how many problems, to-dos,
|
||||
/// and notes there are, then each item as a row (its severity, the title
|
||||
/// that opens where to look, what's wrong, and the prompt for the agent at
|
||||
/// the right; stacked under the text when narrow). The first few show;
|
||||
/// the rest wait behind a button that says how many.
|
||||
fn attention(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
let count = |s: Severity| b.attention.iter().filter(|a| a.severity == s).count();
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Needs attention",
|
||||
|ui| {
|
||||
if !b.attention.is_empty() {
|
||||
widgets::pill(
|
||||
ui,
|
||||
&b.attention.len().to_string(),
|
||||
p.warning,
|
||||
p.warning_soft,
|
||||
);
|
||||
// Laid out right to left: problems end up first, reading on.
|
||||
for (sev, one, many, fg, bg) in [
|
||||
(Severity::Info, "note", "notes", p.muted, p.hover),
|
||||
(Severity::Todo, "to do", "to do", p.warning, p.warning_soft),
|
||||
(
|
||||
Severity::Problem,
|
||||
"problem",
|
||||
"problems",
|
||||
p.negative,
|
||||
p.negative_soft,
|
||||
),
|
||||
] {
|
||||
let n = count(sev);
|
||||
if n > 0 {
|
||||
widgets::pill(ui, &fmt::count(n, one, many), fg, bg);
|
||||
}
|
||||
}
|
||||
},
|
||||
|ui| {
|
||||
if b.attention.is_empty() {
|
||||
widgets::empty(
|
||||
ui,
|
||||
icon::CHECK_CIRCLE,
|
||||
"All clear",
|
||||
"Nothing is missing or overdue.",
|
||||
);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
RichText::new(icon::CHECK_CIRCLE)
|
||||
.size(18.0)
|
||||
.color(p.positive),
|
||||
);
|
||||
ui.label(
|
||||
RichText::new("All clear: nothing is missing or overdue.")
|
||||
.font(theme::regular(13.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
// No inner scrolling: the card grows to fit. Past a handful of
|
||||
// items, the rest sit behind a button that says how many.
|
||||
const FIRST: usize = 6;
|
||||
let id = ui.id().with("attention-expanded");
|
||||
let mut expanded: bool = ui.ctx().data(|d| d.get_temp(id)).unwrap_or(false);
|
||||
@@ -288,34 +307,59 @@ fn attention(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
} else {
|
||||
b.attention.len().min(FIRST)
|
||||
};
|
||||
let wide = ui.available_width() >= 720.0;
|
||||
for (i, a) in b.attention.iter().take(shown).enumerate() {
|
||||
if i > 0 {
|
||||
ui.add_space(4.0);
|
||||
ui.add_space(6.0);
|
||||
widgets::rule(ui);
|
||||
ui.add_space(4.0);
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
let (glyph, color) = match a.severity {
|
||||
Severity::Problem => (icon::WARNING_CIRCLE, p.negative),
|
||||
Severity::Todo => (icon::CIRCLE_DASHED, p.warning),
|
||||
Severity::Info => (icon::INFO, p.muted),
|
||||
let (glyph, color, word) = match a.severity {
|
||||
Severity::Problem => (icon::WARNING_CIRCLE, p.negative, "Problem"),
|
||||
Severity::Todo => (icon::CIRCLE_DASHED, p.warning, "To do"),
|
||||
Severity::Info => (icon::INFO, p.muted, "Note"),
|
||||
};
|
||||
let full = ui.available_width();
|
||||
let button_w = if wide { 150.0 } else { 0.0 };
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.label(RichText::new(glyph).size(16.0).color(color));
|
||||
ui.vertical(|ui| {
|
||||
let t = RichText::new(&a.title).font(theme::semibold(13.0));
|
||||
let r = widgets::link(ui, t);
|
||||
if r.clicked()
|
||||
&& let Some(to) = &a.target
|
||||
{
|
||||
shell.go(to.clone());
|
||||
}
|
||||
ui.label(
|
||||
RichText::new(&a.detail)
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
widgets::copy_prompt(ui, &a.prompt);
|
||||
});
|
||||
ui.label(RichText::new(glyph).size(18.0).color(color))
|
||||
.on_hover_text(word);
|
||||
let text_w = (full - 30.0 - button_w).max(120.0);
|
||||
ui.allocate_ui_with_layout(
|
||||
egui::vec2(text_w, 0.0),
|
||||
egui::Layout::top_down(egui::Align::Min),
|
||||
|ui| {
|
||||
ui.set_width(text_w);
|
||||
ui.spacing_mut().item_spacing.y = 2.0;
|
||||
let t = RichText::new(&a.title).font(theme::semibold(13.5));
|
||||
let r = ui.add(egui::Label::new(t).wrap().sense(egui::Sense::click()));
|
||||
if a.target.is_some() && r.hovered() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
}
|
||||
if r.clicked()
|
||||
&& let Some(to) = &a.target
|
||||
{
|
||||
shell.go(to.clone());
|
||||
}
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
RichText::new(&a.detail)
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
)
|
||||
.wrap(),
|
||||
);
|
||||
if !wide {
|
||||
ui.add_space(4.0);
|
||||
widgets::copy_prompt(ui, &a.prompt);
|
||||
}
|
||||
},
|
||||
);
|
||||
if wide {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
|
||||
widgets::copy_prompt(ui, &a.prompt);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
let hidden = b.attention.len() - shown;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
use super::assets::{Notice, facts, notice_box, row};
|
||||
use super::page;
|
||||
use crate::app::Shell;
|
||||
use crate::books::{Books, PartyInfo};
|
||||
use crate::books::{Books, PartyFlow, PartyInfo, PartyPeriod};
|
||||
use crate::fmt;
|
||||
use crate::nav::{ReportKind, Route};
|
||||
use crate::theme::{self, pal};
|
||||
@@ -29,6 +29,59 @@ pub struct State {
|
||||
pub show_archived: bool,
|
||||
/// Search text: name or display name.
|
||||
pub query: String,
|
||||
/// The span the money columns and a party's page sum over.
|
||||
pub period: PartyPeriod,
|
||||
/// How the directory is ordered.
|
||||
pub sort: Sort,
|
||||
}
|
||||
|
||||
/// The directory's order: by name, or by an amount, largest first.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Sort {
|
||||
#[default]
|
||||
Name,
|
||||
PaidOut,
|
||||
ReceivedIn,
|
||||
Net,
|
||||
}
|
||||
|
||||
/// The directory's rows for `st` in its order: [`listed`] by name, or by
|
||||
/// the chosen amount over `st.period` largest first (ties by name), each
|
||||
/// with its [`PartyFlow`] (totals only).
|
||||
pub fn sorted<'a>(b: &'a Books, st: &State) -> Vec<(&'a Party, PartyFlow)> {
|
||||
let span = b.party_span(st.period);
|
||||
let mut rows: Vec<(&Party, PartyFlow)> = listed(b, st)
|
||||
.into_iter()
|
||||
.map(|p| (p, b.party_flow(p.id, span, false)))
|
||||
.collect();
|
||||
let key = |f: &PartyFlow| match st.sort {
|
||||
Sort::Name => Decimal::ZERO,
|
||||
Sort::PaidOut => f.paid,
|
||||
Sort::ReceivedIn => f.received,
|
||||
Sort::Net => f.net(),
|
||||
};
|
||||
// Stable: equal amounts keep the by-name order.
|
||||
rows.sort_by_key(|x| std::cmp::Reverse(key(&x.1)));
|
||||
rows
|
||||
}
|
||||
|
||||
/// The period chips: each of [`Books::party_periods`], the chosen one
|
||||
/// lit.
|
||||
fn period_chips(ui: &mut Ui, b: &Books, party: Option<PartyId>, period: &mut PartyPeriod) {
|
||||
let p = pal(ui.ctx());
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(6.0, 6.0);
|
||||
ui.label(
|
||||
RichText::new(format!("{} Period", icon::CALENDAR_BLANK))
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
for x in b.party_periods(party) {
|
||||
if chip(ui, &b.party_period_label(x), *period == x) {
|
||||
*period = x;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A role in words, capitalized: "Payout platform".
|
||||
@@ -270,14 +323,11 @@ fn chip(ui: &mut Ui, text: &str, on: bool) -> bool {
|
||||
/// The party directory.
|
||||
pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
let year = b.today.year();
|
||||
page(ui, "parties", |ui| {
|
||||
widgets::page_header(
|
||||
ui,
|
||||
"Parties",
|
||||
&format!(
|
||||
"Everyone the company deals with — vendors, contractors, customers, platforms, agencies — and what it paid each in {year}. Click one for everything the books hold about them."
|
||||
),
|
||||
"Everyone the company deals with — vendors, contractors, customers, platforms, agencies — and what the company paid each and received from each. Click one for everything the books hold about them.",
|
||||
);
|
||||
if b.ledger.parties().next().is_none() {
|
||||
widgets::card(ui, |ui| {
|
||||
@@ -322,8 +372,30 @@ pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
period_chips(ui, b, None, &mut st.period);
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(6.0, 6.0);
|
||||
ui.label(
|
||||
RichText::new(format!("{} Sort", icon::SORT_DESCENDING))
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
for (x, label) in [
|
||||
(Sort::Name, "Name"),
|
||||
(Sort::PaidOut, "Paid out"),
|
||||
(Sort::ReceivedIn, "Received in"),
|
||||
(Sort::Net, "Net"),
|
||||
] {
|
||||
if chip(ui, label, st.sort == x) {
|
||||
st.sort = x;
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
let rows = listed(b, st);
|
||||
let period_label = b.party_period_label(st.period);
|
||||
let rows = sorted(b, st);
|
||||
let missing: Vec<&Party> = b
|
||||
.ledger
|
||||
.parties()
|
||||
@@ -356,42 +428,56 @@ pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
}
|
||||
let mut go = None;
|
||||
widgets::card(ui, |ui| {
|
||||
let amt = widgets::AMOUNT_MIN + 24.0;
|
||||
// Narrow, the roles and W-9 columns give way and the entry
|
||||
// count goes, so the amount stays in view.
|
||||
let amt = widgets::AMOUNT_MIN + 16.0;
|
||||
// Narrow, the entry count and then the roles go (the role
|
||||
// chips above still filter), so the amounts stay in view.
|
||||
let avail = ui.available_width();
|
||||
let roles_w = (avail * 0.2).clamp(140.0, 210.0);
|
||||
let w9_w = (avail * 0.2).clamp(160.0, 230.0);
|
||||
let n_w = if avail >= 860.0 { 76.0 } else { 0.0 };
|
||||
let fixed = roles_w + w9_w + n_w + amt;
|
||||
let w = avail.max(fixed + 200.0);
|
||||
let show_roles = avail >= 900.0;
|
||||
let roles_w = if show_roles {
|
||||
(avail * 0.16).clamp(120.0, 200.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let w9_w = (avail * 0.16).clamp(140.0, 210.0);
|
||||
let n_w = if avail >= 1000.0 { 70.0 } else { 0.0 };
|
||||
let fixed = roles_w + w9_w + n_w + amt * 3.0;
|
||||
let w = avail.max(fixed + 180.0);
|
||||
// Cells without the entry count when it has no room.
|
||||
let cells = |c: [(String, f32, bool, egui::Color32); 5]| -> Vec<_> {
|
||||
let cells = |c: [(String, f32, bool, egui::Color32); 7]| -> Vec<_> {
|
||||
c.into_iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| *i != 3 || n_w > 0.0)
|
||||
.filter(|(i, _)| (*i != 3 || n_w > 0.0) && (*i != 1 || show_roles))
|
||||
.map(|(_, c)| c)
|
||||
.collect()
|
||||
};
|
||||
widgets::min_width(ui, "party_list", w, |ui| {
|
||||
let head = |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted);
|
||||
let head = |t: &str, cw: f32, right: bool, on: bool| {
|
||||
(
|
||||
t.to_string(),
|
||||
cw,
|
||||
right,
|
||||
if on { p.accent } else { p.muted },
|
||||
)
|
||||
};
|
||||
let sort = st.sort;
|
||||
row(
|
||||
ui,
|
||||
w,
|
||||
&cells([
|
||||
head("Name", 0.0, false),
|
||||
head("Roles", roles_w, false),
|
||||
head("W-9", w9_w, false),
|
||||
head("Entries", n_w, true),
|
||||
head(&format!("Paid in {year}"), amt, true),
|
||||
head("Name", 0.0, false, sort == Sort::Name),
|
||||
head("Roles", roles_w, false, false),
|
||||
head("W-9", w9_w, false, false),
|
||||
head("Entries", n_w, true, false),
|
||||
head("Paid out", amt, true, sort == Sort::PaidOut),
|
||||
head("Received in", amt, true, sort == Sort::ReceivedIn),
|
||||
head("Net", amt, true, sort == Sort::Net),
|
||||
]),
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
widgets::rule(ui);
|
||||
for x in &rows {
|
||||
let info = b.party(x.id).cloned().unwrap_or_default();
|
||||
for (x, flow) in &rows {
|
||||
let text = if x.archived { p.muted } else { p.text };
|
||||
let status = w9_status(b, x);
|
||||
let w9_color = match status {
|
||||
@@ -413,7 +499,16 @@ pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
sub.push(format!("Party {}", x.id));
|
||||
}
|
||||
let sub = sub.join(" · ");
|
||||
let (paid, paid_color) = paid_cell(info.paid_ytd, p);
|
||||
let money = |d: Decimal, c| {
|
||||
if d.is_zero() {
|
||||
("—".to_string(), p.faint)
|
||||
} else {
|
||||
(fmt::money(d), c)
|
||||
}
|
||||
};
|
||||
let (out_t, out_c) = money(flow.paid, p.text);
|
||||
let (in_t, in_c) = money(flow.received, p.positive);
|
||||
let (net_t, net_c) = paid_cell(flow.net(), p);
|
||||
let resp = row(
|
||||
ui,
|
||||
w,
|
||||
@@ -421,25 +516,21 @@ pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
(x.label().to_string(), 0.0, false, text),
|
||||
(roles_text(x), roles_w, false, p.muted),
|
||||
(status.text(), w9_w, false, w9_color),
|
||||
(info.entries.len().to_string(), n_w, true, p.muted),
|
||||
(paid, amt, true, paid_color),
|
||||
(flow.entries.to_string(), n_w, true, p.muted),
|
||||
(out_t, amt, true, out_c),
|
||||
(in_t, amt, true, in_c),
|
||||
(net_t, amt, true, net_c),
|
||||
]),
|
||||
Some((sub.as_str(), p.faint)),
|
||||
false,
|
||||
true,
|
||||
);
|
||||
let resp = resp.on_hover_text(if info.paid_ytd < Decimal::ZERO {
|
||||
format!(
|
||||
"Open this party. On net the company received {} from them in {year}.",
|
||||
fmt::dollars(-info.paid_ytd)
|
||||
)
|
||||
} else {
|
||||
"Open this party".to_string()
|
||||
});
|
||||
let resp = resp.on_hover_text("Open this party");
|
||||
resp.context_menu(|ui| {
|
||||
widgets::copy_item(ui, "Copy name", &x.name);
|
||||
widgets::copy_item(ui, "Copy party id", &x.id.to_string());
|
||||
widgets::copy_item(ui, "Copy paid this year", &info.paid_ytd.to_string());
|
||||
widgets::copy_item(ui, "Copy paid out", &flow.paid.to_string());
|
||||
widgets::copy_item(ui, "Copy received in", &flow.received.to_string());
|
||||
});
|
||||
if resp.clicked() {
|
||||
go = Some(x.id);
|
||||
@@ -451,7 +542,7 @@ pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
RichText::new(format!(
|
||||
"Paid in {year}: what the party's posted entries since January 1 moved out of cash, cards, and what's owed back to whoever paid for the company; a plus is money received from them. The 1099 amount, on each party's page, is the 1099 worksheet's."
|
||||
"{period_label}: Paid out is what each party's posted entries moved out of cash, cards, and what's owed back to whoever paid for the company; Received in is what came back in; Net is paid out less received (a plus means more came in). Entries counts the posted ones in the period. The 1099 amount, on each party's page, is the 1099 worksheet's."
|
||||
))
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.faint),
|
||||
@@ -541,18 +632,46 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) {
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
|
||||
// This year's totals.
|
||||
// The money over the chosen period: out, in, and the net.
|
||||
let period = shell.state.parties.period;
|
||||
period_chips(ui, b, Some(id), &mut shell.state.parties.period);
|
||||
ui.add_space(10.0);
|
||||
let span = b.party_span(period);
|
||||
let flow = b.party_flow(id, span, true);
|
||||
let label = b.party_period_label(period);
|
||||
let span_words = format!("{} – {}", fmt::date(span.0), fmt::date(span.1));
|
||||
let mut tiles: Vec<Tile> = Vec::new();
|
||||
let received = info.paid_ytd < Decimal::ZERO;
|
||||
tiles.push((
|
||||
if received {
|
||||
format!("Received in {year}")
|
||||
} else {
|
||||
format!("Paid in {year}")
|
||||
},
|
||||
info.paid_ytd.abs(),
|
||||
"Over this party's posted entries since January 1: credits less debits to cash, cards, and what's owed back to whoever paid for the company (received, when more came in than went out).".into(),
|
||||
Some((fmt::count(info.entries.len(), "entry", "entries"), p.muted)),
|
||||
"Paid out".into(),
|
||||
flow.paid,
|
||||
format!("{label}, {span_words}: what this party's posted entries moved out of cash, cards, and what's owed back to whoever paid for the company (net of refunds and reversals of payments)."),
|
||||
Some((label.clone(), p.muted)),
|
||||
));
|
||||
tiles.push((
|
||||
"Received in".into(),
|
||||
flow.received,
|
||||
format!("{label}, {span_words}: what this party's posted entries brought into cash and those accounts (net of reversals)."),
|
||||
Some((label.clone(), p.muted)),
|
||||
));
|
||||
let net = flow.net();
|
||||
tiles.push((
|
||||
"Net".into(),
|
||||
net.abs(),
|
||||
"Paid out less received in.".into(),
|
||||
Some((
|
||||
if net < Decimal::ZERO {
|
||||
"more received than paid".to_string()
|
||||
} else if net.is_zero() {
|
||||
"even".to_string()
|
||||
} else {
|
||||
"more paid than received".to_string()
|
||||
},
|
||||
if net < Decimal::ZERO {
|
||||
p.positive
|
||||
} else {
|
||||
p.muted
|
||||
},
|
||||
)),
|
||||
));
|
||||
if party.customer.is_some() {
|
||||
tiles.push((
|
||||
@@ -581,28 +700,33 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) {
|
||||
}),
|
||||
));
|
||||
}
|
||||
if let Some(l) = &info.form_1099 {
|
||||
let threshold = info.threshold_1099.unwrap_or_default();
|
||||
let tax_year = b.party_period_year(period);
|
||||
let form = if tax_year == year {
|
||||
info.form_1099.clone().zip(info.threshold_1099)
|
||||
} else {
|
||||
b.party_1099(id, tax_year)
|
||||
};
|
||||
if let Some((l, threshold)) = &form {
|
||||
let sub = match l.form {
|
||||
Some(_) if l.reportable => (
|
||||
format!("Reportable · threshold {}", fmt::dollars(threshold)),
|
||||
format!("Reportable · threshold {}", fmt::dollars(*threshold)),
|
||||
p.warning,
|
||||
),
|
||||
Some(_) => (
|
||||
format!("Under the {} threshold", fmt::dollars(threshold)),
|
||||
format!("Under the {} threshold", fmt::dollars(*threshold)),
|
||||
p.muted,
|
||||
),
|
||||
None => ("Not marked for a 1099".into(), p.muted),
|
||||
};
|
||||
tiles.push((
|
||||
format!("1099 amount, {year}"),
|
||||
format!("1099 amount, {tax_year}"),
|
||||
l.paid,
|
||||
"Cash paid in the calendar year, from the 1099 worksheet (ledger-core): posted entries tagged with its vendor record, net of refunds and reversals. Card payments aren't counted (the card issuer reports them).".into(),
|
||||
Some(sub),
|
||||
));
|
||||
}
|
||||
let n = tiles.len();
|
||||
super::grid(ui, n, 190.0, 5, |i, ui| {
|
||||
super::grid(ui, n, 170.0, 6, |i, ui| {
|
||||
let (label, value, how, sub) = &tiles[i];
|
||||
let r = widgets::kpi(
|
||||
ui,
|
||||
@@ -617,6 +741,13 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) {
|
||||
r.context_menu(|ui| widgets::copy_item(ui, "Copy amount", &value.to_string()));
|
||||
});
|
||||
|
||||
super::columns(ui, 860.0, &[1.0, 1.0], |col, ui| {
|
||||
if col == 0 {
|
||||
what_for_card(ui, b, &flow, &label, &mut go);
|
||||
} else {
|
||||
months_card(ui, b, id, span, &label);
|
||||
}
|
||||
});
|
||||
super::columns(ui, 860.0, &[1.0, 1.0], |col, ui| {
|
||||
if col == 0 {
|
||||
facts_card(ui, party, &mut go);
|
||||
@@ -648,6 +779,23 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) {
|
||||
payroll_card(ui, b, party, &info, &mut go);
|
||||
ui.add_space(14.0);
|
||||
}
|
||||
let mut subjects = vec![ledger_store::Subject::Party(id)];
|
||||
subjects.extend(
|
||||
info.invoices
|
||||
.iter()
|
||||
.map(|i| ledger_store::Subject::Invoice(*i)),
|
||||
);
|
||||
subjects.extend(info.bills.iter().map(|i| ledger_store::Subject::Bill(*i)));
|
||||
super::documents::documents_card_with(
|
||||
ui,
|
||||
b,
|
||||
"Documents",
|
||||
&subjects,
|
||||
&[ledger_store::Subject::Party(id)],
|
||||
"No documents linked to them (contracts, a W-9 is kept in the vault, correspondence).",
|
||||
&mut go,
|
||||
);
|
||||
ui.add_space(14.0);
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"History",
|
||||
@@ -1180,3 +1328,147 @@ fn payroll_card(ui: &mut Ui, b: &Books, party: &Party, info: &PartyInfo, go: &mu
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// "What for": the period's money by the account on the other side of
|
||||
/// each entry ([`PartyFlow::what_for`]), which sums to the net exactly.
|
||||
fn what_for_card(ui: &mut Ui, b: &Books, flow: &PartyFlow, label: &str, go: &mut Option<Route>) {
|
||||
let p = pal(ui.ctx());
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"What for",
|
||||
|ui| {
|
||||
ui.label(
|
||||
RichText::new(label)
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
},
|
||||
|ui| {
|
||||
if flow.what_for.is_empty() {
|
||||
ui.label(
|
||||
RichText::new("No posted entries with them in this period.").color(p.muted),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let amt = widgets::AMOUNT_MIN + 16.0;
|
||||
let w = ui.available_width().max(200.0 + amt);
|
||||
let roles = [
|
||||
(ledger_core::PAYABLES, "billed, not yet paid"),
|
||||
(ledger_core::RECEIVABLES, "invoiced, not yet collected"),
|
||||
];
|
||||
widgets::min_width(ui, "what_for", w, |ui| {
|
||||
for (account, amount) in &flow.what_for {
|
||||
let note = roles
|
||||
.iter()
|
||||
.find(|(r, _)| b.ledger.role_account(r) == Some(*account))
|
||||
.map(|(_, n)| *n);
|
||||
let (text, color) = if *amount < Decimal::ZERO {
|
||||
(format!("+{}", fmt::money(-*amount)), p.positive)
|
||||
} else {
|
||||
(fmt::money(*amount), p.text)
|
||||
};
|
||||
let resp = row(
|
||||
ui,
|
||||
w,
|
||||
&[
|
||||
(b.account_label(*account), 0.0, false, p.text),
|
||||
(text, amt, true, color),
|
||||
],
|
||||
note.map(|n| (n, p.faint)),
|
||||
false,
|
||||
true,
|
||||
);
|
||||
if resp.on_hover_text("Open the account's register").clicked() {
|
||||
*go = Some(Route::Account(*account));
|
||||
}
|
||||
}
|
||||
widgets::rule(ui);
|
||||
let net = flow.net();
|
||||
row(
|
||||
ui,
|
||||
w,
|
||||
&[
|
||||
("Net".into(), 0.0, false, p.text),
|
||||
(
|
||||
if net < Decimal::ZERO {
|
||||
format!("+{}", fmt::money(-net))
|
||||
} else {
|
||||
fmt::money(net)
|
||||
},
|
||||
amt,
|
||||
true,
|
||||
if net < Decimal::ZERO {
|
||||
p.positive
|
||||
} else {
|
||||
p.text
|
||||
},
|
||||
),
|
||||
],
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
RichText::new("Each entry's other side: what the money paid for, or (with a plus) where money received came from. Sums to the net exactly.")
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.faint),
|
||||
)
|
||||
.wrap(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Out versus in, month by month over the period ([`Books::party_months`]).
|
||||
fn months_card(ui: &mut Ui, b: &Books, id: PartyId, span: (NaiveDate, NaiveDate), label: &str) {
|
||||
let p = pal(ui.ctx());
|
||||
let months = b.party_months(id, span);
|
||||
// All time can be long: the chart shows the last 24 months of it.
|
||||
let shown: Vec<_> = months.iter().rev().take(24).rev().collect();
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"By month",
|
||||
|ui| {
|
||||
ui.label(
|
||||
RichText::new(if months.len() > shown.len() {
|
||||
format!("{label}, last {} months", shown.len())
|
||||
} else {
|
||||
label.to_string()
|
||||
})
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
},
|
||||
|ui| {
|
||||
if shown
|
||||
.iter()
|
||||
.all(|m| m.paid.is_zero() && m.received.is_zero())
|
||||
{
|
||||
widgets::empty(ui, icon::CHART_BAR, "No money moved", "");
|
||||
return;
|
||||
}
|
||||
let multi_year =
|
||||
shown.first().map(|m| m.month.year()) != shown.last().map(|m| m.month.year());
|
||||
let groups: Vec<crate::charts::BarGroup> = shown
|
||||
.iter()
|
||||
.map(|m| crate::charts::BarGroup {
|
||||
label: if multi_year && m.month.month() == 1 {
|
||||
m.month.format("%b %y").to_string()
|
||||
} else {
|
||||
m.month.format("%b").to_string()
|
||||
},
|
||||
values: vec![m.paid, m.received],
|
||||
})
|
||||
.collect();
|
||||
crate::charts::bar_chart(
|
||||
ui,
|
||||
&groups,
|
||||
&[("Paid out", p.series[2]), ("Received in", p.series[1])],
|
||||
200.0,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
//! One reconciliation: the account and statement it agrees with, the
|
||||
//! balance, every line it found cleared (each opening its entry), and the
|
||||
//! lines on the account through that date it did not clear. Shared with
|
||||
//! the account page and "Needs attention": the prompt that asks the agent
|
||||
//! to reconcile an account, and where a reconciliation stands.
|
||||
|
||||
use super::assets::{Notice, facts, notice_box, row};
|
||||
use super::page;
|
||||
use crate::app::Shell;
|
||||
use crate::books::Books;
|
||||
use crate::fmt;
|
||||
use crate::nav::Route;
|
||||
use crate::theme::{self, pal};
|
||||
use crate::widgets;
|
||||
use chrono::NaiveDate;
|
||||
use egui::{RichText, Ui};
|
||||
use egui_phosphor::regular as icon;
|
||||
use ledger_core::{AccountId, EntryId, Side};
|
||||
use ledger_store::{Reconciliation, Subject};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
/// The agent prompt that reconciles an account through a date.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: one paste gets the agent to do the monthly reconciliation the
|
||||
/// way the books require it (docs/parties-and-attachments.md,
|
||||
/// Reconciliations: a statement recorded and attached, the
|
||||
/// difference exactly zero).
|
||||
/// INVARIANTS:
|
||||
/// 1. Starts "In wanderbooks,"; names the account's code and name, the
|
||||
/// date (YYYY-MM-DD), books.record_statement, books.mark_cleared,
|
||||
/// and books.reconcile.
|
||||
/// 2. Says where it last stood: reconciled through a date, or never.
|
||||
/// ```
|
||||
pub fn reconcile_prompt(b: &Books, account: AccountId, through: NaiveDate) -> String {
|
||||
let since = match b.reconciled_through(account) {
|
||||
Some(d) => format!("It is reconciled through {d}, so start with the statement after that"),
|
||||
None => "It has never been reconciled, so start with its first statement".to_string(),
|
||||
};
|
||||
format!(
|
||||
"In wanderbooks, reconcile {} through {through}. {since}. For each monthly statement: file the PDF, record it with books.record_statement (its period and opening and closing balances), mark the lines that cleared with books.mark_cleared, then run books.reconcile against it (preview first; the difference must be exactly zero). Show me anything that doesn't match, and the account when it's done.",
|
||||
b.account_label(account)
|
||||
)
|
||||
}
|
||||
|
||||
/// Where a reconciliation stands among its account's.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Standing {
|
||||
/// The account's newest reconciliation not voided.
|
||||
Current,
|
||||
/// A later reconciliation not voided supersedes it.
|
||||
Superseded,
|
||||
/// Voided, with the reason.
|
||||
Voided(String),
|
||||
}
|
||||
|
||||
/// A reconciliation's [`Standing`].
|
||||
pub fn standing(b: &Books, r: &Reconciliation) -> Standing {
|
||||
match &r.voided {
|
||||
Some(why) => Standing::Voided(why.clone()),
|
||||
None if b.reconciled_through(r.account_id) == Some(r.statement_date) => Standing::Current,
|
||||
None => Standing::Superseded,
|
||||
}
|
||||
}
|
||||
|
||||
/// A line's amount in the account's normal direction (a debit raises an
|
||||
/// asset, a credit raises a liability).
|
||||
fn signed(b: &Books, account: AccountId, side: Side, amount: Decimal) -> Decimal {
|
||||
let normal = b
|
||||
.ledger
|
||||
.chart()
|
||||
.get(account)
|
||||
.map_or(Side::Debit, |a| a.account_type.normal_side());
|
||||
if side == normal { amount } else { -amount }
|
||||
}
|
||||
|
||||
/// Every posted line on the reconciliation's account dated on or before
|
||||
/// its statement date, as (entry, line index, date, description, signed
|
||||
/// amount, cleared by it), by date then entry.
|
||||
pub fn lines(
|
||||
b: &Books,
|
||||
r: &Reconciliation,
|
||||
) -> Vec<(EntryId, usize, NaiveDate, String, Decimal, bool)> {
|
||||
let mut out = Vec::new();
|
||||
for e in b.ledger.posted_entries() {
|
||||
if e.date() > r.statement_date {
|
||||
continue;
|
||||
}
|
||||
for (i, l) in e.lines().iter().enumerate() {
|
||||
if l.account == r.account_id {
|
||||
out.push((
|
||||
e.id(),
|
||||
i,
|
||||
e.date(),
|
||||
e.description().to_string(),
|
||||
signed(b, r.account_id, l.side, l.amount.amount),
|
||||
r.cleared.contains(&(e.id(), i)),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| a.2.cmp(&b.2).then(a.0.cmp(&b.0)).then(a.1.cmp(&b.1)));
|
||||
out
|
||||
}
|
||||
|
||||
pub fn show(ui: &mut Ui, b: &Books, id: u64, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
let Some(r) = b.reconciliation(id) else {
|
||||
page(ui, "reconciliation", |ui| {
|
||||
widgets::empty(
|
||||
ui,
|
||||
icon::CHECK_SQUARE,
|
||||
"No such reconciliation",
|
||||
"It isn't in these books.",
|
||||
);
|
||||
});
|
||||
return;
|
||||
};
|
||||
let mut go: Option<Route> = None;
|
||||
page(ui, "reconciliation", |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if widgets::link(
|
||||
ui,
|
||||
RichText::new(format!(
|
||||
"{} {}",
|
||||
icon::ARROW_LEFT,
|
||||
b.account_label(r.account_id)
|
||||
))
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
go = Some(Route::Account(r.account_id));
|
||||
}
|
||||
widgets::copyable(
|
||||
ui,
|
||||
RichText::new(format!("· Reconciliation {id}"))
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
&id.to_string(),
|
||||
);
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"{} reconciled through {}",
|
||||
b.account_name(r.account_id),
|
||||
fmt::date(r.statement_date)
|
||||
))
|
||||
.font(theme::bold(26.0)),
|
||||
);
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal_wrapped(|ui| match standing(b, r) {
|
||||
Standing::Current => {
|
||||
widgets::pill(
|
||||
ui,
|
||||
&format!("{} Current", icon::CHECK),
|
||||
p.positive,
|
||||
p.positive_soft,
|
||||
);
|
||||
}
|
||||
Standing::Superseded => {
|
||||
widgets::pill(ui, "Superseded by a later reconciliation", p.muted, p.hover);
|
||||
}
|
||||
Standing::Voided(why) => {
|
||||
widgets::pill(ui, &format!("Voided: {why}"), p.warning, p.warning_soft);
|
||||
}
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
let all = lines(b, r);
|
||||
let cleared: Vec<_> = all.iter().filter(|l| l.5).collect();
|
||||
let outstanding: Vec<_> = all.iter().filter(|l| !l.5).collect();
|
||||
let cleared_total: Decimal = cleared.iter().map(|l| l.4).sum();
|
||||
let st = b.statement(r.attachment_id);
|
||||
super::columns(ui, 860.0, &[1.0, 1.0], |col, ui| {
|
||||
if col == 0 {
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Reconciliation",
|
||||
|_| {},
|
||||
|ui| {
|
||||
facts(ui, |ui, k| {
|
||||
k(ui, "Account");
|
||||
if widgets::link_truncated(
|
||||
ui,
|
||||
RichText::new(b.account_label(r.account_id)).color(p.accent),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
go = Some(Route::Account(r.account_id));
|
||||
}
|
||||
ui.end_row();
|
||||
k(ui, "Through");
|
||||
ui.label(fmt::date(r.statement_date));
|
||||
ui.end_row();
|
||||
k(ui, "Statement balance");
|
||||
widgets::copyable(
|
||||
ui,
|
||||
RichText::new(fmt::dollars(r.statement_balance))
|
||||
.font(theme::semibold(14.0)),
|
||||
&r.statement_balance.to_string(),
|
||||
);
|
||||
ui.end_row();
|
||||
k(ui, "Cleared lines");
|
||||
ui.label(format!(
|
||||
"{} totaling {}",
|
||||
fmt::count(r.cleared.len(), "line", "lines"),
|
||||
fmt::dollars(cleared_total)
|
||||
));
|
||||
ui.end_row();
|
||||
k(ui, "Books' balance then");
|
||||
ui.label(fmt::dollars(b.balance_on(r.account_id, r.statement_date)));
|
||||
ui.end_row();
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
RichText::new("Recorded only when the cleared lines agree with the statement to the cent. A reconciliation is never edited: a mistake is voided and redone.")
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.faint),
|
||||
)
|
||||
.wrap(),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Statement",
|
||||
|_| {},
|
||||
|ui| {
|
||||
let file = b.attachment(r.attachment_id);
|
||||
facts(ui, |ui, k| {
|
||||
if let Some(st) = st {
|
||||
k(ui, "Period");
|
||||
ui.label(format!(
|
||||
"{} – {}",
|
||||
fmt::date(st.period_start),
|
||||
fmt::date(st.period_end)
|
||||
));
|
||||
ui.end_row();
|
||||
k(ui, "Opening");
|
||||
ui.label(fmt::dollars(st.opening_balance));
|
||||
ui.end_row();
|
||||
k(ui, "Closing");
|
||||
ui.label(fmt::dollars(st.closing_balance));
|
||||
ui.end_row();
|
||||
}
|
||||
k(ui, "File");
|
||||
let name = file.map_or_else(
|
||||
|| format!("attachment {}", r.attachment_id),
|
||||
|a| a.file_name.clone(),
|
||||
);
|
||||
if widgets::link_truncated(
|
||||
ui,
|
||||
RichText::new(format!("{} {name}", icon::FILE_PDF)).color(p.accent),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
go = Some(Route::Document(r.attachment_id));
|
||||
}
|
||||
ui.end_row();
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
lines_card(
|
||||
ui,
|
||||
b,
|
||||
"Cleared lines",
|
||||
&cleared,
|
||||
"It cleared no lines (the account had no activity through this date).",
|
||||
&mut go,
|
||||
);
|
||||
ui.add_space(14.0);
|
||||
if !outstanding.is_empty() {
|
||||
notice_box(
|
||||
ui,
|
||||
&Notice {
|
||||
problem: false,
|
||||
text: format!(
|
||||
"{} on the account through {} had not cleared the bank by this statement (outstanding then).",
|
||||
fmt::count(outstanding.len(), "line", "lines"),
|
||||
fmt::date(r.statement_date)
|
||||
),
|
||||
},
|
||||
);
|
||||
lines_card(ui, b, "Outstanding then", &outstanding, "", &mut go);
|
||||
ui.add_space(14.0);
|
||||
}
|
||||
super::documents::documents_card(
|
||||
ui,
|
||||
b,
|
||||
"Documents",
|
||||
&[Subject::Reconciliation(id)],
|
||||
"No documents linked to it.",
|
||||
&mut go,
|
||||
);
|
||||
ui.add_space(14.0);
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"History",
|
||||
|_| {},
|
||||
|ui| {
|
||||
for line in b.journal.iter().filter(|l| {
|
||||
matches!(
|
||||
l.line_type.as_str(),
|
||||
"reconciliation.completed" | "reconciliation.voided"
|
||||
) && l.payload.get("id").and_then(|v| v.as_u64()) == Some(id)
|
||||
}) {
|
||||
let ev = super::journal::describe_any(b, line);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(ev.icon).color(p.muted));
|
||||
if widgets::link_truncated(
|
||||
ui,
|
||||
RichText::new(&ev.text).font(theme::regular(12.5)),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
go = Some(Route::JournalEvent(line.seq));
|
||||
}
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"event {} · {}",
|
||||
line.seq,
|
||||
fmt::timestamp(&line.ts)
|
||||
))
|
||||
.font(theme::regular(11.0))
|
||||
.color(p.faint),
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
if let Some(to) = go {
|
||||
shell.go(to);
|
||||
}
|
||||
}
|
||||
|
||||
type Line = (EntryId, usize, NaiveDate, String, Decimal, bool);
|
||||
|
||||
/// A table of account lines, each opening its entry.
|
||||
fn lines_card(
|
||||
ui: &mut Ui,
|
||||
b: &Books,
|
||||
title: &str,
|
||||
rows: &[&Line],
|
||||
empty: &str,
|
||||
go: &mut Option<Route>,
|
||||
) {
|
||||
let p = pal(ui.ctx());
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
title,
|
||||
|ui| {
|
||||
if !rows.is_empty() {
|
||||
widgets::pill(ui, &rows.len().to_string(), p.muted, p.hover);
|
||||
}
|
||||
},
|
||||
|ui| {
|
||||
if rows.is_empty() {
|
||||
ui.label(RichText::new(empty).color(p.muted));
|
||||
return;
|
||||
}
|
||||
let amt = widgets::AMOUNT_MIN + 16.0;
|
||||
let w = ui.available_width().max(104.0 + 70.0 + 200.0 + amt);
|
||||
widgets::min_width(ui, title, w, |ui| {
|
||||
let head = |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted);
|
||||
row(
|
||||
ui,
|
||||
w,
|
||||
&[
|
||||
head("Date", 104.0, false),
|
||||
head("Entry", 70.0, false),
|
||||
head("Description", 0.0, false),
|
||||
head("Amount", amt, true),
|
||||
],
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
widgets::rule(ui);
|
||||
for (e, _, date, desc, amount, _) in rows.iter().copied() {
|
||||
let resp = row(
|
||||
ui,
|
||||
w,
|
||||
&[
|
||||
(fmt::date(*date), 104.0, false, p.muted),
|
||||
(format!("#{e}"), 70.0, false, p.muted),
|
||||
(desc.clone(), 0.0, false, p.text),
|
||||
(
|
||||
fmt::money(*amount),
|
||||
amt,
|
||||
true,
|
||||
if *amount < Decimal::ZERO {
|
||||
p.negative
|
||||
} else {
|
||||
p.text
|
||||
},
|
||||
),
|
||||
],
|
||||
None,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
if resp.on_hover_text("Open its entry").clicked() {
|
||||
*go = Some(Route::Entry(*e));
|
||||
}
|
||||
}
|
||||
});
|
||||
let _ = b;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Ctrl+K: jump to anything. Pages, reports, accounts, entries (by text,
|
||||
//! amount, or #id), fixed assets (by tag, serial, or description), parties
|
||||
//! (vendors and customers among them, by name or display name), and
|
||||
//! documents, ranked by a simple fuzzy match.
|
||||
//! (vendors and customers among them, by name or display name),
|
||||
//! documents, bank and card statements, and reconciliations (by account
|
||||
//! and date), ranked by a simple fuzzy match.
|
||||
|
||||
use crate::books::Books;
|
||||
use crate::fmt;
|
||||
@@ -234,6 +235,15 @@ pub fn fuzzy(hay: &str, query: &str) -> Option<i32> {
|
||||
/// case) adds 500.
|
||||
/// 6. Every party result opens Route::Party(its id); archived parties
|
||||
/// are found too, and say so.
|
||||
/// INVARIANTS (statements and reconciliations):
|
||||
/// 7. A statement is listed when the query fuzzily matches "statement",
|
||||
/// its account's code and name, its period's dates (as shown and as
|
||||
/// YYYY-MM-DD), and its file name; it opens Route::Document(its
|
||||
/// attachment).
|
||||
/// 8. A reconciliation is listed when the query fuzzily matches
|
||||
/// "reconciliation reconciled", its account's code and name, and its
|
||||
/// statement date (as shown and as YYYY-MM-DD); it opens
|
||||
/// Route::Reconciliation(its id), voided ones too, saying so.
|
||||
/// ```
|
||||
pub fn search(b: &Books, query: &str) -> Vec<Item> {
|
||||
let q = query.trim();
|
||||
@@ -371,6 +381,61 @@ pub fn search(b: &Books, query: &str) -> Vec<Item> {
|
||||
});
|
||||
}
|
||||
}
|
||||
for st in &b.statements {
|
||||
let file = b
|
||||
.attachment(st.attachment_id)
|
||||
.map_or("", |a| a.file_name.as_str());
|
||||
let hay = format!(
|
||||
"statement {} {} {} {} {} {file}",
|
||||
b.account_label(st.account_id),
|
||||
fmt::date(st.period_start),
|
||||
fmt::date(st.period_end),
|
||||
st.period_start,
|
||||
st.period_end
|
||||
);
|
||||
if let Some(score) = fuzzy(&hay, q) {
|
||||
out.push(Item {
|
||||
icon: icon::FILE_TEXT,
|
||||
title: format!(
|
||||
"Statement: {}, {} – {}",
|
||||
b.account_name(st.account_id),
|
||||
fmt::date_short(st.period_start),
|
||||
fmt::date(st.period_end)
|
||||
),
|
||||
detail: format!("Closing {} · {file}", fmt::dollars(st.closing_balance)),
|
||||
route: Route::Document(st.attachment_id),
|
||||
score,
|
||||
});
|
||||
}
|
||||
}
|
||||
for r in &b.reconciliations {
|
||||
let hay = format!(
|
||||
"reconciliation reconciled {} {} {}",
|
||||
b.account_label(r.account_id),
|
||||
fmt::date(r.statement_date),
|
||||
r.statement_date
|
||||
);
|
||||
if let Some(score) = fuzzy(&hay, q) {
|
||||
let mut detail = vec![
|
||||
fmt::dollars(r.statement_balance),
|
||||
fmt::count(r.cleared.len(), "cleared line", "cleared lines"),
|
||||
];
|
||||
if r.voided.is_some() {
|
||||
detail.push("voided".into());
|
||||
}
|
||||
out.push(Item {
|
||||
icon: icon::CHECK_SQUARE,
|
||||
title: format!(
|
||||
"Reconciliation: {} through {}",
|
||||
b.account_name(r.account_id),
|
||||
fmt::date(r.statement_date)
|
||||
),
|
||||
detail: detail.join(" · "),
|
||||
route: Route::Reconciliation(r.id),
|
||||
score,
|
||||
});
|
||||
}
|
||||
}
|
||||
for a in &b.attachments {
|
||||
let hay = format!("{} {}", a.file_name, a.description.as_deref().unwrap_or(""));
|
||||
if let Some(score) = fuzzy(&hay, q) {
|
||||
@@ -410,6 +475,7 @@ pub fn describe(b: &Books, r: &Route) -> String {
|
||||
.unwrap_or_default(),
|
||||
Route::Asset(id) => b.asset_label(*id),
|
||||
Route::Party(id) => b.party_label(*id),
|
||||
Route::Reconciliation(id) => b.subject_label(ledger_store::Subject::Reconciliation(*id)),
|
||||
Route::JournalEvent(seq) => format!("Event {seq}"),
|
||||
_ => String::new(),
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user