Attachments, statements, reconciliations, TIN vault, counterparty policy

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:
swaitsandClaude Opus 5.5 committed 2026-10-15 15:42:37 -06:00
1 parent a299077991
commit 08ce558ed3
41 files changed
+8463 -465

No files matched your search

+198 -2
View File
@@ -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));
}
}
});
},
);
}
});
}
+8 -50
View File
@@ -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);
+26
View File
@@ -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);
+271 -16
View File
@@ -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),
);
}
});
}
}
+46 -3
View File
@@ -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);
}
+70 -34
View File
@@ -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());
+2
View File
@@ -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),
+102 -58
View File
@@ -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;
+345 -53
View File
@@ -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;
},
);
}