Compare commits
179
Commits
main
...
714867ed58
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
714867ed58
|
||
|
|
105fd52694
|
||
|
|
b48addd0e0
|
||
|
|
d65f9aa840
|
||
|
|
ddb67a2ca2
|
||
|
|
dbdc8e671f
|
||
|
|
f858fb4cfe
|
||
|
|
8164abb2b8
|
||
|
|
f591264533
|
||
|
|
8f623ba810
|
||
|
|
02b3b9c053
|
||
|
|
f4cb01b3f9
|
||
|
|
42e600bbf2 | ||
|
|
03212265ce | ||
|
|
b5af21bb0e | ||
|
|
c6faeeb827 | ||
|
|
5548902f27 | ||
|
|
e59e8001c6 | ||
|
|
3bfea47a59 | ||
|
|
52674af74f | ||
|
|
296818f73c | ||
|
|
a016af9cd4 | ||
|
|
3aae8cd2a6 | ||
|
|
e96b5672e9 | ||
|
|
94c874a56f | ||
|
|
6540bceac1 | ||
|
|
da4a9cb0ee | ||
|
|
623d9462fe | ||
|
|
15a9b0961a | ||
|
|
48ef705185 | ||
|
|
98015b6c58 | ||
|
|
09d1537225 | ||
|
|
0a90a72962 | ||
|
|
22cfc37bbd | ||
|
|
0d91d5839f | ||
|
|
72f7143aa9 | ||
|
|
fe50b38616 | ||
|
|
4969cb557e | ||
|
|
bad75ede4d | ||
|
|
2dd23df7ae | ||
|
|
61b6e48df7 | ||
|
|
ab67dccf8e | ||
|
|
005dcbb803 | ||
|
|
d50b844476 | ||
|
|
3d80a270ca | ||
|
|
f521d7d8c6 | ||
|
|
6fdd8898a9 | ||
|
|
c7a47ce76f | ||
|
|
134c77ae41 | ||
|
|
d201b71d45 | ||
|
|
15c177850b | ||
|
|
4a4d03c329 | ||
|
|
91368eda18 | ||
|
|
6b6e88516d | ||
|
|
eb30becb95 | ||
|
|
71d415d2a8 | ||
|
|
727bdc61e6 | ||
|
|
747f0c951e | ||
|
|
26f15eb867 | ||
|
|
dc852d5f7e | ||
|
|
bb0243c78e | ||
|
|
78af7e201d | ||
|
|
3039625805 | ||
|
|
608788bbe4 | ||
|
|
5e9388e6ad | ||
|
|
343a10a1ac | ||
|
|
a4b6a41ce9 | ||
|
|
fc9960eb83 | ||
|
|
92921d8a72 | ||
|
|
96a7727f92 | ||
|
|
7a1c3a2531 | ||
|
|
d6de58bb41 | ||
|
|
42088e06d5 | ||
|
|
df376d760f | ||
|
|
a27c17c025 | ||
|
|
fa6173be84 | ||
|
|
377756ff94 | ||
|
|
90656055cc | ||
|
|
870a4d359b | ||
|
|
77a51cfd6f | ||
|
|
9a2705d055 | ||
|
|
7025056125 | ||
|
|
0e4ec80cd8 | ||
|
|
5baaa12bf7 | ||
|
|
6313c50b11 | ||
|
|
9ba4917899 | ||
|
|
78915fc3bb | ||
|
|
9fa4232664 | ||
|
|
5225abb9dd | ||
|
|
ac1604ed3f | ||
|
|
792a0dce19 | ||
|
|
9ff4b5261a | ||
|
|
65dd11d26b | ||
|
|
d703874f87 | ||
|
|
2a72f37a9d | ||
|
|
349ccd1ccf | ||
|
|
1c6524ad20 | ||
|
|
8177e6aee3 | ||
|
|
793c4e8ab6 | ||
|
|
6c052141a0 | ||
|
|
686306544a | ||
|
|
50c45e5229 | ||
|
|
d36d718be0 | ||
|
|
284e53feb7 | ||
|
|
87b5b90baf | ||
|
|
8c115f1b9e | ||
|
|
1674ce70b4 | ||
|
|
f92bb4c39c | ||
|
|
325eb1003a | ||
|
|
9126e33f77 | ||
|
|
0d3a6d18ad | ||
|
|
1d211c8faa | ||
|
|
7098ac08e5 | ||
|
|
3790b4e0b6 | ||
|
|
5054268474 | ||
|
|
95d46c27d5 | ||
|
|
d1e3a9525b | ||
|
|
ad0c99853f | ||
|
|
fcdd002fe0 | ||
|
|
afcbc4bbdf | ||
|
|
41acb73b5b | ||
|
|
a7a8a70496 | ||
|
|
99b5b5d0b8 | ||
|
|
31d3c61e32 | ||
|
|
2fcb50c2c4 | ||
|
|
008b5d2d52 | ||
|
|
eb28e87662 | ||
|
|
a44f14a275 | ||
|
|
1f9f441a16 | ||
|
|
e0214fcc4c | ||
|
|
fc5bb190a1 | ||
|
|
291451f725 | ||
|
|
090be37107 | ||
|
|
01d109e80e | ||
|
|
877e6ed503 | ||
|
|
7a26fd2fb1 | ||
|
|
fac7d681e4 | ||
|
|
fae0827246 | ||
|
|
4884fea21a | ||
|
|
cbaa858380 | ||
|
|
7d23f42185 | ||
|
|
9be8398519 | ||
|
|
a65d10c78e | ||
|
|
7d41b99707 | ||
|
|
0ff778526a | ||
|
|
e5f63b0d48 | ||
|
|
08b53d2e55 | ||
|
|
fcf6e5743d | ||
|
|
57c28679d5 | ||
|
|
25df4962ea | ||
|
|
283e2b4a51 | ||
|
|
922e5d3128 | ||
|
|
794b18e6ab | ||
|
|
6979a20414 | ||
|
|
1b1a1791f1 | ||
|
|
b23e0a9011 | ||
|
|
e72b3542d2 | ||
|
|
e313fec903 | ||
|
|
3c33c79a75 | ||
|
|
2c22024abd | ||
|
|
1fa76aeecd | ||
|
|
11397e9c24 | ||
|
|
440c10534d | ||
|
|
57feb81698 | ||
|
|
fd72f1969e | ||
|
|
6df4020c3f | ||
|
|
3eaa669ab9 | ||
|
|
b866e601e7 | ||
|
|
47743bea79 | ||
|
|
7b77a437f1 | ||
|
|
1b8da0a477 | ||
|
|
2a1a7512ae | ||
|
|
f8393d961a | ||
|
|
d18f68464f | ||
|
|
437977f848 | ||
|
|
a3fdc37f1d | ||
|
|
ae61f02eb8 | ||
|
|
3a9d83ec6c | ||
|
|
da5715e215 |
No files matched your search
@@ -104,6 +104,9 @@ clone, with status. Rules:
|
||||
| `docs/architecture.md` | Crate layout, command pattern, dependency rules |
|
||||
| `docs/oracle.md` | How we prove correctness: golden files, property tests, GnuCash diffing |
|
||||
| `docs/parity.md` | Feature catalog and the non-regression floor |
|
||||
| `docs/capability-parity.md` | Parity vs the GSheet+GDrive system + the cutover rule |
|
||||
| `docs/mcp-interface.md` | The MCP tool surface: the primary interface spec |
|
||||
| `docs/receipt-store.md` | Blob store + `ledger-store` SQLite persistence spec |
|
||||
| `docs/glossary.md` | Debit/credit and friends, for agents new to accounting |
|
||||
| `plan/STATUS.md` | Where the project stands right now |
|
||||
| `plan/roadmap.md` | Phase plan: semantics → oracle harness → egui app → workflow parity |
|
||||
|
||||
Generated
+8202
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["crates/ledger-core"]
|
||||
members = ["crates/ledger-core", "crates/ledger-store", "crates/ledger-config", "crates/ledger-mcp", "crates/ledger-import", "crates/ledger-cli", "crates/ledger-gui"]
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
|
||||
@@ -1,50 +1,90 @@
|
||||
# wanderbooks
|
||||
|
||||
Canonical double-entry bookkeeping, rebuilt from scratch — the storytold way:
|
||||
an engine verified against oracles, with automated parity tracking, on its
|
||||
way to becoming a self-hosted bookkeeping app that never charges rent.
|
||||
Canonical double-entry bookkeeping, rebuilt from scratch. The engine is
|
||||
verified against oracles, parity is tracked automatically, and the primary
|
||||
interface is AI-first: you talk to an agent, the agent drives the books
|
||||
through MCP. Self-hosted, no rent.
|
||||
|
||||
Private while it cooks.
|
||||
## What it is
|
||||
|
||||
- **`ledger-core`** — the semantics engine. Exact decimal money, five
|
||||
account types, balanced journal entries, immutable posted log with
|
||||
reversing entries, trial balance, P&L, balance sheet, indirect-method
|
||||
cash flow, fixed-asset register with straight-line depreciation.
|
||||
- **`ledger-store`** — durable storage. The source of truth is an
|
||||
append-only, hash-chained JSONL journal; SQLite is a disposable
|
||||
projection rebuilt from it; receipts live in a content-addressed blob
|
||||
store. Drafts are durable, corrupt projections self-heal.
|
||||
- **`ledger-mcp`** — the AI-first interface. 64 MCP tools over stdio or
|
||||
Streamable HTTP: draft → preview → post workflow, server-side receipt
|
||||
policy (>$25), anti-splitting detection, asset management, digest for
|
||||
human review, plus read-only resources for dashboards.
|
||||
- **`ledger-import`** — QuickBooks interop. Import bank/credit-card CSVs
|
||||
and chart-of-accounts CSVs; export IIF journal entries (for QuickBooks
|
||||
Desktop) and XLSX reports.
|
||||
- **`ledger-config`** — the company settings in a books dir's
|
||||
`wanderbooks.toml`.
|
||||
- **`ledger-gui`** — the read-only desktop view for owners and their
|
||||
accountant (egui). Answers first (cash, runway, profit, what needs
|
||||
attention), then every number drills down to its lines, its entry, the
|
||||
rendered receipt, and the journal history. Follows the journal live:
|
||||
a change the agent makes appears in milliseconds. `--demo` opens a
|
||||
realistic demo company.
|
||||
|
||||
## Verification
|
||||
|
||||
This is the storytold way applied to accounting: agents verify against
|
||||
oracles, never ship what they haven't demonstrated.
|
||||
|
||||
- 70 workspace tests: invariant tests encoding accounting law, golden-file
|
||||
report snapshots, property tests (proptest), end-to-end MCP stdio tests.
|
||||
- Mutation testing via cargo-mutants; fuzzing via cargo-fuzz (11.5M+
|
||||
executions, zero crashes).
|
||||
- GnuCash is the behavioral oracle (never a code source — clean room,
|
||||
MIT OR Apache-2.0).
|
||||
|
||||
## Status
|
||||
|
||||
Phase 1: the semantics engine. `ledger-core` implements the accounting laws
|
||||
in Rust — exact decimal money, five account types, balanced journal entries,
|
||||
immutable posted log with reversing entries, trial balance, P&L, and balance
|
||||
sheet — with 14 invariant tests and golden-file report snapshots.
|
||||
Parity is 12 of 12 against the spreadsheet workflow it replaces
|
||||
(`docs/capability-parity.md`). The parallel-run plan
|
||||
(`docs/parallel-run.md`) is written; the Google Sheet stays the system of
|
||||
record until a clean month and explicit cutover approval.
|
||||
|
||||
See [`plan/STATUS.md`](plan/STATUS.md) for where things stand and
|
||||
[`plan/roadmap.md`](plan/roadmap.md) for where they're going.
|
||||
[`plan/roadmap.md`](plan/roadmap.md) for where it's going.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
cargo test
|
||||
cargo test # whole suite
|
||||
cargo run -p ledger-mcp -- --data-dir /tmp/books # MCP server over stdio
|
||||
cargo run -p ledger-gui -- --demo # the desktop view, on a demo company
|
||||
```
|
||||
New books, one command (installs Rust and wanderbooks if needed, makes the
|
||||
books dir with the MCP server registered for it, and starts the agent):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.swaits.com/swaits/wanderbooks/raw/branch/main/install.sh | sh -s -- ~/Books/Acme
|
||||
```
|
||||
|
||||
That's the whole test suite: validation rejections, immutability, the
|
||||
accounting equation on a fixture company, a 300-entry randomized invariant
|
||||
test, and the golden oracle test.
|
||||
Already installed: `wanderbooks new ~/Books/Acme`. In a books dir, `just`
|
||||
lists the shortcuts (`just gui`, `just agent`, `just backup`).
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
crates/ledger-core/ # the engine: money, accounts, journal, ledger, reports
|
||||
docs/ # requirements, architecture, oracle design, parity, glossary
|
||||
plan/ # STATUS.md, roadmap.md
|
||||
AGENTS.md # operating manual for agents working here
|
||||
crates/ledger-core/ # engine: money, accounts, journal, ledger, reports
|
||||
crates/ledger-store/ # journal + SQLite projection + blob store
|
||||
crates/ledger-mcp/ # MCP server: 64 tools, resources, receipt policy
|
||||
crates/ledger-import/ # QBO CSV import, IIF/XLSX export
|
||||
crates/ledger-config/ # wanderbooks.toml company settings
|
||||
crates/ledger-cli/ # `wanderbooks new`: one-command books dir + agent
|
||||
crates/ledger-gui/ # read-only live desktop view (egui)
|
||||
docs/ # requirements, architecture, oracle, parity, glossary
|
||||
plan/ # STATUS.md, roadmap.md
|
||||
AGENTS.md # operating manual for agents working here
|
||||
```
|
||||
|
||||
## The idea, briefly
|
||||
|
||||
storytold/ArtCraft showed that agents can clone full applications with the
|
||||
right machinery: screenshot-in-the-loop verification, render oracles, and
|
||||
automated parity floors. `wanderbooks` adapts that pipeline to bookkeeping,
|
||||
where the oracle is semantic instead of visual — GnuCash (open source, open
|
||||
formats) is both the spec and the reference implementation we diff against,
|
||||
and two engines fed the same books must produce byte-identical reports.
|
||||
Details in [`docs/oracle.md`](docs/oracle.md).
|
||||
|
||||
## License
|
||||
|
||||
MIT OR Apache-2.0. See `LICENSE-MIT` and `LICENSE-APACHE`.
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "ledger-cli"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "wanderbooks CLI: one command from nothing to a working books directory."
|
||||
|
||||
[[bin]]
|
||||
name = "wanderbooks"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = "1"
|
||||
toml = "1"
|
||||
tempfile = "3"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,464 @@
|
||||
//! `wanderbooks` CLI: one command from nothing to talking with your
|
||||
//! bookkeeper.
|
||||
//!
|
||||
//! ```text
|
||||
//! wanderbooks new [DIR] [--force] [--no-start] (alias: init)
|
||||
//! wanderbooks gui [DIR]
|
||||
//! ```
|
||||
//!
|
||||
//! `new` makes a complete books directory and starts the agent in it:
|
||||
//!
|
||||
//! 1. Finds `ledger-mcp` and `ledger-gui` next to this binary or on the
|
||||
//! PATH, and installs them with cargo if they're missing.
|
||||
//! 2. Writes `AGENTS.md` (the bookkeeper's operating contract, with a
|
||||
//! `CLAUDE.md` that imports it), `wanderbooks.toml`, a `justfile`,
|
||||
//! `.gitignore`, and `books-data/`.
|
||||
//! 3. Registers the MCP server for this directory only: `.mcp.json`, plus
|
||||
//! `.claude/settings.json` approving it, so Claude Code loads the tools
|
||||
//! without `claude mcp add` or a prompt.
|
||||
//! 4. `git init`s the directory (its history is the archive) and commits.
|
||||
//! 5. Starts `claude` in the directory with the onboarding prompt, unless
|
||||
//! `--no-start` or there's no terminal.
|
||||
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// Where `wanderbooks update` installs from.
|
||||
const REPO_URL: &str = "https://git.swaits.com/swaits/wanderbooks";
|
||||
|
||||
/// The checkout this binary was built from, if it still exists.
|
||||
fn repo_root() -> Option<PathBuf> {
|
||||
let raw = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let root = raw.canonicalize().ok()?;
|
||||
root.join("crates/ledger-mcp/Cargo.toml")
|
||||
.exists()
|
||||
.then_some(root)
|
||||
}
|
||||
|
||||
const JUSTFILE: &str = r#"# wanderbooks books dir. Run `just` to list recipes.
|
||||
# Day to day you talk to the agent (`just agent`); the recipes are the
|
||||
# handful of things you might do yourself.
|
||||
|
||||
set shell := ["bash", "-cu"]
|
||||
|
||||
# List recipes
|
||||
default:
|
||||
@just --list
|
||||
|
||||
# Start a bookkeeping session with the agent
|
||||
agent:
|
||||
claude
|
||||
|
||||
# Open the live, read-only view of these books
|
||||
gui:
|
||||
"@@LEDGER_GUI@@" --data-dir books-data --books-toml wanderbooks.toml
|
||||
|
||||
# Commit the journal: the git history of this dir is the archive
|
||||
backup:
|
||||
git add -A && (git commit -qm "books backup $(date -u +%Y-%m-%dT%H:%M:%SZ)" && echo committed || echo "nothing to commit")
|
||||
|
||||
# Update wanderbooks (server, GUI, CLI) to the latest version
|
||||
update:
|
||||
cargo install --locked --git @@REPO_URL@@ ledger-cli ledger-mcp ledger-gui
|
||||
"#;
|
||||
|
||||
const AGENTS_MD: &str = r#"# AGENTS.md — wanderbooks books dir
|
||||
|
||||
This directory is a **wanderbooks** books dir: AI-native double-entry
|
||||
bookkeeping. You (the AI agent) are the bookkeeper. The human talks to you;
|
||||
you drive the books through the `wanderbooks` MCP server (registered for
|
||||
this directory in `.mcp.json`). The humans read the books in the GUI
|
||||
(`just gui`): read-only and live, so whatever you post appears there within
|
||||
a second. Everything else they see, you show them.
|
||||
|
||||
## The MCP contract
|
||||
|
||||
- **Amounts are decimal strings** (`"123.45"`). Never floats, never
|
||||
locale-formatted. Money is exact or it is wrong.
|
||||
- **Draft → add lines → preview → confirmed post.** `post_entry` without
|
||||
confirmation returns a preview and posts nothing: show the preview to the
|
||||
human before confirming. Same pattern for `reverse_entry`.
|
||||
- **Every entry balances.** Debits equal credits exactly. No epsilon.
|
||||
- **Posted is forever.** Corrections are reversing entries linked to the
|
||||
original, never edits, never deletes.
|
||||
- **Receipts:** attach with `attach_receipt` and link to the entry. Anything
|
||||
over the receipt threshold ($25 unless `wanderbooks.toml` says otherwise)
|
||||
requires a receipt — the server enforces this, don't argue with it. One
|
||||
file per receipt: PDF preferred, an image is fine.
|
||||
- **Show the numbers.** After every mutation, show the affected balances.
|
||||
When the human questions a figure, trace it to journal entries.
|
||||
|
||||
## First run: onboarding
|
||||
|
||||
If `books-data/journal.jsonl` is missing or empty, onboard before recording
|
||||
anything. Keep it conversational; ask a few things at a time.
|
||||
|
||||
1. Ask: company legal name, entity type (sole proprietorship or
|
||||
single-member LLC, partnership or multi-member LLC, S corp, C corp),
|
||||
owners and their shares, fiscal year start, and whether they want
|
||||
accrual or cash basis.
|
||||
2. Propose a chart of accounts with `books.chart_template` for that entity
|
||||
type and those owners. Walk the human through it, adjust, then import it
|
||||
with `books.import_chart_csv`. Put the template's settings (members,
|
||||
tax form, close target) in `wanderbooks.toml`.
|
||||
3. Fill in `wanderbooks.toml` `[company]`: name, entity type, tax
|
||||
classification, formation state and date, EIN, address. Don't store
|
||||
SSNs or full account numbers anywhere in this directory.
|
||||
4. Record opening balances as one balanced opening entry (or the owners'
|
||||
first contributions). Verify the trial balance before moving on.
|
||||
5. File the formation documents they have (articles, EIN letter,
|
||||
operating agreement) with `books.file_document`.
|
||||
6. If they're migrating from another system, import from wherever the books
|
||||
live now — QBO/CSV, bank or card CSVs, email receipts, a spreadsheet,
|
||||
whatever they point you at. Diff trial balance, P&L, balance sheet, and
|
||||
cash flow against the old system **to the cent** before calling the
|
||||
migration done.
|
||||
7. Tell them about `just gui`, then commit with `just backup`.
|
||||
|
||||
## Ongoing
|
||||
|
||||
- Every transaction: draft, preview, confirm with the human, post, attach
|
||||
the receipt. Tag payments to contractors with their vendor (1099s).
|
||||
- `books.digest` is the review surface — "what did you do with my books
|
||||
lately." Offer it; don't make the human ask.
|
||||
- Reports are pure functions of the journal. If a number looks wrong, the
|
||||
journal is the evidence — read it, don't guess.
|
||||
- Back up: `just backup` after every session that posted entries. The git
|
||||
history of this directory is the archive; `books-data/journal.jsonl` is
|
||||
the system of record and the SQLite database is disposable.
|
||||
"#;
|
||||
|
||||
const CLAUDE_MD: &str = "@AGENTS.md\n";
|
||||
|
||||
const WANDERBOOKS_TOML: &str = r#"# wanderbooks books dir config. [company] is filled in during onboarding —
|
||||
# the agent asks, the human answers, the agent writes it here.
|
||||
|
||||
[company]
|
||||
name = ""
|
||||
base_currency = "USD"
|
||||
fiscal_year_start = "01-01"
|
||||
|
||||
[books]
|
||||
data_dir = "books-data"
|
||||
accounting_basis = "accrual"
|
||||
receipt_threshold = "25.00"
|
||||
"#;
|
||||
|
||||
const GITIGNORE: &str = r#"# The SQLite projection is disposable — it rebuilds from journal.jsonl.
|
||||
books-data/wanderbooks.db
|
||||
books-data/wanderbooks.db-*
|
||||
# Generated exports can always be regenerated from the journal.
|
||||
exports/
|
||||
"#;
|
||||
|
||||
const START_PROMPT: &str = "Hi! These are brand-new books. Please set them up with me.";
|
||||
|
||||
fn print_usage() {
|
||||
eprintln!("usage: wanderbooks new [DIR] [--force] [--no-start]");
|
||||
eprintln!(" wanderbooks gui [DIR]");
|
||||
eprintln!();
|
||||
eprintln!(" new make a books directory and start the agent in it (alias: init)");
|
||||
eprintln!(" gui open the live, read-only view of a books directory");
|
||||
eprintln!(" DIR the books directory (default: current directory)");
|
||||
eprintln!(" --force rewrite the scaffold in an existing books directory");
|
||||
eprintln!(" --no-start don't start the agent afterwards");
|
||||
}
|
||||
|
||||
/// A binary next to this one (cargo installs them side by side), else on
|
||||
/// the PATH.
|
||||
fn find_binary(name: &str) -> Option<PathBuf> {
|
||||
if let Some(dir) = env::current_exe()
|
||||
.ok()
|
||||
.and_then(|e| e.parent().map(Path::to_path_buf))
|
||||
{
|
||||
let p = dir.join(name);
|
||||
if p.is_file() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
env::var_os("PATH").and_then(|paths| {
|
||||
env::split_paths(&paths)
|
||||
.map(|d| d.join(name))
|
||||
.find(|p| p.is_file())
|
||||
})
|
||||
}
|
||||
|
||||
/// Install the server and GUI with cargo: from this checkout when there is
|
||||
/// one, else from the repo.
|
||||
fn install_binaries() -> Result<(), String> {
|
||||
let mut cmd = Command::new("cargo");
|
||||
cmd.args(["install", "--locked"]);
|
||||
match repo_root() {
|
||||
Some(root) => {
|
||||
eprintln!(
|
||||
"Installing ledger-mcp and ledger-gui from {} (a few minutes, once)…",
|
||||
root.display()
|
||||
);
|
||||
// `--path` takes one crate at a time.
|
||||
for c in ["ledger-mcp", "ledger-gui"] {
|
||||
let ok = Command::new("cargo")
|
||||
.args(["install", "--locked", "--path"])
|
||||
.arg(root.join("crates").join(c))
|
||||
.status()
|
||||
.map_err(|e| {
|
||||
format!("can't run cargo: {e} (install Rust from https://rustup.rs)")
|
||||
})?
|
||||
.success();
|
||||
if !ok {
|
||||
return Err(format!("cargo install of {c} failed"));
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
None => {
|
||||
eprintln!(
|
||||
"Installing ledger-mcp and ledger-gui from {REPO_URL} (a few minutes, once)…"
|
||||
);
|
||||
cmd.args(["--git", REPO_URL, "ledger-mcp", "ledger-gui"]);
|
||||
}
|
||||
}
|
||||
let ok = cmd
|
||||
.status()
|
||||
.map_err(|e| format!("can't run cargo: {e} (install Rust from https://rustup.rs)"))?
|
||||
.success();
|
||||
if ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("cargo install failed".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn binaries() -> Result<(PathBuf, PathBuf), String> {
|
||||
if let (Some(m), Some(g)) = (find_binary("ledger-mcp"), find_binary("ledger-gui")) {
|
||||
return Ok((m, g));
|
||||
}
|
||||
install_binaries()?;
|
||||
match (find_binary("ledger-mcp"), find_binary("ledger-gui")) {
|
||||
(Some(m), Some(g)) => Ok((m, g)),
|
||||
_ => Err(
|
||||
"installed, but ledger-mcp/ledger-gui aren't on the PATH; add ~/.cargo/bin to it"
|
||||
.into(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn json_string(s: &str) -> String {
|
||||
let mut out = String::from("\"");
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
|
||||
/// `.mcp.json`: the wanderbooks server for this directory only.
|
||||
fn mcp_json(ledger_mcp: &Path, books: &Path) -> String {
|
||||
format!(
|
||||
"{{\n \"mcpServers\": {{\n \"wanderbooks\": {{\n \"command\": {},\n \"args\": [\"--data-dir\", {}]\n }}\n }}\n}}\n",
|
||||
json_string(&ledger_mcp.to_string_lossy()),
|
||||
json_string(&books.join("books-data").to_string_lossy())
|
||||
)
|
||||
}
|
||||
|
||||
/// `.claude/settings.json`: approve this directory's server, and its tools,
|
||||
/// so the first session starts working instead of asking. The server still
|
||||
/// previews every posting for the human to confirm.
|
||||
const CLAUDE_SETTINGS: &str = r#"{
|
||||
"enabledMcpjsonServers": ["wanderbooks"],
|
||||
"permissions": {
|
||||
"allow": ["mcp__wanderbooks"]
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
/// `git init` the dir unless already inside a repo; commit the scaffold.
|
||||
/// Failures are warnings, not errors — the scaffold is what matters.
|
||||
fn git_bootstrap(dir: &Path) {
|
||||
let git = |args: &[&str]| {
|
||||
Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.is_ok_and(|o| o.status.success())
|
||||
};
|
||||
if !git(&["rev-parse", "--is-inside-work-tree"]) && !git(&["init", "-q"]) {
|
||||
eprintln!("warning: git init failed; skipping the first commit");
|
||||
return;
|
||||
}
|
||||
if !git(&["add", "-A"]) {
|
||||
eprintln!("warning: git add failed; skipping the first commit");
|
||||
return;
|
||||
}
|
||||
// Use the person's identity when they have one; fall back so a fresh
|
||||
// machine still gets a first commit.
|
||||
let has_identity = git(&["config", "user.email"]);
|
||||
let mut args = Vec::new();
|
||||
if !has_identity {
|
||||
args.extend([
|
||||
"-c",
|
||||
"user.name=wanderbooks",
|
||||
"-c",
|
||||
"user.email=wanderbooks@localhost",
|
||||
]);
|
||||
}
|
||||
args.extend(["commit", "-qm", "init: wanderbooks books dir"]);
|
||||
git(&args);
|
||||
}
|
||||
|
||||
/// Write the books directory. Returns it, canonical.
|
||||
fn scaffold(
|
||||
dir: &Path,
|
||||
force: bool,
|
||||
ledger_mcp: &Path,
|
||||
ledger_gui: &Path,
|
||||
) -> Result<PathBuf, String> {
|
||||
fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
|
||||
let dir = dir
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("cannot resolve {}: {e}", dir.display()))?;
|
||||
if dir.join("wanderbooks.toml").exists() && !force {
|
||||
return Err(format!(
|
||||
"{} is already a books dir (wanderbooks.toml exists); use --force to rewrite the scaffold",
|
||||
dir.display()
|
||||
));
|
||||
}
|
||||
let justfile = JUSTFILE
|
||||
.replace("@@LEDGER_GUI@@", &ledger_gui.to_string_lossy())
|
||||
.replace("@@REPO_URL@@", REPO_URL);
|
||||
let mut files: Vec<(&str, String)> = vec![
|
||||
("justfile", justfile),
|
||||
("AGENTS.md", AGENTS_MD.to_string()),
|
||||
("CLAUDE.md", CLAUDE_MD.to_string()),
|
||||
(".gitignore", GITIGNORE.to_string()),
|
||||
(".mcp.json", mcp_json(ledger_mcp, &dir)),
|
||||
];
|
||||
// The company profile is the person's data: never overwrite it.
|
||||
if !dir.join("wanderbooks.toml").exists() {
|
||||
files.push(("wanderbooks.toml", WANDERBOOKS_TOML.to_string()));
|
||||
}
|
||||
for (name, content) in files {
|
||||
fs::write(dir.join(name), content).map_err(|e| format!("cannot write {name}: {e}"))?;
|
||||
}
|
||||
fs::create_dir_all(dir.join(".claude")).map_err(|e| format!("cannot create .claude: {e}"))?;
|
||||
fs::write(dir.join(".claude/settings.json"), CLAUDE_SETTINGS)
|
||||
.map_err(|e| format!("cannot write .claude/settings.json: {e}"))?;
|
||||
fs::create_dir_all(dir.join("books-data"))
|
||||
.map_err(|e| format!("cannot create books-data: {e}"))?;
|
||||
fs::write(dir.join("books-data/.gitkeep"), "")
|
||||
.map_err(|e| format!("cannot write .gitkeep: {e}"))?;
|
||||
git_bootstrap(&dir);
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
fn is_terminal() -> bool {
|
||||
use std::io::IsTerminal;
|
||||
std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
|
||||
}
|
||||
|
||||
/// Replace this process with `claude` in the books dir, onboarding.
|
||||
fn start_agent(dir: &Path) -> Result<(), String> {
|
||||
let Some(claude) = find_binary("claude") else {
|
||||
println!(
|
||||
"Claude Code isn't installed. Install it, then run `claude` in {}.",
|
||||
dir.display()
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
println!("Starting your bookkeeper…");
|
||||
let mut cmd = Command::new(claude);
|
||||
cmd.arg(START_PROMPT).current_dir(dir);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
let err = cmd.exec();
|
||||
Err(format!("can't start claude: {err}"))
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
cmd.status()
|
||||
.map_err(|e| format!("can't start claude: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn new(dir: &Path, force: bool, start: bool) -> Result<(), String> {
|
||||
let (ledger_mcp, ledger_gui) = binaries()?;
|
||||
let dir = scaffold(dir, force, &ledger_mcp, &ledger_gui)?;
|
||||
println!("Books ready in {}", dir.display());
|
||||
println!(" just gui the live, read-only view of the books");
|
||||
println!(" just agent a session with your bookkeeper");
|
||||
println!(" just backup commit the journal");
|
||||
if start && is_terminal() {
|
||||
start_agent(&dir)?;
|
||||
} else {
|
||||
println!("Next: cd {} && claude", dir.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn gui(dir: &Path) -> Result<(), String> {
|
||||
let gui = find_binary("ledger-gui")
|
||||
.ok_or("ledger-gui isn't installed; run `wanderbooks new` or `just update`")?;
|
||||
let status = Command::new(gui)
|
||||
.arg("--data-dir")
|
||||
.arg(dir.join("books-data"))
|
||||
.arg("--books-toml")
|
||||
.arg(dir.join("wanderbooks.toml"))
|
||||
.status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("ledger-gui exited with an error".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut dir: Option<PathBuf> = None;
|
||||
let mut force = false;
|
||||
let mut start = true;
|
||||
let mut cmd: Option<String> = None;
|
||||
for arg in env::args().skip(1) {
|
||||
match arg.as_str() {
|
||||
"--help" | "-h" => {
|
||||
print_usage();
|
||||
return;
|
||||
}
|
||||
"--force" => force = true,
|
||||
"--no-start" => start = false,
|
||||
s if s.starts_with('-') => {
|
||||
eprintln!("unknown flag: {s}");
|
||||
print_usage();
|
||||
std::process::exit(2);
|
||||
}
|
||||
s if cmd.is_none() => cmd = Some(s.to_string()),
|
||||
s if dir.is_none() => dir = Some(PathBuf::from(s)),
|
||||
s => {
|
||||
eprintln!("unexpected argument: {s}");
|
||||
print_usage();
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
let dir = dir.unwrap_or_else(|| PathBuf::from("."));
|
||||
let result = match cmd.as_deref() {
|
||||
Some("new") | Some("init") => new(&dir, force, start),
|
||||
Some("gui") => gui(&dir),
|
||||
_ => {
|
||||
print_usage();
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
if let Err(e) = result {
|
||||
eprintln!("error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! Integration tests for `wanderbooks new`: one command lands a complete
|
||||
//! books dir with the MCP server registered for it, the TOML and JSON
|
||||
//! parse, and re-running is refused without `--force` (which never
|
||||
//! touches the company profile).
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
fn bin() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_BIN_EXE_wanderbooks"))
|
||||
}
|
||||
|
||||
/// Stand-ins for the installed binaries, so tests never run cargo install.
|
||||
fn fake_bins(tmp: &Path) -> PathBuf {
|
||||
let dir = tmp.join("bin");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
for b in ["ledger-mcp", "ledger-gui"] {
|
||||
fs::write(dir.join(b), "#!/bin/sh\n").unwrap();
|
||||
}
|
||||
dir
|
||||
}
|
||||
|
||||
fn run(tmp: &Path, args: &[&str]) -> (bool, String) {
|
||||
let path = format!(
|
||||
"{}:{}",
|
||||
fake_bins(tmp).display(),
|
||||
std::env::var("PATH").unwrap_or_default()
|
||||
);
|
||||
let out = Command::new(bin())
|
||||
.args(args)
|
||||
.arg("--no-start")
|
||||
.env("PATH", path)
|
||||
.output()
|
||||
.expect("run wanderbooks");
|
||||
let mut s = String::from_utf8_lossy(&out.stdout).into_owned();
|
||||
s.push_str(&String::from_utf8_lossy(&out.stderr));
|
||||
(out.status.success(), s)
|
||||
}
|
||||
|
||||
fn new(tmp: &Path, books: &Path, extra: &[&str]) -> (bool, String) {
|
||||
let mut args = vec!["new", books.to_str().unwrap()];
|
||||
args.extend(extra);
|
||||
run(tmp, &args)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaffold_lands_complete() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let books = tmp.path().join("acme-books");
|
||||
let (ok, out) = new(tmp.path(), &books, &[]);
|
||||
assert!(ok, "new should succeed: {out}");
|
||||
for f in [
|
||||
"justfile",
|
||||
"AGENTS.md",
|
||||
"CLAUDE.md",
|
||||
"wanderbooks.toml",
|
||||
".gitignore",
|
||||
".mcp.json",
|
||||
".claude/settings.json",
|
||||
] {
|
||||
let p = books.join(f);
|
||||
assert!(p.exists(), "{f} exists");
|
||||
assert!(!fs::read_to_string(&p).unwrap().is_empty(), "{f} non-empty");
|
||||
}
|
||||
assert!(books.join("books-data").is_dir(), "books-data dir exists");
|
||||
assert!(books.join(".git").is_dir(), "fresh dir gets git init");
|
||||
let log = Command::new("git")
|
||||
.args(["log", "--oneline"])
|
||||
.current_dir(&books)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
String::from_utf8_lossy(&log.stdout).contains("init"),
|
||||
"scaffold committed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_server_is_registered_for_this_dir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let books = tmp.path().join("b");
|
||||
assert!(new(tmp.path(), &books, &[]).0);
|
||||
let books = books.canonicalize().unwrap();
|
||||
let mcp: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(books.join(".mcp.json")).unwrap()).unwrap();
|
||||
let server = &mcp["mcpServers"]["wanderbooks"];
|
||||
assert!(server["command"].as_str().unwrap().ends_with("ledger-mcp"));
|
||||
assert_eq!(
|
||||
server["args"],
|
||||
serde_json::json!(["--data-dir", books.join("books-data").to_string_lossy()])
|
||||
);
|
||||
let settings: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(books.join(".claude/settings.json")).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
settings["enabledMcpjsonServers"],
|
||||
serde_json::json!(["wanderbooks"])
|
||||
);
|
||||
// The justfile opens this dir's books in the GUI.
|
||||
let just = fs::read_to_string(books.join("justfile")).unwrap();
|
||||
assert!(just.contains("ledger-gui\" --data-dir books-data"));
|
||||
for recipe in ["agent:", "gui:", "backup:", "update:"] {
|
||||
assert!(just.contains(recipe), "justfile has {recipe}");
|
||||
}
|
||||
assert_eq!(
|
||||
fs::read_to_string(books.join("CLAUDE.md")).unwrap().trim(),
|
||||
"@AGENTS.md"
|
||||
);
|
||||
let toml: toml::Value =
|
||||
toml::from_str(&fs::read_to_string(books.join("wanderbooks.toml")).unwrap()).unwrap();
|
||||
assert_eq!(toml["books"]["data_dir"].as_str(), Some("books-data"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agents_md_carries_the_contract() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let books = tmp.path().join("b");
|
||||
assert!(new(tmp.path(), &books, &[]).0);
|
||||
let md = fs::read_to_string(books.join("AGENTS.md")).unwrap();
|
||||
for phrase in [
|
||||
"preview",
|
||||
"Posted is forever",
|
||||
"reversing entries",
|
||||
"receipt",
|
||||
"journal.jsonl",
|
||||
"onboard",
|
||||
"chart_template",
|
||||
"to the cent",
|
||||
"just gui",
|
||||
] {
|
||||
assert!(md.contains(phrase), "AGENTS.md mentions {phrase:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rerun_refused_without_force_and_never_clobbers_the_profile() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let books = tmp.path().join("b");
|
||||
assert!(new(tmp.path(), &books, &[]).0);
|
||||
let (ok, out) = new(tmp.path(), &books, &[]);
|
||||
assert!(!ok, "second new without --force fails");
|
||||
assert!(out.contains("--force"), "error points at --force: {out}");
|
||||
fs::write(
|
||||
books.join("wanderbooks.toml"),
|
||||
"[company]\nname = \"Acme LLC\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let (ok, _) = new(tmp.path(), &books, &["--force"]);
|
||||
assert!(ok, "second new with --force succeeds");
|
||||
assert!(
|
||||
fs::read_to_string(books.join("wanderbooks.toml"))
|
||||
.unwrap()
|
||||
.contains("Acme LLC"),
|
||||
"--force rewrites the scaffold, never the company profile"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_is_an_alias_and_respects_an_existing_repo() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let books = tmp.path().join("b");
|
||||
fs::create_dir_all(&books).unwrap();
|
||||
assert!(
|
||||
Command::new("git")
|
||||
.args(["init", "-q"])
|
||||
.current_dir(&books)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
let (ok, out) = run(tmp.path(), &["init", books.to_str().unwrap()]);
|
||||
assert!(ok, "{out}");
|
||||
assert!(books.join("wanderbooks.toml").exists());
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "ledger-config"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Company settings for wanderbooks: the books dir's wanderbooks.toml."
|
||||
|
||||
[dependencies]
|
||||
ledger-core = { path = "../ledger-core" }
|
||||
chrono = "0.4"
|
||||
rust_decimal = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
toml = { version = "1", features = ["preserve_order"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,427 @@
|
||||
//! Company settings from the books dir's `wanderbooks.toml`.
|
||||
//!
|
||||
//! `wanderbooks init` writes that file next to `books-data/`; onboarding
|
||||
//! fills in `[company]`. The server reads the company name (report headers),
|
||||
//! the fiscal year start (fiscal-year-to-date reports), the members of a
|
||||
//! multi-member LLC (`[[company.members]]`: name, share, capital account —
|
||||
//! for the statement of members' equity), `[books] accounting_basis`
|
||||
//! (report footers), and `[books] receipt_threshold` (the receipt policy,
|
||||
//! unless `config.json` sets one). The fiscal year *end* is never stored: it is the day before
|
||||
//! the next start, so the two can't disagree.
|
||||
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::Deserialize;
|
||||
use std::path::Path;
|
||||
|
||||
/// The company the books belong to.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Company {
|
||||
/// Legal name, for report headers.
|
||||
pub name: String,
|
||||
/// Month and day the fiscal year starts. Default January 1.
|
||||
pub fiscal_year_start: (u32, u32),
|
||||
/// Members of a multi-member LLC; empty for other entities.
|
||||
pub members: Vec<Member>,
|
||||
/// "Accrual" or "Cash", for report footers. Default "Accrual".
|
||||
pub basis: String,
|
||||
/// The income tax return the business files, if known: from
|
||||
/// `tax_form`, else from `tax_classification`.
|
||||
pub tax_form: Option<ledger_core::TaxForm>,
|
||||
/// `[books] close_into`: the equity account a period close puts net
|
||||
/// income in (a sole proprietor's capital, say). Unset: Retained
|
||||
/// Earnings, or members' capital accounts when there are members.
|
||||
pub close_into: Option<String>,
|
||||
/// `[books] close_sweep`: equity accounts (draws, dividends,
|
||||
/// distributions) a close zeroes into `close_into`.
|
||||
pub close_sweep: Vec<String>,
|
||||
/// Every simple `[company]` setting (name, EIN, formation date, ...),
|
||||
/// in file order, as written: the full profile for display.
|
||||
pub profile: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// One member of a multi-member LLC.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Member {
|
||||
/// Name, as it appears on reports.
|
||||
pub name: String,
|
||||
/// Share of income, e.g. 0.50.
|
||||
pub share: Decimal,
|
||||
/// Code of their capital account in the chart.
|
||||
pub capital_account: String,
|
||||
/// Code of their distributions account, if distributions are recorded
|
||||
/// separately from capital (zeroed into capital at close).
|
||||
pub distribution_account: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for Company {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: "Unnamed company".to_string(),
|
||||
fiscal_year_start: (1, 1),
|
||||
members: Vec::new(),
|
||||
basis: "Accrual".to_string(),
|
||||
tax_form: None,
|
||||
close_into: None,
|
||||
close_sweep: Vec::new(),
|
||||
profile: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Company {
|
||||
/// The fiscal year containing `d`: `(first day, last day)`, inclusive.
|
||||
pub fn fiscal_year_containing(&self, d: NaiveDate) -> (NaiveDate, NaiveDate) {
|
||||
let (m, day) = self.fiscal_year_start;
|
||||
let on = |year| NaiveDate::from_ymd_opt(year, m, day).expect("validated month-day");
|
||||
let start = if on(d.year()) <= d {
|
||||
on(d.year())
|
||||
} else {
|
||||
on(d.year() - 1)
|
||||
};
|
||||
let end = on(start.year() + 1)
|
||||
.pred_opt()
|
||||
.expect("not the minimum date");
|
||||
(start, end)
|
||||
}
|
||||
}
|
||||
|
||||
/// What the server takes from `wanderbooks.toml`.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct BooksSettings {
|
||||
/// `[company]`.
|
||||
pub company: Company,
|
||||
/// `[books] receipt_threshold`, validated as a decimal string.
|
||||
pub receipt_threshold: Option<String>,
|
||||
/// `[books] cash_accounts`: account codes that hold cash (bank,
|
||||
/// savings, cash on hand), for cash reports and dashboards. Empty:
|
||||
/// callers decide (e.g. by account name).
|
||||
pub cash_accounts: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BooksToml {
|
||||
#[serde(default)]
|
||||
company: CompanyToml,
|
||||
#[serde(default)]
|
||||
books: BooksSection,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
struct CompanyToml {
|
||||
name: Option<String>,
|
||||
fiscal_year_start: Option<String>,
|
||||
tax_form: Option<String>,
|
||||
tax_classification: Option<String>,
|
||||
#[serde(default)]
|
||||
members: Vec<MemberToml>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MemberToml {
|
||||
name: String,
|
||||
share: String,
|
||||
capital_account: Option<String>,
|
||||
distribution_account: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
struct BooksSection {
|
||||
receipt_threshold: Option<String>,
|
||||
accounting_basis: Option<String>,
|
||||
close_into: Option<String>,
|
||||
#[serde(default)]
|
||||
close_sweep: Vec<String>,
|
||||
#[serde(default)]
|
||||
cash_accounts: Vec<String>,
|
||||
}
|
||||
|
||||
/// The return a federal tax classification files: "partnership" → 1065,
|
||||
/// "s corporation" → 1120-S, "c corporation" → 1120, "sole proprietorship"
|
||||
/// or "disregarded entity" → Schedule C. Unrecognized: unknown.
|
||||
fn form_for_classification(c: &str) -> Option<ledger_core::TaxForm> {
|
||||
let c = c.to_lowercase();
|
||||
if c.contains("partnership") {
|
||||
Some(ledger_core::TaxForm::Form1065)
|
||||
} else if c.contains("s corp") || c.contains("s-corp") || c.contains("s_corp") {
|
||||
Some(ledger_core::TaxForm::Form1120S)
|
||||
} else if c.contains("c corp") || c.contains("c-corp") || c.contains("c_corp") {
|
||||
Some(ledger_core::TaxForm::Form1120)
|
||||
} else if c.contains("sole") || c.contains("disregarded") {
|
||||
Some(ledger_core::TaxForm::ScheduleC)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a share: "50%" or "0.5".
|
||||
fn parse_share(name: &str, s: &str) -> Result<Decimal, String> {
|
||||
let s = s.trim();
|
||||
let bad = || format!("member {name}: share {s:?} must be like \"50%\" or \"0.5\"");
|
||||
let share = match s.strip_suffix('%') {
|
||||
Some(pct) => pct.trim().parse::<Decimal>().map_err(|_| bad())? / Decimal::from(100),
|
||||
None => s.parse::<Decimal>().map_err(|_| bad())?,
|
||||
};
|
||||
if share <= Decimal::ZERO || share > Decimal::ONE {
|
||||
return Err(bad());
|
||||
}
|
||||
Ok(share)
|
||||
}
|
||||
|
||||
/// Parse `"MM-DD"`. February 29 is rejected: a fiscal year must start on a
|
||||
/// day every year has.
|
||||
fn parse_month_day(s: &str) -> Result<(u32, u32), String> {
|
||||
let bad = || format!("fiscal_year_start {s:?}: expected \"MM-DD\", e.g. \"01-01\"");
|
||||
let (m, d) = s.split_once('-').ok_or_else(bad)?;
|
||||
let (m, d): (u32, u32) = (m.parse().map_err(|_| bad())?, d.parse().map_err(|_| bad())?);
|
||||
// 2001 is not a leap year: a valid date there exists every year.
|
||||
NaiveDate::from_ymd_opt(2001, m, d)
|
||||
.ok_or_else(|| format!("fiscal_year_start {s:?} is not a day every year has"))?;
|
||||
Ok((m, d))
|
||||
}
|
||||
|
||||
/// Parse `wanderbooks.toml` text.
|
||||
pub fn parse(text: &str) -> Result<BooksSettings, String> {
|
||||
let raw: BooksToml = toml::from_str(text).map_err(|e| e.to_string())?;
|
||||
let mut company = Company::default();
|
||||
if let Some(name) = raw.company.name.filter(|n| !n.trim().is_empty()) {
|
||||
company.name = name.trim().to_string();
|
||||
}
|
||||
if let Some(fy) = raw.company.fiscal_year_start {
|
||||
company.fiscal_year_start = parse_month_day(fy.trim())?;
|
||||
}
|
||||
for m in raw.company.members {
|
||||
let share = parse_share(&m.name, &m.share)?;
|
||||
let capital_account = m
|
||||
.capital_account
|
||||
.filter(|c| !c.trim().is_empty())
|
||||
.ok_or_else(|| format!("member {}: capital_account is required", m.name))?;
|
||||
company.members.push(Member {
|
||||
name: m.name.trim().to_string(),
|
||||
share,
|
||||
capital_account: capital_account.trim().to_string(),
|
||||
distribution_account: m
|
||||
.distribution_account
|
||||
.map(|d| d.trim().to_string())
|
||||
.filter(|d| !d.is_empty()),
|
||||
});
|
||||
}
|
||||
if !company.members.is_empty() {
|
||||
let total: Decimal = company.members.iter().map(|m| m.share).sum();
|
||||
if total != Decimal::ONE {
|
||||
return Err(format!("member shares sum to {total}, not 100%"));
|
||||
}
|
||||
}
|
||||
company.tax_form =
|
||||
match (&raw.company.tax_form, &raw.company.tax_classification) {
|
||||
(Some(f), _) => Some(ledger_core::TaxForm::parse(f).ok_or_else(|| {
|
||||
format!("tax_form {f:?}: expected schedule_c, 1065, 1120s, or 1120")
|
||||
})?),
|
||||
(None, Some(c)) => form_for_classification(c),
|
||||
(None, None) => None,
|
||||
};
|
||||
if !company.members.is_empty() && raw.books.close_into.is_some() {
|
||||
return Err(
|
||||
"close_into is for single-owner businesses; with [[company.members]] a close goes to each member's capital account"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
company.close_into = raw.books.close_into.map(|c| c.trim().to_string());
|
||||
company.close_sweep = raw.books.close_sweep;
|
||||
if let Some(basis) = raw.books.accounting_basis {
|
||||
company.basis = match basis.trim().to_lowercase().as_str() {
|
||||
"accrual" => "Accrual".to_string(),
|
||||
"cash" => "Cash".to_string(),
|
||||
other => {
|
||||
return Err(format!(
|
||||
"accounting_basis {other:?}: expected accrual or cash"
|
||||
));
|
||||
}
|
||||
};
|
||||
}
|
||||
let receipt_threshold = match raw.books.receipt_threshold {
|
||||
Some(t) => {
|
||||
t.trim()
|
||||
.parse::<Decimal>()
|
||||
.map_err(|_| format!("receipt_threshold {t:?} is not a decimal"))?;
|
||||
Some(t.trim().to_string())
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
|
||||
if let Some(toml::Value::Table(c)) = table.get("company") {
|
||||
company.profile = c
|
||||
.iter()
|
||||
.filter_map(|(k, v)| {
|
||||
let s = match v {
|
||||
toml::Value::String(s) => s.clone(),
|
||||
toml::Value::Integer(i) => i.to_string(),
|
||||
toml::Value::Float(f) => f.to_string(),
|
||||
toml::Value::Boolean(b) => b.to_string(),
|
||||
toml::Value::Datetime(d) => d.to_string(),
|
||||
_ => return None,
|
||||
};
|
||||
Some((k.clone(), s))
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
Ok(BooksSettings {
|
||||
company,
|
||||
receipt_threshold,
|
||||
cash_accounts: raw.books.cash_accounts,
|
||||
})
|
||||
}
|
||||
|
||||
/// Load `wanderbooks.toml` from a path.
|
||||
pub fn load(path: &Path) -> Result<BooksSettings, String> {
|
||||
let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
parse(&text).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calendar_year_by_default() {
|
||||
let c = parse("").unwrap().company;
|
||||
assert_eq!(c.fiscal_year_start, (1, 1));
|
||||
assert_eq!(
|
||||
c.fiscal_year_containing(d(2026, 10, 11)),
|
||||
(d(2026, 1, 1), d(2026, 12, 31))
|
||||
);
|
||||
assert_eq!(
|
||||
c.fiscal_year_containing(d(2026, 1, 1)),
|
||||
(d(2026, 1, 1), d(2026, 12, 31))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_july_fiscal_year_spans_two_calendar_years() {
|
||||
let c = parse("[company]\nfiscal_year_start = \"07-01\"\n")
|
||||
.unwrap()
|
||||
.company;
|
||||
assert_eq!(
|
||||
c.fiscal_year_containing(d(2026, 3, 15)),
|
||||
(d(2025, 7, 1), d(2026, 6, 30))
|
||||
);
|
||||
assert_eq!(
|
||||
c.fiscal_year_containing(d(2026, 7, 1)),
|
||||
(d(2026, 7, 1), d(2027, 6, 30))
|
||||
);
|
||||
assert_eq!(
|
||||
c.fiscal_year_containing(d(2026, 6, 30)),
|
||||
(d(2025, 7, 1), d(2026, 6, 30))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_the_scaffolded_file() {
|
||||
let s = parse(
|
||||
"[company]\nname = \"Wanderland Labs LLC\"\nentity_type = \"x\"\nfiscal_year_start = \"01-01\"\n\n[books]\ndata_dir = \"books-data\"\nreceipt_threshold = \"25.00\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.company.name, "Wanderland Labs LLC");
|
||||
assert_eq!(s.receipt_threshold.as_deref(), Some("25.00"));
|
||||
// An empty name (fresh scaffold) keeps the placeholder.
|
||||
assert_eq!(
|
||||
parse("[company]\nname = \"\"\n").unwrap().company.name,
|
||||
"Unnamed company"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_the_whole_profile_in_order() {
|
||||
let s = parse(
|
||||
"[company]\nname = \"Acme LLC\"\nein = \"12-3456789\"\nformed = \"2026-10-04\"\n[books]\ncash_accounts = [\"1000\", \"1010\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.company.profile,
|
||||
[
|
||||
("name".to_string(), "Acme LLC".to_string()),
|
||||
("ein".to_string(), "12-3456789".to_string()),
|
||||
("formed".to_string(), "2026-10-04".to_string()),
|
||||
]
|
||||
);
|
||||
assert_eq!(s.cash_accounts, ["1000", "1010"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_members_and_basis() {
|
||||
let s = parse(
|
||||
"[company]\n[[company.members]]\nname = \"Stephen Waits\"\nshare = \"50%\"\ncapital_account = \"3000\"\n[[company.members]]\nname = \"Katherine Pendergast\"\nshare = \"0.5\"\ncapital_account = \"3001\"\n[books]\naccounting_basis = \"cash\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let half = Decimal::new(5, 1);
|
||||
assert_eq!(
|
||||
s.company.members,
|
||||
vec![
|
||||
Member {
|
||||
name: "Stephen Waits".into(),
|
||||
share: half,
|
||||
capital_account: "3000".into(),
|
||||
distribution_account: None,
|
||||
},
|
||||
Member {
|
||||
name: "Katherine Pendergast".into(),
|
||||
share: half,
|
||||
capital_account: "3001".into(),
|
||||
distribution_account: None,
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(s.company.basis, "Cash");
|
||||
assert_eq!(parse("").unwrap().company.basis, "Accrual");
|
||||
// Shares must add up; every member needs a capital account.
|
||||
let two = |a: &str, b: &str| {
|
||||
format!(
|
||||
"[company]\n[[company.members]]\nname = \"A\"\nshare = \"{a}\"\ncapital_account = \"3000\"\n[[company.members]]\nname = \"B\"\nshare = \"{b}\"\ncapital_account = \"3001\"\n"
|
||||
)
|
||||
};
|
||||
assert!(parse(&two("50%", "40%")).is_err());
|
||||
assert!(parse(&two("60%", "40%")).is_ok());
|
||||
assert!(parse("[company]\n[[company.members]]\nname = \"A\"\nshare = \"100%\"\n").is_err());
|
||||
assert!(parse("[books]\naccounting_basis = \"modified\"\n").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tax_form_from_setting_or_classification() {
|
||||
use ledger_core::TaxForm;
|
||||
let form = |toml: &str| parse(toml).unwrap().company.tax_form;
|
||||
assert_eq!(
|
||||
form("[company]\ntax_form = \"1120s\"\n"),
|
||||
Some(TaxForm::Form1120S)
|
||||
);
|
||||
assert_eq!(
|
||||
form("[company]\ntax_classification = \"partnership\"\n"),
|
||||
Some(TaxForm::Form1065)
|
||||
);
|
||||
assert_eq!(
|
||||
form("[company]\ntax_classification = \"S corporation\"\n"),
|
||||
Some(TaxForm::Form1120S)
|
||||
);
|
||||
assert_eq!(
|
||||
form("[company]\ntax_classification = \"disregarded entity\"\n"),
|
||||
Some(TaxForm::ScheduleC)
|
||||
);
|
||||
assert_eq!(
|
||||
form("[company]\ntax_classification = \"partnership\"\ntax_form = \"1120\"\n"),
|
||||
Some(TaxForm::Form1120)
|
||||
);
|
||||
assert_eq!(form(""), None);
|
||||
assert!(parse("[company]\ntax_form = \"990\"\n").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_settings() {
|
||||
assert!(parse("[company]\nfiscal_year_start = \"02-29\"\n").is_err());
|
||||
assert!(parse("[company]\nfiscal_year_start = \"13-01\"\n").is_err());
|
||||
assert!(parse("[company]\nfiscal_year_start = \"Jan 1\"\n").is_err());
|
||||
assert!(parse("[books]\nreceipt_threshold = \"25 dollars\"\n").is_err());
|
||||
}
|
||||
}
|
||||
@@ -15,3 +15,6 @@ serde_json = "1"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "1"
|
||||
@@ -6,6 +6,7 @@
|
||||
//! grouping on type, so getting this right is the whole game.
|
||||
|
||||
use crate::{Currency, LedgerError};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -72,6 +73,14 @@ pub struct Account {
|
||||
pub placeholder: bool,
|
||||
/// Day one: always the ledger's base currency (see R-5).
|
||||
pub currency: Currency,
|
||||
/// Tax category key (see [`crate::tax`]), for revenue and expense
|
||||
/// accounts.
|
||||
#[serde(default)]
|
||||
pub tax_category: Option<String>,
|
||||
/// Percent deductible, overriding the category's default (e.g. 50 for
|
||||
/// meals, 0 for penalties).
|
||||
#[serde(default)]
|
||||
pub deductible_percent: Option<Decimal>,
|
||||
}
|
||||
|
||||
/// The registry of accounts. Owns id allocation for accounts it holds.
|
||||
@@ -84,40 +93,20 @@ pub struct ChartOfAccounts {
|
||||
impl ChartOfAccounts {
|
||||
/// Register an account. Codes must be unique and non-empty; a parent, if
|
||||
/// given, must already exist.
|
||||
pub fn add(
|
||||
&mut self,
|
||||
id: AccountId,
|
||||
code: &str,
|
||||
name: &str,
|
||||
account_type: AccountType,
|
||||
parent: Option<AccountId>,
|
||||
placeholder: bool,
|
||||
currency: Currency,
|
||||
) -> Result<(), LedgerError> {
|
||||
if code.is_empty() {
|
||||
pub fn add(&mut self, account: Account) -> Result<(), LedgerError> {
|
||||
if account.code.is_empty() {
|
||||
return Err(LedgerError::EmptyAccountCode);
|
||||
}
|
||||
if self.by_code.contains_key(code) {
|
||||
return Err(LedgerError::DuplicateAccountCode(code.to_string()));
|
||||
if self.by_code.contains_key(&account.code) {
|
||||
return Err(LedgerError::DuplicateAccountCode(account.code));
|
||||
}
|
||||
if let Some(p) = parent {
|
||||
if !self.accounts.contains_key(&p) {
|
||||
return Err(LedgerError::UnknownParentAccount(p));
|
||||
}
|
||||
if let Some(p) = account.parent
|
||||
&& !self.accounts.contains_key(&p)
|
||||
{
|
||||
return Err(LedgerError::UnknownParentAccount(p));
|
||||
}
|
||||
self.by_code.insert(code.to_string(), id);
|
||||
self.accounts.insert(
|
||||
id,
|
||||
Account {
|
||||
id,
|
||||
code: code.to_string(),
|
||||
name: name.to_string(),
|
||||
account_type,
|
||||
parent,
|
||||
placeholder,
|
||||
currency,
|
||||
},
|
||||
);
|
||||
self.by_code.insert(account.code.clone(), account.id);
|
||||
self.accounts.insert(account.id, account);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -136,6 +125,22 @@ impl ChartOfAccounts {
|
||||
self.accounts.values()
|
||||
}
|
||||
|
||||
/// Set (or clear) an account's tax category and deductible percent.
|
||||
pub fn set_tax(
|
||||
&mut self,
|
||||
id: AccountId,
|
||||
category: Option<String>,
|
||||
deductible_percent: Option<Decimal>,
|
||||
) -> Result<(), LedgerError> {
|
||||
let a = self
|
||||
.accounts
|
||||
.get_mut(&id)
|
||||
.ok_or(LedgerError::UnknownAccount(id))?;
|
||||
a.tax_category = category;
|
||||
a.deductible_percent = deductible_percent;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Number of accounts in the chart.
|
||||
pub fn len(&self) -> usize {
|
||||
self.accounts.len()
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
//! Time series over the books, for dashboards and interval-column reports.
|
||||
//!
|
||||
//! Statements answer "what was true for this range"; these answer "how did
|
||||
//! it change": a range split into months, quarters, or years; balances of a
|
||||
//! set of accounts on a series of dates; profit and loss per interval. They
|
||||
//! are built from the same posted entries and statements as everything
|
||||
//! else, so a number on a chart always ties to a number on a statement.
|
||||
|
||||
use crate::reports::profit_and_loss_for_period;
|
||||
use crate::{AccountId, AccountType, Ledger, LedgerError, Side};
|
||||
use chrono::{Datelike, Days, Months, NaiveDate};
|
||||
use rust_decimal::Decimal;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// How to split a date range into columns.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Interval {
|
||||
/// Calendar months.
|
||||
Month,
|
||||
/// Calendar quarters.
|
||||
Quarter,
|
||||
/// Calendar years.
|
||||
Year,
|
||||
}
|
||||
|
||||
impl Interval {
|
||||
fn months(self) -> u32 {
|
||||
match self {
|
||||
Interval::Month => 1,
|
||||
Interval::Quarter => 3,
|
||||
Interval::Year => 12,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The first day of the calendar interval containing `d`.
|
||||
pub fn interval_start(d: NaiveDate, interval: Interval) -> NaiveDate {
|
||||
let month0 = (d.month0() / interval.months()) * interval.months();
|
||||
NaiveDate::from_ymd_opt(d.year(), month0 + 1, 1).expect("valid")
|
||||
}
|
||||
|
||||
/// `[start, end]` split on calendar interval boundaries, first and last
|
||||
/// pieces clipped to the range. Empty when `end < start`.
|
||||
pub fn intervals(
|
||||
start: NaiveDate,
|
||||
end: NaiveDate,
|
||||
interval: Interval,
|
||||
) -> Vec<(NaiveDate, NaiveDate)> {
|
||||
let mut out = Vec::new();
|
||||
let mut from = interval_start(start, interval);
|
||||
while from <= end {
|
||||
let next = from + Months::new(interval.months());
|
||||
let to = (next - Days::new(1)).min(end);
|
||||
out.push((from.max(start), to));
|
||||
from = next;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The combined normal-side balance of `accounts` at the end of each date
|
||||
/// in `dates` (which must be ascending). Assets and expenses count debits
|
||||
/// as positive; liabilities, equity, and revenue count credits. Mixing
|
||||
/// types is allowed but rarely meaningful.
|
||||
pub fn balance_series(
|
||||
ledger: &Ledger,
|
||||
accounts: &[AccountId],
|
||||
dates: &[NaiveDate],
|
||||
) -> Vec<Decimal> {
|
||||
let set: HashSet<AccountId> = accounts.iter().copied().collect();
|
||||
let mut postings: Vec<(NaiveDate, Decimal)> = Vec::new();
|
||||
for entry in ledger.posted_entries() {
|
||||
for line in entry.lines() {
|
||||
if !set.contains(&line.account) {
|
||||
continue;
|
||||
}
|
||||
let Some(account) = ledger.chart().get(line.account) else {
|
||||
continue;
|
||||
};
|
||||
let signed = if line.side == account.account_type.normal_side() {
|
||||
line.amount.amount
|
||||
} else {
|
||||
-line.amount.amount
|
||||
};
|
||||
postings.push((entry.date(), signed));
|
||||
}
|
||||
}
|
||||
postings.sort_by_key(|(d, _)| *d);
|
||||
let mut out = Vec::with_capacity(dates.len());
|
||||
let mut running = Decimal::ZERO;
|
||||
let mut i = 0;
|
||||
for date in dates {
|
||||
while i < postings.len() && postings[i].0 <= *date {
|
||||
running += postings[i].1;
|
||||
i += 1;
|
||||
}
|
||||
out.push(running);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Every date `accounts` moved, with the combined balance at the end of
|
||||
/// that day: the exact shape of a balance-over-time chart.
|
||||
pub fn balance_history(ledger: &Ledger, accounts: &[AccountId]) -> Vec<(NaiveDate, Decimal)> {
|
||||
let set: HashSet<AccountId> = accounts.iter().copied().collect();
|
||||
let mut dates: Vec<NaiveDate> = ledger
|
||||
.posted_entries()
|
||||
.filter(|e| e.lines().iter().any(|l| set.contains(&l.account)))
|
||||
.map(|e| e.date())
|
||||
.collect();
|
||||
dates.sort();
|
||||
dates.dedup();
|
||||
let balances = balance_series(ledger, accounts, &dates);
|
||||
dates.into_iter().zip(balances).collect()
|
||||
}
|
||||
|
||||
/// One interval's profit and loss.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct IntervalPnl {
|
||||
/// First day.
|
||||
pub start: NaiveDate,
|
||||
/// Last day.
|
||||
pub end: NaiveDate,
|
||||
/// Total revenue.
|
||||
pub revenue: Decimal,
|
||||
/// Total expenses.
|
||||
pub expenses: Decimal,
|
||||
}
|
||||
|
||||
impl IntervalPnl {
|
||||
/// Revenue minus expenses.
|
||||
pub fn net(&self) -> Decimal {
|
||||
self.revenue - self.expenses
|
||||
}
|
||||
}
|
||||
|
||||
/// Profit and loss per interval across `[start, end]` (closing entries
|
||||
/// excluded, as on every period statement).
|
||||
pub fn pnl_by_interval(
|
||||
ledger: &Ledger,
|
||||
start: NaiveDate,
|
||||
end: NaiveDate,
|
||||
interval: Interval,
|
||||
) -> Result<Vec<IntervalPnl>, LedgerError> {
|
||||
intervals(start, end, interval)
|
||||
.into_iter()
|
||||
.map(|(s, e)| {
|
||||
let pl = profit_and_loss_for_period(ledger, s, e, None)?;
|
||||
Ok(IntervalPnl {
|
||||
start: s,
|
||||
end: e,
|
||||
revenue: pl.total_revenue,
|
||||
expenses: pl.total_expenses,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Money in and out of `accounts` per interval: (inflows, outflows), each
|
||||
/// positive. Each entry counts by its net debit (in) or credit (out) to the
|
||||
/// accounts together: for cash, deposits and payments; a transfer between
|
||||
/// two of them (checking to savings) is neither; with a credit card in the
|
||||
/// set, a charge is money out and paying the card moves nothing.
|
||||
pub fn flows_by_interval(
|
||||
ledger: &Ledger,
|
||||
accounts: &[AccountId],
|
||||
start: NaiveDate,
|
||||
end: NaiveDate,
|
||||
interval: Interval,
|
||||
) -> Vec<(NaiveDate, NaiveDate, Decimal, Decimal)> {
|
||||
let set: HashSet<AccountId> = accounts.iter().copied().collect();
|
||||
let mut out: Vec<(NaiveDate, NaiveDate, Decimal, Decimal)> = intervals(start, end, interval)
|
||||
.into_iter()
|
||||
.map(|(s, e)| (s, e, Decimal::ZERO, Decimal::ZERO))
|
||||
.collect();
|
||||
for entry in ledger.posted_entries() {
|
||||
let d = entry.date();
|
||||
let Some(slot) = out.iter_mut().find(|(s, e, _, _)| *s <= d && d <= *e) else {
|
||||
continue;
|
||||
};
|
||||
// Debits in, credits out, for every account in the set: cash
|
||||
// received is a debit to cash, and a charge on a card in the set is
|
||||
// a credit to the card.
|
||||
let mut net = Decimal::ZERO;
|
||||
for line in entry.lines().iter().filter(|l| set.contains(&l.account)) {
|
||||
match line.side {
|
||||
Side::Debit => net += line.amount.amount,
|
||||
Side::Credit => net -= line.amount.amount,
|
||||
}
|
||||
}
|
||||
if net > Decimal::ZERO {
|
||||
slot.2 += net;
|
||||
} else {
|
||||
slot.3 -= net;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Accounts of the given types, in chart order.
|
||||
pub fn accounts_of_type(ledger: &Ledger, types: &[AccountType]) -> Vec<AccountId> {
|
||||
ledger
|
||||
.chart()
|
||||
.all()
|
||||
.filter(|a| types.contains(&a.account_type))
|
||||
.map(|a| a.id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intervals_clip_to_the_range() {
|
||||
assert_eq!(
|
||||
intervals(d(2026, 1, 15), d(2026, 3, 10), Interval::Month),
|
||||
[
|
||||
(d(2026, 1, 15), d(2026, 1, 31)),
|
||||
(d(2026, 2, 1), d(2026, 2, 28)),
|
||||
(d(2026, 3, 1), d(2026, 3, 10)),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
intervals(d(2026, 2, 1), d(2026, 12, 31), Interval::Quarter),
|
||||
[
|
||||
(d(2026, 2, 1), d(2026, 3, 31)),
|
||||
(d(2026, 4, 1), d(2026, 6, 30)),
|
||||
(d(2026, 7, 1), d(2026, 9, 30)),
|
||||
(d(2026, 10, 1), d(2026, 12, 31)),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
intervals(d(2025, 6, 1), d(2026, 2, 1), Interval::Year),
|
||||
[
|
||||
(d(2025, 6, 1), d(2025, 12, 31)),
|
||||
(d(2026, 1, 1), d(2026, 2, 1)),
|
||||
]
|
||||
);
|
||||
assert!(intervals(d(2026, 2, 1), d(2026, 1, 1), Interval::Month).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
//! Accounts receivable and payable: customers, vendors, invoices, bills.
|
||||
//!
|
||||
//! Invoices and bills are business documents that auto-post journal entries:
|
||||
//!
|
||||
//! * `create_invoice`: Dr `1200` Accounts Receivable, Cr `4000` Revenue.
|
||||
//! * `create_bill`: Dr `6999` General Expenses, Cr `2000` Accounts Payable.
|
||||
//! * `receive_payment`: Dr Cash, Cr A/R. Updates the invoice's paid amount.
|
||||
//! * `pay_bill`: Dr A/P, Cr Cash. Updates the bill's paid amount.
|
||||
//!
|
||||
//! The control accounts (`1200`, `2000`) and the default revenue/expense
|
||||
//! accounts (`4000`, `6999`) are created on demand if missing. Invoices and
|
||||
//! bills respect closed periods: they cannot be created with dates in a
|
||||
//! closed period.
|
||||
|
||||
use crate::LedgerError;
|
||||
use chrono::NaiveDate;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Opaque customer handle.
|
||||
pub type CustomerId = u64;
|
||||
/// Opaque vendor handle.
|
||||
pub type VendorId = u64;
|
||||
/// Opaque invoice handle.
|
||||
pub type InvoiceId = u64;
|
||||
/// Opaque bill handle.
|
||||
pub type BillId = u64;
|
||||
|
||||
/// A customer: someone we invoice.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Customer {
|
||||
/// Opaque handle.
|
||||
pub id: CustomerId,
|
||||
/// Display name.
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// A vendor: someone who bills us.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Vendor {
|
||||
/// Opaque handle.
|
||||
pub id: VendorId,
|
||||
/// Display name.
|
||||
pub name: String,
|
||||
/// The information return this vendor gets, if any (see
|
||||
/// [`crate::form1099`]).
|
||||
#[serde(default)]
|
||||
pub form_1099: Option<crate::form1099::Form1099>,
|
||||
/// A W-9 (taxpayer identification number) is on file. The number
|
||||
/// itself is never stored here.
|
||||
#[serde(default)]
|
||||
pub tin_on_file: bool,
|
||||
}
|
||||
|
||||
/// Payment status of an invoice or bill.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
|
||||
pub enum DocumentStatus {
|
||||
/// Nothing paid yet.
|
||||
Open,
|
||||
/// Partially paid.
|
||||
Partial,
|
||||
/// Fully paid.
|
||||
Paid,
|
||||
}
|
||||
|
||||
/// One line on an invoice or bill.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct DocumentLine {
|
||||
/// Description of the goods/services.
|
||||
pub description: String,
|
||||
/// Line amount (positive).
|
||||
pub amount: Decimal,
|
||||
}
|
||||
|
||||
/// A sales invoice: we billed a customer.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Invoice {
|
||||
/// Opaque handle.
|
||||
pub id: InvoiceId,
|
||||
/// The customer.
|
||||
pub customer_id: CustomerId,
|
||||
/// Invoice date.
|
||||
pub date: NaiveDate,
|
||||
/// When payment is due.
|
||||
pub due_date: NaiveDate,
|
||||
/// Line items.
|
||||
pub lines: Vec<DocumentLine>,
|
||||
/// How much has been paid.
|
||||
pub amount_paid: Decimal,
|
||||
/// Derived from amount_paid vs total.
|
||||
pub status: DocumentStatus,
|
||||
/// The auto-posted journal entry.
|
||||
pub entry_id: u64,
|
||||
}
|
||||
|
||||
impl Invoice {
|
||||
/// Total of all lines.
|
||||
pub fn total(&self) -> Decimal {
|
||||
self.lines.iter().map(|l| l.amount).sum()
|
||||
}
|
||||
|
||||
/// Amount still owed.
|
||||
pub fn balance(&self) -> Decimal {
|
||||
self.total() - self.amount_paid
|
||||
}
|
||||
}
|
||||
|
||||
/// A vendor bill: we owe a vendor.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Bill {
|
||||
/// Opaque handle.
|
||||
pub id: BillId,
|
||||
/// The vendor.
|
||||
pub vendor_id: VendorId,
|
||||
/// Bill date.
|
||||
pub date: NaiveDate,
|
||||
/// When payment is due.
|
||||
pub due_date: NaiveDate,
|
||||
/// Line items.
|
||||
pub lines: Vec<DocumentLine>,
|
||||
/// How much has been paid.
|
||||
pub amount_paid: Decimal,
|
||||
/// Derived from amount_paid vs total.
|
||||
pub status: DocumentStatus,
|
||||
/// The auto-posted journal entry.
|
||||
pub entry_id: u64,
|
||||
}
|
||||
|
||||
impl Bill {
|
||||
/// Total of all lines.
|
||||
pub fn total(&self) -> Decimal {
|
||||
self.lines.iter().map(|l| l.amount).sum()
|
||||
}
|
||||
|
||||
/// Amount still owed.
|
||||
pub fn balance(&self) -> Decimal {
|
||||
self.total() - self.amount_paid
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate document lines: at least one, all positive amounts.
|
||||
pub fn validate_lines(lines: &[DocumentLine]) -> Result<(), LedgerError> {
|
||||
if lines.is_empty() {
|
||||
return Err(LedgerError::EmptyDocumentLines);
|
||||
}
|
||||
for line in lines {
|
||||
if line.amount <= Decimal::ZERO {
|
||||
return Err(LedgerError::InvalidDocumentLineAmount(line.amount));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate that due date is not before the document date.
|
||||
pub fn validate_due_date(date: NaiveDate, due_date: NaiveDate) -> Result<(), LedgerError> {
|
||||
if due_date < date {
|
||||
return Err(LedgerError::DueBeforeDate(due_date.to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Derive status from paid vs total.
|
||||
pub fn derive_status(paid: Decimal, total: Decimal) -> DocumentStatus {
|
||||
if paid.is_zero() {
|
||||
DocumentStatus::Open
|
||||
} else if paid >= total {
|
||||
DocumentStatus::Paid
|
||||
} else {
|
||||
DocumentStatus::Partial
|
||||
}
|
||||
}
|
||||
|
||||
/// One row of an aging report.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct AgingRow {
|
||||
/// Customer or vendor name.
|
||||
pub name: String,
|
||||
/// Not yet due.
|
||||
pub current: Decimal,
|
||||
/// 1-30 days past due.
|
||||
pub days_30: Decimal,
|
||||
/// 31-60 days past due.
|
||||
pub days_60: Decimal,
|
||||
/// 61-90 days past due.
|
||||
pub days_90: Decimal,
|
||||
/// Over 90 days past due.
|
||||
pub days_90_plus: Decimal,
|
||||
}
|
||||
|
||||
impl AgingRow {
|
||||
/// Total outstanding.
|
||||
pub fn total(&self) -> Decimal {
|
||||
self.current + self.days_30 + self.days_60 + self.days_90 + self.days_90_plus
|
||||
}
|
||||
}
|
||||
|
||||
/// Bucket a past-due balance by days overdue.
|
||||
pub fn aging_bucket(due_date: NaiveDate, as_of: NaiveDate, balance: Decimal) -> AgingRow {
|
||||
let mut row = AgingRow {
|
||||
name: String::new(),
|
||||
current: Decimal::ZERO,
|
||||
days_30: Decimal::ZERO,
|
||||
days_60: Decimal::ZERO,
|
||||
days_90: Decimal::ZERO,
|
||||
days_90_plus: Decimal::ZERO,
|
||||
};
|
||||
if due_date > as_of {
|
||||
row.current = balance;
|
||||
} else {
|
||||
let days = (as_of - due_date).num_days();
|
||||
if days <= 30 {
|
||||
row.days_30 = balance;
|
||||
} else if days <= 60 {
|
||||
row.days_60 = balance;
|
||||
} else if days <= 90 {
|
||||
row.days_90 = balance;
|
||||
} else {
|
||||
row.days_90_plus = balance;
|
||||
}
|
||||
}
|
||||
row
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{AccountType, Currency, Ledger, Money, Side};
|
||||
|
||||
fn arap_ledger() -> Ledger {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
l.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
l
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invoice_lifecycle() {
|
||||
let mut l = arap_ledger();
|
||||
let cust = l.add_customer("Acme Corp").unwrap();
|
||||
let inv = l
|
||||
.create_invoice(
|
||||
cust,
|
||||
NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(),
|
||||
NaiveDate::from_ymd_opt(2026, 10, 31).unwrap(),
|
||||
vec![DocumentLine {
|
||||
description: "Widgets".to_string(),
|
||||
amount: "1000.00".parse().unwrap(),
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
let invoice = l.get_invoice(inv).unwrap();
|
||||
assert_eq!(invoice.total(), "1000.00".parse().unwrap());
|
||||
assert_eq!(invoice.status, DocumentStatus::Open);
|
||||
|
||||
// A/R has a debit balance of 1000.
|
||||
let ar = l.chart().find_by_code("1200").unwrap().id;
|
||||
assert_eq!(l.account_balance(ar).unwrap(), "1000".parse().unwrap());
|
||||
|
||||
// Partial payment.
|
||||
let cash = l.chart().find_by_code("1000").unwrap().id;
|
||||
l.receive_payment(
|
||||
inv,
|
||||
NaiveDate::from_ymd_opt(2026, 10, 15).unwrap(),
|
||||
"400.00".parse().unwrap(),
|
||||
cash,
|
||||
)
|
||||
.unwrap();
|
||||
let invoice = l.get_invoice(inv).unwrap();
|
||||
assert_eq!(invoice.status, DocumentStatus::Partial);
|
||||
assert_eq!(invoice.balance(), "600.00".parse().unwrap());
|
||||
|
||||
// Full payment.
|
||||
l.receive_payment(
|
||||
inv,
|
||||
NaiveDate::from_ymd_opt(2026, 10, 20).unwrap(),
|
||||
"600.00".parse().unwrap(),
|
||||
cash,
|
||||
)
|
||||
.unwrap();
|
||||
let invoice = l.get_invoice(inv).unwrap();
|
||||
assert_eq!(invoice.status, DocumentStatus::Paid);
|
||||
assert_eq!(l.account_balance(ar).unwrap(), Decimal::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bill_lifecycle() {
|
||||
let mut l = arap_ledger();
|
||||
let vend = l.add_vendor("Supply Co").unwrap();
|
||||
let bill = l
|
||||
.create_bill(
|
||||
vend,
|
||||
NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(),
|
||||
NaiveDate::from_ymd_opt(2026, 10, 31).unwrap(),
|
||||
vec![DocumentLine {
|
||||
description: "Paper".to_string(),
|
||||
amount: "250.00".parse().unwrap(),
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
let ap = l.chart().find_by_code("2000").unwrap().id;
|
||||
// A/P is credit-normal; balance should be 250 (credit).
|
||||
assert_eq!(l.account_balance(ap).unwrap(), "250".parse().unwrap());
|
||||
|
||||
let cash = l.chart().find_by_code("1000").unwrap().id;
|
||||
// Seed cash so the payment doesn't go negative (not required, but tidy).
|
||||
let e = l.create_entry(NaiveDate::from_ymd_opt(2026, 9, 1).unwrap(), "seed");
|
||||
let m = Money::new("1000.00".parse().unwrap(), Currency::USD);
|
||||
l.add_line(e, cash, Side::Debit, m).unwrap();
|
||||
let eq = l
|
||||
.add_account("3000", "Equity", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
l.add_line(e, eq, Side::Credit, m).unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
|
||||
l.pay_bill(
|
||||
bill,
|
||||
NaiveDate::from_ymd_opt(2026, 10, 15).unwrap(),
|
||||
"250.00".parse().unwrap(),
|
||||
cash,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(l.get_bill(bill).unwrap().status, DocumentStatus::Paid);
|
||||
assert_eq!(l.account_balance(ap).unwrap(), Decimal::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overpayment_rejected() {
|
||||
let mut l = arap_ledger();
|
||||
let cust = l.add_customer("Acme").unwrap();
|
||||
let inv = l
|
||||
.create_invoice(
|
||||
cust,
|
||||
NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(),
|
||||
NaiveDate::from_ymd_opt(2026, 10, 31).unwrap(),
|
||||
vec![DocumentLine {
|
||||
description: "X".to_string(),
|
||||
amount: "100.00".parse().unwrap(),
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
let cash = l.chart().find_by_code("1000").unwrap().id;
|
||||
let err = l.receive_payment(
|
||||
inv,
|
||||
NaiveDate::from_ymd_opt(2026, 10, 15).unwrap(),
|
||||
"150.00".parse().unwrap(),
|
||||
cash,
|
||||
);
|
||||
assert!(matches!(err, Err(LedgerError::Overpayment(_, _))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ar_aging_buckets_correctly() {
|
||||
let mut l = arap_ledger();
|
||||
let cust = l.add_customer("Acme").unwrap();
|
||||
// Due in the future: current.
|
||||
l.create_invoice(
|
||||
cust,
|
||||
NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(),
|
||||
NaiveDate::from_ymd_opt(2026, 11, 15).unwrap(),
|
||||
vec![DocumentLine {
|
||||
description: "A".to_string(),
|
||||
amount: "100.00".parse().unwrap(),
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
// 45 days past due: 60 bucket.
|
||||
l.create_invoice(
|
||||
cust,
|
||||
NaiveDate::from_ymd_opt(2026, 7, 1).unwrap(),
|
||||
NaiveDate::from_ymd_opt(2026, 8, 17).unwrap(),
|
||||
vec![DocumentLine {
|
||||
description: "B".to_string(),
|
||||
amount: "200.00".parse().unwrap(),
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
let as_of = NaiveDate::from_ymd_opt(2026, 10, 1).unwrap();
|
||||
let aging = l.ar_aging(as_of);
|
||||
assert_eq!(aging.len(), 1);
|
||||
assert_eq!(aging[0].current, "100".parse().unwrap());
|
||||
assert_eq!(aging[0].days_60, "200".parse().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invoice_in_closed_period_rejected() {
|
||||
let mut l = arap_ledger();
|
||||
let cust = l.add_customer("Acme").unwrap();
|
||||
let pid = l
|
||||
.create_period(
|
||||
"2026-10",
|
||||
NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(),
|
||||
NaiveDate::from_ymd_opt(2026, 10, 31).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
l.close_period(pid).unwrap();
|
||||
let err = l.create_invoice(
|
||||
cust,
|
||||
NaiveDate::from_ymd_opt(2026, 10, 15).unwrap(),
|
||||
NaiveDate::from_ymd_opt(2026, 11, 15).unwrap(),
|
||||
vec![DocumentLine {
|
||||
description: "X".to_string(),
|
||||
amount: "100.00".parse().unwrap(),
|
||||
}],
|
||||
);
|
||||
assert!(matches!(err, Err(LedgerError::ClosedPeriod(_, _))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
//! Fixed assets and depreciation: the asset register and the math.
|
||||
//!
|
||||
//! Scope is deliberate: **book depreciation only** (straight-line, GAAP
|
||||
//! style). Tax depreciation — MACRS, Section 179, bonus depreciation — is a
|
||||
//! separate set of books with its own rules and stays with the CPA. This
|
||||
//! module never pretends to do tax.
|
||||
//!
|
||||
//! The register is master data owned by [`Ledger`]: assets are journaled
|
||||
//! like accounts (see `ledger-store`), and ids are allocator-deterministic
|
||||
//! for replay. Depreciation itself is pure math — [`depreciation_schedule`]
|
||||
//! — and postings go through the normal entry machinery, so every
|
||||
//! accounting invariant holds.
|
||||
|
||||
use crate::{AccountId, AccountType, ChartOfAccounts, LedgerError, Money};
|
||||
use chrono::{Datelike, Months, NaiveDate};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Opaque handle for a fixed asset. Never forged: ids come from the ledger's
|
||||
/// allocator.
|
||||
pub type AssetId = u64;
|
||||
|
||||
/// Depreciation method. Straight-line is the whole v1; the enum reserves
|
||||
/// room for declining-balance later.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DepreciationMethod {
|
||||
/// Even allocation over the useful life: (cost − salvage) / months,
|
||||
/// prorated by days for partial first and last months.
|
||||
StraightLine,
|
||||
}
|
||||
|
||||
/// Lifecycle state of an asset.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AssetStatus {
|
||||
/// In service.
|
||||
Active,
|
||||
/// Retired or sold.
|
||||
Disposed {
|
||||
/// Disposal date.
|
||||
date: NaiveDate,
|
||||
/// Cash (or receivable) received.
|
||||
proceeds: Money,
|
||||
},
|
||||
}
|
||||
|
||||
/// A fixed asset on the books.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Asset {
|
||||
/// Opaque handle.
|
||||
pub id: AssetId,
|
||||
/// What it is, e.g. "2023 Sprinter van".
|
||||
pub description: String,
|
||||
/// Purchase date. Depreciation starts here.
|
||||
pub purchase_date: NaiveDate,
|
||||
/// Capitalized cost.
|
||||
pub cost: Money,
|
||||
/// Expected residual value at end of life.
|
||||
pub salvage_value: Money,
|
||||
/// Useful life in months.
|
||||
pub useful_life_months: u32,
|
||||
/// Depreciation method.
|
||||
pub method: DepreciationMethod,
|
||||
/// Asset account carrying the cost.
|
||||
pub cost_account: AccountId,
|
||||
/// Contra-asset account carrying accumulated depreciation.
|
||||
pub accum_depr_account: AccountId,
|
||||
/// Expense account for the periodic charge.
|
||||
pub expense_account: AccountId,
|
||||
/// Lifecycle state.
|
||||
pub status: AssetStatus,
|
||||
}
|
||||
|
||||
/// Parameters for registering an asset. A struct (not eight arguments)
|
||||
/// because the list is long and stable.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NewAsset {
|
||||
/// What it is.
|
||||
pub description: String,
|
||||
/// Purchase date.
|
||||
pub purchase_date: NaiveDate,
|
||||
/// Capitalized cost. Must be positive, in the ledger's base currency.
|
||||
pub cost: Money,
|
||||
/// Residual value. Non-negative, less than cost, base currency.
|
||||
pub salvage_value: Money,
|
||||
/// Useful life in months, at least 1.
|
||||
pub useful_life_months: u32,
|
||||
/// Asset account for the cost.
|
||||
pub cost_account: AccountId,
|
||||
/// Contra-asset account for accumulated depreciation.
|
||||
pub accum_depr_account: AccountId,
|
||||
/// Expense account for the charge.
|
||||
pub expense_account: AccountId,
|
||||
}
|
||||
|
||||
/// One month of a depreciation schedule.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ScheduleLine {
|
||||
/// Calendar month, "YYYY-MM".
|
||||
pub period: String,
|
||||
/// Depreciation for the month, exact cents.
|
||||
pub amount: Decimal,
|
||||
/// Accumulated depreciation through this month, exact cents.
|
||||
pub accumulated: Decimal,
|
||||
/// Book value after this month: cost − accumulated.
|
||||
pub book_value: Decimal,
|
||||
}
|
||||
|
||||
fn days_in_month(year: i32, month: u32) -> u32 {
|
||||
let first = NaiveDate::from_ymd_opt(year, month, 1).expect("valid month");
|
||||
let next = if month == 12 {
|
||||
NaiveDate::from_ymd_opt(year + 1, 1, 1).expect("valid month")
|
||||
} else {
|
||||
NaiveDate::from_ymd_opt(year, month + 1, 1).expect("valid month")
|
||||
};
|
||||
(next - first).num_days() as u32
|
||||
}
|
||||
|
||||
/// The last day depreciation accrues: purchase + life, or disposal, whichever
|
||||
/// is earlier. The accrual window is [purchase_date, depreciation_end).
|
||||
fn depreciation_end(asset: &Asset) -> NaiveDate {
|
||||
let natural = asset
|
||||
.purchase_date
|
||||
.checked_add_months(Months::new(asset.useful_life_months))
|
||||
.expect("useful life does not overflow dates");
|
||||
match &asset.status {
|
||||
AssetStatus::Active => natural,
|
||||
AssetStatus::Disposed { date, .. } => (*date).min(natural),
|
||||
}
|
||||
}
|
||||
|
||||
/// Straight-line depreciation schedule from the purchase month through the
|
||||
/// month containing `through` (clamped to the depreciation end).
|
||||
///
|
||||
/// Partial first and last months are prorated by days held. Each month is
|
||||
/// rounded to cents via the running-total method, and the final month
|
||||
/// trues up: partial months in different-length calendar months don't
|
||||
/// complement exactly (17/31 + 14/30 ≠ 1), so the last month absorbs the
|
||||
/// drift and the lifetime total is exactly (cost − salvage) — never a
|
||||
/// penny off.
|
||||
pub fn depreciation_schedule(asset: &Asset, through: NaiveDate) -> Vec<ScheduleLine> {
|
||||
if through < asset.purchase_date {
|
||||
return Vec::new();
|
||||
}
|
||||
let depreciable = asset.cost.amount - asset.salvage_value.amount;
|
||||
if depreciable <= Decimal::ZERO {
|
||||
return Vec::new();
|
||||
}
|
||||
let end = depreciation_end(asset);
|
||||
// Accrual is inclusive of `through`: work with an exclusive end.
|
||||
let accrual_end = through.succ_opt().unwrap_or(through).min(end);
|
||||
if accrual_end <= asset.purchase_date {
|
||||
return Vec::new();
|
||||
}
|
||||
let monthly_rate = depreciable / Decimal::from(asset.useful_life_months);
|
||||
|
||||
let mut out = Vec::new();
|
||||
let mut cumulative_exact = Decimal::ZERO;
|
||||
let mut accumulated_rounded = Decimal::ZERO;
|
||||
// Walk calendar months from the purchase month.
|
||||
let (mut year, mut month) = (asset.purchase_date.year(), asset.purchase_date.month());
|
||||
loop {
|
||||
let month_start = NaiveDate::from_ymd_opt(year, month, 1).expect("valid month");
|
||||
if month_start >= accrual_end {
|
||||
break;
|
||||
}
|
||||
let dim = days_in_month(year, month);
|
||||
let month_end_excl = month_start
|
||||
.checked_add_months(Months::new(1))
|
||||
.expect("month arithmetic");
|
||||
// Days held this month: overlap of [purchase_date, accrual_end) with the month.
|
||||
let held_start = asset.purchase_date.max(month_start);
|
||||
let held_end = accrual_end.min(month_end_excl);
|
||||
let days_held = (held_end - held_start).num_days().max(0) as u32;
|
||||
if days_held > 0 {
|
||||
let exact = monthly_rate * Decimal::from(days_held) / Decimal::from(dim);
|
||||
cumulative_exact += exact;
|
||||
let rounded_cumulative = cumulative_exact
|
||||
.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero);
|
||||
let amount = rounded_cumulative - accumulated_rounded;
|
||||
accumulated_rounded = rounded_cumulative;
|
||||
out.push(ScheduleLine {
|
||||
period: format!("{year:04}-{month:02}"),
|
||||
amount,
|
||||
accumulated: accumulated_rounded,
|
||||
book_value: asset.cost.amount - accumulated_rounded,
|
||||
});
|
||||
}
|
||||
if month == 12 {
|
||||
year += 1;
|
||||
month = 1;
|
||||
} else {
|
||||
month += 1;
|
||||
}
|
||||
}
|
||||
// True-up: if the schedule reaches the final month of the asset's life,
|
||||
// absorb any month-length drift so the lifetime total is exactly
|
||||
// (cost − salvage). The drift is bounded by a fraction of one month's
|
||||
// charge, so the final month stays positive.
|
||||
if let Some(last) = out.last_mut()
|
||||
&& let Some(day_before_end) = end.pred_opt()
|
||||
&& last.period == day_before_end.format("%Y-%m").to_string()
|
||||
{
|
||||
let plug = depreciable - accumulated_rounded;
|
||||
last.amount += plug;
|
||||
last.accumulated = depreciable;
|
||||
last.book_value = asset.cost.amount - depreciable;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Accumulated depreciation through `as_of` (inclusive), exact cents.
|
||||
pub fn accumulated_depreciation(asset: &Asset, as_of: NaiveDate) -> Decimal {
|
||||
depreciation_schedule(asset, as_of)
|
||||
.last()
|
||||
.map(|l| l.accumulated)
|
||||
.unwrap_or(Decimal::ZERO)
|
||||
}
|
||||
|
||||
/// Book value (cost − accumulated depreciation) as of a date.
|
||||
pub fn book_value(asset: &Asset, as_of: NaiveDate) -> Decimal {
|
||||
asset.cost.amount - accumulated_depreciation(asset, as_of)
|
||||
}
|
||||
|
||||
/// The amounts for a disposal entry: what to debit/credit where.
|
||||
/// Gain (proceeds > book value) is positive; loss is negative.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct DisposalAmounts {
|
||||
/// Book value at disposal.
|
||||
pub book_value: Decimal,
|
||||
/// Accumulated depreciation at disposal.
|
||||
pub accumulated: Decimal,
|
||||
/// proceeds − book_value. Positive is a gain.
|
||||
pub gain_loss: Decimal,
|
||||
}
|
||||
|
||||
/// Compute disposal amounts. Pure math; posting is the caller's job.
|
||||
pub fn disposal_amounts(
|
||||
asset: &Asset,
|
||||
date: NaiveDate,
|
||||
proceeds: Money,
|
||||
) -> Result<DisposalAmounts, LedgerError> {
|
||||
if asset.status != AssetStatus::Active {
|
||||
let id = asset.id;
|
||||
return Err(LedgerError::AssetAlreadyDisposed(id));
|
||||
}
|
||||
if proceeds.amount < Decimal::ZERO {
|
||||
return Err(LedgerError::NegativeProceeds(proceeds.amount));
|
||||
}
|
||||
if date < asset.purchase_date {
|
||||
return Err(LedgerError::DisposalBeforePurchase(date.to_string()));
|
||||
}
|
||||
let accumulated = accumulated_depreciation(asset, date);
|
||||
let book = asset.cost.amount - accumulated;
|
||||
Ok(DisposalAmounts {
|
||||
book_value: book,
|
||||
accumulated,
|
||||
gain_loss: proceeds.amount - book,
|
||||
})
|
||||
}
|
||||
|
||||
/// Validate asset account links against the chart: cost and accumulated
|
||||
/// depreciation must be asset accounts, the expense account an expense
|
||||
/// account, and all three distinct.
|
||||
pub(crate) fn validate_asset_accounts(
|
||||
chart: &ChartOfAccounts,
|
||||
cost_account: AccountId,
|
||||
accum_depr_account: AccountId,
|
||||
expense_account: AccountId,
|
||||
) -> Result<(), LedgerError> {
|
||||
for (id, role, expected) in [
|
||||
(cost_account, "cost", AccountType::Asset),
|
||||
(
|
||||
accum_depr_account,
|
||||
"accumulated depreciation",
|
||||
AccountType::Asset,
|
||||
),
|
||||
(
|
||||
expense_account,
|
||||
"depreciation expense",
|
||||
AccountType::Expense,
|
||||
),
|
||||
] {
|
||||
let account = chart.get(id).ok_or(LedgerError::UnknownAccount(id))?;
|
||||
if account.account_type != expected {
|
||||
return Err(LedgerError::AssetAccountWrongType {
|
||||
code: account.code.clone(),
|
||||
role,
|
||||
expected: match expected {
|
||||
AccountType::Asset => "asset",
|
||||
AccountType::Expense => "expense",
|
||||
_ => unreachable!("asset links are asset or expense accounts"),
|
||||
},
|
||||
found: match account.account_type {
|
||||
AccountType::Asset => "asset",
|
||||
AccountType::Liability => "liability",
|
||||
AccountType::Equity => "equity",
|
||||
AccountType::Revenue => "revenue",
|
||||
AccountType::Expense => "expense",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
for dup in [cost_account, accum_depr_account, expense_account] {
|
||||
let uses = [cost_account, accum_depr_account, expense_account]
|
||||
.iter()
|
||||
.filter(|&&id| id == dup)
|
||||
.count();
|
||||
if uses > 1 {
|
||||
return Err(LedgerError::DuplicateAssetAccount(dup));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
//! Classes: divisional P&L tracking.
|
||||
//!
|
||||
//! A class is a label on a journal line — a department, location, product
|
||||
//! line, or job. Reports can filter the P&L to one class or break it down
|
||||
//! by class, so one ledger serves several divisions without splitting the
|
||||
//! chart of accounts. Classes live on lines, not accounts: one entry can
|
||||
//! touch several divisions.
|
||||
//!
|
||||
//! Classes are master data, journaled like accounts. Tagging a line happens
|
||||
//! on drafts only; posted entries are immutable, so their classes are frozen.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Opaque class handle. Created by [`crate::Ledger`]; never forged.
|
||||
pub type ClassId = u32;
|
||||
|
||||
/// A named division for line-level P&L tracking.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Class {
|
||||
/// Class id (the allocator's).
|
||||
pub id: ClassId,
|
||||
/// Human name, e.g. `"Retail"` or `"Wholesale"`.
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{AccountType, Currency, Ledger, LedgerError, Money, Side};
|
||||
use chrono::NaiveDate;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
fn d(cents: i64) -> Decimal {
|
||||
Decimal::new(cents, 2)
|
||||
}
|
||||
|
||||
fn test_ledger() -> Ledger {
|
||||
let mut ledger = Ledger::new(Currency::USD);
|
||||
ledger
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
ledger
|
||||
.add_account("4000", "Revenue", AccountType::Revenue, None, false)
|
||||
.unwrap();
|
||||
ledger
|
||||
.add_account("6000", "Expenses", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
ledger
|
||||
}
|
||||
|
||||
fn post_sale(
|
||||
ledger: &mut Ledger,
|
||||
date: NaiveDate,
|
||||
revenue: Decimal,
|
||||
expense: Decimal,
|
||||
class: Option<ClassId>,
|
||||
) {
|
||||
let cash = ledger.chart().find_by_code("1000").unwrap().id;
|
||||
let rev = ledger.chart().find_by_code("4000").unwrap().id;
|
||||
let exp = ledger.chart().find_by_code("6000").unwrap().id;
|
||||
let base = Currency::USD;
|
||||
let e = ledger.create_entry(date, "sale");
|
||||
ledger
|
||||
.add_line(e, cash, Side::Debit, Money::new(revenue, base))
|
||||
.unwrap();
|
||||
ledger
|
||||
.add_line(e, rev, Side::Credit, Money::new(revenue, base))
|
||||
.unwrap();
|
||||
if !expense.is_zero() {
|
||||
ledger
|
||||
.add_line(e, exp, Side::Debit, Money::new(expense, base))
|
||||
.unwrap();
|
||||
ledger
|
||||
.add_line(e, cash, Side::Credit, Money::new(expense, base))
|
||||
.unwrap();
|
||||
}
|
||||
if let Some(c) = class {
|
||||
// Tag the revenue and expense lines (indices 1 and 2).
|
||||
ledger.set_line_class(e, 1, Some(c)).unwrap();
|
||||
if !expense.is_zero() {
|
||||
ledger.set_line_class(e, 2, Some(c)).unwrap();
|
||||
}
|
||||
}
|
||||
ledger.post_entry(e).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_crud() {
|
||||
let mut ledger = test_ledger();
|
||||
assert_eq!(ledger.next_class_id(), 1);
|
||||
let retail = ledger.add_class("Retail").unwrap();
|
||||
let wholesale = ledger.add_class("Wholesale").unwrap();
|
||||
assert_eq!((retail, wholesale), (1, 2));
|
||||
assert_eq!(ledger.next_class_id(), 3);
|
||||
assert_eq!(ledger.get_class(retail).unwrap().name, "Retail");
|
||||
assert!(ledger.get_class(99).is_none());
|
||||
let names: Vec<_> = ledger
|
||||
.list_classes()
|
||||
.iter()
|
||||
.map(|c| c.name.clone())
|
||||
.collect();
|
||||
assert_eq!(names, vec!["Retail", "Wholesale"]);
|
||||
// Case-insensitive lookup.
|
||||
assert_eq!(ledger.find_class_by_name("retail").unwrap().id, retail);
|
||||
assert_eq!(ledger.find_class_by_name("RETAIL").unwrap().id, retail);
|
||||
assert!(ledger.find_class_by_name("nope").is_none());
|
||||
// Empty and whitespace names rejected.
|
||||
assert_eq!(ledger.add_class(""), Err(LedgerError::EmptyClassName));
|
||||
assert_eq!(ledger.add_class(" "), Err(LedgerError::EmptyClassName));
|
||||
// The failed adds did not move the allocator.
|
||||
assert_eq!(ledger.next_class_id(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_line_class_draft_only() {
|
||||
let mut ledger = test_ledger();
|
||||
let class = ledger.add_class("Retail").unwrap();
|
||||
let cash = ledger.chart().find_by_code("1000").unwrap().id;
|
||||
let rev = ledger.chart().find_by_code("4000").unwrap().id;
|
||||
let date = NaiveDate::from_ymd_opt(2026, 10, 1).unwrap();
|
||||
|
||||
let e = ledger.create_entry(date, "draft");
|
||||
ledger
|
||||
.add_line(e, cash, Side::Debit, Money::new(d(10_000), Currency::USD))
|
||||
.unwrap();
|
||||
ledger
|
||||
.add_line(e, rev, Side::Credit, Money::new(d(10_000), Currency::USD))
|
||||
.unwrap();
|
||||
// Unknown class rejected.
|
||||
assert_eq!(
|
||||
ledger.set_line_class(e, 0, Some(99)),
|
||||
Err(LedgerError::UnknownClass(99))
|
||||
);
|
||||
// Line index out of range rejected.
|
||||
assert_eq!(
|
||||
ledger.set_line_class(e, 7, Some(class)),
|
||||
Err(LedgerError::LineIndexOutOfRange(7, e))
|
||||
);
|
||||
// Set, then clear.
|
||||
ledger.set_line_class(e, 0, Some(class)).unwrap();
|
||||
assert_eq!(
|
||||
ledger.get_entry(e).unwrap().lines()[0].class_id,
|
||||
Some(class)
|
||||
);
|
||||
ledger.set_line_class(e, 0, None).unwrap();
|
||||
assert_eq!(ledger.get_entry(e).unwrap().lines()[0].class_id, None);
|
||||
// Post: classes freeze. Tagging a posted entry is rejected.
|
||||
ledger.post_entry(e).unwrap();
|
||||
assert_eq!(
|
||||
ledger.set_line_class(e, 0, Some(class)),
|
||||
Err(LedgerError::PostedEntryImmutable)
|
||||
);
|
||||
// Unknown entry rejected.
|
||||
assert_eq!(
|
||||
ledger.set_line_class(999, 0, Some(class)),
|
||||
Err(LedgerError::UnknownEntry(999))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtered_pl_math() {
|
||||
let mut ledger = test_ledger();
|
||||
let retail = ledger.add_class("Retail").unwrap();
|
||||
let wholesale = ledger.add_class("Wholesale").unwrap();
|
||||
let date = NaiveDate::from_ymd_opt(2026, 10, 1).unwrap();
|
||||
post_sale(&mut ledger, date, d(100_000), d(40_000), Some(retail));
|
||||
post_sale(&mut ledger, date, d(50_000), d(10_000), Some(wholesale));
|
||||
post_sale(&mut ledger, date, d(20_000), d(5_000), None);
|
||||
|
||||
let r = crate::reports::profit_and_loss_filtered(&ledger, Some(retail)).unwrap();
|
||||
assert_eq!(r.revenue, d(100_000));
|
||||
assert_eq!(r.expenses, d(40_000));
|
||||
assert_eq!(r.net_income(), d(60_000));
|
||||
let w = crate::reports::profit_and_loss_filtered(&ledger, Some(wholesale)).unwrap();
|
||||
assert_eq!(w.revenue, d(50_000));
|
||||
assert_eq!(w.expenses, d(10_000));
|
||||
// A class with no lines reports zeros.
|
||||
let empty = ledger.add_class("Empty").unwrap();
|
||||
let z = crate::reports::profit_and_loss_filtered(&ledger, Some(empty)).unwrap();
|
||||
assert_eq!((z.revenue, z.expenses), (Decimal::ZERO, Decimal::ZERO));
|
||||
// None counts every line: identical to the account-based P&L.
|
||||
let all = crate::reports::profit_and_loss_filtered(&ledger, None).unwrap();
|
||||
let plain = crate::reports::profit_and_loss(&ledger).unwrap();
|
||||
assert_eq!((all.revenue, all.expenses), (plain.revenue, plain.expenses));
|
||||
assert_eq!((all.revenue, all.expenses), (d(170_000), d(55_000)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn by_class_partitions_total_pl() {
|
||||
let mut ledger = test_ledger();
|
||||
let retail = ledger.add_class("Retail").unwrap();
|
||||
let wholesale = ledger.add_class("Wholesale").unwrap();
|
||||
let date = NaiveDate::from_ymd_opt(2026, 10, 1).unwrap();
|
||||
post_sale(&mut ledger, date, d(100_000), d(40_000), Some(retail));
|
||||
post_sale(&mut ledger, date, d(50_000), d(10_000), Some(wholesale));
|
||||
post_sale(&mut ledger, date, d(20_000), d(5_000), None);
|
||||
|
||||
let rows = crate::reports::profit_and_loss_by_class(&ledger).unwrap();
|
||||
assert_eq!(rows.len(), 3);
|
||||
// Sorted by class name; unclassified last.
|
||||
assert_eq!(rows[0].class.as_ref().unwrap().name, "Retail");
|
||||
assert_eq!(rows[1].class.as_ref().unwrap().name, "Wholesale");
|
||||
assert!(rows[2].class.is_none());
|
||||
assert_eq!(rows[0].profit_and_loss.net_income(), d(60_000));
|
||||
assert_eq!(rows[1].profit_and_loss.net_income(), d(40_000));
|
||||
assert_eq!(rows[2].profit_and_loss.net_income(), d(15_000));
|
||||
// The rows partition the ledger: they sum to the total P&L.
|
||||
let total = crate::reports::profit_and_loss(&ledger).unwrap();
|
||||
let rev: Decimal = rows.iter().map(|r| r.profit_and_loss.revenue).sum();
|
||||
let exp: Decimal = rows.iter().map(|r| r.profit_and_loss.expenses).sum();
|
||||
assert_eq!((rev, exp), (total.revenue, total.expenses));
|
||||
|
||||
// A class with only balance-sheet lines gets no row.
|
||||
let cash_only = ledger.add_class("CashOnly").unwrap();
|
||||
let cash = ledger.chart().find_by_code("1000").unwrap().id;
|
||||
let e = ledger.create_entry(date, "cash move");
|
||||
ledger
|
||||
.add_line(e, cash, Side::Debit, Money::new(d(1_000), Currency::USD))
|
||||
.unwrap();
|
||||
// Need a second cash-ish line to balance; use revenue... no — use a
|
||||
// second asset account so no P&L line is tagged.
|
||||
let bank = ledger
|
||||
.add_account("1010", "Bank", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
ledger
|
||||
.add_line(e, bank, Side::Credit, Money::new(d(1_000), Currency::USD))
|
||||
.unwrap();
|
||||
ledger.set_line_class(e, 0, Some(cash_only)).unwrap();
|
||||
ledger.set_line_class(e, 1, Some(cash_only)).unwrap();
|
||||
ledger.post_entry(e).unwrap();
|
||||
let rows = crate::reports::profit_and_loss_by_class(&ledger).unwrap();
|
||||
assert_eq!(rows.len(), 3, "balance-sheet-only class must not add a row");
|
||||
// Empty ledger: no rows at all.
|
||||
let empty = test_ledger();
|
||||
assert!(
|
||||
crate::reports::profit_and_loss_by_class(&empty)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_class_checks_id() {
|
||||
let mut ledger = test_ledger();
|
||||
let err = ledger.replay_class(5, "Retail".to_string());
|
||||
assert_eq!(err, Err(LedgerError::ReplayIdMismatch(5, 1)));
|
||||
assert_eq!(ledger.next_class_id(), 1);
|
||||
ledger.replay_class(1, "Retail".to_string()).unwrap();
|
||||
assert_eq!(ledger.next_class_id(), 2);
|
||||
assert_eq!(ledger.get_class(1).unwrap().name, "Retail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reversal_carries_class() {
|
||||
let mut ledger = test_ledger();
|
||||
let retail = ledger.add_class("Retail").unwrap();
|
||||
let date = NaiveDate::from_ymd_opt(2026, 10, 1).unwrap();
|
||||
post_sale(&mut ledger, date, d(100_000), Decimal::ZERO, Some(retail));
|
||||
let entry_id = ledger.posted_entries().map(|e| e.id()).next().unwrap();
|
||||
let rev = ledger.reverse_entry(entry_id, date, "reversal").unwrap();
|
||||
let rev_entry = ledger.get_entry(rev).unwrap();
|
||||
// The flipped revenue line keeps its class; the cash lines were
|
||||
// never tagged.
|
||||
assert_eq!(rev_entry.lines()[0].class_id, None);
|
||||
assert_eq!(rev_entry.lines()[1].class_id, Some(retail));
|
||||
// The class P&L nets to zero after the reversal.
|
||||
let pl = crate::reports::profit_and_loss_filtered(&ledger, Some(retail)).unwrap();
|
||||
assert_eq!((pl.revenue, pl.expenses), (Decimal::ZERO, Decimal::ZERO));
|
||||
}
|
||||
}
|
||||
@@ -75,4 +75,201 @@ pub enum LedgerError {
|
||||
/// Any attempt to mutate a posted entry: add lines, delete it, edit it.
|
||||
#[error("cannot modify a posted entry; post a reversing entry instead")]
|
||||
PostedEntryImmutable,
|
||||
|
||||
/// The referenced fixed asset does not exist.
|
||||
#[error("unknown asset id {0}")]
|
||||
UnknownAsset(u64),
|
||||
|
||||
/// Asset descriptions must not be empty.
|
||||
#[error("asset description must not be empty")]
|
||||
EmptyAssetDescription,
|
||||
|
||||
/// Asset cost must be positive.
|
||||
#[error("asset cost must be positive, got {0}")]
|
||||
InvalidAssetCost(Decimal),
|
||||
|
||||
/// Salvage value must be non-negative and less than cost.
|
||||
#[error("salvage value must be between zero and cost, got {0}")]
|
||||
InvalidSalvageValue(Decimal),
|
||||
|
||||
/// Useful life must be at least one month.
|
||||
#[error("useful life must be at least one month, got {0}")]
|
||||
InvalidUsefulLife(u32),
|
||||
|
||||
/// The asset was already disposed.
|
||||
#[error("asset {0} is already disposed")]
|
||||
AssetAlreadyDisposed(u64),
|
||||
|
||||
/// Asset account links must have the right types: cost and accumulated
|
||||
/// depreciation are asset accounts, the depreciation expense account is
|
||||
/// an expense account.
|
||||
#[error("account '{code}' has type {found}, but the asset {role} account must be {expected}")]
|
||||
AssetAccountWrongType {
|
||||
/// Account code.
|
||||
code: String,
|
||||
/// Role the account was given: "cost", "accumulated depreciation", or
|
||||
/// "depreciation expense".
|
||||
role: &'static str,
|
||||
/// The type it must be.
|
||||
expected: &'static str,
|
||||
/// The type it has.
|
||||
found: &'static str,
|
||||
},
|
||||
|
||||
/// Disposal proceeds must not be negative.
|
||||
#[error("disposal proceeds must not be negative, got {0}")]
|
||||
NegativeProceeds(Decimal),
|
||||
|
||||
/// Disposal date must not precede the purchase date.
|
||||
#[error("disposal date {0} is before the purchase date")]
|
||||
DisposalBeforePurchase(String),
|
||||
|
||||
/// The three asset account links must be three distinct accounts.
|
||||
#[error("asset account links must be distinct; account id {0} is linked twice")]
|
||||
DuplicateAssetAccount(u64),
|
||||
|
||||
/// purchase_date + useful life overflows the date range.
|
||||
#[error("purchase date plus useful life overflows the calendar")]
|
||||
AssetDateOverflow,
|
||||
|
||||
/// An amortization schedule or recurring entry is invalid, unknown, or
|
||||
/// already posted for that date.
|
||||
#[error("invalid schedule: {0}")]
|
||||
InvalidSchedule(String),
|
||||
|
||||
/// A payout doesn't reconcile or uses the wrong accounts.
|
||||
#[error("invalid payout: {0}")]
|
||||
InvalidPayout(String),
|
||||
|
||||
/// A tax category doesn't exist, doesn't fit the account, or has a bad
|
||||
/// deductible percent.
|
||||
#[error("invalid tax category: {0}")]
|
||||
InvalidTaxCategory(String),
|
||||
|
||||
/// Partnership members are misconfigured (shares, capital accounts).
|
||||
#[error("invalid members: {0}")]
|
||||
InvalidMembers(String),
|
||||
|
||||
/// Cash-flow period end is before period start.
|
||||
#[error("invalid date range: end {end} is before start {start}")]
|
||||
InvalidDateRange {
|
||||
/// Period start, YYYY-MM-DD.
|
||||
start: String,
|
||||
/// Period end, YYYY-MM-DD.
|
||||
end: String,
|
||||
},
|
||||
|
||||
/// The referenced accounting period does not exist.
|
||||
#[error("unknown period id {0}")]
|
||||
UnknownPeriod(u64),
|
||||
|
||||
/// The accounting period is already closed.
|
||||
#[error("period {0} is already closed")]
|
||||
PeriodAlreadyClosed(u64),
|
||||
|
||||
/// Period end is before period start.
|
||||
#[error("invalid period: end {end} is before start {start}")]
|
||||
InvalidPeriodRange {
|
||||
/// Period start, YYYY-MM-DD.
|
||||
start: String,
|
||||
/// Period end, YYYY-MM-DD.
|
||||
end: String,
|
||||
},
|
||||
|
||||
/// A draft entry exists with a date inside the period being closed.
|
||||
#[error("cannot close period {0}: draft entries exist with dates in the period")]
|
||||
DraftsInPeriod(u64),
|
||||
|
||||
/// A period can't close while a later period is already closed.
|
||||
#[error("period {0} can't close: later period {1} is already closed (close in order)")]
|
||||
CloseOutOfOrder(u64, u64),
|
||||
|
||||
/// Entries cannot be created or posted in a closed period.
|
||||
#[error("period {0} is closed; entries dated {1} are rejected")]
|
||||
ClosedPeriod(u64, String),
|
||||
|
||||
/// The referenced customer does not exist.
|
||||
#[error("unknown customer id {0}")]
|
||||
UnknownCustomer(u64),
|
||||
|
||||
/// The referenced vendor does not exist.
|
||||
#[error("unknown vendor id {0}")]
|
||||
UnknownVendor(u64),
|
||||
|
||||
/// The referenced invoice does not exist.
|
||||
#[error("unknown invoice id {0}")]
|
||||
UnknownInvoice(u64),
|
||||
|
||||
/// The referenced bill does not exist.
|
||||
#[error("unknown bill id {0}")]
|
||||
UnknownBill(u64),
|
||||
|
||||
/// Invoice/bill must have at least one line.
|
||||
#[error("invoice/bill must have at least one line")]
|
||||
EmptyDocumentLines,
|
||||
|
||||
/// Invoice/bill line amounts must be positive.
|
||||
#[error("document line amount must be positive, got {0}")]
|
||||
InvalidDocumentLineAmount(Decimal),
|
||||
|
||||
/// Payment amount must be positive.
|
||||
#[error("payment amount must be positive, got {0}")]
|
||||
InvalidPaymentAmount(Decimal),
|
||||
|
||||
/// Payment exceeds the remaining balance.
|
||||
#[error("payment of {0} exceeds remaining balance of {1}")]
|
||||
Overpayment(Decimal, Decimal),
|
||||
|
||||
/// Invoice/bill due date is before its date.
|
||||
#[error("due date {0} is before document date")]
|
||||
DueBeforeDate(String),
|
||||
|
||||
/// Line index out of range for cleared-flag update.
|
||||
#[error("line index {0} out of range for entry {1}")]
|
||||
LineIndexOutOfRange(usize, u64),
|
||||
|
||||
/// Cannot set a cleared flag on a draft entry's line.
|
||||
#[error("cannot mark cleared on draft entry {0}; only posted lines can clear")]
|
||||
ClearedOnDraft(u64),
|
||||
|
||||
/// FX rate must be positive.
|
||||
#[error("fx rate must be positive, got {0}")]
|
||||
InvalidFxRate(Decimal),
|
||||
|
||||
/// FX line requires both foreign currency and foreign amount.
|
||||
#[error("fx line requires foreign currency, foreign amount, and fx rate")]
|
||||
IncompleteFxLine,
|
||||
|
||||
/// Replay recorded an id the allocator was not expecting: the journal
|
||||
/// and the engine disagree about event ordering.
|
||||
#[error("replay id mismatch: journal says {0}, allocator expects {1}")]
|
||||
ReplayIdMismatch(u64, u64),
|
||||
|
||||
/// A payroll run must have at least one employee line.
|
||||
#[error("payroll run must have at least one line")]
|
||||
EmptyPayroll,
|
||||
|
||||
/// Payroll amounts must not be negative.
|
||||
#[error("payroll amount must not be negative, got {0}")]
|
||||
NegativePayrollAmount(Decimal),
|
||||
|
||||
/// Net pay went negative: withholdings exceed gross for this employee.
|
||||
#[error("payroll net pay for '{0}' is negative")]
|
||||
NegativeNetPay(String),
|
||||
|
||||
/// No payroll tax payables are outstanding: nothing to remit.
|
||||
#[error("no payroll tax payables to remit")]
|
||||
NothingToRemit,
|
||||
|
||||
/// The referenced payroll run does not exist.
|
||||
#[error("unknown payroll run id {0}")]
|
||||
UnknownPayrollRun(u64),
|
||||
|
||||
/// Class names must not be empty.
|
||||
#[error("class name must not be empty")]
|
||||
EmptyClassName,
|
||||
|
||||
/// The referenced class does not exist.
|
||||
#[error("unknown class id {0}")]
|
||||
UnknownClass(u32),
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//! 1099 tracking: what was paid to each vendor in a calendar year.
|
||||
//!
|
||||
//! A vendor is marked with the information return it gets (1099-NEC for
|
||||
//! nonemployee compensation, 1099-MISC for rents and the like) and whether
|
||||
//! its W-9 is on file. Entries are tagged with the vendor they paid
|
||||
//! (`pay_bill` tags automatically). The amount paid is the cash that left
|
||||
//! asset accounts on the vendor's tagged entries: credits minus debits to
|
||||
//! asset accounts. A card payment (Dr expense, Cr credit card) moves no
|
||||
//! asset, so it is excluded, as it should be: card payments are reported by
|
||||
//! the card processor on 1099-K, not by the payer.
|
||||
//!
|
||||
//! Thresholds are set by law and change; [`default_threshold`] encodes
|
||||
//! them and the report takes an override. Confirm with the CPA.
|
||||
|
||||
use crate::{AccountType, Ledger, VendorId};
|
||||
use chrono::Datelike;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The information return a vendor gets.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Form1099 {
|
||||
/// 1099-NEC: nonemployee compensation (contractors, attorneys' fees).
|
||||
Nec,
|
||||
/// 1099-MISC: rents, royalties, prizes, other income.
|
||||
Misc,
|
||||
}
|
||||
|
||||
impl Form1099 {
|
||||
/// Parse "nec", "1099-NEC", "misc", "1099-MISC".
|
||||
pub fn parse(s: &str) -> Option<Form1099> {
|
||||
let s = s.trim().to_lowercase();
|
||||
match s.strip_prefix("1099-").unwrap_or(&s) {
|
||||
"nec" => Some(Form1099::Nec),
|
||||
"misc" => Some(Form1099::Misc),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// "1099-NEC" or "1099-MISC".
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Form1099::Nec => "1099-NEC",
|
||||
Form1099::Misc => "1099-MISC",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The reporting threshold for payments made in `year`: $600 through 2025;
|
||||
/// $2,000 from 2026 (One Big Beautiful Bill Act), indexed for inflation
|
||||
/// after 2026, so later years return $2,000 until updated. Royalties
|
||||
/// (1099-MISC) have a $10 threshold this doesn't model. Pass an explicit
|
||||
/// threshold to override.
|
||||
pub fn default_threshold(year: i32) -> Decimal {
|
||||
if year <= 2025 {
|
||||
Decimal::new(600, 0)
|
||||
} else {
|
||||
Decimal::new(2000, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// One vendor's year.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Vendor1099Line {
|
||||
/// The vendor.
|
||||
pub vendor_id: VendorId,
|
||||
/// Name.
|
||||
pub name: String,
|
||||
/// The return it gets, if marked.
|
||||
pub form: Option<Form1099>,
|
||||
/// W-9 on file.
|
||||
pub tin_on_file: bool,
|
||||
/// Cash paid in the year (net of refunds and reversals).
|
||||
pub paid: Decimal,
|
||||
/// Marked for a 1099 and paid at least the threshold.
|
||||
pub reportable: bool,
|
||||
}
|
||||
|
||||
/// The year's 1099 report.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Vendor1099Report {
|
||||
/// Calendar year.
|
||||
pub year: i32,
|
||||
/// The threshold applied.
|
||||
pub threshold: Decimal,
|
||||
/// Every vendor marked for a 1099 or paid in the year, by name.
|
||||
pub vendors: Vec<Vendor1099Line>,
|
||||
}
|
||||
|
||||
/// Cash paid per vendor in calendar `year`, from posted entries tagged
|
||||
/// with the vendor.
|
||||
pub fn vendor_1099_report(
|
||||
ledger: &Ledger,
|
||||
year: i32,
|
||||
threshold: Option<Decimal>,
|
||||
) -> Vendor1099Report {
|
||||
let threshold = threshold.unwrap_or_else(|| default_threshold(year));
|
||||
let mut paid: std::collections::HashMap<VendorId, Decimal> = Default::default();
|
||||
for entry in ledger.posted_entries() {
|
||||
if entry.date().year() != year {
|
||||
continue;
|
||||
}
|
||||
let Some(vendor) = ledger.entry_vendor(entry.id()) else {
|
||||
continue;
|
||||
};
|
||||
let cash: Decimal = entry
|
||||
.lines()
|
||||
.iter()
|
||||
.filter(|l| {
|
||||
ledger
|
||||
.chart()
|
||||
.get(l.account)
|
||||
.is_some_and(|a| a.account_type == AccountType::Asset)
|
||||
})
|
||||
.map(|l| match l.side {
|
||||
crate::Side::Credit => l.amount.amount,
|
||||
crate::Side::Debit => -l.amount.amount,
|
||||
})
|
||||
.sum();
|
||||
*paid.entry(vendor).or_default() += cash;
|
||||
}
|
||||
let mut vendors: Vec<Vendor1099Line> = ledger
|
||||
.list_vendors()
|
||||
.into_iter()
|
||||
.filter(|v| v.form_1099.is_some() || paid.get(&v.id).is_some_and(|p| !p.is_zero()))
|
||||
.map(|v| {
|
||||
let amount = paid.get(&v.id).copied().unwrap_or_default();
|
||||
Vendor1099Line {
|
||||
vendor_id: v.id,
|
||||
name: v.name.clone(),
|
||||
form: v.form_1099,
|
||||
tin_on_file: v.tin_on_file,
|
||||
paid: amount,
|
||||
reportable: v.form_1099.is_some() && amount >= threshold,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
vendors.sort_by(|a, b| a.name.cmp(&b.name).then(a.vendor_id.cmp(&b.vendor_id)));
|
||||
Vendor1099Report {
|
||||
year,
|
||||
threshold,
|
||||
vendors,
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
//! total credits. Entries are born as drafts, validated, then posted. Posting
|
||||
//! is one-way; a posted entry can only be corrected by a reversing entry.
|
||||
|
||||
use crate::{AccountId, ChartOfAccounts, Currency, LedgerError, Money, Side};
|
||||
use crate::{AccountId, ChartOfAccounts, ClassId, Currency, LedgerError, Money, Side};
|
||||
use chrono::NaiveDate;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -29,8 +29,24 @@ pub struct JournalLine {
|
||||
pub account: AccountId,
|
||||
/// Which side of the entry this line is on.
|
||||
pub side: Side,
|
||||
/// The exact amount. Never zero (rejected at validation).
|
||||
/// The exact amount, in the ledger's base currency. Never zero (rejected
|
||||
/// at validation).
|
||||
pub amount: Money,
|
||||
/// For foreign-currency lines: the original currency. `None` for base-
|
||||
/// currency lines. When set, `amount` is the base-currency equivalent
|
||||
/// converted at the line's rate.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub foreign_currency: Option<Currency>,
|
||||
/// For foreign-currency lines: the original foreign amount. `None` for
|
||||
/// base-currency lines.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub foreign_amount: Option<Decimal>,
|
||||
/// Divisional tracking: which class (department, location, job) this
|
||||
/// line belongs to. `None` means unclassified. Set on drafts only via
|
||||
/// [`crate::Ledger::set_line_class`]; posted lines are immutable, so
|
||||
/// their classes are frozen.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub class_id: Option<ClassId>,
|
||||
}
|
||||
|
||||
/// A balanced set of journal lines with a date and a description.
|
||||
@@ -93,6 +109,12 @@ impl JournalEntry {
|
||||
self.lines.push(line);
|
||||
}
|
||||
|
||||
/// Tag a line with a class (or clear it). Only while drafted; only via
|
||||
/// [`crate::Ledger::set_line_class`].
|
||||
pub(crate) fn set_line_class(&mut self, index: usize, class_id: Option<ClassId>) {
|
||||
self.lines[index].class_id = class_id;
|
||||
}
|
||||
|
||||
/// Mark posted. Only via [`crate::Ledger`], after [`Self::validate`].
|
||||
pub(crate) fn mark_posted(&mut self) {
|
||||
self.status = EntryStatus::Posted;
|
||||
|
||||
+1818
-24
File diff suppressed because it is too large.
Load diff
@@ -17,15 +17,49 @@
|
||||
//! (`docs/oracle.md`) possible.
|
||||
|
||||
pub mod account;
|
||||
pub mod analysis;
|
||||
pub mod arap;
|
||||
pub mod assets;
|
||||
pub mod classes;
|
||||
pub mod error;
|
||||
pub mod form1099;
|
||||
pub mod journal;
|
||||
pub mod ledger;
|
||||
pub mod money;
|
||||
pub mod payouts;
|
||||
pub mod payroll;
|
||||
pub mod periods;
|
||||
pub mod reports;
|
||||
pub mod schedules;
|
||||
pub mod tax;
|
||||
|
||||
pub use account::{Account, AccountId, AccountType, ChartOfAccounts, Side};
|
||||
pub use arap::{
|
||||
AgingRow, Bill, BillId, Customer, CustomerId, DocumentLine, DocumentStatus, Invoice, InvoiceId,
|
||||
Vendor, VendorId,
|
||||
};
|
||||
pub use assets::{
|
||||
Asset, AssetId, AssetStatus, DepreciationMethod, DisposalAmounts, NewAsset, ScheduleLine,
|
||||
accumulated_depreciation, book_value, depreciation_schedule, disposal_amounts,
|
||||
};
|
||||
pub use classes::{Class, ClassId};
|
||||
pub use error::LedgerError;
|
||||
pub use form1099::{Form1099, Vendor1099Line, Vendor1099Report, vendor_1099_report};
|
||||
pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
|
||||
pub use ledger::Ledger;
|
||||
pub use ledger::{Ledger, ReconciliationReport, UnclearedLine};
|
||||
pub use money::{Currency, Money};
|
||||
pub use reports::{BalanceSheet, ProfitAndLoss, TrialBalance, TrialBalanceLine};
|
||||
pub use payouts::{Payout, PayoutAdjustment, PayoutLine, PayoutTotals, payout_plan};
|
||||
pub use payroll::{PayrollLine, PayrollRun, PayrollRunId};
|
||||
pub use periods::{CloseTo, MemberClose, Period, PeriodId, PeriodStatus, RETAINED_EARNINGS_CODE};
|
||||
pub use reports::{
|
||||
BalanceSheet, BalanceSheetStatement, CashFlowLine, CashFlowParams, CashFlowStatement,
|
||||
ClassProfitAndLoss, GeneralLedger, LedgerAccount, LedgerPosting, MemberCapital,
|
||||
MemberEquityColumn, MembersEquityStatement, ProfitAndLoss, ProfitAndLossStatement,
|
||||
StatementLine, TrialBalance, TrialBalanceLine, balance_sheet_as_of, cash_flow, general_ledger,
|
||||
members_equity, profit_and_loss_for_period, trial_balance_as_of,
|
||||
};
|
||||
pub use schedules::{
|
||||
AmortizationSchedule, Frequency, NewRecurring, NewSchedule, RecurringEntry, RecurringId,
|
||||
ScheduleId, ScheduleKind, TemplateLine,
|
||||
};
|
||||
pub use tax::{TaxCategory, TaxForm, TaxKind, TaxSummary, TaxSummaryLine, tax_summary};
|
||||
@@ -15,15 +15,15 @@ pub struct Currency([u8; 3]);
|
||||
|
||||
impl Currency {
|
||||
/// US dollar.
|
||||
pub const USD: Currency = Currency([b'U', b'S', b'D']);
|
||||
pub const USD: Currency = Currency(*b"USD");
|
||||
/// Euro.
|
||||
pub const EUR: Currency = Currency([b'E', b'U', b'R']);
|
||||
pub const EUR: Currency = Currency(*b"EUR");
|
||||
/// British pound.
|
||||
pub const GBP: Currency = Currency([b'G', b'B', b'P']);
|
||||
pub const GBP: Currency = Currency(*b"GBP");
|
||||
/// Swiss franc.
|
||||
pub const CHF: Currency = Currency([b'C', b'H', b'F']);
|
||||
pub const CHF: Currency = Currency(*b"CHF");
|
||||
/// Japanese yen.
|
||||
pub const JPY: Currency = Currency([b'J', b'P', b'Y']);
|
||||
pub const JPY: Currency = Currency(*b"JPY");
|
||||
|
||||
/// Parse and validate a three-letter code. Input is uppercased, so
|
||||
/// `"usd"` and `"USD"` both work; `"US"` or `"USDD"` fail.
|
||||
@@ -123,3 +123,40 @@ impl fmt::Display for Money {
|
||||
write!(f, "{} {}", self.amount, self.currency)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn usd(amount: Decimal) -> Money {
|
||||
Money::new(amount, Currency::USD)
|
||||
}
|
||||
|
||||
fn d(cents: i64) -> Decimal {
|
||||
Decimal::new(cents, 2)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_arithmetic() {
|
||||
assert_eq!(
|
||||
usd(d(1050)).checked_add(usd(d(425))).unwrap().amount,
|
||||
d(1475)
|
||||
);
|
||||
assert_eq!(
|
||||
usd(d(1050)).checked_sub(usd(d(425))).unwrap().amount,
|
||||
d(625)
|
||||
);
|
||||
// Subtraction is not division, and not addition.
|
||||
assert_ne!(
|
||||
usd(d(1000)).checked_sub(usd(d(200))).unwrap().amount,
|
||||
d(500)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_arithmetic_rejects_currency_mismatch() {
|
||||
let eur = Money::new(d(100), Currency::EUR);
|
||||
assert!(usd(d(100)).checked_add(eur).is_err());
|
||||
assert!(usd(d(100)).checked_sub(eur).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//! Payouts: a platform deposit grossed up into what really happened.
|
||||
//!
|
||||
//! App stores, payment processors, and marketplaces collect revenue, keep
|
||||
//! their fees, and deposit the difference. Booking only the deposit as
|
||||
//! revenue understates income and hides the fees; the correct entry grosses
|
||||
//! it up:
|
||||
//!
|
||||
//! ```text
|
||||
//! Dr bank net deposit
|
||||
//! Dr platform fees fees withheld
|
||||
//! Dr/Cr adjustments refunds, chargebacks, reserves, tax withheld, ...
|
||||
//! Cr revenue gross sales (one line per product or class)
|
||||
//! ```
|
||||
//!
|
||||
//! [`payout_plan`] builds those lines and refuses unless gross − fees ±
|
||||
//! adjustments equals the deposit exactly, naming the difference. It knows
|
||||
//! no platform: callers (or a format-specific parser) supply the numbers
|
||||
//! from the payout statement, which is the entry's receipt.
|
||||
|
||||
use crate::{AccountId, AccountType, ChartOfAccounts, ClassId, LedgerError, Side};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
/// A revenue or fee line of a payout.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PayoutLine {
|
||||
/// The account.
|
||||
pub account: AccountId,
|
||||
/// Positive amount.
|
||||
pub amount: Decimal,
|
||||
/// Optional class (product line, app, store).
|
||||
pub class: Option<ClassId>,
|
||||
}
|
||||
|
||||
/// Anything else on the statement: refunds, chargebacks, reserves held or
|
||||
/// released, taxes withheld, currency conversion. A debit reduces the
|
||||
/// deposit; a credit increases it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PayoutAdjustment {
|
||||
/// The account.
|
||||
pub account: AccountId,
|
||||
/// Debit (reduces the deposit) or credit (increases it).
|
||||
pub side: Side,
|
||||
/// Positive amount.
|
||||
pub amount: Decimal,
|
||||
/// Optional class.
|
||||
pub class: Option<ClassId>,
|
||||
}
|
||||
|
||||
/// A payout to gross up.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Payout {
|
||||
/// Where the money landed.
|
||||
pub deposit_account: AccountId,
|
||||
/// The net deposit. Negative when the platform debited the account
|
||||
/// (fees and refunds exceeded sales).
|
||||
pub deposit: Decimal,
|
||||
/// Gross revenue, credited.
|
||||
pub revenue: Vec<PayoutLine>,
|
||||
/// Fees withheld, debited.
|
||||
pub fees: Vec<PayoutLine>,
|
||||
/// Everything else.
|
||||
pub adjustments: Vec<PayoutAdjustment>,
|
||||
}
|
||||
|
||||
/// The payout's totals.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PayoutTotals {
|
||||
/// Sum of revenue lines.
|
||||
pub gross: Decimal,
|
||||
/// Sum of fee lines.
|
||||
pub fees: Decimal,
|
||||
/// Net effect of adjustments on the deposit (credits − debits).
|
||||
pub adjustments: Decimal,
|
||||
/// The deposit.
|
||||
pub deposit: Decimal,
|
||||
}
|
||||
|
||||
/// One planned line: account, side, amount, class.
|
||||
pub type PayoutPlanLine = (AccountId, Side, Decimal, Option<ClassId>);
|
||||
|
||||
/// Validate a payout and build its entry's lines: the deposit first, then
|
||||
/// fees, adjustments, and revenue. Errors unless every amount is positive,
|
||||
/// every account is postable and fits its role (revenue lines credit
|
||||
/// revenue or liability accounts, e.g. deferred revenue or sales tax
|
||||
/// collected; fees debit expense accounts), and the payout reconciles to
|
||||
/// the cent.
|
||||
pub fn payout_plan(
|
||||
payout: &Payout,
|
||||
chart: &ChartOfAccounts,
|
||||
) -> Result<(Vec<PayoutPlanLine>, PayoutTotals), LedgerError> {
|
||||
let bad = |m: String| Err(LedgerError::InvalidPayout(m));
|
||||
if payout.revenue.is_empty() {
|
||||
return bad("a payout needs at least one revenue line".into());
|
||||
}
|
||||
let check = |account: AccountId,
|
||||
amount: Decimal,
|
||||
role: &str,
|
||||
allowed: &[AccountType]|
|
||||
-> Result<(), LedgerError> {
|
||||
let a = chart
|
||||
.get(account)
|
||||
.ok_or(LedgerError::UnknownAccount(account))?;
|
||||
if a.placeholder {
|
||||
return Err(LedgerError::InvalidPayout(format!(
|
||||
"{role} account {} is a placeholder",
|
||||
a.code
|
||||
)));
|
||||
}
|
||||
if !allowed.is_empty() && !allowed.contains(&a.account_type) {
|
||||
return Err(LedgerError::InvalidPayout(format!(
|
||||
"{role} account {} is {:?}; expected {}",
|
||||
a.code,
|
||||
a.account_type,
|
||||
allowed
|
||||
.iter()
|
||||
.map(|t| format!("{t:?}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" or ")
|
||||
)));
|
||||
}
|
||||
if amount <= Decimal::ZERO {
|
||||
return Err(LedgerError::InvalidPayout(format!(
|
||||
"{role} amounts must be positive, got {amount} on {}",
|
||||
a.code
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
let deposit_account = chart
|
||||
.get(payout.deposit_account)
|
||||
.ok_or(LedgerError::UnknownAccount(payout.deposit_account))?;
|
||||
if deposit_account.placeholder || deposit_account.account_type != AccountType::Asset {
|
||||
return bad(format!(
|
||||
"deposit account {} must be a postable asset account",
|
||||
deposit_account.code
|
||||
));
|
||||
}
|
||||
for l in &payout.revenue {
|
||||
check(
|
||||
l.account,
|
||||
l.amount,
|
||||
"revenue",
|
||||
&[AccountType::Revenue, AccountType::Liability],
|
||||
)?;
|
||||
}
|
||||
for l in &payout.fees {
|
||||
check(l.account, l.amount, "fee", &[AccountType::Expense])?;
|
||||
}
|
||||
for a in &payout.adjustments {
|
||||
check(a.account, a.amount, "adjustment", &[])?;
|
||||
}
|
||||
let gross: Decimal = payout.revenue.iter().map(|l| l.amount).sum();
|
||||
let fees: Decimal = payout.fees.iter().map(|l| l.amount).sum();
|
||||
let adjustments: Decimal = payout
|
||||
.adjustments
|
||||
.iter()
|
||||
.map(|a| match a.side {
|
||||
Side::Credit => a.amount,
|
||||
Side::Debit => -a.amount,
|
||||
})
|
||||
.sum();
|
||||
let expected = gross - fees + adjustments;
|
||||
if expected != payout.deposit {
|
||||
return bad(format!(
|
||||
"payout doesn't reconcile: gross {gross} − fees {fees} + adjustments \
|
||||
{adjustments} = {expected}, but the deposit is {} (off by {})",
|
||||
payout.deposit,
|
||||
payout.deposit - expected
|
||||
));
|
||||
}
|
||||
let mut plan = Vec::new();
|
||||
if payout.deposit > Decimal::ZERO {
|
||||
plan.push((payout.deposit_account, Side::Debit, payout.deposit, None));
|
||||
} else if payout.deposit < Decimal::ZERO {
|
||||
plan.push((payout.deposit_account, Side::Credit, -payout.deposit, None));
|
||||
}
|
||||
plan.extend(
|
||||
payout
|
||||
.fees
|
||||
.iter()
|
||||
.map(|l| (l.account, Side::Debit, l.amount, l.class)),
|
||||
);
|
||||
plan.extend(
|
||||
payout
|
||||
.adjustments
|
||||
.iter()
|
||||
.map(|a| (a.account, a.side, a.amount, a.class)),
|
||||
);
|
||||
plan.extend(
|
||||
payout
|
||||
.revenue
|
||||
.iter()
|
||||
.map(|l| (l.account, Side::Credit, l.amount, l.class)),
|
||||
);
|
||||
Ok((
|
||||
plan,
|
||||
PayoutTotals {
|
||||
gross,
|
||||
fees,
|
||||
adjustments,
|
||||
deposit: payout.deposit,
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
//! Payroll: accounting-only payroll runs (no tax tables).
|
||||
//!
|
||||
//! A payroll run posts one balanced entry: gross wages hit Salary & Wage
|
||||
//! Expense, the employer share of FICA hits Payroll Tax Expense, employee
|
||||
//! withholdings and deductions accumulate in the 2110–2150 payable accounts,
|
||||
//! and net pay leaves cash. A later [`crate::Ledger::remit_payroll_taxes`]
|
||||
//! clears the payables when the tax payments go out.
|
||||
//!
|
||||
//! There is no tax law here: every withholding number is supplied by the
|
||||
//! caller. All amounts are exact [`rust_decimal::Decimal`], never floats.
|
||||
|
||||
use crate::{AccountId, Currency, EntryId, LedgerError, Money, Side};
|
||||
use chrono::NaiveDate;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Opaque payroll run handle. Created by [`crate::Ledger`]; never forged.
|
||||
pub type PayrollRunId = u64;
|
||||
|
||||
/// One employee's payroll line: gross pay and every withholding and
|
||||
/// deduction. All amounts must be non-negative; the engine rejects
|
||||
/// negatives, and rejects the line when withholdings exceed gross.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PayrollLine {
|
||||
/// Employee name, for the run record.
|
||||
pub employee_name: String,
|
||||
/// Gross pay for the period.
|
||||
pub gross_pay: Decimal,
|
||||
/// Federal income tax withheld.
|
||||
pub federal_withholding: Decimal,
|
||||
/// State income tax withheld.
|
||||
pub state_withholding: Decimal,
|
||||
/// Employee share of Social Security tax.
|
||||
pub ss_employee: Decimal,
|
||||
/// Employee share of Medicare tax.
|
||||
pub medicare_employee: Decimal,
|
||||
/// Employer share of Social Security tax.
|
||||
pub ss_employer: Decimal,
|
||||
/// Employer share of Medicare tax.
|
||||
pub medicare_employer: Decimal,
|
||||
/// Other deductions (retirement, insurance, garnishments, ...).
|
||||
pub other_deductions: Decimal,
|
||||
}
|
||||
|
||||
impl PayrollLine {
|
||||
/// Gross minus all employee withholdings and deductions. Must be >= 0:
|
||||
/// you cannot withhold more than the employee earned.
|
||||
pub fn net_pay(&self) -> Decimal {
|
||||
self.gross_pay
|
||||
- self.federal_withholding
|
||||
- self.state_withholding
|
||||
- self.ss_employee
|
||||
- self.medicare_employee
|
||||
- self.other_deductions
|
||||
}
|
||||
}
|
||||
|
||||
/// A posted payroll run: the inputs plus the journal entry it posted.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PayrollRun {
|
||||
/// Run id (the allocator's).
|
||||
pub id: PayrollRunId,
|
||||
/// Pay date: the date of the posted entry.
|
||||
pub date: NaiveDate,
|
||||
/// One line per employee.
|
||||
pub lines: Vec<PayrollLine>,
|
||||
/// The posted journal entry.
|
||||
pub entry_id: EntryId,
|
||||
}
|
||||
|
||||
/// The chart accounts a payroll run posts to, resolved by code.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct PayrollAccounts {
|
||||
/// `6100` Salary & Wage Expense.
|
||||
pub salary_expense: AccountId,
|
||||
/// `6110` Payroll Tax Expense.
|
||||
pub payroll_tax_expense: AccountId,
|
||||
/// `2110` Federal Withholding Payable.
|
||||
pub federal_payable: AccountId,
|
||||
/// `2120` State Withholding Payable.
|
||||
pub state_payable: AccountId,
|
||||
/// `2130` SS Tax Payable.
|
||||
pub ss_payable: AccountId,
|
||||
/// `2140` Medicare Tax Payable.
|
||||
pub medicare_payable: AccountId,
|
||||
/// `2150` Other Deductions Payable.
|
||||
pub other_payable: AccountId,
|
||||
}
|
||||
|
||||
/// Validate a payroll run's lines without mutating: non-empty, no negative
|
||||
/// amounts, no negative net pay. Journaling stores run this before writing
|
||||
/// anything, so a doomed run never leaves journal lines behind.
|
||||
pub fn validate_payroll_lines(lines: &[PayrollLine]) -> Result<(), LedgerError> {
|
||||
if lines.is_empty() {
|
||||
return Err(LedgerError::EmptyPayroll);
|
||||
}
|
||||
for line in lines {
|
||||
for amount in [
|
||||
line.gross_pay,
|
||||
line.federal_withholding,
|
||||
line.state_withholding,
|
||||
line.ss_employee,
|
||||
line.medicare_employee,
|
||||
line.ss_employer,
|
||||
line.medicare_employer,
|
||||
line.other_deductions,
|
||||
] {
|
||||
if amount < Decimal::ZERO {
|
||||
return Err(LedgerError::NegativePayrollAmount(amount));
|
||||
}
|
||||
}
|
||||
if line.net_pay() < Decimal::ZERO {
|
||||
return Err(LedgerError::NegativeNetPay(line.employee_name.clone()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The auto-posted payroll entry's lines, in posting order: Dr 6100 gross,
|
||||
/// Dr 6110 the employer FICA share, Cr 2110–2150 the payables, Cr cash net
|
||||
/// pay. Zero totals are skipped — the engine rejects zero-amount lines.
|
||||
///
|
||||
/// Shared by [`crate::Ledger::run_payroll`] and the journaling store, so the
|
||||
/// journaled entry and the posted entry are built from the same math. The
|
||||
/// plan always balances: debits are gross + employer FICA, credits are the
|
||||
/// payables + net, and net is defined as gross minus employee withholdings.
|
||||
pub fn payroll_entry_plan(
|
||||
lines: &[PayrollLine],
|
||||
accounts: PayrollAccounts,
|
||||
cash_account: AccountId,
|
||||
base: Currency,
|
||||
) -> Vec<(AccountId, Side, Money)> {
|
||||
let sum = |f: fn(&PayrollLine) -> Decimal| lines.iter().map(f).sum::<Decimal>();
|
||||
let gross = sum(|l| l.gross_pay);
|
||||
let employer = sum(|l| l.ss_employer + l.medicare_employer);
|
||||
let federal = sum(|l| l.federal_withholding);
|
||||
let state = sum(|l| l.state_withholding);
|
||||
let ss = sum(|l| l.ss_employee + l.ss_employer);
|
||||
let medicare = sum(|l| l.medicare_employee + l.medicare_employer);
|
||||
let other = sum(|l| l.other_deductions);
|
||||
let net = sum(|l| l.net_pay());
|
||||
let mut plan = Vec::new();
|
||||
let mut push = |account: AccountId, side: Side, amount: Decimal| {
|
||||
if !amount.is_zero() {
|
||||
plan.push((account, side, Money::new(amount, base)));
|
||||
}
|
||||
};
|
||||
push(accounts.salary_expense, Side::Debit, gross);
|
||||
push(accounts.payroll_tax_expense, Side::Debit, employer);
|
||||
push(accounts.federal_payable, Side::Credit, federal);
|
||||
push(accounts.state_payable, Side::Credit, state);
|
||||
push(accounts.ss_payable, Side::Credit, ss);
|
||||
push(accounts.medicare_payable, Side::Credit, medicare);
|
||||
push(accounts.other_payable, Side::Credit, other);
|
||||
push(cash_account, Side::Credit, net);
|
||||
plan
|
||||
}
|
||||
|
||||
/// The remittance entry's lines: Dr each payable with a non-zero balance,
|
||||
/// Cr cash for the total. Balances are normal-side (positive = owed), so
|
||||
/// the plan balances by construction.
|
||||
pub fn payroll_remit_plan(
|
||||
payables: &[(AccountId, Decimal)],
|
||||
cash_account: AccountId,
|
||||
base: Currency,
|
||||
) -> Vec<(AccountId, Side, Money)> {
|
||||
let mut plan = Vec::new();
|
||||
let mut total = Decimal::ZERO;
|
||||
for (account, balance) in payables {
|
||||
if balance.is_zero() {
|
||||
continue;
|
||||
}
|
||||
plan.push((*account, Side::Debit, Money::new(*balance, base)));
|
||||
total += *balance;
|
||||
}
|
||||
if !total.is_zero() {
|
||||
plan.push((cash_account, Side::Credit, Money::new(total, base)));
|
||||
}
|
||||
plan
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{AccountType, Ledger};
|
||||
|
||||
fn d(cents: i64) -> Decimal {
|
||||
Decimal::new(cents, 2)
|
||||
}
|
||||
|
||||
fn line(name: &str) -> PayrollLine {
|
||||
PayrollLine {
|
||||
employee_name: name.to_string(),
|
||||
gross_pay: d(500_000), // 5000.00
|
||||
federal_withholding: d(75_000), // 750.00
|
||||
state_withholding: d(25_000), // 250.00
|
||||
ss_employee: d(31_000), // 310.00
|
||||
medicare_employee: d(7_250), // 72.50
|
||||
ss_employer: d(31_000),
|
||||
medicare_employer: d(7_250),
|
||||
other_deductions: d(10_000), // 100.00
|
||||
}
|
||||
}
|
||||
|
||||
fn ledger_with_cash() -> (Ledger, AccountId) {
|
||||
let mut ledger = Ledger::new(Currency::USD);
|
||||
let cash = ledger
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
(ledger, cash)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn net_pay_math() {
|
||||
let l = line("Ada");
|
||||
// 5000 - 750 - 250 - 310 - 72.50 - 100 = 3517.50
|
||||
assert_eq!(l.net_pay(), d(351_750));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_rejects_empty_negative_and_overwithheld() {
|
||||
assert_eq!(validate_payroll_lines(&[]), Err(LedgerError::EmptyPayroll));
|
||||
let mut bad = line("Bob");
|
||||
bad.ss_employee = d(-1);
|
||||
assert_eq!(
|
||||
validate_payroll_lines(std::slice::from_ref(&bad)),
|
||||
Err(LedgerError::NegativePayrollAmount(d(-1)))
|
||||
);
|
||||
let mut over = line("Cat");
|
||||
over.other_deductions = d(99_999_999);
|
||||
assert!(over.net_pay() < Decimal::ZERO);
|
||||
assert_eq!(
|
||||
validate_payroll_lines(std::slice::from_ref(&over)),
|
||||
Err(LedgerError::NegativeNetPay("Cat".to_string()))
|
||||
);
|
||||
assert!(validate_payroll_lines(&[line("Ada")]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_plan_balances_and_skips_zeros() {
|
||||
let cash = ledger_with_cash().1;
|
||||
let accounts = PayrollAccounts {
|
||||
salary_expense: 101,
|
||||
payroll_tax_expense: 102,
|
||||
federal_payable: 103,
|
||||
state_payable: 104,
|
||||
ss_payable: 105,
|
||||
medicare_payable: 106,
|
||||
other_payable: 107,
|
||||
};
|
||||
// No state withholding and no other deductions: those credits vanish.
|
||||
let mut l = line("Ada");
|
||||
l.state_withholding = Decimal::ZERO;
|
||||
l.other_deductions = Decimal::ZERO;
|
||||
let plan = payroll_entry_plan(&[l], accounts, cash, Currency::USD);
|
||||
assert_eq!(plan.len(), 6); // gross, employer tax, fed, ss, medicare, cash
|
||||
let mut debits = Decimal::ZERO;
|
||||
let mut credits = Decimal::ZERO;
|
||||
for (_, side, amount) in &plan {
|
||||
match side {
|
||||
Side::Debit => debits += amount.amount,
|
||||
Side::Credit => credits += amount.amount,
|
||||
}
|
||||
}
|
||||
assert_eq!(debits, credits);
|
||||
assert!(debits > Decimal::ZERO);
|
||||
for (_, _, amount) in &plan {
|
||||
assert!(!amount.amount.is_zero());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_payroll_posts_balanced_entry_and_records_run() {
|
||||
let (mut ledger, cash) = ledger_with_cash();
|
||||
let date = NaiveDate::from_ymd_opt(2026, 10, 9).unwrap();
|
||||
let id = ledger
|
||||
.run_payroll(date, vec![line("Ada"), line("Bob")], cash)
|
||||
.unwrap();
|
||||
assert_eq!(id, 1);
|
||||
assert_eq!(ledger.next_payroll_run_id(), 2);
|
||||
let run = ledger.get_payroll_run(id).unwrap();
|
||||
assert_eq!(run.date, date);
|
||||
assert_eq!(run.lines.len(), 2);
|
||||
let entry = ledger.get_entry(run.entry_id).unwrap();
|
||||
assert_eq!(entry.status(), crate::EntryStatus::Posted);
|
||||
// Debits equal credits, exactly.
|
||||
let mut debits = Decimal::ZERO;
|
||||
let mut credits = Decimal::ZERO;
|
||||
for l in entry.lines() {
|
||||
match l.side {
|
||||
Side::Debit => debits += l.amount.amount,
|
||||
Side::Credit => credits += l.amount.amount,
|
||||
}
|
||||
}
|
||||
assert_eq!(debits, credits);
|
||||
// Gross x2 = 10000 hits Salary & Wage Expense (debit-normal).
|
||||
let salary = ledger.chart().find_by_code("6100").unwrap().id;
|
||||
assert_eq!(ledger.account_balance(salary).unwrap(), d(1_000_000));
|
||||
// Net x2 = 7035 leaves cash (debit-normal): -7035.
|
||||
assert_eq!(ledger.account_balance(cash).unwrap(), -d(703_500));
|
||||
// The seven payroll accounts were auto-created.
|
||||
for code in ["6100", "6110", "2110", "2120", "2130", "2140", "2150"] {
|
||||
assert!(
|
||||
ledger.chart().find_by_code(code).is_some(),
|
||||
"missing {code}"
|
||||
);
|
||||
}
|
||||
// Runs list in id order.
|
||||
let id2 = ledger.run_payroll(date, vec![line("Cat")], cash).unwrap();
|
||||
let ids: Vec<_> = ledger.list_payroll_runs().iter().map(|r| r.id).collect();
|
||||
assert_eq!(ids, vec![id, id2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_payroll_rejects_closed_period() {
|
||||
let (mut ledger, cash) = ledger_with_cash();
|
||||
let date = NaiveDate::from_ymd_opt(2026, 9, 30).unwrap();
|
||||
let pid = ledger
|
||||
.create_period("Sep", NaiveDate::from_ymd_opt(2026, 9, 1).unwrap(), date)
|
||||
.unwrap();
|
||||
ledger.close_period(pid).unwrap();
|
||||
let err = ledger.run_payroll(date, vec![line("Ada")], cash);
|
||||
assert_eq!(err, Err(LedgerError::ClosedPeriod(pid, date.to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_payroll_rejects_bad_inputs() {
|
||||
let (mut ledger, cash) = ledger_with_cash();
|
||||
let date = NaiveDate::from_ymd_opt(2026, 10, 9).unwrap();
|
||||
assert_eq!(
|
||||
ledger.run_payroll(date, vec![], cash),
|
||||
Err(LedgerError::EmptyPayroll)
|
||||
);
|
||||
let mut bad = line("Ada");
|
||||
bad.gross_pay = d(-100);
|
||||
assert_eq!(
|
||||
ledger.run_payroll(date, vec![bad], cash),
|
||||
Err(LedgerError::NegativePayrollAmount(d(-100)))
|
||||
);
|
||||
assert_eq!(
|
||||
ledger.run_payroll(date, vec![line("Ada")], 999),
|
||||
Err(LedgerError::UnknownAccount(999))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remit_clears_payables() {
|
||||
let (mut ledger, cash) = ledger_with_cash();
|
||||
let date = NaiveDate::from_ymd_opt(2026, 10, 9).unwrap();
|
||||
ledger.run_payroll(date, vec![line("Ada")], cash).unwrap();
|
||||
// Withholdings owed: 750 + 250 + (310+310) + (72.50+72.50) + 100 = 1865.
|
||||
let fed = ledger.chart().find_by_code("2110").unwrap().id;
|
||||
assert_eq!(ledger.account_balance(fed).unwrap(), d(75_000));
|
||||
// Nothing owed before any payroll: fresh ledger remits nothing.
|
||||
let (mut fresh, fresh_cash) = ledger_with_cash();
|
||||
assert_eq!(
|
||||
fresh.remit_payroll_taxes(date, fresh_cash),
|
||||
Err(LedgerError::NothingToRemit)
|
||||
);
|
||||
// Remit: payables go to zero, cash drops by the total.
|
||||
let cash_before = ledger.account_balance(cash).unwrap();
|
||||
let entry = ledger.remit_payroll_taxes(date, cash).unwrap();
|
||||
let posted = ledger.get_entry(entry).unwrap();
|
||||
assert_eq!(posted.status(), crate::EntryStatus::Posted);
|
||||
for code in ["2110", "2120", "2130", "2140", "2150"] {
|
||||
let id = ledger.chart().find_by_code(code).unwrap().id;
|
||||
assert_eq!(
|
||||
ledger.account_balance(id).unwrap(),
|
||||
Decimal::ZERO,
|
||||
"payable {code} not cleared"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
ledger.account_balance(cash).unwrap(),
|
||||
cash_before - d(186_500)
|
||||
);
|
||||
// Second remit finds nothing left.
|
||||
assert_eq!(
|
||||
ledger.remit_payroll_taxes(date, cash),
|
||||
Err(LedgerError::NothingToRemit)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remit_rejects_closed_period() {
|
||||
let (mut ledger, cash) = ledger_with_cash();
|
||||
let date = NaiveDate::from_ymd_opt(2026, 9, 30).unwrap();
|
||||
ledger
|
||||
.run_payroll(
|
||||
NaiveDate::from_ymd_opt(2026, 9, 15).unwrap(),
|
||||
vec![line("Ada")],
|
||||
cash,
|
||||
)
|
||||
.unwrap();
|
||||
let pid = ledger
|
||||
.create_period("Sep", NaiveDate::from_ymd_opt(2026, 9, 1).unwrap(), date)
|
||||
.unwrap();
|
||||
ledger.close_period(pid).unwrap();
|
||||
assert_eq!(
|
||||
ledger.remit_payroll_taxes(date, cash),
|
||||
Err(LedgerError::ClosedPeriod(pid, date.to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_payroll_run_checks_id() {
|
||||
let (mut ledger, _cash) = ledger_with_cash();
|
||||
let date = NaiveDate::from_ymd_opt(2026, 10, 9).unwrap();
|
||||
let entry = ledger.create_entry(date, "x");
|
||||
let err = ledger.replay_payroll_run(7, date, vec![line("Ada")], entry);
|
||||
assert_eq!(err, Err(LedgerError::ReplayIdMismatch(7, 1)));
|
||||
// The allocator did not move on failure.
|
||||
assert_eq!(ledger.next_payroll_run_id(), 1);
|
||||
ledger
|
||||
.replay_payroll_run(1, date, vec![line("Ada")], entry)
|
||||
.unwrap();
|
||||
assert_eq!(ledger.next_payroll_run_id(), 2);
|
||||
assert!(ledger.get_payroll_run(1).is_some());
|
||||
assert!(ledger.get_payroll_run(2).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
//! Accounting periods and period close.
|
||||
//!
|
||||
//! A [`Period`] is a named date range (a month, a quarter, a year). Periods
|
||||
//! start open; closing one is a one-way operation that:
|
||||
//!
|
||||
//! 1. Rejects the close if any draft entries have dates inside the period.
|
||||
//! 2. Posts a closing entry dated at period end: revenue accounts are
|
||||
//! debited to zero, expense accounts are credited to zero, and the net
|
||||
//! difference lands in Retained Earnings (created as `3999` if missing).
|
||||
//! 3. Marks the period closed.
|
||||
//!
|
||||
//! Once closed, [`Ledger::create_entry`] and [`Ledger::post_entry`] reject
|
||||
//! any entry dated inside the period. Corrections to closed periods go
|
||||
//! through the *next* open period (or a reversal dated in an open period).
|
||||
|
||||
use crate::LedgerError;
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Opaque period handle. Created by [`crate::Ledger`]; never forged.
|
||||
pub type PeriodId = u64;
|
||||
|
||||
/// Lifecycle of an accounting period. `Open -> Closed` is one-way.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
|
||||
pub enum PeriodStatus {
|
||||
/// Accepting entries.
|
||||
Open,
|
||||
/// Closed. Entries dated inside are rejected.
|
||||
Closed,
|
||||
}
|
||||
|
||||
/// Where a period close puts the period's net income, and which owner
|
||||
/// accounts (draws, distributions, dividends) it zeroes into equity too.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum CloseTo {
|
||||
/// The Retained Earnings account ([`RETAINED_EARNINGS_CODE`], created if
|
||||
/// missing): the corporate convention. `sweep` lists dividend or
|
||||
/// distribution accounts to close into it.
|
||||
RetainedEarnings {
|
||||
/// Equity accounts zeroed into Retained Earnings.
|
||||
sweep: Vec<crate::AccountId>,
|
||||
},
|
||||
/// One equity account, e.g. a sole proprietor's capital, with `sweep`
|
||||
/// (e.g. the owner's draws) zeroed into it.
|
||||
Account {
|
||||
/// The equity account receiving the net.
|
||||
into: crate::AccountId,
|
||||
/// Equity accounts zeroed into it.
|
||||
sweep: Vec<crate::AccountId>,
|
||||
},
|
||||
/// Members' capital accounts by share (shares sum to 1): the
|
||||
/// partnership convention, where each member's capital account carries
|
||||
/// their allocated earnings and absorbs their distributions.
|
||||
Members(Vec<MemberClose>),
|
||||
}
|
||||
|
||||
/// One member in a partnership close.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MemberClose {
|
||||
/// The member's capital account.
|
||||
pub capital: crate::AccountId,
|
||||
/// Their share of net income.
|
||||
pub share: rust_decimal::Decimal,
|
||||
/// Their distributions account, zeroed into their capital, if they
|
||||
/// have one.
|
||||
pub distributions: Option<crate::AccountId>,
|
||||
}
|
||||
|
||||
/// Split `amount` by `shares` to the cent; the last share absorbs rounding,
|
||||
/// so the parts always sum to `amount` exactly.
|
||||
pub fn allocate(
|
||||
amount: rust_decimal::Decimal,
|
||||
shares: &[rust_decimal::Decimal],
|
||||
) -> Vec<rust_decimal::Decimal> {
|
||||
use rust_decimal::RoundingStrategy::MidpointAwayFromZero;
|
||||
let mut parts: Vec<_> = shares
|
||||
.iter()
|
||||
.map(|s| (amount * s).round_dp_with_strategy(2, MidpointAwayFromZero))
|
||||
.collect();
|
||||
if let Some((last, rest)) = parts.split_last_mut() {
|
||||
*last = amount - rest.iter().copied().sum::<rust_decimal::Decimal>();
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
/// Code of the Retained Earnings account that closing entries credit (or
|
||||
/// debit), created on the first close if the chart lacks it.
|
||||
pub const RETAINED_EARNINGS_CODE: &str = "3999";
|
||||
|
||||
/// A named accounting period: a month, quarter, or year.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Period {
|
||||
/// Opaque handle.
|
||||
pub id: PeriodId,
|
||||
/// Human name, e.g. `"2026-10"` or `"Q4 2026"`.
|
||||
pub name: String,
|
||||
/// First date in the period (inclusive).
|
||||
pub start: NaiveDate,
|
||||
/// Last date in the period (inclusive).
|
||||
pub end: NaiveDate,
|
||||
/// Open or closed.
|
||||
pub status: PeriodStatus,
|
||||
/// The closing entry's id, once closed.
|
||||
pub closing_entry: Option<u64>,
|
||||
}
|
||||
|
||||
impl Period {
|
||||
/// True when `date` falls inside the period (inclusive).
|
||||
pub fn contains(&self, date: NaiveDate) -> bool {
|
||||
date >= self.start && date <= self.end
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a period's date range before creation.
|
||||
pub fn validate_period_range(start: NaiveDate, end: NaiveDate) -> Result<(), LedgerError> {
|
||||
if end < start {
|
||||
return Err(LedgerError::InvalidPeriodRange {
|
||||
start: start.to_string(),
|
||||
end: end.to_string(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Currency, Ledger};
|
||||
|
||||
fn period_ledger() -> Ledger {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
l.add_account("4000", "Revenue", crate::AccountType::Revenue, None, false)
|
||||
.unwrap();
|
||||
l.add_account("6000", "Expenses", crate::AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
l.add_account("1000", "Cash", crate::AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
l
|
||||
}
|
||||
|
||||
fn post_sale(l: &mut Ledger, date: NaiveDate, amount: &str) {
|
||||
let e = l.create_entry(date, "sale");
|
||||
let rev = l.chart().find_by_code("4000").unwrap().id;
|
||||
let cash = l.chart().find_by_code("1000").unwrap().id;
|
||||
l.add_line(
|
||||
e,
|
||||
cash,
|
||||
crate::Side::Debit,
|
||||
crate::Money::new(amount.parse().unwrap(), Currency::USD),
|
||||
)
|
||||
.unwrap();
|
||||
l.add_line(
|
||||
e,
|
||||
rev,
|
||||
crate::Side::Credit,
|
||||
crate::Money::new(amount.parse().unwrap(), Currency::USD),
|
||||
)
|
||||
.unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_zeroes_revenue_and_expense_to_retained_earnings() {
|
||||
let mut l = period_ledger();
|
||||
let start = NaiveDate::from_ymd_opt(2026, 10, 1).unwrap();
|
||||
let end = NaiveDate::from_ymd_opt(2026, 10, 31).unwrap();
|
||||
let pid = l.create_period("2026-10", start, end).unwrap();
|
||||
|
||||
post_sale(
|
||||
&mut l,
|
||||
NaiveDate::from_ymd_opt(2026, 10, 15).unwrap(),
|
||||
"1000.00",
|
||||
);
|
||||
// An expense: debit expense, credit cash.
|
||||
let e = l.create_entry(NaiveDate::from_ymd_opt(2026, 10, 20).unwrap(), "rent");
|
||||
let exp = l.chart().find_by_code("6000").unwrap().id;
|
||||
let cash = l.chart().find_by_code("1000").unwrap().id;
|
||||
let m = crate::Money::new("400.00".parse().unwrap(), Currency::USD);
|
||||
l.add_line(e, exp, crate::Side::Debit, m).unwrap();
|
||||
l.add_line(e, cash, crate::Side::Credit, m).unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
|
||||
l.close_period(pid).unwrap();
|
||||
|
||||
// Revenue and expense are zeroed.
|
||||
let rev = l.chart().find_by_code("4000").unwrap().id;
|
||||
let exp = l.chart().find_by_code("6000").unwrap().id;
|
||||
assert_eq!(l.account_balance(rev).unwrap(), rust_decimal::Decimal::ZERO);
|
||||
assert_eq!(l.account_balance(exp).unwrap(), rust_decimal::Decimal::ZERO);
|
||||
// Retained earnings holds the net: 1000 - 400 = 600 (credit-normal).
|
||||
let re = l.chart().find_by_code(RETAINED_EARNINGS_CODE).unwrap().id;
|
||||
assert_eq!(
|
||||
l.account_balance(re).unwrap(),
|
||||
"600".parse::<rust_decimal::Decimal>().unwrap()
|
||||
);
|
||||
// Period is closed.
|
||||
assert_eq!(l.get_period(pid).unwrap().status, PeriodStatus::Closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_rejects_when_already_closed() {
|
||||
let mut l = period_ledger();
|
||||
let pid = l
|
||||
.create_period(
|
||||
"2026-10",
|
||||
NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(),
|
||||
NaiveDate::from_ymd_opt(2026, 10, 31).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
l.close_period(pid).unwrap();
|
||||
assert_eq!(
|
||||
l.close_period(pid).unwrap_err(),
|
||||
LedgerError::PeriodAlreadyClosed(pid)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_rejects_with_drafts_in_period() {
|
||||
let mut l = period_ledger();
|
||||
let pid = l
|
||||
.create_period(
|
||||
"2026-10",
|
||||
NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(),
|
||||
NaiveDate::from_ymd_opt(2026, 10, 31).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
// A draft dated inside the period blocks the close.
|
||||
l.create_entry(NaiveDate::from_ymd_opt(2026, 10, 15).unwrap(), "draft");
|
||||
assert_eq!(
|
||||
l.close_period(pid).unwrap_err(),
|
||||
LedgerError::DraftsInPeriod(pid)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_period_rejects_new_entries() {
|
||||
let mut l = period_ledger();
|
||||
let pid = l
|
||||
.create_period(
|
||||
"2026-10",
|
||||
NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(),
|
||||
NaiveDate::from_ymd_opt(2026, 10, 31).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
l.close_period(pid).unwrap();
|
||||
// create_entry with a date in the closed period fails.
|
||||
let err = l.try_create_entry(NaiveDate::from_ymd_opt(2026, 10, 15).unwrap(), "late");
|
||||
assert!(matches!(err, Err(LedgerError::ClosedPeriod(_, _))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_range_rejected() {
|
||||
let mut l = period_ledger();
|
||||
let err = l.create_period(
|
||||
"bad",
|
||||
NaiveDate::from_ymd_opt(2026, 10, 31).unwrap(),
|
||||
NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(),
|
||||
);
|
||||
assert!(matches!(err, Err(LedgerError::InvalidPeriodRange { .. })));
|
||||
}
|
||||
}
|
||||
+1073
-13
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,341 @@
|
||||
//! Things that happen on a schedule.
|
||||
//!
|
||||
//! Two kinds, both master data owned by [`crate::Ledger`] and journaled:
|
||||
//!
|
||||
//! - **Amortization schedules** spread a prepaid expense or deferred revenue
|
||||
//! evenly over the months it covers: a year of insurance paid up front is
|
||||
//! an asset expensed 1/12 a month; a year of subscription revenue
|
||||
//! collected up front is a liability earned 1/12 a month. Like
|
||||
//! depreciation, the schedule is the evidence for its postings, so they
|
||||
//! need no receipts. Each month posts once (idempotent runs).
|
||||
//! - **Recurring entries** (rent, subscriptions, payroll) generate *drafts*
|
||||
//! on their dates, never posted entries: the agent attaches the actual
|
||||
//! receipt and posts through the normal preview, so the receipt policy is
|
||||
//! never bypassed.
|
||||
|
||||
use crate::{AccountId, AccountType, ChartOfAccounts, LedgerError, Money, Side};
|
||||
use chrono::{Datelike, Days, Months, NaiveDate};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// Opaque handle for an amortization schedule.
|
||||
pub type ScheduleId = u64;
|
||||
/// Opaque handle for a recurring entry.
|
||||
pub type RecurringId = u64;
|
||||
/// One line of a template entry: account, side, amount.
|
||||
pub type TemplateLine = (AccountId, Side, Money);
|
||||
|
||||
/// What an amortization schedule recognizes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ScheduleKind {
|
||||
/// A prepaid expense: each month debits the expense account and
|
||||
/// credits the prepaid (asset) account.
|
||||
PrepaidExpense,
|
||||
/// Deferred (unearned) revenue: each month debits the deferred revenue
|
||||
/// (liability) account and credits the revenue account.
|
||||
DeferredRevenue,
|
||||
}
|
||||
|
||||
/// An amortization schedule.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AmortizationSchedule {
|
||||
/// Opaque handle.
|
||||
pub id: ScheduleId,
|
||||
/// What it is, e.g. "2027 general liability insurance".
|
||||
pub description: String,
|
||||
/// Prepaid expense or deferred revenue.
|
||||
pub kind: ScheduleKind,
|
||||
/// The amount to spread.
|
||||
pub total: Money,
|
||||
/// Any date in the first month covered.
|
||||
pub start: NaiveDate,
|
||||
/// Months covered.
|
||||
pub months: u32,
|
||||
/// The balance-sheet account: prepaid (asset) or deferred revenue
|
||||
/// (liability).
|
||||
pub balance_account: AccountId,
|
||||
/// The P&L account: expense or revenue.
|
||||
pub pl_account: AccountId,
|
||||
/// Months already posted, as their month-end dates.
|
||||
pub posted: BTreeSet<NaiveDate>,
|
||||
}
|
||||
|
||||
/// Parameters for a new schedule.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NewSchedule {
|
||||
/// What it is.
|
||||
pub description: String,
|
||||
/// Prepaid expense or deferred revenue.
|
||||
pub kind: ScheduleKind,
|
||||
/// The amount to spread (positive).
|
||||
pub total: Money,
|
||||
/// Any date in the first month covered.
|
||||
pub start: NaiveDate,
|
||||
/// Months covered (at least 1).
|
||||
pub months: u32,
|
||||
/// Prepaid (asset) or deferred revenue (liability) account.
|
||||
pub balance_account: AccountId,
|
||||
/// Expense or revenue account.
|
||||
pub pl_account: AccountId,
|
||||
}
|
||||
|
||||
/// The last day of `d`'s month.
|
||||
pub fn month_end(d: NaiveDate) -> NaiveDate {
|
||||
let first = NaiveDate::from_ymd_opt(d.year(), d.month(), 1).expect("valid");
|
||||
first + Months::new(1) - Days::new(1)
|
||||
}
|
||||
|
||||
/// The schedule's monthly postings: (month-end date, amount). Equal
|
||||
/// amounts to the cent, the last month absorbing rounding, so they sum to
|
||||
/// the total exactly.
|
||||
pub fn amortization_plan(
|
||||
total: Decimal,
|
||||
start: NaiveDate,
|
||||
months: u32,
|
||||
) -> Vec<(NaiveDate, Decimal)> {
|
||||
let each = (total / Decimal::from(months)).round_dp(2);
|
||||
let first = NaiveDate::from_ymd_opt(start.year(), start.month(), 1).expect("valid");
|
||||
(0..months)
|
||||
.map(|i| {
|
||||
let date = month_end(first + Months::new(i));
|
||||
let amount = if i + 1 == months {
|
||||
total - each * Decimal::from(months - 1)
|
||||
} else {
|
||||
each
|
||||
};
|
||||
(date, amount)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Validate a new schedule against the chart.
|
||||
pub fn validate_schedule(new: &NewSchedule, chart: &ChartOfAccounts) -> Result<(), LedgerError> {
|
||||
let bad = |m: String| Err(LedgerError::InvalidSchedule(m));
|
||||
if new.description.trim().is_empty() {
|
||||
return bad("description is required".into());
|
||||
}
|
||||
if new.total.amount <= Decimal::ZERO {
|
||||
return bad(format!("total must be positive, got {}", new.total.amount));
|
||||
}
|
||||
if new.months == 0 || new.months > 600 {
|
||||
return bad(format!("months must be 1 to 600, got {}", new.months));
|
||||
}
|
||||
let (want_balance, want_pl) = match new.kind {
|
||||
ScheduleKind::PrepaidExpense => (AccountType::Asset, AccountType::Expense),
|
||||
ScheduleKind::DeferredRevenue => (AccountType::Liability, AccountType::Revenue),
|
||||
};
|
||||
for (id, want) in [
|
||||
(new.balance_account, want_balance),
|
||||
(new.pl_account, want_pl),
|
||||
] {
|
||||
let a = chart.get(id).ok_or(LedgerError::UnknownAccount(id))?;
|
||||
if a.account_type != want || a.placeholder {
|
||||
return bad(format!(
|
||||
"account {} must be a postable {want:?} account for a {:?} schedule",
|
||||
a.code, new.kind
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl AmortizationSchedule {
|
||||
/// The postings for one month: (debit account, credit account).
|
||||
pub fn accounts(&self) -> (AccountId, AccountId) {
|
||||
match self.kind {
|
||||
ScheduleKind::PrepaidExpense => (self.pl_account, self.balance_account),
|
||||
ScheduleKind::DeferredRevenue => (self.balance_account, self.pl_account),
|
||||
}
|
||||
}
|
||||
|
||||
/// Months due through `through` and not yet posted.
|
||||
pub fn due(&self, through: NaiveDate) -> Vec<(NaiveDate, Decimal)> {
|
||||
amortization_plan(self.total.amount, self.start, self.months)
|
||||
.into_iter()
|
||||
.filter(|(d, _)| *d <= through && !self.posted.contains(d))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The amount not yet recognized.
|
||||
pub fn remaining(&self) -> Decimal {
|
||||
amortization_plan(self.total.amount, self.start, self.months)
|
||||
.into_iter()
|
||||
.filter(|(d, _)| !self.posted.contains(d))
|
||||
.map(|(_, a)| a)
|
||||
.sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// How often a recurring entry happens.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Frequency {
|
||||
/// Every 7 days.
|
||||
Weekly,
|
||||
/// Every month, on the start date's day (clamped to month end).
|
||||
Monthly,
|
||||
/// Every three months.
|
||||
Quarterly,
|
||||
/// Every year.
|
||||
Yearly,
|
||||
}
|
||||
|
||||
impl Frequency {
|
||||
/// Parse "weekly", "monthly", "quarterly", "yearly" ("annually").
|
||||
pub fn parse(s: &str) -> Option<Frequency> {
|
||||
match s.trim().to_lowercase().as_str() {
|
||||
"weekly" => Some(Frequency::Weekly),
|
||||
"monthly" => Some(Frequency::Monthly),
|
||||
"quarterly" => Some(Frequency::Quarterly),
|
||||
"yearly" | "annually" | "annual" => Some(Frequency::Yearly),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `k`th occurrence from `start`, computed from the start each
|
||||
/// time so month-end dates don't drift (Jan 31 → Feb 28 → Mar 31).
|
||||
pub fn nth(&self, start: NaiveDate, k: u32) -> Option<NaiveDate> {
|
||||
match self {
|
||||
Frequency::Weekly => start.checked_add_days(Days::new(7 * k as u64)),
|
||||
Frequency::Monthly => start.checked_add_months(Months::new(k)),
|
||||
Frequency::Quarterly => start.checked_add_months(Months::new(3 * k)),
|
||||
Frequency::Yearly => start.checked_add_months(Months::new(12 * k)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A recurring entry: a balanced template and when it happens.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RecurringEntry {
|
||||
/// Opaque handle.
|
||||
pub id: RecurringId,
|
||||
/// Description for each generated entry.
|
||||
pub description: String,
|
||||
/// The balanced lines.
|
||||
pub lines: Vec<TemplateLine>,
|
||||
/// How often.
|
||||
pub frequency: Frequency,
|
||||
/// First occurrence.
|
||||
pub start: NaiveDate,
|
||||
/// Last date an occurrence may fall on, if it ends.
|
||||
pub end: Option<NaiveDate>,
|
||||
/// Occurrences already generated, by date.
|
||||
pub generated: BTreeSet<NaiveDate>,
|
||||
}
|
||||
|
||||
/// Parameters for a new recurring entry.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NewRecurring {
|
||||
/// Description for each generated entry.
|
||||
pub description: String,
|
||||
/// The balanced lines.
|
||||
pub lines: Vec<TemplateLine>,
|
||||
/// How often.
|
||||
pub frequency: Frequency,
|
||||
/// First occurrence.
|
||||
pub start: NaiveDate,
|
||||
/// Optional last date.
|
||||
pub end: Option<NaiveDate>,
|
||||
}
|
||||
|
||||
/// Validate a new recurring entry: at least two lines, positive amounts,
|
||||
/// postable accounts, balanced, and an end not before the start.
|
||||
pub fn validate_recurring(new: &NewRecurring, chart: &ChartOfAccounts) -> Result<(), LedgerError> {
|
||||
let bad = |m: String| Err(LedgerError::InvalidSchedule(m));
|
||||
if new.description.trim().is_empty() {
|
||||
return bad("description is required".into());
|
||||
}
|
||||
if new.lines.len() < 2 {
|
||||
return bad("a recurring entry needs at least two lines".into());
|
||||
}
|
||||
if new.end.is_some_and(|e| e < new.start) {
|
||||
return bad("end is before start".into());
|
||||
}
|
||||
let (mut debits, mut credits) = (Decimal::ZERO, Decimal::ZERO);
|
||||
for (account, side, money) in &new.lines {
|
||||
let a = chart
|
||||
.get(*account)
|
||||
.ok_or(LedgerError::UnknownAccount(*account))?;
|
||||
if a.placeholder {
|
||||
return bad(format!("account {} is a placeholder", a.code));
|
||||
}
|
||||
if money.amount <= Decimal::ZERO {
|
||||
return bad(format!(
|
||||
"line amounts must be positive, got {}",
|
||||
money.amount
|
||||
));
|
||||
}
|
||||
match side {
|
||||
Side::Debit => debits += money.amount,
|
||||
Side::Credit => credits += money.amount,
|
||||
}
|
||||
}
|
||||
if debits != credits {
|
||||
return bad(format!(
|
||||
"lines don't balance: debits {debits}, credits {credits}"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl RecurringEntry {
|
||||
/// Occurrences on or before `through` (and the end), not yet generated.
|
||||
pub fn due(&self, through: NaiveDate) -> Vec<NaiveDate> {
|
||||
let last = self.end.map_or(through, |e| e.min(through));
|
||||
(0..)
|
||||
.map_while(|k| self.frequency.nth(self.start, k).filter(|d| *d <= last))
|
||||
.filter(|d| !self.generated.contains(d))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plans_sum_exactly_and_land_on_month_ends() {
|
||||
let plan = amortization_plan(Decimal::new(100_000, 2), d(2026, 11, 15), 3);
|
||||
// 1,000.00 / 3 = 333.33, 333.33, 333.34.
|
||||
assert_eq!(
|
||||
plan,
|
||||
[
|
||||
(d(2026, 11, 30), Decimal::new(33_333, 2)),
|
||||
(d(2026, 12, 31), Decimal::new(33_333, 2)),
|
||||
(d(2027, 1, 31), Decimal::new(33_334, 2)),
|
||||
]
|
||||
);
|
||||
let leap = amortization_plan(Decimal::new(1200, 0), d(2028, 2, 1), 1);
|
||||
assert_eq!(leap, [(d(2028, 2, 29), Decimal::new(1200, 0))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monthly_occurrences_do_not_drift() {
|
||||
let f = Frequency::Monthly;
|
||||
let dates: Vec<NaiveDate> = (0..4).map(|k| f.nth(d(2026, 1, 31), k).unwrap()).collect();
|
||||
assert_eq!(
|
||||
dates,
|
||||
[
|
||||
d(2026, 1, 31),
|
||||
d(2026, 2, 28),
|
||||
d(2026, 3, 31),
|
||||
d(2026, 4, 30)
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
Frequency::Quarterly.nth(d(2026, 1, 15), 3),
|
||||
Some(d(2026, 10, 15))
|
||||
);
|
||||
assert_eq!(
|
||||
Frequency::Weekly.nth(d(2026, 1, 1), 2),
|
||||
Some(d(2026, 1, 15))
|
||||
);
|
||||
assert_eq!(Frequency::parse("Annually"), Some(Frequency::Yearly));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
//! Tax categories: where each revenue and expense account lands on the
|
||||
//! business's income tax return, and how much of it is deductible.
|
||||
//!
|
||||
//! The books stay book-basis; this is the bridge a CPA (or tax software)
|
||||
//! walks across. Categories are *stable semantic keys* ("meals",
|
||||
//! "guaranteed_payments") rather than form line numbers, which move from
|
||||
//! year to year; each category says which returns it applies to. A
|
||||
//! deductible percentage (meals 50%, penalties 0%) turns book amounts into
|
||||
//! deductible amounts and surfaces the book-to-tax differences (Schedule
|
||||
//! M-1 items) instead of hiding them.
|
||||
//!
|
||||
//! Scope is deliberate, like fixed assets: no tax depreciation (MACRS,
|
||||
//! Section 179, bonus), no basis computations, no filing. Not tax advice.
|
||||
|
||||
use crate::{AccountType, Ledger, LedgerError};
|
||||
use chrono::NaiveDate;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// The federal income tax return a business files.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TaxForm {
|
||||
/// Sole proprietorship or single-member LLC: Schedule C (Form 1040).
|
||||
ScheduleC,
|
||||
/// Partnership or multi-member LLC: Form 1065.
|
||||
Form1065,
|
||||
/// S corporation: Form 1120-S.
|
||||
Form1120S,
|
||||
/// C corporation: Form 1120.
|
||||
Form1120,
|
||||
}
|
||||
|
||||
impl TaxForm {
|
||||
/// Every form.
|
||||
pub const ALL: [TaxForm; 4] = [
|
||||
TaxForm::ScheduleC,
|
||||
TaxForm::Form1065,
|
||||
TaxForm::Form1120S,
|
||||
TaxForm::Form1120,
|
||||
];
|
||||
|
||||
/// "Form 1065", "Schedule C", ...
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
TaxForm::ScheduleC => "Schedule C",
|
||||
TaxForm::Form1065 => "Form 1065",
|
||||
TaxForm::Form1120S => "Form 1120-S",
|
||||
TaxForm::Form1120 => "Form 1120",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse "schedule_c", "1065", "1120s", "1120" (and their labels).
|
||||
pub fn parse(s: &str) -> Option<TaxForm> {
|
||||
let k: String = s
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric())
|
||||
.collect();
|
||||
match k.as_str() {
|
||||
"schedulec" | "c" => Some(TaxForm::ScheduleC),
|
||||
"1065" | "form1065" => Some(TaxForm::Form1065),
|
||||
"1120s" | "form1120s" => Some(TaxForm::Form1120S),
|
||||
"1120" | "form1120" => Some(TaxForm::Form1120),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a category sits on the return.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TaxKind {
|
||||
/// Income (revenue accounts).
|
||||
Income,
|
||||
/// Cost of goods sold (expense accounts).
|
||||
CostOfGoods,
|
||||
/// A deduction (expense accounts).
|
||||
Deduction,
|
||||
}
|
||||
|
||||
/// One tax category.
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
pub struct TaxCategory {
|
||||
/// Stable key, e.g. `"meals"`.
|
||||
pub key: &'static str,
|
||||
/// Human label, e.g. `"Meals"`.
|
||||
pub label: &'static str,
|
||||
/// Income, COGS, or deduction.
|
||||
pub kind: TaxKind,
|
||||
/// The returns it appears on.
|
||||
pub forms: &'static [TaxForm],
|
||||
/// Percent deductible by default (100 for most; meals 50).
|
||||
pub default_deductible_percent: u32,
|
||||
/// What to know about it, for the agent and the CPA.
|
||||
pub note: &'static str,
|
||||
}
|
||||
|
||||
const ALL: &[TaxForm] = &TaxForm::ALL;
|
||||
const CORPS: &[TaxForm] = &[TaxForm::Form1120S, TaxForm::Form1120];
|
||||
|
||||
const fn cat(
|
||||
key: &'static str,
|
||||
label: &'static str,
|
||||
kind: TaxKind,
|
||||
forms: &'static [TaxForm],
|
||||
pct: u32,
|
||||
note: &'static str,
|
||||
) -> TaxCategory {
|
||||
TaxCategory {
|
||||
key,
|
||||
label,
|
||||
kind,
|
||||
forms,
|
||||
default_deductible_percent: pct,
|
||||
note,
|
||||
}
|
||||
}
|
||||
|
||||
use TaxKind::{CostOfGoods, Deduction, Income};
|
||||
|
||||
/// The catalog.
|
||||
pub const CATEGORIES: &[TaxCategory] = &[
|
||||
cat(
|
||||
"gross_receipts",
|
||||
"Gross receipts or sales",
|
||||
Income,
|
||||
ALL,
|
||||
100,
|
||||
"Sales recorded gross (platform and processor fees are a separate deduction).",
|
||||
),
|
||||
cat(
|
||||
"returns_allowances",
|
||||
"Returns and allowances",
|
||||
Income,
|
||||
ALL,
|
||||
100,
|
||||
"Refunds and allowances; reduces gross receipts.",
|
||||
),
|
||||
cat(
|
||||
"interest_income",
|
||||
"Interest income",
|
||||
Income,
|
||||
ALL,
|
||||
100,
|
||||
"Pass-throughs report it separately to owners (portfolio income).",
|
||||
),
|
||||
cat("other_income", "Other income", Income, ALL, 100, ""),
|
||||
cat(
|
||||
"cost_of_goods_sold",
|
||||
"Cost of goods sold",
|
||||
CostOfGoods,
|
||||
ALL,
|
||||
100,
|
||||
"Inventory and direct costs of what was sold.",
|
||||
),
|
||||
cat("advertising", "Advertising", Deduction, ALL, 100, ""),
|
||||
cat(
|
||||
"bad_debts",
|
||||
"Bad debts",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"Only debts previously included in income (accrual basis).",
|
||||
),
|
||||
cat(
|
||||
"car_truck",
|
||||
"Car and truck expenses",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"Actual costs or the standard mileage rate, not both.",
|
||||
),
|
||||
cat(
|
||||
"charitable_contributions",
|
||||
"Charitable contributions",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"Separately stated to owners for partnerships and S corps; limited for C corps; not a Schedule C deduction.",
|
||||
),
|
||||
cat(
|
||||
"commissions_fees",
|
||||
"Commissions and fees",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"Includes app store, marketplace, and payment processor fees.",
|
||||
),
|
||||
cat(
|
||||
"contract_labor",
|
||||
"Contract labor",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"Payments to non-employees; see 1099 reporting.",
|
||||
),
|
||||
cat(
|
||||
"depreciation",
|
||||
"Depreciation (book)",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"Book depreciation; tax depreciation (MACRS, Section 179, bonus) is computed separately by the CPA.",
|
||||
),
|
||||
cat(
|
||||
"employee_benefits",
|
||||
"Employee benefit programs",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"",
|
||||
),
|
||||
cat(
|
||||
"guaranteed_payments",
|
||||
"Guaranteed payments to partners",
|
||||
Deduction,
|
||||
&[TaxForm::Form1065],
|
||||
100,
|
||||
"Payments to partners for services or capital, regardless of profit; reported on each partner's K-1.",
|
||||
),
|
||||
cat(
|
||||
"insurance",
|
||||
"Insurance (other than health)",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"",
|
||||
),
|
||||
cat("interest_expense", "Interest", Deduction, ALL, 100, ""),
|
||||
cat(
|
||||
"legal_professional",
|
||||
"Legal and professional services",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"Attorneys, accountants, consultants.",
|
||||
),
|
||||
cat(
|
||||
"meals",
|
||||
"Meals",
|
||||
Deduction,
|
||||
ALL,
|
||||
50,
|
||||
"Generally 50% deductible; the other half is a book-to-tax difference.",
|
||||
),
|
||||
cat("office_expense", "Office expense", Deduction, ALL, 100, ""),
|
||||
cat(
|
||||
"officer_compensation",
|
||||
"Compensation of officers",
|
||||
Deduction,
|
||||
CORPS,
|
||||
100,
|
||||
"Wages paid to officer-shareholders through payroll.",
|
||||
),
|
||||
cat(
|
||||
"organization_startup",
|
||||
"Organizational and start-up costs",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"Tax: up to $5,000 each deductible in the first year, the rest amortized over 180 months; the CPA computes it.",
|
||||
),
|
||||
cat(
|
||||
"pension_profit_sharing",
|
||||
"Pension and profit-sharing plans",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"",
|
||||
),
|
||||
cat(
|
||||
"rent_equipment",
|
||||
"Rent or lease: vehicles, machinery, equipment",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"",
|
||||
),
|
||||
cat(
|
||||
"rent_property",
|
||||
"Rent or lease: other business property",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"",
|
||||
),
|
||||
cat(
|
||||
"repairs",
|
||||
"Repairs and maintenance",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"",
|
||||
),
|
||||
cat(
|
||||
"salaries_wages",
|
||||
"Salaries and wages",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"Employees (not owners of a partnership, whose pay is guaranteed payments).",
|
||||
),
|
||||
cat("supplies", "Supplies", Deduction, ALL, 100, ""),
|
||||
cat(
|
||||
"taxes_licenses",
|
||||
"Taxes and licenses",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"Payroll taxes, business licenses, state fees; not federal income tax.",
|
||||
),
|
||||
cat(
|
||||
"travel",
|
||||
"Travel",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"Lodging and transportation away from home; meals go under Meals.",
|
||||
),
|
||||
cat("utilities", "Utilities", Deduction, ALL, 100, ""),
|
||||
cat(
|
||||
"other_deductions",
|
||||
"Other deductions",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"Software, subscriptions, bank fees, dues, and the rest; the CPA itemizes.",
|
||||
),
|
||||
cat(
|
||||
"nondeductible",
|
||||
"Nondeductible expenses",
|
||||
Deduction,
|
||||
ALL,
|
||||
0,
|
||||
"Fines, penalties, political contributions, federal income tax: a book expense with no deduction.",
|
||||
),
|
||||
];
|
||||
|
||||
/// Look up a category by key.
|
||||
pub fn category(key: &str) -> Option<&'static TaxCategory> {
|
||||
CATEGORIES.iter().find(|c| c.key == key)
|
||||
}
|
||||
|
||||
/// Check that `key` can tag an account of `account_type` and, when a form
|
||||
/// is known, that it appears on that form.
|
||||
pub fn check_category(
|
||||
key: &str,
|
||||
account_type: AccountType,
|
||||
form: Option<TaxForm>,
|
||||
) -> Result<&'static TaxCategory, LedgerError> {
|
||||
let c = category(key)
|
||||
.ok_or_else(|| LedgerError::InvalidTaxCategory(format!("unknown tax category {key:?}")))?;
|
||||
let fits = match c.kind {
|
||||
TaxKind::Income => matches!(account_type, AccountType::Revenue | AccountType::Expense),
|
||||
TaxKind::CostOfGoods | TaxKind::Deduction => account_type == AccountType::Expense,
|
||||
};
|
||||
if !fits {
|
||||
return Err(LedgerError::InvalidTaxCategory(format!(
|
||||
"{key} is for {} accounts, not {account_type:?}",
|
||||
if c.kind == TaxKind::Income {
|
||||
"revenue"
|
||||
} else {
|
||||
"expense"
|
||||
}
|
||||
)));
|
||||
}
|
||||
if let Some(f) = form
|
||||
&& !c.forms.contains(&f)
|
||||
{
|
||||
return Err(LedgerError::InvalidTaxCategory(format!(
|
||||
"{key} does not appear on {}",
|
||||
f.label()
|
||||
)));
|
||||
}
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
/// One category's line on the tax summary.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct TaxSummaryLine {
|
||||
/// Category key.
|
||||
pub category: String,
|
||||
/// Category label.
|
||||
pub label: String,
|
||||
/// The accounts that roll into it, `"CODE Name"`.
|
||||
pub accounts: Vec<String>,
|
||||
/// Book amount: revenue for income, expense for COGS and deductions.
|
||||
pub book: Decimal,
|
||||
/// Deductible (or taxable) amount after the deductible percentage.
|
||||
pub tax: Decimal,
|
||||
}
|
||||
|
||||
/// An account with P&L activity and no tax category.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Uncategorized {
|
||||
/// `"CODE Name"`.
|
||||
pub account: String,
|
||||
/// Revenue or Expense.
|
||||
pub account_type: AccountType,
|
||||
/// Book amount (normal side).
|
||||
pub book: Decimal,
|
||||
}
|
||||
|
||||
/// The tax summary for a period: book amounts by tax category, deductible
|
||||
/// amounts, and every account still needing a category.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct TaxSummary {
|
||||
/// First date, inclusive.
|
||||
pub start: NaiveDate,
|
||||
/// Last date, inclusive.
|
||||
pub end: NaiveDate,
|
||||
/// The return, when known.
|
||||
pub form: Option<TaxForm>,
|
||||
/// Income categories.
|
||||
pub income: Vec<TaxSummaryLine>,
|
||||
/// Cost of goods sold.
|
||||
pub cost_of_goods: Vec<TaxSummaryLine>,
|
||||
/// Deductions.
|
||||
pub deductions: Vec<TaxSummaryLine>,
|
||||
/// P&L accounts with activity and no category.
|
||||
pub uncategorized: Vec<Uncategorized>,
|
||||
}
|
||||
|
||||
impl TaxSummary {
|
||||
/// Book net income of the categorized accounts.
|
||||
pub fn book_net(&self) -> Decimal {
|
||||
sum(&self.income, |l| l.book)
|
||||
- sum(&self.cost_of_goods, |l| l.book)
|
||||
- sum(&self.deductions, |l| l.book)
|
||||
}
|
||||
|
||||
/// Income less deductible amounts, before the CPA's other adjustments.
|
||||
pub fn tax_net(&self) -> Decimal {
|
||||
sum(&self.income, |l| l.tax)
|
||||
- sum(&self.cost_of_goods, |l| l.tax)
|
||||
- sum(&self.deductions, |l| l.tax)
|
||||
}
|
||||
}
|
||||
|
||||
fn sum(lines: &[TaxSummaryLine], f: fn(&TaxSummaryLine) -> Decimal) -> Decimal {
|
||||
lines.iter().map(f).sum()
|
||||
}
|
||||
|
||||
/// Build the tax summary for `[start, end]`, closing entries excluded (like
|
||||
/// the P&L). Deductible amounts round to the cent, half away from zero.
|
||||
pub fn tax_summary(
|
||||
ledger: &Ledger,
|
||||
start: NaiveDate,
|
||||
end: NaiveDate,
|
||||
form: Option<TaxForm>,
|
||||
) -> Result<TaxSummary, LedgerError> {
|
||||
let pl = crate::reports::profit_and_loss_for_period(ledger, start, end, None)?;
|
||||
let mut by_cat: BTreeMap<&'static str, TaxSummaryLine> = BTreeMap::new();
|
||||
let mut uncategorized = Vec::new();
|
||||
for line in pl.revenue.iter().chain(pl.expenses.iter()) {
|
||||
let account = ledger.chart().find_by_code(&line.code).ok_or_else(|| {
|
||||
LedgerError::InvalidTaxCategory(format!("unknown account {}", line.code))
|
||||
})?;
|
||||
let name = format!("{} {}", account.code, account.name);
|
||||
let Some(c) = account.tax_category.as_deref().and_then(category) else {
|
||||
uncategorized.push(Uncategorized {
|
||||
account: name,
|
||||
account_type: account.account_type,
|
||||
book: line.amount,
|
||||
});
|
||||
continue;
|
||||
};
|
||||
// Income from an expense-type account (e.g. a contra) counts with
|
||||
// its sign flipped, so returns reduce receipts.
|
||||
let book = if c.kind == TaxKind::Income && account.account_type == AccountType::Expense {
|
||||
-line.amount
|
||||
} else {
|
||||
line.amount
|
||||
};
|
||||
let pct = account
|
||||
.deductible_percent
|
||||
.unwrap_or_else(|| Decimal::from(c.default_deductible_percent));
|
||||
let tax = (book * pct / Decimal::from(100))
|
||||
.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero);
|
||||
let entry = by_cat.entry(c.key).or_insert_with(|| TaxSummaryLine {
|
||||
category: c.key.to_string(),
|
||||
label: c.label.to_string(),
|
||||
accounts: vec![],
|
||||
book: Decimal::ZERO,
|
||||
tax: Decimal::ZERO,
|
||||
});
|
||||
entry.accounts.push(name);
|
||||
entry.book += book;
|
||||
entry.tax += tax;
|
||||
}
|
||||
let mut out = TaxSummary {
|
||||
start,
|
||||
end,
|
||||
form,
|
||||
income: vec![],
|
||||
cost_of_goods: vec![],
|
||||
deductions: vec![],
|
||||
uncategorized,
|
||||
};
|
||||
// Catalog order, so the summary reads like the return.
|
||||
for c in CATEGORIES {
|
||||
if let Some(line) = by_cat.remove(c.key) {
|
||||
match c.kind {
|
||||
TaxKind::Income => out.income.push(line),
|
||||
TaxKind::CostOfGoods => out.cost_of_goods.push(line),
|
||||
TaxKind::Deduction => out.deductions.push(line),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//! Time series. Numbers computed by hand.
|
||||
//!
|
||||
//! Cash 1000 and card 2100. Jan 5: owner puts in 5,000. Jan 20: 300 of
|
||||
//! software on the card. Feb 3: 1,200 of sales into cash. Feb 10: pay the
|
||||
//! card 300. Mar 15: 800 rent from cash.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::analysis::{
|
||||
Interval, balance_history, balance_series, flows_by_interval, pnl_by_interval,
|
||||
};
|
||||
use ledger_core::{AccountType, Currency, Ledger, Money, Side};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
fn d(m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, m, day).unwrap()
|
||||
}
|
||||
|
||||
fn dec(n: i64) -> Decimal {
|
||||
Decimal::from(n)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn balances_pnl_and_flows_over_time() {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let mut a = |c: &str, t| l.add_account(c, c, t, None, false).unwrap();
|
||||
let cash = a("1000", AccountType::Asset);
|
||||
let card = a("2100", AccountType::Liability);
|
||||
let equity = a("3000", AccountType::Equity);
|
||||
let sales = a("4000", AccountType::Revenue);
|
||||
let software = a("6300", AccountType::Expense);
|
||||
let rent = a("6600", AccountType::Expense);
|
||||
for (date, dr, cr, amt) in [
|
||||
(d(1, 5), cash, equity, 5000),
|
||||
(d(1, 20), software, card, 300),
|
||||
(d(2, 3), cash, sales, 1200),
|
||||
(d(2, 10), card, cash, 300),
|
||||
(d(3, 15), rent, cash, 800),
|
||||
] {
|
||||
let e = l.create_entry(date, "x");
|
||||
let m = Money::new(dec(amt), Currency::USD);
|
||||
l.add_line(e, dr, Side::Debit, m).unwrap();
|
||||
l.add_line(e, cr, Side::Credit, m).unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
balance_series(&l, &[cash], &[d(1, 1), d(1, 31), d(2, 28), d(3, 31)]),
|
||||
[dec(0), dec(5000), dec(5900), dec(5100)]
|
||||
);
|
||||
// The card is a liability: owed 300, then paid off.
|
||||
assert_eq!(
|
||||
balance_series(&l, &[card], &[d(1, 31), d(2, 28)]),
|
||||
[dec(300), dec(0)]
|
||||
);
|
||||
assert_eq!(
|
||||
balance_history(&l, &[cash]),
|
||||
[
|
||||
(d(1, 5), dec(5000)),
|
||||
(d(2, 3), dec(6200)),
|
||||
(d(2, 10), dec(5900)),
|
||||
(d(3, 15), dec(5100))
|
||||
]
|
||||
);
|
||||
let pnl = pnl_by_interval(&l, d(1, 1), d(3, 31), Interval::Month).unwrap();
|
||||
let nets: Vec<Decimal> = pnl.iter().map(|p| p.net()).collect();
|
||||
assert_eq!(nets, [dec(-300), dec(1200), dec(-800)]);
|
||||
let flows = flows_by_interval(&l, &[cash], d(1, 1), d(3, 31), Interval::Month);
|
||||
let io: Vec<(Decimal, Decimal)> = flows.iter().map(|f| (f.2, f.3)).collect();
|
||||
assert_eq!(
|
||||
io,
|
||||
[
|
||||
(dec(5000), dec(0)),
|
||||
(dec(1200), dec(300)),
|
||||
(dec(0), dec(800))
|
||||
]
|
||||
);
|
||||
// Cash and card together: a card charge is money out when it's made,
|
||||
// and paying the card moves nothing out of the pair.
|
||||
let both = flows_by_interval(&l, &[cash, card], d(1, 1), d(2, 28), Interval::Month);
|
||||
let io: Vec<(Decimal, Decimal)> = both.iter().map(|f| (f.2, f.3)).collect();
|
||||
assert_eq!(io, [(dec(5000), dec(300)), (dec(1200), dec(0))]);
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
//! Fixed-asset tests: the depreciation math must be exact to the penny.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::{
|
||||
AccountType, AssetStatus, Currency, DepreciationMethod, Ledger, Money, NewAsset,
|
||||
accumulated_depreciation, book_value, depreciation_schedule, disposal_amounts,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
fn usd(amount: &str) -> Money {
|
||||
Money::new(amount.parse().unwrap(), Currency::USD)
|
||||
}
|
||||
|
||||
fn date(y: i32, m: u32, d: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, d).unwrap()
|
||||
}
|
||||
|
||||
fn test_ledger() -> (Ledger, u64, u64, u64) {
|
||||
let mut ledger = Ledger::new(Currency::USD);
|
||||
let cost = ledger
|
||||
.add_account("1500", "Equipment", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let accum = ledger
|
||||
.add_account(
|
||||
"1501",
|
||||
"Accumulated Depreciation",
|
||||
AccountType::Asset,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let expense = ledger
|
||||
.add_account(
|
||||
"6100",
|
||||
"Depreciation Expense",
|
||||
AccountType::Expense,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
(ledger, cost, accum, expense)
|
||||
}
|
||||
|
||||
/// Register the standard test asset: bought 2026-01-15, 60-month life,
|
||||
/// zero salvage, straight-line.
|
||||
fn van_asset(
|
||||
ledger: &mut Ledger,
|
||||
dollars: u64,
|
||||
cost_acct: u64,
|
||||
accum_acct: u64,
|
||||
expense_acct: u64,
|
||||
) -> u64 {
|
||||
ledger
|
||||
.register_asset(NewAsset {
|
||||
description: "Sprinter van".to_string(),
|
||||
purchase_date: date(2026, 1, 15),
|
||||
cost: usd(&dollars.to_string()),
|
||||
salvage_value: usd("0"),
|
||||
useful_life_months: 60,
|
||||
cost_account: cost_acct,
|
||||
accum_depr_account: accum_acct,
|
||||
expense_account: expense_acct,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedule_sums_exactly_to_depreciable_amount() {
|
||||
let (mut ledger, cost, accum, expense) = test_ledger();
|
||||
// Odd numbers chosen to not divide evenly: 100000 / 60.
|
||||
let id = van_asset(&mut ledger, 100_000, cost, accum, expense);
|
||||
let asset = ledger.get_asset(id).unwrap();
|
||||
let sched = depreciation_schedule(asset, date(2031, 12, 31));
|
||||
// 61 calendar months touched (partial first and last), 60 months' worth.
|
||||
assert_eq!(sched.len(), 61);
|
||||
let total: Decimal = sched.iter().map(|l| l.amount).sum();
|
||||
assert_eq!(
|
||||
total,
|
||||
Decimal::from(100_000),
|
||||
"lifetime total must be exact"
|
||||
);
|
||||
assert_eq!(sched.last().unwrap().accumulated, Decimal::from(100_000));
|
||||
assert_eq!(sched.last().unwrap().book_value, Decimal::ZERO);
|
||||
// First month prorated: 17 of 31 days → 100000/60 * 17/31 = 913.98.
|
||||
let first = &sched[0];
|
||||
assert_eq!(first.period, "2026-01");
|
||||
assert_eq!(first.amount, Decimal::new(91398, 2));
|
||||
// Last month is the complement: 14 of 31 days.
|
||||
assert_eq!(sched[60].period, "2031-01");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_months_complement_to_whole() {
|
||||
let (mut ledger, cost, accum, expense) = test_ledger();
|
||||
let id = van_asset(&mut ledger, 100_000, cost, accum, expense);
|
||||
let asset = ledger.get_asset(id).unwrap();
|
||||
let sched = depreciation_schedule(asset, date(2031, 12, 31));
|
||||
// Jan 2026 (17/31) + Jan 2031 (14/31) = exactly one month's charge.
|
||||
let monthly = Decimal::from(100_000) / Decimal::from(60);
|
||||
let monthly_rounded =
|
||||
monthly.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero);
|
||||
let pair = sched[0].amount + sched[60].amount;
|
||||
assert!(
|
||||
(pair - monthly_rounded).abs() <= Decimal::new(1, 2),
|
||||
"partial months should complement: {pair} vs {monthly_rounded}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn salvage_value_is_never_depreciated() {
|
||||
let (mut ledger, cost, accum, expense) = test_ledger();
|
||||
let id = ledger
|
||||
.register_asset(NewAsset {
|
||||
description: "Machine".to_string(),
|
||||
purchase_date: date(2026, 3, 1),
|
||||
cost: usd("50000"),
|
||||
salvage_value: usd("5000"),
|
||||
useful_life_months: 36,
|
||||
cost_account: cost,
|
||||
accum_depr_account: accum,
|
||||
expense_account: expense,
|
||||
})
|
||||
.unwrap();
|
||||
let asset = ledger.get_asset(id).unwrap();
|
||||
let sched = depreciation_schedule(asset, date(2030, 12, 31));
|
||||
let total: Decimal = sched.iter().map(|l| l.amount).sum();
|
||||
assert_eq!(total, Decimal::from(45_000));
|
||||
assert_eq!(sched.last().unwrap().book_value, Decimal::from(5_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn book_value_tracks_accumulated() {
|
||||
let (mut ledger, cost, accum, expense) = test_ledger();
|
||||
let id = van_asset(&mut ledger, 60_000, cost, accum, expense);
|
||||
let asset = ledger.get_asset(id).unwrap();
|
||||
let acc = accumulated_depreciation(asset, date(2026, 12, 31));
|
||||
// Jan 2026 prorated 17/31 of 1000 = 548.39, then 11 full months of 1000.
|
||||
assert_eq!(acc, Decimal::new(54839, 2) + Decimal::from(11_000));
|
||||
assert_eq!(
|
||||
book_value(asset, date(2026, 12, 31)),
|
||||
Decimal::from(60_000) - acc
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disposal_math() {
|
||||
let (mut ledger, cost, accum, expense) = test_ledger();
|
||||
let id = van_asset(&mut ledger, 60_000, cost, accum, expense);
|
||||
let asset = ledger.get_asset(id).unwrap();
|
||||
// Sell after one year for 55,000. Book value is 60000 − 11548.39 =
|
||||
// 48451.61, so this is a gain of 6548.39.
|
||||
let d = disposal_amounts(asset, date(2026, 12, 31), usd("55000")).unwrap();
|
||||
assert_eq!(d.book_value, Decimal::new(4845161, 2));
|
||||
assert_eq!(d.accumulated, Decimal::new(1154839, 2));
|
||||
assert_eq!(d.gain_loss, Decimal::new(654839, 2));
|
||||
// Disposing twice fails.
|
||||
ledger
|
||||
.dispose_asset(id, date(2026, 12, 31), usd("55000"))
|
||||
.unwrap();
|
||||
assert!(
|
||||
ledger
|
||||
.dispose_asset(id, date(2027, 1, 1), usd("1"))
|
||||
.is_err()
|
||||
);
|
||||
let asset = ledger.get_asset(id).unwrap();
|
||||
assert!(matches!(asset.status, AssetStatus::Disposed { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registration_validation() {
|
||||
let (mut ledger, cost, accum, expense) = test_ledger();
|
||||
let liability = ledger
|
||||
.add_account("2000", "Payable", AccountType::Liability, None, false)
|
||||
.unwrap();
|
||||
let base = NewAsset {
|
||||
description: "X".to_string(),
|
||||
purchase_date: date(2026, 1, 1),
|
||||
cost: usd("1000"),
|
||||
salvage_value: usd("0"),
|
||||
useful_life_months: 12,
|
||||
cost_account: cost,
|
||||
accum_depr_account: accum,
|
||||
expense_account: expense,
|
||||
};
|
||||
// Wrong account type for the expense link.
|
||||
let mut bad = base.clone();
|
||||
bad.expense_account = liability;
|
||||
assert!(ledger.register_asset(bad).is_err());
|
||||
// Salvage >= cost.
|
||||
let mut bad = base.clone();
|
||||
bad.salvage_value = usd("1000");
|
||||
assert!(ledger.register_asset(bad).is_err());
|
||||
// Zero life.
|
||||
let mut bad = base.clone();
|
||||
bad.useful_life_months = 0;
|
||||
assert!(ledger.register_asset(bad).is_err());
|
||||
// Duplicate account links.
|
||||
let mut bad = base.clone();
|
||||
bad.accum_depr_account = cost;
|
||||
assert!(ledger.register_asset(bad).is_err());
|
||||
// Unknown account.
|
||||
let mut bad = base.clone();
|
||||
bad.cost_account = 999;
|
||||
assert!(ledger.register_asset(bad).is_err());
|
||||
// The good one works.
|
||||
assert!(ledger.register_asset(base).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_is_straight_line() {
|
||||
let (mut ledger, cost, accum, expense) = test_ledger();
|
||||
let id = van_asset(&mut ledger, 12_000, cost, accum, expense);
|
||||
let asset = ledger.get_asset(id).unwrap();
|
||||
assert_eq!(asset.method, DepreciationMethod::StraightLine);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registration_rejects_date_overflow() {
|
||||
let (mut ledger, cost, accum, expense) = test_ledger();
|
||||
// A purchase date near the end of the calendar with a long life would
|
||||
// overflow the schedule's month arithmetic; registration must refuse.
|
||||
let err = ledger
|
||||
.register_asset(NewAsset {
|
||||
description: "Time machine".to_string(),
|
||||
purchase_date: NaiveDate::MAX,
|
||||
cost: usd("1000"),
|
||||
salvage_value: usd("0"),
|
||||
useful_life_months: 12,
|
||||
cost_account: cost,
|
||||
accum_depr_account: accum,
|
||||
expense_account: expense,
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ledger_core::LedgerError::AssetDateOverflow));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedule_through_purchase_date_covers_one_day() {
|
||||
let (mut ledger, cost, accum, expense) = test_ledger();
|
||||
let id = ledger
|
||||
.register_asset(NewAsset {
|
||||
description: "X".to_string(),
|
||||
purchase_date: date(2026, 1, 15),
|
||||
cost: usd("62000"),
|
||||
salvage_value: usd("0"),
|
||||
useful_life_months: 62,
|
||||
cost_account: cost,
|
||||
accum_depr_account: accum,
|
||||
expense_account: expense,
|
||||
})
|
||||
.unwrap();
|
||||
let asset = ledger.get_asset(id).unwrap();
|
||||
// Through the day before purchase: nothing.
|
||||
assert!(depreciation_schedule(asset, date(2026, 1, 14)).is_empty());
|
||||
// Through the purchase date itself: one day of the first month.
|
||||
let one_day = depreciation_schedule(asset, date(2026, 1, 15));
|
||||
assert_eq!(one_day.len(), 1);
|
||||
assert_eq!(one_day[0].period, "2026-01");
|
||||
// 62000/62 = 1000/month; 1/31 of that = 32.26.
|
||||
assert_eq!(one_day[0].amount, Decimal::new(3226, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn golden_van_depreciation_schedule() {
|
||||
// The canonical van's month-by-month schedule, pinned byte-for-value.
|
||||
// Hand-verified: 61 months, first 548.39 = 1000 x 17/31, last 451.61 =
|
||||
// 1000 x 14/31, lifetime total exactly 60000.00.
|
||||
let (mut ledger, cost, accum, expense) = test_ledger();
|
||||
let id = van_asset(&mut ledger, 60_000, cost, accum, expense);
|
||||
let asset = ledger.get_asset(id).unwrap();
|
||||
let sched = depreciation_schedule(asset, date(2032, 1, 1));
|
||||
let got = serde_json::to_value(&sched).unwrap();
|
||||
let golden: serde_json::Value =
|
||||
serde_json::from_str(include_str!("golden/van_schedule.json")).unwrap();
|
||||
assert_eq!(got, golden);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registration_rejects_wrong_typed_accounts() {
|
||||
let (mut ledger, cost, accum, expense) = test_ledger();
|
||||
let base = || NewAsset {
|
||||
description: "X".to_string(),
|
||||
purchase_date: date(2026, 1, 15),
|
||||
cost: usd("1000"),
|
||||
salvage_value: usd("0"),
|
||||
useful_life_months: 12,
|
||||
cost_account: cost,
|
||||
accum_depr_account: accum,
|
||||
expense_account: expense,
|
||||
};
|
||||
// An expense account cannot hold the cost.
|
||||
let err = ledger
|
||||
.register_asset(NewAsset {
|
||||
cost_account: expense,
|
||||
..base()
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
ledger_core::LedgerError::AssetAccountWrongType { .. }
|
||||
));
|
||||
// An asset account cannot be the depreciation expense.
|
||||
let err = ledger
|
||||
.register_asset(NewAsset {
|
||||
expense_account: cost,
|
||||
..base()
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
ledger_core::LedgerError::AssetAccountWrongType { .. }
|
||||
));
|
||||
// The three links must be distinct accounts.
|
||||
let err = ledger
|
||||
.register_asset(NewAsset {
|
||||
accum_depr_account: cost,
|
||||
..base()
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
ledger_core::LedgerError::DuplicateAssetAccount(_)
|
||||
));
|
||||
// Unknown accounts are rejected.
|
||||
let err = ledger
|
||||
.register_asset(NewAsset {
|
||||
cost_account: 999,
|
||||
..base()
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ledger_core::LedgerError::UnknownAccount(999)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disposal_accepts_zero_proceeds() {
|
||||
// Scrapping an asset for nothing is legitimate: full loss.
|
||||
let (mut ledger, cost, accum, expense) = test_ledger();
|
||||
let id = van_asset(&mut ledger, 60_000, cost, accum, expense);
|
||||
let asset = ledger.get_asset(id).unwrap().clone();
|
||||
let d = disposal_amounts(&asset, date(2026, 6, 30), usd("0")).unwrap();
|
||||
assert!(d.gain_loss < Decimal::ZERO);
|
||||
assert_eq!(d.book_value + d.gain_loss, Decimal::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_assets_get_distinct_ids_and_list() {
|
||||
let (mut ledger, cost, accum, expense) = test_ledger();
|
||||
let id1 = van_asset(&mut ledger, 60_000, cost, accum, expense);
|
||||
let id2 = van_asset(&mut ledger, 30_000, cost, accum, expense);
|
||||
assert_ne!(id1, id2);
|
||||
let listed: Vec<_> = ledger.registered_assets().collect();
|
||||
assert_eq!(listed.len(), 2);
|
||||
assert!(listed.iter().any(|a| a.id == id1));
|
||||
assert!(listed.iter().any(|a| a.id == id2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_month_useful_life_is_valid() {
|
||||
let (mut ledger, cost, accum, expense) = test_ledger();
|
||||
let id = ledger
|
||||
.register_asset(NewAsset {
|
||||
description: "Cheap tool".to_string(),
|
||||
purchase_date: date(2026, 3, 10),
|
||||
cost: usd("300"),
|
||||
salvage_value: usd("0"),
|
||||
useful_life_months: 1,
|
||||
cost_account: cost,
|
||||
accum_depr_account: accum,
|
||||
expense_account: expense,
|
||||
})
|
||||
.unwrap();
|
||||
let asset = ledger.get_asset(id).unwrap();
|
||||
let sched = depreciation_schedule(asset, date(2026, 12, 31));
|
||||
// A one-month life starting Mar 10 spans two calendar months.
|
||||
assert_eq!(sched.len(), 2);
|
||||
// March: 22/31 of 300 = 212.90.
|
||||
assert_eq!(sched[0].amount, Decimal::new(21290, 2));
|
||||
// Lifetime total is exactly 300.00.
|
||||
let total: Decimal = sched.iter().map(|l| l.amount).sum();
|
||||
assert_eq!(total, Decimal::new(30000, 2));
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! 1099 tracking. Numbers computed by hand.
|
||||
//!
|
||||
//! 2026 payments:
|
||||
//! - Dana (1099-NEC, W-9): bill 1,500 paid by check + 700 direct = 2,200,
|
||||
//! over the 2,000 threshold. A 300 payment on 2025-12-30 counts in 2025.
|
||||
//! - Ace Rentals (1099-MISC): 1,200 twice, one reversed = 1,200, under.
|
||||
//! - Sam (1099-NEC): 5,000 by credit card = 0 cash: the card processor's
|
||||
//! 1099-K covers it.
|
||||
//! - Bigco (no 1099): 9,000, listed but never reportable.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::arap::DocumentLine;
|
||||
use ledger_core::form1099::default_threshold;
|
||||
use ledger_core::{
|
||||
AccountType, Currency, Form1099, Ledger, LedgerError, Money, Side, vendor_1099_report,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
use std::str::FromStr;
|
||||
|
||||
fn dec(s: &str) -> Decimal {
|
||||
Decimal::from_str(s).unwrap()
|
||||
}
|
||||
|
||||
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
||||
}
|
||||
|
||||
fn books() -> Ledger {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let bank = l
|
||||
.add_account("1000", "Bank", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let card = l
|
||||
.add_account("2100", "Credit Card", AccountType::Liability, None, false)
|
||||
.unwrap();
|
||||
let contract = l
|
||||
.add_account("6000", "Contract Labor", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let rent = l
|
||||
.add_account("6600", "Rent", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let dana = l.add_vendor("Dana").unwrap();
|
||||
let ace = l.add_vendor("Ace Rentals").unwrap();
|
||||
let sam = l.add_vendor("Sam").unwrap();
|
||||
let bigco = l.add_vendor("Bigco").unwrap();
|
||||
l.set_vendor_1099(dana, Some(Form1099::Nec), true).unwrap();
|
||||
l.set_vendor_1099(ace, Some(Form1099::Misc), false).unwrap();
|
||||
l.set_vendor_1099(sam, Some(Form1099::Nec), true).unwrap();
|
||||
let pay = |l: &mut Ledger, date, vendor, dr, cr, amt: &str| {
|
||||
let e = l.create_entry(date, "payment");
|
||||
let m = Money::new(dec(amt), Currency::USD);
|
||||
l.add_line(e, dr, Side::Debit, m).unwrap();
|
||||
l.add_line(e, cr, Side::Credit, m).unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
l.set_entry_vendor(e, Some(vendor)).unwrap();
|
||||
e
|
||||
};
|
||||
let bill = l
|
||||
.create_bill(
|
||||
dana,
|
||||
d(2026, 3, 1),
|
||||
d(2026, 3, 31),
|
||||
vec![DocumentLine {
|
||||
description: "Design work".into(),
|
||||
amount: dec("1500"),
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
l.pay_bill(bill, d(2026, 3, 20), dec("1500"), bank).unwrap();
|
||||
pay(&mut l, d(2026, 6, 1), dana, contract, bank, "700");
|
||||
pay(&mut l, d(2025, 12, 30), dana, contract, bank, "300");
|
||||
pay(&mut l, d(2026, 1, 1), ace, rent, bank, "1200");
|
||||
let wrong = pay(&mut l, d(2026, 2, 1), ace, rent, bank, "1200");
|
||||
l.reverse_entry(wrong, d(2026, 2, 2), "duplicate").unwrap();
|
||||
pay(&mut l, d(2026, 4, 1), sam, contract, card, "5000");
|
||||
pay(&mut l, d(2026, 5, 1), bigco, contract, bank, "9000");
|
||||
// Untagged spending is not anyone's 1099.
|
||||
let e = l.create_entry(d(2026, 7, 1), "untagged");
|
||||
let m = Money::new(dec("50"), Currency::USD);
|
||||
l.add_line(e, contract, Side::Debit, m).unwrap();
|
||||
l.add_line(e, bank, Side::Credit, m).unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
l
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cash_paid_per_vendor_and_who_is_reportable() {
|
||||
let l = books();
|
||||
let r = vendor_1099_report(&l, 2026, None);
|
||||
assert_eq!(r.threshold, dec("2000"));
|
||||
let rows: Vec<(&str, Decimal, bool)> = r
|
||||
.vendors
|
||||
.iter()
|
||||
.map(|v| (v.name.as_str(), v.paid, v.reportable))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
rows,
|
||||
[
|
||||
("Ace Rentals", dec("1200"), false),
|
||||
("Bigco", dec("9000"), false),
|
||||
("Dana", dec("2200"), true),
|
||||
("Sam", dec("0"), false),
|
||||
]
|
||||
);
|
||||
let ace = &r.vendors[0];
|
||||
assert_eq!(ace.form, Some(Form1099::Misc));
|
||||
assert!(!ace.tin_on_file);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn years_and_thresholds() {
|
||||
let l = books();
|
||||
assert_eq!(default_threshold(2025), dec("600"));
|
||||
assert_eq!(default_threshold(2026), dec("2000"));
|
||||
let y2025 = vendor_1099_report(&l, 2025, None);
|
||||
let dana = y2025.vendors.iter().find(|v| v.name == "Dana").unwrap();
|
||||
assert_eq!(dana.paid, dec("300"));
|
||||
assert!(!dana.reportable, "300 < 600");
|
||||
// An override: at 1,000, Ace's 1,200 is reportable.
|
||||
let r = vendor_1099_report(&l, 2026, Some(dec("1000")));
|
||||
assert!(
|
||||
r.vendors
|
||||
.iter()
|
||||
.any(|v| v.name == "Ace Rentals" && v.reportable)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_need_real_entries_and_vendors() {
|
||||
let mut l = books();
|
||||
assert!(matches!(
|
||||
l.set_entry_vendor(999, Some(1)),
|
||||
Err(LedgerError::UnknownEntry(999))
|
||||
));
|
||||
assert!(matches!(
|
||||
l.set_entry_vendor(1, Some(999)),
|
||||
Err(LedgerError::UnknownVendor(999))
|
||||
));
|
||||
// The bill and its payment were tagged automatically.
|
||||
assert_eq!(l.entry_vendor(1), Some(1));
|
||||
assert_eq!(l.entry_vendor(2), Some(1));
|
||||
l.set_entry_vendor(2, None).unwrap();
|
||||
assert_eq!(l.entry_vendor(2), None);
|
||||
assert_eq!(Form1099::parse("1099-NEC"), Some(Form1099::Nec));
|
||||
assert_eq!(Form1099::parse("misc"), Some(Form1099::Misc));
|
||||
assert_eq!(Form1099::parse("K"), None);
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
[
|
||||
{
|
||||
"accumulated": "548.39",
|
||||
"amount": "548.39",
|
||||
"book_value": "59451.61",
|
||||
"period": "2026-01"
|
||||
},
|
||||
{
|
||||
"accumulated": "1548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "58451.61",
|
||||
"period": "2026-02"
|
||||
},
|
||||
{
|
||||
"accumulated": "2548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "57451.61",
|
||||
"period": "2026-03"
|
||||
},
|
||||
{
|
||||
"accumulated": "3548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "56451.61",
|
||||
"period": "2026-04"
|
||||
},
|
||||
{
|
||||
"accumulated": "4548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "55451.61",
|
||||
"period": "2026-05"
|
||||
},
|
||||
{
|
||||
"accumulated": "5548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "54451.61",
|
||||
"period": "2026-06"
|
||||
},
|
||||
{
|
||||
"accumulated": "6548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "53451.61",
|
||||
"period": "2026-07"
|
||||
},
|
||||
{
|
||||
"accumulated": "7548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "52451.61",
|
||||
"period": "2026-08"
|
||||
},
|
||||
{
|
||||
"accumulated": "8548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "51451.61",
|
||||
"period": "2026-09"
|
||||
},
|
||||
{
|
||||
"accumulated": "9548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "50451.61",
|
||||
"period": "2026-10"
|
||||
},
|
||||
{
|
||||
"accumulated": "10548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "49451.61",
|
||||
"period": "2026-11"
|
||||
},
|
||||
{
|
||||
"accumulated": "11548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "48451.61",
|
||||
"period": "2026-12"
|
||||
},
|
||||
{
|
||||
"accumulated": "12548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "47451.61",
|
||||
"period": "2027-01"
|
||||
},
|
||||
{
|
||||
"accumulated": "13548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "46451.61",
|
||||
"period": "2027-02"
|
||||
},
|
||||
{
|
||||
"accumulated": "14548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "45451.61",
|
||||
"period": "2027-03"
|
||||
},
|
||||
{
|
||||
"accumulated": "15548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "44451.61",
|
||||
"period": "2027-04"
|
||||
},
|
||||
{
|
||||
"accumulated": "16548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "43451.61",
|
||||
"period": "2027-05"
|
||||
},
|
||||
{
|
||||
"accumulated": "17548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "42451.61",
|
||||
"period": "2027-06"
|
||||
},
|
||||
{
|
||||
"accumulated": "18548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "41451.61",
|
||||
"period": "2027-07"
|
||||
},
|
||||
{
|
||||
"accumulated": "19548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "40451.61",
|
||||
"period": "2027-08"
|
||||
},
|
||||
{
|
||||
"accumulated": "20548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "39451.61",
|
||||
"period": "2027-09"
|
||||
},
|
||||
{
|
||||
"accumulated": "21548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "38451.61",
|
||||
"period": "2027-10"
|
||||
},
|
||||
{
|
||||
"accumulated": "22548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "37451.61",
|
||||
"period": "2027-11"
|
||||
},
|
||||
{
|
||||
"accumulated": "23548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "36451.61",
|
||||
"period": "2027-12"
|
||||
},
|
||||
{
|
||||
"accumulated": "24548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "35451.61",
|
||||
"period": "2028-01"
|
||||
},
|
||||
{
|
||||
"accumulated": "25548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "34451.61",
|
||||
"period": "2028-02"
|
||||
},
|
||||
{
|
||||
"accumulated": "26548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "33451.61",
|
||||
"period": "2028-03"
|
||||
},
|
||||
{
|
||||
"accumulated": "27548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "32451.61",
|
||||
"period": "2028-04"
|
||||
},
|
||||
{
|
||||
"accumulated": "28548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "31451.61",
|
||||
"period": "2028-05"
|
||||
},
|
||||
{
|
||||
"accumulated": "29548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "30451.61",
|
||||
"period": "2028-06"
|
||||
},
|
||||
{
|
||||
"accumulated": "30548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "29451.61",
|
||||
"period": "2028-07"
|
||||
},
|
||||
{
|
||||
"accumulated": "31548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "28451.61",
|
||||
"period": "2028-08"
|
||||
},
|
||||
{
|
||||
"accumulated": "32548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "27451.61",
|
||||
"period": "2028-09"
|
||||
},
|
||||
{
|
||||
"accumulated": "33548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "26451.61",
|
||||
"period": "2028-10"
|
||||
},
|
||||
{
|
||||
"accumulated": "34548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "25451.61",
|
||||
"period": "2028-11"
|
||||
},
|
||||
{
|
||||
"accumulated": "35548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "24451.61",
|
||||
"period": "2028-12"
|
||||
},
|
||||
{
|
||||
"accumulated": "36548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "23451.61",
|
||||
"period": "2029-01"
|
||||
},
|
||||
{
|
||||
"accumulated": "37548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "22451.61",
|
||||
"period": "2029-02"
|
||||
},
|
||||
{
|
||||
"accumulated": "38548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "21451.61",
|
||||
"period": "2029-03"
|
||||
},
|
||||
{
|
||||
"accumulated": "39548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "20451.61",
|
||||
"period": "2029-04"
|
||||
},
|
||||
{
|
||||
"accumulated": "40548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "19451.61",
|
||||
"period": "2029-05"
|
||||
},
|
||||
{
|
||||
"accumulated": "41548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "18451.61",
|
||||
"period": "2029-06"
|
||||
},
|
||||
{
|
||||
"accumulated": "42548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "17451.61",
|
||||
"period": "2029-07"
|
||||
},
|
||||
{
|
||||
"accumulated": "43548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "16451.61",
|
||||
"period": "2029-08"
|
||||
},
|
||||
{
|
||||
"accumulated": "44548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "15451.61",
|
||||
"period": "2029-09"
|
||||
},
|
||||
{
|
||||
"accumulated": "45548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "14451.61",
|
||||
"period": "2029-10"
|
||||
},
|
||||
{
|
||||
"accumulated": "46548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "13451.61",
|
||||
"period": "2029-11"
|
||||
},
|
||||
{
|
||||
"accumulated": "47548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "12451.61",
|
||||
"period": "2029-12"
|
||||
},
|
||||
{
|
||||
"accumulated": "48548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "11451.61",
|
||||
"period": "2030-01"
|
||||
},
|
||||
{
|
||||
"accumulated": "49548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "10451.61",
|
||||
"period": "2030-02"
|
||||
},
|
||||
{
|
||||
"accumulated": "50548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "9451.61",
|
||||
"period": "2030-03"
|
||||
},
|
||||
{
|
||||
"accumulated": "51548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "8451.61",
|
||||
"period": "2030-04"
|
||||
},
|
||||
{
|
||||
"accumulated": "52548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "7451.61",
|
||||
"period": "2030-05"
|
||||
},
|
||||
{
|
||||
"accumulated": "53548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "6451.61",
|
||||
"period": "2030-06"
|
||||
},
|
||||
{
|
||||
"accumulated": "54548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "5451.61",
|
||||
"period": "2030-07"
|
||||
},
|
||||
{
|
||||
"accumulated": "55548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "4451.61",
|
||||
"period": "2030-08"
|
||||
},
|
||||
{
|
||||
"accumulated": "56548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "3451.61",
|
||||
"period": "2030-09"
|
||||
},
|
||||
{
|
||||
"accumulated": "57548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "2451.61",
|
||||
"period": "2030-10"
|
||||
},
|
||||
{
|
||||
"accumulated": "58548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "1451.61",
|
||||
"period": "2030-11"
|
||||
},
|
||||
{
|
||||
"accumulated": "59548.39",
|
||||
"amount": "1000.00",
|
||||
"book_value": "451.61",
|
||||
"period": "2030-12"
|
||||
},
|
||||
{
|
||||
"accumulated": "60000",
|
||||
"amount": "451.61",
|
||||
"book_value": "0",
|
||||
"period": "2031-01"
|
||||
}
|
||||
]
|
||||
@@ -11,7 +11,9 @@
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::reports::{balance_sheet, profit_and_loss, snapshot, trial_balance};
|
||||
use ledger_core::{AccountId, AccountType, Currency, EntryStatus, Ledger, LedgerError, Money, Side};
|
||||
use ledger_core::{
|
||||
AccountId, AccountType, Currency, EntryStatus, Ledger, LedgerError, Money, Side,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -49,15 +51,29 @@ struct Company {
|
||||
|
||||
fn fixture_company() -> Company {
|
||||
let mut ledger = Ledger::new(Currency::USD);
|
||||
let cash = ledger.add_account("1000", "Cash", AccountType::Asset, None, false).unwrap();
|
||||
let cash = ledger
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let ar = ledger
|
||||
.add_account("1200", "Accounts Receivable", AccountType::Asset, None, false)
|
||||
.add_account(
|
||||
"1200",
|
||||
"Accounts Receivable",
|
||||
AccountType::Asset,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let equipment = ledger
|
||||
.add_account("1500", "Equipment", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let ap = ledger
|
||||
.add_account("2000", "Accounts Payable", AccountType::Liability, None, false)
|
||||
.add_account(
|
||||
"2000",
|
||||
"Accounts Payable",
|
||||
AccountType::Liability,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let equity = ledger
|
||||
.add_account("3000", "Owner's Equity", AccountType::Equity, None, false)
|
||||
@@ -70,32 +86,51 @@ fn fixture_company() -> Company {
|
||||
.unwrap();
|
||||
|
||||
// Owner invests $10,000 cash.
|
||||
post(&mut ledger, date(2), "Owner investment", &[
|
||||
(cash, Side::Debit, 10_000),
|
||||
(equity, Side::Credit, 10_000),
|
||||
]);
|
||||
post(
|
||||
&mut ledger,
|
||||
date(2),
|
||||
"Owner investment",
|
||||
&[(cash, Side::Debit, 10_000), (equity, Side::Credit, 10_000)],
|
||||
);
|
||||
// Buy equipment for $2,500 cash.
|
||||
post(&mut ledger, date(5), "Buy equipment", &[
|
||||
(equipment, Side::Debit, 2_500),
|
||||
(cash, Side::Credit, 2_500),
|
||||
]);
|
||||
post(
|
||||
&mut ledger,
|
||||
date(5),
|
||||
"Buy equipment",
|
||||
&[(equipment, Side::Debit, 2_500), (cash, Side::Credit, 2_500)],
|
||||
);
|
||||
// Bill a client $4,000.
|
||||
post(&mut ledger, date(10), "Invoice client", &[
|
||||
(ar, Side::Debit, 4_000),
|
||||
(revenue, Side::Credit, 4_000),
|
||||
]);
|
||||
post(
|
||||
&mut ledger,
|
||||
date(10),
|
||||
"Invoice client",
|
||||
&[(ar, Side::Debit, 4_000), (revenue, Side::Credit, 4_000)],
|
||||
);
|
||||
// Pay January rent, $800.
|
||||
post(&mut ledger, date(15), "January rent", &[
|
||||
(rent, Side::Debit, 800),
|
||||
(cash, Side::Credit, 800),
|
||||
]);
|
||||
post(
|
||||
&mut ledger,
|
||||
date(15),
|
||||
"January rent",
|
||||
&[(rent, Side::Debit, 800), (cash, Side::Credit, 800)],
|
||||
);
|
||||
// Collect $1,500 of receivables.
|
||||
post(&mut ledger, date(20), "Client payment", &[
|
||||
(cash, Side::Debit, 1_500),
|
||||
(ar, Side::Credit, 1_500),
|
||||
]);
|
||||
post(
|
||||
&mut ledger,
|
||||
date(20),
|
||||
"Client payment",
|
||||
&[(cash, Side::Debit, 1_500), (ar, Side::Credit, 1_500)],
|
||||
);
|
||||
|
||||
Company { ledger, cash, ar, equipment, _ap: ap, equity, revenue, rent }
|
||||
Company {
|
||||
ledger,
|
||||
cash,
|
||||
ar,
|
||||
equipment,
|
||||
_ap: ap,
|
||||
equity,
|
||||
revenue,
|
||||
rent,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -108,7 +143,9 @@ fn unbalanced_entry_is_rejected() {
|
||||
let mut ledger = c.ledger;
|
||||
let id = ledger.create_entry(date(21), "Bad entry");
|
||||
ledger.add_line(id, c.cash, Side::Debit, usd(100)).unwrap();
|
||||
ledger.add_line(id, c.equity, Side::Credit, usd(99)).unwrap();
|
||||
ledger
|
||||
.add_line(id, c.equity, Side::Credit, usd(99))
|
||||
.unwrap();
|
||||
let err = ledger.post_entry(id).unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
@@ -135,7 +172,9 @@ fn zero_amount_line_is_rejected() {
|
||||
let id = ledger.create_entry(date(21), "Zero line");
|
||||
ledger.add_line(id, c.cash, Side::Debit, usd(100)).unwrap();
|
||||
ledger.add_line(id, c.cash, Side::Debit, usd(0)).unwrap();
|
||||
ledger.add_line(id, c.equity, Side::Credit, usd(100)).unwrap();
|
||||
ledger
|
||||
.add_line(id, c.equity, Side::Credit, usd(100))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ledger.post_entry(id).unwrap_err(),
|
||||
LedgerError::ZeroAmountLine
|
||||
@@ -148,7 +187,9 @@ fn unknown_account_is_rejected() {
|
||||
let mut ledger = c.ledger;
|
||||
let id = ledger.create_entry(date(21), "Ghost account");
|
||||
ledger.add_line(id, c.cash, Side::Debit, usd(100)).unwrap();
|
||||
ledger.add_line(id, 999_999, Side::Credit, usd(100)).unwrap();
|
||||
ledger
|
||||
.add_line(id, 999_999, Side::Credit, usd(100))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ledger.post_entry(id).unwrap_err(),
|
||||
LedgerError::UnknownAccount(999_999)
|
||||
@@ -164,7 +205,9 @@ fn placeholder_account_is_rejected() {
|
||||
.unwrap();
|
||||
let id = ledger.create_entry(date(21), "Post to header");
|
||||
ledger.add_line(id, header, Side::Debit, usd(100)).unwrap();
|
||||
ledger.add_line(id, c.equity, Side::Credit, usd(100)).unwrap();
|
||||
ledger
|
||||
.add_line(id, c.equity, Side::Credit, usd(100))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ledger.post_entry(id).unwrap_err(),
|
||||
LedgerError::PlaceholderAccount("100".to_string())
|
||||
@@ -207,12 +250,16 @@ fn posted_entry_is_immutable() {
|
||||
let mut ledger = c.ledger;
|
||||
let id = ledger.create_entry(date(21), "To post");
|
||||
ledger.add_line(id, c.cash, Side::Debit, usd(50)).unwrap();
|
||||
ledger.add_line(id, c.equity, Side::Credit, usd(50)).unwrap();
|
||||
ledger
|
||||
.add_line(id, c.equity, Side::Credit, usd(50))
|
||||
.unwrap();
|
||||
ledger.post_entry(id).unwrap();
|
||||
|
||||
// Cannot add lines after posting.
|
||||
assert_eq!(
|
||||
ledger.add_line(id, c.cash, Side::Debit, usd(1)).unwrap_err(),
|
||||
ledger
|
||||
.add_line(id, c.cash, Side::Debit, usd(1))
|
||||
.unwrap_err(),
|
||||
LedgerError::PostedEntryImmutable
|
||||
);
|
||||
// Cannot post twice.
|
||||
@@ -233,7 +280,9 @@ fn reversal_nets_to_zero_and_links_back() {
|
||||
ledger.post_entry(id).unwrap();
|
||||
assert_eq!(ledger.account_movement(c.rent), Decimal::from(1300));
|
||||
|
||||
let rev = ledger.reverse_entry(id, date(22), "Reverse mistake").unwrap();
|
||||
let rev = ledger
|
||||
.reverse_entry(id, date(22), "Reverse mistake")
|
||||
.unwrap();
|
||||
let rev_entry = ledger.get_entry(rev).unwrap();
|
||||
assert_eq!(rev_entry.reversal_of(), Some(id));
|
||||
assert_eq!(rev_entry.status(), EntryStatus::Posted);
|
||||
@@ -319,7 +368,15 @@ fn random_balanced_journals_preserve_invariants() {
|
||||
let mut rng = Lcg(0x1234_5678_9abc_def0);
|
||||
let c = fixture_company();
|
||||
let mut ledger = c.ledger;
|
||||
let accounts = [c.cash, c.ar, c.equipment, c._ap, c.equity, c.revenue, c.rent];
|
||||
let accounts = [
|
||||
c.cash,
|
||||
c.ar,
|
||||
c.equipment,
|
||||
c._ap,
|
||||
c.equity,
|
||||
c.revenue,
|
||||
c.rent,
|
||||
];
|
||||
|
||||
for i in 0..300u32 {
|
||||
let total = rng.next(100_000) + 100;
|
||||
@@ -362,3 +419,217 @@ fn golden_fixture_company_reports() {
|
||||
serde_json::from_str(include_str!("golden/fixture_company.json")).unwrap();
|
||||
assert_eq!(snap, golden);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_account_validation() {
|
||||
let mut ledger = Ledger::new(Currency::USD);
|
||||
// Unknown parent is rejected.
|
||||
let err = ledger
|
||||
.add_account("1100", "Child", AccountType::Asset, Some(999), false)
|
||||
.unwrap_err();
|
||||
assert_eq!(err, LedgerError::UnknownParentAccount(999));
|
||||
// A real parent works, and the child records it.
|
||||
let parent = ledger
|
||||
.add_account("1000", "Parent", AccountType::Asset, None, true)
|
||||
.unwrap();
|
||||
let child = ledger
|
||||
.add_account("1100", "Child", AccountType::Asset, Some(parent), false)
|
||||
.unwrap();
|
||||
assert_eq!(ledger.chart().get(child).unwrap().parent, Some(parent));
|
||||
// Chart accessors report honestly.
|
||||
assert!(Ledger::new(Currency::USD).chart().is_empty());
|
||||
assert!(!ledger.chart().is_empty());
|
||||
assert_eq!(ledger.chart().len(), 2);
|
||||
assert!(ledger.chart().find_by_code("1000").is_some());
|
||||
assert!(ledger.chart().find_by_code("nope").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_type_balance_sheet_predicate() {
|
||||
assert!(AccountType::Asset.is_balance_sheet());
|
||||
assert!(AccountType::Liability.is_balance_sheet());
|
||||
assert!(AccountType::Equity.is_balance_sheet());
|
||||
assert!(!AccountType::Revenue.is_balance_sheet());
|
||||
assert!(!AccountType::Expense.is_balance_sheet());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_movements_sum_to_zero() {
|
||||
let mut c = fixture_company();
|
||||
let cash = c.cash;
|
||||
let revenue = c.revenue;
|
||||
let id = c.ledger.create_entry(date(15), "sale");
|
||||
c.ledger.add_line(id, cash, Side::Debit, usd(1000)).unwrap();
|
||||
c.ledger
|
||||
.add_line(id, revenue, Side::Credit, usd(1000))
|
||||
.unwrap();
|
||||
c.ledger.post_entry(id).unwrap();
|
||||
let entry = c.ledger.get_entry(id).unwrap();
|
||||
let m = entry.movements();
|
||||
assert_eq!(m[&cash], Decimal::new(100000, 2));
|
||||
assert_eq!(m[&revenue], Decimal::new(-100000, 2));
|
||||
let total: Decimal = m.values().copied().sum();
|
||||
assert_eq!(total, Decimal::ZERO);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cash flow statement (indirect method).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use ledger_core::NewAsset;
|
||||
use ledger_core::reports::{CashFlowParams, cash_flow};
|
||||
|
||||
fn cash_flow_params(cash: AccountId) -> CashFlowParams {
|
||||
CashFlowParams {
|
||||
start: date(1),
|
||||
end: date(31),
|
||||
cash_accounts: vec![cash],
|
||||
financing_accounts: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cash_flow_reconciles_to_change_in_cash() {
|
||||
let c = fixture_company();
|
||||
let cf = cash_flow(&c.ledger, &cash_flow_params(c.cash)).unwrap();
|
||||
|
||||
// The three sections must sum to the net change in cash. This is the
|
||||
// accounting law the statement exists to uphold.
|
||||
let sections = cf.cash_from_operations + cf.cash_from_investing + cf.cash_from_financing;
|
||||
assert_eq!(sections, cf.net_change_in_cash);
|
||||
assert_eq!(cf.cash_ending - cf.cash_beginning, cf.net_change_in_cash);
|
||||
|
||||
// Hand-computed from the fixture:
|
||||
// Cash: +10,000 (invest) - 2,500 (equipment) - 800 (rent) + 1,500 (collect)
|
||||
// = 8,200. Beginning 0.
|
||||
assert_eq!(cf.cash_beginning, Decimal::ZERO);
|
||||
assert_eq!(cf.cash_ending, Decimal::new(820000, 2));
|
||||
assert_eq!(cf.net_change_in_cash, Decimal::new(820000, 2));
|
||||
|
||||
// Net income: 4,000 revenue - 800 rent = 3,200.
|
||||
assert_eq!(cf.net_income, Decimal::new(320000, 2));
|
||||
// No depreciation: the fixture's equipment is not a registered asset.
|
||||
assert_eq!(cf.depreciation, Decimal::ZERO);
|
||||
|
||||
// Operating: 3,200 net income
|
||||
// - 2,500 increase in AR (4,000 billed - 1,500 collected)
|
||||
// - 2,500 equipment purchase (not a registered asset: working capital)
|
||||
// + 10,000 owner investment is equity -> financing, not operating.
|
||||
// Financing: +10,000 owner contribution.
|
||||
// => operating = 3,200 - 2,500 - 2,500 = -1,800
|
||||
// => investing = 0, financing = 10,000. Sum = 8,200. Reconciles.
|
||||
assert_eq!(cf.cash_from_operations, Decimal::new(-180000, 2));
|
||||
assert_eq!(cf.cash_from_investing, Decimal::ZERO);
|
||||
assert_eq!(cf.cash_from_financing, Decimal::new(1000000, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cash_flow_with_registered_asset_and_disposal() {
|
||||
let mut ledger = Ledger::new(Currency::USD);
|
||||
let cash = ledger
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let cost = ledger
|
||||
.add_account("1500", "Equipment at cost", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let accum = ledger
|
||||
.add_account(
|
||||
"1501",
|
||||
"Accum. depr. — equip.",
|
||||
AccountType::Asset,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let depr_exp = ledger
|
||||
.add_account(
|
||||
"6000",
|
||||
"Depreciation expense",
|
||||
AccountType::Expense,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
ledger
|
||||
.add_account("4000", "Service Revenue", AccountType::Revenue, None, false)
|
||||
.unwrap();
|
||||
let gain = ledger
|
||||
.add_account("4100", "Gain on sale", AccountType::Revenue, None, false)
|
||||
.unwrap();
|
||||
let equity = ledger
|
||||
.add_account("3000", "Owner's Equity", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
|
||||
// Owner invests $100,000.
|
||||
post(
|
||||
&mut ledger,
|
||||
date(2),
|
||||
"Owner investment",
|
||||
&[
|
||||
(cash, Side::Debit, 100_000),
|
||||
(equity, Side::Credit, 100_000),
|
||||
],
|
||||
);
|
||||
// Register a $60,000 van, 60-month life, bought Jan 15.
|
||||
let asset = ledger
|
||||
.register_asset(NewAsset {
|
||||
description: "Van".to_string(),
|
||||
purchase_date: NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
|
||||
cost: usd(60_000),
|
||||
salvage_value: usd(0),
|
||||
useful_life_months: 60,
|
||||
cost_account: cost,
|
||||
accum_depr_account: accum,
|
||||
expense_account: depr_exp,
|
||||
})
|
||||
.unwrap();
|
||||
// Buy the van for cash.
|
||||
post(
|
||||
&mut ledger,
|
||||
date(15),
|
||||
"Buy van",
|
||||
&[(cost, Side::Debit, 60_000), (cash, Side::Credit, 60_000)],
|
||||
);
|
||||
// January depreciation: $1,000 * 17/31 = $548.39.
|
||||
post(
|
||||
&mut ledger,
|
||||
date(31),
|
||||
"January depreciation",
|
||||
&[(depr_exp, Side::Debit, 548), (accum, Side::Credit, 548)],
|
||||
);
|
||||
// Sell the van Jan 31 for $59,600 cash. Book value 60,000 - 548 = 59,452.
|
||||
// Gain = 59,600 - 59,452 = 148.
|
||||
// Note: amounts are whole dollars here to keep the fixture simple.
|
||||
let _ = asset;
|
||||
post(
|
||||
&mut ledger,
|
||||
date(31),
|
||||
"Sell van",
|
||||
&[
|
||||
(cash, Side::Debit, 59_600),
|
||||
(accum, Side::Debit, 548),
|
||||
(cost, Side::Credit, 60_000),
|
||||
(gain, Side::Credit, 148),
|
||||
],
|
||||
);
|
||||
|
||||
let cf = cash_flow(&ledger, &cash_flow_params(cash)).unwrap();
|
||||
|
||||
// Reconciliation is the law.
|
||||
let sections = cf.cash_from_operations + cf.cash_from_investing + cf.cash_from_financing;
|
||||
assert_eq!(sections, cf.net_change_in_cash);
|
||||
|
||||
// Cash: 100,000 - 60,000 + 59,600 = 99,600.
|
||||
assert_eq!(cf.cash_ending, Decimal::new(9960000, 2));
|
||||
// Net income: 148 gain - 548 depreciation = -400.
|
||||
assert_eq!(cf.net_income, Decimal::new(-40000, 2));
|
||||
// Depreciation added back: 548.
|
||||
assert_eq!(cf.depreciation, Decimal::new(54800, 2));
|
||||
// Operating: -400 + 548 - 148 (gain removed) = 0.
|
||||
assert_eq!(cf.cash_from_operations, Decimal::ZERO);
|
||||
// Investing: -60,000 purchase + 59,600 proceeds = -400.
|
||||
assert_eq!(cf.cash_from_investing, Decimal::new(-40000, 2));
|
||||
// Financing: +100,000 owner investment.
|
||||
assert_eq!(cf.cash_from_financing, Decimal::new(10000000, 2));
|
||||
// Sum: 0 - 400 + 100,000 = 99,600. Matches cash.
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
//! Payout gross-up. Numbers computed by hand.
|
||||
//!
|
||||
//! An app-store month: App A sold 1,000.00, App B 500.00; the store kept
|
||||
//! 15% (225.00) and clawed back a 9.99 refund. Deposit: 1,500.00 − 225.00
|
||||
//! − 9.99 = 1,265.01.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::reports::profit_and_loss_for_period;
|
||||
use ledger_core::{
|
||||
AccountType, Currency, Ledger, LedgerError, Money, Payout, PayoutAdjustment, PayoutLine, Side,
|
||||
payout_plan,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
use std::str::FromStr;
|
||||
|
||||
fn dec(s: &str) -> Decimal {
|
||||
Decimal::from_str(s).unwrap()
|
||||
}
|
||||
|
||||
fn d(day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, 11, day).unwrap()
|
||||
}
|
||||
|
||||
struct Books {
|
||||
l: Ledger,
|
||||
bank: u64,
|
||||
sales: u64,
|
||||
fees: u64,
|
||||
refunds: u64,
|
||||
}
|
||||
|
||||
fn books() -> Books {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let mut a = |code: &str, name: &str, t| l.add_account(code, name, t, None, false).unwrap();
|
||||
let bank = a("1000", "Bank", AccountType::Asset);
|
||||
let sales = a("4000", "App Sales", AccountType::Revenue);
|
||||
let fees = a("6000", "Platform Fees", AccountType::Expense);
|
||||
let refunds = a("4100", "Refunds", AccountType::Expense);
|
||||
Books {
|
||||
l,
|
||||
bank,
|
||||
sales,
|
||||
fees,
|
||||
refunds,
|
||||
}
|
||||
}
|
||||
|
||||
fn line(account: u64, amount: &str) -> PayoutLine {
|
||||
PayoutLine {
|
||||
account,
|
||||
amount: dec(amount),
|
||||
class: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn month(b: &Books, deposit: &str) -> Payout {
|
||||
Payout {
|
||||
deposit_account: b.bank,
|
||||
deposit: dec(deposit),
|
||||
revenue: vec![line(b.sales, "1000.00"), line(b.sales, "500.00")],
|
||||
fees: vec![line(b.fees, "225.00")],
|
||||
adjustments: vec![PayoutAdjustment {
|
||||
account: b.refunds,
|
||||
side: Side::Debit,
|
||||
amount: dec("9.99"),
|
||||
class: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_payout_is_grossed_up_to_revenue_and_fees() {
|
||||
let mut b = books();
|
||||
let (plan, totals) = payout_plan(&month(&b, "1265.01"), b.l.chart()).unwrap();
|
||||
assert_eq!(totals.gross, dec("1500.00"));
|
||||
assert_eq!(totals.fees, dec("225.00"));
|
||||
assert_eq!(totals.adjustments, dec("-9.99"));
|
||||
assert_eq!(
|
||||
plan.iter()
|
||||
.map(|(a, s, m, _)| (*a, *s, *m))
|
||||
.collect::<Vec<_>>(),
|
||||
[
|
||||
(b.bank, Side::Debit, dec("1265.01")),
|
||||
(b.fees, Side::Debit, dec("225.00")),
|
||||
(b.refunds, Side::Debit, dec("9.99")),
|
||||
(b.sales, Side::Credit, dec("1000.00")),
|
||||
(b.sales, Side::Credit, dec("500.00")),
|
||||
]
|
||||
);
|
||||
let e = b.l.create_entry(d(30), "App store payout");
|
||||
for (a, s, m, _) in plan {
|
||||
b.l.add_line(e, a, s, Money::new(m, Currency::USD)).unwrap();
|
||||
}
|
||||
b.l.post_entry(e).unwrap();
|
||||
let pl = profit_and_loss_for_period(&b.l, d(1), d(30), None).unwrap();
|
||||
assert_eq!(pl.total_revenue, dec("1500.00"));
|
||||
assert_eq!(pl.total_expenses, dec("234.99"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_payout_must_reconcile_to_the_cent() {
|
||||
let b = books();
|
||||
let err = payout_plan(&month(&b, "1265.00"), b.l.chart()).unwrap_err();
|
||||
let LedgerError::InvalidPayout(msg) = err else {
|
||||
panic!("{err:?}")
|
||||
};
|
||||
assert!(msg.contains("= 1265.01"), "{msg}");
|
||||
assert!(msg.contains("off by -0.01"), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_negative_payout_credits_the_bank() {
|
||||
let b = books();
|
||||
// 10.00 of sales, 3.00 of fees, a 50.00 refund: the store debits 43.00.
|
||||
let p = Payout {
|
||||
deposit_account: b.bank,
|
||||
deposit: dec("-43.00"),
|
||||
revenue: vec![line(b.sales, "10.00")],
|
||||
fees: vec![line(b.fees, "3.00")],
|
||||
adjustments: vec![PayoutAdjustment {
|
||||
account: b.refunds,
|
||||
side: Side::Debit,
|
||||
amount: dec("50.00"),
|
||||
class: None,
|
||||
}],
|
||||
};
|
||||
let (plan, _) = payout_plan(&p, b.l.chart()).unwrap();
|
||||
assert_eq!(plan[0], (b.bank, Side::Credit, dec("43.00"), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accounts_must_fit_their_roles() {
|
||||
let b = books();
|
||||
let invalid = |p: Payout| {
|
||||
matches!(
|
||||
payout_plan(&p, b.l.chart()),
|
||||
Err(LedgerError::InvalidPayout(_))
|
||||
)
|
||||
};
|
||||
let mut p = month(&b, "1265.01");
|
||||
p.fees[0].account = b.sales;
|
||||
assert!(invalid(p), "fees must be expenses");
|
||||
let mut p = month(&b, "1265.01");
|
||||
p.revenue[0].account = b.fees;
|
||||
assert!(invalid(p), "revenue must be revenue or a liability");
|
||||
let mut p = month(&b, "1265.01");
|
||||
p.deposit_account = b.sales;
|
||||
assert!(invalid(p), "deposits land in an asset");
|
||||
let mut p = month(&b, "1265.01");
|
||||
p.fees[0].amount = dec("-225.00");
|
||||
assert!(invalid(p), "amounts are positive");
|
||||
let mut p = month(&b, "1265.01");
|
||||
p.revenue.clear();
|
||||
assert!(invalid(p), "needs revenue");
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
//! Property tests for the ledger engine: invariants that must hold for
|
||||
//! *every* input, not just the hand-picked examples in `invariants.rs`.
|
||||
//!
|
||||
//! These are the properties mutation testing will try to break: if a
|
||||
//! mutant survives the example tests but breaks a property, the property
|
||||
//! catches it.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::{
|
||||
AccountType, Currency, Ledger, Money, NewAsset, Side, accumulated_depreciation, book_value,
|
||||
depreciation_schedule,
|
||||
reports::{
|
||||
balance_sheet, balance_sheet_as_of, profit_and_loss, profit_and_loss_for_period,
|
||||
trial_balance,
|
||||
},
|
||||
};
|
||||
use proptest::prelude::*;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
fn arb_account_type() -> impl Strategy<Value = AccountType> {
|
||||
prop_oneof![
|
||||
Just(AccountType::Asset),
|
||||
Just(AccountType::Liability),
|
||||
Just(AccountType::Equity),
|
||||
Just(AccountType::Revenue),
|
||||
Just(AccountType::Expense),
|
||||
]
|
||||
}
|
||||
|
||||
/// A random chart: 2..=6 accounts with unique codes, no placeholders.
|
||||
fn arb_chart() -> impl Strategy<Value = Vec<(String, String, AccountType)>> {
|
||||
prop::collection::vec((1u32..9999u32, arb_account_type()), 2..=6).prop_map(|v| {
|
||||
v.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, (_, t))| (format!("{:04}", 1000 + i), format!("Account {i}"), t))
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct GenEntry {
|
||||
lines: Vec<(u64, Side, Decimal)>,
|
||||
date: NaiveDate,
|
||||
}
|
||||
|
||||
/// A random *balanced* entry over `num_accounts` (1-based ids): 2..=4
|
||||
/// random lines plus a plug line for the imbalance. Amounts are whole
|
||||
/// cents, so the plug is always exact.
|
||||
fn arb_entry(num_accounts: usize) -> impl Strategy<Value = GenEntry> {
|
||||
(
|
||||
prop::collection::vec(
|
||||
(
|
||||
1u64..=(num_accounts as u64),
|
||||
prop_oneof![Just(Side::Debit), Just(Side::Credit)],
|
||||
1u32..1_000_000u32,
|
||||
),
|
||||
2..=4,
|
||||
),
|
||||
1u32..=12u32,
|
||||
1u32..=28u32,
|
||||
)
|
||||
.prop_map(|(raw, month, day)| {
|
||||
let mut lines: Vec<(u64, Side, Decimal)> = raw
|
||||
.into_iter()
|
||||
.map(|(a, s, cents)| (a, s, Decimal::new(cents as i64, 2)))
|
||||
.collect();
|
||||
let net: Decimal = lines
|
||||
.iter()
|
||||
.map(|(_, s, amt)| match s {
|
||||
Side::Debit => *amt,
|
||||
Side::Credit => -*amt,
|
||||
})
|
||||
.sum();
|
||||
if net != Decimal::ZERO {
|
||||
let (side, amt) = if net > Decimal::ZERO {
|
||||
(Side::Credit, net)
|
||||
} else {
|
||||
(Side::Debit, -net)
|
||||
};
|
||||
lines.push((1, side, amt));
|
||||
}
|
||||
GenEntry {
|
||||
lines,
|
||||
date: NaiveDate::from_ymd_opt(2026, month, day).unwrap(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn arb_ops() -> impl Strategy<Value = (Vec<(String, String, AccountType)>, Vec<GenEntry>)> {
|
||||
arb_chart().prop_flat_map(|chart| {
|
||||
let n = chart.len();
|
||||
(Just(chart), prop::collection::vec(arb_entry(n), 0..=10))
|
||||
})
|
||||
}
|
||||
|
||||
fn build_ledger(
|
||||
chart: &[(String, String, AccountType)],
|
||||
entries: &[GenEntry],
|
||||
) -> (Ledger, Vec<u64>) {
|
||||
let mut ledger = Ledger::new(Currency::USD);
|
||||
for (code, name, typ) in chart {
|
||||
ledger.add_account(code, name, *typ, None, false).unwrap();
|
||||
}
|
||||
let mut ids = Vec::new();
|
||||
for e in entries {
|
||||
let id = ledger.create_entry(e.date, "prop");
|
||||
for (a, s, amt) in &e.lines {
|
||||
ledger
|
||||
.add_line(id, *a, *s, Money::new(*amt, Currency::USD))
|
||||
.unwrap();
|
||||
}
|
||||
ledger.post_entry(id).unwrap();
|
||||
ids.push(id);
|
||||
}
|
||||
(ledger, ids)
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(256))]
|
||||
|
||||
/// The trial balance always balances, no matter the operations.
|
||||
#[test]
|
||||
fn prop_trial_balance_balances((chart, entries) in arb_ops()) {
|
||||
let (ledger, _) = build_ledger(&chart, &entries);
|
||||
let tb = trial_balance(&ledger);
|
||||
prop_assert_eq!(tb.total_debits, tb.total_credits);
|
||||
let debit_sum: Decimal = tb.lines.iter().map(|l| l.debit).sum();
|
||||
let credit_sum: Decimal = tb.lines.iter().map(|l| l.credit).sum();
|
||||
prop_assert_eq!(debit_sum, tb.total_debits);
|
||||
prop_assert_eq!(credit_sum, tb.total_credits);
|
||||
}
|
||||
|
||||
/// The accounting equation holds for every ledger state:
|
||||
/// assets == liabilities + equity (with current earnings folded in).
|
||||
#[test]
|
||||
fn prop_balance_sheet_balances((chart, entries) in arb_ops()) {
|
||||
let (ledger, _) = build_ledger(&chart, &entries);
|
||||
let bs = balance_sheet(&ledger).unwrap();
|
||||
prop_assert_eq!(bs.assets, bs.liabilities + bs.equity);
|
||||
}
|
||||
|
||||
/// Period P&Ls partition the year: split anywhere, the two halves sum
|
||||
/// to the whole, and the whole is the life-of-ledger P&L.
|
||||
#[test]
|
||||
fn prop_period_pnl_partitions((chart, entries) in arb_ops(), split in 0u32..365) {
|
||||
let (ledger, _) = build_ledger(&chart, &entries);
|
||||
let jan1 = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
|
||||
let dec31 = NaiveDate::from_ymd_opt(2026, 12, 31).unwrap();
|
||||
let cut = jan1 + chrono::Days::new(split as u64);
|
||||
let first = profit_and_loss_for_period(&ledger, jan1, cut, None).unwrap();
|
||||
let rest = if cut < dec31 {
|
||||
Some(profit_and_loss_for_period(&ledger, cut.succ_opt().unwrap(), dec31, None).unwrap())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let whole = profit_and_loss_for_period(&ledger, jan1, dec31, None).unwrap();
|
||||
let (r2, e2) = rest.map_or((Decimal::ZERO, Decimal::ZERO), |p| (p.total_revenue, p.total_expenses));
|
||||
prop_assert_eq!(first.total_revenue + r2, whole.total_revenue);
|
||||
prop_assert_eq!(first.total_expenses + e2, whole.total_expenses);
|
||||
let all = profit_and_loss(&ledger).unwrap();
|
||||
prop_assert_eq!((whole.total_revenue, whole.total_expenses), (all.revenue, all.expenses));
|
||||
}
|
||||
|
||||
/// The accounting equation holds as of every date, and a period's net
|
||||
/// income is exactly the change in current earnings across it.
|
||||
#[test]
|
||||
fn prop_balance_sheet_as_of_balances((chart, entries) in arb_ops(), a in 0u32..365, b in 0u32..365) {
|
||||
let (ledger, _) = build_ledger(&chart, &entries);
|
||||
let jan1 = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
|
||||
let (lo, hi) = (a.min(b), a.max(b));
|
||||
let start = jan1 + chrono::Days::new(lo as u64);
|
||||
let end = jan1 + chrono::Days::new(hi as u64);
|
||||
let before = balance_sheet_as_of(&ledger, start.pred_opt().unwrap()).unwrap();
|
||||
let after = balance_sheet_as_of(&ledger, end).unwrap();
|
||||
prop_assert!(before.balanced());
|
||||
prop_assert!(after.balanced());
|
||||
let pnl = profit_and_loss_for_period(&ledger, start, end, None).unwrap();
|
||||
prop_assert_eq!(after.current_earnings - before.current_earnings, pnl.net_income());
|
||||
// Past the last entry, as-of agrees with the life-of-ledger sheet.
|
||||
let all = balance_sheet(&ledger).unwrap();
|
||||
let last = balance_sheet_as_of(&ledger, NaiveDate::from_ymd_opt(2026, 12, 31).unwrap()).unwrap();
|
||||
prop_assert_eq!(
|
||||
(last.total_assets, last.total_liabilities, last.total_equity),
|
||||
(all.assets, all.liabilities, all.equity)
|
||||
);
|
||||
}
|
||||
|
||||
/// Every posted entry balances on its own, exactly.
|
||||
#[test]
|
||||
fn prop_posted_entries_balance((chart, entries) in arb_ops()) {
|
||||
let (ledger, ids) = build_ledger(&chart, &entries);
|
||||
for id in ids {
|
||||
let e = ledger.get_entry(id).unwrap();
|
||||
let debits: Decimal = e.lines().iter()
|
||||
.filter(|l| l.side == Side::Debit)
|
||||
.map(|l| l.amount.amount).sum();
|
||||
let credits: Decimal = e.lines().iter()
|
||||
.filter(|l| l.side == Side::Credit)
|
||||
.map(|l| l.amount.amount).sum();
|
||||
prop_assert_eq!(debits, credits);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reversing every entry returns the trial balance to zero:
|
||||
/// reversals are exact inverses.
|
||||
#[test]
|
||||
fn prop_reversals_restore_zero((chart, entries) in arb_ops()) {
|
||||
let (mut ledger, ids) = build_ledger(&chart, &entries);
|
||||
for id in &ids {
|
||||
ledger.reverse_entry(*id, NaiveDate::from_ymd_opt(2026, 12, 31).unwrap(), "reversal").unwrap();
|
||||
}
|
||||
let tb = trial_balance(&ledger);
|
||||
prop_assert_eq!(tb.total_debits, Decimal::ZERO);
|
||||
prop_assert_eq!(tb.total_credits, Decimal::ZERO);
|
||||
}
|
||||
|
||||
/// Posted entries are immutable: serializing them before and after
|
||||
/// further operations yields identical bytes.
|
||||
#[test]
|
||||
fn prop_posted_entries_immutable((chart, entries) in arb_ops(), extra in prop::collection::vec(arb_entry(2), 0..=5)) {
|
||||
prop_assume!(chart.len() >= 2);
|
||||
let (mut ledger, ids) = build_ledger(&chart, &entries);
|
||||
let before: Vec<String> = ids.iter().map(|id| {
|
||||
serde_json::to_string(&serde_json::to_value(ledger.get_entry(*id).unwrap()).unwrap()).unwrap()
|
||||
}).collect();
|
||||
// More operations: entries sized for a 2-account chart would break
|
||||
// on bigger charts, so rebuild them against this chart.
|
||||
let n = chart.len();
|
||||
let extra_entries: Vec<GenEntry> = extra.into_iter().map(|mut e| {
|
||||
for (a, _, _) in e.lines.iter_mut() {
|
||||
*a = (*a % n as u64) + 1;
|
||||
}
|
||||
// Rebalance after remapping accounts (net may have changed).
|
||||
let net: Decimal = e.lines.iter().map(|(_, s, amt)| match s {
|
||||
Side::Debit => *amt, Side::Credit => -*amt,
|
||||
}).sum();
|
||||
if net != Decimal::ZERO {
|
||||
let (side, amt) = if net > Decimal::ZERO { (Side::Credit, net) } else { (Side::Debit, -net) };
|
||||
e.lines.push((1, side, amt));
|
||||
}
|
||||
e
|
||||
}).collect();
|
||||
for e in &extra_entries {
|
||||
let id = ledger.create_entry(e.date, "extra");
|
||||
for (a, s, amt) in &e.lines {
|
||||
ledger.add_line(id, *a, *s, Money::new(*amt, Currency::USD)).unwrap();
|
||||
}
|
||||
ledger.post_entry(id).unwrap();
|
||||
}
|
||||
for id in &ids {
|
||||
ledger.reverse_entry(*id, NaiveDate::from_ymd_opt(2026, 12, 31).unwrap(), "rev").unwrap();
|
||||
}
|
||||
let after: Vec<String> = ids.iter().map(|id| {
|
||||
serde_json::to_string(&serde_json::to_value(ledger.get_entry(*id).unwrap()).unwrap()).unwrap()
|
||||
}).collect();
|
||||
prop_assert_eq!(before, after);
|
||||
}
|
||||
|
||||
/// A journal entry survives a JSON round-trip unchanged.
|
||||
#[test]
|
||||
fn prop_entry_json_roundtrip((chart, entries) in arb_ops()) {
|
||||
let (ledger, ids) = build_ledger(&chart, &entries);
|
||||
for id in ids {
|
||||
let e = ledger.get_entry(id).unwrap();
|
||||
let v = serde_json::to_value(e).unwrap();
|
||||
let s = serde_json::to_string(&v).unwrap();
|
||||
let v2: serde_json::Value = serde_json::from_str(&s).unwrap();
|
||||
prop_assert_eq!(v, v2);
|
||||
}
|
||||
}
|
||||
|
||||
/// Depreciation schedules are exact: the lifetime total is precisely
|
||||
/// cost minus salvage, book value plus accumulated always equals cost,
|
||||
/// and amounts are never negative.
|
||||
#[test]
|
||||
fn prop_depreciation_schedule_exact(
|
||||
cost_cents in 100u64..10_000_000u64,
|
||||
salvage_cents in 0u64..5_000_000u64,
|
||||
life in 1u32..360u32,
|
||||
y in 2000i32..2027i32,
|
||||
m in 1u32..=12u32,
|
||||
d in 1u32..=28u32,
|
||||
) {
|
||||
let cost_cents = cost_cents.max(salvage_cents + 1);
|
||||
let cost = Decimal::new(cost_cents as i64, 2);
|
||||
let salvage = Decimal::new(salvage_cents as i64, 2);
|
||||
let mut ledger = Ledger::new(Currency::USD);
|
||||
let ca = ledger.add_account("1500", "Equipment", AccountType::Asset, None, false).unwrap();
|
||||
let aa = ledger.add_account("1501", "Accum", AccountType::Asset, None, false).unwrap();
|
||||
let ex = ledger.add_account("6100", "Depr", AccountType::Expense, None, false).unwrap();
|
||||
let id = ledger.register_asset(NewAsset {
|
||||
description: "prop".to_string(),
|
||||
purchase_date: NaiveDate::from_ymd_opt(y, m, d).unwrap(),
|
||||
cost: Money::new(cost, Currency::USD),
|
||||
salvage_value: Money::new(salvage, Currency::USD),
|
||||
useful_life_months: life,
|
||||
cost_account: ca,
|
||||
accum_depr_account: aa,
|
||||
expense_account: ex,
|
||||
}).unwrap();
|
||||
let asset = ledger.get_asset(id).unwrap();
|
||||
let far = asset.purchase_date.checked_add_months(chrono::Months::new(life + 24)).unwrap();
|
||||
let sched = depreciation_schedule(asset, far);
|
||||
// The lifetime total is exactly cost minus salvage: never a penny off.
|
||||
let total: Decimal = sched.iter().map(|l| l.amount).sum();
|
||||
prop_assert_eq!(total, cost - salvage);
|
||||
for l in &sched {
|
||||
prop_assert!(l.amount >= Decimal::ZERO);
|
||||
prop_assert_eq!(l.book_value + l.accumulated, cost);
|
||||
}
|
||||
// Accumulated is monotone non-decreasing.
|
||||
for w in sched.windows(2) {
|
||||
prop_assert!(w[1].accumulated >= w[0].accumulated);
|
||||
}
|
||||
// Book value at any date matches the schedule.
|
||||
if let Some(last) = sched.last() {
|
||||
prop_assert_eq!(book_value(asset, far), last.book_value);
|
||||
prop_assert_eq!(accumulated_depreciation(asset, far), last.accumulated);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
//! Amortization schedules and recurring entries. Numbers computed by hand.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::reports::{balance_sheet_as_of, profit_and_loss_for_period};
|
||||
use ledger_core::{
|
||||
AccountType, Currency, EntryStatus, Frequency, Ledger, LedgerError, Money, NewRecurring,
|
||||
NewSchedule, ScheduleKind, Side,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
use std::str::FromStr;
|
||||
|
||||
fn d(m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, m, day).unwrap()
|
||||
}
|
||||
|
||||
fn dec(s: &str) -> Decimal {
|
||||
Decimal::from_str(s).unwrap()
|
||||
}
|
||||
|
||||
fn usd(s: &str) -> Money {
|
||||
Money::new(dec(s), Currency::USD)
|
||||
}
|
||||
|
||||
struct Books {
|
||||
l: Ledger,
|
||||
cash: u64,
|
||||
prepaid: u64,
|
||||
deferred: u64,
|
||||
insurance: u64,
|
||||
revenue: u64,
|
||||
rent: u64,
|
||||
}
|
||||
|
||||
fn books() -> Books {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let mut a = |code: &str, name: &str, t| l.add_account(code, name, t, None, false).unwrap();
|
||||
let cash = a("1000", "Cash", AccountType::Asset);
|
||||
let prepaid = a("1300", "Prepaid Expenses", AccountType::Asset);
|
||||
let deferred = a("2400", "Deferred Revenue", AccountType::Liability);
|
||||
let insurance = a("6300", "Insurance", AccountType::Expense);
|
||||
let revenue = a("4000", "Subscription Revenue", AccountType::Revenue);
|
||||
let rent = a("6600", "Rent", AccountType::Expense);
|
||||
Books {
|
||||
l,
|
||||
cash,
|
||||
prepaid,
|
||||
deferred,
|
||||
insurance,
|
||||
revenue,
|
||||
rent,
|
||||
}
|
||||
}
|
||||
|
||||
fn post(l: &mut Ledger, date: NaiveDate, dr: u64, cr: u64, amt: &str) {
|
||||
let e = l.create_entry(date, "x");
|
||||
l.add_line(e, dr, Side::Debit, usd(amt)).unwrap();
|
||||
l.add_line(e, cr, Side::Credit, usd(amt)).unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_prepaid_expense_is_expensed_month_by_month() {
|
||||
let mut b = books();
|
||||
// A year of insurance, paid March 15: an asset, not an expense yet.
|
||||
post(&mut b.l, d(3, 15), b.prepaid, b.cash, "1200");
|
||||
let id =
|
||||
b.l.register_schedule(NewSchedule {
|
||||
description: "Liability insurance".into(),
|
||||
kind: ScheduleKind::PrepaidExpense,
|
||||
total: usd("1200"),
|
||||
start: d(3, 15),
|
||||
months: 12,
|
||||
balance_account: b.prepaid,
|
||||
pl_account: b.insurance,
|
||||
})
|
||||
.unwrap();
|
||||
// Through May: March, April, May, at month end, 100 each.
|
||||
let due: Vec<_> = b.l.get_schedule(id).unwrap().due(d(5, 31));
|
||||
assert_eq!(
|
||||
due,
|
||||
[
|
||||
(d(3, 31), dec("100")),
|
||||
(d(4, 30), dec("100")),
|
||||
(d(5, 31), dec("100"))
|
||||
]
|
||||
);
|
||||
for (month, _) in due {
|
||||
b.l.post_schedule_month(id, month).unwrap();
|
||||
}
|
||||
assert!(
|
||||
b.l.get_schedule(id).unwrap().due(d(5, 31)).is_empty(),
|
||||
"idempotent"
|
||||
);
|
||||
assert!(matches!(
|
||||
b.l.post_schedule_month(id, d(4, 30)),
|
||||
Err(LedgerError::InvalidSchedule(_))
|
||||
));
|
||||
assert_eq!(b.l.get_schedule(id).unwrap().remaining(), dec("900"));
|
||||
let bs = balance_sheet_as_of(&b.l, d(5, 31)).unwrap();
|
||||
let prepaid = bs.assets.iter().find(|a| a.code == "1300").unwrap();
|
||||
assert_eq!(prepaid.amount, dec("900"));
|
||||
let pl = profit_and_loss_for_period(&b.l, d(3, 1), d(5, 31), None).unwrap();
|
||||
assert_eq!(pl.total_expenses, dec("300"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deferred_revenue_is_earned_to_the_cent() {
|
||||
let mut b = books();
|
||||
// Three months of a subscription collected up front.
|
||||
post(&mut b.l, d(1, 5), b.cash, b.deferred, "1000");
|
||||
let id =
|
||||
b.l.register_schedule(NewSchedule {
|
||||
description: "Quarterly plan, Acme".into(),
|
||||
kind: ScheduleKind::DeferredRevenue,
|
||||
total: usd("1000"),
|
||||
start: d(1, 5),
|
||||
months: 3,
|
||||
balance_account: b.deferred,
|
||||
pl_account: b.revenue,
|
||||
})
|
||||
.unwrap();
|
||||
let plan: Vec<Decimal> =
|
||||
b.l.get_schedule(id)
|
||||
.unwrap()
|
||||
.due(d(12, 31))
|
||||
.into_iter()
|
||||
.map(|(_, a)| a)
|
||||
.collect();
|
||||
assert_eq!(plan, [dec("333.33"), dec("333.33"), dec("333.34")]);
|
||||
for (m, _) in b.l.get_schedule(id).unwrap().due(d(12, 31)) {
|
||||
b.l.post_schedule_month(id, m).unwrap();
|
||||
}
|
||||
let pl = profit_and_loss_for_period(&b.l, d(1, 1), d(3, 31), None).unwrap();
|
||||
assert_eq!(pl.total_revenue, dec("1000"));
|
||||
let bs = balance_sheet_as_of(&b.l, d(3, 31)).unwrap();
|
||||
assert!(bs.liabilities.is_empty(), "fully earned");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedules_respect_closed_periods_and_account_types() {
|
||||
let mut b = books();
|
||||
let jan = b.l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
|
||||
b.l.close_period(jan).unwrap();
|
||||
let id =
|
||||
b.l.register_schedule(NewSchedule {
|
||||
description: "Insurance".into(),
|
||||
kind: ScheduleKind::PrepaidExpense,
|
||||
total: usd("120"),
|
||||
start: d(1, 1),
|
||||
months: 12,
|
||||
balance_account: b.prepaid,
|
||||
pl_account: b.insurance,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
b.l.post_schedule_month(id, d(1, 31)),
|
||||
Err(LedgerError::ClosedPeriod(..))
|
||||
));
|
||||
b.l.post_schedule_month(id, d(2, 28)).unwrap();
|
||||
// A prepaid schedule needs an asset and an expense, not a liability.
|
||||
let bad = b.l.register_schedule(NewSchedule {
|
||||
description: "x".into(),
|
||||
kind: ScheduleKind::PrepaidExpense,
|
||||
total: usd("120"),
|
||||
start: d(1, 1),
|
||||
months: 12,
|
||||
balance_account: b.deferred,
|
||||
pl_account: b.insurance,
|
||||
});
|
||||
assert!(matches!(bad, Err(LedgerError::InvalidSchedule(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recurring_entries_generate_drafts_never_posts() {
|
||||
let mut b = books();
|
||||
let id =
|
||||
b.l.register_recurring(NewRecurring {
|
||||
description: "Office rent".into(),
|
||||
lines: vec![
|
||||
(b.rent, Side::Debit, usd("2000")),
|
||||
(b.cash, Side::Credit, usd("2000")),
|
||||
],
|
||||
frequency: Frequency::Monthly,
|
||||
start: d(1, 31),
|
||||
end: Some(d(4, 30)),
|
||||
})
|
||||
.unwrap();
|
||||
// Through March: Jan 31, Feb 28, Mar 31 (month ends, no drift).
|
||||
let due = b.l.recurring_entries().next().unwrap().due(d(3, 31));
|
||||
assert_eq!(due, [d(1, 31), d(2, 28), d(3, 31)]);
|
||||
let drafts: Vec<_> = due
|
||||
.iter()
|
||||
.map(|date| b.l.generate_recurring(id, *date).unwrap())
|
||||
.collect();
|
||||
for e in &drafts {
|
||||
assert_eq!(b.l.get_entry(*e).unwrap().status(), EntryStatus::Draft);
|
||||
}
|
||||
// Drafts don't touch the books until posted.
|
||||
let q1 = profit_and_loss_for_period(&b.l, d(1, 1), d(3, 31), None).unwrap();
|
||||
assert_eq!(q1.total_expenses, Decimal::ZERO);
|
||||
b.l.post_entry(drafts[1]).unwrap();
|
||||
let feb = profit_and_loss_for_period(&b.l, d(2, 1), d(2, 28), None).unwrap();
|
||||
assert_eq!(feb.total_expenses, dec("2000"));
|
||||
// Never twice; nothing after the end date.
|
||||
assert!(matches!(
|
||||
b.l.generate_recurring(id, d(2, 28)),
|
||||
Err(LedgerError::InvalidSchedule(_))
|
||||
));
|
||||
assert_eq!(
|
||||
b.l.recurring_entries().next().unwrap().due(d(12, 31)),
|
||||
[d(4, 30)]
|
||||
);
|
||||
// Templates must balance.
|
||||
let bad = b.l.register_recurring(NewRecurring {
|
||||
description: "x".into(),
|
||||
lines: vec![
|
||||
(b.rent, Side::Debit, usd("2000")),
|
||||
(b.cash, Side::Credit, usd("1999")),
|
||||
],
|
||||
frequency: Frequency::Monthly,
|
||||
start: d(1, 1),
|
||||
end: None,
|
||||
});
|
||||
assert!(matches!(bad, Err(LedgerError::InvalidSchedule(_))));
|
||||
let _ = b.revenue;
|
||||
}
|
||||
@@ -0,0 +1,890 @@
|
||||
//! Period-scoped financial statements: profit and loss for a date range and
|
||||
//! the balance sheet as of a date, line per account.
|
||||
//!
|
||||
//! Every expected number below is computed by hand from the entries, not
|
||||
//! read off the engine. The fixture is two months of a small company:
|
||||
//!
|
||||
//! | date | entry | Dr | Cr |
|
||||
//! |--------|-------------------------------|---------------|---------------|
|
||||
//! | Jan 2 | owner contributes 10,000 | 1000 Cash | 3000 Capital |
|
||||
//! | Jan 15 | client pays 2,500 | 1000 Cash | 4000 Revenue |
|
||||
//! | Jan 20 | rent 800 | 6100 Rent | 1000 Cash |
|
||||
//! | Feb 3 | software 120 on the card | 6000 Software | 2000 Card |
|
||||
//! | Feb 10 | client pays 1,000 | 1000 Cash | 4000 Revenue |
|
||||
//! | Feb 25 | pay the card 120 | 2000 Card | 1000 Cash |
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::reports::{
|
||||
balance_sheet, balance_sheet_as_of, profit_and_loss, profit_and_loss_for_period,
|
||||
};
|
||||
use ledger_core::{
|
||||
AccountId, AccountType, CashFlowParams, ClassId, Currency, Ledger, LedgerError, MemberCapital,
|
||||
Money, Side, StatementLine, cash_flow, general_ledger, members_equity, trial_balance_as_of,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
use std::str::FromStr;
|
||||
|
||||
fn d(m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, m, day).unwrap()
|
||||
}
|
||||
|
||||
fn usd(n: i64) -> Money {
|
||||
Money::new(Decimal::from(n), Currency::USD)
|
||||
}
|
||||
|
||||
fn dec(n: i64) -> Decimal {
|
||||
Decimal::from(n)
|
||||
}
|
||||
|
||||
struct Books {
|
||||
ledger: Ledger,
|
||||
cash: AccountId,
|
||||
software_class: ClassId,
|
||||
}
|
||||
|
||||
fn post(ledger: &mut Ledger, date: NaiveDate, desc: &str, dr: AccountId, cr: AccountId, n: i64) {
|
||||
let e = ledger.create_entry(date, desc);
|
||||
ledger.add_line(e, dr, Side::Debit, usd(n)).unwrap();
|
||||
ledger.add_line(e, cr, Side::Credit, usd(n)).unwrap();
|
||||
ledger.post_entry(e).unwrap();
|
||||
}
|
||||
|
||||
/// The fixture. With `close_january`, January is closed before any February
|
||||
/// entry exists (the close sweeps every revenue and expense balance).
|
||||
fn books(close_january: bool) -> Books {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let cash = l
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let card = l
|
||||
.add_account("2000", "Card", AccountType::Liability, None, false)
|
||||
.unwrap();
|
||||
let capital = l
|
||||
.add_account("3000", "Capital", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let revenue = l
|
||||
.add_account("4000", "Revenue", AccountType::Revenue, None, false)
|
||||
.unwrap();
|
||||
let software = l
|
||||
.add_account("6000", "Software", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let rent = l
|
||||
.add_account("6100", "Rent", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let software_class = l.add_class("Apps").unwrap();
|
||||
|
||||
post(&mut l, d(1, 2), "Owner contribution", cash, capital, 10_000);
|
||||
post(&mut l, d(1, 15), "Client payment", cash, revenue, 2_500);
|
||||
post(&mut l, d(1, 20), "Rent", rent, cash, 800);
|
||||
if close_january {
|
||||
let jan = l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
|
||||
l.close_period(jan).unwrap();
|
||||
}
|
||||
// Software on the card, tagged to the Apps class.
|
||||
let e = l.create_entry(d(2, 3), "Software");
|
||||
l.add_line(e, software, Side::Debit, usd(120)).unwrap();
|
||||
l.add_line(e, card, Side::Credit, usd(120)).unwrap();
|
||||
l.set_line_class(e, 0, Some(software_class)).unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
post(&mut l, d(2, 10), "Client payment", cash, revenue, 1_000);
|
||||
post(&mut l, d(2, 25), "Pay card", card, cash, 120);
|
||||
Books {
|
||||
ledger: l,
|
||||
cash,
|
||||
software_class,
|
||||
}
|
||||
}
|
||||
|
||||
fn line(code: &str, name: &str, n: i64) -> StatementLine {
|
||||
StatementLine {
|
||||
code: code.to_string(),
|
||||
name: name.to_string(),
|
||||
amount: dec(n),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profit_and_loss_is_scoped_to_the_period() {
|
||||
let b = books(false);
|
||||
let jan = profit_and_loss_for_period(&b.ledger, d(1, 1), d(1, 31), None).unwrap();
|
||||
assert_eq!(jan.revenue, vec![line("4000", "Revenue", 2_500)]);
|
||||
assert_eq!(jan.expenses, vec![line("6100", "Rent", 800)]);
|
||||
assert_eq!(jan.net_income(), dec(1_700));
|
||||
|
||||
let feb = profit_and_loss_for_period(&b.ledger, d(2, 1), d(2, 28), None).unwrap();
|
||||
assert_eq!(feb.revenue, vec![line("4000", "Revenue", 1_000)]);
|
||||
assert_eq!(feb.expenses, vec![line("6000", "Software", 120)]);
|
||||
assert_eq!(feb.net_income(), dec(880));
|
||||
|
||||
// Both months: 3,500 revenue, 920 expenses, lines sorted by code.
|
||||
let both = profit_and_loss_for_period(&b.ledger, d(1, 1), d(2, 28), None).unwrap();
|
||||
assert_eq!(
|
||||
both.expenses,
|
||||
vec![line("6000", "Software", 120), line("6100", "Rent", 800)]
|
||||
);
|
||||
assert_eq!(
|
||||
(both.total_revenue, both.total_expenses),
|
||||
(dec(3_500), dec(920))
|
||||
);
|
||||
// ... which is the life-of-ledger P&L, because nothing is outside it.
|
||||
let all = profit_and_loss(&b.ledger).unwrap();
|
||||
assert_eq!((all.revenue, all.expenses), (dec(3_500), dec(920)));
|
||||
|
||||
// One class: only the tagged software line.
|
||||
let apps =
|
||||
profit_and_loss_for_period(&b.ledger, d(1, 1), d(12, 31), Some(b.software_class)).unwrap();
|
||||
assert!(apps.revenue.is_empty());
|
||||
assert_eq!(apps.expenses, vec![line("6000", "Software", 120)]);
|
||||
|
||||
// A backwards range is rejected, not silently empty.
|
||||
assert!(profit_and_loss_for_period(&b.ledger, d(2, 1), d(1, 1), None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn balance_sheet_is_as_of_the_date() {
|
||||
let b = books(false);
|
||||
// Before anything happened: empty and balanced.
|
||||
let start = balance_sheet_as_of(&b.ledger, d(1, 1)).unwrap();
|
||||
assert!(start.assets.is_empty() && start.equity.is_empty());
|
||||
assert!(start.balanced());
|
||||
|
||||
// Jan 31: cash 10,000 + 2,500 − 800 = 11,700; equity 10,000 + 1,700.
|
||||
let jan = balance_sheet_as_of(&b.ledger, d(1, 31)).unwrap();
|
||||
assert_eq!(jan.assets, vec![line("1000", "Cash", 11_700)]);
|
||||
assert!(jan.liabilities.is_empty());
|
||||
assert_eq!(jan.equity, vec![line("3000", "Capital", 10_000)]);
|
||||
assert_eq!(jan.current_earnings, dec(1_700));
|
||||
assert_eq!(jan.total_equity, dec(11_700));
|
||||
assert!(jan.balanced());
|
||||
|
||||
// Feb 15: cash 12,700; the card owes 120; earnings 1,700 + 880.
|
||||
let mid = balance_sheet_as_of(&b.ledger, d(2, 15)).unwrap();
|
||||
assert_eq!(mid.total_assets, dec(12_700));
|
||||
assert_eq!(mid.liabilities, vec![line("2000", "Card", 120)]);
|
||||
assert_eq!(mid.current_earnings, dec(2_580));
|
||||
assert_eq!(mid.total_liabilities + mid.total_equity, dec(12_700));
|
||||
|
||||
// Feb 28: card paid off, so it drops off the statement.
|
||||
let end = balance_sheet_as_of(&b.ledger, d(2, 28)).unwrap();
|
||||
assert_eq!(end.total_assets, dec(12_580));
|
||||
assert!(end.liabilities.is_empty());
|
||||
assert!(end.balanced());
|
||||
// ... and agrees with the life-of-ledger balance sheet.
|
||||
let all = balance_sheet(&b.ledger).unwrap();
|
||||
assert_eq!(
|
||||
(all.assets, all.liabilities, all.equity),
|
||||
(end.total_assets, end.total_liabilities, end.total_equity)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closed_period_still_reports_its_earnings() {
|
||||
let b = books(true);
|
||||
// The close posted Dr 4000 2,500 / Cr 6100 800 / Cr 3999 1,700 on Jan
|
||||
// 31. The January P&L must not count it: January earned 1,700.
|
||||
let jan = profit_and_loss_for_period(&b.ledger, d(1, 1), d(1, 31), None).unwrap();
|
||||
assert_eq!(jan.net_income(), dec(1_700));
|
||||
assert_eq!(jan.revenue, vec![line("4000", "Revenue", 2_500)]);
|
||||
let feb = profit_and_loss_for_period(&b.ledger, d(2, 1), d(2, 28), None).unwrap();
|
||||
assert_eq!(feb.net_income(), dec(880));
|
||||
|
||||
// After the close, January's earnings sit in retained earnings, not in
|
||||
// current earnings; February's are current.
|
||||
let jan_bs = balance_sheet_as_of(&b.ledger, d(1, 31)).unwrap();
|
||||
assert_eq!(
|
||||
jan_bs.equity,
|
||||
vec![
|
||||
line("3000", "Capital", 10_000),
|
||||
line("3999", "Retained Earnings", 1_700)
|
||||
]
|
||||
);
|
||||
assert_eq!(jan_bs.current_earnings, Decimal::ZERO);
|
||||
assert!(jan_bs.balanced());
|
||||
let feb_bs = balance_sheet_as_of(&b.ledger, d(2, 28)).unwrap();
|
||||
assert_eq!(feb_bs.current_earnings, dec(880));
|
||||
assert_eq!(feb_bs.total_equity, dec(12_580));
|
||||
assert!(feb_bs.balanced());
|
||||
|
||||
// The cash flow for January: net income 1,700, and the only financing
|
||||
// flow is the owner's 10,000. The close is not an owner contribution.
|
||||
let cf = cash_flow(
|
||||
&b.ledger,
|
||||
&CashFlowParams {
|
||||
start: d(1, 1),
|
||||
end: d(1, 31),
|
||||
cash_accounts: vec![b.cash],
|
||||
financing_accounts: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cf.net_income, dec(1_700));
|
||||
assert_eq!(cf.financing.len(), 1);
|
||||
assert_eq!(cf.cash_from_financing, dec(10_000));
|
||||
assert_eq!(cf.net_change_in_cash, dec(11_700));
|
||||
}
|
||||
|
||||
/// Cash flow lines come out in account-code order, every time. (The chart
|
||||
/// iterates in hash order; the statement must not.)
|
||||
#[test]
|
||||
fn cash_flow_lines_are_in_code_order() {
|
||||
for _ in 0..20 {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let cash = l
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let mut owners = Vec::new();
|
||||
for code in ["3005", "3001", "3009", "3000", "3003"] {
|
||||
owners.push(
|
||||
l.add_account(code, code, AccountType::Equity, None, false)
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
for o in &owners {
|
||||
post(&mut l, d(3, 1), "Contribution", cash, *o, 100);
|
||||
}
|
||||
let cf = cash_flow(
|
||||
&l,
|
||||
&CashFlowParams {
|
||||
start: d(1, 1),
|
||||
end: d(12, 31),
|
||||
cash_accounts: vec![cash],
|
||||
financing_accounts: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
// Labels read "Owner contributions — 3000 3000" (code, then name).
|
||||
let codes: Vec<&str> = cf
|
||||
.financing
|
||||
.iter()
|
||||
.map(|f| f.label.rsplit(' ').next().unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
codes,
|
||||
["3000", "3001", "3003", "3005", "3009"],
|
||||
"{:?}",
|
||||
cf.financing
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn cents(s: &str) -> Decimal {
|
||||
Decimal::from_str(s).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trial_balance_is_as_of_the_date() {
|
||||
let b = books(false);
|
||||
// Jan 31: Dr cash 11,700 and rent 800; Cr capital 10,000 and revenue 2,500.
|
||||
let tb = trial_balance_as_of(&b.ledger, d(1, 31));
|
||||
let rows: Vec<(&str, Decimal, Decimal)> = tb
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| (l.code.as_str(), l.debit, l.credit))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
rows,
|
||||
[
|
||||
("1000", dec(11_700), dec(0)),
|
||||
("3000", dec(0), dec(10_000)),
|
||||
("4000", dec(0), dec(2_500)),
|
||||
("6100", dec(800), dec(0)),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
(tb.total_debits, tb.total_credits),
|
||||
(dec(12_500), dec(12_500))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn general_ledger_runs_balances_by_account() {
|
||||
let b = books(false);
|
||||
let gl = general_ledger(&b.ledger, d(2, 1), d(2, 28)).unwrap();
|
||||
let codes: Vec<&str> = gl.accounts.iter().map(|a| a.code.as_str()).collect();
|
||||
// Every account with a beginning balance or February activity, by code.
|
||||
assert_eq!(codes, ["1000", "2000", "3000", "4000", "6000", "6100"]);
|
||||
|
||||
let cash = &gl.accounts[0];
|
||||
assert_eq!(cash.beginning_balance, dec(11_700));
|
||||
let p: Vec<(Decimal, Decimal, &str, Decimal)> = cash
|
||||
.postings
|
||||
.iter()
|
||||
.map(|p| (p.debit, p.credit, p.split.as_str(), p.balance))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
p,
|
||||
[
|
||||
(dec(1_000), dec(0), "4000 Revenue", dec(12_700)),
|
||||
(dec(0), dec(120), "2000 Card", dec(12_580)),
|
||||
]
|
||||
);
|
||||
assert_eq!(cash.ending_balance, dec(12_580));
|
||||
|
||||
// The card: borrowed 120, paid 120, back to zero (credit-normal).
|
||||
let card = &gl.accounts[1];
|
||||
assert_eq!(card.beginning_balance, dec(0));
|
||||
assert_eq!(card.postings[0].balance, dec(120));
|
||||
assert_eq!(card.ending_balance, dec(0));
|
||||
// Capital: no February activity, still listed with its balance.
|
||||
assert!(gl.accounts[2].postings.is_empty());
|
||||
assert_eq!(gl.accounts[2].ending_balance, dec(10_000));
|
||||
|
||||
// Every ending balance ties to the trial balance as of the end.
|
||||
let tb = trial_balance_as_of(&b.ledger, d(2, 28));
|
||||
for a in &gl.accounts {
|
||||
let tb_line = tb.lines.iter().find(|l| l.code == a.code);
|
||||
let tb_amount = tb_line.map_or(Decimal::ZERO, |l| l.debit - l.credit);
|
||||
let normal = match a.account_type {
|
||||
AccountType::Asset | AccountType::Expense => a.ending_balance,
|
||||
_ => -a.ending_balance,
|
||||
};
|
||||
assert_eq!(tb_amount, normal, "account {}", a.code);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn general_ledger_marks_compound_entries_as_split() {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let cash = l
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let card = l
|
||||
.add_account("2000", "Card", AccountType::Liability, None, false)
|
||||
.unwrap();
|
||||
let soft = l
|
||||
.add_account("6000", "Software", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
// Dr software 100 / Cr cash 60 / Cr card 40: one debit, split credits.
|
||||
let e = l.create_entry(d(3, 1), "Annual license, paid two ways");
|
||||
l.add_line(e, soft, Side::Debit, usd(100)).unwrap();
|
||||
l.add_line(e, cash, Side::Credit, usd(60)).unwrap();
|
||||
l.add_line(e, card, Side::Credit, usd(40)).unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
let gl = general_ledger(&l, d(1, 1), d(12, 31)).unwrap();
|
||||
let split_of = |code: &str| {
|
||||
let a = gl.accounts.iter().find(|a| a.code == code).unwrap();
|
||||
(a.postings[0].split.clone(), a.postings[0].entry_id)
|
||||
};
|
||||
// The debit side has two other accounts; each credit has one.
|
||||
assert_eq!(split_of("6000"), ("-Split-".to_string(), e));
|
||||
assert_eq!(split_of("1000"), ("6000 Software".to_string(), e));
|
||||
assert_eq!(split_of("2000"), ("6000 Software".to_string(), e));
|
||||
}
|
||||
|
||||
/// Two members, 50/50: Alice and Bob. January: Alice puts in 600, Bob 400;
|
||||
/// revenue 1,000; expense 333.33, so net income 666.67, which doesn't split
|
||||
/// evenly. February: Bob takes a 100 distribution; revenue 100.01.
|
||||
fn partnership(close_january: bool) -> (Ledger, Vec<MemberCapital>) {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let cash = l
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let alice = l
|
||||
.add_account("3000", "Capital — Alice", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let bob = l
|
||||
.add_account("3001", "Capital — Bob", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let rev = l
|
||||
.add_account("4000", "Revenue", AccountType::Revenue, None, false)
|
||||
.unwrap();
|
||||
let exp = l
|
||||
.add_account("6000", "Expense", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let money = |s: &str| Money::new(cents(s), Currency::USD);
|
||||
let post = |l: &mut Ledger, date, dr, cr, amt: &str| {
|
||||
let e = l.create_entry(date, "x");
|
||||
l.add_line(e, dr, Side::Debit, money(amt)).unwrap();
|
||||
l.add_line(e, cr, Side::Credit, money(amt)).unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
};
|
||||
post(&mut l, d(1, 2), cash, alice, "600");
|
||||
post(&mut l, d(1, 2), cash, bob, "400");
|
||||
post(&mut l, d(1, 10), cash, rev, "1000");
|
||||
post(&mut l, d(1, 20), exp, cash, "333.33");
|
||||
if close_january {
|
||||
let jan = l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
|
||||
l.close_period(jan).unwrap();
|
||||
}
|
||||
post(&mut l, d(2, 5), bob, cash, "100");
|
||||
post(&mut l, d(2, 9), cash, rev, "100.01");
|
||||
let half = cents("0.5");
|
||||
let members = vec![
|
||||
MemberCapital {
|
||||
name: "Alice".into(),
|
||||
capital_account: alice,
|
||||
distribution_account: None,
|
||||
share: half,
|
||||
},
|
||||
MemberCapital {
|
||||
name: "Bob".into(),
|
||||
capital_account: bob,
|
||||
distribution_account: None,
|
||||
share: half,
|
||||
},
|
||||
];
|
||||
(l, members)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn members_equity_rolls_capital_forward_and_ties_to_the_balance_sheet() {
|
||||
for close in [false, true] {
|
||||
let (l, members) = partnership(close);
|
||||
// January: 666.67 splits 333.34 / 333.33 (half a cent rounds up for
|
||||
// Alice; Bob gets the remainder so the total is exact).
|
||||
let jan = members_equity(&l, d(1, 1), d(1, 31), &members).unwrap();
|
||||
let row = |c: &ledger_core::MemberEquityColumn| {
|
||||
(
|
||||
c.beginning,
|
||||
c.contributions,
|
||||
c.net_income,
|
||||
c.distributions,
|
||||
c.ending,
|
||||
)
|
||||
};
|
||||
assert_eq!(
|
||||
row(&jan.members[0]),
|
||||
(
|
||||
cents("0"),
|
||||
cents("600"),
|
||||
cents("333.34"),
|
||||
cents("0"),
|
||||
cents("933.34")
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
row(&jan.members[1]),
|
||||
(
|
||||
cents("0"),
|
||||
cents("400"),
|
||||
cents("333.33"),
|
||||
cents("0"),
|
||||
cents("733.33")
|
||||
)
|
||||
);
|
||||
assert_eq!(jan.total.ending, cents("1666.67"));
|
||||
assert_eq!(
|
||||
jan.total.ending,
|
||||
balance_sheet_as_of(&l, d(1, 31)).unwrap().total_equity
|
||||
);
|
||||
|
||||
// February begins where January ended, whether or not January was
|
||||
// closed to retained earnings. 100.01 splits 50.01 / 50.00.
|
||||
let feb = members_equity(&l, d(2, 1), d(2, 28), &members).unwrap();
|
||||
assert_eq!(
|
||||
row(&feb.members[0]),
|
||||
(
|
||||
cents("933.34"),
|
||||
cents("0"),
|
||||
cents("50.01"),
|
||||
cents("0"),
|
||||
cents("983.35")
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
row(&feb.members[1]),
|
||||
(
|
||||
cents("733.33"),
|
||||
cents("0"),
|
||||
cents("50.00"),
|
||||
cents("100"),
|
||||
cents("683.33")
|
||||
)
|
||||
);
|
||||
assert_eq!(feb.total.ending, cents("1666.68"));
|
||||
assert_eq!(
|
||||
feb.total.ending,
|
||||
balance_sheet_as_of(&l, d(2, 28)).unwrap().total_equity
|
||||
);
|
||||
assert!(
|
||||
feb.members.iter().all(|m| m.other.is_zero()),
|
||||
"close={close}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn members_must_be_configured_correctly() {
|
||||
let (l, mut members) = partnership(false);
|
||||
members[1].share = cents("0.4");
|
||||
assert!(matches!(
|
||||
members_equity(&l, d(1, 1), d(1, 31), &members),
|
||||
Err(LedgerError::InvalidMembers(_))
|
||||
));
|
||||
members[1].share = cents("0.5");
|
||||
members[1].capital_account = members[0].capital_account;
|
||||
assert!(matches!(
|
||||
members_equity(&l, d(1, 1), d(1, 31), &members),
|
||||
Err(LedgerError::InvalidMembers(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cash_flow_lines_name_their_account() {
|
||||
let (l, _) = partnership(false);
|
||||
let cf = cash_flow(
|
||||
&l,
|
||||
&CashFlowParams {
|
||||
start: d(1, 1),
|
||||
end: d(12, 31),
|
||||
cash_accounts: vec![1],
|
||||
financing_accounts: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let accounts: Vec<Option<&str>> = cf.financing.iter().map(|f| f.account.as_deref()).collect();
|
||||
assert_eq!(accounts, [Some("3000"), Some("3001")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_close_sweeps_only_through_the_period_end() {
|
||||
// February is already posted when January closes: the close must take
|
||||
// January's 2,500 revenue and 800 rent (net 1,700) and leave
|
||||
// February's 1,000 revenue and 120 software open.
|
||||
let mut b = books(false);
|
||||
let jan = b
|
||||
.ledger
|
||||
.create_period("2026-01", d(1, 1), d(1, 31))
|
||||
.unwrap();
|
||||
let closing = b.ledger.close_period(jan).unwrap().unwrap();
|
||||
let lines: Vec<(String, Side, Decimal)> = b
|
||||
.ledger
|
||||
.get_entry(closing)
|
||||
.unwrap()
|
||||
.lines()
|
||||
.iter()
|
||||
.map(|l| {
|
||||
(
|
||||
b.ledger.chart().get(l.account).unwrap().code.clone(),
|
||||
l.side,
|
||||
l.amount.amount,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
lines,
|
||||
[
|
||||
("4000".to_string(), Side::Debit, dec(2_500)),
|
||||
("6100".to_string(), Side::Credit, dec(800)),
|
||||
("3999".to_string(), Side::Credit, dec(1_700)),
|
||||
]
|
||||
);
|
||||
let feb = profit_and_loss_for_period(&b.ledger, d(2, 1), d(2, 28), None).unwrap();
|
||||
assert_eq!(feb.net_income(), dec(880));
|
||||
let bs = balance_sheet_as_of(&b.ledger, d(2, 28)).unwrap();
|
||||
assert_eq!(bs.current_earnings, dec(880));
|
||||
assert_eq!(
|
||||
bs.equity.last().unwrap(),
|
||||
&line("3999", "Retained Earnings", 1_700)
|
||||
);
|
||||
assert!(bs.balanced());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn periods_close_in_order() {
|
||||
let mut b = books(false);
|
||||
let jan = b
|
||||
.ledger
|
||||
.create_period("2026-01", d(1, 1), d(1, 31))
|
||||
.unwrap();
|
||||
let feb = b
|
||||
.ledger
|
||||
.create_period("2026-02", d(2, 1), d(2, 28))
|
||||
.unwrap();
|
||||
b.ledger.close_period(feb).unwrap();
|
||||
// February swept everything through Feb 28, January included.
|
||||
assert!(matches!(
|
||||
b.ledger.close_period(jan),
|
||||
Err(LedgerError::CloseOutOfOrder(j, f)) if j == jan && f == feb
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_partnership_closes_into_members_capital() {
|
||||
let (mut l, members) = partnership(false);
|
||||
let to = ledger_core::CloseTo::Members(
|
||||
members
|
||||
.iter()
|
||||
.map(|m| ledger_core::MemberClose {
|
||||
capital: m.capital_account,
|
||||
share: m.share,
|
||||
distributions: None,
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
let jan = l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
|
||||
l.close_period_to(jan, &to).unwrap();
|
||||
// January earned 666.67: Alice's capital gets 333.34, Bob's 333.33,
|
||||
// and there is no Retained Earnings account at all.
|
||||
assert!(l.chart().find_by_code("3999").is_none());
|
||||
let bs = balance_sheet_as_of(&l, d(1, 31)).unwrap();
|
||||
let capital: Vec<Decimal> = bs.equity.iter().map(|e| e.amount).collect();
|
||||
assert_eq!(capital, [cents("933.34"), cents("733.33")]);
|
||||
assert_eq!(bs.current_earnings, Decimal::ZERO);
|
||||
|
||||
// The statement still reads: contributions are contributions (the
|
||||
// close is income, not a contribution), and February starts where
|
||||
// January ended.
|
||||
let row = |c: &ledger_core::MemberEquityColumn| {
|
||||
(
|
||||
c.beginning,
|
||||
c.contributions,
|
||||
c.net_income,
|
||||
c.distributions,
|
||||
c.ending,
|
||||
)
|
||||
};
|
||||
let jan_me = members_equity(&l, d(1, 1), d(1, 31), &members).unwrap();
|
||||
assert_eq!(
|
||||
row(&jan_me.members[0]),
|
||||
(
|
||||
cents("0"),
|
||||
cents("600"),
|
||||
cents("333.34"),
|
||||
cents("0"),
|
||||
cents("933.34")
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
row(&jan_me.members[1]),
|
||||
(
|
||||
cents("0"),
|
||||
cents("400"),
|
||||
cents("333.33"),
|
||||
cents("0"),
|
||||
cents("733.33")
|
||||
)
|
||||
);
|
||||
let feb_me = members_equity(&l, d(2, 1), d(2, 28), &members).unwrap();
|
||||
assert_eq!(feb_me.members[0].beginning, cents("933.34"));
|
||||
assert_eq!(feb_me.members[1].ending, cents("683.33"));
|
||||
assert_eq!(
|
||||
feb_me.total.ending,
|
||||
balance_sheet_as_of(&l, d(2, 28)).unwrap().total_equity
|
||||
);
|
||||
|
||||
// Shares must still add up.
|
||||
let feb = l.create_period("2026-02", d(2, 1), d(2, 28)).unwrap();
|
||||
let bad = ledger_core::CloseTo::Members(vec![ledger_core::MemberClose {
|
||||
capital: members[0].capital_account,
|
||||
share: cents("0.5"),
|
||||
distributions: None,
|
||||
}]);
|
||||
assert!(matches!(
|
||||
l.close_period_to(feb, &bad),
|
||||
Err(LedgerError::InvalidMembers(_))
|
||||
));
|
||||
}
|
||||
|
||||
fn entry_lines(l: &Ledger, id: ledger_core::EntryId) -> Vec<(String, Side, Decimal)> {
|
||||
l.get_entry(id)
|
||||
.unwrap()
|
||||
.lines()
|
||||
.iter()
|
||||
.map(|x| {
|
||||
(
|
||||
l.chart().get(x.account).unwrap().code.clone(),
|
||||
x.side,
|
||||
x.amount.amount,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sole_proprietor_closes_net_income_and_draws_into_capital() {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let cash = l
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let capital = l
|
||||
.add_account("3000", "Owner's Capital", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let draws = l
|
||||
.add_account("3100", "Owner's Draws", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let sales = l
|
||||
.add_account("4000", "Sales", AccountType::Revenue, None, false)
|
||||
.unwrap();
|
||||
let rent = l
|
||||
.add_account("6000", "Rent", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
post(&mut l, d(1, 2), "Owner invests", cash, capital, 5_000);
|
||||
post(&mut l, d(1, 10), "Sales", cash, sales, 3_000);
|
||||
post(&mut l, d(1, 15), "Rent", rent, cash, 1_000);
|
||||
post(&mut l, d(1, 20), "Owner draw", draws, cash, 500);
|
||||
let jan = l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
|
||||
let to = ledger_core::CloseTo::Account {
|
||||
into: capital,
|
||||
sweep: vec![draws],
|
||||
};
|
||||
let closing = l.close_period_to(jan, &to).unwrap().unwrap();
|
||||
// Net 2,000 to capital; the 500 of draws zeroed into capital.
|
||||
assert_eq!(
|
||||
entry_lines(&l, closing),
|
||||
[
|
||||
("4000".to_string(), Side::Debit, dec(3_000)),
|
||||
("6000".to_string(), Side::Credit, dec(1_000)),
|
||||
("3000".to_string(), Side::Credit, dec(2_000)),
|
||||
("3100".to_string(), Side::Credit, dec(500)),
|
||||
("3000".to_string(), Side::Debit, dec(500)),
|
||||
]
|
||||
);
|
||||
let bs = balance_sheet_as_of(&l, d(1, 31)).unwrap();
|
||||
// 5,000 + 2,000 − 500 = 6,500, which is exactly the cash.
|
||||
assert_eq!(bs.equity, vec![line("3000", "Owner's Capital", 6_500)]);
|
||||
assert_eq!(bs.total_assets, dec(6_500));
|
||||
assert_eq!(bs.current_earnings, Decimal::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_corporation_closes_dividends_into_retained_earnings() {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let cash = l
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let stock = l
|
||||
.add_account("3000", "Common Stock", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let dividends = l
|
||||
.add_account("3200", "Dividends Paid", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let sales = l
|
||||
.add_account("4000", "Sales", AccountType::Revenue, None, false)
|
||||
.unwrap();
|
||||
post(&mut l, d(1, 2), "Stock issued", cash, stock, 1_000);
|
||||
post(&mut l, d(1, 10), "Sales", cash, sales, 900);
|
||||
post(&mut l, d(1, 25), "Dividend", dividends, cash, 300);
|
||||
let jan = l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
|
||||
let to = ledger_core::CloseTo::RetainedEarnings {
|
||||
sweep: vec![dividends],
|
||||
};
|
||||
l.close_period_to(jan, &to).unwrap();
|
||||
let bs = balance_sheet_as_of(&l, d(1, 31)).unwrap();
|
||||
// Retained earnings: 900 earned − 300 paid out.
|
||||
assert_eq!(
|
||||
bs.equity,
|
||||
vec![
|
||||
line("3000", "Common Stock", 1_000),
|
||||
line("3999", "Retained Earnings", 600)
|
||||
]
|
||||
);
|
||||
assert!(bs.balanced());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partner_distribution_accounts_roll_into_capital() {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let cash = l
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let cap_a = l
|
||||
.add_account("3000", "Capital — Alice", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let cap_b = l
|
||||
.add_account("3001", "Capital — Bob", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let dist_a = l
|
||||
.add_account(
|
||||
"3100",
|
||||
"Distributions — Alice",
|
||||
AccountType::Equity,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let dist_b = l
|
||||
.add_account(
|
||||
"3101",
|
||||
"Distributions — Bob",
|
||||
AccountType::Equity,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let sales = l
|
||||
.add_account("4000", "Sales", AccountType::Revenue, None, false)
|
||||
.unwrap();
|
||||
post(&mut l, d(1, 2), "Alice invests", cash, cap_a, 100);
|
||||
post(&mut l, d(1, 2), "Bob invests", cash, cap_b, 100);
|
||||
post(&mut l, d(1, 10), "Sales", cash, sales, 50);
|
||||
post(&mut l, d(1, 20), "Bob's distribution", dist_b, cash, 20);
|
||||
let half = cents("0.5");
|
||||
let members = vec![
|
||||
MemberCapital {
|
||||
name: "Alice".into(),
|
||||
capital_account: cap_a,
|
||||
distribution_account: Some(dist_a),
|
||||
share: half,
|
||||
},
|
||||
MemberCapital {
|
||||
name: "Bob".into(),
|
||||
capital_account: cap_b,
|
||||
distribution_account: Some(dist_b),
|
||||
share: half,
|
||||
},
|
||||
];
|
||||
// January: Bob's distribution comes from his distributions account.
|
||||
let jan_me = members_equity(&l, d(1, 1), d(1, 31), &members).unwrap();
|
||||
let row = |c: &ledger_core::MemberEquityColumn| {
|
||||
(c.contributions, c.net_income, c.distributions, c.ending)
|
||||
};
|
||||
assert_eq!(
|
||||
row(&jan_me.members[0]),
|
||||
(dec(100), dec(25), dec(0), dec(125))
|
||||
);
|
||||
assert_eq!(
|
||||
row(&jan_me.members[1]),
|
||||
(dec(100), dec(25), dec(20), dec(105))
|
||||
);
|
||||
assert_eq!(
|
||||
jan_me.total.ending,
|
||||
balance_sheet_as_of(&l, d(1, 31)).unwrap().total_equity
|
||||
);
|
||||
|
||||
// The close sweeps Bob's distributions into his capital.
|
||||
let jan = l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
|
||||
let to = ledger_core::CloseTo::Members(
|
||||
members
|
||||
.iter()
|
||||
.map(|m| ledger_core::MemberClose {
|
||||
capital: m.capital_account,
|
||||
share: m.share,
|
||||
distributions: m.distribution_account,
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
l.close_period_to(jan, &to).unwrap();
|
||||
let bs = balance_sheet_as_of(&l, d(1, 31)).unwrap();
|
||||
assert_eq!(
|
||||
bs.equity,
|
||||
vec![
|
||||
line("3000", "Capital — Alice", 125),
|
||||
line("3001", "Capital — Bob", 105)
|
||||
]
|
||||
);
|
||||
// February starts there.
|
||||
let feb_me = members_equity(&l, d(2, 1), d(2, 28), &members).unwrap();
|
||||
assert_eq!(
|
||||
(feb_me.members[0].beginning, feb_me.members[1].beginning),
|
||||
(dec(125), dec(105))
|
||||
);
|
||||
|
||||
// A sweep account must be equity.
|
||||
let mut l2 = Ledger::new(Currency::USD);
|
||||
let c = l2
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let e = l2
|
||||
.add_account("3000", "Capital", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let p = l2.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
|
||||
let bad = ledger_core::CloseTo::Account {
|
||||
into: e,
|
||||
sweep: vec![c],
|
||||
};
|
||||
assert!(matches!(
|
||||
l2.close_period_to(p, &bad),
|
||||
Err(LedgerError::InvalidMembers(_))
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Tax categories and the tax summary. Expected numbers computed by hand.
|
||||
//!
|
||||
//! The fixture: a partnership's first quarter.
|
||||
//!
|
||||
//! | account | category | % | book | deductible |
|
||||
//! |-----------------------|-----------------------|---|---------|------------|
|
||||
//! | 4000 Sales | gross_receipts | | 10,000 | 10,000 |
|
||||
//! | 4100 Refunds (exp.) | returns_allowances | | 250 | (250) |
|
||||
//! | 6000 Platform fees | commissions_fees | | 1,500 | 1,500 |
|
||||
//! | 6100 Meals | meals |50%| 333.33 | 166.67 |
|
||||
//! | 6200 Penalties | nondeductible | 0%| 40 | 0 |
|
||||
//! | 6300 Software | other_deductions | | 600 | 600 |
|
||||
//! | 6400 Partner pay | guaranteed_payments | | 2,000 | 2,000 |
|
||||
//! | 6500 Misc | (none) | | 75 | uncategorized |
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::tax::{TaxForm, check_category, tax_summary};
|
||||
use ledger_core::{AccountType, Currency, Ledger, LedgerError, Money, Side};
|
||||
use rust_decimal::Decimal;
|
||||
use std::str::FromStr;
|
||||
|
||||
fn d(m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, m, day).unwrap()
|
||||
}
|
||||
|
||||
fn dec(s: &str) -> Decimal {
|
||||
Decimal::from_str(s).unwrap()
|
||||
}
|
||||
|
||||
fn books() -> Ledger {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let cash = l
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let mut acct =
|
||||
|code: &str, name: &str, t: AccountType, cat: Option<&str>, pct: Option<&str>| {
|
||||
let id = l.add_account(code, name, t, None, false).unwrap();
|
||||
if let Some(c) = cat {
|
||||
l.set_account_tax(id, Some(c), pct.map(dec)).unwrap();
|
||||
}
|
||||
id
|
||||
};
|
||||
let sales = acct(
|
||||
"4000",
|
||||
"Sales",
|
||||
AccountType::Revenue,
|
||||
Some("gross_receipts"),
|
||||
None,
|
||||
);
|
||||
let refunds = acct(
|
||||
"4100",
|
||||
"Refunds",
|
||||
AccountType::Expense,
|
||||
Some("returns_allowances"),
|
||||
None,
|
||||
);
|
||||
let fees = acct(
|
||||
"6000",
|
||||
"Platform fees",
|
||||
AccountType::Expense,
|
||||
Some("commissions_fees"),
|
||||
None,
|
||||
);
|
||||
let meals = acct("6100", "Meals", AccountType::Expense, Some("meals"), None);
|
||||
let pen = acct(
|
||||
"6200",
|
||||
"Penalties",
|
||||
AccountType::Expense,
|
||||
Some("nondeductible"),
|
||||
None,
|
||||
);
|
||||
let soft = acct(
|
||||
"6300",
|
||||
"Software",
|
||||
AccountType::Expense,
|
||||
Some("other_deductions"),
|
||||
None,
|
||||
);
|
||||
let gp = acct(
|
||||
"6400",
|
||||
"Partner pay",
|
||||
AccountType::Expense,
|
||||
Some("guaranteed_payments"),
|
||||
None,
|
||||
);
|
||||
let misc = acct("6500", "Misc", AccountType::Expense, None, None);
|
||||
let mut post = |dr, cr, amt: &str| {
|
||||
let e = l.create_entry(d(2, 1), "x");
|
||||
l.add_line(e, dr, Side::Debit, Money::new(dec(amt), Currency::USD))
|
||||
.unwrap();
|
||||
l.add_line(e, cr, Side::Credit, Money::new(dec(amt), Currency::USD))
|
||||
.unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
};
|
||||
post(cash, sales, "10000");
|
||||
for (a, amt) in [
|
||||
(refunds, "250"),
|
||||
(fees, "1500"),
|
||||
(meals, "333.33"),
|
||||
(pen, "40"),
|
||||
(soft, "600"),
|
||||
(gp, "2000"),
|
||||
(misc, "75"),
|
||||
] {
|
||||
post(a, cash, amt);
|
||||
}
|
||||
l
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_summary_groups_by_category_and_applies_deductible_percents() {
|
||||
let l = books();
|
||||
let ts = tax_summary(&l, d(1, 1), d(3, 31), Some(TaxForm::Form1065)).unwrap();
|
||||
let rows = |lines: &[ledger_core::TaxSummaryLine]| -> Vec<(String, Decimal, Decimal)> {
|
||||
lines
|
||||
.iter()
|
||||
.map(|l| (l.category.clone(), l.book, l.tax))
|
||||
.collect()
|
||||
};
|
||||
// Refunds (an expense account) reduce receipts as negative income.
|
||||
assert_eq!(
|
||||
rows(&ts.income),
|
||||
[
|
||||
("gross_receipts".into(), dec("10000"), dec("10000")),
|
||||
("returns_allowances".into(), dec("-250"), dec("-250")),
|
||||
]
|
||||
);
|
||||
// Catalog order; meals at 50% (333.33 → 166.665 → 166.67, half away
|
||||
// from zero); penalties at 0%.
|
||||
assert_eq!(
|
||||
rows(&ts.deductions),
|
||||
[
|
||||
("commissions_fees".into(), dec("1500"), dec("1500")),
|
||||
("guaranteed_payments".into(), dec("2000"), dec("2000")),
|
||||
("meals".into(), dec("333.33"), dec("166.67")),
|
||||
("other_deductions".into(), dec("600"), dec("600")),
|
||||
("nondeductible".into(), dec("40"), dec("0")),
|
||||
]
|
||||
);
|
||||
assert_eq!(ts.uncategorized.len(), 1);
|
||||
assert_eq!(ts.uncategorized[0].account, "6500 Misc");
|
||||
assert_eq!(ts.uncategorized[0].book, dec("75"));
|
||||
// Book: 9,750 − 4,473.33 = 5,276.67. Tax: 9,750 − 4,266.67 = 5,483.33.
|
||||
assert_eq!(ts.book_net(), dec("5276.67"));
|
||||
assert_eq!(ts.tax_net(), dec("5483.33"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_override_beats_the_category_default() {
|
||||
let mut l = books();
|
||||
let meals = l.chart().find_by_code("6100").unwrap().id;
|
||||
// A company party is 100% deductible: override the 50% default.
|
||||
l.set_account_tax(meals, Some("meals"), Some(dec("100")))
|
||||
.unwrap();
|
||||
let ts = tax_summary(&l, d(1, 1), d(3, 31), None).unwrap();
|
||||
let m = ts
|
||||
.deductions
|
||||
.iter()
|
||||
.find(|x| x.category == "meals")
|
||||
.unwrap();
|
||||
assert_eq!(m.tax, dec("333.33"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn categories_must_fit_the_account_and_the_return() {
|
||||
let mut l = books();
|
||||
let sales = l.chart().find_by_code("4000").unwrap().id;
|
||||
let meals = l.chart().find_by_code("6100").unwrap().id;
|
||||
let bad = |r: Result<(), LedgerError>| matches!(r, Err(LedgerError::InvalidTaxCategory(_)));
|
||||
assert!(
|
||||
bad(l.set_account_tax(sales, Some("meals"), None)),
|
||||
"deduction on revenue"
|
||||
);
|
||||
assert!(
|
||||
bad(l.set_account_tax(meals, Some("no_such"), None)),
|
||||
"unknown key"
|
||||
);
|
||||
assert!(
|
||||
bad(l.set_account_tax(meals, Some("meals"), Some(dec("101")))),
|
||||
"over 100%"
|
||||
);
|
||||
assert!(
|
||||
bad(l.set_account_tax(meals, None, Some(dec("50")))),
|
||||
"percent without category"
|
||||
);
|
||||
// Guaranteed payments exist only on a partnership return.
|
||||
assert!(
|
||||
check_category(
|
||||
"guaranteed_payments",
|
||||
AccountType::Expense,
|
||||
Some(TaxForm::Form1065)
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
check_category(
|
||||
"guaranteed_payments",
|
||||
AccountType::Expense,
|
||||
Some(TaxForm::ScheduleC)
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
check_category(
|
||||
"officer_compensation",
|
||||
AccountType::Expense,
|
||||
Some(TaxForm::Form1120S)
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
// Clearing works.
|
||||
l.set_account_tax(meals, None, None).unwrap();
|
||||
assert!(l.chart().get(meals).unwrap().tax_category.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forms_parse_from_common_spellings() {
|
||||
for (s, f) in [
|
||||
("schedule_c", TaxForm::ScheduleC),
|
||||
("Schedule C", TaxForm::ScheduleC),
|
||||
("1065", TaxForm::Form1065),
|
||||
("Form 1065", TaxForm::Form1065),
|
||||
("1120s", TaxForm::Form1120S),
|
||||
("1120-S", TaxForm::Form1120S),
|
||||
("1120", TaxForm::Form1120),
|
||||
] {
|
||||
assert_eq!(TaxForm::parse(s), Some(f), "{s}");
|
||||
}
|
||||
assert_eq!(TaxForm::parse("990"), None);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
[package]
|
||||
name = "ledger-gui"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Read-only, live desktop view of wanderbooks books."
|
||||
|
||||
[[bin]]
|
||||
name = "ledger-gui"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
ledger-core = { path = "../ledger-core" }
|
||||
ledger-store = { path = "../ledger-store" }
|
||||
ledger-config = { path = "../ledger-config" }
|
||||
eframe = { version = "0.36", default-features = false, features = ["default_fonts", "wgpu", "wayland", "x11", "persistence"] }
|
||||
egui = "0.36"
|
||||
egui_extras = { version = "0.36", features = ["image", "datepicker", "serde"] }
|
||||
egui-phosphor = "0.14"
|
||||
hayro = { version = "0.8", features = ["embed-fonts"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
|
||||
notify = "8"
|
||||
jiff = "0.2"
|
||||
chrono = "0.4"
|
||||
rust_decimal = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
chrono = "0.4"
|
||||
egui_kittest = { version = "0.36", features = ["wgpu", "snapshot", "eframe"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,92 @@
|
||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION AND CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,442 @@
|
||||
//! The window: sidebar, top bar, and the current page.
|
||||
|
||||
use crate::books::Books;
|
||||
use crate::data::{Loader, Source, Update};
|
||||
use crate::nav::{Nav, Route, Section};
|
||||
use crate::pages;
|
||||
use crate::receipts::Receipts;
|
||||
use crate::theme::{self, pal};
|
||||
use egui::{
|
||||
Align, Color32, CornerRadius, Frame, Key, Layout, Margin, RichText, Sense, Stroke, Ui, vec2,
|
||||
};
|
||||
use egui_phosphor::regular as icon;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Per-page state that survives navigation.
|
||||
#[derive(Default)]
|
||||
pub struct PageState {
|
||||
pub transactions: pages::transactions::State,
|
||||
pub reports: pages::reports::State,
|
||||
pub accounts: pages::accounts::State,
|
||||
pub documents: pages::documents::State,
|
||||
pub journal: pages::journal::State,
|
||||
}
|
||||
|
||||
/// What a page can ask of the shell.
|
||||
pub struct Shell<'a> {
|
||||
pub receipts: &'a mut Receipts,
|
||||
pub state: &'a mut PageState,
|
||||
pub go: Option<Route>,
|
||||
/// Seconds since the last live update arrived (for "new" highlights).
|
||||
pub since_update: f64,
|
||||
}
|
||||
|
||||
impl Shell<'_> {
|
||||
pub fn go(&mut self, r: Route) {
|
||||
self.go = Some(r);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
loader: Option<Loader>,
|
||||
books: Option<Arc<Books>>,
|
||||
error: Option<String>,
|
||||
nav: Nav,
|
||||
receipts: Receipts,
|
||||
dark: bool,
|
||||
state: PageState,
|
||||
palette: crate::palette::Palette,
|
||||
updated_at: f64,
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// The live app over a books directory.
|
||||
pub fn new(cc: &eframe::CreationContext<'_>, source: Source) -> Self {
|
||||
let dark = cc
|
||||
.storage
|
||||
.and_then(|s| s.get_string("dark"))
|
||||
.map(|v| v == "true")
|
||||
.unwrap_or(cc.egui_ctx.global_style().visuals.dark_mode);
|
||||
let mut app = Self::bare(&cc.egui_ctx, dark);
|
||||
app.loader = Some(Loader::spawn(source, cc.egui_ctx.clone()));
|
||||
app
|
||||
}
|
||||
|
||||
/// An app showing fixed books (tests and screenshots).
|
||||
pub fn with_books(ctx: &egui::Context, books: Books, dark: bool, start: Route) -> Self {
|
||||
let mut app = Self::bare(ctx, dark);
|
||||
app.books = Some(Arc::new(books));
|
||||
app.nav = Nav::new(start);
|
||||
app
|
||||
}
|
||||
|
||||
fn bare(ctx: &egui::Context, dark: bool) -> Self {
|
||||
theme::install_fonts(ctx);
|
||||
theme::apply(ctx, dark);
|
||||
egui_extras::install_image_loaders(ctx);
|
||||
Self {
|
||||
loader: None,
|
||||
books: None,
|
||||
error: None,
|
||||
nav: Nav::new(Route::Overview),
|
||||
receipts: Receipts::new(ctx),
|
||||
dark,
|
||||
state: PageState::default(),
|
||||
palette: Default::default(),
|
||||
updated_at: -1e9,
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate (tests).
|
||||
pub fn goto(&mut self, r: Route) {
|
||||
self.nav.go(r);
|
||||
}
|
||||
|
||||
/// Block until every receipt on screen has rendered (screenshots).
|
||||
pub fn settle(&mut self, ctx: &egui::Context) {
|
||||
self.receipts.settle(ctx);
|
||||
}
|
||||
|
||||
/// Page state (tests).
|
||||
pub fn state_mut(&mut self) -> &mut PageState {
|
||||
&mut self.state
|
||||
}
|
||||
|
||||
fn handle_keys(&mut self, ui: &Ui) {
|
||||
let (back, fwd, palette) = ui.input(|i| {
|
||||
(
|
||||
(i.modifiers.alt && i.key_pressed(Key::ArrowLeft))
|
||||
|| i.pointer.button_pressed(egui::PointerButton::Extra1),
|
||||
(i.modifiers.alt && i.key_pressed(Key::ArrowRight))
|
||||
|| i.pointer.button_pressed(egui::PointerButton::Extra2),
|
||||
i.modifiers.command && i.key_pressed(Key::K),
|
||||
)
|
||||
});
|
||||
if back {
|
||||
self.nav.back();
|
||||
}
|
||||
if fwd {
|
||||
self.nav.forward();
|
||||
}
|
||||
if palette {
|
||||
self.palette.open();
|
||||
}
|
||||
}
|
||||
|
||||
fn sidebar(&mut self, ui: &mut Ui) {
|
||||
let p = pal(ui.ctx());
|
||||
let books = self.books.clone();
|
||||
egui::Panel::left("sidebar")
|
||||
.exact_size(224.0)
|
||||
.resizable(false)
|
||||
.frame(
|
||||
Frame::new()
|
||||
.fill(p.sidebar)
|
||||
.inner_margin(Margin::symmetric(12, 16)),
|
||||
)
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(28.0, 28.0), Sense::hover());
|
||||
ui.painter().rect_filled(r, CornerRadius::same(8), p.accent);
|
||||
let initial = books
|
||||
.as_ref()
|
||||
.and_then(|b| b.company_name().chars().next())
|
||||
.unwrap_or('W');
|
||||
ui.painter().text(
|
||||
r.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
initial,
|
||||
theme::bold(15.0),
|
||||
Color32::WHITE,
|
||||
);
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
let name = books
|
||||
.as_ref()
|
||||
.map(|b| b.company_name().to_string())
|
||||
.unwrap_or_else(|| "wanderbooks".into());
|
||||
ui.add(
|
||||
egui::Label::new(RichText::new(name).font(theme::semibold(13.5)))
|
||||
.truncate(),
|
||||
);
|
||||
ui.label(
|
||||
RichText::new("Books")
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
});
|
||||
});
|
||||
ui.add_space(18.0);
|
||||
let current = self.nav.current.section();
|
||||
let attention = books.as_ref().map(|b| b.attention.len()).unwrap_or(0);
|
||||
for s in Section::ALL {
|
||||
let on = s == current;
|
||||
let (rect, resp) =
|
||||
ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click());
|
||||
if on {
|
||||
ui.painter()
|
||||
.rect_filled(rect, CornerRadius::same(8), p.surface);
|
||||
ui.painter().rect_stroke(
|
||||
rect,
|
||||
CornerRadius::same(8),
|
||||
Stroke::new(1.0, p.border),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
} else if resp.hovered() {
|
||||
ui.painter()
|
||||
.rect_filled(rect, CornerRadius::same(8), p.hover);
|
||||
}
|
||||
let color = if on { p.text } else { p.muted };
|
||||
ui.painter().text(
|
||||
rect.left_center() + vec2(10.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
s.icon(),
|
||||
egui::FontId::proportional(16.0),
|
||||
if on { p.accent } else { p.muted },
|
||||
);
|
||||
ui.painter().text(
|
||||
rect.left_center() + vec2(36.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
s.title(),
|
||||
if on {
|
||||
theme::semibold(13.5)
|
||||
} else {
|
||||
theme::regular(13.5)
|
||||
},
|
||||
color,
|
||||
);
|
||||
if s == Section::Overview && attention > 0 {
|
||||
let badge = egui::Rect::from_center_size(
|
||||
rect.right_center() - vec2(18.0, 0.0),
|
||||
vec2(22.0, 18.0),
|
||||
);
|
||||
ui.painter()
|
||||
.rect_filled(badge, CornerRadius::same(9), p.warning_soft);
|
||||
ui.painter().text(
|
||||
badge.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
attention.to_string(),
|
||||
theme::semibold(11.0),
|
||||
p.warning,
|
||||
);
|
||||
}
|
||||
if resp.clicked() {
|
||||
self.nav.go(s.home());
|
||||
}
|
||||
}
|
||||
ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
|
||||
let label = if self.dark {
|
||||
format!("{} Light mode", icon::SUN)
|
||||
} else {
|
||||
format!("{} Dark mode", icon::MOON)
|
||||
};
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(
|
||||
RichText::new(label)
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
)
|
||||
.frame(false),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
self.dark = !self.dark;
|
||||
theme::apply(ui.ctx(), self.dark);
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
if let Some(b) = &books {
|
||||
live_status(
|
||||
ui,
|
||||
b,
|
||||
ui.input(|i| i.time) - self.updated_at,
|
||||
self.loader.is_some(),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn topbar(&mut self, ui: &mut Ui) {
|
||||
let p = pal(ui.ctx());
|
||||
egui::Panel::top("topbar")
|
||||
.exact_size(52.0)
|
||||
.frame(
|
||||
Frame::new()
|
||||
.fill(p.bg)
|
||||
.inner_margin(Margin::symmetric(20, 10))
|
||||
.stroke(Stroke::new(1.0, p.border)),
|
||||
)
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal_centered(|ui| {
|
||||
let nav_btn = |ui: &mut Ui, glyph: &str, enabled: bool, tip: &str| {
|
||||
ui.add_enabled(
|
||||
enabled,
|
||||
egui::Button::new(RichText::new(glyph).size(16.0))
|
||||
.frame(false)
|
||||
.min_size(vec2(28.0, 28.0)),
|
||||
)
|
||||
.on_hover_text(tip)
|
||||
.clicked()
|
||||
};
|
||||
if nav_btn(ui, icon::ARROW_LEFT, self.nav.can_back(), "Back (Alt+←)") {
|
||||
self.nav.back();
|
||||
}
|
||||
if nav_btn(
|
||||
ui,
|
||||
icon::ARROW_RIGHT,
|
||||
self.nav.can_forward(),
|
||||
"Forward (Alt+→)",
|
||||
) {
|
||||
self.nav.forward();
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
let crumb = self
|
||||
.books
|
||||
.as_ref()
|
||||
.map(|b| crate::palette::describe(b, &self.nav.current))
|
||||
.unwrap_or_default();
|
||||
ui.label(
|
||||
RichText::new(self.nav.current.section().title())
|
||||
.font(theme::regular(13.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
if !crumb.is_empty() {
|
||||
ui.label(RichText::new(icon::CARET_RIGHT).color(p.faint));
|
||||
ui.add(
|
||||
egui::Label::new(RichText::new(crumb).font(theme::semibold(13.5)))
|
||||
.truncate(),
|
||||
);
|
||||
}
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
let search = egui::Button::new(
|
||||
RichText::new(format!(
|
||||
"{} Search everything… Ctrl K",
|
||||
icon::MAGNIFYING_GLASS
|
||||
))
|
||||
.font(theme::regular(13.0))
|
||||
.color(p.faint),
|
||||
)
|
||||
.fill(p.surface)
|
||||
.stroke(Stroke::new(1.0, p.border))
|
||||
.corner_radius(CornerRadius::same(8))
|
||||
.min_size(vec2(260.0, 30.0));
|
||||
if ui.add(search).clicked() {
|
||||
self.palette.open();
|
||||
}
|
||||
if let Some(b) = &self.books {
|
||||
ui.label(
|
||||
RichText::new(format!("Today {}", crate::fmt::date(b.today)))
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn live_status(ui: &mut Ui, b: &Books, since_update: f64, live: bool) {
|
||||
let p = pal(ui.ctx());
|
||||
Frame::new()
|
||||
.fill(p.surface)
|
||||
.stroke(Stroke::new(1.0, p.border))
|
||||
.corner_radius(CornerRadius::same(8))
|
||||
.inner_margin(Margin::same(10))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.horizontal(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(8.0, 8.0), Sense::hover());
|
||||
let pulse = (since_update < 2.0) as u8 as f32;
|
||||
let color = if live { p.positive } else { p.faint };
|
||||
ui.painter().circle_filled(r.center(), 4.0 + pulse, color);
|
||||
ui.label(
|
||||
RichText::new(if live { "Live" } else { "Snapshot" })
|
||||
.font(theme::semibold(12.0)),
|
||||
);
|
||||
ui.label(
|
||||
RichText::new(format!("· event {}", b.seq()))
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
});
|
||||
ui.label(
|
||||
RichText::new(format!("{} Journal chain verified", icon::SEAL_CHECK))
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.muted),
|
||||
)
|
||||
.on_hover_text(format!(
|
||||
"Every journal event's hash chains to the one before it.\nHead: {}\nData: {}",
|
||||
b.head,
|
||||
b.data_dir.display()
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) {
|
||||
if let Some(loader) = &self.loader {
|
||||
match loader.poll() {
|
||||
Some(Update::Books(b)) => {
|
||||
if self.books.is_some() {
|
||||
self.updated_at = ui.input(|i| i.time);
|
||||
}
|
||||
self.books = Some(b);
|
||||
self.error = None;
|
||||
}
|
||||
Some(Update::Error(e)) => self.error = Some(e),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
self.receipts.pump(ui.ctx());
|
||||
self.handle_keys(ui);
|
||||
self.sidebar(ui);
|
||||
self.topbar(ui);
|
||||
let p = pal(ui.ctx());
|
||||
let since_update = ui.input(|i| i.time) - self.updated_at;
|
||||
if since_update < 6.0 {
|
||||
ui.ctx()
|
||||
.request_repaint_after(std::time::Duration::from_millis(250));
|
||||
}
|
||||
egui::CentralPanel::no_frame()
|
||||
.frame(Frame::new().fill(p.bg))
|
||||
.show(ui, |ui| {
|
||||
if let Some(err) = &self.error {
|
||||
Frame::new()
|
||||
.fill(p.negative_soft)
|
||||
.inner_margin(Margin::symmetric(20, 8))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.label(
|
||||
RichText::new(format!("{} {err}", icon::WARNING)).color(p.negative),
|
||||
);
|
||||
});
|
||||
}
|
||||
let Some(books) = self.books.clone() else {
|
||||
ui.centered_and_justified(|ui| {
|
||||
ui.label(RichText::new("Opening the books…").color(p.muted));
|
||||
});
|
||||
return;
|
||||
};
|
||||
let mut shell = Shell {
|
||||
receipts: &mut self.receipts,
|
||||
state: &mut self.state,
|
||||
go: None,
|
||||
since_update,
|
||||
};
|
||||
let route = self.nav.current.clone();
|
||||
pages::show(ui, &books, &route, &mut shell);
|
||||
if let Some(to) = shell.go.take() {
|
||||
self.nav.go(to);
|
||||
}
|
||||
if let Some(to) = self.palette.show(ui.ctx(), &books) {
|
||||
self.nav.go(to);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||
storage.set_string("dark", self.dark.to_string());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
//! What the books need: drafts left unposted, missing receipts, schedules
|
||||
//! and recurring entries due, open past periods, uncategorized tax lines,
|
||||
//! 1099 vendors without a W-9, overdue bills and invoices, money owed to
|
||||
//! owners. 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).
|
||||
|
||||
use crate::books::{Attention, Books, Severity};
|
||||
use crate::fmt;
|
||||
use crate::nav::Route;
|
||||
use chrono::Datelike;
|
||||
use ledger_core::reports::profit_and_loss_for_period;
|
||||
use ledger_core::{AccountType, EntryStatus, PeriodStatus, vendor_1099_report};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
pub fn find(b: &Books) -> Vec<Attention> {
|
||||
let mut out = Vec::new();
|
||||
let today = b.today;
|
||||
|
||||
for e in b.entries.iter().filter(|e| e.status == EntryStatus::Draft) {
|
||||
out.push(Attention {
|
||||
severity: Severity::Todo,
|
||||
title: format!("Draft not posted: {}", e.description),
|
||||
detail: format!(
|
||||
"#{} · {} · {}. Drafts appear in no report until posted.",
|
||||
e.id,
|
||||
fmt::date(e.date),
|
||||
fmt::dollars(e.amount)
|
||||
),
|
||||
target: Some(Route::Entry(e.id)),
|
||||
prompt: format!(
|
||||
"In wanderbooks, review draft entry {} (\"{}\", {}): attach its receipt if it needs one, preview it, and post it if it's right.",
|
||||
e.id,
|
||||
e.description,
|
||||
fmt::dollars(e.amount)
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let missing: Vec<_> = b
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| e.status == EntryStatus::Posted && e.missing_receipt)
|
||||
.collect();
|
||||
if missing.len() > 3 {
|
||||
let total: Decimal = missing.iter().map(|e| e.amount).sum();
|
||||
let list: Vec<String> = missing
|
||||
.iter()
|
||||
.map(|e| format!("{} ({}, {})", e.id, e.description, e.date))
|
||||
.collect();
|
||||
out.push(Attention {
|
||||
severity: Severity::Problem,
|
||||
title: format!("{} without receipts", fmt::count(missing.len(), "expense", "expenses")),
|
||||
detail: format!(
|
||||
"{} of spending over the {} threshold has no receipt attached.",
|
||||
fmt::dollars(total),
|
||||
fmt::dollars(b.receipt_threshold)
|
||||
),
|
||||
target: Some(Route::Transactions),
|
||||
prompt: format!(
|
||||
"In wanderbooks, these entries have no receipt: {}. Help me find each receipt and attach it with books.attach_receipt.",
|
||||
list.join("; ")
|
||||
),
|
||||
});
|
||||
}
|
||||
for e in missing.iter().filter(|_| missing.len() <= 3) {
|
||||
out.push(Attention {
|
||||
severity: Severity::Problem,
|
||||
title: format!("No receipt: {}", e.description),
|
||||
detail: format!(
|
||||
"#{} · {} · {} is over the {} receipt threshold.",
|
||||
e.id,
|
||||
fmt::date(e.date),
|
||||
fmt::dollars(e.amount),
|
||||
fmt::dollars(b.receipt_threshold)
|
||||
),
|
||||
target: Some(Route::Entry(e.id)),
|
||||
prompt: format!(
|
||||
"In wanderbooks, entry {} (\"{}\", {} on {}) has no receipt. Find it and attach it with books.attach_receipt.",
|
||||
e.id,
|
||||
e.description,
|
||||
fmt::dollars(e.amount),
|
||||
e.date
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
for r in b.ledger.recurring_entries() {
|
||||
let due = r.due(today);
|
||||
if !due.is_empty() {
|
||||
out.push(Attention {
|
||||
severity: Severity::Todo,
|
||||
title: format!("Recurring \"{}\" is due", r.description),
|
||||
detail: format!(
|
||||
"{} since {}.",
|
||||
fmt::count(due.len(), "occurrence", "occurrences"),
|
||||
fmt::date(due[0])
|
||||
),
|
||||
target: Some(Route::Company),
|
||||
prompt: format!(
|
||||
"In wanderbooks, run books.run_recurring through {today}, then attach each draft's receipt and post them."
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for s in b.ledger.schedules() {
|
||||
let due = s.due(today);
|
||||
if !due.is_empty() {
|
||||
let total: Decimal = due.iter().map(|(_, a)| *a).sum();
|
||||
out.push(Attention {
|
||||
severity: Severity::Todo,
|
||||
title: format!("Amortization due: {}", s.description),
|
||||
detail: format!(
|
||||
"{} totaling {} not yet posted.",
|
||||
fmt::count(due.len(), "month", "months"),
|
||||
fmt::dollars(total)
|
||||
),
|
||||
target: Some(Route::Company),
|
||||
prompt: format!(
|
||||
"In wanderbooks, preview books.run_schedules through {today}, then post it if it's right."
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for p in b.ledger.list_periods() {
|
||||
if p.status == PeriodStatus::Open && p.end < today {
|
||||
out.push(Attention {
|
||||
severity: Severity::Todo,
|
||||
title: format!("Period {} ended but is open", p.name),
|
||||
detail: format!(
|
||||
"{} – {}. Closing locks it and moves its earnings to equity.",
|
||||
fmt::date(p.start),
|
||||
fmt::date(p.end)
|
||||
),
|
||||
target: Some(Route::Company),
|
||||
prompt: format!(
|
||||
"In wanderbooks, review period {} and, if everything for it is recorded and reconciled, preview and close it.",
|
||||
p.name
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let (fy_start, _) = b.fiscal_ytd();
|
||||
if let Ok(pl) = profit_and_loss_for_period(&b.ledger, fy_start, today, None) {
|
||||
let uncategorized: Vec<String> = pl
|
||||
.revenue
|
||||
.iter()
|
||||
.chain(&pl.expenses)
|
||||
.filter(|l| {
|
||||
b.ledger
|
||||
.chart()
|
||||
.find_by_code(&l.code)
|
||||
.is_some_and(|a| a.tax_category.is_none())
|
||||
})
|
||||
.map(|l| format!("{} {}", l.code, l.name))
|
||||
.collect();
|
||||
if !uncategorized.is_empty() {
|
||||
out.push(Attention {
|
||||
severity: Severity::Info,
|
||||
title: format!(
|
||||
"{} without a tax category",
|
||||
fmt::count(uncategorized.len(), "account", "accounts")
|
||||
),
|
||||
detail: format!(
|
||||
"{}. The tax summary lists them as uncategorized.",
|
||||
uncategorized.join(", ")
|
||||
),
|
||||
target: Some(Route::Accounts),
|
||||
prompt: format!(
|
||||
"In wanderbooks, propose a tax category for each of these accounts ({}) using books.tax_categories, and set them with books.set_tax_category once I agree.",
|
||||
uncategorized.join(", ")
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut years = vec![today.year()];
|
||||
if today.month() == 1 {
|
||||
years.insert(0, today.year() - 1);
|
||||
}
|
||||
for year in years {
|
||||
let r = vendor_1099_report(&b.ledger, year, None);
|
||||
for v in r.vendors.iter().filter(|v| v.reportable && !v.tin_on_file) {
|
||||
out.push(Attention {
|
||||
severity: Severity::Problem,
|
||||
title: format!("{} needs a W-9", v.name),
|
||||
detail: format!(
|
||||
"Paid {} in {year}, over the {} 1099 threshold.",
|
||||
fmt::dollars(v.paid),
|
||||
fmt::dollars(r.threshold)
|
||||
),
|
||||
target: Some(Route::Company),
|
||||
prompt: format!(
|
||||
"Remind me to request a W-9 from {}; once I have it, mark it on file in wanderbooks with books.set_vendor_1099.",
|
||||
v.name
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let overdue = |rows: Vec<ledger_core::AgingRow>| -> (usize, Decimal) {
|
||||
let late: Vec<Decimal> = rows
|
||||
.iter()
|
||||
.map(|r| r.days_30 + r.days_60 + r.days_90 + r.days_90_plus)
|
||||
.filter(|d| !d.is_zero())
|
||||
.collect();
|
||||
(late.len(), late.iter().sum())
|
||||
};
|
||||
let (n, total) = overdue(b.ledger.ap_aging(today));
|
||||
if n > 0 {
|
||||
out.push(Attention {
|
||||
severity: Severity::Problem,
|
||||
title: format!("{} overdue to pay", fmt::dollars(total)),
|
||||
detail: format!(
|
||||
"{} with bills past due.",
|
||||
fmt::count(n, "vendor", "vendors")
|
||||
),
|
||||
target: Some(Route::Reports(crate::nav::ReportKind::ApAging)),
|
||||
prompt: "In wanderbooks, show me the A/P aging and which bills are past due.".into(),
|
||||
});
|
||||
}
|
||||
let (n, total) = overdue(b.ledger.ar_aging(today));
|
||||
if n > 0 {
|
||||
out.push(Attention {
|
||||
severity: Severity::Problem,
|
||||
title: format!("{} overdue to collect", fmt::dollars(total)),
|
||||
detail: format!(
|
||||
"{} with invoices past due.",
|
||||
fmt::count(n, "customer", "customers")
|
||||
),
|
||||
target: Some(Route::Reports(crate::nav::ReportKind::ArAging)),
|
||||
prompt:
|
||||
"In wanderbooks, show me the A/R aging and draft reminders for past-due invoices."
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
|
||||
for a in b.ledger.chart().all().filter(|a| {
|
||||
a.account_type == AccountType::Liability && {
|
||||
let n = a.name.to_lowercase();
|
||||
n.contains("due to") || n.contains("owed to")
|
||||
}
|
||||
}) {
|
||||
let bal = b.balance_on(a.id, today);
|
||||
if bal > Decimal::ZERO {
|
||||
out.push(Attention {
|
||||
severity: Severity::Info,
|
||||
title: format!("{} owed on {}", fmt::dollars(bal), a.name),
|
||||
detail: "Expenses someone paid personally, not yet reimbursed.".into(),
|
||||
target: Some(Route::Account(a.id)),
|
||||
prompt: format!(
|
||||
"In wanderbooks, list what makes up the {} balance on {} {} and who is owed what.",
|
||||
fmt::dollars(bal),
|
||||
a.code,
|
||||
a.name
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
out.sort_by_key(|a| a.severity);
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
//! The view model: one immutable picture of the books, rebuilt whenever the
|
||||
//! journal grows. Built off the UI thread from a [`Snapshot`]; the UI only
|
||||
//! reads it. Every number here comes from `ledger-core`; this module only
|
||||
//! indexes and arranges.
|
||||
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use ledger_config::BooksSettings;
|
||||
use ledger_core::analysis::{balance_history, balance_series};
|
||||
use ledger_core::reports::{balance_sheet_as_of, profit_and_loss_for_period};
|
||||
use ledger_core::{AccountId, AccountType, EntryId, EntryStatus, Ledger, Side};
|
||||
use ledger_store::{Attachment, JournalLine, Snapshot};
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::Value;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// One entry, indexed for listing and detail.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct EntryInfo {
|
||||
pub id: EntryId,
|
||||
pub date: NaiveDate,
|
||||
pub description: String,
|
||||
pub status: EntryStatus,
|
||||
/// Total debits.
|
||||
pub amount: Decimal,
|
||||
/// Accounts touched, debits first: "6300 Software".
|
||||
pub accounts: Vec<AccountId>,
|
||||
/// Indexes into [`Books::attachments`] of linked, current receipts.
|
||||
pub receipts: Vec<usize>,
|
||||
/// How the entry came to be, in words.
|
||||
pub origin: &'static str,
|
||||
/// Indexes into [`Books::journal`] of every event that touched it.
|
||||
pub events: Vec<usize>,
|
||||
pub vendor: Option<String>,
|
||||
pub reversal_of: Option<EntryId>,
|
||||
pub reversed_by: Option<EntryId>,
|
||||
/// Spending over the receipt threshold with no receipt.
|
||||
pub missing_receipt: bool,
|
||||
/// Net effect on cash accounts: positive in, negative out, zero if the
|
||||
/// entry doesn't touch cash.
|
||||
pub cash_effect: Decimal,
|
||||
/// The customer or vendor, from invoices, bills, and vendor tags.
|
||||
pub party: Option<String>,
|
||||
/// Lower-cased text for search: description, accounts, vendor, amount.
|
||||
pub haystack: String,
|
||||
}
|
||||
|
||||
/// Something the books need, with how to fix it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Attention {
|
||||
pub severity: Severity,
|
||||
pub title: String,
|
||||
pub detail: String,
|
||||
pub target: Option<crate::nav::Route>,
|
||||
/// A prompt to paste into the agent session that fixes it.
|
||||
pub prompt: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Severity {
|
||||
Problem,
|
||||
Todo,
|
||||
Info,
|
||||
}
|
||||
|
||||
/// The whole picture.
|
||||
pub struct Books {
|
||||
pub ledger: Ledger,
|
||||
pub settings: BooksSettings,
|
||||
pub data_dir: PathBuf,
|
||||
pub attachments: Vec<Attachment>,
|
||||
pub journal: Vec<JournalLine>,
|
||||
pub head: String,
|
||||
pub today: NaiveDate,
|
||||
pub loaded_at: chrono::DateTime<chrono::Utc>,
|
||||
/// Newest first (date, then id).
|
||||
pub entries: Vec<EntryInfo>,
|
||||
pub entry_index: HashMap<EntryId, usize>,
|
||||
pub cash_accounts: Vec<AccountId>,
|
||||
pub card_accounts: Vec<AccountId>,
|
||||
/// Entries created or changed by the most recent refresh.
|
||||
pub fresh: HashSet<EntryId>,
|
||||
/// Journal seq before the most recent refresh.
|
||||
pub fresh_from_seq: u64,
|
||||
pub receipt_threshold: Decimal,
|
||||
pub attention: Vec<Attention>,
|
||||
pub kpis: Kpis,
|
||||
}
|
||||
|
||||
/// The headline numbers.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Kpis {
|
||||
pub cash: Decimal,
|
||||
pub cash_30d_ago: Decimal,
|
||||
pub liabilities: Decimal,
|
||||
pub receivables: Decimal,
|
||||
pub revenue_mtd: Decimal,
|
||||
pub expenses_mtd: Decimal,
|
||||
/// Last month over the same days as this month so far.
|
||||
pub revenue_prev_month: Decimal,
|
||||
pub expenses_prev_month: Decimal,
|
||||
pub net_ytd: Decimal,
|
||||
/// Average monthly net loss over the last three full months, if losing.
|
||||
pub monthly_burn: Option<Decimal>,
|
||||
pub first_entry: Option<NaiveDate>,
|
||||
}
|
||||
|
||||
impl Books {
|
||||
/// Build the picture from a snapshot. `prev` is the previous picture,
|
||||
/// to mark what's new.
|
||||
pub fn build(
|
||||
snap: &Snapshot,
|
||||
settings: BooksSettings,
|
||||
today: NaiveDate,
|
||||
prev_seq: Option<u64>,
|
||||
) -> Result<Books, String> {
|
||||
let ledger = snap.ledger().clone();
|
||||
let attachments = snap.attachments(None).map_err(|e| e.to_string())?;
|
||||
let journal = snap.journal().to_vec();
|
||||
let receipt_threshold = settings
|
||||
.receipt_threshold
|
||||
.as_deref()
|
||||
.and_then(|t| t.parse().ok())
|
||||
.unwrap_or(Decimal::new(25, 0));
|
||||
let cash_accounts = cash_accounts(&ledger, &settings);
|
||||
let card_accounts: Vec<AccountId> = ledger
|
||||
.chart()
|
||||
.all()
|
||||
.filter(|a| {
|
||||
a.account_type == AccountType::Liability && a.name.to_lowercase().contains("card")
|
||||
})
|
||||
.map(|a| a.id)
|
||||
.collect();
|
||||
|
||||
// Journal events per entry.
|
||||
let mut events: HashMap<EntryId, Vec<usize>> = HashMap::new();
|
||||
for (i, line) in journal.iter().enumerate() {
|
||||
for id in entry_refs(line) {
|
||||
events.entry(id).or_default().push(i);
|
||||
}
|
||||
}
|
||||
let mut receipts: HashMap<EntryId, Vec<usize>> = HashMap::new();
|
||||
for (i, a) in attachments.iter().enumerate() {
|
||||
if let (Some(e), None) = (a.entry_id, &a.withdrawn_at) {
|
||||
receipts.entry(e).or_default().push(i);
|
||||
}
|
||||
}
|
||||
let fresh_from_seq = prev_seq.unwrap_or(journal.len() as u64);
|
||||
let parties = parties(&ledger, &journal);
|
||||
let mut fresh = HashSet::new();
|
||||
let closings = ledger.closing_entry_ids();
|
||||
let mut entries = Vec::new();
|
||||
let mut reversed_by: HashMap<EntryId, EntryId> = HashMap::new();
|
||||
for id in 1..ledger.next_entry_id() {
|
||||
if let Some(e) = ledger.get_entry(id)
|
||||
&& let Some(orig) = e.reversal_of()
|
||||
{
|
||||
reversed_by.insert(orig, id);
|
||||
}
|
||||
}
|
||||
for id in 1..ledger.next_entry_id() {
|
||||
let Some(e) = ledger.get_entry(id) else {
|
||||
continue;
|
||||
};
|
||||
let evs = events.remove(&id).unwrap_or_default();
|
||||
if evs.iter().any(|&i| journal[i].seq > fresh_from_seq) {
|
||||
fresh.insert(id);
|
||||
}
|
||||
let origin = origin(&evs, &journal, closings.contains(&id));
|
||||
let mut accounts: Vec<AccountId> = Vec::new();
|
||||
for side in [Side::Debit, Side::Credit] {
|
||||
for l in e.lines().iter().filter(|l| l.side == side) {
|
||||
if !accounts.contains(&l.account) {
|
||||
accounts.push(l.account);
|
||||
}
|
||||
}
|
||||
}
|
||||
let amount: Decimal = e
|
||||
.lines()
|
||||
.iter()
|
||||
.filter(|l| l.side == Side::Debit)
|
||||
.map(|l| l.amount.amount)
|
||||
.sum();
|
||||
let rec = receipts.remove(&id).unwrap_or_default();
|
||||
let vendor = ledger
|
||||
.entry_vendor(id)
|
||||
.and_then(|v| ledger.get_vendor(v))
|
||||
.map(|v| v.name.clone());
|
||||
// Receipts prove spending: hand-made entries (and recurring
|
||||
// drafts) that debit an expense or buy an asset from outside.
|
||||
// Invoices, payments, transfers, draws, closes, and schedules
|
||||
// carry their own evidence.
|
||||
let spends = e.lines().iter().any(|l| {
|
||||
l.side == Side::Debit
|
||||
&& ledger.chart().get(l.account).is_some_and(|a| {
|
||||
a.account_type == AccountType::Expense
|
||||
|| (a.account_type == AccountType::Asset
|
||||
&& !cash_accounts.contains(&a.id)
|
||||
&& !a.name.to_lowercase().contains("receivable"))
|
||||
})
|
||||
});
|
||||
let missing_receipt = matches!(origin, "Journal entry" | "Recurring")
|
||||
&& spends
|
||||
&& rec.is_empty()
|
||||
&& amount > receipt_threshold;
|
||||
let cash_effect: Decimal = e
|
||||
.lines()
|
||||
.iter()
|
||||
.filter(|l| cash_accounts.contains(&l.account))
|
||||
.map(|l| match l.side {
|
||||
Side::Debit => l.amount.amount,
|
||||
Side::Credit => -l.amount.amount,
|
||||
})
|
||||
.sum();
|
||||
let party = vendor.clone().or_else(|| parties.get(&id).cloned());
|
||||
let mut haystack = format!(
|
||||
"{} {} #{} {}",
|
||||
e.description(),
|
||||
crate::fmt::money(amount),
|
||||
id,
|
||||
amount
|
||||
);
|
||||
for a in &accounts {
|
||||
if let Some(acct) = ledger.chart().get(*a) {
|
||||
haystack.push_str(&format!(" {} {}", acct.code, acct.name));
|
||||
}
|
||||
}
|
||||
if let Some(v) = &party {
|
||||
haystack.push(' ');
|
||||
haystack.push_str(v);
|
||||
}
|
||||
entries.push(EntryInfo {
|
||||
id,
|
||||
date: e.date(),
|
||||
description: e.description().to_string(),
|
||||
status: e.status(),
|
||||
amount,
|
||||
accounts,
|
||||
receipts: rec,
|
||||
origin,
|
||||
events: evs,
|
||||
vendor,
|
||||
reversal_of: e.reversal_of(),
|
||||
reversed_by: reversed_by.get(&id).copied(),
|
||||
missing_receipt,
|
||||
cash_effect,
|
||||
party,
|
||||
haystack: haystack.to_lowercase(),
|
||||
});
|
||||
}
|
||||
entries.sort_by(|a, b| b.date.cmp(&a.date).then(b.id.cmp(&a.id)));
|
||||
let entry_index = entries.iter().enumerate().map(|(i, e)| (e.id, i)).collect();
|
||||
let mut books = Books {
|
||||
ledger,
|
||||
settings,
|
||||
data_dir: snap.data_dir().to_path_buf(),
|
||||
attachments,
|
||||
journal,
|
||||
head: snap.head_hash().to_string(),
|
||||
today,
|
||||
loaded_at: chrono::Utc::now(),
|
||||
entries,
|
||||
entry_index,
|
||||
cash_accounts,
|
||||
card_accounts,
|
||||
fresh,
|
||||
fresh_from_seq,
|
||||
receipt_threshold,
|
||||
attention: Vec::new(),
|
||||
kpis: Kpis::default(),
|
||||
};
|
||||
books.kpis = books.compute_kpis();
|
||||
books.attention = crate::attention::find(&books);
|
||||
Ok(books)
|
||||
}
|
||||
|
||||
pub fn seq(&self) -> u64 {
|
||||
self.journal.len() as u64
|
||||
}
|
||||
|
||||
pub fn entry(&self, id: EntryId) -> Option<&EntryInfo> {
|
||||
self.entry_index.get(&id).map(|&i| &self.entries[i])
|
||||
}
|
||||
|
||||
pub fn account_label(&self, id: AccountId) -> String {
|
||||
self.ledger
|
||||
.chart()
|
||||
.get(id)
|
||||
.map(|a| format!("{} {}", a.code, a.name))
|
||||
.unwrap_or_else(|| format!("account {id}"))
|
||||
}
|
||||
|
||||
pub fn account_name(&self, id: AccountId) -> String {
|
||||
self.ledger
|
||||
.chart()
|
||||
.get(id)
|
||||
.map(|a| a.name.clone())
|
||||
.unwrap_or_else(|| format!("account {id}"))
|
||||
}
|
||||
|
||||
pub fn company_name(&self) -> &str {
|
||||
&self.settings.company.name
|
||||
}
|
||||
|
||||
/// Cash balance at the end of each day it changed.
|
||||
pub fn cash_history(&self) -> Vec<(NaiveDate, Decimal)> {
|
||||
balance_history(&self.ledger, &self.cash_accounts)
|
||||
}
|
||||
|
||||
/// Normal-side balance of one account at the end of `date`.
|
||||
pub fn balance_on(&self, account: AccountId, date: NaiveDate) -> Decimal {
|
||||
balance_series(&self.ledger, &[account], &[date])[0]
|
||||
}
|
||||
|
||||
/// First day of the current month.
|
||||
pub fn month_start(&self) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(self.today.year(), self.today.month(), 1).expect("valid")
|
||||
}
|
||||
|
||||
/// Range of the last `n` months, ending with the current one.
|
||||
pub fn trailing_months(&self, n: u32) -> (NaiveDate, NaiveDate) {
|
||||
let start = self.month_start() - chrono::Months::new(n.saturating_sub(1));
|
||||
(start, self.today)
|
||||
}
|
||||
|
||||
/// The fiscal year to date.
|
||||
pub fn fiscal_ytd(&self) -> (NaiveDate, NaiveDate) {
|
||||
let (start, _) = self.settings.company.fiscal_year_containing(self.today);
|
||||
(start, self.today)
|
||||
}
|
||||
|
||||
fn compute_kpis(&self) -> Kpis {
|
||||
let l = &self.ledger;
|
||||
let today = self.today;
|
||||
let cash = balance_series(
|
||||
l,
|
||||
&self.cash_accounts,
|
||||
&[today - chrono::Days::new(30), today],
|
||||
);
|
||||
let bs = balance_sheet_as_of(l, today).ok();
|
||||
let receivables: Decimal = l
|
||||
.chart()
|
||||
.all()
|
||||
.filter(|a| {
|
||||
a.account_type == AccountType::Asset && a.name.to_lowercase().contains("receivable")
|
||||
})
|
||||
.map(|a| self.balance_on(a.id, today))
|
||||
.sum();
|
||||
let ms = self.month_start();
|
||||
let prev_start = ms - chrono::Months::new(1);
|
||||
let prev_end = ms - chrono::Days::new(1);
|
||||
let pl = |s, e| profit_and_loss_for_period(l, s, e, None).ok();
|
||||
let mtd = pl(ms, today);
|
||||
// Same days of last month: a fair comparison mid-month.
|
||||
let same_day = (prev_start + chrono::Days::new(u64::from(today.day() - 1))).min(prev_end);
|
||||
let prev = pl(prev_start, same_day);
|
||||
let (fy_start, _) = self.fiscal_ytd();
|
||||
let ytd = pl(fy_start, today);
|
||||
let three = pl(ms - chrono::Months::new(3), prev_end);
|
||||
let first_entry = self
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| e.status == EntryStatus::Posted)
|
||||
.map(|e| e.date)
|
||||
.min();
|
||||
let monthly_burn = three.and_then(|p| {
|
||||
let loss = p.total_expenses - p.total_revenue;
|
||||
// Only months the books cover count toward the average.
|
||||
let months = match first_entry {
|
||||
Some(f) if f > ms - chrono::Months::new(3) => {
|
||||
let m = (prev_end.year() * 12 + prev_end.month() as i32)
|
||||
- (f.year() * 12 + f.month() as i32)
|
||||
+ 1;
|
||||
m.max(0)
|
||||
}
|
||||
Some(_) => 3,
|
||||
None => 0,
|
||||
};
|
||||
(loss > Decimal::ZERO && months > 0).then(|| (loss / Decimal::from(months)).round_dp(2))
|
||||
});
|
||||
Kpis {
|
||||
cash: cash[1],
|
||||
cash_30d_ago: cash[0],
|
||||
liabilities: bs.as_ref().map(|b| b.total_liabilities).unwrap_or_default(),
|
||||
receivables,
|
||||
revenue_mtd: mtd.as_ref().map(|p| p.total_revenue).unwrap_or_default(),
|
||||
expenses_mtd: mtd.as_ref().map(|p| p.total_expenses).unwrap_or_default(),
|
||||
revenue_prev_month: prev.as_ref().map(|p| p.total_revenue).unwrap_or_default(),
|
||||
expenses_prev_month: prev.as_ref().map(|p| p.total_expenses).unwrap_or_default(),
|
||||
net_ytd: ytd
|
||||
.map(|p| p.total_revenue - p.total_expenses)
|
||||
.unwrap_or_default(),
|
||||
monthly_burn,
|
||||
first_entry,
|
||||
}
|
||||
}
|
||||
|
||||
/// Blob store path for an attachment.
|
||||
pub fn blob_root(&self) -> PathBuf {
|
||||
self.data_dir.join("blobs")
|
||||
}
|
||||
}
|
||||
|
||||
/// Customer or vendor names for entries created by invoices and bills and
|
||||
/// their payments, from the journal.
|
||||
fn parties(ledger: &Ledger, journal: &[JournalLine]) -> HashMap<EntryId, String> {
|
||||
let mut out = HashMap::new();
|
||||
let customer = |id: Option<u64>| {
|
||||
id.and_then(|c| ledger.get_customer(c))
|
||||
.map(|c| c.name.clone())
|
||||
};
|
||||
let vendor = |id: Option<u64>| {
|
||||
id.and_then(|v| ledger.get_vendor(v))
|
||||
.map(|v| v.name.clone())
|
||||
};
|
||||
let u = |p: &Value, k: &str| p.get(k).and_then(|v| v.as_u64());
|
||||
for line in journal {
|
||||
let p = &line.payload;
|
||||
let Some(entry) = u(p, "entry_id") else {
|
||||
continue;
|
||||
};
|
||||
let name = match line.line_type.as_str() {
|
||||
"invoice.created" => customer(u(p, "customer_id")),
|
||||
"invoice.payment_received" => customer(
|
||||
u(p, "invoice_id")
|
||||
.and_then(|i| ledger.get_invoice(i))
|
||||
.map(|i| i.customer_id),
|
||||
),
|
||||
"bill.created" => vendor(u(p, "vendor_id")),
|
||||
"bill.paid" => vendor(
|
||||
u(p, "bill_id")
|
||||
.and_then(|b| ledger.get_bill(b))
|
||||
.map(|b| b.vendor_id),
|
||||
),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(n) = name {
|
||||
out.insert(entry, n);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Entry ids a journal line refers to.
|
||||
pub fn entry_refs(line: &JournalLine) -> Vec<EntryId> {
|
||||
let p = &line.payload;
|
||||
let mut out = Vec::new();
|
||||
let mut push = |v: Option<&Value>| {
|
||||
if let Some(id) = v.and_then(|v| v.as_u64())
|
||||
&& !out.contains(&id)
|
||||
{
|
||||
out.push(id);
|
||||
}
|
||||
};
|
||||
push(p.get("entry_id"));
|
||||
push(p.get("closing_entry"));
|
||||
push(p.get("entry").and_then(|e| e.get("id")));
|
||||
if line.line_type.starts_with("entry.") {
|
||||
push(p.get("id"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn origin(events: &[usize], journal: &[JournalLine], closing: bool) -> &'static str {
|
||||
if closing {
|
||||
return "Period close";
|
||||
}
|
||||
let has = |t: &str| events.iter().any(|&i| journal[i].line_type == t);
|
||||
if has("entry.reversed") {
|
||||
"Reversal"
|
||||
} else if has("schedule.posted") {
|
||||
"Amortization"
|
||||
} else if has("asset.depreciation_posted") {
|
||||
"Depreciation"
|
||||
} else if has("asset.disposed") {
|
||||
"Asset disposal"
|
||||
} else if has("bill.created") {
|
||||
"Bill"
|
||||
} else if has("bill.paid") {
|
||||
"Bill payment"
|
||||
} else if has("invoice.created") {
|
||||
"Invoice"
|
||||
} else if has("invoice.payment_received") {
|
||||
"Customer payment"
|
||||
} else if has("payroll.run") {
|
||||
"Payroll"
|
||||
} else if has("payroll.remitted") || has("payroll.taxes_remitted") {
|
||||
"Payroll taxes"
|
||||
} else if has("recurring.generated") {
|
||||
"Recurring"
|
||||
} else {
|
||||
"Journal entry"
|
||||
}
|
||||
}
|
||||
|
||||
/// Cash accounts: from `[books] cash_accounts`, else asset accounts whose
|
||||
/// names say they hold cash.
|
||||
fn cash_accounts(ledger: &Ledger, settings: &BooksSettings) -> Vec<AccountId> {
|
||||
if !settings.cash_accounts.is_empty() {
|
||||
return settings
|
||||
.cash_accounts
|
||||
.iter()
|
||||
.filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id))
|
||||
.collect();
|
||||
}
|
||||
ledger
|
||||
.chart()
|
||||
.all()
|
||||
.filter(|a| {
|
||||
let n = a.name.to_lowercase();
|
||||
a.account_type == AccountType::Asset
|
||||
&& !a.placeholder
|
||||
&& ["cash", "bank", "checking", "savings", "money market"]
|
||||
.iter()
|
||||
.any(|w| n.contains(w))
|
||||
})
|
||||
.map(|a| a.id)
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
//! Charts, painted directly: a balance line (step-shaped, because a
|
||||
//! balance holds until the next transaction), grouped bars, ranked
|
||||
//! horizontal bars, and sparklines. Every point answers hover with its
|
||||
//! exact value; bars report clicks so they can drill down.
|
||||
|
||||
use crate::fmt;
|
||||
use crate::theme::{self, pal};
|
||||
use chrono::NaiveDate;
|
||||
use egui::{
|
||||
Align2, Color32, CornerRadius, Painter, Pos2, Rect, Sense, Shape, Stroke, Ui, pos2, vec2,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
fn f(d: Decimal) -> f64 {
|
||||
d.try_into().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// "Nice" axis ticks covering [lo, hi].
|
||||
fn ticks(lo: f64, hi: f64, n: usize) -> Vec<f64> {
|
||||
let span = (hi - lo).max(1e-9);
|
||||
let raw = span / n as f64;
|
||||
let mag = 10f64.powf(raw.log10().floor());
|
||||
let step = [1.0, 2.0, 2.5, 5.0, 10.0]
|
||||
.iter()
|
||||
.map(|m| m * mag)
|
||||
.find(|s| span / s <= n as f64)
|
||||
.unwrap_or(10.0 * mag);
|
||||
let start = (lo / step).floor() * step;
|
||||
let mut v = vec![start];
|
||||
// Up to the first tick at or above the top, so the data never pokes
|
||||
// past the last gridline.
|
||||
while *v.last().expect("non-empty") < hi - step * 1e-9 {
|
||||
let next = v.last().expect("non-empty") + step;
|
||||
v.push(next);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn money_label(v: f64) -> String {
|
||||
fmt::compact(Decimal::from_f64_retain(v).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// A balance over time as a step line with a soft fill. `points` are
|
||||
/// (date, balance at end of day), ascending; the line runs to `until`.
|
||||
pub fn balance_line(ui: &mut Ui, points: &[(NaiveDate, Decimal)], until: NaiveDate, height: f32) {
|
||||
let p = pal(ui.ctx());
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), height), Sense::hover());
|
||||
let painter = ui.painter_at(rect);
|
||||
if points.is_empty() {
|
||||
painter.text(
|
||||
rect.center(),
|
||||
Align2::CENTER_CENTER,
|
||||
"No cash activity yet",
|
||||
theme::regular(12.5),
|
||||
p.faint,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let first = points[0].0;
|
||||
let last = until.max(points[points.len() - 1].0);
|
||||
let days = ((last - first).num_days().max(1)) as f64;
|
||||
let vals: Vec<f64> = points.iter().map(|(_, v)| f(*v)).collect();
|
||||
let lo = vals.iter().cloned().fold(0.0f64, f64::min);
|
||||
let hi = vals.iter().cloned().fold(f64::MIN, f64::max).max(lo + 1.0);
|
||||
let ts = ticks(lo, hi, 4);
|
||||
let (lo, hi) = (ts[0].min(lo), ts[ts.len() - 1].max(hi));
|
||||
let plot = Rect::from_min_max(rect.min + vec2(52.0, 6.0), rect.max - vec2(8.0, 22.0));
|
||||
let x =
|
||||
|d: NaiveDate| plot.left() + ((d - first).num_days() as f64 / days) as f32 * plot.width();
|
||||
let y = |v: f64| plot.bottom() - ((v - lo) / (hi - lo)) as f32 * plot.height();
|
||||
for t in &ts {
|
||||
let yy = y(*t);
|
||||
painter.line_segment(
|
||||
[pos2(plot.left(), yy), pos2(plot.right(), yy)],
|
||||
Stroke::new(1.0, p.border),
|
||||
);
|
||||
painter.text(
|
||||
pos2(plot.left() - 8.0, yy),
|
||||
Align2::RIGHT_CENTER,
|
||||
money_label(*t),
|
||||
theme::regular(11.0),
|
||||
p.faint,
|
||||
);
|
||||
}
|
||||
// X labels: first, last, and a few between.
|
||||
let n_labels = 4;
|
||||
for i in 0..=n_labels {
|
||||
let d = first + chrono::Days::new(((days * i as f64) / n_labels as f64).round() as u64);
|
||||
let align = match i {
|
||||
0 => Align2::LEFT_TOP,
|
||||
i if i == n_labels => Align2::RIGHT_TOP,
|
||||
_ => Align2::CENTER_TOP,
|
||||
};
|
||||
painter.text(
|
||||
pos2(x(d), plot.bottom() + 6.0),
|
||||
align,
|
||||
fmt::date_short(d),
|
||||
theme::regular(11.0),
|
||||
p.faint,
|
||||
);
|
||||
}
|
||||
// Step path.
|
||||
let mut line: Vec<Pos2> = Vec::new();
|
||||
for (i, (d, v)) in points.iter().enumerate() {
|
||||
let px = x(*d);
|
||||
if i > 0 {
|
||||
line.push(pos2(px, line.last().unwrap().y));
|
||||
}
|
||||
line.push(pos2(px, y(f(*v))));
|
||||
}
|
||||
line.push(pos2(x(last), line.last().unwrap().y));
|
||||
let base = y(0.0f64.clamp(lo, hi));
|
||||
let mut fill = line.clone();
|
||||
fill.push(pos2(x(last), base));
|
||||
fill.push(pos2(x(first), base));
|
||||
let soft = Color32::from_rgba_unmultiplied(p.accent.r(), p.accent.g(), p.accent.b(), 28);
|
||||
// Fill: one mesh of strips under each flat step (the outline may be
|
||||
// non-convex). A mesh has no anti-aliased edges, so strips meet without
|
||||
// seams.
|
||||
let mut mesh = egui::Mesh::default();
|
||||
for w in line.windows(2) {
|
||||
if (w[0].y - w[1].y).abs() < 0.01 && w[1].x > w[0].x {
|
||||
mesh.add_colored_rect(
|
||||
Rect::from_min_max(
|
||||
pos2(w[0].x, w[0].y.min(base)),
|
||||
pos2(w[1].x, w[0].y.max(base)),
|
||||
),
|
||||
soft,
|
||||
);
|
||||
}
|
||||
}
|
||||
painter.add(Shape::mesh(mesh));
|
||||
let _ = fill;
|
||||
painter.add(Shape::line(line.clone(), Stroke::new(2.0, p.accent)));
|
||||
// Hover: the balance on the hovered day.
|
||||
if let Some(hp) = resp.hover_pos()
|
||||
&& plot.x_range().contains(hp.x)
|
||||
{
|
||||
let frac = ((hp.x - plot.left()) / plot.width()) as f64;
|
||||
let day = first + chrono::Days::new((frac * days).round() as u64);
|
||||
let bal = points
|
||||
.iter()
|
||||
.take_while(|(d, _)| *d <= day)
|
||||
.last()
|
||||
.map(|(_, v)| *v);
|
||||
if let Some(b) = bal {
|
||||
let hx = x(day);
|
||||
painter.line_segment(
|
||||
[pos2(hx, plot.top()), pos2(hx, plot.bottom())],
|
||||
Stroke::new(1.0, p.faint),
|
||||
);
|
||||
painter.circle_filled(pos2(hx, y(f(b))), 4.0, p.accent);
|
||||
tooltip(
|
||||
&painter,
|
||||
pos2(hx, plot.top()),
|
||||
&fmt::date(day),
|
||||
&fmt::dollars(b),
|
||||
p.surface,
|
||||
p.border,
|
||||
p.text,
|
||||
p.muted,
|
||||
rect,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn tooltip(
|
||||
painter: &Painter,
|
||||
at: Pos2,
|
||||
title: &str,
|
||||
value: &str,
|
||||
bg: Color32,
|
||||
border: Color32,
|
||||
fg: Color32,
|
||||
muted: Color32,
|
||||
bounds: Rect,
|
||||
) {
|
||||
let t1 = painter.layout_no_wrap(title.to_string(), theme::regular(11.5), muted);
|
||||
let t2 = painter.layout_no_wrap(value.to_string(), theme::semibold(13.0), fg);
|
||||
let w = t1.size().x.max(t2.size().x) + 16.0;
|
||||
let h = t1.size().y + t2.size().y + 12.0;
|
||||
let mut r = Rect::from_min_size(pos2(at.x + 8.0, at.y), vec2(w, h));
|
||||
if r.right() > bounds.right() {
|
||||
r = r.translate(vec2(-w - 16.0, 0.0));
|
||||
}
|
||||
painter.rect(
|
||||
r,
|
||||
CornerRadius::same(6),
|
||||
bg,
|
||||
Stroke::new(1.0, border),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
painter.galley(r.min + vec2(8.0, 6.0), t1.clone(), muted);
|
||||
painter.galley(r.min + vec2(8.0, 6.0 + t1.size().y), t2, fg);
|
||||
}
|
||||
|
||||
/// One group of bars on a [`bar_chart`].
|
||||
pub struct BarGroup {
|
||||
pub label: String,
|
||||
pub values: Vec<Decimal>,
|
||||
}
|
||||
|
||||
/// Grouped bars (e.g. income and spending per month), with a legend.
|
||||
/// Returns the clicked group, if any.
|
||||
pub fn bar_chart(
|
||||
ui: &mut Ui,
|
||||
groups: &[BarGroup],
|
||||
series: &[(&str, Color32)],
|
||||
height: f32,
|
||||
) -> Option<usize> {
|
||||
let p = pal(ui.ctx());
|
||||
ui.horizontal(|ui| {
|
||||
for (name, color) in series {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover());
|
||||
ui.painter().rect_filled(r, CornerRadius::same(3), *color);
|
||||
ui.label(
|
||||
egui::RichText::new(*name)
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
});
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), height), Sense::click());
|
||||
let painter = ui.painter_at(rect);
|
||||
if groups.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let all: Vec<f64> = groups
|
||||
.iter()
|
||||
.flat_map(|g| g.values.iter().map(|v| f(*v)))
|
||||
.collect();
|
||||
let lo = all.iter().cloned().fold(0.0f64, f64::min);
|
||||
let hi = all.iter().cloned().fold(0.0f64, f64::max).max(lo + 1.0);
|
||||
let ts = ticks(lo, hi, 4);
|
||||
let (lo, hi) = (ts[0].min(lo), ts[ts.len() - 1].max(hi));
|
||||
let plot = Rect::from_min_max(rect.min + vec2(52.0, 6.0), rect.max - vec2(4.0, 22.0));
|
||||
let y = |v: f64| plot.bottom() - ((v - lo) / (hi - lo)) as f32 * plot.height();
|
||||
for t in &ts {
|
||||
let yy = y(*t);
|
||||
painter.line_segment(
|
||||
[pos2(plot.left(), yy), pos2(plot.right(), yy)],
|
||||
Stroke::new(1.0, p.border),
|
||||
);
|
||||
painter.text(
|
||||
pos2(plot.left() - 8.0, yy),
|
||||
Align2::RIGHT_CENTER,
|
||||
money_label(*t),
|
||||
theme::regular(11.0),
|
||||
p.faint,
|
||||
);
|
||||
}
|
||||
let gw = plot.width() / groups.len() as f32;
|
||||
let n = series.len().max(1) as f32;
|
||||
let bw = ((gw * 0.7) / n).min(28.0);
|
||||
let mut clicked = None;
|
||||
let hover = resp.hover_pos();
|
||||
for (gi, g) in groups.iter().enumerate() {
|
||||
let gx = plot.left() + gw * gi as f32;
|
||||
let grect = Rect::from_min_max(pos2(gx, plot.top()), pos2(gx + gw, plot.bottom()));
|
||||
let hovered = hover.is_some_and(|h| grect.contains(h));
|
||||
if hovered {
|
||||
painter.rect_filled(grect, CornerRadius::same(6), p.hover);
|
||||
}
|
||||
let total_w = bw * n + 3.0 * (n - 1.0);
|
||||
let start = gx + (gw - total_w) / 2.0;
|
||||
for (si, v) in g.values.iter().enumerate() {
|
||||
let color = series.get(si).map(|s| s.1).unwrap_or(p.accent);
|
||||
let x0 = start + si as f32 * (bw + 3.0);
|
||||
let (y0, y1) = (y(0.0), y(f(*v)));
|
||||
let r = Rect::from_min_max(
|
||||
pos2(x0, y0.min(y1)),
|
||||
pos2(x0 + bw, y0.max(y1).max(y0.min(y1) + 1.0)),
|
||||
);
|
||||
painter.rect_filled(
|
||||
r,
|
||||
CornerRadius {
|
||||
nw: 3,
|
||||
ne: 3,
|
||||
sw: 0,
|
||||
se: 0,
|
||||
},
|
||||
color,
|
||||
);
|
||||
}
|
||||
painter.text(
|
||||
pos2(gx + gw / 2.0, plot.bottom() + 6.0),
|
||||
Align2::CENTER_TOP,
|
||||
&g.label,
|
||||
theme::regular(11.0),
|
||||
p.faint,
|
||||
);
|
||||
if hovered {
|
||||
let lines: Vec<String> = series
|
||||
.iter()
|
||||
.zip(&g.values)
|
||||
.map(|((name, _), v)| format!("{name}: {}", fmt::dollars(*v)))
|
||||
.collect();
|
||||
tooltip(
|
||||
&painter,
|
||||
pos2(gx + gw / 2.0, plot.top()),
|
||||
&g.label,
|
||||
&lines.join("\n"),
|
||||
p.surface,
|
||||
p.border,
|
||||
p.text,
|
||||
p.muted,
|
||||
rect,
|
||||
);
|
||||
if resp.clicked() {
|
||||
clicked = Some(gi);
|
||||
}
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
}
|
||||
}
|
||||
clicked
|
||||
}
|
||||
|
||||
/// Ranked horizontal bars with labels and exact amounts. Returns the
|
||||
/// clicked row.
|
||||
pub fn hbars(ui: &mut Ui, rows: &[(String, Decimal)], color: Color32) -> Option<usize> {
|
||||
let p = pal(ui.ctx());
|
||||
let max = rows
|
||||
.iter()
|
||||
.map(|(_, v)| f(*v).abs())
|
||||
.fold(0.0, f64::max)
|
||||
.max(1e-9);
|
||||
let mut clicked = None;
|
||||
for (i, (label, v)) in rows.iter().enumerate() {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click());
|
||||
let painter = ui.painter_at(rect);
|
||||
if resp.hovered() {
|
||||
painter.rect_filled(rect, CornerRadius::same(6), p.hover);
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
}
|
||||
let amount = fmt::dollars(*v);
|
||||
let ag = painter.layout_no_wrap(amount, theme::regular(13.0), p.text);
|
||||
let label_w = (rect.width() * 0.38).min(220.0);
|
||||
let lg = painter.layout(label.clone(), theme::regular(13.0), p.text, label_w);
|
||||
painter.galley(
|
||||
pos2(rect.left() + 6.0, rect.center().y - lg.size().y / 2.0),
|
||||
lg,
|
||||
p.text,
|
||||
);
|
||||
let bar_left = rect.left() + label_w + 14.0;
|
||||
let bar_max = rect.right() - ag.size().x - 18.0 - bar_left;
|
||||
let w = (f(*v).abs() / max) as f32 * bar_max.max(0.0);
|
||||
let bar = Rect::from_min_size(
|
||||
pos2(bar_left, rect.center().y - 6.0),
|
||||
vec2(w.max(2.0), 12.0),
|
||||
);
|
||||
painter.rect_filled(bar, CornerRadius::same(3), color);
|
||||
painter.galley(
|
||||
pos2(
|
||||
rect.right() - ag.size().x - 6.0,
|
||||
rect.center().y - ag.size().y / 2.0,
|
||||
),
|
||||
ag,
|
||||
p.text,
|
||||
);
|
||||
if resp.clicked() {
|
||||
clicked = Some(i);
|
||||
}
|
||||
}
|
||||
clicked
|
||||
}
|
||||
|
||||
/// A tiny trend line.
|
||||
pub fn sparkline(painter: &Painter, rect: Rect, values: &[f64], color: Color32) {
|
||||
let lo = values.iter().cloned().fold(f64::MAX, f64::min);
|
||||
let hi = values.iter().cloned().fold(f64::MIN, f64::max);
|
||||
let span = (hi - lo).max(1e-9);
|
||||
let n = values.len().max(2) - 1;
|
||||
let pts: Vec<Pos2> = values
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| {
|
||||
pos2(
|
||||
rect.left() + rect.width() * i as f32 / n as f32,
|
||||
rect.bottom() - ((v - lo) / span) as f32 * rect.height(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
painter.add(Shape::line(pts, Stroke::new(1.5, color)));
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//! The live loader. A background thread owns the read-only [`Snapshot`],
|
||||
//! watches the data directory, and on every change applies the new journal
|
||||
//! lines and sends a fresh [`Books`] to the UI, then asks for a repaint.
|
||||
//! A change made by the agent in another window shows up within a frame or
|
||||
//! two. Filesystem events are the trigger; a slow poll backs them up in case
|
||||
//! an event is missed (network drives, odd editors).
|
||||
|
||||
use crate::books::Books;
|
||||
use chrono::NaiveDate;
|
||||
use ledger_config::BooksSettings;
|
||||
use ledger_store::Snapshot;
|
||||
use notify::{RecursiveMode, Watcher};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
/// Where the books come from.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Source {
|
||||
/// The `books-data` directory (journal and blobs).
|
||||
pub data_dir: PathBuf,
|
||||
/// The company's `wanderbooks.toml`, if any.
|
||||
pub books_toml: Option<PathBuf>,
|
||||
/// Fix "today" (tests, screenshots); otherwise the local date.
|
||||
pub today: Option<NaiveDate>,
|
||||
}
|
||||
|
||||
pub enum Update {
|
||||
Books(Arc<Books>),
|
||||
Error(String),
|
||||
}
|
||||
|
||||
pub struct Loader {
|
||||
rx: Receiver<Update>,
|
||||
}
|
||||
|
||||
impl Loader {
|
||||
pub fn spawn(source: Source, ctx: egui::Context) -> Loader {
|
||||
let (tx, rx) = channel();
|
||||
std::thread::Builder::new()
|
||||
.name("books-loader".into())
|
||||
.spawn(move || run(source, ctx, tx))
|
||||
.expect("spawn loader");
|
||||
Loader { rx }
|
||||
}
|
||||
|
||||
/// The newest update, if any arrived since the last call.
|
||||
pub fn poll(&self) -> Option<Update> {
|
||||
let mut last = None;
|
||||
while let Ok(u) = self.rx.try_recv() {
|
||||
last = Some(u);
|
||||
}
|
||||
last
|
||||
}
|
||||
|
||||
/// Block until the first update (tests, headless).
|
||||
pub fn wait(&self, timeout: Duration) -> Option<Update> {
|
||||
self.rx.recv_timeout(timeout).ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// Load once, synchronously (tests and one-shot tools).
|
||||
pub fn load_once(source: &Source) -> Result<Books, String> {
|
||||
let snap = Snapshot::load(&source.data_dir).map_err(|e| e.to_string())?;
|
||||
Books::build(&snap, settings(source)?, today(source), None)
|
||||
}
|
||||
|
||||
fn today(source: &Source) -> NaiveDate {
|
||||
source
|
||||
.today
|
||||
.unwrap_or_else(|| chrono::Local::now().date_naive())
|
||||
}
|
||||
|
||||
fn settings(source: &Source) -> Result<BooksSettings, String> {
|
||||
match &source.books_toml {
|
||||
Some(p) if p.exists() => ledger_config::load(p),
|
||||
_ => Ok(BooksSettings::default()),
|
||||
}
|
||||
}
|
||||
|
||||
fn mtime(p: &Option<PathBuf>) -> Option<SystemTime> {
|
||||
p.as_ref()
|
||||
.and_then(|p| std::fs::metadata(p).ok())
|
||||
.and_then(|m| m.modified().ok())
|
||||
}
|
||||
|
||||
fn run(source: Source, ctx: egui::Context, tx: Sender<Update>) {
|
||||
let send = |u: Update| {
|
||||
let _ = tx.send(u);
|
||||
ctx.request_repaint();
|
||||
};
|
||||
let mut snap = loop {
|
||||
match Snapshot::load(&source.data_dir) {
|
||||
Ok(s) => break s,
|
||||
Err(e) => {
|
||||
send(Update::Error(format!(
|
||||
"Can't read the books in {}: {e}",
|
||||
source.data_dir.display()
|
||||
)));
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
}
|
||||
}
|
||||
};
|
||||
let build = |snap: &Snapshot, prev: Option<u64>| -> Update {
|
||||
match settings(&source).and_then(|s| Books::build(snap, s, today(&source), prev)) {
|
||||
Ok(b) => Update::Books(Arc::new(b)),
|
||||
Err(e) => Update::Error(e),
|
||||
}
|
||||
};
|
||||
send(build(&snap, None));
|
||||
|
||||
let (ev_tx, ev_rx) = channel();
|
||||
let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
|
||||
if res.is_ok() {
|
||||
let _ = ev_tx.send(());
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
if let Some(w) = watcher.as_mut() {
|
||||
let _ = w.watch(&source.data_dir, RecursiveMode::NonRecursive);
|
||||
if let Some(t) = &source.books_toml
|
||||
&& let Some(dir) = t.parent()
|
||||
{
|
||||
let _ = w.watch(dir, RecursiveMode::NonRecursive);
|
||||
}
|
||||
}
|
||||
let mut toml_mtime = mtime(&source.books_toml);
|
||||
let mut day = today(&source);
|
||||
loop {
|
||||
match ev_rx.recv_timeout(Duration::from_secs(3)) {
|
||||
Ok(()) => {
|
||||
// Let a burst of writes settle, then take them all at once.
|
||||
std::thread::sleep(Duration::from_millis(30));
|
||||
while ev_rx.try_recv().is_ok() {}
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout) => {}
|
||||
Err(RecvTimeoutError::Disconnected) => {
|
||||
std::thread::sleep(Duration::from_secs(3));
|
||||
}
|
||||
}
|
||||
let before = snap.journal_seq();
|
||||
let grew = match snap.refresh() {
|
||||
Ok(n) => n > 0,
|
||||
Err(e) => {
|
||||
send(Update::Error(format!("Reading the journal failed: {e}")));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let toml_now = mtime(&source.books_toml);
|
||||
let new_day = today(&source);
|
||||
if grew || toml_now != toml_mtime || new_day != day {
|
||||
toml_mtime = toml_now;
|
||||
day = new_day;
|
||||
send(build(&snap, grew.then_some(before)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,796 @@
|
||||
//! A demo company, built through the real engine: fifteen months of a
|
||||
//! 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`.
|
||||
|
||||
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 rust_decimal::Decimal;
|
||||
use std::path::Path;
|
||||
|
||||
/// The demo's `wanderbooks.toml`.
|
||||
pub const BOOKS_TOML: &str = r#"[company]
|
||||
name = "Juniper Studio LLC"
|
||||
entity_type = "LLC, single-member"
|
||||
tax_classification = "disregarded entity" # Schedule C
|
||||
fiscal_year_start = "01-01"
|
||||
formation_state = "OR"
|
||||
formed = "2025-06-02"
|
||||
ein = "00-0000000"
|
||||
address = "1200 NW Everett St, Portland, OR 97209"
|
||||
owner = "Alex Rivera"
|
||||
|
||||
[books]
|
||||
accounting_basis = "accrual"
|
||||
receipt_threshold = "25.00"
|
||||
close_into = "3000"
|
||||
close_sweep = ["3100"]
|
||||
cash_accounts = ["1000", "1010"]
|
||||
"#;
|
||||
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 = self
|
||||
.0
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
self.0 >> 33
|
||||
}
|
||||
/// Uniform in [lo, hi] dollars, to the cent.
|
||||
fn amount(&mut self, lo: u64, hi: u64) -> Decimal {
|
||||
let cents = lo * 100 + self.next() % ((hi - lo) * 100 + 1);
|
||||
Decimal::new(cents as i64, 2)
|
||||
}
|
||||
fn pick(&mut self, n: u64) -> u64 {
|
||||
self.next() % n
|
||||
}
|
||||
}
|
||||
|
||||
fn usd(d: Decimal) -> Money {
|
||||
Money::new(d, Currency::USD)
|
||||
}
|
||||
|
||||
fn dec(n: i64) -> Decimal {
|
||||
Decimal::from(n)
|
||||
}
|
||||
|
||||
fn day(month_start: NaiveDate, d: u32) -> NaiveDate {
|
||||
let last = (month_start + Months::new(1) - Days::new(1)).day();
|
||||
NaiveDate::from_ymd_opt(month_start.year(), month_start.month(), d.min(last)).expect("valid")
|
||||
}
|
||||
|
||||
/// A one-page PDF receipt with the given lines (Helvetica).
|
||||
pub fn receipt_pdf(title: &str, lines: &[String]) -> Vec<u8> {
|
||||
let esc = |s: &str| {
|
||||
s.replace('\\', "\\\\")
|
||||
.replace('(', "\\(")
|
||||
.replace(')', "\\)")
|
||||
};
|
||||
let mut content = String::new();
|
||||
content.push_str("BT /F2 22 Tf 56 760 Td (");
|
||||
content.push_str(&esc(title));
|
||||
content.push_str(") Tj ET\n");
|
||||
content.push_str("0.85 0.85 0.85 RG 56 742 m 556 742 l S\n");
|
||||
let mut y = 712;
|
||||
for l in lines {
|
||||
let (font, size) = if l.starts_with("TOTAL") {
|
||||
("F2", 13)
|
||||
} else {
|
||||
("F1", 11)
|
||||
};
|
||||
content.push_str(&format!(
|
||||
"BT /{font} {size} Tf 56 {y} Td ({}) Tj ET\n",
|
||||
esc(l)
|
||||
));
|
||||
y -= 20;
|
||||
}
|
||||
let objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>".to_string(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>".to_string(),
|
||||
format!("<< /Length {} >>\nstream\n{content}endstream", content.len()),
|
||||
];
|
||||
let mut pdf = b"%PDF-1.4\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objects.iter().enumerate() {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = pdf.len();
|
||||
pdf.extend_from_slice(
|
||||
format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes(),
|
||||
);
|
||||
for off in offsets {
|
||||
pdf.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
pdf.extend_from_slice(
|
||||
format!(
|
||||
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n",
|
||||
objects.len() + 1
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
pdf
|
||||
}
|
||||
|
||||
fn receipt(vendor: &str, date: NaiveDate, item: &str, amount: Decimal, paid: &str) -> Vec<u8> {
|
||||
receipt_pdf(
|
||||
vendor,
|
||||
&[
|
||||
format!("Receipt {}", date.format("%B %-d, %Y")),
|
||||
format!(
|
||||
"Order {:08}",
|
||||
(date.num_days_from_ce() as u64 * 7919
|
||||
+ (amount * dec(100)).try_into().unwrap_or(0u64))
|
||||
% 100_000_000
|
||||
),
|
||||
String::new(),
|
||||
item.to_string(),
|
||||
format!("Amount: ${amount}"),
|
||||
String::new(),
|
||||
format!("TOTAL ${amount}"),
|
||||
format!("Paid with {paid}"),
|
||||
String::new(),
|
||||
"Thank you for your business.".into(),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// Build the demo books in `dir` (which must not already hold books): the
|
||||
/// journal and blobs under `dir/books-data`, the settings in
|
||||
/// `dir/wanderbooks.toml`.
|
||||
pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
std::fs::write(dir.join("wanderbooks.toml"), BOOKS_TOML)?;
|
||||
let mut s = Store::create(&dir.join("books-data"), Currency::USD)?;
|
||||
let mut rng = Rng(0x5eed_1234);
|
||||
let start =
|
||||
NaiveDate::from_ymd_opt(today.year(), today.month(), 1).expect("valid") - Months::new(15);
|
||||
let this_month = NaiveDate::from_ymd_opt(today.year(), today.month(), 1).expect("valid");
|
||||
|
||||
let acct =
|
||||
|s: &mut Store, code: &str, name: &str, t, tax: Option<&str>| -> Result<u64, StoreError> {
|
||||
let id = s.add_account(code, name, t, None, false)?;
|
||||
if let Some(c) = tax {
|
||||
s.set_account_tax(id, Some(c), None)?;
|
||||
}
|
||||
Ok(id)
|
||||
};
|
||||
use AccountType::*;
|
||||
let checking = acct(&mut s, "1000", "Operating Checking", Asset, None)?;
|
||||
let savings = acct(&mut s, "1010", "Savings", Asset, None)?;
|
||||
let prepaid = acct(&mut s, "1300", "Prepaid Insurance", Asset, None)?;
|
||||
let equipment = acct(&mut s, "1500", "Computer Equipment", Asset, None)?;
|
||||
let accum = acct(&mut s, "1510", "Accumulated Depreciation", Asset, None)?;
|
||||
let card = acct(&mut s, "2100", "Business Credit Card", Liability, None)?;
|
||||
let capital = acct(&mut s, "3000", "Owner's Capital", Equity, None)?;
|
||||
let draws = acct(&mut s, "3100", "Owner's Draws", Equity, None)?;
|
||||
let _services = acct(
|
||||
&mut s,
|
||||
"4000",
|
||||
"Design Services",
|
||||
Revenue,
|
||||
Some("gross_receipts"),
|
||||
)?;
|
||||
let templates = acct(
|
||||
&mut s,
|
||||
"4100",
|
||||
"Template Sales",
|
||||
Revenue,
|
||||
Some("gross_receipts"),
|
||||
)?;
|
||||
let fees = acct(
|
||||
&mut s,
|
||||
"6000",
|
||||
"Platform Fees",
|
||||
Expense,
|
||||
Some("commissions_fees"),
|
||||
)?;
|
||||
let contract = acct(
|
||||
&mut s,
|
||||
"6050",
|
||||
"Contract Labor",
|
||||
Expense,
|
||||
Some("contract_labor"),
|
||||
)?;
|
||||
let software = acct(
|
||||
&mut s,
|
||||
"6100",
|
||||
"Software & Subscriptions",
|
||||
Expense,
|
||||
Some("office_expense"),
|
||||
)?;
|
||||
let rent = acct(
|
||||
&mut s,
|
||||
"6200",
|
||||
"Studio Rent",
|
||||
Expense,
|
||||
Some("rent_property"),
|
||||
)?;
|
||||
let insurance = acct(&mut s, "6300", "Insurance", Expense, Some("insurance"))?;
|
||||
let advertising = acct(&mut s, "6400", "Advertising", Expense, Some("advertising"))?;
|
||||
let meals = acct(&mut s, "6500", "Meals", Expense, Some("meals"))?;
|
||||
let travel = acct(&mut s, "6600", "Travel", Expense, Some("travel"))?;
|
||||
let supplies = acct(&mut s, "6700", "Office Supplies", Expense, Some("supplies"))?;
|
||||
let depreciation = acct(
|
||||
&mut s,
|
||||
"6800",
|
||||
"Depreciation",
|
||||
Expense,
|
||||
Some("depreciation"),
|
||||
)?;
|
||||
let bank_fees = acct(&mut s, "6900", "Bank Fees", Expense, None)?;
|
||||
let client_class = s.add_class("Client work")?;
|
||||
let template_class = s.add_class("Templates")?;
|
||||
|
||||
let sam = s.add_vendor("Sam Okafor")?;
|
||||
s.set_vendor_1099(sam, Some(Form1099::Nec), true)?;
|
||||
let riley = s.add_vendor("Riley Chen")?;
|
||||
s.set_vendor_1099(riley, Some(Form1099::Nec), false)?;
|
||||
let landlord = s.add_vendor("Everett Lofts LLC")?;
|
||||
s.set_vendor_1099(landlord, Some(Form1099::Misc), true)?;
|
||||
let customers: Vec<u64> = [
|
||||
"Fern & Field Coffee",
|
||||
"Bramble Outdoor Co.",
|
||||
"Northwind Clinics",
|
||||
"Tidepool Records",
|
||||
"Larkspur Bakery",
|
||||
]
|
||||
.iter()
|
||||
.map(|n| s.add_customer(n))
|
||||
.collect::<Result<_, _>>()?;
|
||||
|
||||
// Formation documents.
|
||||
s.file_document(
|
||||
"formation",
|
||||
"Articles of Organization.pdf",
|
||||
"application/pdf",
|
||||
&receipt_pdf(
|
||||
"Articles of Organization",
|
||||
&[
|
||||
"Juniper Studio LLC".into(),
|
||||
"Oregon Secretary of State".into(),
|
||||
format!("Filed {}", start.format("%B %-d, %Y")),
|
||||
],
|
||||
),
|
||||
Some("Filed with the state"),
|
||||
)?;
|
||||
s.file_document(
|
||||
"tax",
|
||||
"IRS EIN confirmation.pdf",
|
||||
"application/pdf",
|
||||
&receipt_pdf(
|
||||
"EIN Assignment",
|
||||
&[
|
||||
"Juniper Studio LLC".into(),
|
||||
"Employer Identification Number assigned".into(),
|
||||
],
|
||||
),
|
||||
Some("CP 575"),
|
||||
)?;
|
||||
|
||||
// Helper: post a two-line entry with an optional receipt and vendor.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn post(
|
||||
s: &mut Store,
|
||||
date: NaiveDate,
|
||||
desc: &str,
|
||||
dr: u64,
|
||||
cr: u64,
|
||||
amount: Decimal,
|
||||
receipt: Option<(&str, Vec<u8>)>,
|
||||
vendor: Option<u64>,
|
||||
class: Option<u32>,
|
||||
) -> Result<u64, StoreError> {
|
||||
let e = s.create_entry(date, desc)?;
|
||||
s.add_line(e, dr, Side::Debit, usd(amount))?;
|
||||
s.add_line(e, cr, Side::Credit, usd(amount))?;
|
||||
if let Some(c) = class {
|
||||
s.set_line_class(e, 0, Some(c))?;
|
||||
s.set_line_class(e, 1, Some(c))?;
|
||||
}
|
||||
if let Some((name, bytes)) = receipt {
|
||||
s.attach_receipt(Some(e), name, "application/pdf", &bytes, None)?;
|
||||
}
|
||||
s.post_entry(e)?;
|
||||
if vendor.is_some() {
|
||||
s.set_entry_vendor(e, vendor)?;
|
||||
}
|
||||
Ok(e)
|
||||
}
|
||||
|
||||
post(
|
||||
&mut s,
|
||||
day(start, 2),
|
||||
"Owner contribution — startup capital",
|
||||
checking,
|
||||
capital,
|
||||
dec(15000),
|
||||
Some((
|
||||
"bank-transfer.pdf",
|
||||
receipt(
|
||||
"Transfer confirmation",
|
||||
day(start, 2),
|
||||
"Transfer from Alex Rivera personal checking",
|
||||
dec(15000),
|
||||
"ACH",
|
||||
),
|
||||
)),
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
|
||||
// Rent: a recurring entry, posted every month with its invoice; this
|
||||
// month's is left as a draft.
|
||||
s.register_recurring(NewRecurring {
|
||||
description: "Studio rent — Everett Lofts".into(),
|
||||
lines: vec![
|
||||
(rent, Side::Debit, usd(dec(1800))),
|
||||
(checking, Side::Credit, usd(dec(1800))),
|
||||
],
|
||||
frequency: Frequency::Monthly,
|
||||
start: day(start, 1),
|
||||
end: None,
|
||||
})?;
|
||||
|
||||
// Laptop and its depreciation.
|
||||
let laptop_date = day(start + Months::new(1), 9);
|
||||
let laptop = dec(3199);
|
||||
post(
|
||||
&mut s,
|
||||
laptop_date,
|
||||
"MacBook Pro 16\" — studio workstation",
|
||||
equipment,
|
||||
card,
|
||||
laptop,
|
||||
Some((
|
||||
"apple-store.pdf",
|
||||
receipt(
|
||||
"Apple Store",
|
||||
laptop_date,
|
||||
"MacBook Pro 16-inch, M-series",
|
||||
laptop,
|
||||
"Visa ending 4242",
|
||||
),
|
||||
)),
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
let asset = s.register_asset(NewAsset {
|
||||
description: "MacBook Pro 16\"".into(),
|
||||
purchase_date: laptop_date,
|
||||
cost: usd(laptop),
|
||||
salvage_value: usd(dec(400)),
|
||||
useful_life_months: 36,
|
||||
cost_account: equipment,
|
||||
accum_depr_account: accum,
|
||||
expense_account: depreciation,
|
||||
})?;
|
||||
|
||||
// Prepaid insurance, spread over a year.
|
||||
let ins_date = day(start + Months::new(2), 15);
|
||||
post(
|
||||
&mut s,
|
||||
ins_date,
|
||||
"General liability insurance — annual premium",
|
||||
prepaid,
|
||||
checking,
|
||||
dec(1440),
|
||||
Some((
|
||||
"hiscox-policy.pdf",
|
||||
receipt(
|
||||
"Hiscox",
|
||||
ins_date,
|
||||
"General liability, 12 months",
|
||||
dec(1440),
|
||||
"ACH",
|
||||
),
|
||||
)),
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
s.register_schedule(NewSchedule {
|
||||
description: "General liability insurance".into(),
|
||||
kind: ScheduleKind::PrepaidExpense,
|
||||
total: usd(dec(1440)),
|
||||
start: ins_date,
|
||||
months: 12,
|
||||
balance_account: prepaid,
|
||||
pl_account: insurance,
|
||||
})?;
|
||||
|
||||
let mut open_invoices: Vec<(u64, NaiveDate, Decimal)> = Vec::new();
|
||||
let mut card_balance = Decimal::ZERO;
|
||||
let mut m = start;
|
||||
let mut month_index = 0;
|
||||
while m <= this_month {
|
||||
let current = m == this_month;
|
||||
let in_range = |d: NaiveDate| d <= today;
|
||||
// Software on the card.
|
||||
for (vendor, item, lo, hi, d) in [
|
||||
("Figma", "Figma Professional — monthly", 45, 45, 3),
|
||||
("Adobe", "Creative Cloud All Apps", 59, 60, 7),
|
||||
("Google Workspace", "Business Starter — 2 users", 28, 28, 11),
|
||||
] {
|
||||
let date = day(m, d);
|
||||
if !in_range(date) {
|
||||
continue;
|
||||
}
|
||||
let amt = rng.amount(lo, hi);
|
||||
post(
|
||||
&mut s,
|
||||
date,
|
||||
&format!("{vendor} — {item}"),
|
||||
software,
|
||||
card,
|
||||
amt,
|
||||
Some((
|
||||
&format!("{}-{}.pdf", vendor.to_lowercase().replace(' ', "-"), date),
|
||||
receipt(vendor, date, item, amt, "Visa ending 4242"),
|
||||
)),
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
card_balance += amt;
|
||||
}
|
||||
// Advertising some months.
|
||||
if month_index % 3 == 1 {
|
||||
let date = day(m, 14);
|
||||
if in_range(date) {
|
||||
let amt = rng.amount(150, 420);
|
||||
post(
|
||||
&mut s,
|
||||
date,
|
||||
"Instagram ads — portfolio campaign",
|
||||
advertising,
|
||||
card,
|
||||
amt,
|
||||
Some((
|
||||
"meta-ads.pdf",
|
||||
receipt("Meta Ads", date, "Sponsored posts", amt, "Visa ending 4242"),
|
||||
)),
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
card_balance += amt;
|
||||
}
|
||||
}
|
||||
// Meals and supplies.
|
||||
let date = day(m, 17);
|
||||
if in_range(date) {
|
||||
let amt = rng.amount(28, 96);
|
||||
let missing = current || month_index == 13;
|
||||
post(
|
||||
&mut s,
|
||||
date,
|
||||
"Client lunch — project kickoff",
|
||||
meals,
|
||||
card,
|
||||
amt,
|
||||
(!missing).then(|| {
|
||||
(
|
||||
"lunch.pdf",
|
||||
receipt(
|
||||
"Tasty n Alder",
|
||||
date,
|
||||
"Lunch for 2",
|
||||
amt,
|
||||
"Visa ending 4242",
|
||||
),
|
||||
)
|
||||
}),
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
card_balance += amt;
|
||||
}
|
||||
if month_index % 4 == 2 {
|
||||
let date = day(m, 21);
|
||||
if in_range(date) {
|
||||
let amt = rng.amount(40, 180);
|
||||
post(
|
||||
&mut s,
|
||||
date,
|
||||
"Paper, ink, and sketchbooks",
|
||||
supplies,
|
||||
card,
|
||||
amt,
|
||||
Some((
|
||||
"blick.pdf",
|
||||
receipt(
|
||||
"Blick Art Materials",
|
||||
date,
|
||||
"Supplies",
|
||||
amt,
|
||||
"Visa ending 4242",
|
||||
),
|
||||
)),
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
card_balance += amt;
|
||||
}
|
||||
}
|
||||
if month_index == 7 {
|
||||
let date = day(m, 9);
|
||||
if in_range(date) {
|
||||
let amt = dec(1186) + rng.amount(0, 90);
|
||||
post(
|
||||
&mut s,
|
||||
date,
|
||||
"Flights and hotel — client workshop, Austin",
|
||||
travel,
|
||||
card,
|
||||
amt,
|
||||
Some((
|
||||
"alaska-air.pdf",
|
||||
receipt(
|
||||
"Alaska Airlines",
|
||||
date,
|
||||
"PDX-AUS round trip + 2 nights",
|
||||
amt,
|
||||
"Visa ending 4242",
|
||||
),
|
||||
)),
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
card_balance += amt;
|
||||
}
|
||||
}
|
||||
// Pay the card in full on the 25th.
|
||||
let date = day(m, 25);
|
||||
if in_range(date) && card_balance > Decimal::ZERO {
|
||||
post(
|
||||
&mut s,
|
||||
date,
|
||||
"Credit card payment",
|
||||
card,
|
||||
checking,
|
||||
card_balance,
|
||||
Some((
|
||||
"card-statement.pdf",
|
||||
receipt(
|
||||
"Chase Ink",
|
||||
date,
|
||||
"Statement balance payment",
|
||||
card_balance,
|
||||
"ACH from Operating Checking",
|
||||
),
|
||||
)),
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
card_balance = Decimal::ZERO;
|
||||
}
|
||||
// Client invoices: two or three a month, net 30.
|
||||
for k in 0..(2 + rng.pick(2)) {
|
||||
let date = day(m, 5 + 9 * k as u32);
|
||||
if !in_range(date) {
|
||||
continue;
|
||||
}
|
||||
let cust = customers[rng.pick(customers.len() as u64) as usize];
|
||||
let amt = (rng.amount(1800, 7800) / dec(50)).round() * dec(50);
|
||||
let inv = s.create_invoice(
|
||||
cust,
|
||||
date,
|
||||
date + Days::new(30),
|
||||
vec![DocumentLine {
|
||||
description: [
|
||||
"Brand identity",
|
||||
"Website design",
|
||||
"Packaging",
|
||||
"Illustration set",
|
||||
"Design retainer",
|
||||
][rng.pick(5) as usize]
|
||||
.into(),
|
||||
amount: amt,
|
||||
}],
|
||||
)?;
|
||||
let entry = s.ledger().get_invoice(inv).map(|i| i.entry_id);
|
||||
if let Some(e) = entry {
|
||||
let _ = e;
|
||||
}
|
||||
open_invoices.push((inv, date, amt));
|
||||
}
|
||||
// Collect invoices 20-50 days out; leave the latest ones open and one overdue.
|
||||
let mut still_open = Vec::new();
|
||||
for (inv, date, amt) in open_invoices.drain(..) {
|
||||
let paid_on = (date + Days::new(20 + rng.pick(30))).max(day(m, 2));
|
||||
let overdue_one = month_index == 12 && inv % 2 == 0;
|
||||
if paid_on <= today && paid_on < m + Months::new(1) && !overdue_one {
|
||||
s.receive_payment(inv, paid_on, amt, checking)?;
|
||||
} else {
|
||||
still_open.push((inv, date, amt));
|
||||
}
|
||||
}
|
||||
open_invoices = still_open;
|
||||
// Template sales, paid out by the platform on the 1st for last month.
|
||||
if month_index > 1 {
|
||||
let date = day(m, 1);
|
||||
if in_range(date) {
|
||||
let gross = rng.amount(450, 1600);
|
||||
let fee = (gross * Decimal::new(10, 2)).round_dp(2) + Decimal::new(30, 2);
|
||||
let net = gross - fee;
|
||||
let e = s.create_entry(date, "Gumroad payout — template sales")?;
|
||||
s.add_line(e, checking, Side::Debit, usd(net))?;
|
||||
s.add_line(e, fees, Side::Debit, usd(fee))?;
|
||||
s.add_line(e, templates, Side::Credit, usd(gross))?;
|
||||
for i in 0..3 {
|
||||
s.set_line_class(e, i, Some(template_class))?;
|
||||
}
|
||||
s.attach_receipt(
|
||||
Some(e),
|
||||
"gumroad-payout.pdf",
|
||||
"application/pdf",
|
||||
&receipt_pdf(
|
||||
"Gumroad payout",
|
||||
&[
|
||||
format!("Payout {}", date.format("%B %-d, %Y")),
|
||||
format!("Gross sales: ${gross}"),
|
||||
format!("Fees: ${fee}"),
|
||||
format!("TOTAL ${net}"),
|
||||
"Deposited to checking ending 0187".into(),
|
||||
],
|
||||
),
|
||||
None,
|
||||
)?;
|
||||
s.post_entry(e)?;
|
||||
}
|
||||
}
|
||||
// Contractors.
|
||||
if month_index % 2 == 0 && month_index > 0 {
|
||||
let date = day(m, 28);
|
||||
if in_range(date) {
|
||||
let amt = (rng.amount(900, 2200) / dec(25)).round() * dec(25);
|
||||
post(
|
||||
&mut s,
|
||||
date,
|
||||
"Sam Okafor — illustration (invoice)",
|
||||
contract,
|
||||
checking,
|
||||
amt,
|
||||
Some((
|
||||
"sam-okafor-invoice.pdf",
|
||||
receipt(
|
||||
"Sam Okafor Illustration",
|
||||
date,
|
||||
"Illustration for client projects",
|
||||
amt,
|
||||
"ACH",
|
||||
),
|
||||
)),
|
||||
Some(sam),
|
||||
Some(client_class),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
if month_index == 11 || month_index == 13 {
|
||||
let date = day(m, 19);
|
||||
if in_range(date) {
|
||||
post(
|
||||
&mut s,
|
||||
date,
|
||||
"Riley Chen — front-end development",
|
||||
contract,
|
||||
checking,
|
||||
dec(1400),
|
||||
Some((
|
||||
"riley-chen.pdf",
|
||||
receipt(
|
||||
"Riley Chen",
|
||||
date,
|
||||
"Website build, milestone",
|
||||
dec(1400),
|
||||
"ACH",
|
||||
),
|
||||
)),
|
||||
Some(riley),
|
||||
Some(client_class),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
// Owner draws and savings.
|
||||
if month_index >= 3 {
|
||||
let date = day(m, 28);
|
||||
if in_range(date) {
|
||||
post(
|
||||
&mut s,
|
||||
date,
|
||||
"Owner draw",
|
||||
draws,
|
||||
checking,
|
||||
dec(3500),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
if month_index % 3 == 2 {
|
||||
let date = day(m, 26);
|
||||
if in_range(date) {
|
||||
post(
|
||||
&mut s,
|
||||
date,
|
||||
"Transfer to savings",
|
||||
savings,
|
||||
checking,
|
||||
dec(4000),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
let date = day(m, 30);
|
||||
if in_range(date) && month_index % 5 == 4 {
|
||||
post(
|
||||
&mut s,
|
||||
date,
|
||||
"Wire fee",
|
||||
bank_fees,
|
||||
checking,
|
||||
dec(30),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
// Month-end: rent occurrences, depreciation, amortization.
|
||||
let month_end = m + Months::new(1) - Days::new(1);
|
||||
let through = if current { today } else { month_end };
|
||||
for (_, date, entry) in s.run_recurring(through)? {
|
||||
if current {
|
||||
continue; // this month's rent waits as a draft
|
||||
}
|
||||
s.attach_receipt(
|
||||
Some(entry),
|
||||
&format!("rent-{date}.pdf"),
|
||||
"application/pdf",
|
||||
&receipt(
|
||||
"Everett Lofts LLC",
|
||||
date,
|
||||
"Studio 4B rent",
|
||||
dec(1800),
|
||||
"ACH",
|
||||
),
|
||||
None,
|
||||
)?;
|
||||
s.post_entry(entry)?;
|
||||
s.set_entry_vendor(entry, Some(landlord))?;
|
||||
}
|
||||
if !current {
|
||||
if m >= laptop_date.with_day(1).expect("valid") {
|
||||
s.run_depreciation(asset, &m.format("%Y-%m").to_string())?;
|
||||
}
|
||||
s.run_schedules(month_end)?;
|
||||
}
|
||||
// Close each finished calendar year into capital, draws swept in.
|
||||
if m.month() == 12 && !current {
|
||||
let ys = NaiveDate::from_ymd_opt(m.year(), 1, 1).expect("valid");
|
||||
let p = s.create_period(&m.year().to_string(), ys.max(start), month_end)?;
|
||||
s.close_period_to(
|
||||
p,
|
||||
&CloseTo::Account {
|
||||
into: capital,
|
||||
sweep: vec![draws],
|
||||
},
|
||||
)?;
|
||||
}
|
||||
m = m + Months::new(1);
|
||||
month_index += 1;
|
||||
}
|
||||
let _ = (bank_fees, client_class);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//! Formatting money, dates, and counts. Amounts are exact decimals in and
|
||||
//! exact strings out: nothing here rounds except `compact`, which says so.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
/// Thousands separators, two decimals, a true minus sign: `−1,234.56`.
|
||||
pub fn money(d: Decimal) -> String {
|
||||
let neg = d.is_sign_negative() && !d.is_zero();
|
||||
let s = group(d.abs().round_dp(2));
|
||||
if neg { format!("\u{2212}{s}") } else { s }
|
||||
}
|
||||
|
||||
/// Accounting style: negatives in parentheses, `(1,234.56)`.
|
||||
pub fn accounting(d: Decimal) -> String {
|
||||
if d.is_sign_negative() && !d.is_zero() {
|
||||
format!("({})", group(d.abs().round_dp(2)))
|
||||
} else {
|
||||
group(d.round_dp(2))
|
||||
}
|
||||
}
|
||||
|
||||
/// With the currency sign: `$1,234.56`, `−$12.00`.
|
||||
pub fn dollars(d: Decimal) -> String {
|
||||
let neg = d.is_sign_negative() && !d.is_zero();
|
||||
let s = format!("${}", group(d.abs().round_dp(2)));
|
||||
if neg { format!("\u{2212}{s}") } else { s }
|
||||
}
|
||||
|
||||
/// Rounded for chart axes and tight tiles: `$1.2k`, `$3.4M`, `$950`.
|
||||
pub fn compact(d: Decimal) -> String {
|
||||
let f: f64 = d.try_into().unwrap_or(0.0);
|
||||
let (neg, a) = (f < 0.0, f.abs());
|
||||
let s = if a >= 1_000_000.0 {
|
||||
format!("${:.1}M", a / 1_000_000.0)
|
||||
} else if a >= 10_000.0 {
|
||||
format!("${:.0}k", a / 1_000.0)
|
||||
} else if a >= 1_000.0 {
|
||||
format!("${:.1}k", a / 1_000.0)
|
||||
} else {
|
||||
format!("${a:.0}")
|
||||
};
|
||||
if neg { format!("\u{2212}{s}") } else { s }
|
||||
}
|
||||
|
||||
fn group(d: Decimal) -> String {
|
||||
let s = format!("{:.2}", d);
|
||||
let (int, frac) = s.split_once('.').unwrap_or((&s, "00"));
|
||||
let mut out = String::new();
|
||||
for (i, c) in int.chars().enumerate() {
|
||||
if i > 0 && (int.len() - i) % 3 == 0 {
|
||||
out.push(',');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
format!("{out}.{frac}")
|
||||
}
|
||||
|
||||
/// A percent change from `prev` to `now`, if `prev` isn't zero.
|
||||
pub fn pct_change(prev: Decimal, now: Decimal) -> Option<f64> {
|
||||
if prev.is_zero() {
|
||||
return None;
|
||||
}
|
||||
let p: f64 = prev.try_into().ok()?;
|
||||
let n: f64 = now.try_into().ok()?;
|
||||
Some((n - p) / p.abs() * 100.0)
|
||||
}
|
||||
|
||||
/// `Oct 12, 2026`.
|
||||
pub fn date(d: NaiveDate) -> String {
|
||||
d.format("%b %-d, %Y").to_string()
|
||||
}
|
||||
|
||||
/// `Oct 12`.
|
||||
pub fn date_short(d: NaiveDate) -> String {
|
||||
d.format("%b %-d").to_string()
|
||||
}
|
||||
|
||||
/// `Oct 2026`.
|
||||
pub fn month(d: NaiveDate) -> String {
|
||||
d.format("%b %Y").to_string()
|
||||
}
|
||||
|
||||
/// `1 receipt`, `3 receipts`.
|
||||
pub fn count(n: usize, one: &str, many: &str) -> String {
|
||||
format!("{n} {}", if n == 1 { one } else { many })
|
||||
}
|
||||
|
||||
/// An RFC 3339 timestamp from the journal, shown as local-ish UTC text:
|
||||
/// `Oct 11, 2026 18:04 UTC`.
|
||||
pub fn timestamp(ts: &str) -> String {
|
||||
chrono::DateTime::parse_from_rfc3339(ts)
|
||||
.map(|t| t.format("%b %-d, %Y %H:%M UTC").to_string())
|
||||
.unwrap_or_else(|_| ts.to_string())
|
||||
}
|
||||
|
||||
/// Bytes as `12.3 KB`.
|
||||
pub fn bytes(n: u64) -> String {
|
||||
let f = n as f64;
|
||||
if f >= 1_048_576.0 {
|
||||
format!("{:.1} MB", f / 1_048_576.0)
|
||||
} else if f >= 1024.0 {
|
||||
format!("{:.1} KB", f / 1024.0)
|
||||
} else {
|
||||
format!("{n} B")
|
||||
}
|
||||
}
|
||||
|
||||
/// `snake_case_key` as `Snake case key`, for settings shown as labels,
|
||||
/// keeping acronyms upper case (`ein` → `EIN`).
|
||||
pub fn label(key: &str) -> String {
|
||||
const ACRONYMS: [&str; 9] = [
|
||||
"ein", "duns", "irs", "cpa", "llc", "tin", "ssn", "id", "url",
|
||||
];
|
||||
let words: Vec<String> = key
|
||||
.split(['_', ' '])
|
||||
.filter(|w| !w.is_empty())
|
||||
.enumerate()
|
||||
.map(|(i, w)| {
|
||||
if ACRONYMS.contains(&w.to_lowercase().as_str()) {
|
||||
w.to_uppercase()
|
||||
} else if i == 0 {
|
||||
let mut c = w.chars();
|
||||
c.next()
|
||||
.map(|f| f.to_uppercase().collect::<String>() + c.as_str())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
w.to_string()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
words.join(" ")
|
||||
}
|
||||
|
||||
/// A `CamelCase` enum name as words: `PrepaidExpense` → `Prepaid expense`.
|
||||
pub fn words(debug: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for (i, c) in debug.chars().enumerate() {
|
||||
if c.is_uppercase() && i > 0 {
|
||||
out.push(' ');
|
||||
out.extend(c.to_lowercase());
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The tax return a form enum stands for.
|
||||
pub fn tax_form(f: ledger_core::TaxForm) -> &'static str {
|
||||
match f {
|
||||
ledger_core::TaxForm::ScheduleC => "Schedule C (Form 1040)",
|
||||
ledger_core::TaxForm::Form1065 => "Form 1065 (partnership)",
|
||||
ledger_core::TaxForm::Form1120S => "Form 1120-S (S corporation)",
|
||||
ledger_core::TaxForm::Form1120 => "Form 1120 (C corporation)",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::str::FromStr;
|
||||
|
||||
fn dec(s: &str) -> Decimal {
|
||||
Decimal::from_str(s).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn money_formats() {
|
||||
assert_eq!(money(dec("1234567.5")), "1,234,567.50");
|
||||
assert_eq!(money(dec("-12")), "\u{2212}12.00");
|
||||
assert_eq!(money(dec("0")), "0.00");
|
||||
assert_eq!(money(dec("-0.00")), "0.00");
|
||||
assert_eq!(accounting(dec("-207.68")), "(207.68)");
|
||||
assert_eq!(dollars(dec("999.999")), "$1,000.00");
|
||||
assert_eq!(compact(dec("1234")), "$1.2k");
|
||||
assert_eq!(compact(dec("-25000")), "\u{2212}$25k");
|
||||
assert_eq!(compact(dec("950.4")), "$950");
|
||||
assert_eq!(label("state_entity_number"), "State entity number");
|
||||
assert_eq!(label("ein"), "EIN");
|
||||
assert_eq!(label("irs_name_control"), "IRS name control");
|
||||
assert_eq!(words("PrepaidExpense"), "Prepaid expense");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! `ledger-gui`: a read-only, live view of wanderbooks books for the people
|
||||
//! who own them and the accountant who checks them. See `plan/gui.md`.
|
||||
|
||||
#![allow(missing_docs)]
|
||||
|
||||
pub mod app;
|
||||
mod attention;
|
||||
pub mod books;
|
||||
mod charts;
|
||||
pub mod data;
|
||||
pub mod demo;
|
||||
mod fmt;
|
||||
pub mod nav;
|
||||
mod pages;
|
||||
mod palette;
|
||||
mod receipts;
|
||||
mod theme;
|
||||
mod widgets;
|
||||
|
||||
pub use app::App;
|
||||
pub use data::Source;
|
||||
@@ -0,0 +1,72 @@
|
||||
//! `ledger-gui [--data-dir PATH] [--books-toml PATH] [--today YYYY-MM-DD] [--demo]`
|
||||
//!
|
||||
//! Opens the books read-only and follows them live. With no flags it looks
|
||||
//! for `books-data/` and `wanderbooks.toml` in the current directory.
|
||||
//! `--demo` builds a demo company in a temp directory and opens that.
|
||||
|
||||
use ledger_gui::{App, Source};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() -> eframe::Result<()> {
|
||||
let mut data_dir: Option<PathBuf> = None;
|
||||
let mut books_toml: Option<PathBuf> = None;
|
||||
let mut today = None;
|
||||
let mut demo = false;
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--data-dir" => data_dir = args.next().map(PathBuf::from),
|
||||
"--books-toml" => books_toml = args.next().map(PathBuf::from),
|
||||
"--today" => today = args.next().and_then(|d| d.parse().ok()),
|
||||
"--demo" => demo = true,
|
||||
"-h" | "--help" => {
|
||||
println!(
|
||||
"usage: ledger-gui [--data-dir PATH] [--books-toml PATH] [--today YYYY-MM-DD] [--demo]"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
other => {
|
||||
eprintln!("unknown argument {other}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
if demo {
|
||||
let d = today.unwrap_or_else(|| chrono::Local::now().date_naive());
|
||||
let dir = std::env::temp_dir().join(format!("wanderbooks-demo-{d}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
if let Err(e) = ledger_gui::demo::build(&dir, d) {
|
||||
eprintln!("building the demo books failed: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
eprintln!("demo books: {}", dir.display());
|
||||
data_dir = Some(dir.join("books-data"));
|
||||
books_toml = Some(dir.join("wanderbooks.toml"));
|
||||
}
|
||||
let data_dir = data_dir.unwrap_or_else(|| PathBuf::from("books-data"));
|
||||
let books_toml = books_toml.or_else(|| {
|
||||
let guess = data_dir
|
||||
.parent()
|
||||
.unwrap_or(&data_dir)
|
||||
.join("wanderbooks.toml");
|
||||
guess.exists().then_some(guess)
|
||||
});
|
||||
let source = Source {
|
||||
data_dir,
|
||||
books_toml,
|
||||
today,
|
||||
};
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("wanderbooks")
|
||||
.with_app_id("wanderbooks")
|
||||
.with_inner_size([1440.0, 920.0])
|
||||
.with_min_inner_size([900.0, 600.0]),
|
||||
..Default::default()
|
||||
};
|
||||
eframe::run_native(
|
||||
"wanderbooks",
|
||||
options,
|
||||
Box::new(move |cc| Ok(Box::new(App::new(cc, source)))),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
//! Where you are and how you got there: a browser-style history.
|
||||
|
||||
use ledger_core::{AccountId, EntryId};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum ReportKind {
|
||||
ProfitAndLoss,
|
||||
BalanceSheet,
|
||||
CashFlow,
|
||||
TrialBalance,
|
||||
GeneralLedger,
|
||||
MembersEquity,
|
||||
TaxSummary,
|
||||
ArAging,
|
||||
ApAging,
|
||||
Vendor1099,
|
||||
}
|
||||
|
||||
impl ReportKind {
|
||||
pub const ALL: [ReportKind; 10] = [
|
||||
ReportKind::ProfitAndLoss,
|
||||
ReportKind::BalanceSheet,
|
||||
ReportKind::CashFlow,
|
||||
ReportKind::MembersEquity,
|
||||
ReportKind::TrialBalance,
|
||||
ReportKind::GeneralLedger,
|
||||
ReportKind::TaxSummary,
|
||||
ReportKind::Vendor1099,
|
||||
ReportKind::ArAging,
|
||||
ReportKind::ApAging,
|
||||
];
|
||||
|
||||
pub fn title(self) -> &'static str {
|
||||
match self {
|
||||
ReportKind::ProfitAndLoss => "Profit and loss",
|
||||
ReportKind::BalanceSheet => "Balance sheet",
|
||||
ReportKind::CashFlow => "Cash flow",
|
||||
ReportKind::TrialBalance => "Trial balance",
|
||||
ReportKind::GeneralLedger => "General ledger",
|
||||
ReportKind::MembersEquity => "Owners' equity",
|
||||
ReportKind::TaxSummary => "Tax summary",
|
||||
ReportKind::ArAging => "Receivables aging",
|
||||
ReportKind::ApAging => "Payables aging",
|
||||
ReportKind::Vendor1099 => "1099 worksheet",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Route {
|
||||
Overview,
|
||||
Cash,
|
||||
Profit,
|
||||
Transactions,
|
||||
Entry(EntryId),
|
||||
Reports(ReportKind),
|
||||
Accounts,
|
||||
Account(AccountId),
|
||||
Documents,
|
||||
Document(u64),
|
||||
Company,
|
||||
Journal,
|
||||
JournalEvent(u64),
|
||||
}
|
||||
|
||||
impl Route {
|
||||
/// The sidebar section this route belongs to.
|
||||
pub fn section(&self) -> Section {
|
||||
match self {
|
||||
Route::Overview => Section::Overview,
|
||||
Route::Cash => Section::Cash,
|
||||
Route::Profit => Section::Profit,
|
||||
Route::Transactions | Route::Entry(_) => Section::Transactions,
|
||||
Route::Reports(_) => Section::Reports,
|
||||
Route::Accounts | Route::Account(_) => Section::Accounts,
|
||||
Route::Documents | Route::Document(_) => Section::Documents,
|
||||
Route::Company => Section::Company,
|
||||
Route::Journal | Route::JournalEvent(_) => Section::Journal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Section {
|
||||
Overview,
|
||||
Cash,
|
||||
Profit,
|
||||
Transactions,
|
||||
Reports,
|
||||
Accounts,
|
||||
Documents,
|
||||
Company,
|
||||
Journal,
|
||||
}
|
||||
|
||||
impl Section {
|
||||
pub const ALL: [Section; 9] = [
|
||||
Section::Overview,
|
||||
Section::Cash,
|
||||
Section::Profit,
|
||||
Section::Transactions,
|
||||
Section::Reports,
|
||||
Section::Accounts,
|
||||
Section::Documents,
|
||||
Section::Company,
|
||||
Section::Journal,
|
||||
];
|
||||
|
||||
pub fn title(self) -> &'static str {
|
||||
match self {
|
||||
Section::Overview => "Overview",
|
||||
Section::Cash => "Cash",
|
||||
Section::Profit => "Profit",
|
||||
Section::Transactions => "Transactions",
|
||||
Section::Reports => "Reports",
|
||||
Section::Accounts => "Accounts",
|
||||
Section::Documents => "Documents",
|
||||
Section::Company => "Company",
|
||||
Section::Journal => "Journal",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon(self) -> &'static str {
|
||||
use egui_phosphor::regular as i;
|
||||
match self {
|
||||
Section::Overview => i::SQUARES_FOUR,
|
||||
Section::Cash => i::BANK,
|
||||
Section::Profit => i::TREND_UP,
|
||||
Section::Transactions => i::LIST_BULLETS,
|
||||
Section::Reports => i::FILE_TEXT,
|
||||
Section::Accounts => i::TREE_STRUCTURE,
|
||||
Section::Documents => i::FILES,
|
||||
Section::Company => i::BUILDINGS,
|
||||
Section::Journal => i::SCROLL,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn home(self) -> Route {
|
||||
match self {
|
||||
Section::Overview => Route::Overview,
|
||||
Section::Cash => Route::Cash,
|
||||
Section::Profit => Route::Profit,
|
||||
Section::Transactions => Route::Transactions,
|
||||
Section::Reports => Route::Reports(ReportKind::ProfitAndLoss),
|
||||
Section::Accounts => Route::Accounts,
|
||||
Section::Documents => Route::Documents,
|
||||
Section::Company => Route::Company,
|
||||
Section::Journal => Route::Journal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Back/forward history.
|
||||
pub struct Nav {
|
||||
back: Vec<Route>,
|
||||
forward: Vec<Route>,
|
||||
pub current: Route,
|
||||
}
|
||||
|
||||
impl Nav {
|
||||
pub fn new(start: Route) -> Self {
|
||||
Self {
|
||||
back: Vec::new(),
|
||||
forward: Vec::new(),
|
||||
current: start,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn go(&mut self, to: Route) {
|
||||
if to != self.current {
|
||||
self.back.push(std::mem::replace(&mut self.current, to));
|
||||
self.forward.clear();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn can_back(&self) -> bool {
|
||||
!self.back.is_empty()
|
||||
}
|
||||
|
||||
pub fn can_forward(&self) -> bool {
|
||||
!self.forward.is_empty()
|
||||
}
|
||||
|
||||
pub fn back(&mut self) {
|
||||
if let Some(r) = self.back.pop() {
|
||||
self.forward.push(std::mem::replace(&mut self.current, r));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn forward(&mut self) {
|
||||
if let Some(r) = self.forward.pop() {
|
||||
self.back.push(std::mem::replace(&mut self.current, r));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
//! The chart of accounts with balances, and one account's register: every
|
||||
//! posting with a running balance and the balance over time.
|
||||
|
||||
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::{CornerRadius, RichText, Sense, Ui, vec2};
|
||||
use egui_phosphor::regular as icon;
|
||||
use ledger_core::AccountType;
|
||||
use ledger_core::analysis::balance_history;
|
||||
use ledger_core::reports::general_ledger;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
/// Limit the register to these dates (set by drill-downs).
|
||||
pub range: Option<(NaiveDate, NaiveDate)>,
|
||||
pub show_empty: bool,
|
||||
}
|
||||
|
||||
pub fn show_chart(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
page(ui, "accounts", |ui| {
|
||||
widgets::page_header(
|
||||
ui,
|
||||
"Accounts",
|
||||
"Every account in the chart with today's balance. Click one for its register.",
|
||||
);
|
||||
ui.checkbox(
|
||||
&mut shell.state.accounts.show_empty,
|
||||
"Show accounts with no activity",
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
let used: std::collections::HashSet<u64> = b
|
||||
.entries
|
||||
.iter()
|
||||
.flat_map(|e| e.accounts.iter().copied())
|
||||
.collect();
|
||||
for (t, title) in [
|
||||
(AccountType::Asset, "Assets"),
|
||||
(AccountType::Liability, "Liabilities"),
|
||||
(AccountType::Equity, "Equity"),
|
||||
(AccountType::Revenue, "Income"),
|
||||
(AccountType::Expense, "Expenses"),
|
||||
] {
|
||||
let accounts: Vec<_> = b
|
||||
.ledger
|
||||
.chart()
|
||||
.all()
|
||||
.filter(|a| {
|
||||
a.account_type == t && (shell.state.accounts.show_empty || used.contains(&a.id))
|
||||
})
|
||||
.collect();
|
||||
if accounts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut go = None;
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
title,
|
||||
|_| {},
|
||||
|ui| {
|
||||
for a in &accounts {
|
||||
let bal = b.balance_on(a.id, b.today);
|
||||
let (r, resp) = ui
|
||||
.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(6), p.hover);
|
||||
}
|
||||
let painter = ui.painter_at(r);
|
||||
painter.text(
|
||||
r.left_center() + vec2(8.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
&a.code,
|
||||
theme::regular(13.0),
|
||||
p.muted,
|
||||
);
|
||||
painter.text(
|
||||
r.left_center() + vec2(64.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
&a.name,
|
||||
theme::regular(13.5),
|
||||
p.text,
|
||||
);
|
||||
let mut tags = Vec::new();
|
||||
if b.cash_accounts.contains(&a.id) {
|
||||
tags.push("cash".to_string());
|
||||
}
|
||||
if let Some(c) = &a.tax_category {
|
||||
let pct = a
|
||||
.deductible_percent
|
||||
.map(|p| format!(" {}%", p.normalize()))
|
||||
.unwrap_or_default();
|
||||
tags.push(format!("tax: {}{pct}", c.replace('_', " ")));
|
||||
} else if matches!(t, AccountType::Revenue | AccountType::Expense) {
|
||||
tags.push("no tax category".into());
|
||||
}
|
||||
if a.placeholder {
|
||||
tags.push("header".into());
|
||||
}
|
||||
painter.text(
|
||||
r.left_center() + vec2(340.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
tags.join(" · "),
|
||||
theme::regular(12.0),
|
||||
p.faint,
|
||||
);
|
||||
painter.text(
|
||||
r.right_center() - vec2(8.0, 0.0),
|
||||
egui::Align2::RIGHT_CENTER,
|
||||
fmt::accounting(bal),
|
||||
theme::semibold(13.5),
|
||||
p.text,
|
||||
);
|
||||
if resp.clicked() {
|
||||
go = Some(a.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
if let Some(id) = go {
|
||||
shell.state.accounts.range = None;
|
||||
shell.go(Route::Account(id));
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn show_account(ui: &mut Ui, b: &Books, id: u64, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
let Some(a) = b.ledger.chart().get(id) else {
|
||||
return;
|
||||
};
|
||||
let first = b.kpis.first_entry.unwrap_or(b.today);
|
||||
let (s, e) = shell
|
||||
.state
|
||||
.accounts
|
||||
.range
|
||||
.unwrap_or((first.min(b.today), b.today));
|
||||
page(ui, "account", |ui| {
|
||||
ui.label(
|
||||
RichText::new(format!("{:?} account · {}", a.account_type, a.code))
|
||||
.font(theme::regular(13.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
ui.label(RichText::new(&a.name).font(theme::bold(26.0)));
|
||||
ui.add_space(4.0);
|
||||
let bal = b.balance_on(id, b.today);
|
||||
ui.label(
|
||||
RichText::new(format!("Balance today {}", fmt::dollars(bal)))
|
||||
.font(theme::regular(15.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
ui.add_space(14.0);
|
||||
let hist = balance_history(&b.ledger, &[id]);
|
||||
if hist.len() > 1 && a.account_type.is_balance_sheet() {
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Balance over time",
|
||||
|_| {},
|
||||
|ui| crate::charts::balance_line(ui, &hist, b.today, 180.0),
|
||||
);
|
||||
ui.add_space(12.0);
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"{} {} – {}",
|
||||
icon::CALENDAR_BLANK,
|
||||
fmt::date(s),
|
||||
fmt::date(e)
|
||||
))
|
||||
.font(theme::semibold(13.5)),
|
||||
);
|
||||
if shell.state.accounts.range.is_some() && ui.button("Show all dates").clicked() {
|
||||
shell.state.accounts.range = None;
|
||||
}
|
||||
if ui
|
||||
.button(format!("{} Find in transactions", icon::LIST_BULLETS))
|
||||
.clicked()
|
||||
{
|
||||
shell.state.transactions.account = Some(id);
|
||||
shell.go(Route::Transactions);
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
let Ok(gl) = general_ledger(&b.ledger, s, e) else {
|
||||
return;
|
||||
};
|
||||
let Some(acct) = gl.accounts.iter().find(|x| x.code == a.code) else {
|
||||
widgets::card(ui, |ui| {
|
||||
widgets::empty(
|
||||
ui,
|
||||
icon::EMPTY,
|
||||
"No activity",
|
||||
"Nothing posted to this account in this range.",
|
||||
)
|
||||
});
|
||||
return;
|
||||
};
|
||||
let mut go = None;
|
||||
widgets::card(ui, |ui| {
|
||||
let w = ui.available_width();
|
||||
let amt = 120.0;
|
||||
let header = |ui: &mut Ui, vals: [&str; 5]| {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(w, 26.0), Sense::hover());
|
||||
ui.painter().text(
|
||||
r.left_center() + vec2(4.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
vals[0],
|
||||
theme::semibold(12.0),
|
||||
p.muted,
|
||||
);
|
||||
ui.painter().text(
|
||||
r.left_center() + vec2(90.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
vals[1],
|
||||
theme::semibold(12.0),
|
||||
p.muted,
|
||||
);
|
||||
for (i, v) in vals[2..].iter().enumerate() {
|
||||
ui.painter().text(
|
||||
egui::pos2(r.right() - amt * (2 - i) as f32 - 4.0, r.center().y),
|
||||
egui::Align2::RIGHT_CENTER,
|
||||
*v,
|
||||
theme::semibold(12.0),
|
||||
p.muted,
|
||||
);
|
||||
}
|
||||
};
|
||||
header(ui, ["Date", "Description", "Debit", "Credit", "Balance"]);
|
||||
widgets::rule(ui);
|
||||
let line = |ui: &mut Ui,
|
||||
date: String,
|
||||
desc: String,
|
||||
sub: String,
|
||||
d: Decimal,
|
||||
c: Decimal,
|
||||
bal: Decimal,
|
||||
strong: bool|
|
||||
-> egui::Response {
|
||||
let (r, resp) = ui.allocate_exact_size(
|
||||
vec2(w, if sub.is_empty() { 30.0 } else { 42.0 }),
|
||||
Sense::click(),
|
||||
);
|
||||
if resp.hovered() && !strong {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(6), p.hover);
|
||||
}
|
||||
let painter = ui.painter_at(r);
|
||||
let font = if strong {
|
||||
theme::semibold(13.0)
|
||||
} else {
|
||||
theme::regular(13.0)
|
||||
};
|
||||
let y = if sub.is_empty() {
|
||||
r.center().y
|
||||
} else {
|
||||
r.center().y - 7.0
|
||||
};
|
||||
painter.text(
|
||||
egui::pos2(r.left() + 4.0, y),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
date,
|
||||
theme::regular(12.5),
|
||||
p.muted,
|
||||
);
|
||||
painter.text(
|
||||
egui::pos2(r.left() + 90.0, y),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
desc,
|
||||
font.clone(),
|
||||
p.text,
|
||||
);
|
||||
if !sub.is_empty() {
|
||||
painter.text(
|
||||
egui::pos2(r.left() + 90.0, r.center().y + 9.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
sub,
|
||||
theme::regular(11.5),
|
||||
p.faint,
|
||||
);
|
||||
}
|
||||
for (i, v) in [d, c].iter().enumerate() {
|
||||
if !v.is_zero() {
|
||||
painter.text(
|
||||
egui::pos2(r.right() - amt * (2 - i) as f32 - 4.0, y),
|
||||
egui::Align2::RIGHT_CENTER,
|
||||
fmt::money(*v),
|
||||
font.clone(),
|
||||
p.text,
|
||||
);
|
||||
}
|
||||
}
|
||||
painter.text(
|
||||
egui::pos2(r.right() - 4.0, y),
|
||||
egui::Align2::RIGHT_CENTER,
|
||||
fmt::accounting(bal),
|
||||
font,
|
||||
p.text,
|
||||
);
|
||||
resp
|
||||
};
|
||||
line(
|
||||
ui,
|
||||
fmt::date_short(s),
|
||||
"Beginning balance".into(),
|
||||
String::new(),
|
||||
Decimal::ZERO,
|
||||
Decimal::ZERO,
|
||||
acct.beginning_balance,
|
||||
true,
|
||||
);
|
||||
for post in acct.postings.iter().rev() {
|
||||
let r = line(
|
||||
ui,
|
||||
fmt::date_short(post.date),
|
||||
post.description.clone(),
|
||||
format!("#{} · {}", post.entry_id, post.split),
|
||||
post.debit,
|
||||
post.credit,
|
||||
post.balance,
|
||||
false,
|
||||
);
|
||||
if r.clicked() {
|
||||
go = Some(post.entry_id);
|
||||
}
|
||||
}
|
||||
widgets::rule(ui);
|
||||
line(
|
||||
ui,
|
||||
fmt::date_short(e),
|
||||
"Ending balance".into(),
|
||||
String::new(),
|
||||
acct.total_debits,
|
||||
acct.total_credits,
|
||||
acct.ending_balance,
|
||||
true,
|
||||
);
|
||||
});
|
||||
if let Some(entry) = go {
|
||||
shell.go(Route::Entry(entry));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
//! Cash: what's in the bank, where it's been going, how long it lasts, and
|
||||
//! what's coming in and out next.
|
||||
|
||||
use super::{columns, page};
|
||||
use crate::app::Shell;
|
||||
use crate::books::Books;
|
||||
use crate::charts::{self, BarGroup};
|
||||
use crate::fmt;
|
||||
use crate::nav::Route;
|
||||
use crate::theme::{self, pal};
|
||||
use crate::widgets;
|
||||
use chrono::{Days, NaiveDate};
|
||||
use egui::{CornerRadius, RichText, Sense, Ui, vec2};
|
||||
use egui_phosphor::regular as icon;
|
||||
use ledger_core::analysis::{Interval, balance_history, flows_by_interval, interval_start};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
page(ui, "cash", |ui| {
|
||||
let k = &b.kpis;
|
||||
let since = b.today - Days::new(30);
|
||||
let flows = flows_by_interval(
|
||||
&b.ledger,
|
||||
&b.cash_accounts,
|
||||
since + Days::new(1),
|
||||
b.today,
|
||||
Interval::Year,
|
||||
);
|
||||
let (cin, cout): (Decimal, Decimal) =
|
||||
flows.iter().fold((Decimal::ZERO, Decimal::ZERO), |a, f| {
|
||||
(a.0 + f.2, a.1 + f.3)
|
||||
});
|
||||
widgets::page_header(
|
||||
ui,
|
||||
"Cash",
|
||||
&format!(
|
||||
"{} across {}. In the last 30 days {} came in and {} went out.",
|
||||
fmt::dollars(k.cash),
|
||||
fmt::count(b.cash_accounts.len(), "account", "accounts"),
|
||||
fmt::dollars(cin),
|
||||
fmt::dollars(cout)
|
||||
),
|
||||
);
|
||||
if b.cash_accounts.is_empty() {
|
||||
widgets::card(ui, |ui| {
|
||||
widgets::empty(
|
||||
ui,
|
||||
icon::BANK,
|
||||
"No cash accounts found",
|
||||
"Name an asset account \"checking\", \"savings\", or \"cash\", or set [books] cash_accounts in wanderbooks.toml.",
|
||||
)
|
||||
});
|
||||
return;
|
||||
}
|
||||
// One tile per cash and card account.
|
||||
let mut tiles: Vec<(u64, bool)> = b.cash_accounts.iter().map(|a| (*a, false)).collect();
|
||||
tiles.extend(b.card_accounts.iter().map(|a| (*a, true)));
|
||||
let n = tiles.len().clamp(1, 4);
|
||||
for row in tiles.chunks(n) {
|
||||
columns(ui, 0.0, &vec![1.0; n], |i, ui| {
|
||||
let Some((id, is_card)) = row.get(i) else {
|
||||
return;
|
||||
};
|
||||
let hist = balance_history(&b.ledger, &[*id]);
|
||||
let spark: Vec<f64> = hist
|
||||
.iter()
|
||||
.rev()
|
||||
.take(40)
|
||||
.rev()
|
||||
.map(|(_, v)| (*v).try_into().unwrap_or(0.0))
|
||||
.collect();
|
||||
let bal = b.balance_on(*id, b.today);
|
||||
let ago = b.balance_on(*id, since);
|
||||
let delta = bal - ago;
|
||||
let r = widgets::kpi(
|
||||
ui,
|
||||
widgets::Kpi {
|
||||
label: &b.account_label(*id),
|
||||
value: fmt::dollars(bal),
|
||||
sub: Some((
|
||||
if *is_card {
|
||||
"owed on the card".to_string()
|
||||
} else if delta.is_zero() {
|
||||
"no change in 30 days".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"{}{} in 30 days",
|
||||
if delta > Decimal::ZERO { "+" } else { "" },
|
||||
fmt::dollars(delta)
|
||||
)
|
||||
},
|
||||
if *is_card || delta.is_zero() {
|
||||
p.muted
|
||||
} else if delta > Decimal::ZERO {
|
||||
p.positive
|
||||
} else {
|
||||
p.negative
|
||||
},
|
||||
)),
|
||||
spark: (spark.len() > 1).then_some(&spark[..]),
|
||||
how: "",
|
||||
},
|
||||
);
|
||||
if r.interact(Sense::click()).clicked() {
|
||||
shell.state.accounts.range = None;
|
||||
shell.go(Route::Account(*id));
|
||||
}
|
||||
});
|
||||
}
|
||||
let first = k.first_entry.unwrap_or(b.today);
|
||||
let (start, end) = b.trailing_months(12);
|
||||
let start = start.max(interval_start(first, Interval::Month));
|
||||
columns(ui, 980.0, &[3.0, 2.0], |i, ui| match i {
|
||||
0 => {
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Cash balance",
|
||||
|_| {},
|
||||
|ui| charts::balance_line(ui, &b.cash_history(), b.today, 240.0),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
let months =
|
||||
flows_by_interval(&b.ledger, &b.cash_accounts, start, end, Interval::Month);
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Money in and out by month",
|
||||
|_| {},
|
||||
|ui| {
|
||||
let groups: Vec<BarGroup> = months
|
||||
.iter()
|
||||
.map(|(s, _, i, o)| BarGroup {
|
||||
label: s.format("%b").to_string(),
|
||||
values: vec![*i, *o],
|
||||
})
|
||||
.collect();
|
||||
charts::bar_chart(
|
||||
ui,
|
||||
&groups,
|
||||
&[("In", p.series[1]), ("Out", p.series[2])],
|
||||
210.0,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i {
|
||||
0 => runway(ui, b),
|
||||
_ => coming_up(ui, b, shell),
|
||||
});
|
||||
biggest_outflows(ui, b, shell);
|
||||
});
|
||||
}
|
||||
|
||||
fn runway(ui: &mut Ui, b: &Books) {
|
||||
let p = pal(ui.ctx());
|
||||
let ms = b.month_start();
|
||||
let three = ms - chrono::Months::new(3);
|
||||
let start = three.max(
|
||||
b.kpis
|
||||
.first_entry
|
||||
.map_or(three, |f| interval_start(f, Interval::Month)),
|
||||
);
|
||||
let months = flows_by_interval(
|
||||
&b.ledger,
|
||||
&b.cash_accounts,
|
||||
start,
|
||||
ms - Days::new(1),
|
||||
Interval::Month,
|
||||
);
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Burn and runway",
|
||||
|_| {},
|
||||
|ui| {
|
||||
if months.is_empty() {
|
||||
ui.label(RichText::new("Needs at least one full month of books.").color(p.muted));
|
||||
return;
|
||||
}
|
||||
let net: Decimal = months.iter().map(|(_, _, i, o)| i - o).sum::<Decimal>()
|
||||
/ Decimal::from(months.len() as u64);
|
||||
let net = net.round_dp(2);
|
||||
let big = |ui: &mut Ui, label: &str, value: String, color| {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(
|
||||
RichText::new(label)
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
ui.label(
|
||||
RichText::new(value)
|
||||
.font(theme::semibold(22.0))
|
||||
.color(color),
|
||||
);
|
||||
});
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
big(
|
||||
ui,
|
||||
"Average monthly net cash flow",
|
||||
format!(
|
||||
"{}{}",
|
||||
if net > Decimal::ZERO { "+" } else { "" },
|
||||
fmt::dollars(net)
|
||||
),
|
||||
if net >= Decimal::ZERO {
|
||||
p.positive
|
||||
} else {
|
||||
p.negative
|
||||
},
|
||||
);
|
||||
ui.add_space(32.0);
|
||||
if net < Decimal::ZERO {
|
||||
let months_left = (b.kpis.cash / -net).round_dp(1);
|
||||
let out = b.today
|
||||
+ Days::new((months_left * Decimal::from(30)).try_into().unwrap_or(0u64));
|
||||
big(ui, "Runway", format!("{months_left} months"), p.text);
|
||||
ui.add_space(32.0);
|
||||
big(ui, "Cash runs out around", fmt::month(out), p.warning);
|
||||
} else {
|
||||
big(ui, "Runway", "Cash is growing".into(), p.positive);
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"Cash in minus cash out of {}, averaged over the last {} (owner contributions and draws included: this is what the bank sees).",
|
||||
b.cash_accounts.iter().map(|a| b.account_name(*a)).collect::<Vec<_>>().join(" and "),
|
||||
fmt::count(months.len(), "full month", "full months")
|
||||
))
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.faint),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
struct Upcoming {
|
||||
date: NaiveDate,
|
||||
label: String,
|
||||
amount: Decimal,
|
||||
route: Option<Route>,
|
||||
}
|
||||
|
||||
fn coming_up(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
let horizon = b.today + Days::new(45);
|
||||
let mut items: Vec<Upcoming> = Vec::new();
|
||||
for r in b.ledger.recurring_entries() {
|
||||
let cash: Decimal = r
|
||||
.lines
|
||||
.iter()
|
||||
.filter(|(a, _, _)| b.cash_accounts.contains(a))
|
||||
.map(|(_, s, m)| {
|
||||
if *s == ledger_core::Side::Debit {
|
||||
m.amount
|
||||
} else {
|
||||
-m.amount
|
||||
}
|
||||
})
|
||||
.sum();
|
||||
for d in r
|
||||
.due(horizon)
|
||||
.into_iter()
|
||||
.filter(|d| *d > b.today - Days::new(1))
|
||||
{
|
||||
items.push(Upcoming {
|
||||
date: d,
|
||||
label: format!("{} (recurring)", r.description),
|
||||
amount: cash,
|
||||
route: Some(Route::Company),
|
||||
});
|
||||
}
|
||||
}
|
||||
for id in 1..b.ledger.next_invoice_id() {
|
||||
if let Some(inv) = b.ledger.get_invoice(id)
|
||||
&& !inv.balance().is_zero()
|
||||
{
|
||||
let who = b
|
||||
.ledger
|
||||
.get_customer(inv.customer_id)
|
||||
.map(|c| c.name.clone())
|
||||
.unwrap_or_default();
|
||||
let late = inv.due_date < b.today;
|
||||
items.push(Upcoming {
|
||||
date: inv.due_date,
|
||||
label: format!(
|
||||
"Invoice {id} from {who}{}",
|
||||
if late { " — overdue" } else { "" }
|
||||
),
|
||||
amount: inv.balance(),
|
||||
route: Some(Route::Entry(inv.entry_id)),
|
||||
});
|
||||
}
|
||||
}
|
||||
for id in 1..b.ledger.next_bill_id() {
|
||||
if let Some(bill) = b.ledger.get_bill(id)
|
||||
&& !bill.balance().is_zero()
|
||||
{
|
||||
let who = b
|
||||
.ledger
|
||||
.get_vendor(bill.vendor_id)
|
||||
.map(|v| v.name.clone())
|
||||
.unwrap_or_default();
|
||||
items.push(Upcoming {
|
||||
date: bill.due_date,
|
||||
label: format!("Bill {id} to {who}"),
|
||||
amount: -bill.balance(),
|
||||
route: Some(Route::Entry(bill.entry_id)),
|
||||
});
|
||||
}
|
||||
}
|
||||
items.sort_by_key(|i| i.date);
|
||||
let mut go = None;
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Coming up",
|
||||
|ui| {
|
||||
ui.label(
|
||||
RichText::new("open invoices, bills, recurring")
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
},
|
||||
|ui| {
|
||||
if items.is_empty() {
|
||||
widgets::empty(
|
||||
ui,
|
||||
icon::CALENDAR_CHECK,
|
||||
"Nothing scheduled",
|
||||
"No open invoices, bills, or recurring entries due soon.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
let (inflow, outflow): (Decimal, Decimal) =
|
||||
items.iter().fold((Decimal::ZERO, Decimal::ZERO), |a, i| {
|
||||
if i.amount > Decimal::ZERO {
|
||||
(a.0 + i.amount, a.1)
|
||||
} else {
|
||||
(a.0, a.1 - i.amount)
|
||||
}
|
||||
});
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"Expected in {} · expected out {}",
|
||||
fmt::dollars(inflow),
|
||||
fmt::dollars(outflow)
|
||||
))
|
||||
.font(theme::semibold(13.0)),
|
||||
);
|
||||
ui.add_space(6.0);
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("upcoming")
|
||||
.max_height(220.0)
|
||||
.show(ui, |ui| {
|
||||
for it in &items {
|
||||
let (r, resp) = ui
|
||||
.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(6), p.hover);
|
||||
}
|
||||
let late = it.date < b.today;
|
||||
ui.painter().text(
|
||||
r.left_center() + vec2(6.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
fmt::date_short(it.date),
|
||||
theme::regular(12.5),
|
||||
if late { p.negative } else { p.muted },
|
||||
);
|
||||
ui.painter().text(
|
||||
r.left_center() + vec2(70.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
&it.label,
|
||||
theme::regular(13.0),
|
||||
p.text,
|
||||
);
|
||||
let (txt, c) = if it.amount > Decimal::ZERO {
|
||||
(format!("+{}", fmt::money(it.amount)), p.positive)
|
||||
} else {
|
||||
(fmt::money(it.amount), p.text)
|
||||
};
|
||||
ui.painter().text(
|
||||
r.right_center() - vec2(6.0, 0.0),
|
||||
egui::Align2::RIGHT_CENTER,
|
||||
txt,
|
||||
theme::semibold(13.0),
|
||||
c,
|
||||
);
|
||||
if resp.clicked() {
|
||||
go = it.route.clone();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
if let Some(r) = go {
|
||||
shell.go(r);
|
||||
}
|
||||
}
|
||||
|
||||
fn biggest_outflows(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
let since = b.today - Days::new(30);
|
||||
let mut out: Vec<&crate::books::EntryInfo> = b
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| e.date > since && e.cash_effect < Decimal::ZERO)
|
||||
.collect();
|
||||
out.sort_by_key(|e| e.cash_effect);
|
||||
out.truncate(8);
|
||||
let mut go = None;
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Biggest payments, last 30 days",
|
||||
|_| {},
|
||||
|ui| {
|
||||
if out.is_empty() {
|
||||
widgets::empty(ui, icon::ARROW_UP_RIGHT, "No payments", "");
|
||||
}
|
||||
for e in &out {
|
||||
let (r, resp) =
|
||||
ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(6), p.hover);
|
||||
}
|
||||
ui.painter().text(
|
||||
r.left_center() + vec2(6.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
fmt::date_short(e.date),
|
||||
theme::regular(12.5),
|
||||
p.muted,
|
||||
);
|
||||
ui.painter().text(
|
||||
r.left_center() + vec2(70.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
&e.description,
|
||||
theme::regular(13.0),
|
||||
p.text,
|
||||
);
|
||||
ui.painter().text(
|
||||
r.right_center() - vec2(6.0, 0.0),
|
||||
egui::Align2::RIGHT_CENTER,
|
||||
fmt::money(e.cash_effect),
|
||||
theme::semibold(13.0),
|
||||
p.text,
|
||||
);
|
||||
if resp.clicked() {
|
||||
go = Some(e.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
if let Some(id) = go {
|
||||
shell.go(Route::Entry(id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
//! The company: who it is, who owns it, and the standing machinery of the
|
||||
//! books — periods, fixed assets, amortization schedules, recurring
|
||||
//! entries, vendors and customers.
|
||||
|
||||
use super::{columns, 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 egui::{CornerRadius, RichText, Sense, Ui, vec2};
|
||||
use egui_phosphor::regular as icon;
|
||||
use ledger_core::assets::{accumulated_depreciation, book_value};
|
||||
use ledger_core::{PeriodStatus, vendor_1099_report};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
fn kv(ui: &mut Ui, k: &str, v: &str) {
|
||||
let p = pal(ui.ctx());
|
||||
ui.horizontal(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(180.0, 20.0), Sense::hover());
|
||||
ui.painter().text(
|
||||
r.left_center(),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
k,
|
||||
theme::regular(13.0),
|
||||
p.muted,
|
||||
);
|
||||
ui.add(egui::Label::new(RichText::new(v).font(theme::regular(13.0))).wrap());
|
||||
});
|
||||
}
|
||||
|
||||
pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
page(ui, "company", |ui| {
|
||||
let c = &b.settings.company;
|
||||
widgets::page_header(
|
||||
ui,
|
||||
&c.name,
|
||||
"The company's profile and the standing parts of its books.",
|
||||
);
|
||||
columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i {
|
||||
0 => {
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Profile",
|
||||
|_| {},
|
||||
|ui| {
|
||||
if c.profile.is_empty() {
|
||||
ui.label(
|
||||
RichText::new("No [company] settings in wanderbooks.toml.")
|
||||
.color(p.muted),
|
||||
);
|
||||
}
|
||||
for (k, v) in &c.profile {
|
||||
kv(ui, &fmt::label(k), v);
|
||||
}
|
||||
kv(ui, "Accounting basis", &c.basis);
|
||||
kv(
|
||||
ui,
|
||||
"Tax return",
|
||||
c.tax_form.map(fmt::tax_form).unwrap_or("not set"),
|
||||
);
|
||||
kv(ui, "Receipt threshold", &fmt::dollars(b.receipt_threshold));
|
||||
},
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Owners",
|
||||
|_| {},
|
||||
|ui| {
|
||||
if c.members.is_empty() {
|
||||
// A single owner or a corporation: equity by account.
|
||||
for a in b
|
||||
.ledger
|
||||
.chart()
|
||||
.all()
|
||||
.filter(|a| a.account_type == ledger_core::AccountType::Equity)
|
||||
{
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
RichText::new(format!("{} {}", a.code, a.name))
|
||||
.font(theme::regular(13.0)),
|
||||
);
|
||||
ui.with_layout(
|
||||
egui::Layout::right_to_left(egui::Align::Center),
|
||||
|ui| {
|
||||
ui.label(
|
||||
RichText::new(fmt::accounting(
|
||||
b.balance_on(a.id, b.today),
|
||||
))
|
||||
.font(theme::semibold(13.0)),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
for m in &c.members {
|
||||
let cap = b.ledger.chart().find_by_code(&m.capital_account);
|
||||
let bal = cap.map(|a| b.balance_on(a.id, b.today)).unwrap_or_default();
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(&m.name).font(theme::semibold(13.5)));
|
||||
widgets::pill(
|
||||
ui,
|
||||
&format!("{}%", (m.share * Decimal::from(100)).normalize()),
|
||||
p.accent,
|
||||
p.accent_soft,
|
||||
);
|
||||
ui.with_layout(
|
||||
egui::Layout::right_to_left(egui::Align::Center),
|
||||
|ui| {
|
||||
ui.label(
|
||||
RichText::new(fmt::dollars(bal))
|
||||
.font(theme::semibold(13.5)),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
if let Some(a) = cap {
|
||||
ui.label(
|
||||
RichText::new(format!("Capital account {} {}", a.code, a.name))
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
},
|
||||
);
|
||||
ui.add_space(12.0);
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Periods",
|
||||
|_| {},
|
||||
|ui| {
|
||||
let periods = b.ledger.list_periods();
|
||||
if periods.is_empty() {
|
||||
ui.label(RichText::new("No periods yet. Closing a period locks it and moves its earnings into equity.").color(p.muted));
|
||||
}
|
||||
for per in periods {
|
||||
ui.horizontal(|ui| {
|
||||
let (g, color) = match per.status {
|
||||
PeriodStatus::Closed => (icon::LOCK, p.muted),
|
||||
PeriodStatus::Open => (icon::LOCK_OPEN, p.warning),
|
||||
};
|
||||
ui.label(RichText::new(g).color(color));
|
||||
ui.label(RichText::new(&per.name).font(theme::semibold(13.0)));
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"{} – {}",
|
||||
fmt::date(per.start),
|
||||
fmt::date(per.end)
|
||||
))
|
||||
.color(p.muted),
|
||||
);
|
||||
if let Some(e) = per.closing_entry
|
||||
&& widgets::link(
|
||||
ui,
|
||||
RichText::new(format!("closing entry #{e}"))
|
||||
.color(p.accent),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
shell.go(Route::Entry(e));
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
assets(ui, b, shell);
|
||||
schedules(ui, b);
|
||||
recurring(ui, b);
|
||||
parties(ui, b);
|
||||
});
|
||||
}
|
||||
|
||||
fn assets(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
let assets: Vec<_> = b.ledger.registered_assets().collect();
|
||||
if assets.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut go = None;
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Fixed assets",
|
||||
|_| {},
|
||||
|ui| {
|
||||
for a in &assets {
|
||||
// What has actually been posted, so this ties to the balance
|
||||
// sheet; the schedule may run ahead of the books.
|
||||
let acc: Decimal = b
|
||||
.journal
|
||||
.iter()
|
||||
.filter(|l| {
|
||||
l.line_type == "asset.depreciation_posted"
|
||||
&& l.payload.get("asset_id").and_then(|v| v.as_u64()) == Some(a.id)
|
||||
})
|
||||
.filter_map(|l| {
|
||||
l.payload
|
||||
.get("amount")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<Decimal>().ok())
|
||||
})
|
||||
.sum();
|
||||
let bv = a.cost.amount - acc;
|
||||
let scheduled = accumulated_depreciation(a, b.today);
|
||||
let _ = book_value;
|
||||
let dep = a.cost.amount - a.salvage_value.amount;
|
||||
let frac: f32 = if dep.is_zero() {
|
||||
1.0
|
||||
} else {
|
||||
(acc / dep).try_into().unwrap_or(0.0)
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
if widgets::link(
|
||||
ui,
|
||||
RichText::new(&a.description).font(theme::semibold(13.5)),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
go = Some(a.cost_account);
|
||||
}
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"bought {} · {} months · {}",
|
||||
fmt::date(a.purchase_date),
|
||||
a.useful_life_months,
|
||||
fmt::words(&format!("{:?}", a.status)).to_lowercase()
|
||||
))
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label(
|
||||
RichText::new(format!("book value {}", fmt::dollars(bv)))
|
||||
.font(theme::semibold(13.0)),
|
||||
);
|
||||
});
|
||||
});
|
||||
let behind = scheduled - acc;
|
||||
let note = if behind > Decimal::ZERO {
|
||||
format!(" · {} more scheduled through today", fmt::dollars(behind))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
progress(
|
||||
ui,
|
||||
frac,
|
||||
&format!(
|
||||
"{} of {} depreciated{note}",
|
||||
fmt::dollars(acc),
|
||||
fmt::dollars(dep)
|
||||
),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
},
|
||||
);
|
||||
if let Some(id) = go {
|
||||
shell.go(Route::Account(id));
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
}
|
||||
|
||||
fn progress(ui: &mut Ui, frac: f32, label: &str) {
|
||||
let p = pal(ui.ctx());
|
||||
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 8.0), Sense::hover());
|
||||
ui.painter().rect_filled(r, CornerRadius::same(4), p.hover);
|
||||
let mut done = r;
|
||||
done.set_width(r.width() * frac.clamp(0.0, 1.0));
|
||||
ui.painter()
|
||||
.rect_filled(done, CornerRadius::same(4), p.accent);
|
||||
ui.label(
|
||||
RichText::new(label)
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.faint),
|
||||
);
|
||||
}
|
||||
|
||||
fn schedules(ui: &mut Ui, b: &Books) {
|
||||
let p = pal(ui.ctx());
|
||||
let list: Vec<_> = b.ledger.schedules().collect();
|
||||
if list.is_empty() {
|
||||
return;
|
||||
}
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Amortization schedules",
|
||||
|_| {},
|
||||
|ui| {
|
||||
for s in list {
|
||||
let total = s.total.amount;
|
||||
let remaining = s.remaining();
|
||||
let frac: f32 = if total.is_zero() {
|
||||
1.0
|
||||
} else {
|
||||
((total - remaining) / total).try_into().unwrap_or(0.0)
|
||||
};
|
||||
let due = s.due(b.today);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(&s.description).font(theme::semibold(13.5)));
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"{} · {} months from {}",
|
||||
fmt::words(&format!("{:?}", s.kind)),
|
||||
s.months,
|
||||
fmt::month(s.start)
|
||||
))
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
if !due.is_empty() {
|
||||
widgets::pill(ui, &format!("{} due", due.len()), p.warning, p.warning_soft);
|
||||
}
|
||||
});
|
||||
progress(
|
||||
ui,
|
||||
frac,
|
||||
&format!(
|
||||
"{} of {} recognized · {} left",
|
||||
fmt::dollars(total - remaining),
|
||||
fmt::dollars(total),
|
||||
fmt::dollars(remaining)
|
||||
),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
},
|
||||
);
|
||||
ui.add_space(16.0);
|
||||
}
|
||||
|
||||
fn recurring(ui: &mut Ui, b: &Books) {
|
||||
let p = pal(ui.ctx());
|
||||
let list: Vec<_> = b.ledger.recurring_entries().collect();
|
||||
if list.is_empty() {
|
||||
return;
|
||||
}
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Recurring entries",
|
||||
|_| {},
|
||||
|ui| {
|
||||
for r in list {
|
||||
let amount: Decimal = r
|
||||
.lines
|
||||
.iter()
|
||||
.filter(|(_, s, _)| *s == ledger_core::Side::Debit)
|
||||
.map(|(_, _, m)| m.amount)
|
||||
.sum();
|
||||
let next = (0..)
|
||||
.map_while(|k| {
|
||||
r.frequency
|
||||
.nth(r.start, k)
|
||||
.filter(|d| r.end.is_none_or(|e| *d <= e))
|
||||
})
|
||||
.find(|d| !r.generated.contains(d));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(&r.description).font(theme::semibold(13.5)));
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"{} · {}",
|
||||
fmt::words(&format!("{:?}", r.frequency)),
|
||||
fmt::dollars(amount)
|
||||
))
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let text = match next {
|
||||
Some(d) if d <= b.today => format!("due {}", fmt::date(d)),
|
||||
Some(d) => format!("next {}", fmt::date(d)),
|
||||
None => "ended".into(),
|
||||
};
|
||||
ui.label(RichText::new(text).font(theme::regular(12.5)).color(
|
||||
if next.is_some_and(|d| d <= b.today) {
|
||||
p.warning
|
||||
} else {
|
||||
p.muted
|
||||
},
|
||||
));
|
||||
});
|
||||
});
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"{} generated so far · drafts only; each one is posted with its receipt",
|
||||
r.generated.len()
|
||||
))
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.faint),
|
||||
);
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
},
|
||||
);
|
||||
ui.add_space(16.0);
|
||||
}
|
||||
|
||||
fn parties(ui: &mut Ui, b: &Books) {
|
||||
let p = pal(ui.ctx());
|
||||
let year = chrono::Datelike::year(&b.today);
|
||||
let r1099 = vendor_1099_report(&b.ledger, year, None);
|
||||
columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i {
|
||||
0 => {
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Vendors",
|
||||
|_| {},
|
||||
|ui| {
|
||||
let vendors = b.ledger.list_vendors();
|
||||
if vendors.is_empty() {
|
||||
ui.label(RichText::new("No vendors yet.").color(p.muted));
|
||||
}
|
||||
for v in vendors {
|
||||
let paid = r1099
|
||||
.vendors
|
||||
.iter()
|
||||
.find(|x| x.vendor_id == v.id)
|
||||
.map(|x| x.paid)
|
||||
.unwrap_or_default();
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(&v.name).font(theme::semibold(13.0)));
|
||||
if let Some(f) = v.form_1099 {
|
||||
widgets::pill(ui, f.label(), p.accent, p.accent_soft);
|
||||
if v.tin_on_file {
|
||||
widgets::pill(ui, "W-9", p.positive, p.positive_soft);
|
||||
} else {
|
||||
widgets::pill(ui, "no W-9", p.negative, p.negative_soft);
|
||||
}
|
||||
}
|
||||
ui.with_layout(
|
||||
egui::Layout::right_to_left(egui::Align::Center),
|
||||
|ui| {
|
||||
if !paid.is_zero() {
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"{} paid in {year}",
|
||||
fmt::dollars(paid)
|
||||
))
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Customers",
|
||||
|_| {},
|
||||
|ui| {
|
||||
let customers = b.ledger.list_customers();
|
||||
if customers.is_empty() {
|
||||
ui.label(RichText::new("No customers yet.").color(p.muted));
|
||||
}
|
||||
for c in customers {
|
||||
let owed: Decimal = (1..b.ledger.next_invoice_id())
|
||||
.filter_map(|i| b.ledger.get_invoice(i))
|
||||
.filter(|i| i.customer_id == c.id)
|
||||
.map(|i| i.balance())
|
||||
.sum();
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(&c.name).font(theme::semibold(13.0)));
|
||||
ui.with_layout(
|
||||
egui::Layout::right_to_left(egui::Align::Center),
|
||||
|ui| {
|
||||
if !owed.is_zero() {
|
||||
ui.label(
|
||||
RichText::new(format!("owes {}", fmt::dollars(owed)))
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
//! Every receipt and document the books hold, rendered: what it is, what
|
||||
//! entry it proves, and whether it was withdrawn (and why).
|
||||
|
||||
use super::page;
|
||||
use crate::app::Shell;
|
||||
use crate::books::Books;
|
||||
use crate::fmt;
|
||||
use crate::nav::Route;
|
||||
use crate::receipts::State as Render;
|
||||
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;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
pub category: Option<String>,
|
||||
pub show_withdrawn: bool,
|
||||
pub query: String,
|
||||
}
|
||||
|
||||
pub fn show(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
|
||||
match selected.and_then(|id| b.attachments.iter().find(|a| a.id == id)) {
|
||||
Some(a) => super::split(
|
||||
ui,
|
||||
"documents",
|
||||
(ui.available_width() * 0.5).max(460.0),
|
||||
shell,
|
||||
|ui, shell| grid(ui, b, Some(a.id), shell),
|
||||
|ui, shell| detail(ui, b, a, shell),
|
||||
),
|
||||
None => grid(ui, b, None, shell),
|
||||
}
|
||||
}
|
||||
|
||||
fn grid(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
|
||||
page(ui, "documents", |ui| {
|
||||
let current = b
|
||||
.attachments
|
||||
.iter()
|
||||
.filter(|a| a.withdrawn_at.is_none())
|
||||
.count();
|
||||
widgets::page_header(
|
||||
ui,
|
||||
"Documents",
|
||||
&format!(
|
||||
"{} on file: receipts that prove entries, and the company's records. Bytes are content-addressed and verified on every read.",
|
||||
fmt::count(current, "document", "documents")
|
||||
),
|
||||
);
|
||||
let mut cats: Vec<String> = b.attachments.iter().map(|a| a.category.clone()).collect();
|
||||
cats.sort();
|
||||
cats.dedup();
|
||||
let st = &mut shell.state.documents;
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut st.query)
|
||||
.hint_text(format!("{} Search file names", icon::MAGNIFYING_GLASS))
|
||||
.desired_width(260.0),
|
||||
);
|
||||
if ui.selectable_label(st.category.is_none(), "All").clicked() {
|
||||
st.category = None;
|
||||
}
|
||||
for c in &cats {
|
||||
if ui
|
||||
.selectable_label(st.category.as_deref() == Some(c.as_str()), fmt::label(c))
|
||||
.clicked()
|
||||
{
|
||||
st.category = Some(c.clone());
|
||||
}
|
||||
}
|
||||
ui.checkbox(&mut st.show_withdrawn, "Show withdrawn");
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
let q = st.query.to_lowercase();
|
||||
let docs: Vec<&Attachment> = b
|
||||
.attachments
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|a| st.show_withdrawn || a.withdrawn_at.is_none())
|
||||
.filter(|a| st.category.as_ref().is_none_or(|c| &a.category == c))
|
||||
.filter(|a| {
|
||||
q.is_empty()
|
||||
|| a.file_name.to_lowercase().contains(&q)
|
||||
|| a.description
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.to_lowercase()
|
||||
.contains(&q)
|
||||
})
|
||||
.collect();
|
||||
if docs.is_empty() {
|
||||
widgets::card(ui, |ui| widgets::empty(ui, icon::FILES, "No documents", ""));
|
||||
return;
|
||||
}
|
||||
let tile_w = 200.0;
|
||||
let per_row = ((ui.available_width() + 16.0) / (tile_w + 16.0))
|
||||
.floor()
|
||||
.max(1.0) as usize;
|
||||
let mut go = None;
|
||||
for row in docs.chunks(per_row) {
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 16.0;
|
||||
for a in row {
|
||||
if tile(ui, b, a, tile_w, selected == Some(a.id), shell).clicked() {
|
||||
go = Some(a.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(16.0);
|
||||
}
|
||||
if let Some(id) = go {
|
||||
shell.go(Route::Document(id));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn tile(
|
||||
ui: &mut Ui,
|
||||
b: &Books,
|
||||
a: &Attachment,
|
||||
w: f32,
|
||||
on: bool,
|
||||
shell: &mut Shell<'_>,
|
||||
) -> egui::Response {
|
||||
let p = pal(ui.ctx());
|
||||
let h = 250.0;
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(w, h), Sense::click());
|
||||
let fill = if on { p.selected } else { p.surface };
|
||||
ui.painter().rect(
|
||||
rect,
|
||||
CornerRadius::same(10),
|
||||
fill,
|
||||
Stroke::new(1.0, if resp.hovered() { p.accent } else { p.border }),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
let thumb = egui::Rect::from_min_size(rect.min + vec2(10.0, 10.0), vec2(w - 20.0, 170.0));
|
||||
ui.painter()
|
||||
.rect_filled(thumb, CornerRadius::same(6), p.hover);
|
||||
match shell.receipts.get(&a.blob_sha, &a.mime_type, b.blob_root()) {
|
||||
Render::Ready(pages) => {
|
||||
if let Some(tex) = pages.first() {
|
||||
// Top of the first page, filling the width.
|
||||
let size = tex.size_vec2();
|
||||
let scale = thumb.width() / size.x;
|
||||
let visible_h = (thumb.height() / scale).min(size.y);
|
||||
let uv = egui::Rect::from_min_max(
|
||||
egui::pos2(0.0, 0.0),
|
||||
egui::pos2(1.0, visible_h / size.y),
|
||||
);
|
||||
let img_rect =
|
||||
egui::Rect::from_min_size(thumb.min, vec2(thumb.width(), visible_h * scale));
|
||||
ui.painter()
|
||||
.image(tex.id(), img_rect, uv, egui::Color32::WHITE);
|
||||
}
|
||||
}
|
||||
Render::Loading => {
|
||||
ui.painter().text(
|
||||
thumb.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
"…",
|
||||
theme::regular(16.0),
|
||||
p.faint,
|
||||
);
|
||||
}
|
||||
Render::Failed(_) => {
|
||||
ui.painter().text(
|
||||
thumb.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
icon::FILE,
|
||||
egui::FontId::proportional(28.0),
|
||||
p.faint,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Title: what it proves, else what it is.
|
||||
let title = a
|
||||
.entry_id
|
||||
.and_then(|e| b.entry(e))
|
||||
.map(|e| e.description.clone())
|
||||
.or_else(|| a.description.clone())
|
||||
.unwrap_or_else(|| a.file_name.clone());
|
||||
let max_chars = ((w - 20.0) / 7.2) as usize;
|
||||
let title = if title.chars().count() > max_chars {
|
||||
title.chars().take(max_chars - 1).collect::<String>() + "…"
|
||||
} else {
|
||||
title
|
||||
};
|
||||
ui.painter().text(
|
||||
egui::pos2(rect.left() + 10.0, thumb.bottom() + 18.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
title,
|
||||
theme::semibold(12.5),
|
||||
p.text,
|
||||
);
|
||||
let sub = match (a.entry_id, &a.withdrawn_at) {
|
||||
(_, Some(_)) => format!("Withdrawn · {}", a.file_name),
|
||||
(Some(e), None) => match b.entry(e) {
|
||||
Some(x) => format!(
|
||||
"#{e} · {} · {}",
|
||||
fmt::date_short(x.date),
|
||||
fmt::dollars(x.amount)
|
||||
),
|
||||
None => format!("#{e}"),
|
||||
},
|
||||
(None, None) => format!("{} · {}", fmt::label(&a.category), a.file_name),
|
||||
};
|
||||
let sub = if sub.chars().count() > max_chars + 4 {
|
||||
sub.chars().take(max_chars + 3).collect::<String>() + "…"
|
||||
} else {
|
||||
sub
|
||||
};
|
||||
ui.painter().text(
|
||||
egui::pos2(rect.left() + 10.0, thumb.bottom() + 40.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
sub,
|
||||
theme::regular(11.5),
|
||||
if a.withdrawn_at.is_some() {
|
||||
p.warning
|
||||
} else {
|
||||
p.muted
|
||||
},
|
||||
);
|
||||
resp
|
||||
}
|
||||
|
||||
fn detail(ui: &mut Ui, b: &Books, a: &Attachment, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt(("doc", a.id))
|
||||
.auto_shrink(false)
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
RichText::new(format!("Document {}", a.id))
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui
|
||||
.add(egui::Button::new(RichText::new(icon::X).size(15.0)).frame(false))
|
||||
.clicked()
|
||||
{
|
||||
shell.go(Route::Documents);
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.label(RichText::new(&a.file_name).font(theme::bold(20.0)));
|
||||
ui.add_space(8.0);
|
||||
egui::Grid::new(("docfacts", a.id))
|
||||
.num_columns(2)
|
||||
.spacing(vec2(20.0, 6.0))
|
||||
.show(ui, |ui| {
|
||||
let k = |ui: &mut Ui, s: &str| {
|
||||
ui.label(RichText::new(s).font(theme::regular(12.5)).color(p.muted))
|
||||
};
|
||||
k(ui, "Category");
|
||||
ui.label(fmt::label(&a.category));
|
||||
ui.end_row();
|
||||
k(ui, "Type");
|
||||
ui.label(format!("{} · {}", a.mime_type, fmt::bytes(a.size_bytes)));
|
||||
ui.end_row();
|
||||
k(ui, "Filed");
|
||||
ui.label(fmt::timestamp(&a.created_at));
|
||||
ui.end_row();
|
||||
k(ui, "SHA-256");
|
||||
ui.add(
|
||||
egui::Label::new(RichText::new(&a.blob_sha).monospace().size(11.5)).wrap(),
|
||||
);
|
||||
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(ui, RichText::new(label).color(p.accent)).clicked() {
|
||||
shell.go(Route::Entry(e));
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
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);
|
||||
ui.label(RichText::new(d).color(p.muted));
|
||||
}
|
||||
if a.withdrawn_at.is_some() {
|
||||
ui.add_space(6.0);
|
||||
widgets::pill(
|
||||
ui,
|
||||
&format!("Withdrawn: {}", a.withdrawn_reason.as_deref().unwrap_or("")),
|
||||
p.warning,
|
||||
p.warning_soft,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
//! One entry, completely: what it is, every line, the receipt itself, and
|
||||
//! every journal event that touched it.
|
||||
|
||||
use crate::app::Shell;
|
||||
use crate::books::{Books, EntryInfo};
|
||||
use crate::fmt;
|
||||
use crate::nav::Route;
|
||||
use crate::receipts::State as Render;
|
||||
use crate::theme::{self, pal};
|
||||
use crate::widgets;
|
||||
use egui::{CornerRadius, Margin, RichText, Stroke, Ui, vec2};
|
||||
use egui_phosphor::regular as icon;
|
||||
use ledger_core::{EntryStatus, Side};
|
||||
use ledger_store::Attachment;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
let Some(entry) = b.ledger.get_entry(e.id) else {
|
||||
return;
|
||||
};
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt(("entry", e.id))
|
||||
.auto_shrink(false)
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
RichText::new(format!("Entry #{}", e.id))
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui
|
||||
.add(egui::Button::new(RichText::new(icon::X).size(15.0)).frame(false))
|
||||
.on_hover_text("Close")
|
||||
.clicked()
|
||||
{
|
||||
shell.go(Route::Transactions);
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.label(RichText::new(&e.description).font(theme::bold(21.0)));
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
match e.status {
|
||||
EntryStatus::Posted => widgets::pill(ui, "Posted", p.positive, p.positive_soft),
|
||||
EntryStatus::Draft => {
|
||||
widgets::pill(ui, "Draft — not in any report", p.warning, p.warning_soft)
|
||||
}
|
||||
};
|
||||
widgets::pill(ui, e.origin, p.muted, p.hover);
|
||||
if let Some(v) = &e.vendor {
|
||||
widgets::pill(ui, &format!("{} {v}", icon::STOREFRONT), p.muted, p.hover);
|
||||
}
|
||||
if e.missing_receipt {
|
||||
widgets::pill(ui, "Missing receipt", p.negative, p.negative_soft);
|
||||
}
|
||||
});
|
||||
if let Some(o) = e.reversal_of {
|
||||
ui.add_space(4.0);
|
||||
if widgets::link(
|
||||
ui,
|
||||
RichText::new(format!("{} Reverses #{o}", icon::ARROW_COUNTER_CLOCKWISE))
|
||||
.color(p.accent),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
shell.go(Route::Entry(o));
|
||||
}
|
||||
}
|
||||
if let Some(r) = e.reversed_by {
|
||||
ui.add_space(4.0);
|
||||
if widgets::link(
|
||||
ui,
|
||||
RichText::new(format!(
|
||||
"{} Reversed by #{r}",
|
||||
icon::ARROW_COUNTER_CLOCKWISE
|
||||
))
|
||||
.color(p.accent),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
shell.go(Route::Entry(r));
|
||||
}
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
|
||||
// Facts.
|
||||
let created = e.events.first().map(|&i| fmt::timestamp(&b.journal[i].ts));
|
||||
// Posted by entry.posted, or in one step by whatever created it
|
||||
// (a schedule, a close, a bill).
|
||||
let posted = e
|
||||
.events
|
||||
.iter()
|
||||
.find(|&&i| b.journal[i].line_type == "entry.posted")
|
||||
.or_else(|| e.events.first())
|
||||
.map(|&i| fmt::timestamp(&b.journal[i].ts));
|
||||
egui::Grid::new(("facts", e.id))
|
||||
.num_columns(2)
|
||||
.spacing(vec2(24.0, 6.0))
|
||||
.show(ui, |ui| {
|
||||
let k = |ui: &mut Ui, s: &str| {
|
||||
ui.label(RichText::new(s).font(theme::regular(12.5)).color(p.muted))
|
||||
};
|
||||
k(ui, "Date");
|
||||
ui.label(fmt::date(e.date));
|
||||
ui.end_row();
|
||||
k(ui, "Amount");
|
||||
ui.label(RichText::new(fmt::dollars(e.amount)).font(theme::semibold(14.0)));
|
||||
ui.end_row();
|
||||
if let Some(c) = &created {
|
||||
k(ui, "Recorded");
|
||||
ui.label(c);
|
||||
ui.end_row();
|
||||
}
|
||||
if e.status == EntryStatus::Posted
|
||||
&& let Some(t) = &posted
|
||||
{
|
||||
k(ui, "Posted");
|
||||
ui.label(t);
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
ui.add_space(16.0);
|
||||
|
||||
// Lines.
|
||||
section(ui, "Lines");
|
||||
let total_w = ui.available_width();
|
||||
let amt_w = 104.0;
|
||||
let class_names: std::collections::HashMap<u32, String> = b
|
||||
.ledger
|
||||
.list_classes()
|
||||
.into_iter()
|
||||
.map(|c| (c.id, c.name.clone()))
|
||||
.collect();
|
||||
egui::Frame::new()
|
||||
.stroke(Stroke::new(1.0, p.border))
|
||||
.corner_radius(CornerRadius::same(10))
|
||||
.inner_margin(Margin::symmetric(12, 8))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(total_w - 26.0);
|
||||
ui.horizontal(|ui| {
|
||||
let w = ui.available_width() - amt_w * 2.0 - 16.0;
|
||||
let (r, _) = ui.allocate_exact_size(vec2(w, 18.0), egui::Sense::hover());
|
||||
ui.painter().text(
|
||||
r.left_center(),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
"Account",
|
||||
theme::regular(12.0),
|
||||
p.muted,
|
||||
);
|
||||
widgets::amount_cell(ui, "Debit", amt_w, p.muted, false, false);
|
||||
widgets::amount_cell(ui, "Credit", amt_w, p.muted, false, false);
|
||||
});
|
||||
let (mut dr, mut cr) = (Decimal::ZERO, Decimal::ZERO);
|
||||
for line in entry.lines() {
|
||||
ui.horizontal(|ui| {
|
||||
let w = ui.available_width() - amt_w * 2.0 - 16.0;
|
||||
ui.allocate_ui(vec2(w, 22.0), |ui| {
|
||||
ui.set_width(w);
|
||||
ui.horizontal(|ui| {
|
||||
let r = widgets::link(
|
||||
ui,
|
||||
RichText::new(b.account_label(line.account))
|
||||
.font(theme::regular(13.5)),
|
||||
);
|
||||
if r.clicked() {
|
||||
shell.go(Route::Account(line.account));
|
||||
}
|
||||
if let Some(c) = line.class_id.and_then(|c| class_names.get(&c))
|
||||
{
|
||||
ui.label(
|
||||
RichText::new(format!("{} {c}", icon::TAG))
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
}
|
||||
if let (Some(cur), Some(fa)) =
|
||||
(&line.foreign_currency, line.foreign_amount)
|
||||
{
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"{} {}",
|
||||
fmt::money(fa),
|
||||
cur.code()
|
||||
))
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
let a = fmt::money(line.amount.amount);
|
||||
let (d, c) = match line.side {
|
||||
Side::Debit => {
|
||||
dr += line.amount.amount;
|
||||
(a, String::new())
|
||||
}
|
||||
Side::Credit => {
|
||||
cr += line.amount.amount;
|
||||
(String::new(), a)
|
||||
}
|
||||
};
|
||||
widgets::amount_cell(ui, &d, amt_w, p.text, false, false);
|
||||
widgets::amount_cell(ui, &c, amt_w, p.text, false, false);
|
||||
});
|
||||
}
|
||||
widgets::rule(ui);
|
||||
ui.horizontal(|ui| {
|
||||
let w = ui.available_width() - amt_w * 2.0 - 16.0;
|
||||
let balanced = dr == cr;
|
||||
let (r, _) = ui.allocate_exact_size(vec2(w, 22.0), egui::Sense::hover());
|
||||
ui.painter().text(
|
||||
r.left_center(),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
if balanced {
|
||||
format!("{} Balanced", icon::CHECK)
|
||||
} else {
|
||||
"Out of balance".into()
|
||||
},
|
||||
theme::regular(12.0),
|
||||
if balanced { p.positive } else { p.negative },
|
||||
);
|
||||
widgets::amount_cell(ui, &fmt::money(dr), amt_w, p.text, true, false);
|
||||
widgets::amount_cell(ui, &fmt::money(cr), amt_w, p.text, true, false);
|
||||
});
|
||||
});
|
||||
ui.add_space(18.0);
|
||||
|
||||
// Receipts.
|
||||
let all_for_entry: Vec<&Attachment> = b
|
||||
.attachments
|
||||
.iter()
|
||||
.filter(|a| a.entry_id == Some(e.id))
|
||||
.collect();
|
||||
section(
|
||||
ui,
|
||||
if all_for_entry.len() == 1 {
|
||||
"Receipt"
|
||||
} else {
|
||||
"Receipts"
|
||||
},
|
||||
);
|
||||
if all_for_entry.is_empty() {
|
||||
let msg = if e.missing_receipt {
|
||||
format!(
|
||||
"None attached, and this entry is over the {} receipt threshold.",
|
||||
fmt::dollars(b.receipt_threshold)
|
||||
)
|
||||
} else {
|
||||
"None attached.".to_string()
|
||||
};
|
||||
ui.label(RichText::new(msg).color(if e.missing_receipt {
|
||||
p.negative
|
||||
} else {
|
||||
p.muted
|
||||
}));
|
||||
}
|
||||
for a in all_for_entry {
|
||||
attachment_view(ui, b, a, shell, true);
|
||||
ui.add_space(12.0);
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
|
||||
// History.
|
||||
section(ui, "History");
|
||||
for &i in &e.events {
|
||||
let line = &b.journal[i];
|
||||
let ev = super::journal::describe_any(b, line);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(ev.icon).color(p.muted));
|
||||
let r = widgets::link(ui, RichText::new(&ev.text).font(theme::regular(12.5)));
|
||||
if r.clicked() {
|
||||
shell.go(Route::JournalEvent(line.seq));
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label(
|
||||
RichText::new(format!("#{} · {}", line.seq, fmt::timestamp(&line.ts)))
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.faint),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn section(ui: &mut Ui, title: &str) {
|
||||
let p = pal(ui.ctx());
|
||||
ui.label(
|
||||
RichText::new(title.to_uppercase())
|
||||
.font(theme::semibold(11.5))
|
||||
.color(p.faint),
|
||||
);
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
|
||||
/// A receipt or document: its name and facts, then the file itself.
|
||||
pub fn attachment_view(
|
||||
ui: &mut Ui,
|
||||
b: &Books,
|
||||
a: &Attachment,
|
||||
shell: &mut Shell<'_>,
|
||||
link_to_doc: bool,
|
||||
) {
|
||||
let p = pal(ui.ctx());
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
RichText::new(if a.mime_type == "application/pdf" {
|
||||
icon::FILE_PDF
|
||||
} else {
|
||||
icon::FILE_IMAGE
|
||||
})
|
||||
.size(18.0)
|
||||
.color(p.muted),
|
||||
);
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 1.0;
|
||||
let name = RichText::new(&a.file_name).font(theme::semibold(13.0));
|
||||
if link_to_doc {
|
||||
if widgets::link(ui, name).clicked() {
|
||||
shell.go(Route::Document(a.id));
|
||||
}
|
||||
} else {
|
||||
ui.label(name);
|
||||
}
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"{} · {} · filed {}",
|
||||
fmt::label(&a.category),
|
||||
fmt::bytes(a.size_bytes),
|
||||
fmt::timestamp(&a.created_at)
|
||||
))
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
});
|
||||
});
|
||||
if let Some(when) = &a.withdrawn_at {
|
||||
widgets::pill(
|
||||
ui,
|
||||
&format!(
|
||||
"Withdrawn {}: {}",
|
||||
fmt::timestamp(when),
|
||||
a.withdrawn_reason.as_deref().unwrap_or("")
|
||||
),
|
||||
p.warning,
|
||||
p.warning_soft,
|
||||
);
|
||||
}
|
||||
if let Some(d) = &a.description {
|
||||
ui.label(RichText::new(d).font(theme::regular(12.5)).color(p.muted));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
render(ui, b, a, shell, ui.available_width());
|
||||
}
|
||||
|
||||
/// The file itself, pages stacked, scaled to `width`.
|
||||
pub fn render(ui: &mut Ui, b: &Books, a: &Attachment, shell: &mut Shell<'_>, width: f32) {
|
||||
let p = pal(ui.ctx());
|
||||
match shell.receipts.get(&a.blob_sha, &a.mime_type, b.blob_root()) {
|
||||
Render::Loading => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spinner();
|
||||
ui.label(RichText::new("Rendering…").color(p.muted));
|
||||
});
|
||||
}
|
||||
Render::Failed(err) => {
|
||||
ui.label(
|
||||
RichText::new(format!("{} Can't show this file: {err}", icon::WARNING))
|
||||
.color(p.negative),
|
||||
);
|
||||
}
|
||||
Render::Ready(pages) => {
|
||||
for (i, tex) in pages.iter().enumerate() {
|
||||
let size = tex.size_vec2();
|
||||
let scale = (width / size.x).min(1.0);
|
||||
let img = egui::Image::new(tex)
|
||||
.fit_to_exact_size(size * scale)
|
||||
.corner_radius(CornerRadius::same(4));
|
||||
egui::Frame::new()
|
||||
.stroke(Stroke::new(1.0, p.border))
|
||||
.corner_radius(CornerRadius::same(6))
|
||||
.show(ui, |ui| {
|
||||
ui.add(img);
|
||||
});
|
||||
if pages.len() > 1 {
|
||||
ui.label(
|
||||
RichText::new(format!("Page {} of {}", i + 1, pages.len()))
|
||||
.font(theme::regular(11.0))
|
||||
.color(p.faint),
|
||||
);
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
//! The journal: every event, in order, as recorded. The audit trail an
|
||||
//! accountant wants and the raw truth everything else is built from.
|
||||
|
||||
use super::page;
|
||||
use crate::app::Shell;
|
||||
use crate::books::{Books, entry_refs};
|
||||
use crate::fmt;
|
||||
use crate::nav::Route;
|
||||
use crate::theme::{self, pal};
|
||||
use crate::widgets;
|
||||
use egui::{CornerRadius, Margin, RichText, Sense, Ui, vec2};
|
||||
use egui_phosphor::regular as icon;
|
||||
use ledger_store::JournalLine;
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
pub query: String,
|
||||
pub hide_steps: bool,
|
||||
}
|
||||
|
||||
pub struct Event {
|
||||
pub icon: &'static str,
|
||||
pub text: String,
|
||||
pub route: Option<Route>,
|
||||
}
|
||||
|
||||
fn s<'a>(p: &'a Value, k: &str) -> &'a str {
|
||||
p.get(k).and_then(|v| v.as_str()).unwrap_or("")
|
||||
}
|
||||
|
||||
fn amount(p: &Value) -> String {
|
||||
p.get("amount")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|a| a.parse().ok())
|
||||
.map(fmt::dollars)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn entry_text(b: &Books, id: u64) -> String {
|
||||
b.entry(id)
|
||||
.map(|e| format!("#{} {} ({})", e.id, e.description, fmt::dollars(e.amount)))
|
||||
.unwrap_or_else(|| format!("#{id}"))
|
||||
}
|
||||
|
||||
fn attachment_name(b: &Books, id: u64) -> String {
|
||||
b.attachments
|
||||
.iter()
|
||||
.find(|a| a.id == id)
|
||||
.map(|a| a.file_name.clone())
|
||||
.unwrap_or_else(|| format!("attachment {id}"))
|
||||
}
|
||||
|
||||
/// A sentence for a meaningful event; `None` for the intermediate steps of
|
||||
/// building an entry (created, line added), which the posted event covers.
|
||||
pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
|
||||
let p = &line.payload;
|
||||
let id = p.get("id").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let entry_id = p.get("entry_id").and_then(|v| v.as_u64());
|
||||
let att = p.get("attachment_id").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let ev = |icon, text: String, route: Option<Route>| Some(Event { icon, text, route });
|
||||
match line.line_type.as_str() {
|
||||
"entry.created" | "entry.line_added" | "entry.line_class_set" => None,
|
||||
"store.created" => ev(icon::BOOK_OPEN, "Books created".into(), None),
|
||||
"entry.posted" => ev(
|
||||
icon::CHECK_CIRCLE,
|
||||
format!("Posted {}", entry_text(b, id)),
|
||||
Some(Route::Entry(id)),
|
||||
),
|
||||
"entry.reversed" => ev(
|
||||
icon::ARROW_COUNTER_CLOCKWISE,
|
||||
format!(
|
||||
"Reversed #{} with #{id}",
|
||||
p.get("reverses").and_then(|v| v.as_u64()).unwrap_or(0)
|
||||
),
|
||||
Some(Route::Entry(id)),
|
||||
),
|
||||
"receipt.attached" => ev(
|
||||
icon::PAPERCLIP,
|
||||
match entry_id {
|
||||
Some(e) => format!("Attached {} to #{e}", s(p, "file_name")),
|
||||
None => format!("Filed receipt {}", s(p, "file_name")),
|
||||
},
|
||||
Some(Route::Document(att)),
|
||||
),
|
||||
"document.filed" => ev(
|
||||
icon::FILE_ARROW_UP,
|
||||
format!(
|
||||
"Filed {} ({})",
|
||||
s(p, "file_name"),
|
||||
fmt::label(s(p, "category"))
|
||||
),
|
||||
Some(Route::Document(att)),
|
||||
),
|
||||
"receipt.linked" => ev(
|
||||
icon::LINK,
|
||||
format!(
|
||||
"Linked {} to #{}",
|
||||
attachment_name(b, att),
|
||||
entry_id.unwrap_or(0)
|
||||
),
|
||||
entry_id.map(Route::Entry),
|
||||
),
|
||||
"receipt.unlinked" => ev(
|
||||
icon::LINK_BREAK,
|
||||
format!(
|
||||
"Unlinked {} from #{}: {}",
|
||||
attachment_name(b, att),
|
||||
entry_id.unwrap_or(0),
|
||||
s(p, "reason")
|
||||
),
|
||||
entry_id.map(Route::Entry),
|
||||
),
|
||||
"attachment.withdrawn" => ev(
|
||||
icon::ARCHIVE,
|
||||
format!("Withdrew {}: {}", attachment_name(b, att), s(p, "reason")),
|
||||
Some(Route::Document(att)),
|
||||
),
|
||||
"account.added" => ev(
|
||||
icon::PLUS_CIRCLE,
|
||||
format!("Added account {} {}", s(p, "code"), s(p, "name")),
|
||||
Some(Route::Account(id)),
|
||||
),
|
||||
"account.tax_set" => ev(
|
||||
icon::TAG,
|
||||
format!(
|
||||
"Tax category of {}: {}",
|
||||
b.account_label(p.get("account_id").and_then(|v| v.as_u64()).unwrap_or(0)),
|
||||
p.get("tax_category")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("none")
|
||||
),
|
||||
p.get("account_id")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(Route::Account),
|
||||
),
|
||||
"vendor.added" => ev(
|
||||
icon::STOREFRONT,
|
||||
format!("Added vendor {}", s(p, "name")),
|
||||
Some(Route::Company),
|
||||
),
|
||||
"customer.added" => ev(
|
||||
icon::USER_PLUS,
|
||||
format!("Added customer {}", s(p, "name")),
|
||||
Some(Route::Company),
|
||||
),
|
||||
"class.added" => ev(icon::TAG, format!("Added class {}", s(p, "name")), None),
|
||||
"vendor.1099_set" => ev(
|
||||
icon::IDENTIFICATION_CARD,
|
||||
"Updated a vendor's 1099 details".into(),
|
||||
Some(Route::Company),
|
||||
),
|
||||
"entry.vendor_set" => ev(
|
||||
icon::STOREFRONT,
|
||||
format!("Tagged #{} with a vendor", entry_id.unwrap_or(0)),
|
||||
entry_id.map(Route::Entry),
|
||||
),
|
||||
"invoice.created" => ev(
|
||||
icon::INVOICE,
|
||||
format!("Invoice {id} created"),
|
||||
entry_id.map(Route::Entry),
|
||||
),
|
||||
"bill.created" => ev(
|
||||
icon::RECEIPT,
|
||||
format!("Bill {id} recorded"),
|
||||
entry_id.map(Route::Entry),
|
||||
),
|
||||
"invoice.payment_received" => ev(
|
||||
icon::HAND_COINS,
|
||||
format!("Payment received, {}", amount(p)),
|
||||
entry_id.map(Route::Entry),
|
||||
),
|
||||
"bill.paid" => ev(
|
||||
icon::MONEY,
|
||||
format!("Bill paid, {}", amount(p)),
|
||||
entry_id.map(Route::Entry),
|
||||
),
|
||||
"period.created" => ev(
|
||||
icon::CALENDAR_PLUS,
|
||||
format!("Period {} created", s(p, "name")),
|
||||
Some(Route::Company),
|
||||
),
|
||||
"period.closed" => ev(
|
||||
icon::LOCK,
|
||||
"Period closed".into(),
|
||||
p.get("closing_entry")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(Route::Entry),
|
||||
),
|
||||
"asset.registered" => ev(
|
||||
icon::PACKAGE,
|
||||
format!("Registered asset {}", s(p, "description")),
|
||||
Some(Route::Company),
|
||||
),
|
||||
"asset.depreciation_posted" => ev(
|
||||
icon::TREND_DOWN,
|
||||
"Depreciation posted".into(),
|
||||
entry_id.map(Route::Entry),
|
||||
),
|
||||
"asset.disposed" => ev(
|
||||
icon::TRASH,
|
||||
"Asset disposed".into(),
|
||||
entry_id.map(Route::Entry),
|
||||
),
|
||||
"schedule.registered" => ev(
|
||||
icon::CALENDAR,
|
||||
format!("Schedule: {}", s(p, "description")),
|
||||
Some(Route::Company),
|
||||
),
|
||||
"schedule.posted" => ev(
|
||||
icon::CALENDAR_CHECK,
|
||||
"Amortization posted".into(),
|
||||
p.get("entry")
|
||||
.and_then(|e| e.get("id"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(Route::Entry),
|
||||
),
|
||||
"recurring.registered" => ev(
|
||||
icon::REPEAT,
|
||||
format!("Recurring: {}", s(p, "description")),
|
||||
Some(Route::Company),
|
||||
),
|
||||
"recurring.generated" => ev(
|
||||
icon::REPEAT,
|
||||
"Recurring draft created".into(),
|
||||
entry_id.map(Route::Entry),
|
||||
),
|
||||
"fx.rate_set" => ev(
|
||||
icon::CURRENCY_CIRCLE_DOLLAR,
|
||||
format!("{} rate set", s(p, "currency")),
|
||||
None,
|
||||
),
|
||||
"payroll.run" => ev(
|
||||
icon::USERS,
|
||||
"Payroll run".into(),
|
||||
entry_id.map(Route::Entry),
|
||||
),
|
||||
"line.cleared" => ev(
|
||||
icon::CHECK_SQUARE,
|
||||
format!("Cleared a line of #{}", entry_id.unwrap_or(0)),
|
||||
entry_id.map(Route::Entry),
|
||||
),
|
||||
other => ev(
|
||||
icon::DOT,
|
||||
other.to_string(),
|
||||
entry_refs(line).first().copied().map(Route::Entry),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`describe`], but every event gets a sentence.
|
||||
pub fn describe_any(b: &Books, line: &JournalLine) -> Event {
|
||||
if let Some(e) = describe(b, line) {
|
||||
return e;
|
||||
}
|
||||
let p = &line.payload;
|
||||
let id = p
|
||||
.get("id")
|
||||
.and_then(|v| v.as_u64())
|
||||
.or_else(|| p.get("entry_id").and_then(|v| v.as_u64()))
|
||||
.unwrap_or(0);
|
||||
match line.line_type.as_str() {
|
||||
"entry.created" => Event {
|
||||
icon: icon::PENCIL_SIMPLE,
|
||||
text: format!("Drafted #{id} {}", s(p, "description")),
|
||||
route: Some(Route::Entry(id)),
|
||||
},
|
||||
"entry.line_added" => {
|
||||
let amount = p
|
||||
.get("amount")
|
||||
.and_then(|a| a.get("amount").or(Some(a)))
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|v| v.parse().ok())
|
||||
.map(fmt::money)
|
||||
.unwrap_or_default();
|
||||
Event {
|
||||
icon: icon::PLUS,
|
||||
text: format!(
|
||||
"Line on #{id}: {} {} {amount}",
|
||||
s(p, "side"),
|
||||
b.account_label(
|
||||
p.get("account_id")
|
||||
.or_else(|| p.get("account"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0)
|
||||
),
|
||||
),
|
||||
route: Some(Route::Entry(id)),
|
||||
}
|
||||
}
|
||||
"entry.line_class_set" => {
|
||||
let class = p
|
||||
.get("class_id")
|
||||
.and_then(|v| v.as_u64())
|
||||
.and_then(|c| b.ledger.get_class(c as u32))
|
||||
.map(|c| c.name.clone())
|
||||
.unwrap_or_else(|| "no class".into());
|
||||
Event {
|
||||
icon: icon::TAG,
|
||||
text: format!(
|
||||
"Line {} of #{id} tagged {class}",
|
||||
p.get("line_index")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map_or(0, |i| i + 1)
|
||||
),
|
||||
route: Some(Route::Entry(id)),
|
||||
}
|
||||
}
|
||||
t => Event {
|
||||
icon: icon::DOT,
|
||||
text: t.to_string(),
|
||||
route: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
|
||||
if let Some(seq) = selected
|
||||
&& let Some(line) = b.journal.get(seq.saturating_sub(1) as usize)
|
||||
{
|
||||
super::split(
|
||||
ui,
|
||||
"journal",
|
||||
480.0,
|
||||
shell,
|
||||
|ui, shell| list(ui, b, selected, shell),
|
||||
|ui, shell| detail(ui, b, line, shell),
|
||||
);
|
||||
} else {
|
||||
list(ui, b, None, shell);
|
||||
}
|
||||
}
|
||||
|
||||
fn list(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
page(ui, "journal", |ui| {
|
||||
widgets::page_header(
|
||||
ui,
|
||||
"Journal",
|
||||
&format!(
|
||||
"Every change to the books, in order: {} events, each hash-chained to the one before. Nothing is ever edited or deleted; corrections are new events.",
|
||||
b.journal.len()
|
||||
),
|
||||
);
|
||||
let st = &mut shell.state.journal;
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut st.query)
|
||||
.hint_text(format!("{} Filter events", icon::MAGNIFYING_GLASS))
|
||||
.desired_width(320.0),
|
||||
);
|
||||
ui.checkbox(&mut st.hide_steps, "Hide entry-building steps");
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
let q = st.query.to_lowercase();
|
||||
let hide = st.hide_steps;
|
||||
let rows: Vec<(&JournalLine, Event)> = b
|
||||
.journal
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|l| !(hide && describe(b, l).is_none()))
|
||||
.map(|l| (l, describe_any(b, l)))
|
||||
.filter(|(l, e)| {
|
||||
q.is_empty() || e.text.to_lowercase().contains(&q) || l.line_type.contains(&q)
|
||||
})
|
||||
.collect();
|
||||
widgets::card(ui, |ui| {
|
||||
let row_h = 34.0;
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("journal_rows")
|
||||
.max_height(ui.available_height().max(400.0))
|
||||
.show_rows(ui, row_h, rows.len(), |ui, range| {
|
||||
for (line, ev) in &rows[range] {
|
||||
let (rect, resp) = ui
|
||||
.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click());
|
||||
let on = selected == Some(line.seq);
|
||||
let fresh = line.seq > b.fresh_from_seq && shell.since_update < 8.0;
|
||||
if on {
|
||||
ui.painter()
|
||||
.rect_filled(rect, CornerRadius::same(6), p.selected);
|
||||
} else if fresh {
|
||||
ui.painter()
|
||||
.rect_filled(rect, CornerRadius::same(6), p.new_highlight);
|
||||
} else if resp.hovered() {
|
||||
ui.painter()
|
||||
.rect_filled(rect, CornerRadius::same(6), p.hover);
|
||||
}
|
||||
let painter = ui.painter_at(rect);
|
||||
let y = rect.center().y;
|
||||
painter.text(
|
||||
egui::pos2(rect.left() + 8.0, y),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
format!("{}", line.seq),
|
||||
theme::regular(12.0),
|
||||
p.faint,
|
||||
);
|
||||
painter.text(
|
||||
egui::pos2(rect.left() + 56.0, y),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
ev.icon,
|
||||
egui::FontId::proportional(15.0),
|
||||
p.muted,
|
||||
);
|
||||
painter.text(
|
||||
egui::pos2(rect.left() + 80.0, y),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
&ev.text,
|
||||
theme::regular(13.0),
|
||||
p.text,
|
||||
);
|
||||
painter.text(
|
||||
egui::pos2(rect.right() - 8.0, y),
|
||||
egui::Align2::RIGHT_CENTER,
|
||||
fmt::timestamp(&line.ts),
|
||||
theme::regular(12.0),
|
||||
p.faint,
|
||||
);
|
||||
if resp.clicked() {
|
||||
shell.go(Route::JournalEvent(line.seq));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn detail(ui: &mut Ui, b: &Books, line: &JournalLine, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
let ev = describe_any(b, line);
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("event_detail")
|
||||
.show(ui, |ui| {
|
||||
ui.label(
|
||||
RichText::new(format!("Event {}", line.seq))
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
ui.label(RichText::new(&ev.text).font(theme::semibold(17.0)));
|
||||
ui.add_space(10.0);
|
||||
let fact = |ui: &mut Ui, k: &str, v: &str| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(
|
||||
vec2(90.0, 18.0),
|
||||
egui::Label::new(
|
||||
RichText::new(k).font(theme::regular(12.5)).color(p.muted),
|
||||
),
|
||||
);
|
||||
ui.add(egui::Label::new(RichText::new(v).font(theme::regular(12.5))).wrap());
|
||||
});
|
||||
};
|
||||
fact(ui, "Type", &line.line_type);
|
||||
fact(ui, "Recorded", &fmt::timestamp(&line.ts));
|
||||
fact(ui, "Previous", &line.prev);
|
||||
let own = ledger_store::journal::sha256_hex(line.to_canonical().as_bytes());
|
||||
fact(ui, "This hash", &own);
|
||||
if let Some(r) = &ev.route {
|
||||
ui.add_space(6.0);
|
||||
if ui
|
||||
.button(format!(
|
||||
"{} Open {}",
|
||||
icon::ARROW_SQUARE_OUT,
|
||||
crate::palette::describe(b, r)
|
||||
))
|
||||
.clicked()
|
||||
{
|
||||
shell.go(r.clone());
|
||||
}
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
ui.label(RichText::new("Payload").font(theme::semibold(13.0)));
|
||||
ui.add_space(4.0);
|
||||
let json = serde_json::to_string_pretty(&line.payload).unwrap_or_default();
|
||||
egui::Frame::new()
|
||||
.fill(p.hover)
|
||||
.corner_radius(CornerRadius::same(8))
|
||||
.inner_margin(Margin::same(10))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.add(egui::Label::new(RichText::new(json).monospace().size(12.0)).wrap());
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//! One module per screen.
|
||||
|
||||
pub mod accounts;
|
||||
pub mod cash;
|
||||
pub mod company;
|
||||
pub mod documents;
|
||||
pub mod entry;
|
||||
pub mod journal;
|
||||
pub mod overview;
|
||||
pub mod profit;
|
||||
pub mod reports;
|
||||
pub mod transactions;
|
||||
|
||||
use crate::app::Shell;
|
||||
use crate::books::Books;
|
||||
use crate::nav::Route;
|
||||
use egui::{Margin, Ui};
|
||||
|
||||
/// Standard page body: scrollable, padded.
|
||||
pub fn page(ui: &mut Ui, id: &str, add: impl FnOnce(&mut Ui)) {
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt(id)
|
||||
.auto_shrink(false)
|
||||
.show(ui, |ui| {
|
||||
egui::Frame::new()
|
||||
.inner_margin(Margin {
|
||||
left: 32,
|
||||
right: 32,
|
||||
top: 24,
|
||||
bottom: 40,
|
||||
})
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
add(ui)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
pub fn show(ui: &mut Ui, b: &Books, route: &Route, shell: &mut Shell<'_>) {
|
||||
match route {
|
||||
Route::Overview => overview::show(ui, b, shell),
|
||||
Route::Cash => cash::show(ui, b, shell),
|
||||
Route::Profit => profit::show(ui, b, shell),
|
||||
Route::Transactions => transactions::show(ui, b, None, shell),
|
||||
Route::Entry(id) => transactions::show(ui, b, Some(*id), 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::Documents => documents::show(ui, b, None, shell),
|
||||
Route::Document(id) => documents::show(ui, b, Some(*id), shell),
|
||||
Route::Company => company::show(ui, b, shell),
|
||||
Route::Journal => journal::show(ui, b, None, shell),
|
||||
Route::JournalEvent(seq) => journal::show(ui, b, Some(*seq), shell),
|
||||
}
|
||||
}
|
||||
|
||||
/// Columns sized by `weights` when the page is at least `min_width`
|
||||
/// wide; stacked otherwise. `add(i, ui)` fills column `i`.
|
||||
pub fn columns(ui: &mut Ui, min_width: f32, weights: &[f32], mut add: impl FnMut(usize, &mut Ui)) {
|
||||
let gap = 16.0;
|
||||
if ui.available_width() < min_width {
|
||||
for i in 0..weights.len() {
|
||||
add(i, ui);
|
||||
ui.add_space(gap);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let total: f32 = weights.iter().sum();
|
||||
let avail = ui.available_width() - gap * (weights.len() as f32 - 1.0);
|
||||
let top = ui.cursor().min;
|
||||
let mut x = top.x;
|
||||
let mut bottom = top.y;
|
||||
for (i, w) in weights.iter().enumerate() {
|
||||
let width = avail * w / total;
|
||||
let rect =
|
||||
egui::Rect::from_min_size(egui::pos2(x, top.y), egui::vec2(width, f32::INFINITY));
|
||||
let mut child = ui.new_child(
|
||||
egui::UiBuilder::new()
|
||||
.max_rect(rect)
|
||||
.layout(egui::Layout::top_down(egui::Align::Min)),
|
||||
);
|
||||
child.set_width(width);
|
||||
add(i, &mut child);
|
||||
bottom = bottom.max(child.min_rect().bottom());
|
||||
x += width + gap;
|
||||
}
|
||||
let used = egui::Rect::from_min_max(top, egui::pos2(top.x + ui.available_width(), bottom));
|
||||
ui.advance_cursor_after_rect(used);
|
||||
ui.add_space(gap);
|
||||
}
|
||||
|
||||
/// A list on the left and a detail pane on the right, split by a
|
||||
/// draggable divider. The split is remembered per `id`.
|
||||
pub fn split<T: ?Sized>(
|
||||
ui: &mut Ui,
|
||||
id: &str,
|
||||
default_detail: f32,
|
||||
state: &mut T,
|
||||
left: impl FnOnce(&mut Ui, &mut T),
|
||||
right: impl FnOnce(&mut Ui, &mut T),
|
||||
) {
|
||||
let p = crate::theme::pal(ui.ctx());
|
||||
let rect = ui.available_rect_before_wrap();
|
||||
let key = egui::Id::new(("split", id));
|
||||
let mut detail_w: f32 = ui
|
||||
.ctx()
|
||||
.data_mut(|d| d.get_persisted(key))
|
||||
.unwrap_or(default_detail);
|
||||
detail_w = detail_w.clamp(360.0, (rect.width() - 380.0).max(360.0));
|
||||
let divider_x = rect.right() - detail_w;
|
||||
let left_rect = egui::Rect::from_min_max(rect.min, egui::pos2(divider_x, rect.bottom()));
|
||||
let right_rect = egui::Rect::from_min_max(egui::pos2(divider_x + 1.0, rect.top()), rect.max);
|
||||
ui.painter().rect_filled(right_rect, 0.0, p.surface);
|
||||
ui.painter().line_segment(
|
||||
[
|
||||
egui::pos2(divider_x, rect.top()),
|
||||
egui::pos2(divider_x, rect.bottom()),
|
||||
],
|
||||
egui::Stroke::new(1.0, p.border),
|
||||
);
|
||||
let mut l = ui.new_child(
|
||||
egui::UiBuilder::new()
|
||||
.max_rect(left_rect)
|
||||
.layout(egui::Layout::top_down(egui::Align::Min)),
|
||||
);
|
||||
l.set_clip_rect(left_rect);
|
||||
left(&mut l, state);
|
||||
let inner = right_rect.shrink2(egui::vec2(24.0, 20.0));
|
||||
let mut r = ui.new_child(
|
||||
egui::UiBuilder::new()
|
||||
.max_rect(inner)
|
||||
.layout(egui::Layout::top_down(egui::Align::Min)),
|
||||
);
|
||||
r.set_clip_rect(right_rect);
|
||||
right(&mut r, state);
|
||||
let handle = egui::Rect::from_center_size(
|
||||
egui::pos2(divider_x, rect.center().y),
|
||||
egui::vec2(8.0, rect.height()),
|
||||
);
|
||||
let resp = ui.interact(handle, key.with("handle"), egui::Sense::drag());
|
||||
if resp.hovered() || resp.dragged() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
|
||||
ui.painter().line_segment(
|
||||
[
|
||||
egui::pos2(divider_x, rect.top()),
|
||||
egui::pos2(divider_x, rect.bottom()),
|
||||
],
|
||||
egui::Stroke::new(2.0, p.accent),
|
||||
);
|
||||
}
|
||||
if resp.dragged() {
|
||||
detail_w -= resp.drag_delta().x;
|
||||
ui.ctx().data_mut(|d| d.insert_persisted(key, detail_w));
|
||||
}
|
||||
ui.allocate_rect(rect, egui::Sense::hover());
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
//! Home: the state of the business in one sentence, the numbers behind
|
||||
//! it, what needs doing, and what just happened.
|
||||
|
||||
use super::{columns, page};
|
||||
use crate::app::Shell;
|
||||
use crate::books::{Books, Severity};
|
||||
use crate::charts::{self, BarGroup};
|
||||
use crate::fmt;
|
||||
use crate::nav::{ReportKind, Route};
|
||||
use crate::theme::{self, pal};
|
||||
use crate::widgets::{self, Kpi};
|
||||
use egui::{RichText, Ui};
|
||||
use egui_phosphor::regular as icon;
|
||||
use ledger_core::analysis::{Interval, pnl_by_interval};
|
||||
use ledger_core::reports::profit_and_loss_for_period;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
page(ui, "overview", |ui| {
|
||||
let k = &b.kpis;
|
||||
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(
|
||||
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),
|
||||
});
|
||||
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),
|
||||
});
|
||||
recent(ui, b, shell);
|
||||
});
|
||||
}
|
||||
|
||||
/// The answer, in words.
|
||||
fn headline(b: &Books) -> String {
|
||||
let k = &b.kpis;
|
||||
let Some(first) = k.first_entry else {
|
||||
return "No transactions yet. Once your agent records some, this page fills in on its own."
|
||||
.into();
|
||||
};
|
||||
let mut s = format!(
|
||||
"You have {} in cash and owe {}.",
|
||||
fmt::dollars(k.cash),
|
||||
fmt::dollars(k.liabilities)
|
||||
);
|
||||
if let Some(burn) = k.monthly_burn {
|
||||
if burn > Decimal::ZERO && k.cash > Decimal::ZERO {
|
||||
let months = (k.cash / burn).round_dp(1);
|
||||
s.push_str(&format!(
|
||||
" Lately you spend {} a month more than you earn: about {} months of runway.",
|
||||
fmt::dollars(burn),
|
||||
months
|
||||
));
|
||||
}
|
||||
} else if k.net_ytd > Decimal::ZERO {
|
||||
s.push_str(&format!(
|
||||
" You've earned {} more than you've spent this year.",
|
||||
fmt::dollars(k.net_ytd)
|
||||
));
|
||||
} else if k.net_ytd < Decimal::ZERO {
|
||||
s.push_str(&format!(
|
||||
" You've spent {} more than you've earned this year.",
|
||||
fmt::dollars(-k.net_ytd)
|
||||
));
|
||||
}
|
||||
if (b.today - first).num_days() < 60 {
|
||||
s.push_str(&format!(
|
||||
" Your books start {}; trends fill in as the months go by.",
|
||||
fmt::date(first)
|
||||
));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn change(
|
||||
now: Decimal,
|
||||
prev: Decimal,
|
||||
up_is_good: bool,
|
||||
what: &str,
|
||||
) -> Option<(String, egui::Color32)> {
|
||||
let pct = fmt::pct_change(prev, now)?;
|
||||
let good = (pct >= 0.0) == up_is_good;
|
||||
let p = &theme::LIGHT;
|
||||
let arrow = if pct >= 0.0 { "▲" } else { "▼" };
|
||||
Some((
|
||||
format!("{arrow} {:.0}% vs {what}", pct.abs()),
|
||||
if pct.abs() < 0.5 {
|
||||
p.muted
|
||||
} else if good {
|
||||
p.positive
|
||||
} else {
|
||||
p.negative
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn kpis(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
let k = &b.kpis;
|
||||
let p = pal(ui.ctx());
|
||||
// Last 12 months of month-end cash for the sparkline.
|
||||
let (start, end) = b.trailing_months(12);
|
||||
let start = k.first_entry.map_or(start, |f| {
|
||||
start.max(ledger_core::analysis::interval_start(f, Interval::Month))
|
||||
});
|
||||
let month_ends: Vec<chrono::NaiveDate> =
|
||||
ledger_core::analysis::intervals(start, end, Interval::Month)
|
||||
.into_iter()
|
||||
.map(|(_, e)| e)
|
||||
.collect();
|
||||
let cash_spark: Vec<f64> =
|
||||
ledger_core::analysis::balance_series(&b.ledger, &b.cash_accounts, &month_ends)
|
||||
.into_iter()
|
||||
.map(|d| d.try_into().unwrap_or(0.0))
|
||||
.collect();
|
||||
let runway = match k.monthly_burn {
|
||||
Some(burn) if burn > Decimal::ZERO && k.cash > Decimal::ZERO => (
|
||||
format!("{} mo", (k.cash / burn).round_dp(1)),
|
||||
Some((format!("burning {}/mo", fmt::compact(burn)), p.warning)),
|
||||
),
|
||||
_ if k.first_entry.is_none() => ("—".to_string(), None),
|
||||
_ if k.first_entry.is_some_and(|f| f >= b.month_start()) => (
|
||||
"Too early".to_string(),
|
||||
Some(("needs a full month of books".to_string(), p.muted)),
|
||||
),
|
||||
_ => (
|
||||
"Not burning".to_string(),
|
||||
Some(("earning ≥ spending".to_string(), p.positive)),
|
||||
),
|
||||
};
|
||||
let ytd = b.fiscal_ytd();
|
||||
let tiles: Vec<(Kpi, Route)> = vec![
|
||||
(
|
||||
Kpi {
|
||||
label: "Cash on hand",
|
||||
value: fmt::dollars(k.cash),
|
||||
sub: change(k.cash, k.cash_30d_ago, true, "30 days ago")
|
||||
.map(|(t, c)| (t, color_for(c, p))),
|
||||
spark: Some(&cash_spark),
|
||||
how: "The balance of every cash account (bank, savings, cash on hand) at the end of today.",
|
||||
},
|
||||
Route::Cash,
|
||||
),
|
||||
(
|
||||
Kpi {
|
||||
label: "Owed by the business",
|
||||
value: fmt::dollars(k.liabilities),
|
||||
sub: Some(("bills, cards, owners, taxes".into(), p.muted)),
|
||||
spark: None,
|
||||
how: "Total liabilities on today's balance sheet.",
|
||||
},
|
||||
Route::Reports(ReportKind::BalanceSheet),
|
||||
),
|
||||
(
|
||||
Kpi {
|
||||
label: "Spending this month",
|
||||
value: fmt::dollars(k.expenses_mtd),
|
||||
sub: change(
|
||||
k.expenses_mtd,
|
||||
k.expenses_prev_month,
|
||||
false,
|
||||
"same days last month",
|
||||
)
|
||||
.map(|(t, c)| (t, color_for(c, p))),
|
||||
spark: None,
|
||||
how: "Expenses dated this month (accrual basis: when incurred, not when paid).",
|
||||
},
|
||||
Route::Profit,
|
||||
),
|
||||
(
|
||||
Kpi {
|
||||
label: "Revenue this month",
|
||||
value: fmt::dollars(k.revenue_mtd),
|
||||
sub: change(
|
||||
k.revenue_mtd,
|
||||
k.revenue_prev_month,
|
||||
true,
|
||||
"same days last month",
|
||||
)
|
||||
.map(|(t, c)| (t, color_for(c, p))),
|
||||
spark: None,
|
||||
how: "Revenue dated this month.",
|
||||
},
|
||||
Route::Profit,
|
||||
),
|
||||
(
|
||||
Kpi {
|
||||
label: "Net income, year to date",
|
||||
value: fmt::dollars(k.net_ytd),
|
||||
sub: Some((format!("since {}", fmt::date_short(ytd.0)), p.muted)),
|
||||
spark: None,
|
||||
how: "Revenue minus expenses for the fiscal year so far.",
|
||||
},
|
||||
Route::Reports(ReportKind::ProfitAndLoss),
|
||||
),
|
||||
(
|
||||
Kpi {
|
||||
label: "Runway",
|
||||
value: runway.0,
|
||||
sub: runway.1,
|
||||
spark: None,
|
||||
how: "Cash on hand divided by the average monthly net loss over the last three full months the books cover.",
|
||||
},
|
||||
Route::Cash,
|
||||
),
|
||||
];
|
||||
let n = if ui.available_width() > 1180.0 { 6 } else { 3 };
|
||||
for row in tiles.chunks(n) {
|
||||
let weights = vec![1.0; n];
|
||||
columns(ui, 0.0, &weights, |i, ui| {
|
||||
if let Some((tile, route)) = row.get(i) {
|
||||
let r = widgets::kpi(
|
||||
ui,
|
||||
Kpi {
|
||||
label: tile.label,
|
||||
value: tile.value.clone(),
|
||||
sub: tile.sub.clone(),
|
||||
spark: tile.spark,
|
||||
how: tile.how,
|
||||
},
|
||||
);
|
||||
if r.interact(egui::Sense::click()).clicked() {
|
||||
shell.go(route.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// `change` picks from the light palette; map to the current one.
|
||||
fn color_for(c: egui::Color32, p: &theme::Palette) -> egui::Color32 {
|
||||
let l = &theme::LIGHT;
|
||||
if c == l.positive {
|
||||
p.positive
|
||||
} else if c == l.negative {
|
||||
p.negative
|
||||
} else {
|
||||
p.muted
|
||||
}
|
||||
}
|
||||
|
||||
fn attention(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
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,
|
||||
);
|
||||
}
|
||||
},
|
||||
|ui| {
|
||||
if b.attention.is_empty() {
|
||||
widgets::empty(
|
||||
ui,
|
||||
icon::CHECK_CIRCLE,
|
||||
"All clear",
|
||||
"Nothing is missing or overdue.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("attention")
|
||||
.max_height(260.0)
|
||||
.show(ui, |ui| {
|
||||
for (i, a) in b.attention.iter().enumerate() {
|
||||
if i > 0 {
|
||||
ui.add_space(4.0);
|
||||
widgets::rule(ui);
|
||||
ui.add_space(4.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),
|
||||
};
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn income_vs_spending(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
let (start, end) = b.trailing_months(12);
|
||||
let start = b.kpis.first_entry.map_or(start, |f| {
|
||||
start.max(ledger_core::analysis::interval_start(f, Interval::Month))
|
||||
});
|
||||
let months = pnl_by_interval(&b.ledger, start, end, Interval::Month).unwrap_or_default();
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Income and spending by month",
|
||||
|_| {},
|
||||
|ui| {
|
||||
if months.is_empty() {
|
||||
widgets::empty(ui, icon::CHART_BAR, "No months yet", "");
|
||||
return;
|
||||
}
|
||||
let groups: Vec<BarGroup> = months
|
||||
.iter()
|
||||
.map(|m| BarGroup {
|
||||
label: m.start.format("%b").to_string(),
|
||||
values: vec![m.revenue, m.expenses, m.net()],
|
||||
})
|
||||
.collect();
|
||||
let clicked = charts::bar_chart(
|
||||
ui,
|
||||
&groups,
|
||||
&[
|
||||
("Income", p.series[1]),
|
||||
("Spending", p.series[2]),
|
||||
("Net", p.series[0]),
|
||||
],
|
||||
220.0,
|
||||
);
|
||||
if let Some(i) = clicked {
|
||||
shell.state.reports.range =
|
||||
crate::pages::reports::Range::Custom(months[i].start, months[i].end);
|
||||
shell.go(Route::Reports(ReportKind::ProfitAndLoss));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn where_it_went(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
let (start, end) = b.fiscal_ytd();
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Where the money went, this year",
|
||||
|ui| {
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"{} – {}",
|
||||
fmt::date_short(start),
|
||||
fmt::date_short(end)
|
||||
))
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
},
|
||||
|ui| {
|
||||
let Ok(pl) = profit_and_loss_for_period(&b.ledger, start, end, None) else {
|
||||
return;
|
||||
};
|
||||
let mut rows: Vec<(String, Decimal, String)> = pl
|
||||
.expenses
|
||||
.iter()
|
||||
.map(|l| (l.name.clone(), l.amount, l.code.clone()))
|
||||
.collect();
|
||||
rows.sort_by_key(|r| std::cmp::Reverse(r.1));
|
||||
rows.truncate(8);
|
||||
if rows.is_empty() {
|
||||
widgets::empty(ui, icon::CHART_BAR_HORIZONTAL, "No spending yet", "");
|
||||
return;
|
||||
}
|
||||
let bars: Vec<(String, Decimal)> = rows.iter().map(|r| (r.0.clone(), r.1)).collect();
|
||||
if let Some(i) = charts::hbars(ui, &bars, p.series[2])
|
||||
&& let Some(a) = b.ledger.chart().find_by_code(&rows[i].2)
|
||||
{
|
||||
shell.go(Route::Account(a.id));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn recent(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
let since_update = shell.since_update;
|
||||
let mut go = None;
|
||||
let (full, _) = widgets::titled_card(
|
||||
ui,
|
||||
"Recent activity",
|
||||
|ui| {
|
||||
widgets::link(
|
||||
ui,
|
||||
RichText::new("Full journal")
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.accent),
|
||||
)
|
||||
.clicked()
|
||||
},
|
||||
|ui| {
|
||||
let mut shown = 0;
|
||||
for (i, line) in b.journal.iter().enumerate().rev() {
|
||||
let Some(ev) = super::journal::describe(b, line) else {
|
||||
continue;
|
||||
};
|
||||
if shown == 12 {
|
||||
break;
|
||||
}
|
||||
shown += 1;
|
||||
let fresh = line.seq > b.fresh_from_seq && since_update < 8.0;
|
||||
let row = egui::Frame::new()
|
||||
.fill(if fresh {
|
||||
p.new_highlight
|
||||
} else {
|
||||
egui::Color32::TRANSPARENT
|
||||
})
|
||||
.corner_radius(egui::CornerRadius::same(6))
|
||||
.inner_margin(egui::Margin::symmetric(6, 3))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(ev.icon).size(15.0).color(p.muted));
|
||||
let r = widgets::link(
|
||||
ui,
|
||||
RichText::new(&ev.text).font(theme::regular(13.0)),
|
||||
);
|
||||
if r.clicked() {
|
||||
go =
|
||||
Some(ev.route.clone().unwrap_or(Route::JournalEvent(line.seq)));
|
||||
}
|
||||
ui.with_layout(
|
||||
egui::Layout::right_to_left(egui::Align::Center),
|
||||
|ui| {
|
||||
ui.label(
|
||||
RichText::new(fmt::timestamp(&line.ts))
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.faint),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
let _ = (row, i);
|
||||
}
|
||||
if shown == 0 {
|
||||
widgets::empty(ui, icon::CLOCK, "Nothing yet", "");
|
||||
}
|
||||
},
|
||||
);
|
||||
if full {
|
||||
go = Some(Route::Journal);
|
||||
}
|
||||
if let Some(r) = go {
|
||||
shell.go(r);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
//! Profit: are we making money, how is it trending, and what moved.
|
||||
//! Spending by category this month against the recent average, income by
|
||||
//! customer and by class.
|
||||
|
||||
use super::{columns, page};
|
||||
use crate::app::Shell;
|
||||
use crate::books::Books;
|
||||
use crate::charts::{self, BarGroup};
|
||||
use crate::fmt;
|
||||
use crate::nav::{ReportKind, Route};
|
||||
use crate::theme::{self, pal};
|
||||
use crate::widgets;
|
||||
use chrono::{Days, Months};
|
||||
use egui::{CornerRadius, RichText, Sense, Ui, vec2};
|
||||
use egui_phosphor::regular as icon;
|
||||
use ledger_core::analysis::{Interval, interval_start, pnl_by_interval};
|
||||
use ledger_core::reports::profit_and_loss_for_period;
|
||||
use rust_decimal::Decimal;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn pct(part: Decimal, whole: Decimal) -> Option<Decimal> {
|
||||
(!whole.is_zero()).then(|| (part / whole * Decimal::from(100)).round_dp(1))
|
||||
}
|
||||
|
||||
pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
page(ui, "profit", |ui| {
|
||||
let (ys, ye) = b.fiscal_ytd();
|
||||
let ytd = profit_and_loss_for_period(&b.ledger, ys, ye, None).ok();
|
||||
let (rev, exp) = ytd
|
||||
.as_ref()
|
||||
.map(|p| (p.total_revenue, p.total_expenses))
|
||||
.unwrap_or_default();
|
||||
let net = rev - exp;
|
||||
let margin = pct(net, rev);
|
||||
let sentence = match margin {
|
||||
Some(m) => format!(
|
||||
"This year: {} of income, {} of spending, {} {} — a {}% margin.",
|
||||
fmt::dollars(rev),
|
||||
fmt::dollars(exp),
|
||||
fmt::dollars(net.abs()),
|
||||
if net >= Decimal::ZERO {
|
||||
"profit"
|
||||
} else {
|
||||
"loss"
|
||||
},
|
||||
m
|
||||
),
|
||||
None if exp.is_zero() => "No income or spending recorded this year yet.".into(),
|
||||
None => format!(
|
||||
"This year: {} of spending and no income yet.",
|
||||
fmt::dollars(exp)
|
||||
),
|
||||
};
|
||||
widgets::page_header(ui, "Profit", &sentence);
|
||||
|
||||
let first = b.kpis.first_entry.unwrap_or(b.today);
|
||||
let (start, end) = b.trailing_months(12);
|
||||
let start = start.max(interval_start(first, Interval::Month));
|
||||
let months = pnl_by_interval(&b.ledger, start, end, Interval::Month).unwrap_or_default();
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"By month",
|
||||
|_| {},
|
||||
|ui| {
|
||||
if months.is_empty() {
|
||||
widgets::empty(ui, icon::CHART_BAR, "No months yet", "");
|
||||
return;
|
||||
}
|
||||
let groups: Vec<BarGroup> = months
|
||||
.iter()
|
||||
.map(|m| BarGroup {
|
||||
label: m.start.format("%b").to_string(),
|
||||
values: vec![m.revenue, m.expenses, m.net()],
|
||||
})
|
||||
.collect();
|
||||
if let Some(i) = charts::bar_chart(
|
||||
ui,
|
||||
&groups,
|
||||
&[
|
||||
("Income", p.series[1]),
|
||||
("Spending", p.series[2]),
|
||||
("Net", p.series[0]),
|
||||
],
|
||||
230.0,
|
||||
) {
|
||||
shell.state.reports.range =
|
||||
crate::pages::reports::Range::Custom(months[i].start, months[i].end);
|
||||
shell.go(Route::Reports(ReportKind::ProfitAndLoss));
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
// The same numbers as a table, with the margin.
|
||||
let w = ui.available_width();
|
||||
let cw = 130.0;
|
||||
let header = |ui: &mut Ui, cells: [&str; 5]| {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(w, 24.0), Sense::hover());
|
||||
ui.painter().text(
|
||||
r.left_center() + vec2(4.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
cells[0],
|
||||
theme::semibold(12.0),
|
||||
p.muted,
|
||||
);
|
||||
for (i, c) in cells[1..].iter().enumerate() {
|
||||
ui.painter().text(
|
||||
egui::pos2(r.right() - cw * (3 - i) as f32 - 4.0, r.center().y),
|
||||
egui::Align2::RIGHT_CENTER,
|
||||
*c,
|
||||
theme::semibold(12.0),
|
||||
p.muted,
|
||||
);
|
||||
}
|
||||
};
|
||||
header(ui, ["Month", "Income", "Spending", "Net", "Margin"]);
|
||||
for m in months.iter().rev() {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 26.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(4), p.hover);
|
||||
}
|
||||
ui.painter().text(
|
||||
r.left_center() + vec2(4.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
fmt::month(m.start),
|
||||
theme::regular(13.0),
|
||||
p.text,
|
||||
);
|
||||
let vals = [
|
||||
fmt::money(m.revenue),
|
||||
fmt::money(m.expenses),
|
||||
fmt::accounting(m.net()),
|
||||
pct(m.net(), m.revenue)
|
||||
.map(|x| format!("{x}%"))
|
||||
.unwrap_or_else(|| "—".into()),
|
||||
];
|
||||
for (i, v) in vals.iter().enumerate() {
|
||||
let color = if i == 2 && m.net() < Decimal::ZERO {
|
||||
p.negative
|
||||
} else {
|
||||
p.text
|
||||
};
|
||||
ui.painter().text(
|
||||
egui::pos2(r.right() - cw * (3 - i) as f32 - 4.0, r.center().y),
|
||||
egui::Align2::RIGHT_CENTER,
|
||||
v,
|
||||
theme::regular(13.0),
|
||||
color,
|
||||
);
|
||||
}
|
||||
if resp.clicked() {
|
||||
shell.state.reports.range =
|
||||
crate::pages::reports::Range::Custom(m.start, m.end);
|
||||
shell.go(Route::Reports(ReportKind::ProfitAndLoss));
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
ui.add_space(16.0);
|
||||
spending_changes(ui, b, shell);
|
||||
columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i {
|
||||
0 => by_customer(ui, b),
|
||||
_ => by_class(ui, b),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Each expense account: this month so far, last month, the three-month
|
||||
/// average, and how far this month is running from it.
|
||||
fn spending_changes(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
let ms = b.month_start();
|
||||
let prev =
|
||||
profit_and_loss_for_period(&b.ledger, ms - Months::new(1), ms - Days::new(1), None).ok();
|
||||
let three =
|
||||
profit_and_loss_for_period(&b.ledger, ms - Months::new(3), ms - Days::new(1), None).ok();
|
||||
let now = profit_and_loss_for_period(&b.ledger, ms, b.today, None).ok();
|
||||
let mut rows: BTreeMap<String, (String, Decimal, Decimal, Decimal)> = BTreeMap::new();
|
||||
let mut add = |s: &Option<ledger_core::ProfitAndLossStatement>, slot: usize| {
|
||||
for l in s.iter().flat_map(|s| &s.expenses) {
|
||||
let e = rows.entry(l.code.clone()).or_insert((
|
||||
l.name.clone(),
|
||||
Decimal::ZERO,
|
||||
Decimal::ZERO,
|
||||
Decimal::ZERO,
|
||||
));
|
||||
match slot {
|
||||
0 => e.1 = l.amount,
|
||||
1 => e.2 = l.amount,
|
||||
_ => e.3 = l.amount / Decimal::from(3),
|
||||
}
|
||||
}
|
||||
};
|
||||
add(&now, 0);
|
||||
add(&prev, 1);
|
||||
add(&three, 2);
|
||||
let mut rows: Vec<_> = rows.into_iter().collect();
|
||||
rows.sort_by_key(|r| std::cmp::Reverse(r.1.3.max(r.1.1)));
|
||||
let mut go = None;
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Spending by category",
|
||||
|ui| {
|
||||
ui.label(
|
||||
RichText::new("this month so far vs. the last three months")
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
},
|
||||
|ui| {
|
||||
if rows.is_empty() {
|
||||
widgets::empty(ui, icon::RECEIPT, "No spending yet", "");
|
||||
return;
|
||||
}
|
||||
let w = ui.available_width();
|
||||
let cw = 130.0;
|
||||
let (r, _) = ui.allocate_exact_size(vec2(w, 24.0), Sense::hover());
|
||||
for (i, c) in ["This month", "Last month", "3-month avg"]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
ui.painter().text(
|
||||
egui::pos2(r.right() - cw * (2 - i) as f32 - 4.0, r.center().y),
|
||||
egui::Align2::RIGHT_CENTER,
|
||||
*c,
|
||||
theme::semibold(12.0),
|
||||
p.muted,
|
||||
);
|
||||
}
|
||||
ui.painter().text(
|
||||
r.left_center() + vec2(4.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
"Account",
|
||||
theme::semibold(12.0),
|
||||
p.muted,
|
||||
);
|
||||
for (code, (name, now, last, avg)) in &rows {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 28.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(4), p.hover);
|
||||
}
|
||||
ui.painter().text(
|
||||
r.left_center() + vec2(4.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
format!("{code} {name}"),
|
||||
theme::regular(13.0),
|
||||
p.text,
|
||||
);
|
||||
// Flag a category already running well above its average.
|
||||
let hot = *avg > Decimal::ZERO && *now > *avg * Decimal::new(125, 2);
|
||||
let vals = [(*now, hot), (*last, false), (*avg, false)];
|
||||
for (i, (v, flag)) in vals.iter().enumerate() {
|
||||
let x = r.right() - cw * (2 - i) as f32 - 4.0;
|
||||
ui.painter().text(
|
||||
egui::pos2(x, r.center().y),
|
||||
egui::Align2::RIGHT_CENTER,
|
||||
fmt::money(v.round_dp(2)),
|
||||
theme::regular(13.0),
|
||||
if *flag { p.warning } else { p.text },
|
||||
);
|
||||
if *flag {
|
||||
ui.painter().text(
|
||||
egui::pos2(x - 86.0, r.center().y),
|
||||
egui::Align2::RIGHT_CENTER,
|
||||
icon::ARROW_UP_RIGHT,
|
||||
egui::FontId::proportional(13.0),
|
||||
p.warning,
|
||||
);
|
||||
}
|
||||
}
|
||||
if resp.clicked() {
|
||||
go = b.ledger.chart().find_by_code(code).map(|a| a.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
if let Some(id) = go {
|
||||
shell.state.accounts.range = Some((ms - Months::new(3), b.today));
|
||||
shell.go(Route::Account(id));
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
}
|
||||
|
||||
fn by_customer(ui: &mut Ui, b: &Books) {
|
||||
let p = pal(ui.ctx());
|
||||
let (ys, ye) = b.fiscal_ytd();
|
||||
let mut by: BTreeMap<String, Decimal> = BTreeMap::new();
|
||||
for id in 1..b.ledger.next_invoice_id() {
|
||||
if let Some(inv) = b.ledger.get_invoice(id)
|
||||
&& inv.date >= ys
|
||||
&& inv.date <= ye
|
||||
{
|
||||
let who = b
|
||||
.ledger
|
||||
.get_customer(inv.customer_id)
|
||||
.map(|c| c.name.clone())
|
||||
.unwrap_or_default();
|
||||
*by.entry(who).or_default() += inv.total();
|
||||
}
|
||||
}
|
||||
let mut rows: Vec<(String, Decimal)> = by.into_iter().collect();
|
||||
rows.sort_by_key(|r| std::cmp::Reverse(r.1));
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Invoiced by customer, this year",
|
||||
|_| {},
|
||||
|ui| {
|
||||
if rows.is_empty() {
|
||||
widgets::empty(
|
||||
ui,
|
||||
icon::USERS,
|
||||
"No invoices this year",
|
||||
"Income recorded without invoices doesn't show a customer.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
let total: Decimal = rows.iter().map(|r| r.1).sum();
|
||||
if let Some((top, v)) = rows.first() {
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"{top} is {}% of invoiced income.",
|
||||
pct(*v, total).unwrap_or_default()
|
||||
))
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
charts::hbars(ui, &rows, p.series[1]);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn by_class(ui: &mut Ui, b: &Books) {
|
||||
let p = pal(ui.ctx());
|
||||
let (ys, ye) = b.fiscal_ytd();
|
||||
let classes = b.ledger.list_classes();
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Net income by class, this year",
|
||||
|_| {},
|
||||
|ui| {
|
||||
if classes.is_empty() {
|
||||
widgets::empty(
|
||||
ui,
|
||||
icon::TAG,
|
||||
"No classes",
|
||||
"Tag lines with classes (departments, products) to see profit by each.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
let mut rows: Vec<(String, Decimal)> = classes
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
let pl = profit_and_loss_for_period(&b.ledger, ys, ye, Some(c.id)).ok()?;
|
||||
let net = pl.total_revenue - pl.total_expenses;
|
||||
(!pl.total_revenue.is_zero() || !pl.total_expenses.is_zero())
|
||||
.then(|| (c.name.clone(), net))
|
||||
})
|
||||
.collect();
|
||||
rows.sort_by_key(|r| std::cmp::Reverse(r.1));
|
||||
if rows.is_empty() {
|
||||
widgets::empty(
|
||||
ui,
|
||||
icon::TAG,
|
||||
"No classified income or spending this year",
|
||||
"",
|
||||
);
|
||||
return;
|
||||
}
|
||||
charts::hbars(ui, &rows, p.series[0]);
|
||||
ui.label(
|
||||
RichText::new("Only lines tagged with a class count here.")
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.faint),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,279 @@
|
||||
//! Every entry, searchable and filterable, with the selected one open
|
||||
//! beside the list: its lines, its receipt rendered, its history.
|
||||
|
||||
use super::page;
|
||||
use crate::app::Shell;
|
||||
use crate::books::{Books, EntryInfo};
|
||||
use crate::fmt;
|
||||
use crate::nav::Route;
|
||||
use crate::theme::{self, pal};
|
||||
use crate::widgets;
|
||||
use egui::{CornerRadius, RichText, Sense, Ui, vec2};
|
||||
use egui_phosphor::regular as icon;
|
||||
use ledger_core::{AccountId, EntryStatus};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum Filter {
|
||||
#[default]
|
||||
All,
|
||||
Posted,
|
||||
Drafts,
|
||||
NoReceipt,
|
||||
WithReceipt,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
pub query: String,
|
||||
pub filter: Filter,
|
||||
pub account: Option<AccountId>,
|
||||
}
|
||||
|
||||
pub fn matches(e: &EntryInfo, st: &State) -> bool {
|
||||
let f = match st.filter {
|
||||
Filter::All => true,
|
||||
Filter::Posted => e.status == EntryStatus::Posted,
|
||||
Filter::Drafts => e.status == EntryStatus::Draft,
|
||||
Filter::NoReceipt => e.receipts.is_empty(),
|
||||
Filter::WithReceipt => !e.receipts.is_empty(),
|
||||
};
|
||||
let a = st.account.is_none_or(|a| e.accounts.contains(&a));
|
||||
let q = st.query.trim().to_lowercase();
|
||||
let t = q.is_empty() || q.split_whitespace().all(|w| e.haystack.contains(w));
|
||||
f && a && t
|
||||
}
|
||||
|
||||
pub fn show(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
|
||||
match selected.and_then(|id| b.entry(id)) {
|
||||
Some(e) => {
|
||||
let w = (ui.available_width() * 0.5).max(420.0);
|
||||
super::split(
|
||||
ui,
|
||||
"transactions",
|
||||
w,
|
||||
shell,
|
||||
|ui, shell| list(ui, b, selected, shell),
|
||||
|ui, shell| super::entry::detail(ui, b, e, shell),
|
||||
);
|
||||
}
|
||||
None => list(ui, b, None, shell),
|
||||
}
|
||||
}
|
||||
|
||||
fn list(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
page(ui, "transactions", |ui| {
|
||||
let total = b.entries.len();
|
||||
widgets::page_header(
|
||||
ui,
|
||||
"Transactions",
|
||||
&format!(
|
||||
"{} recorded, newest first. Click one to see its lines, its receipt, and its history.",
|
||||
fmt::count(total, "entry", "entries")
|
||||
),
|
||||
);
|
||||
let st = &mut shell.state.transactions;
|
||||
let narrow = ui.available_width() < 720.0;
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut st.query)
|
||||
.hint_text(format!(
|
||||
"{} Search description, account, vendor, amount",
|
||||
icon::MAGNIFYING_GLASS
|
||||
))
|
||||
.desired_width(if narrow { ui.available_width() } else { 340.0 }),
|
||||
);
|
||||
if !narrow {
|
||||
filters(ui, b, st);
|
||||
}
|
||||
});
|
||||
if narrow {
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| filters(ui, b, st));
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
let rows: Vec<&EntryInfo> = b.entries.iter().filter(|e| matches(e, st)).collect();
|
||||
if rows.is_empty() {
|
||||
widgets::card(ui, |ui| {
|
||||
widgets::empty(
|
||||
ui,
|
||||
icon::MAGNIFYING_GLASS,
|
||||
"No matching entries",
|
||||
"Try a different search or filter.",
|
||||
)
|
||||
});
|
||||
return;
|
||||
}
|
||||
widgets::card(ui, |ui| {
|
||||
let mut last_month: Option<(i32, u32)> = None;
|
||||
for e in rows {
|
||||
let m = (
|
||||
chrono::Datelike::year(&e.date),
|
||||
chrono::Datelike::month(&e.date),
|
||||
);
|
||||
if last_month != Some(m) {
|
||||
if last_month.is_some() {
|
||||
ui.add_space(10.0);
|
||||
}
|
||||
last_month = Some(m);
|
||||
ui.label(
|
||||
RichText::new(fmt::month(e.date))
|
||||
.font(theme::semibold(12.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
ui.add_space(2.0);
|
||||
}
|
||||
if row(ui, b, e, selected == Some(e.id), shell.since_update).clicked() {
|
||||
shell.go(Route::Entry(e.id));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn row(ui: &mut Ui, b: &Books, e: &EntryInfo, on: bool, since_update: f64) -> egui::Response {
|
||||
let p = pal(ui.ctx());
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 50.0), Sense::click());
|
||||
let fresh = b.fresh.contains(&e.id) && since_update < 8.0;
|
||||
if on {
|
||||
ui.painter()
|
||||
.rect_filled(rect, CornerRadius::same(8), p.selected);
|
||||
} else if fresh {
|
||||
ui.painter()
|
||||
.rect_filled(rect, CornerRadius::same(8), p.new_highlight);
|
||||
} else if resp.hovered() {
|
||||
ui.painter()
|
||||
.rect_filled(rect, CornerRadius::same(8), p.hover);
|
||||
}
|
||||
let painter = ui.painter_at(rect);
|
||||
let left = rect.left() + 10.0;
|
||||
// Date block.
|
||||
painter.text(
|
||||
egui::pos2(left, rect.center().y - 8.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
e.date.format("%b").to_string().to_uppercase(),
|
||||
theme::semibold(10.5),
|
||||
p.faint,
|
||||
);
|
||||
painter.text(
|
||||
egui::pos2(left, rect.center().y + 8.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
e.date.format("%-d").to_string(),
|
||||
theme::semibold(16.0),
|
||||
p.text,
|
||||
);
|
||||
// Description and accounts.
|
||||
let text_left = left + 44.0;
|
||||
let amount_w = 120.0;
|
||||
let max_w = (rect.right() - amount_w - 70.0 - text_left).max(60.0);
|
||||
let desc = painter.layout(e.description.clone(), theme::semibold(13.5), p.text, max_w);
|
||||
let desc = if desc.rows.len() > 1 {
|
||||
painter.layout_no_wrap(
|
||||
truncate(&e.description, (max_w / 7.5) as usize),
|
||||
theme::semibold(13.5),
|
||||
p.text,
|
||||
)
|
||||
} else {
|
||||
desc
|
||||
};
|
||||
painter.galley(egui::pos2(text_left, rect.center().y - 17.0), desc, p.text);
|
||||
let accounts: Vec<String> = e.accounts.iter().map(|a| b.account_name(*a)).collect();
|
||||
let mut sub = format!("#{} · {} · {}", e.id, e.origin, accounts.join(" → "));
|
||||
if let Some(v) = &e.party {
|
||||
sub.push_str(&format!(" · {v}"));
|
||||
}
|
||||
let sub = truncate(&sub, (max_w / 6.3) as usize);
|
||||
painter.text(
|
||||
egui::pos2(text_left, rect.center().y + 10.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
sub,
|
||||
theme::regular(12.0),
|
||||
p.muted,
|
||||
);
|
||||
// Amount and badges.
|
||||
let right = rect.right() - 10.0;
|
||||
// Cash in reads green with a plus, cash out with a minus; entries that
|
||||
// don't move cash show their size, muted.
|
||||
let (amount, color) = if e.cash_effect > rust_decimal::Decimal::ZERO {
|
||||
(format!("+{}", fmt::money(e.cash_effect)), p.positive)
|
||||
} else if e.cash_effect < rust_decimal::Decimal::ZERO {
|
||||
(fmt::money(e.cash_effect), p.text)
|
||||
} else {
|
||||
(fmt::money(e.amount), p.muted)
|
||||
};
|
||||
painter.text(
|
||||
egui::pos2(right, rect.center().y - 6.0),
|
||||
egui::Align2::RIGHT_CENTER,
|
||||
amount,
|
||||
theme::semibold(14.0),
|
||||
color,
|
||||
);
|
||||
let mut badges: Vec<(&str, egui::Color32)> = Vec::new();
|
||||
if e.status == EntryStatus::Draft {
|
||||
badges.push(("DRAFT", p.warning));
|
||||
}
|
||||
if e.reversed_by.is_some() {
|
||||
badges.push(("REVERSED", p.muted));
|
||||
}
|
||||
if e.reversal_of.is_some() {
|
||||
badges.push(("REVERSAL", p.muted));
|
||||
}
|
||||
let mut x = right;
|
||||
for (text, color) in badges {
|
||||
let g = painter.layout_no_wrap(text.to_string(), theme::semibold(10.0), color);
|
||||
x -= g.size().x;
|
||||
painter.galley(egui::pos2(x, rect.center().y + 6.0), g, color);
|
||||
x -= 8.0;
|
||||
}
|
||||
let clip = if !e.receipts.is_empty() {
|
||||
Some((icon::PAPERCLIP, p.muted))
|
||||
} else if e.missing_receipt {
|
||||
Some((icon::WARNING_CIRCLE, p.negative))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some((g, c)) = clip {
|
||||
painter.text(
|
||||
egui::pos2(rect.right() - amount_w - 24.0, rect.center().y),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
g,
|
||||
egui::FontId::proportional(16.0),
|
||||
c,
|
||||
);
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
fn truncate(s: &str, n: usize) -> String {
|
||||
if s.chars().count() <= n {
|
||||
s.to_string()
|
||||
} else {
|
||||
let t: String = s.chars().take(n.saturating_sub(1)).collect();
|
||||
format!("{t}…")
|
||||
}
|
||||
}
|
||||
|
||||
fn filters(ui: &mut Ui, b: &Books, st: &mut State) {
|
||||
widgets::segmented(
|
||||
ui,
|
||||
&mut st.filter,
|
||||
&[
|
||||
(Filter::All, "All"),
|
||||
(Filter::Posted, "Posted"),
|
||||
(Filter::Drafts, "Drafts"),
|
||||
(Filter::WithReceipt, "With receipt"),
|
||||
(Filter::NoReceipt, "No receipt"),
|
||||
],
|
||||
);
|
||||
if let Some(a) = st.account {
|
||||
ui.add_space(6.0);
|
||||
let label = format!("{} {} {}", icon::FUNNEL, b.account_label(a), icon::X);
|
||||
if ui
|
||||
.button(label)
|
||||
.on_hover_text("Clear the account filter")
|
||||
.clicked()
|
||||
{
|
||||
st.account = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
//! Ctrl+K: jump to anything. Pages, reports, accounts, entries (by text,
|
||||
//! amount, or #id), vendors, and documents, ranked by a simple fuzzy match.
|
||||
|
||||
use crate::books::Books;
|
||||
use crate::fmt;
|
||||
use crate::nav::{ReportKind, Route, Section};
|
||||
use crate::theme::{self, pal};
|
||||
use egui::{CornerRadius, Key, Margin, RichText, Sense, Stroke, vec2};
|
||||
use egui_phosphor::regular as icon;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Palette {
|
||||
open: bool,
|
||||
query: String,
|
||||
selected: usize,
|
||||
focus: bool,
|
||||
}
|
||||
|
||||
struct Item {
|
||||
icon: &'static str,
|
||||
title: String,
|
||||
detail: String,
|
||||
route: Route,
|
||||
score: i32,
|
||||
}
|
||||
|
||||
impl Palette {
|
||||
pub fn open(&mut self) {
|
||||
self.open = true;
|
||||
self.query.clear();
|
||||
self.selected = 0;
|
||||
self.focus = true;
|
||||
}
|
||||
|
||||
/// Show the palette if open; returns where to go.
|
||||
pub fn show(&mut self, ctx: &egui::Context, books: &Books) -> Option<Route> {
|
||||
if !self.open {
|
||||
return None;
|
||||
}
|
||||
let p = pal(ctx);
|
||||
let items = search(books, &self.query);
|
||||
let mut go = None;
|
||||
let (up, down, enter, esc) = ctx.input(|i| {
|
||||
(
|
||||
i.key_pressed(Key::ArrowUp),
|
||||
i.key_pressed(Key::ArrowDown),
|
||||
i.key_pressed(Key::Enter),
|
||||
i.key_pressed(Key::Escape),
|
||||
)
|
||||
});
|
||||
if down {
|
||||
self.selected = (self.selected + 1).min(items.len().saturating_sub(1));
|
||||
}
|
||||
if up {
|
||||
self.selected = self.selected.saturating_sub(1);
|
||||
}
|
||||
if esc {
|
||||
self.open = false;
|
||||
return None;
|
||||
}
|
||||
if enter && let Some(it) = items.get(self.selected) {
|
||||
self.open = false;
|
||||
return Some(it.route.clone());
|
||||
}
|
||||
let modal = egui::Modal::new(egui::Id::new("palette"))
|
||||
.frame(
|
||||
egui::Frame::new()
|
||||
.fill(p.surface)
|
||||
.stroke(Stroke::new(1.0, p.border))
|
||||
.corner_radius(CornerRadius::same(14))
|
||||
.inner_margin(Margin::same(10)),
|
||||
)
|
||||
.show(ctx, |ui| {
|
||||
ui.set_width(620.0);
|
||||
let edit = egui::TextEdit::singleline(&mut self.query)
|
||||
.hint_text("Search entries, accounts, vendors, documents, reports…")
|
||||
.font(theme::regular(16.0))
|
||||
.frame(egui::Frame::NONE)
|
||||
.desired_width(f32::INFINITY);
|
||||
let r = ui.add(edit);
|
||||
if self.focus {
|
||||
r.request_focus();
|
||||
self.focus = false;
|
||||
}
|
||||
if r.changed() {
|
||||
self.selected = 0;
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
crate::widgets::rule(ui);
|
||||
ui.add_space(4.0);
|
||||
egui::ScrollArea::vertical()
|
||||
.max_height(420.0)
|
||||
.show(ui, |ui| {
|
||||
if items.is_empty() {
|
||||
ui.label(RichText::new("Nothing matches.").color(p.muted));
|
||||
}
|
||||
for (i, it) in items.iter().enumerate() {
|
||||
let (rect, resp) = ui.allocate_exact_size(
|
||||
vec2(ui.available_width(), 40.0),
|
||||
Sense::click(),
|
||||
);
|
||||
if i == self.selected || resp.hovered() {
|
||||
ui.painter()
|
||||
.rect_filled(rect, CornerRadius::same(8), p.hover);
|
||||
}
|
||||
if i == self.selected {
|
||||
ui.scroll_to_rect(rect, None);
|
||||
}
|
||||
let painter = ui.painter_at(rect);
|
||||
painter.text(
|
||||
rect.left_center() + vec2(12.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
it.icon,
|
||||
egui::FontId::proportional(16.0),
|
||||
p.muted,
|
||||
);
|
||||
painter.text(
|
||||
rect.left_center() + vec2(38.0, -7.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
&it.title,
|
||||
theme::regular(14.0),
|
||||
p.text,
|
||||
);
|
||||
painter.text(
|
||||
rect.left_center() + vec2(38.0, 9.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
&it.detail,
|
||||
theme::regular(11.5),
|
||||
p.faint,
|
||||
);
|
||||
if resp.clicked() {
|
||||
go = Some(it.route.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
if modal.should_close() {
|
||||
self.open = false;
|
||||
}
|
||||
if go.is_some() {
|
||||
self.open = false;
|
||||
}
|
||||
go
|
||||
}
|
||||
}
|
||||
|
||||
/// Subsequence match: every query character in order, rewarding
|
||||
/// contiguous runs and word starts. None if no match.
|
||||
fn fuzzy(hay: &str, query: &str) -> Option<i32> {
|
||||
if query.is_empty() {
|
||||
return Some(0);
|
||||
}
|
||||
let hay: Vec<char> = hay.to_lowercase().chars().collect();
|
||||
let mut score = 0;
|
||||
let mut hi = 0;
|
||||
let mut prev_match = false;
|
||||
for qc in query.to_lowercase().chars() {
|
||||
if qc == ' ' {
|
||||
prev_match = false;
|
||||
continue;
|
||||
}
|
||||
let mut found = false;
|
||||
while hi < hay.len() {
|
||||
let c = hay[hi];
|
||||
hi += 1;
|
||||
if c == qc {
|
||||
score += if prev_match { 6 } else { 1 };
|
||||
if hi == 1 || !hay[hi - 2].is_alphanumeric() {
|
||||
score += 4;
|
||||
}
|
||||
prev_match = true;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
prev_match = false;
|
||||
}
|
||||
if !found {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(score)
|
||||
}
|
||||
|
||||
fn search(b: &Books, query: &str) -> Vec<Item> {
|
||||
let q = query.trim();
|
||||
let mut out: Vec<Item> = Vec::new();
|
||||
let mut add = |icon: &'static str, title: String, detail: String, route: Route, hay: &str| {
|
||||
if let Some(score) = fuzzy(hay, q) {
|
||||
out.push(Item {
|
||||
icon,
|
||||
title,
|
||||
detail,
|
||||
route,
|
||||
score,
|
||||
});
|
||||
}
|
||||
};
|
||||
for s in Section::ALL {
|
||||
add(
|
||||
s.icon(),
|
||||
s.title().into(),
|
||||
"Page".into(),
|
||||
s.home(),
|
||||
s.title(),
|
||||
);
|
||||
}
|
||||
for r in ReportKind::ALL {
|
||||
add(
|
||||
icon::FILE_TEXT,
|
||||
r.title().into(),
|
||||
"Report".into(),
|
||||
Route::Reports(r),
|
||||
&format!("report {}", r.title()),
|
||||
);
|
||||
}
|
||||
if !q.is_empty() {
|
||||
for a in b.ledger.chart().all() {
|
||||
add(
|
||||
icon::TREE_STRUCTURE,
|
||||
format!("{} {}", a.code, a.name),
|
||||
format!("{:?} account", a.account_type),
|
||||
Route::Account(a.id),
|
||||
&format!("{} {}", a.code, a.name),
|
||||
);
|
||||
}
|
||||
let id_query = q.strip_prefix('#').and_then(|s| s.parse::<u64>().ok());
|
||||
for e in &b.entries {
|
||||
let exact = id_query == Some(e.id);
|
||||
let hay = &e.haystack;
|
||||
let score = if exact {
|
||||
Some(1000)
|
||||
} else {
|
||||
fuzzy(hay, q).filter(|_| q.len() >= 2)
|
||||
};
|
||||
if let Some(score) = score {
|
||||
// Prefer substring hits for entries: fuzzy over long text is noisy.
|
||||
let bonus = if hay.contains(&q.to_lowercase()) {
|
||||
50
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if bonus == 0 && !exact && q.len() < 4 {
|
||||
continue;
|
||||
}
|
||||
out.push(Item {
|
||||
icon: icon::RECEIPT,
|
||||
title: format!("#{} {}", e.id, e.description),
|
||||
detail: format!("{} · {}", fmt::date(e.date), fmt::dollars(e.amount)),
|
||||
route: Route::Entry(e.id),
|
||||
score: score + bonus,
|
||||
});
|
||||
}
|
||||
}
|
||||
for v in b.ledger.list_vendors() {
|
||||
if let Some(score) = fuzzy(&v.name, q) {
|
||||
out.push(Item {
|
||||
icon: icon::STOREFRONT,
|
||||
title: v.name.clone(),
|
||||
detail: "Vendor".into(),
|
||||
route: Route::Company,
|
||||
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) {
|
||||
out.push(Item {
|
||||
icon: icon::FILE,
|
||||
title: a.file_name.clone(),
|
||||
detail: format!(
|
||||
"{} · {}",
|
||||
fmt::label(&a.category),
|
||||
a.description.as_deref().unwrap_or("")
|
||||
),
|
||||
route: Route::Document(a.id),
|
||||
score,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_by_key(|i| std::cmp::Reverse(i.score));
|
||||
out.truncate(40);
|
||||
out
|
||||
}
|
||||
|
||||
/// A short description of a route for the breadcrumb.
|
||||
pub fn describe(b: &Books, r: &Route) -> String {
|
||||
match r {
|
||||
Route::Entry(id) => b
|
||||
.entry(*id)
|
||||
.map(|e| format!("#{} {}", e.id, e.description))
|
||||
.unwrap_or_default(),
|
||||
Route::Reports(k) => k.title().into(),
|
||||
Route::Account(id) => b.account_label(*id),
|
||||
Route::Document(id) => b
|
||||
.attachments
|
||||
.iter()
|
||||
.find(|a| a.id == *id)
|
||||
.map(|a| a.file_name.clone())
|
||||
.unwrap_or_default(),
|
||||
Route::JournalEvent(seq) => format!("Event {seq}"),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
//! Rendering receipts and documents to textures, off the UI thread.
|
||||
//!
|
||||
//! PDFs are rasterized with `hayro` (pure Rust), images decoded with
|
||||
//! `image`. Results are cached by blob hash: the same bytes never render
|
||||
//! twice, and a hash is the content, so the cache never goes stale. Bytes
|
||||
//! are read through the verifying blob store: a corrupt blob shows as an
|
||||
//! error, never as a picture.
|
||||
|
||||
use egui::{ColorImage, Context, TextureHandle, TextureOptions};
|
||||
use ledger_store::BlobStore;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
|
||||
pub enum State {
|
||||
Loading,
|
||||
Ready(Vec<TextureHandle>),
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
struct Job {
|
||||
sha: String,
|
||||
mime: String,
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
struct Done {
|
||||
sha: String,
|
||||
result: Result<Vec<ColorImage>, String>,
|
||||
}
|
||||
|
||||
pub struct Receipts {
|
||||
cache: HashMap<String, State>,
|
||||
jobs: Sender<Job>,
|
||||
done: Receiver<Done>,
|
||||
}
|
||||
|
||||
impl Receipts {
|
||||
pub fn new(ctx: &Context) -> Self {
|
||||
let (jobs, job_rx) = channel::<Job>();
|
||||
let (done_tx, done) = channel();
|
||||
let job_rx = std::sync::Arc::new(std::sync::Mutex::new(job_rx));
|
||||
let workers = std::thread::available_parallelism().map_or(2, |n| n.get().clamp(2, 4));
|
||||
for i in 0..workers {
|
||||
let ctx = ctx.clone();
|
||||
let job_rx = job_rx.clone();
|
||||
let done_tx = done_tx.clone();
|
||||
std::thread::Builder::new()
|
||||
.name(format!("receipt-render-{i}"))
|
||||
.spawn(move || {
|
||||
loop {
|
||||
let job = match job_rx.lock() {
|
||||
Ok(rx) => rx.recv(),
|
||||
Err(_) => return,
|
||||
};
|
||||
let Ok(job) = job else {
|
||||
return;
|
||||
};
|
||||
let result = render(&job);
|
||||
let _ = done_tx.send(Done {
|
||||
sha: job.sha,
|
||||
result,
|
||||
});
|
||||
ctx.request_repaint();
|
||||
}
|
||||
})
|
||||
.expect("spawn renderer");
|
||||
}
|
||||
Self {
|
||||
cache: HashMap::new(),
|
||||
jobs,
|
||||
done,
|
||||
}
|
||||
}
|
||||
|
||||
/// The rendering of a blob, starting it if needed.
|
||||
pub fn get(&mut self, sha: &str, mime: &str, root: PathBuf) -> &State {
|
||||
if !self.cache.contains_key(sha) {
|
||||
self.cache.insert(sha.to_string(), State::Loading);
|
||||
let _ = self.jobs.send(Job {
|
||||
sha: sha.to_string(),
|
||||
mime: mime.to_string(),
|
||||
root,
|
||||
});
|
||||
}
|
||||
&self.cache[sha]
|
||||
}
|
||||
|
||||
/// Turn finished renders into textures. Call once per frame.
|
||||
pub fn pump(&mut self, ctx: &Context) {
|
||||
while let Ok(done) = self.done.try_recv() {
|
||||
self.accept(ctx, done);
|
||||
}
|
||||
}
|
||||
|
||||
fn accept(&mut self, ctx: &Context, done: Done) {
|
||||
let state = match done.result {
|
||||
Ok(images) => State::Ready(
|
||||
images
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, img)| {
|
||||
ctx.load_texture(
|
||||
format!("blob-{}-{i}", done.sha),
|
||||
img,
|
||||
TextureOptions::LINEAR,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
Err(e) => State::Failed(e),
|
||||
};
|
||||
self.cache.insert(done.sha, state);
|
||||
}
|
||||
|
||||
/// Wait until nothing is rendering (tests and screenshots).
|
||||
pub fn settle(&mut self, ctx: &Context) {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
|
||||
while self.cache.values().any(|s| matches!(s, State::Loading))
|
||||
&& std::time::Instant::now() < deadline
|
||||
{
|
||||
if let Ok(done) = self
|
||||
.done
|
||||
.recv_timeout(std::time::Duration::from_millis(100))
|
||||
{
|
||||
self.accept(ctx, done);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render(job: &Job) -> Result<Vec<ColorImage>, String> {
|
||||
let bytes = BlobStore::open_read_only(&job.root)
|
||||
.get(&job.sha)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let is_pdf = job.mime == "application/pdf" || bytes.starts_with(b"%PDF");
|
||||
if is_pdf {
|
||||
render_pdf(bytes)
|
||||
} else if job.mime.starts_with("image/") {
|
||||
let img = image::load_from_memory(&bytes).map_err(|e| e.to_string())?;
|
||||
Ok(vec![to_color_image(img.to_rgba8())])
|
||||
} else {
|
||||
Err(format!("{} can't be previewed", job.mime))
|
||||
}
|
||||
}
|
||||
|
||||
fn render_pdf(bytes: Vec<u8>) -> Result<Vec<ColorImage>, String> {
|
||||
use hayro::hayro_interpret::InterpreterSettings;
|
||||
use hayro::hayro_syntax::Pdf;
|
||||
use hayro::vello_cpu::color::palette::css::WHITE;
|
||||
use hayro::{PixmapSettings, RenderCache, RenderSettings, render};
|
||||
let pdf = Pdf::new(bytes).map_err(|e| format!("not a readable PDF: {e:?}"))?;
|
||||
let cache = RenderCache::new();
|
||||
let interp = InterpreterSettings::default();
|
||||
let settings = RenderSettings::default();
|
||||
let px = PixmapSettings {
|
||||
x_scale: 2.0,
|
||||
y_scale: 2.0,
|
||||
bg_color: WHITE,
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for page in pdf.pages().iter().take(30) {
|
||||
let pixmap = render(page, &cache, &interp, &settings, &px);
|
||||
let png = pixmap.into_png().map_err(|e| format!("{e:?}"))?;
|
||||
let img = image::load_from_memory(&png).map_err(|e| e.to_string())?;
|
||||
out.push(to_color_image(img.to_rgba8()));
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err("the PDF has no pages".into());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn to_color_image(img: image::RgbaImage) -> ColorImage {
|
||||
let size = [img.width() as usize, img.height() as usize];
|
||||
ColorImage::from_rgba_unmultiplied(size, img.as_raw())
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
//! Look and feel: one palette per mode, Inter with tabular figures, and
|
||||
//! the spacing the whole app shares.
|
||||
|
||||
use egui::{
|
||||
Color32, Context, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Margin, Stroke,
|
||||
TextStyle, Visuals,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Colors for one mode. Every color in the app comes from here.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Palette {
|
||||
pub bg: Color32,
|
||||
pub surface: Color32,
|
||||
pub sidebar: Color32,
|
||||
pub hover: Color32,
|
||||
pub selected: Color32,
|
||||
pub border: Color32,
|
||||
pub text: Color32,
|
||||
pub muted: Color32,
|
||||
pub faint: Color32,
|
||||
pub accent: Color32,
|
||||
pub accent_soft: Color32,
|
||||
pub positive: Color32,
|
||||
pub negative: Color32,
|
||||
pub warning: Color32,
|
||||
pub warning_soft: Color32,
|
||||
pub negative_soft: Color32,
|
||||
pub positive_soft: Color32,
|
||||
pub new_highlight: Color32,
|
||||
/// Chart series, in order.
|
||||
pub series: [Color32; 6],
|
||||
}
|
||||
|
||||
pub const LIGHT: Palette = Palette {
|
||||
bg: Color32::from_rgb(0xF6, 0xF6, 0xF4),
|
||||
surface: Color32::from_rgb(0xFF, 0xFF, 0xFF),
|
||||
sidebar: Color32::from_rgb(0xEF, 0xEF, 0xEC),
|
||||
hover: Color32::from_rgb(0xF2, 0xF2, 0xF0),
|
||||
selected: Color32::from_rgb(0xE8, 0xE9, 0xFB),
|
||||
border: Color32::from_rgb(0xE4, 0xE4, 0xE0),
|
||||
text: Color32::from_rgb(0x1B, 0x1B, 0x1F),
|
||||
muted: Color32::from_rgb(0x67, 0x67, 0x70),
|
||||
faint: Color32::from_rgb(0x9C, 0x9C, 0xA4),
|
||||
accent: Color32::from_rgb(0x4F, 0x46, 0xE5),
|
||||
accent_soft: Color32::from_rgb(0xEE, 0xEE, 0xFD),
|
||||
positive: Color32::from_rgb(0x15, 0x80, 0x3D),
|
||||
negative: Color32::from_rgb(0xB9, 0x1C, 0x1C),
|
||||
warning: Color32::from_rgb(0xB4, 0x53, 0x09),
|
||||
warning_soft: Color32::from_rgb(0xFE, 0xF3, 0xE2),
|
||||
negative_soft: Color32::from_rgb(0xFD, 0xEC, 0xEC),
|
||||
positive_soft: Color32::from_rgb(0xE8, 0xF5, 0xEC),
|
||||
new_highlight: Color32::from_rgb(0xFF, 0xF7, 0xD6),
|
||||
series: [
|
||||
Color32::from_rgb(0x4F, 0x46, 0xE5),
|
||||
Color32::from_rgb(0x0D, 0x94, 0x88),
|
||||
Color32::from_rgb(0xD9, 0x77, 0x06),
|
||||
Color32::from_rgb(0xDB, 0x27, 0x77),
|
||||
Color32::from_rgb(0x25, 0x63, 0xEB),
|
||||
Color32::from_rgb(0x64, 0x74, 0x8B),
|
||||
],
|
||||
};
|
||||
|
||||
pub const DARK: Palette = Palette {
|
||||
bg: Color32::from_rgb(0x11, 0x12, 0x14),
|
||||
surface: Color32::from_rgb(0x19, 0x1A, 0x1D),
|
||||
sidebar: Color32::from_rgb(0x15, 0x16, 0x18),
|
||||
hover: Color32::from_rgb(0x22, 0x23, 0x27),
|
||||
selected: Color32::from_rgb(0x26, 0x27, 0x45),
|
||||
border: Color32::from_rgb(0x2A, 0x2B, 0x30),
|
||||
text: Color32::from_rgb(0xEC, 0xEC, 0xEE),
|
||||
muted: Color32::from_rgb(0xA3, 0xA3, 0xAC),
|
||||
faint: Color32::from_rgb(0x6E, 0x6E, 0x78),
|
||||
accent: Color32::from_rgb(0x8B, 0x93, 0xFA),
|
||||
accent_soft: Color32::from_rgb(0x24, 0x25, 0x45),
|
||||
positive: Color32::from_rgb(0x4A, 0xDE, 0x80),
|
||||
negative: Color32::from_rgb(0xF8, 0x71, 0x71),
|
||||
warning: Color32::from_rgb(0xFB, 0xBF, 0x24),
|
||||
warning_soft: Color32::from_rgb(0x33, 0x29, 0x16),
|
||||
negative_soft: Color32::from_rgb(0x3A, 0x1C, 0x1E),
|
||||
positive_soft: Color32::from_rgb(0x16, 0x30, 0x22),
|
||||
new_highlight: Color32::from_rgb(0x36, 0x31, 0x18),
|
||||
series: [
|
||||
Color32::from_rgb(0x8B, 0x93, 0xFA),
|
||||
Color32::from_rgb(0x2D, 0xD4, 0xBF),
|
||||
Color32::from_rgb(0xFB, 0xBF, 0x24),
|
||||
Color32::from_rgb(0xF4, 0x72, 0xB6),
|
||||
Color32::from_rgb(0x60, 0xA5, 0xFA),
|
||||
Color32::from_rgb(0x94, 0xA3, 0xB8),
|
||||
],
|
||||
};
|
||||
|
||||
/// The palette for the current mode.
|
||||
pub fn pal(ctx: &Context) -> &'static Palette {
|
||||
if ctx.global_style().visuals.dark_mode {
|
||||
&DARK
|
||||
} else {
|
||||
&LIGHT
|
||||
}
|
||||
}
|
||||
|
||||
pub const SEMIBOLD: &str = "semibold";
|
||||
pub const BOLD: &str = "bold";
|
||||
|
||||
pub fn regular(size: f32) -> FontId {
|
||||
FontId::new(size, FontFamily::Proportional)
|
||||
}
|
||||
|
||||
pub fn semibold(size: f32) -> FontId {
|
||||
FontId::new(size, FontFamily::Name(SEMIBOLD.into()))
|
||||
}
|
||||
|
||||
pub fn bold(size: f32) -> FontId {
|
||||
FontId::new(size, FontFamily::Name(BOLD.into()))
|
||||
}
|
||||
|
||||
/// Install fonts: Inter with tabular figures frozen in, so every number in
|
||||
/// the app lines up digit for digit. Phosphor icons ride along as a
|
||||
/// fallback in every family.
|
||||
pub fn install_fonts(ctx: &Context) {
|
||||
let mut fonts = FontDefinitions::default();
|
||||
let add = |fonts: &mut FontDefinitions, name: &str, bytes: &'static [u8]| {
|
||||
fonts
|
||||
.font_data
|
||||
.insert(name.to_owned(), Arc::new(FontData::from_static(bytes)));
|
||||
};
|
||||
add(
|
||||
&mut fonts,
|
||||
"InterTab-Regular",
|
||||
include_bytes!("../assets/fonts/InterTab-Regular.ttf"),
|
||||
);
|
||||
add(
|
||||
&mut fonts,
|
||||
"InterTab-SemiBold",
|
||||
include_bytes!("../assets/fonts/InterTab-SemiBold.ttf"),
|
||||
);
|
||||
add(
|
||||
&mut fonts,
|
||||
"InterTab-Bold",
|
||||
include_bytes!("../assets/fonts/InterTab-Bold.ttf"),
|
||||
);
|
||||
egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular);
|
||||
let fallbacks: Vec<String> = fonts
|
||||
.families
|
||||
.get(&FontFamily::Proportional)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
// Text first, icons as a fallback. The bundled Inter copies have their
|
||||
// private-use glyphs removed, so Phosphor's icons (which live there)
|
||||
// are never shadowed.
|
||||
let family = |primary: &str| {
|
||||
let mut v = vec![primary.to_owned()];
|
||||
v.extend(fallbacks.iter().cloned());
|
||||
v
|
||||
};
|
||||
fonts
|
||||
.families
|
||||
.insert(FontFamily::Proportional, family("InterTab-Regular"));
|
||||
fonts.families.insert(
|
||||
FontFamily::Name(SEMIBOLD.into()),
|
||||
family("InterTab-SemiBold"),
|
||||
);
|
||||
fonts
|
||||
.families
|
||||
.insert(FontFamily::Name(BOLD.into()), family("InterTab-Bold"));
|
||||
ctx.set_fonts(fonts);
|
||||
}
|
||||
|
||||
/// Apply the app's style for light or dark mode.
|
||||
pub fn apply(ctx: &Context, dark: bool) {
|
||||
let p = if dark { &DARK } else { &LIGHT };
|
||||
let mut visuals = if dark {
|
||||
Visuals::dark()
|
||||
} else {
|
||||
Visuals::light()
|
||||
};
|
||||
visuals.panel_fill = p.bg;
|
||||
visuals.window_fill = p.surface;
|
||||
visuals.extreme_bg_color = p.surface;
|
||||
visuals.faint_bg_color = p.hover;
|
||||
visuals.override_text_color = Some(p.text);
|
||||
visuals.hyperlink_color = p.accent;
|
||||
visuals.selection.bg_fill = p.selected;
|
||||
visuals.selection.stroke = Stroke::new(1.0, p.accent);
|
||||
visuals.window_stroke = Stroke::new(1.0, p.border);
|
||||
visuals.window_corner_radius = CornerRadius::same(12);
|
||||
visuals.menu_corner_radius = CornerRadius::same(10);
|
||||
for w in [
|
||||
&mut visuals.widgets.noninteractive,
|
||||
&mut visuals.widgets.inactive,
|
||||
&mut visuals.widgets.hovered,
|
||||
&mut visuals.widgets.active,
|
||||
&mut visuals.widgets.open,
|
||||
] {
|
||||
w.corner_radius = CornerRadius::same(8);
|
||||
}
|
||||
visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0, p.border);
|
||||
visuals.widgets.noninteractive.fg_stroke = Stroke::new(1.0, p.text);
|
||||
visuals.widgets.inactive.bg_fill = p.surface;
|
||||
visuals.widgets.inactive.weak_bg_fill = p.surface;
|
||||
visuals.widgets.inactive.bg_stroke = Stroke::new(1.0, p.border);
|
||||
visuals.widgets.inactive.fg_stroke = Stroke::new(1.0, p.text);
|
||||
visuals.widgets.hovered.bg_fill = p.hover;
|
||||
visuals.widgets.hovered.weak_bg_fill = p.hover;
|
||||
visuals.widgets.hovered.bg_stroke = Stroke::new(1.0, p.border);
|
||||
visuals.widgets.hovered.fg_stroke = Stroke::new(1.0, p.text);
|
||||
visuals.widgets.active.bg_fill = p.selected;
|
||||
visuals.widgets.active.weak_bg_fill = p.selected;
|
||||
visuals.widgets.active.fg_stroke = Stroke::new(1.0, p.text);
|
||||
ctx.set_visuals(visuals);
|
||||
ctx.all_styles_mut(|style| {
|
||||
style.text_styles = [
|
||||
(TextStyle::Heading, semibold(20.0)),
|
||||
(TextStyle::Body, regular(14.0)),
|
||||
(TextStyle::Button, regular(14.0)),
|
||||
(TextStyle::Small, regular(12.0)),
|
||||
(TextStyle::Monospace, FontId::monospace(12.5)),
|
||||
]
|
||||
.into();
|
||||
style.spacing.item_spacing = egui::vec2(8.0, 6.0);
|
||||
style.spacing.button_padding = egui::vec2(10.0, 5.0);
|
||||
style.spacing.interact_size.y = 28.0;
|
||||
style.spacing.window_margin = Margin::same(16);
|
||||
style.spacing.scroll.bar_width = 8.0;
|
||||
style.spacing.scroll.floating = true;
|
||||
style.interaction.selectable_labels = false;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
//! Small building blocks shared by every screen.
|
||||
|
||||
use crate::theme::{self, pal};
|
||||
use egui::{
|
||||
Align, Color32, CornerRadius, Frame, Layout, Margin, Response, RichText, Sense, Stroke, Ui,
|
||||
vec2,
|
||||
};
|
||||
|
||||
/// A white (or dark) rounded surface with a hairline border.
|
||||
pub fn card<R>(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R {
|
||||
let p = pal(ui.ctx());
|
||||
Frame::new()
|
||||
.fill(p.surface)
|
||||
.stroke(Stroke::new(1.0, p.border))
|
||||
.corner_radius(CornerRadius::same(12))
|
||||
.inner_margin(Margin::same(16))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
add(ui)
|
||||
})
|
||||
.inner
|
||||
}
|
||||
|
||||
/// A card with a title row and a right-aligned extra (a link, a pill).
|
||||
/// Returns what `extra` and `add` return.
|
||||
pub fn titled_card<E, R>(
|
||||
ui: &mut Ui,
|
||||
title: &str,
|
||||
extra: impl FnOnce(&mut Ui) -> E,
|
||||
add: impl FnOnce(&mut Ui) -> R,
|
||||
) -> (E, R) {
|
||||
card(ui, |ui| {
|
||||
let e = ui
|
||||
.horizontal(|ui| {
|
||||
ui.label(RichText::new(title).font(theme::semibold(14.0)));
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), extra)
|
||||
.inner
|
||||
})
|
||||
.inner;
|
||||
ui.add_space(8.0);
|
||||
(e, add(ui))
|
||||
})
|
||||
}
|
||||
|
||||
/// Page title and the sentence under it.
|
||||
pub fn page_header(ui: &mut Ui, title: &str, subtitle: &str) {
|
||||
let p = pal(ui.ctx());
|
||||
ui.label(RichText::new(title).font(theme::bold(26.0)));
|
||||
if !subtitle.is_empty() {
|
||||
ui.add_space(2.0);
|
||||
let w = ui.available_width().min(860.0);
|
||||
ui.allocate_ui(vec2(w, 0.0), |ui| {
|
||||
ui.set_max_width(w);
|
||||
ui.label(
|
||||
RichText::new(subtitle)
|
||||
.font(theme::regular(15.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
});
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
}
|
||||
|
||||
/// A rounded tag.
|
||||
pub fn pill(ui: &mut Ui, text: &str, fg: Color32, bg: Color32) -> Response {
|
||||
Frame::new()
|
||||
.fill(bg)
|
||||
.corner_radius(CornerRadius::same(255))
|
||||
.inner_margin(Margin::symmetric(8, 2))
|
||||
.show(ui, |ui| {
|
||||
ui.label(RichText::new(text).font(theme::semibold(11.5)).color(fg))
|
||||
})
|
||||
.response
|
||||
}
|
||||
|
||||
/// Text that looks like plain text until you hover it, then like a link.
|
||||
pub fn link(ui: &mut Ui, text: RichText) -> Response {
|
||||
let p = pal(ui.ctx());
|
||||
let r = ui.add(egui::Label::new(text.clone()).sense(Sense::click()));
|
||||
if r.hovered() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
let rect = r.rect;
|
||||
ui.painter().line_segment(
|
||||
[
|
||||
rect.left_bottom() + vec2(0.0, 1.0),
|
||||
rect.right_bottom() + vec2(0.0, 1.0),
|
||||
],
|
||||
Stroke::new(1.0, p.accent),
|
||||
);
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// An amount, right-aligned in a fixed-width cell, clickable when `drill`.
|
||||
pub fn amount_cell(
|
||||
ui: &mut Ui,
|
||||
text: &str,
|
||||
width: f32,
|
||||
color: Color32,
|
||||
strong: bool,
|
||||
drill: bool,
|
||||
) -> Response {
|
||||
let font = if strong {
|
||||
theme::semibold(14.0)
|
||||
} else {
|
||||
theme::regular(14.0)
|
||||
};
|
||||
let (rect, resp) = ui.allocate_exact_size(
|
||||
vec2(width, 22.0),
|
||||
if drill {
|
||||
Sense::click()
|
||||
} else {
|
||||
Sense::hover()
|
||||
},
|
||||
);
|
||||
let p = pal(ui.ctx());
|
||||
let hovered = drill && resp.hovered();
|
||||
let galley = ui.painter().layout_no_wrap(
|
||||
text.to_string(),
|
||||
font,
|
||||
if hovered { p.accent } else { color },
|
||||
);
|
||||
let pos = egui::pos2(
|
||||
rect.right() - galley.size().x,
|
||||
rect.center().y - galley.size().y / 2.0,
|
||||
);
|
||||
if hovered {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
ui.painter().line_segment(
|
||||
[
|
||||
egui::pos2(pos.x, rect.center().y + galley.size().y / 2.0),
|
||||
egui::pos2(rect.right(), rect.center().y + galley.size().y / 2.0),
|
||||
],
|
||||
Stroke::new(1.0, p.accent),
|
||||
);
|
||||
}
|
||||
ui.painter().galley(pos, galley, color);
|
||||
resp
|
||||
}
|
||||
|
||||
/// A big number with a label, a change line, and an optional sparkline.
|
||||
pub struct Kpi<'a> {
|
||||
pub label: &'a str,
|
||||
pub value: String,
|
||||
pub sub: Option<(String, Color32)>,
|
||||
pub spark: Option<&'a [f64]>,
|
||||
pub how: &'a str,
|
||||
}
|
||||
|
||||
pub fn kpi(ui: &mut Ui, k: Kpi<'_>) -> Response {
|
||||
let p = pal(ui.ctx());
|
||||
let resp = Frame::new()
|
||||
.fill(p.surface)
|
||||
.stroke(Stroke::new(1.0, p.border))
|
||||
.corner_radius(CornerRadius::same(12))
|
||||
.inner_margin(Margin::same(14))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.set_min_height(104.0);
|
||||
ui.label(
|
||||
RichText::new(k.label)
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
ui.label(RichText::new(&k.value).font(theme::semibold(24.0)));
|
||||
if let Some((text, color)) = &k.sub {
|
||||
ui.add_space(2.0);
|
||||
ui.label(RichText::new(text).font(theme::regular(12.0)).color(*color));
|
||||
}
|
||||
if let Some(values) = k.spark
|
||||
&& values.len() > 1
|
||||
{
|
||||
ui.add_space(6.0);
|
||||
let (rect, _) =
|
||||
ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::hover());
|
||||
crate::charts::sparkline(ui.painter(), rect, values, p.accent);
|
||||
}
|
||||
})
|
||||
.response;
|
||||
if !k.how.is_empty() {
|
||||
resp.on_hover_text(k.how)
|
||||
} else {
|
||||
resp
|
||||
}
|
||||
}
|
||||
|
||||
/// A friendly placeholder when there's nothing to show yet.
|
||||
pub fn empty(ui: &mut Ui, icon: &str, title: &str, text: &str) {
|
||||
let p = pal(ui.ctx());
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.add_space(18.0);
|
||||
ui.label(RichText::new(icon).size(28.0).color(p.faint));
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
RichText::new(title)
|
||||
.font(theme::semibold(14.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
ui.label(
|
||||
RichText::new(text)
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.faint),
|
||||
);
|
||||
ui.add_space(18.0);
|
||||
});
|
||||
}
|
||||
|
||||
/// "Copy prompt": puts an agent prompt on the clipboard.
|
||||
pub fn copy_prompt(ui: &mut Ui, prompt: &str) {
|
||||
let p = pal(ui.ctx());
|
||||
let id = ui.id().with(("copied", prompt));
|
||||
let copied_at: Option<f64> = ui.ctx().data(|d| d.get_temp(id));
|
||||
let now = ui.input(|i| i.time);
|
||||
let recently = copied_at.is_some_and(|t| now - t < 1.5);
|
||||
let label = if recently {
|
||||
format!("{} Copied", egui_phosphor::regular::CHECK)
|
||||
} else {
|
||||
format!("{} Ask the agent", egui_phosphor::regular::SPARKLE)
|
||||
};
|
||||
let r = ui
|
||||
.add(
|
||||
egui::Button::new(
|
||||
RichText::new(label)
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.accent),
|
||||
)
|
||||
.fill(p.accent_soft)
|
||||
.stroke(Stroke::NONE)
|
||||
.corner_radius(CornerRadius::same(6)),
|
||||
)
|
||||
.on_hover_text(format!(
|
||||
"Copies this prompt for your agent session:\n\n{prompt}"
|
||||
));
|
||||
if r.clicked() {
|
||||
ui.ctx().copy_text(prompt.to_string());
|
||||
ui.ctx().data_mut(|d| d.insert_temp(id, now));
|
||||
}
|
||||
if recently {
|
||||
ui.ctx()
|
||||
.request_repaint_after(std::time::Duration::from_millis(300));
|
||||
}
|
||||
}
|
||||
|
||||
/// A horizontal rule in the border color.
|
||||
pub fn rule(ui: &mut Ui) {
|
||||
let p = pal(ui.ctx());
|
||||
let w = ui.available_width();
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(w, 1.0), Sense::hover());
|
||||
ui.painter().rect_filled(rect, 0.0, p.border);
|
||||
}
|
||||
|
||||
/// Selectable segmented control.
|
||||
pub fn segmented<T: PartialEq + Copy>(ui: &mut Ui, value: &mut T, options: &[(T, &str)]) -> bool {
|
||||
let p = pal(ui.ctx());
|
||||
let mut changed = false;
|
||||
Frame::new()
|
||||
.fill(p.hover)
|
||||
.corner_radius(CornerRadius::same(8))
|
||||
.inner_margin(Margin::same(2))
|
||||
.show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
ui.horizontal(|ui| {
|
||||
for (v, label) in options {
|
||||
let on = *value == *v;
|
||||
let b = egui::Button::new(
|
||||
RichText::new(*label)
|
||||
.font(if on {
|
||||
theme::semibold(12.5)
|
||||
} else {
|
||||
theme::regular(12.5)
|
||||
})
|
||||
.color(if on { p.text } else { p.muted }),
|
||||
)
|
||||
.fill(if on { p.surface } else { Color32::TRANSPARENT })
|
||||
.stroke(if on {
|
||||
Stroke::new(1.0, p.border)
|
||||
} else {
|
||||
Stroke::NONE
|
||||
})
|
||||
.corner_radius(CornerRadius::same(6))
|
||||
.min_size(vec2(0.0, 24.0));
|
||||
if ui.add(b).clicked() && !on {
|
||||
*value = *v;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
changed
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Live updates: a change written to the books (by the MCP server, in
|
||||
//! practice) reaches the GUI's view model on its own, without a restart,
|
||||
//! and the changed entry is marked fresh.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::{AccountType, Currency, Money, Side};
|
||||
use ledger_gui::data::{Loader, Source, Update};
|
||||
use ledger_store::Store;
|
||||
use rust_decimal::Decimal;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[test]
|
||||
fn a_change_in_another_process_shows_up_live() {
|
||||
let dir = std::env::temp_dir().join(format!("ledger-gui-live-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let data = dir.join("books-data");
|
||||
let mut store = Store::create(&data, Currency::USD).unwrap();
|
||||
let cash = store
|
||||
.add_account("1000", "Checking", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let equity = store
|
||||
.add_account("3000", "Capital", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
drop(store);
|
||||
|
||||
let ctx = egui::Context::default();
|
||||
let loader = Loader::spawn(
|
||||
Source {
|
||||
data_dir: data.clone(),
|
||||
books_toml: None,
|
||||
today: NaiveDate::from_ymd_opt(2026, 10, 12),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
let first = match loader.wait(Duration::from_secs(10)) {
|
||||
Some(Update::Books(b)) => b,
|
||||
_ => panic!("no initial load"),
|
||||
};
|
||||
assert_eq!(first.kpis.cash, Decimal::ZERO);
|
||||
|
||||
// The writer (the MCP server's store) posts an entry.
|
||||
let started = Instant::now();
|
||||
let mut store = Store::open(&data).unwrap();
|
||||
let d = NaiveDate::from_ymd_opt(2026, 10, 12).unwrap();
|
||||
let e = store.create_entry(d, "Owner contribution").unwrap();
|
||||
let m = Money::new(Decimal::from(2500), Currency::USD);
|
||||
store.add_line(e, cash, Side::Debit, m).unwrap();
|
||||
store.add_line(e, equity, Side::Credit, m).unwrap();
|
||||
store.post_entry(e).unwrap();
|
||||
drop(store);
|
||||
|
||||
// The GUI sees it, without being asked.
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
let mut seen = None;
|
||||
while Instant::now() < deadline {
|
||||
if let Some(Update::Books(b)) = loader.wait(Duration::from_millis(200))
|
||||
&& b.kpis.cash == Decimal::from(2500)
|
||||
{
|
||||
seen = Some(b);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let b = seen.expect("the posted entry never reached the GUI");
|
||||
assert!(b.fresh.contains(&e), "the new entry is highlighted as new");
|
||||
assert!(b.seq() > first.seq());
|
||||
eprintln!("live update arrived in {:?}", started.elapsed());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! Screenshots of every screen, for eyes. Set `SHOT_BOOKS` to a books dir
|
||||
//! (containing `books-data/` and `wanderbooks.toml`) and `SHOT_DIR` to
|
||||
//! where the PNGs go; without them this test does nothing.
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use ledger_gui::nav::{ReportKind, Route};
|
||||
use ledger_gui::{App, Source, data};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn screenshots() {
|
||||
let Ok(out) = std::env::var("SHOT_DIR") else {
|
||||
return;
|
||||
};
|
||||
let fixed_today: Option<chrono::NaiveDate> = std::env::var("SHOT_TODAY")
|
||||
.ok()
|
||||
.and_then(|d| d.parse().ok());
|
||||
let books = match std::env::var("SHOT_BOOKS") {
|
||||
Ok(b) => PathBuf::from(b),
|
||||
Err(_) => {
|
||||
let d = fixed_today
|
||||
.unwrap_or_else(|| chrono::NaiveDate::from_ymd_opt(2026, 10, 12).unwrap());
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("ledger-gui-shots-demo-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
ledger_gui::demo::build(&dir, d).unwrap();
|
||||
dir
|
||||
}
|
||||
};
|
||||
let out = PathBuf::from(out);
|
||||
std::fs::create_dir_all(&out).unwrap();
|
||||
let source = Source {
|
||||
data_dir: books.join("books-data"),
|
||||
books_toml: Some(books.join("wanderbooks.toml")),
|
||||
today: fixed_today.or_else(|| {
|
||||
std::env::var("SHOT_BOOKS")
|
||||
.is_err()
|
||||
.then(|| chrono::NaiveDate::from_ymd_opt(2026, 10, 12).unwrap())
|
||||
}),
|
||||
};
|
||||
let only = std::env::var("SHOT_ONLY").ok();
|
||||
let dark = std::env::var("SHOT_DARK").is_ok();
|
||||
let first_entry = data::load_once(&source)
|
||||
.unwrap()
|
||||
.entries
|
||||
.last()
|
||||
.map(|e| e.id)
|
||||
.unwrap_or(1);
|
||||
let routes: Vec<(&str, Route)> = vec![
|
||||
("overview", Route::Overview),
|
||||
("transactions", Route::Transactions),
|
||||
(
|
||||
"entry",
|
||||
Route::Entry(
|
||||
std::env::var("SHOT_ENTRY")
|
||||
.ok()
|
||||
.and_then(|e| e.parse().ok())
|
||||
.unwrap_or(first_entry),
|
||||
),
|
||||
),
|
||||
("cash", Route::Cash),
|
||||
("profit", Route::Profit),
|
||||
("pnl", Route::Reports(ReportKind::ProfitAndLoss)),
|
||||
("balance", Route::Reports(ReportKind::BalanceSheet)),
|
||||
("accounts", Route::Accounts),
|
||||
("documents", Route::Documents),
|
||||
("company", Route::Company),
|
||||
("journal", Route::Journal),
|
||||
];
|
||||
for (name, route) in routes {
|
||||
if only
|
||||
.as_deref()
|
||||
.is_some_and(|o| !o.split(',').any(|x| x == name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let books = data::load_once(&source).unwrap();
|
||||
let mut harness = Harness::builder()
|
||||
.with_size(egui::vec2(1440.0, 920.0))
|
||||
.build_eframe(|cc| App::with_books(&cc.egui_ctx, books, dark, route.clone()));
|
||||
harness.run_steps(4);
|
||||
let ctx = harness.ctx.clone();
|
||||
harness.state_mut().settle(&ctx);
|
||||
harness.run_steps(4);
|
||||
let img = harness.render().unwrap();
|
||||
img.save(out.join(format!("{name}.png"))).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "ledger-import"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[dependencies]
|
||||
ledger-core = { path = "../ledger-core" }
|
||||
csv = "1"
|
||||
rust_decimal = "1"
|
||||
chrono = "0.4"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
thiserror = "2"
|
||||
rust_xlsxwriter = "0.90"
|
||||
typst-as-lib = { version = "0.17", default-features = false }
|
||||
typst-pdf = "0.15.1"
|
||||
typst = "0.15.1"
|
||||
serde_json = "1"
|
||||
typst-layout = "0.15.1"
|
||||
Binary file not shown.
@@ -0,0 +1,92 @@
|
||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION AND CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,167 @@
|
||||
//! Parse QuickBooks Online bank/credit-card upload CSVs.
|
||||
//!
|
||||
//! QBO accepts two layouts:
|
||||
//!
|
||||
//! - 3 columns: `Date, Description, Amount` (positive = money in).
|
||||
//! - 4 columns: `Date, Description, Expense, Income` (or `Debit, Credit`).
|
||||
//!
|
||||
//! Headers are optional; the layout is detected from the column count.
|
||||
//! Dates accept `YYYY-MM-DD`, `MM/DD/YYYY`, and `MM/DD/YY`.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use rust_decimal::Decimal;
|
||||
use std::str::FromStr;
|
||||
use thiserror::Error;
|
||||
|
||||
/// One parsed bank transaction.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct BankTransaction {
|
||||
/// Transaction date.
|
||||
pub date: NaiveDate,
|
||||
/// Bank's description.
|
||||
pub description: String,
|
||||
/// Signed amount: positive = money in, negative = money out.
|
||||
pub amount: Decimal,
|
||||
}
|
||||
|
||||
/// Parse errors.
|
||||
#[derive(Debug, Error, PartialEq)]
|
||||
pub enum BankCsvError {
|
||||
/// The CSV has neither 3 nor 4 columns.
|
||||
#[error("expected 3 or 4 columns, found {0}")]
|
||||
BadColumnCount(usize),
|
||||
/// A date did not parse.
|
||||
#[error("bad date {0:?}")]
|
||||
BadDate(String),
|
||||
/// An amount did not parse.
|
||||
#[error("bad amount {0:?}")]
|
||||
BadAmount(String),
|
||||
/// The CSV itself is malformed.
|
||||
#[error("csv error: {0}")]
|
||||
Csv(String),
|
||||
}
|
||||
|
||||
/// Parse a QBO bank CSV. Returns transactions in file order.
|
||||
pub fn parse_bank_csv(text: &str) -> Result<Vec<BankTransaction>, BankCsvError> {
|
||||
let mut reader = csv::ReaderBuilder::new()
|
||||
.has_headers(false)
|
||||
.trim(csv::Trim::All)
|
||||
.from_reader(text.as_bytes());
|
||||
let mut out = Vec::new();
|
||||
for (idx, record) in reader.records().enumerate() {
|
||||
let record = record.map_err(|e| BankCsvError::Csv(e.to_string()))?;
|
||||
let fields: Vec<&str> = record.iter().collect();
|
||||
if fields.iter().all(|f| f.is_empty()) {
|
||||
continue; // skip blank lines
|
||||
}
|
||||
// Skip a header row.
|
||||
if idx == 0 && fields[0].eq_ignore_ascii_case("date") {
|
||||
continue;
|
||||
}
|
||||
let (date, description, amount) = match fields.len() {
|
||||
3 => {
|
||||
let amount = parse_amount(fields[2])?;
|
||||
(fields[0], fields[1], amount)
|
||||
}
|
||||
4 => {
|
||||
// Expense/Debit in col 2, Income/Credit in col 3. Exactly one
|
||||
// should be non-empty; a row with both is ambiguous.
|
||||
let out_amt = fields[2].trim();
|
||||
let in_amt = fields[3].trim();
|
||||
let amount = match (!out_amt.is_empty(), !in_amt.is_empty()) {
|
||||
(true, false) => -parse_amount(out_amt)?,
|
||||
(false, true) => parse_amount(in_amt)?,
|
||||
(false, false) => Decimal::ZERO,
|
||||
(true, true) => {
|
||||
return Err(BankCsvError::Csv(format!(
|
||||
"row {} has both expense and income amounts",
|
||||
idx + 1
|
||||
)));
|
||||
}
|
||||
};
|
||||
(fields[0], fields[1], amount)
|
||||
}
|
||||
n => return Err(BankCsvError::BadColumnCount(n)),
|
||||
};
|
||||
if amount.is_zero() {
|
||||
continue; // zero-amount rows carry no money
|
||||
}
|
||||
out.push(BankTransaction {
|
||||
date: parse_date(date)?,
|
||||
description: description.to_string(),
|
||||
amount,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn parse_date(s: &str) -> Result<NaiveDate, BankCsvError> {
|
||||
let s = s.trim();
|
||||
// YYYY-MM-DD
|
||||
if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
|
||||
return Ok(d);
|
||||
}
|
||||
// MM/DD/YYYY
|
||||
if let Ok(d) = NaiveDate::parse_from_str(s, "%m/%d/%Y") {
|
||||
return Ok(d);
|
||||
}
|
||||
// MM/DD/YY (assume 2000s)
|
||||
if let Ok(d) = NaiveDate::parse_from_str(s, "%m/%d/%y") {
|
||||
return Ok(d);
|
||||
}
|
||||
Err(BankCsvError::BadDate(s.to_string()))
|
||||
}
|
||||
|
||||
fn parse_amount(s: &str) -> Result<Decimal, BankCsvError> {
|
||||
let cleaned: String = s.trim().replace(['$', ','], "");
|
||||
// Parentheses mean negative: (123.45).
|
||||
let (neg, inner) = match cleaned.strip_prefix('(').and_then(|t| t.strip_suffix(')')) {
|
||||
Some(inner) => (true, inner),
|
||||
None => (false, cleaned.as_str()),
|
||||
};
|
||||
let mut amount =
|
||||
Decimal::from_str(inner).map_err(|_| BankCsvError::BadAmount(s.to_string()))?;
|
||||
if neg {
|
||||
amount = -amount;
|
||||
}
|
||||
Ok(amount)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn three_column_with_header() {
|
||||
let csv =
|
||||
"Date,Description,Amount\n2026-01-15,Whole Foods,-84.32\n01/16/2026,Paycheck,2500.00\n";
|
||||
let txns = parse_bank_csv(csv).unwrap();
|
||||
assert_eq!(txns.len(), 2);
|
||||
assert_eq!(txns[0].description, "Whole Foods");
|
||||
assert_eq!(txns[0].amount, Decimal::new(-8432, 2));
|
||||
assert_eq!(txns[1].amount, Decimal::new(250000, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_column_expense_income() {
|
||||
let csv =
|
||||
"Date,Description,Expense,Income\n2026-01-15,Coffee,4.50,\n2026-01-16,Refund,,12.00\n";
|
||||
let txns = parse_bank_csv(csv).unwrap();
|
||||
assert_eq!(txns.len(), 2);
|
||||
assert_eq!(txns[0].amount, Decimal::new(-450, 2));
|
||||
assert_eq!(txns[1].amount, Decimal::new(1200, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parenthesized_negatives_and_dollar_signs() {
|
||||
let csv = "01/15/2026,Store,\"($1,234.56)\"\n";
|
||||
let txns = parse_bank_csv(csv).unwrap();
|
||||
assert_eq!(txns[0].amount, Decimal::new(-123456, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_column_count_rejected() {
|
||||
let csv = "a,b\n";
|
||||
assert_eq!(parse_bank_csv(csv), Err(BankCsvError::BadColumnCount(2)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//! Parse chart-of-accounts CSVs.
|
||||
//!
|
||||
//! Expected layout (header row required):
|
||||
//!
|
||||
//! ```csv
|
||||
//! code,name,type,parent_code
|
||||
//! 1000,Cash,asset,
|
||||
//! 1010,Checking,asset,1000
|
||||
//! ```
|
||||
//!
|
||||
//! `type` is one of `asset`, `liability`, `equity`, `revenue`, `expense`
|
||||
//! (case-insensitive). `parent_code` is optional; empty means top-level.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// One chart row.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ChartRow {
|
||||
/// Account code.
|
||||
pub code: String,
|
||||
/// Account name.
|
||||
pub name: String,
|
||||
/// Account type, lowercase: asset, liability, equity, revenue, expense.
|
||||
pub account_type: String,
|
||||
/// Parent account code, if any.
|
||||
pub parent_code: Option<String>,
|
||||
/// Optional fifth column: the tax category key (see
|
||||
/// `ledger_core::tax`).
|
||||
pub tax_category: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse errors.
|
||||
#[derive(Debug, Error, PartialEq)]
|
||||
pub enum ChartCsvError {
|
||||
/// Wrong column count: (found, expected by the header).
|
||||
#[error("expected {1} columns to match the header, found {0}")]
|
||||
BadColumnCount(usize, usize),
|
||||
/// Missing or bad header.
|
||||
#[error("bad header: expected code,name,type,parent_code[,tax_category]")]
|
||||
BadHeader,
|
||||
/// Unknown account type.
|
||||
#[error("bad account type {0:?}: expected asset, liability, equity, revenue, or expense")]
|
||||
BadType(String),
|
||||
/// Malformed CSV.
|
||||
#[error("csv error: {0}")]
|
||||
Csv(String),
|
||||
}
|
||||
|
||||
/// Parse a chart CSV. Returns rows in file order (parents should come first,
|
||||
/// but order is not validated here).
|
||||
pub fn parse_chart_csv(text: &str) -> Result<Vec<ChartRow>, ChartCsvError> {
|
||||
let mut reader = csv::ReaderBuilder::new()
|
||||
.has_headers(false)
|
||||
.flexible(true)
|
||||
.trim(csv::Trim::All)
|
||||
.from_reader(text.as_bytes());
|
||||
let mut out = Vec::new();
|
||||
let mut width = 4;
|
||||
for (idx, record) in reader.records().enumerate() {
|
||||
let record = record.map_err(|e| ChartCsvError::Csv(e.to_string()))?;
|
||||
let fields: Vec<&str> = record.iter().collect();
|
||||
if fields.iter().all(|f| f.is_empty()) {
|
||||
continue;
|
||||
}
|
||||
if idx == 0 {
|
||||
let lower: Vec<String> = fields.iter().map(|f| f.to_lowercase()).collect();
|
||||
width = if lower == ["code", "name", "type", "parent_code"] {
|
||||
4
|
||||
} else if lower == ["code", "name", "type", "parent_code", "tax_category"] {
|
||||
5
|
||||
} else {
|
||||
return Err(ChartCsvError::BadHeader);
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if fields.len() != width {
|
||||
return Err(ChartCsvError::BadColumnCount(fields.len(), width));
|
||||
}
|
||||
let account_type = fields[2].to_lowercase();
|
||||
match account_type.as_str() {
|
||||
"asset" | "liability" | "equity" | "revenue" | "expense" => {}
|
||||
_ => return Err(ChartCsvError::BadType(fields[2].to_string())),
|
||||
}
|
||||
let parent_code = fields[3].trim();
|
||||
out.push(ChartRow {
|
||||
code: fields[0].to_string(),
|
||||
name: fields[1].to_string(),
|
||||
account_type,
|
||||
parent_code: if parent_code.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parent_code.to_string())
|
||||
},
|
||||
tax_category: fields
|
||||
.get(4)
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_chart() {
|
||||
let csv = "code,name,type,parent_code\n1000,Cash,asset,\n1010,Checking,asset,1000\n4000,Revenue,revenue,\n";
|
||||
let rows = parse_chart_csv(csv).unwrap();
|
||||
assert_eq!(rows.len(), 3);
|
||||
assert_eq!(rows[0].parent_code, None);
|
||||
assert_eq!(rows[1].parent_code, Some("1000".to_string()));
|
||||
assert_eq!(rows[2].account_type, "revenue");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_optional_tax_category_column() {
|
||||
let csv = "code,name,type,parent_code,tax_category\n4000,Sales,revenue,,gross_receipts\n1000,Cash,asset,,\n";
|
||||
let rows = parse_chart_csv(csv).unwrap();
|
||||
assert_eq!(rows[0].tax_category.as_deref(), Some("gross_receipts"));
|
||||
assert_eq!(rows[1].tax_category, None);
|
||||
// Rows must match the header's width.
|
||||
let short = "code,name,type,parent_code,tax_category\n1000,Cash,asset,\n";
|
||||
assert_eq!(
|
||||
parse_chart_csv(short),
|
||||
Err(ChartCsvError::BadColumnCount(4, 5))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_type_rejected() {
|
||||
let csv = "code,name,type,parent_code\n1000,Cash,money,\n";
|
||||
assert!(matches!(
|
||||
parse_chart_csv(csv),
|
||||
Err(ChartCsvError::BadType(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
//! Generate Intuit Interchange Format (IIF) journal entries for QuickBooks
|
||||
//! Desktop, which many CPAs still run.
|
||||
//!
|
||||
//! Layout per entry (fields are tab-separated; `\t` marks each tab):
|
||||
//!
|
||||
//! ```text
|
||||
//! !TRNS\tTRNSTYPE\tDATE\tACCNT\tNAME\tAMOUNT\tDOCNUM\tMEMO
|
||||
//! !SPL\tTRNSTYPE\tDATE\tACCNT\tAMOUNT\tDOCNUM\tMEMO
|
||||
//! !ENDTRNS
|
||||
//! TRNS\tGENERAL JOURNAL\t01/15/2026\tCash\t\t1000.00\t1\tMemo
|
||||
//! SPL\tGENERAL JOURNAL\t01/15/2026\tRevenue\t-1000.00\t1\tMemo
|
||||
//! ENDTRNS
|
||||
//! ```
|
||||
//!
|
||||
//! Amounts are signed from the entry's perspective: debits positive,
|
||||
//! credits negative. Dates are `MM/DD/YYYY`. Accounts are referenced by
|
||||
//! name (QuickBooks matches on name).
|
||||
|
||||
use ledger_core::{JournalEntry, Side};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
/// One entry's data for IIF export.
|
||||
pub struct IifEntry<'a> {
|
||||
/// The journal entry.
|
||||
pub entry: &'a JournalEntry,
|
||||
/// Account id → account name, for resolving line accounts.
|
||||
pub account_names: &'a dyn Fn(ledger_core::AccountId) -> String,
|
||||
}
|
||||
|
||||
/// Render posted entries as IIF text. `docnum` numbers entries sequentially
|
||||
/// from 1 in the order given.
|
||||
pub fn journal_to_iif(entries: &[IifEntry<'_>]) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str("!TRNS\tTRNSTYPE\tDATE\tACCNT\tNAME\tAMOUNT\tDOCNUM\tMEMO\n");
|
||||
out.push_str("!SPL\tTRNSTYPE\tDATE\tACCNT\tAMOUNT\tDOCNUM\tMEMO\n");
|
||||
out.push_str("!ENDTRNS\n");
|
||||
for (idx, item) in entries.iter().enumerate() {
|
||||
let docnum = idx + 1;
|
||||
let date = item.entry.date().format("%m/%d/%Y").to_string();
|
||||
let memo = sanitize(&item.entry.description().replace('\t', " "));
|
||||
let mut lines: Vec<_> = item.entry.lines().iter().collect();
|
||||
// TRNS carries the first debit (or first line); the rest are SPLs.
|
||||
lines.sort_by_key(|l| match l.side {
|
||||
Side::Debit => 0,
|
||||
Side::Credit => 1,
|
||||
});
|
||||
let (first, rest) = lines.split_first().expect("entry has lines");
|
||||
let first_acct = sanitize(&(item.account_names)(first.account));
|
||||
let first_amount = signed(first.side, first.amount.amount);
|
||||
out.push_str(&format!(
|
||||
"TRNS\tGENERAL JOURNAL\t{date}\t{first_acct}\t\t{}\t{docnum}\t{memo}\n",
|
||||
format_amount(first_amount),
|
||||
));
|
||||
for line in rest {
|
||||
let acct = sanitize(&(item.account_names)(line.account));
|
||||
let amount = signed(line.side, line.amount.amount);
|
||||
out.push_str(&format!(
|
||||
"SPL\tGENERAL JOURNAL\t{date}\t{acct}\t{}\t{docnum}\t{memo}\n",
|
||||
format_amount(amount),
|
||||
));
|
||||
}
|
||||
out.push_str("ENDTRNS\n");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Debit positive, credit negative.
|
||||
fn signed(side: Side, amount: Decimal) -> Decimal {
|
||||
match side {
|
||||
Side::Debit => amount,
|
||||
Side::Credit => -amount,
|
||||
}
|
||||
}
|
||||
|
||||
fn format_amount(d: Decimal) -> String {
|
||||
// Two decimal places, no thousands separators.
|
||||
format!("{d:.2}")
|
||||
}
|
||||
|
||||
/// Strip tabs/newlines that would corrupt the tab-delimited format.
|
||||
fn sanitize(s: &str) -> String {
|
||||
s.replace(['\t', '\n', '\r'], " ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ledger_core::{AccountType, Currency, Ledger, Money};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
#[test]
|
||||
fn iif_round_trip_shape() {
|
||||
let mut ledger = Ledger::new(Currency::USD);
|
||||
let cash = ledger
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let rev = ledger
|
||||
.add_account("4000", "Revenue", AccountType::Revenue, None, false)
|
||||
.unwrap();
|
||||
let id = ledger.create_entry(
|
||||
chrono::NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
|
||||
"Sale",
|
||||
);
|
||||
ledger
|
||||
.add_line(
|
||||
id,
|
||||
cash,
|
||||
Side::Debit,
|
||||
Money::new(Decimal::new(100000, 2), Currency::USD),
|
||||
)
|
||||
.unwrap();
|
||||
ledger
|
||||
.add_line(
|
||||
id,
|
||||
rev,
|
||||
Side::Credit,
|
||||
Money::new(Decimal::new(100000, 2), Currency::USD),
|
||||
)
|
||||
.unwrap();
|
||||
ledger.post_entry(id).unwrap();
|
||||
let entry = ledger.get_entry(id).unwrap();
|
||||
let names = |a: ledger_core::AccountId| {
|
||||
ledger
|
||||
.chart()
|
||||
.get(a)
|
||||
.map(|ac| ac.name.clone())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let text = journal_to_iif(&[IifEntry {
|
||||
entry,
|
||||
account_names: &names,
|
||||
}]);
|
||||
assert!(text.contains("!TRNS\tTRNSTYPE\tDATE\tACCNT\tNAME\tAMOUNT\tDOCNUM\tMEMO"));
|
||||
assert!(text.contains("TRNS\tGENERAL JOURNAL\t01/15/2026\tCash"));
|
||||
assert!(text.contains("\t1000.00\t"));
|
||||
assert!(text.contains("SPL\tGENERAL JOURNAL\t01/15/2026\tRevenue\t-1000.00"));
|
||||
assert!(text.contains("ENDTRNS"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! QuickBooks-compatible import/export for wanderbooks.
|
||||
//!
|
||||
//! - [`bank_csv`]: parse QBO bank/credit-card upload CSVs.
|
||||
//! - [`chart_csv`]: parse chart-of-accounts CSVs.
|
||||
//! - [`iif`]: generate IIF journal entries for QuickBooks Desktop.
|
||||
//! - [`report`]: the statement layout every renderer draws from.
|
||||
//! - [`xlsx`]: generate XLSX reports.
|
||||
//! - [`pdf`]: generate PDF financial statements (CPA handoff).
|
||||
//!
|
||||
//! All money stays `Decimal`; no floats in money paths.
|
||||
|
||||
pub mod bank_csv;
|
||||
pub mod chart_csv;
|
||||
pub mod iif;
|
||||
pub mod pdf;
|
||||
pub mod report;
|
||||
pub mod templates;
|
||||
pub mod xlsx;
|
||||
|
||||
pub use bank_csv::{BankTransaction, parse_bank_csv};
|
||||
pub use chart_csv::{ChartRow, parse_chart_csv};
|
||||
pub use iif::{IifEntry, journal_to_iif};
|
||||
pub use pdf::{PdfError, statement_pdf};
|
||||
pub use report::{Member, ReportMeta, Row, RowKind, Statement, YearEarnings, format_amount};
|
||||
pub use templates::{ChartTemplate, EntityType, chart_template};
|
||||
pub use xlsx::{XlsxReport, report_to_xlsx};
|
||||
@@ -0,0 +1,188 @@
|
||||
//! PDF financial statements, typeset with Typst.
|
||||
//!
|
||||
//! [`crate::report`] lays a statement out as rows; this module hands those
|
||||
//! rows to `templates/statement.typ` as JSON data (`sys.inputs.data`) and
|
||||
//! renders the PDF. Amounts arrive already split by
|
||||
//! [`crate::report::amount_parts`] — Typst never does arithmetic on money,
|
||||
//! and labels are data, never markup. The font (Inter, SIL OFL 1.1; see
|
||||
//! `fonts/Inter-LICENSE.txt`) is bundled, so output doesn't depend on
|
||||
//! what's installed, and it is deterministic: the same statement and
|
||||
//! report metadata render to the same bytes.
|
||||
|
||||
use crate::report::{ReportMeta, RowKind, Statement, amount_parts};
|
||||
use std::sync::OnceLock;
|
||||
use thiserror::Error;
|
||||
use typst::foundations::{Dict, IntoValue};
|
||||
use typst_as_lib::{TypstEngine, TypstTemplateMainFile};
|
||||
use typst_layout::PagedDocument;
|
||||
|
||||
/// PDF rendering errors.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PdfError {
|
||||
/// Typst failed to compile the statement.
|
||||
#[error("pdf typesetting failed: {0}")]
|
||||
Compile(String),
|
||||
/// The PDF writer failed.
|
||||
#[error("pdf save failed: {0}")]
|
||||
Save(String),
|
||||
}
|
||||
|
||||
static TEMPLATE: &str = include_str!("../templates/statement.typ");
|
||||
static INTER: [&[u8]; 3] = [
|
||||
include_bytes!("../fonts/Inter-Regular.ttf"),
|
||||
include_bytes!("../fonts/Inter-SemiBold.ttf"),
|
||||
include_bytes!("../fonts/Inter-Bold.ttf"),
|
||||
];
|
||||
|
||||
/// The engine, built once: parsing the bundled fonts is the expensive part.
|
||||
fn engine() -> &'static TypstEngine<TypstTemplateMainFile> {
|
||||
static ENGINE: OnceLock<TypstEngine<TypstTemplateMainFile>> = OnceLock::new();
|
||||
ENGINE.get_or_init(|| {
|
||||
TypstEngine::builder()
|
||||
.main_file(TEMPLATE)
|
||||
.fonts(INTER)
|
||||
.build()
|
||||
})
|
||||
}
|
||||
|
||||
/// The template's input: the statement with every amount split for
|
||||
/// alignment.
|
||||
pub fn template_data(st: &Statement, meta: &ReportMeta) -> serde_json::Value {
|
||||
let kind = |k: RowKind| match k {
|
||||
RowKind::Section => "section",
|
||||
RowKind::Subheading => "subheading",
|
||||
RowKind::Line => "line",
|
||||
RowKind::Subtotal => "subtotal",
|
||||
RowKind::Total => "total",
|
||||
RowKind::Spacer => "spacer",
|
||||
};
|
||||
serde_json::json!({
|
||||
"company": meta.company,
|
||||
"title": st.title,
|
||||
"period": st.period,
|
||||
"footer": meta.footer(),
|
||||
"columns": st.columns,
|
||||
"rows": st.rows.iter().map(|r| serde_json::json!({
|
||||
"kind": kind(r.kind),
|
||||
"label": r.label,
|
||||
"rule": r.rule_above,
|
||||
"currency": r.currency,
|
||||
"amounts": r.amounts.iter().map(|a| a.map(|a| {
|
||||
let p = amount_parts(a);
|
||||
serde_json::json!({ "digits": p.digits, "negative": p.negative })
|
||||
})).collect::<Vec<_>>(),
|
||||
})).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Typeset a statement into a document (pages, before PDF encoding).
|
||||
fn typeset(st: &Statement, meta: &ReportMeta) -> Result<PagedDocument, PdfError> {
|
||||
let mut inputs = Dict::new();
|
||||
inputs.insert(
|
||||
"data".into(),
|
||||
template_data(st, meta).to_string().into_value(),
|
||||
);
|
||||
engine()
|
||||
.compile_with_input(inputs)
|
||||
.output
|
||||
.map_err(|e| PdfError::Compile(format!("{e:?}")))
|
||||
}
|
||||
|
||||
/// Render a laid-out statement (see [`crate::report`]) as a PDF.
|
||||
pub fn statement_pdf(st: &Statement, meta: &ReportMeta) -> Result<Vec<u8>, PdfError> {
|
||||
let doc = typeset(st, meta)?;
|
||||
typst_pdf::pdf(&doc, &typst_pdf::PdfOptions::default())
|
||||
.map_err(|e| PdfError::Save(format!("{e:?}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::report::{Row, profit_and_loss};
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::reports::{ProfitAndLossStatement, StatementLine};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
fn meta() -> ReportMeta {
|
||||
ReportMeta {
|
||||
company: "Test Co".to_string(),
|
||||
basis: "Accrual".to_string(),
|
||||
prepared_on: NaiveDate::from_ymd_opt(2026, 10, 11).unwrap(),
|
||||
journal_seq: 42,
|
||||
}
|
||||
}
|
||||
|
||||
fn pl() -> ProfitAndLossStatement {
|
||||
let line = |code: &str, name: &str, cents| StatementLine {
|
||||
code: code.to_string(),
|
||||
name: name.to_string(),
|
||||
amount: Decimal::new(cents, 2),
|
||||
};
|
||||
ProfitAndLossStatement {
|
||||
start: NaiveDate::from_ymd_opt(2026, 10, 4).unwrap(),
|
||||
end: NaiveDate::from_ymd_opt(2026, 10, 11).unwrap(),
|
||||
revenue: vec![],
|
||||
expenses: vec![
|
||||
line("6000", "Organizational Costs", 5900),
|
||||
line("6400", "Memberships & Licenses", 10638),
|
||||
],
|
||||
total_revenue: Decimal::ZERO,
|
||||
total_expenses: Decimal::new(16538, 2),
|
||||
}
|
||||
}
|
||||
|
||||
/// The exact data the template typesets: hand-checked, like a golden.
|
||||
#[test]
|
||||
fn template_data_is_exact() {
|
||||
let amt = |digits: &str, negative: bool| serde_json::json!([{"digits": digits, "negative": negative}]);
|
||||
let data = template_data(&profit_and_loss(&pl()), &meta());
|
||||
assert_eq!(
|
||||
data,
|
||||
serde_json::json!({
|
||||
"company": "Test Co",
|
||||
"title": "Profit and Loss",
|
||||
"period": "October 4 – October 11, 2026",
|
||||
"footer": "Accrual basis · Prepared Sunday, October 11, 2026 · Journal seq 42",
|
||||
"columns": ["Total"],
|
||||
"rows": [
|
||||
{"kind": "section", "label": "Revenue", "rule": false, "currency": false, "amounts": []},
|
||||
{"kind": "subtotal", "label": "Total revenue", "rule": false, "currency": true, "amounts": amt("0.00", false)},
|
||||
{"kind": "spacer", "label": "", "rule": false, "currency": false, "amounts": []},
|
||||
{"kind": "section", "label": "Expenses", "rule": false, "currency": false, "amounts": []},
|
||||
{"kind": "line", "label": "6000 Organizational Costs", "rule": false, "currency": false, "amounts": amt("59.00", false)},
|
||||
{"kind": "line", "label": "6400 Memberships & Licenses", "rule": false, "currency": false, "amounts": amt("106.38", false)},
|
||||
{"kind": "subtotal", "label": "Total expenses", "rule": true, "currency": true, "amounts": amt("165.38", false)},
|
||||
{"kind": "spacer", "label": "", "rule": false, "currency": false, "amounts": []},
|
||||
{"kind": "total", "label": "Net income (loss)", "rule": true, "currency": true, "amounts": amt("165.38", true)},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_a_pdf_deterministically() {
|
||||
let st = profit_and_loss(&pl());
|
||||
let a = statement_pdf(&st, &meta()).unwrap();
|
||||
assert!(a.starts_with(b"%PDF"));
|
||||
assert_eq!(a, statement_pdf(&st, &meta()).unwrap());
|
||||
}
|
||||
|
||||
/// Labels are data: Typst markup characters print literally, and a
|
||||
/// statement too long for one page flows onto more instead of being cut.
|
||||
#[test]
|
||||
fn markup_is_inert_and_long_statements_paginate() {
|
||||
let mut st = profit_and_loss(&pl());
|
||||
st.rows = (0..200)
|
||||
.map(|i| Row {
|
||||
kind: RowKind::Line,
|
||||
label: format!("#{i} *not bold* $x$ <label> @ref [link] \\ #panic()"),
|
||||
amounts: vec![Some(Decimal::new(i, 0))],
|
||||
currency: false,
|
||||
rule_above: false,
|
||||
})
|
||||
.collect();
|
||||
let doc = typeset(&st, &meta()).unwrap();
|
||||
assert!(doc.pages().len() > 1, "200 rows should span pages");
|
||||
assert!(statement_pdf(&st, &meta()).unwrap().starts_with(b"%PDF"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,719 @@
|
||||
//! The presentation model every statement renderer (XLSX, PDF) draws from.
|
||||
//!
|
||||
//! Core computes the statements; this module lays them out once — title,
|
||||
//! period, column headers, and rows (sections, account lines, subtotals,
|
||||
//! totals) — so the formats can't drift apart. Amounts stay exact
|
||||
//! `Decimal`s here; [`format_amount`] and [`amount_parts`] are the only
|
||||
//! places they become text.
|
||||
//!
|
||||
//! Presentation follows US small-business convention (QuickBooks-style):
|
||||
//! a currency sign on subtotals and totals, a rule above every sum, a
|
||||
//! double rule under statement totals, "Net income (loss)", and — for a
|
||||
//! multi-member LLC — "Members' equity" with each member named.
|
||||
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use ledger_core::RETAINED_EARNINGS_CODE;
|
||||
use ledger_core::reports::{
|
||||
BalanceSheetStatement, CashFlowLine, CashFlowStatement, MembersEquityStatement,
|
||||
ProfitAndLossStatement, StatementLine, TrialBalance,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::Serialize;
|
||||
|
||||
/// Who the report is for and exactly what it was prepared from.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ReportMeta {
|
||||
/// Company name for the header, e.g. "Wanderland Labs LLC".
|
||||
pub company: String,
|
||||
/// Accounting basis for the footer: "Accrual" or "Cash".
|
||||
pub basis: String,
|
||||
/// The day the report was prepared.
|
||||
pub prepared_on: NaiveDate,
|
||||
/// Sequence number of the last journal line the report reflects.
|
||||
pub journal_seq: u64,
|
||||
}
|
||||
|
||||
impl ReportMeta {
|
||||
/// "Accrual basis · Prepared Sunday, October 11, 2026 · Journal seq 114".
|
||||
pub fn footer(&self) -> String {
|
||||
format!(
|
||||
"{} basis · Prepared {}, {} · Journal seq {}",
|
||||
self.basis,
|
||||
self.prepared_on.format("%A"),
|
||||
long_date(self.prepared_on),
|
||||
self.journal_seq
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A member of a multi-member LLC, for naming capital accounts on reports.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Member {
|
||||
/// The member's name.
|
||||
pub name: String,
|
||||
/// Code of their capital account.
|
||||
pub capital_code: String,
|
||||
/// Code of their distributions account, if they have one.
|
||||
pub distribution_code: Option<String>,
|
||||
}
|
||||
|
||||
/// What a row is, which decides how renderers style it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RowKind {
|
||||
/// A section heading ("Revenue"); no amounts.
|
||||
Section,
|
||||
/// An explanatory heading inside a section; no amounts.
|
||||
Subheading,
|
||||
/// One account or one flow, indented under its section.
|
||||
Line,
|
||||
/// A section total ("Total revenue").
|
||||
Subtotal,
|
||||
/// A statement total ("Net income (loss)"); double rule beneath.
|
||||
Total,
|
||||
/// Vertical space between sections.
|
||||
Spacer,
|
||||
}
|
||||
|
||||
/// One row of a statement.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct Row {
|
||||
/// Row kind.
|
||||
pub kind: RowKind,
|
||||
/// Label text.
|
||||
pub label: String,
|
||||
/// One amount per column; `None` leaves the cell blank.
|
||||
pub amounts: Vec<Option<Decimal>>,
|
||||
/// Print the currency sign with the amounts: subtotals and totals.
|
||||
pub currency: bool,
|
||||
/// Draw a rule above the row: the "sum of the above" line. Totals
|
||||
/// always have one; a subtotal only when amounts sit above it.
|
||||
pub rule_above: bool,
|
||||
}
|
||||
|
||||
/// A laid-out financial statement.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct Statement {
|
||||
/// "Profit and Loss", "Balance Sheet", ...
|
||||
pub title: String,
|
||||
/// "October 4 – October 11, 2026" or "As of October 11, 2026".
|
||||
pub period: String,
|
||||
/// Amount column headings ("Total"; "Debit", "Credit"; member names).
|
||||
pub columns: Vec<String>,
|
||||
/// The rows, top to bottom.
|
||||
pub rows: Vec<Row>,
|
||||
}
|
||||
|
||||
fn row(kind: RowKind, label: impl Into<String>, amounts: Vec<Option<Decimal>>) -> Row {
|
||||
Row {
|
||||
kind,
|
||||
label: label.into(),
|
||||
amounts,
|
||||
currency: false,
|
||||
rule_above: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn one(kind: RowKind, label: impl Into<String>, amount: Decimal) -> Row {
|
||||
row(kind, label, vec![Some(amount)])
|
||||
}
|
||||
|
||||
fn section(label: &str) -> Row {
|
||||
row(RowKind::Section, label, vec![])
|
||||
}
|
||||
|
||||
fn subheading(label: &str) -> Row {
|
||||
row(RowKind::Subheading, label, vec![])
|
||||
}
|
||||
|
||||
fn spacer() -> Row {
|
||||
row(RowKind::Spacer, "", vec![])
|
||||
}
|
||||
|
||||
/// Finish a statement: currency signs on sums, rules above them.
|
||||
fn statement(title: &str, period: String, columns: &[&str], mut rows: Vec<Row>) -> Statement {
|
||||
let mut above: Option<RowKind> = None;
|
||||
for r in &mut rows {
|
||||
r.currency = matches!(r.kind, RowKind::Subtotal | RowKind::Total);
|
||||
r.rule_above = match r.kind {
|
||||
RowKind::Total => true,
|
||||
RowKind::Subtotal => matches!(above, Some(RowKind::Line | RowKind::Subtotal)),
|
||||
_ => false,
|
||||
};
|
||||
if !matches!(r.kind, RowKind::Spacer | RowKind::Subheading) {
|
||||
above = Some(r.kind);
|
||||
}
|
||||
}
|
||||
Statement {
|
||||
title: title.to_string(),
|
||||
period,
|
||||
columns: columns.iter().map(|c| c.to_string()).collect(),
|
||||
rows,
|
||||
}
|
||||
}
|
||||
|
||||
fn account_lines(lines: &[StatementLine]) -> impl Iterator<Item = Row> + '_ {
|
||||
lines
|
||||
.iter()
|
||||
.map(|l| one(RowKind::Line, format!("{} {}", l.code, l.name), l.amount))
|
||||
}
|
||||
|
||||
/// Lay out a profit and loss statement.
|
||||
pub fn profit_and_loss(pl: &ProfitAndLossStatement) -> Statement {
|
||||
let mut rows = vec![section("Revenue")];
|
||||
rows.extend(account_lines(&pl.revenue));
|
||||
rows.push(one(RowKind::Subtotal, "Total revenue", pl.total_revenue));
|
||||
rows.push(spacer());
|
||||
rows.push(section("Expenses"));
|
||||
rows.extend(account_lines(&pl.expenses));
|
||||
rows.push(one(RowKind::Subtotal, "Total expenses", pl.total_expenses));
|
||||
rows.push(spacer());
|
||||
rows.push(one(RowKind::Total, "Net income (loss)", pl.net_income()));
|
||||
statement(
|
||||
"Profit and Loss",
|
||||
period_label(pl.start, pl.end),
|
||||
&["Total"],
|
||||
rows,
|
||||
)
|
||||
}
|
||||
|
||||
/// The fiscal year's earnings, as the balance sheet presents them.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct YearEarnings {
|
||||
/// Net income (loss) for the fiscal year through the as-of date.
|
||||
pub net_income_ytd: Decimal,
|
||||
/// The part of it not yet closed into equity accounts.
|
||||
pub unclosed: Decimal,
|
||||
}
|
||||
|
||||
/// Lay out a balance sheet.
|
||||
///
|
||||
/// Without members (corporate convention), equity ends with "Retained
|
||||
/// earnings" — the Retained Earnings account plus earlier years' earnings
|
||||
/// not yet closed — and the fiscal year's "Net income (loss)".
|
||||
///
|
||||
/// With members (a partnership), each member's capital account already
|
||||
/// carries the earnings closed into it, so equity ends with only what
|
||||
/// hasn't been allocated: "Undistributed earnings, prior years" (if any)
|
||||
/// and this year's unclosed "Net income (loss)". Equity is "Members'
|
||||
/// equity".
|
||||
pub fn balance_sheet(
|
||||
bs: &BalanceSheetStatement,
|
||||
earnings: YearEarnings,
|
||||
members: &[Member],
|
||||
) -> Statement {
|
||||
let equity_name = if members.is_empty() {
|
||||
"Equity"
|
||||
} else {
|
||||
"Members' equity"
|
||||
};
|
||||
let mut rows = vec![section("Assets")];
|
||||
rows.extend(account_lines(&bs.assets));
|
||||
rows.push(one(RowKind::Subtotal, "Total assets", bs.total_assets));
|
||||
rows.push(spacer());
|
||||
rows.push(section("Liabilities"));
|
||||
rows.extend(account_lines(&bs.liabilities));
|
||||
rows.push(one(
|
||||
RowKind::Subtotal,
|
||||
"Total liabilities",
|
||||
bs.total_liabilities,
|
||||
));
|
||||
rows.push(spacer());
|
||||
rows.push(section(equity_name));
|
||||
if members.is_empty() {
|
||||
let (retained, other): (Vec<_>, Vec<_>) = bs
|
||||
.equity
|
||||
.iter()
|
||||
.partition(|l| l.code == RETAINED_EARNINGS_CODE);
|
||||
rows.extend(
|
||||
other
|
||||
.iter()
|
||||
.map(|l| one(RowKind::Line, format!("{} {}", l.code, l.name), l.amount)),
|
||||
);
|
||||
let retained: Decimal = retained.iter().map(|l| l.amount).sum::<Decimal>()
|
||||
+ bs.current_earnings
|
||||
- earnings.net_income_ytd;
|
||||
if !retained.is_zero() {
|
||||
rows.push(one(RowKind::Line, "Retained earnings", retained));
|
||||
}
|
||||
rows.push(one(
|
||||
RowKind::Line,
|
||||
"Net income (loss)",
|
||||
earnings.net_income_ytd,
|
||||
));
|
||||
} else {
|
||||
rows.extend(account_lines(&bs.equity));
|
||||
let prior = bs.current_earnings - earnings.unclosed;
|
||||
if !prior.is_zero() {
|
||||
rows.push(one(
|
||||
RowKind::Line,
|
||||
"Undistributed earnings, prior years",
|
||||
prior,
|
||||
));
|
||||
}
|
||||
rows.push(one(RowKind::Line, "Net income (loss)", earnings.unclosed));
|
||||
}
|
||||
rows.push(one(
|
||||
RowKind::Subtotal,
|
||||
format!("Total {}", equity_name.to_lowercase()),
|
||||
bs.total_equity,
|
||||
));
|
||||
rows.push(spacer());
|
||||
rows.push(one(
|
||||
RowKind::Total,
|
||||
format!("Total liabilities and {}", equity_name.to_lowercase()),
|
||||
bs.total_liabilities + bs.total_equity,
|
||||
));
|
||||
statement(
|
||||
"Balance Sheet",
|
||||
format!("As of {}", long_date(bs.as_of)),
|
||||
&["Total"],
|
||||
rows,
|
||||
)
|
||||
}
|
||||
|
||||
/// Cash flow lines, naming members on their capital accounts.
|
||||
fn flow_lines<'a>(
|
||||
lines: &'a [CashFlowLine],
|
||||
members: &'a [Member],
|
||||
) -> impl Iterator<Item = Row> + 'a {
|
||||
lines.iter().map(move |l| {
|
||||
let member = members.iter().find(|m| {
|
||||
let code = l.account.as_deref();
|
||||
code == Some(m.capital_code.as_str()) || code == m.distribution_code.as_deref()
|
||||
});
|
||||
let label = match member {
|
||||
Some(m) if l.amount >= Decimal::ZERO => format!("Member contributions — {}", m.name),
|
||||
Some(m) => format!("Member distributions — {}", m.name),
|
||||
None => l.label.clone(),
|
||||
};
|
||||
one(RowKind::Line, label, l.amount)
|
||||
})
|
||||
}
|
||||
|
||||
/// Lay out a cash flow statement (indirect method).
|
||||
pub fn cash_flow(cf: &CashFlowStatement, members: &[Member]) -> Statement {
|
||||
let mut rows = vec![section("Operating activities")];
|
||||
rows.push(one(RowKind::Line, "Net income (loss)", cf.net_income));
|
||||
if !cf.depreciation.is_zero() || !cf.working_capital.is_empty() {
|
||||
rows.push(subheading(
|
||||
"Adjustments to reconcile net income (loss) to net cash from operating activities:",
|
||||
));
|
||||
}
|
||||
if !cf.depreciation.is_zero() {
|
||||
rows.push(one(RowKind::Line, "Depreciation", cf.depreciation));
|
||||
}
|
||||
rows.extend(flow_lines(&cf.working_capital, members));
|
||||
rows.push(one(
|
||||
RowKind::Subtotal,
|
||||
"Net cash from operating activities",
|
||||
cf.cash_from_operations,
|
||||
));
|
||||
rows.push(spacer());
|
||||
rows.push(section("Investing activities"));
|
||||
rows.extend(flow_lines(&cf.investing, members));
|
||||
rows.push(one(
|
||||
RowKind::Subtotal,
|
||||
"Net cash from investing activities",
|
||||
cf.cash_from_investing,
|
||||
));
|
||||
rows.push(spacer());
|
||||
rows.push(section("Financing activities"));
|
||||
rows.extend(flow_lines(&cf.financing, members));
|
||||
rows.push(one(
|
||||
RowKind::Subtotal,
|
||||
"Net cash from financing activities",
|
||||
cf.cash_from_financing,
|
||||
));
|
||||
rows.push(spacer());
|
||||
rows.push(one(
|
||||
RowKind::Subtotal,
|
||||
"Net increase (decrease) in cash",
|
||||
cf.net_change_in_cash,
|
||||
));
|
||||
rows.push(one(
|
||||
RowKind::Line,
|
||||
"Cash at beginning of period",
|
||||
cf.cash_beginning,
|
||||
));
|
||||
rows.push(one(RowKind::Total, "Cash at end of period", cf.cash_ending));
|
||||
statement(
|
||||
"Statement of Cash Flows",
|
||||
period_label(cf.start, cf.end),
|
||||
&["Total"],
|
||||
rows,
|
||||
)
|
||||
}
|
||||
|
||||
/// Lay out a trial balance as of a date: Debit and Credit columns, a zero
|
||||
/// side left blank.
|
||||
pub fn trial_balance(tb: &TrialBalance, as_of: NaiveDate) -> Statement {
|
||||
let nz = |d: Decimal| if d.is_zero() { None } else { Some(d) };
|
||||
let mut rows: Vec<Row> = tb
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
row(
|
||||
RowKind::Line,
|
||||
format!("{} {}", l.code, l.name),
|
||||
vec![nz(l.debit), nz(l.credit)],
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
rows.push(row(
|
||||
RowKind::Total,
|
||||
"Total",
|
||||
vec![Some(tb.total_debits), Some(tb.total_credits)],
|
||||
));
|
||||
statement(
|
||||
"Trial Balance",
|
||||
format!("As of {}", long_date(as_of)),
|
||||
&["Debit", "Credit"],
|
||||
rows,
|
||||
)
|
||||
}
|
||||
|
||||
/// Lay out the statement of changes in members' equity: one column per
|
||||
/// member and a total. Distributions print as reductions.
|
||||
pub fn members_equity(me: &MembersEquityStatement) -> Statement {
|
||||
let cols: Vec<_> = me
|
||||
.members
|
||||
.iter()
|
||||
.chain(std::iter::once(&me.total))
|
||||
.collect();
|
||||
let pick = |f: &dyn Fn(&ledger_core::MemberEquityColumn) -> Decimal| {
|
||||
cols.iter().map(|c| Some(f(c))).collect::<Vec<_>>()
|
||||
};
|
||||
let mut rows = vec![
|
||||
row(RowKind::Line, "Beginning balance", pick(&|c| c.beginning)),
|
||||
row(RowKind::Line, "Contributions", pick(&|c| c.contributions)),
|
||||
row(RowKind::Line, "Net income (loss)", pick(&|c| c.net_income)),
|
||||
row(RowKind::Line, "Distributions", pick(&|c| -c.distributions)),
|
||||
];
|
||||
if !me.total.other.is_zero() {
|
||||
rows.push(row(RowKind::Line, "Other changes", pick(&|c| c.other)));
|
||||
}
|
||||
rows.push(row(RowKind::Total, "Ending balance", pick(&|c| c.ending)));
|
||||
let names: Vec<&str> = cols.iter().map(|c| c.name.as_str()).collect();
|
||||
statement(
|
||||
"Statement of Changes in Members' Equity",
|
||||
period_label(me.start, me.end),
|
||||
&names,
|
||||
rows,
|
||||
)
|
||||
}
|
||||
|
||||
/// Lay out the tax summary: book and tax amounts by tax category, the
|
||||
/// difference where they disagree (meals at 50%, penalties at 0%), and any
|
||||
/// account still needing a category.
|
||||
pub fn tax_summary(ts: &ledger_core::TaxSummary) -> Statement {
|
||||
let diff = |b: Decimal, t: Decimal| if b == t { None } else { Some(b - t) };
|
||||
let lines = |ls: &[ledger_core::TaxSummaryLine]| -> Vec<Row> {
|
||||
ls.iter()
|
||||
.map(|l| {
|
||||
let codes: Vec<&str> = l
|
||||
.accounts
|
||||
.iter()
|
||||
.map(|a| a.split_whitespace().next().unwrap_or(""))
|
||||
.collect();
|
||||
row(
|
||||
RowKind::Line,
|
||||
format!("{} ({})", l.label, codes.join(", ")),
|
||||
vec![Some(l.book), Some(l.tax), diff(l.book, l.tax)],
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let total = |kind, label: &str, ls: &[ledger_core::TaxSummaryLine]| {
|
||||
let b: Decimal = ls.iter().map(|l| l.book).sum();
|
||||
let t: Decimal = ls.iter().map(|l| l.tax).sum();
|
||||
row(kind, label, vec![Some(b), Some(t), diff(b, t)])
|
||||
};
|
||||
let mut rows = vec![section("Income")];
|
||||
rows.extend(lines(&ts.income));
|
||||
rows.push(total(RowKind::Subtotal, "Total income", &ts.income));
|
||||
if !ts.cost_of_goods.is_empty() {
|
||||
rows.push(spacer());
|
||||
rows.push(section("Cost of goods sold"));
|
||||
rows.extend(lines(&ts.cost_of_goods));
|
||||
rows.push(total(
|
||||
RowKind::Subtotal,
|
||||
"Total cost of goods sold",
|
||||
&ts.cost_of_goods,
|
||||
));
|
||||
}
|
||||
rows.push(spacer());
|
||||
rows.push(section("Deductions"));
|
||||
rows.extend(lines(&ts.deductions));
|
||||
rows.push(total(RowKind::Subtotal, "Total deductions", &ts.deductions));
|
||||
rows.push(spacer());
|
||||
rows.push(row(
|
||||
RowKind::Total,
|
||||
"Income less deductions",
|
||||
vec![
|
||||
Some(ts.book_net()),
|
||||
Some(ts.tax_net()),
|
||||
diff(ts.book_net(), ts.tax_net()),
|
||||
],
|
||||
));
|
||||
if !ts.uncategorized.is_empty() {
|
||||
rows.push(spacer());
|
||||
rows.push(section("Not yet categorized"));
|
||||
rows.push(subheading(
|
||||
"Assign each a tax category (books.set_tax_category); not included above:",
|
||||
));
|
||||
rows.extend(ts.uncategorized.iter().map(|u| {
|
||||
row(
|
||||
RowKind::Line,
|
||||
u.account.clone(),
|
||||
vec![Some(u.book), None, None],
|
||||
)
|
||||
}));
|
||||
}
|
||||
let title = match ts.form {
|
||||
Some(f) => format!("Tax Summary ({} categories)", f.label()),
|
||||
None => "Tax Summary".to_string(),
|
||||
};
|
||||
statement(
|
||||
&title,
|
||||
period_label(ts.start, ts.end),
|
||||
&["Book", "Tax", "Difference"],
|
||||
rows,
|
||||
)
|
||||
}
|
||||
|
||||
/// "October 11, 2026".
|
||||
pub fn long_date(d: NaiveDate) -> String {
|
||||
d.format("%B %-d, %Y").to_string()
|
||||
}
|
||||
|
||||
/// "October 4 – October 11, 2026", or with both years when they differ.
|
||||
pub fn period_label(start: NaiveDate, end: NaiveDate) -> String {
|
||||
if start.year() == end.year() {
|
||||
format!("{} – {}", start.format("%B %-d"), long_date(end))
|
||||
} else {
|
||||
format!("{} – {}", long_date(start), long_date(end))
|
||||
}
|
||||
}
|
||||
|
||||
/// Exact accounting format: thousands separators, two decimals, negatives
|
||||
/// in parentheses. "1,234.56", "(207.68)", "0.00". Never a float.
|
||||
pub fn format_amount(d: Decimal) -> String {
|
||||
let negative = d.is_sign_negative() && !d.is_zero();
|
||||
let fixed = format!("{:.2}", d.abs());
|
||||
let (int, frac) = fixed.split_once('.').expect("two decimals");
|
||||
let mut grouped = String::new();
|
||||
for (i, c) in int.chars().enumerate() {
|
||||
if i > 0 && (int.len() - i) % 3 == 0 {
|
||||
grouped.push(',');
|
||||
}
|
||||
grouped.push(c);
|
||||
}
|
||||
if negative {
|
||||
format!("({grouped}.{frac})")
|
||||
} else {
|
||||
format!("{grouped}.{frac}")
|
||||
}
|
||||
}
|
||||
|
||||
/// An amount split for column alignment: the digits, and whether it is a
|
||||
/// negative (printed in parentheses) or zero (printed as 0.00). Renderers
|
||||
/// right-align `digits` and reserve the closing parenthesis's width on every
|
||||
/// row, so decimal points line up whether or not a value is negative.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct AmountParts {
|
||||
/// "1,234.56" — absolute value, grouped, two decimals.
|
||||
pub digits: String,
|
||||
/// Below zero.
|
||||
pub negative: bool,
|
||||
}
|
||||
|
||||
/// Split an amount for aligned accounting display. Exact: no floats.
|
||||
pub fn amount_parts(d: Decimal) -> AmountParts {
|
||||
let rounded = d.round_dp(2);
|
||||
AmountParts {
|
||||
digits: format_amount(rounded.abs()),
|
||||
negative: rounded.is_sign_negative() && !rounded.is_zero(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::str::FromStr;
|
||||
|
||||
fn dec(s: &str) -> Decimal {
|
||||
Decimal::from_str(s).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn amounts_format_exactly() {
|
||||
assert_eq!(format_amount(dec("0")), "0.00");
|
||||
assert_eq!(format_amount(dec("-0")), "0.00");
|
||||
assert_eq!(format_amount(dec("5.8")), "5.80");
|
||||
assert_eq!(format_amount(dec("207.68")), "207.68");
|
||||
assert_eq!(format_amount(dec("-207.68")), "(207.68)");
|
||||
assert_eq!(format_amount(dec("1000")), "1,000.00");
|
||||
assert_eq!(format_amount(dec("1234567.891")), "1,234,567.89");
|
||||
assert_eq!(format_amount(dec("-100000")), "(100,000.00)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn amounts_split_for_alignment() {
|
||||
let p = amount_parts(dec("-1234.5"));
|
||||
assert_eq!((p.digits.as_str(), p.negative), ("1,234.50", true));
|
||||
let z = amount_parts(dec("-0.004"));
|
||||
assert_eq!((z.digits.as_str(), z.negative), ("0.00", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn currency_signs_and_rules_go_on_sums() {
|
||||
let rows = vec![
|
||||
section("Assets"),
|
||||
one(RowKind::Line, "a", dec("1")),
|
||||
one(RowKind::Line, "b", dec("2")),
|
||||
one(RowKind::Subtotal, "t", dec("3")),
|
||||
spacer(),
|
||||
section("Investing"),
|
||||
one(RowKind::Subtotal, "empty", dec("0")),
|
||||
one(RowKind::Total, "T", dec("3")),
|
||||
];
|
||||
let st = statement("X", String::new(), &["Total"], rows);
|
||||
let signs: Vec<bool> = st.rows.iter().map(|r| r.currency).collect();
|
||||
assert_eq!(signs, [false, false, false, true, false, false, true, true]);
|
||||
let rules: Vec<bool> = st.rows.iter().map(|r| r.rule_above).collect();
|
||||
// No rule over an empty section's subtotal; always one over a total.
|
||||
assert_eq!(
|
||||
rules,
|
||||
[false, false, false, true, false, false, false, true]
|
||||
);
|
||||
}
|
||||
|
||||
fn sl(code: &str, name: &str, a: &str) -> StatementLine {
|
||||
StatementLine {
|
||||
code: code.into(),
|
||||
name: name.into(),
|
||||
amount: dec(a),
|
||||
}
|
||||
}
|
||||
|
||||
fn equity_rows(st: &Statement, from: &str) -> Vec<(String, Option<Decimal>)> {
|
||||
st.rows
|
||||
.iter()
|
||||
.skip_while(|r| r.label != from)
|
||||
.map(|r| (r.label.clone(), r.amounts.first().copied().flatten()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corporate_balance_sheet_splits_retained_earnings_from_this_year() {
|
||||
// Prior years closed 1,000 to Retained Earnings; 200 more unclosed
|
||||
// from last year; this year's net income is 300 (current earnings
|
||||
// 500 = 200 + 300).
|
||||
let bs = BalanceSheetStatement {
|
||||
as_of: NaiveDate::from_ymd_opt(2027, 3, 31).unwrap(),
|
||||
assets: vec![sl("1000", "Cash", "2500")],
|
||||
liabilities: vec![],
|
||||
equity: vec![
|
||||
sl("3000", "Common Stock", "1000"),
|
||||
sl("3999", "Retained Earnings", "1000"),
|
||||
],
|
||||
current_earnings: dec("500"),
|
||||
total_assets: dec("2500"),
|
||||
total_liabilities: dec("0"),
|
||||
total_equity: dec("2500"),
|
||||
};
|
||||
let e = YearEarnings {
|
||||
net_income_ytd: dec("300"),
|
||||
unclosed: dec("300"),
|
||||
};
|
||||
let st = balance_sheet(&bs, e, &[]);
|
||||
let some = |s: &str| Some(dec(s));
|
||||
assert_eq!(
|
||||
equity_rows(&st, "Equity"),
|
||||
[
|
||||
("Equity".into(), None),
|
||||
("3000 Common Stock".into(), some("1000")),
|
||||
("Retained earnings".into(), some("1200")),
|
||||
("Net income (loss)".into(), some("300")),
|
||||
("Total equity".into(), some("2500")),
|
||||
("".into(), None),
|
||||
("Total liabilities and equity".into(), some("2500")),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partnership_balance_sheet_shows_only_unallocated_earnings() {
|
||||
// January (100) was closed into the capital accounts; February and
|
||||
// March (200) are this year's unclosed income; 50 from last year
|
||||
// was never closed.
|
||||
let bs = BalanceSheetStatement {
|
||||
as_of: NaiveDate::from_ymd_opt(2027, 3, 31).unwrap(),
|
||||
assets: vec![sl("1000", "Cash", "1350")],
|
||||
liabilities: vec![],
|
||||
equity: vec![
|
||||
sl("3000", "Capital — Alice", "550"),
|
||||
sl("3001", "Capital — Bob", "550"),
|
||||
],
|
||||
current_earnings: dec("250"),
|
||||
total_assets: dec("1350"),
|
||||
total_liabilities: dec("0"),
|
||||
total_equity: dec("1350"),
|
||||
};
|
||||
let e = YearEarnings {
|
||||
net_income_ytd: dec("300"),
|
||||
unclosed: dec("200"),
|
||||
};
|
||||
let members = [
|
||||
Member {
|
||||
name: "Alice".into(),
|
||||
capital_code: "3000".into(),
|
||||
distribution_code: None,
|
||||
},
|
||||
Member {
|
||||
name: "Bob".into(),
|
||||
capital_code: "3001".into(),
|
||||
distribution_code: None,
|
||||
},
|
||||
];
|
||||
let st = balance_sheet(&bs, e, &members);
|
||||
let some = |s: &str| Some(dec(s));
|
||||
assert_eq!(
|
||||
equity_rows(&st, "Members' equity"),
|
||||
[
|
||||
("Members' equity".into(), None),
|
||||
("3000 Capital — Alice".into(), some("550")),
|
||||
("3001 Capital — Bob".into(), some("550")),
|
||||
("Undistributed earnings, prior years".into(), some("50")),
|
||||
("Net income (loss)".into(), some("200")),
|
||||
("Total members' equity".into(), some("1350")),
|
||||
("".into(), None),
|
||||
("Total liabilities and members' equity".into(), some("1350")),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn periods_read_like_english() {
|
||||
let d = |y, m, dd| NaiveDate::from_ymd_opt(y, m, dd).unwrap();
|
||||
assert_eq!(
|
||||
period_label(d(2026, 10, 4), d(2026, 10, 11)),
|
||||
"October 4 – October 11, 2026"
|
||||
);
|
||||
assert_eq!(
|
||||
period_label(d(2026, 12, 1), d(2027, 1, 31)),
|
||||
"December 1, 2026 – January 31, 2027"
|
||||
);
|
||||
let meta = ReportMeta {
|
||||
company: "X".into(),
|
||||
basis: "Accrual".into(),
|
||||
prepared_on: d(2026, 10, 11),
|
||||
journal_seq: 114,
|
||||
};
|
||||
assert_eq!(
|
||||
meta.footer(),
|
||||
"Accrual basis · Prepared Sunday, October 11, 2026 · Journal seq 114"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
//! Starter charts of accounts by entity type.
|
||||
//!
|
||||
//! A new business shouldn't start from a blank chart, and the right chart
|
||||
//! depends on what the business *is*: a sole proprietor has an owner's
|
||||
//! capital and draws; a partnership (or multi-member LLC) has a capital and
|
||||
//! distributions account per partner and pays partners guaranteed payments;
|
||||
//! an S corporation pays officer wages and makes shareholder distributions;
|
||||
//! a C corporation pays dividends and income tax. Each template is a
|
||||
//! proposal for the agent to review with the human before importing (via
|
||||
//! `books.import_chart_csv`), with tax categories already assigned, plus
|
||||
//! the `wanderbooks.toml` settings that go with it (tax form, owners, and
|
||||
//! where period closes go).
|
||||
|
||||
use crate::chart_csv::ChartRow;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
/// The kinds of business a template exists for.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum EntityType {
|
||||
/// Sole proprietorship or single-member LLC (Schedule C).
|
||||
SoleProprietorship,
|
||||
/// Partnership or multi-member LLC (Form 1065).
|
||||
Partnership,
|
||||
/// S corporation, including an LLC electing S status (Form 1120-S).
|
||||
SCorporation,
|
||||
/// C corporation (Form 1120).
|
||||
CCorporation,
|
||||
}
|
||||
|
||||
impl EntityType {
|
||||
/// Parse "sole_proprietorship", "partnership", "s_corporation",
|
||||
/// "c_corporation" and common spellings ("llc" alone is ambiguous).
|
||||
pub fn parse(s: &str) -> Option<EntityType> {
|
||||
let k: String = s
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric())
|
||||
.collect();
|
||||
match k.as_str() {
|
||||
"soleproprietorship" | "soleprop" | "singlememberllc" | "smllc" => {
|
||||
Some(EntityType::SoleProprietorship)
|
||||
}
|
||||
"partnership" | "multimemberllc" | "mmllc" => Some(EntityType::Partnership),
|
||||
"scorporation" | "scorp" | "s" => Some(EntityType::SCorporation),
|
||||
"ccorporation" | "ccorp" | "c" | "corporation" => Some(EntityType::CCorporation),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A starter chart plus the settings that go with it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ChartTemplate {
|
||||
/// Accounts, parents first.
|
||||
pub rows: Vec<ChartRow>,
|
||||
/// The `wanderbooks.toml` additions: tax form, owners, close settings.
|
||||
pub settings_toml: String,
|
||||
/// What the human should decide or know, in plain words.
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
impl ChartTemplate {
|
||||
/// The accounts as chart CSV with the tax_category column.
|
||||
pub fn csv(&self) -> String {
|
||||
let mut out = String::from("code,name,type,parent_code,tax_category\n");
|
||||
for r in &self.rows {
|
||||
let field = |s: &str| {
|
||||
if s.contains([',', '"']) {
|
||||
format!("\"{}\"", s.replace('"', "\"\""))
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
};
|
||||
out.push_str(&format!(
|
||||
"{},{},{},{},{}\n",
|
||||
r.code,
|
||||
field(&r.name),
|
||||
r.account_type,
|
||||
r.parent_code.as_deref().unwrap_or(""),
|
||||
r.tax_category.as_deref().unwrap_or("")
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
fn acct(code: &str, name: &str, t: &str, tax: Option<&str>) -> ChartRow {
|
||||
ChartRow {
|
||||
code: code.to_string(),
|
||||
name: name.to_string(),
|
||||
account_type: t.to_string(),
|
||||
parent_code: None,
|
||||
tax_category: tax.map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
/// Equal shares for `n` owners as percents to two places, the last
|
||||
/// absorbing the remainder so they sum to exactly 100.
|
||||
fn equal_shares(n: usize) -> Vec<Decimal> {
|
||||
let each = (Decimal::from(100) / Decimal::from(n as u64)).round_dp(2);
|
||||
let mut v = vec![each; n];
|
||||
if let Some((last, rest)) = v.split_last_mut() {
|
||||
*last = Decimal::from(100) - rest.iter().sum::<Decimal>();
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Build the template for `entity` with these owners (names; at least two
|
||||
/// for a partnership, which gets a capital and distributions account each).
|
||||
pub fn chart_template(entity: EntityType, owners: &[String]) -> Result<ChartTemplate, String> {
|
||||
use EntityType::*;
|
||||
if entity == Partnership && owners.len() < 2 {
|
||||
return Err("a partnership template needs at least two owners' names".to_string());
|
||||
}
|
||||
if owners.len() > 90 {
|
||||
return Err("at most 90 owners".to_string());
|
||||
}
|
||||
let owed_to = match entity {
|
||||
SoleProprietorship => "Due to Owner",
|
||||
Partnership => "Due to Members",
|
||||
SCorporation | CCorporation => "Due to Shareholders",
|
||||
};
|
||||
let mut rows = vec![
|
||||
acct("1000", "Business Checking", "asset", None),
|
||||
acct("1100", "Savings", "asset", None),
|
||||
acct("1200", "Accounts Receivable", "asset", None),
|
||||
acct("1300", "Prepaid Expenses", "asset", None),
|
||||
acct("1500", "Equipment", "asset", None),
|
||||
acct("1590", "Accumulated Depreciation", "asset", None),
|
||||
acct("2000", "Accounts Payable", "liability", None),
|
||||
acct("2100", "Credit Card", "liability", None),
|
||||
acct("2200", "Sales Tax Payable", "liability", None),
|
||||
acct("2300", "Payroll Liabilities", "liability", None),
|
||||
acct("2400", "Deferred Revenue", "liability", None),
|
||||
acct("2500", owed_to, "liability", None),
|
||||
acct("2700", "Loans Payable", "liability", None),
|
||||
];
|
||||
let mut settings = String::new();
|
||||
let mut notes = vec![
|
||||
"Rename, add, or drop accounts before importing; codes are a convention, not a rule."
|
||||
.to_string(),
|
||||
format!(
|
||||
"{owed_to}: business expenses an owner paid personally, until the business reimburses them."
|
||||
),
|
||||
];
|
||||
match entity {
|
||||
SoleProprietorship => {
|
||||
rows.push(acct("3000", "Owner's Capital", "equity", None));
|
||||
rows.push(acct("3100", "Owner's Draws", "equity", None));
|
||||
settings.push_str("[company]\ntax_form = \"schedule_c\"\n\n[books]\n# Net income and draws close into the owner's capital.\nclose_into = \"3000\"\nclose_sweep = [\"3100\"]\n");
|
||||
notes
|
||||
.push("Money you take out is a draw (3100), not a wage or an expense.".to_string());
|
||||
}
|
||||
Partnership => {
|
||||
let shares = equal_shares(owners.len());
|
||||
settings.push_str("[company]\ntax_form = \"1065\"\n\n# Each partner's share drives allocations and closes. Equal shares are a\n# placeholder: use the operating agreement's numbers.\n");
|
||||
for (i, name) in owners.iter().enumerate() {
|
||||
let cap = format!("{}", 3000 + i);
|
||||
let dist = format!("{}", 3100 + i);
|
||||
rows.push(acct(&cap, &format!("Capital — {name}"), "equity", None));
|
||||
rows.push(acct(
|
||||
&dist,
|
||||
&format!("Distributions — {name}"),
|
||||
"equity",
|
||||
None,
|
||||
));
|
||||
settings.push_str(&format!(
|
||||
"\n[[company.members]]\nname = \"{name}\"\nshare = \"{}%\"\ncapital_account = \"{cap}\"\ndistribution_account = \"{dist}\"\n",
|
||||
shares[i]
|
||||
));
|
||||
}
|
||||
notes.push("A partner paid for work gets guaranteed payments (7200), not wages; profit taken out is a distribution.".to_string());
|
||||
notes.push(
|
||||
"Shares default to equal; set them from the operating agreement.".to_string(),
|
||||
);
|
||||
}
|
||||
SCorporation | CCorporation => {
|
||||
rows.push(acct("3000", "Common Stock", "equity", None));
|
||||
rows.push(acct("3100", "Additional Paid-in Capital", "equity", None));
|
||||
if entity == SCorporation {
|
||||
rows.push(acct("3200", "Shareholder Distributions", "equity", None));
|
||||
settings.push_str("[company]\ntax_form = \"1120s\"\n\n[books]\n# Net income and distributions close into Retained Earnings (3999).\nclose_sweep = [\"3200\"]\n");
|
||||
notes.push("Owner-employees must take reasonable W-2 wages (officer compensation, 7200) before distributions.".to_string());
|
||||
} else {
|
||||
rows.push(acct("3200", "Dividends Paid", "equity", None));
|
||||
settings.push_str("[company]\ntax_form = \"1120\"\n\n[books]\n# Net income and dividends close into Retained Earnings (3999).\nclose_sweep = [\"3200\"]\n");
|
||||
notes.push("The corporation pays its own income tax (7900, not deductible); dividends are not expenses.".to_string());
|
||||
}
|
||||
rows.push(acct("3999", "Retained Earnings", "equity", None));
|
||||
}
|
||||
}
|
||||
rows.extend([
|
||||
acct("4000", "Sales", "revenue", Some("gross_receipts")),
|
||||
acct(
|
||||
"4100",
|
||||
"Refunds and Allowances",
|
||||
"revenue",
|
||||
Some("returns_allowances"),
|
||||
),
|
||||
acct(
|
||||
"4900",
|
||||
"Interest Income",
|
||||
"revenue",
|
||||
Some("interest_income"),
|
||||
),
|
||||
acct("4950", "Other Income", "revenue", Some("other_income")),
|
||||
acct(
|
||||
"5000",
|
||||
"Cost of Goods Sold",
|
||||
"expense",
|
||||
Some("cost_of_goods_sold"),
|
||||
),
|
||||
acct("6000", "Advertising", "expense", Some("advertising")),
|
||||
acct("6050", "Bank Fees", "expense", Some("other_deductions")),
|
||||
acct(
|
||||
"6100",
|
||||
"Platform and Payment Processing Fees",
|
||||
"expense",
|
||||
Some("commissions_fees"),
|
||||
),
|
||||
acct("6150", "Contract Labor", "expense", Some("contract_labor")),
|
||||
acct("6200", "Depreciation", "expense", Some("depreciation")),
|
||||
acct(
|
||||
"6250",
|
||||
"Dues and Memberships",
|
||||
"expense",
|
||||
Some("other_deductions"),
|
||||
),
|
||||
acct("6300", "Insurance", "expense", Some("insurance")),
|
||||
acct(
|
||||
"6350",
|
||||
"Interest Expense",
|
||||
"expense",
|
||||
Some("interest_expense"),
|
||||
),
|
||||
acct(
|
||||
"6400",
|
||||
"Legal and Professional Fees",
|
||||
"expense",
|
||||
Some("legal_professional"),
|
||||
),
|
||||
acct("6450", "Meals", "expense", Some("meals")),
|
||||
acct("6500", "Office Expense", "expense", Some("office_expense")),
|
||||
acct(
|
||||
"6550",
|
||||
"Organizational and Start-up Costs",
|
||||
"expense",
|
||||
Some("organization_startup"),
|
||||
),
|
||||
acct("6600", "Rent", "expense", Some("rent_property")),
|
||||
acct(
|
||||
"6650",
|
||||
"Repairs and Maintenance",
|
||||
"expense",
|
||||
Some("repairs"),
|
||||
),
|
||||
acct(
|
||||
"6700",
|
||||
"Software and Subscriptions",
|
||||
"expense",
|
||||
Some("other_deductions"),
|
||||
),
|
||||
acct("6750", "Supplies", "expense", Some("supplies")),
|
||||
acct(
|
||||
"6800",
|
||||
"Taxes and Licenses",
|
||||
"expense",
|
||||
Some("taxes_licenses"),
|
||||
),
|
||||
acct("6850", "Travel", "expense", Some("travel")),
|
||||
acct("6900", "Utilities", "expense", Some("utilities")),
|
||||
acct(
|
||||
"6950",
|
||||
"Penalties and Nondeductible Expenses",
|
||||
"expense",
|
||||
Some("nondeductible"),
|
||||
),
|
||||
acct(
|
||||
"7000",
|
||||
"Salaries and Wages",
|
||||
"expense",
|
||||
Some("salaries_wages"),
|
||||
),
|
||||
acct("7050", "Payroll Taxes", "expense", Some("taxes_licenses")),
|
||||
acct(
|
||||
"7100",
|
||||
"Employee Benefits",
|
||||
"expense",
|
||||
Some("employee_benefits"),
|
||||
),
|
||||
]);
|
||||
match entity {
|
||||
Partnership => rows.push(acct(
|
||||
"7200",
|
||||
"Guaranteed Payments to Partners",
|
||||
"expense",
|
||||
Some("guaranteed_payments"),
|
||||
)),
|
||||
SCorporation | CCorporation => rows.push(acct(
|
||||
"7200",
|
||||
"Officer Compensation",
|
||||
"expense",
|
||||
Some("officer_compensation"),
|
||||
)),
|
||||
SoleProprietorship => {}
|
||||
}
|
||||
if entity == CCorporation {
|
||||
rows.push(acct(
|
||||
"7900",
|
||||
"Income Tax Expense",
|
||||
"expense",
|
||||
Some("nondeductible"),
|
||||
));
|
||||
}
|
||||
Ok(ChartTemplate {
|
||||
rows,
|
||||
settings_toml: settings,
|
||||
notes,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::chart_csv::parse_chart_csv;
|
||||
|
||||
#[test]
|
||||
fn templates_round_trip_through_the_chart_parser() {
|
||||
for e in [
|
||||
EntityType::SoleProprietorship,
|
||||
EntityType::Partnership,
|
||||
EntityType::SCorporation,
|
||||
EntityType::CCorporation,
|
||||
] {
|
||||
let t = chart_template(e, &["Ann Lee".into(), "Bo, Jr.".into(), "Cy".into()]).unwrap();
|
||||
let parsed = parse_chart_csv(&t.csv()).unwrap();
|
||||
assert_eq!(parsed, t.rows, "{e:?}");
|
||||
// Codes are unique.
|
||||
let mut codes: Vec<&str> = t.rows.iter().map(|r| r.code.as_str()).collect();
|
||||
codes.sort_unstable();
|
||||
codes.dedup();
|
||||
assert_eq!(codes.len(), t.rows.len(), "{e:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equity_fits_the_entity() {
|
||||
let names = |t: &ChartTemplate| -> Vec<String> {
|
||||
t.rows
|
||||
.iter()
|
||||
.filter(|r| r.account_type == "equity")
|
||||
.map(|r| r.name.clone())
|
||||
.collect()
|
||||
};
|
||||
let sole = chart_template(EntityType::SoleProprietorship, &[]).unwrap();
|
||||
assert_eq!(names(&sole), ["Owner's Capital", "Owner's Draws"]);
|
||||
assert!(sole.settings_toml.contains("close_into = \"3000\""));
|
||||
|
||||
let p = chart_template(
|
||||
EntityType::Partnership,
|
||||
&["A".into(), "B".into(), "C".into()],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
names(&p),
|
||||
[
|
||||
"Capital — A",
|
||||
"Distributions — A",
|
||||
"Capital — B",
|
||||
"Distributions — B",
|
||||
"Capital — C",
|
||||
"Distributions — C"
|
||||
]
|
||||
);
|
||||
// Three equal shares that sum to exactly 100.
|
||||
assert!(p.settings_toml.contains("share = \"33.33%\""));
|
||||
assert!(p.settings_toml.contains("share = \"33.34%\""));
|
||||
assert!(
|
||||
p.rows
|
||||
.iter()
|
||||
.any(|r| r.tax_category.as_deref() == Some("guaranteed_payments"))
|
||||
);
|
||||
assert!(chart_template(EntityType::Partnership, &["solo".into()]).is_err());
|
||||
|
||||
let s = chart_template(EntityType::SCorporation, &[]).unwrap();
|
||||
assert!(names(&s).contains(&"Shareholder Distributions".to_string()));
|
||||
assert!(
|
||||
s.rows
|
||||
.iter()
|
||||
.any(|r| r.tax_category.as_deref() == Some("officer_compensation"))
|
||||
);
|
||||
let c = chart_template(EntityType::CCorporation, &[]).unwrap();
|
||||
assert!(names(&c).contains(&"Dividends Paid".to_string()));
|
||||
assert!(c.rows.iter().any(|r| r.name == "Income Tax Expense"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_category_is_valid_for_the_entitys_return() {
|
||||
use ledger_core::{AccountType, TaxForm, tax::check_category};
|
||||
for (e, form) in [
|
||||
(EntityType::SoleProprietorship, TaxForm::ScheduleC),
|
||||
(EntityType::Partnership, TaxForm::Form1065),
|
||||
(EntityType::SCorporation, TaxForm::Form1120S),
|
||||
(EntityType::CCorporation, TaxForm::Form1120),
|
||||
] {
|
||||
for r in chart_template(e, &["A".into(), "B".into()]).unwrap().rows {
|
||||
let Some(cat) = &r.tax_category else { continue };
|
||||
let t = if r.account_type == "revenue" {
|
||||
AccountType::Revenue
|
||||
} else {
|
||||
AccountType::Expense
|
||||
};
|
||||
check_category(cat, t, Some(form))
|
||||
.unwrap_or_else(|err| panic!("{e:?} {}: {err}", r.code));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_types_parse() {
|
||||
assert_eq!(
|
||||
EntityType::parse("multi-member LLC"),
|
||||
Some(EntityType::Partnership)
|
||||
);
|
||||
assert_eq!(EntityType::parse("S corp"), Some(EntityType::SCorporation));
|
||||
assert_eq!(
|
||||
EntityType::parse("sole_proprietorship"),
|
||||
Some(EntityType::SoleProprietorship)
|
||||
);
|
||||
assert_eq!(EntityType::parse("LLC"), None, "ambiguous");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
//! Generate XLSX reports with `rust_xlsxwriter` (MIT).
|
||||
//!
|
||||
//! Money is converted to f64 ONLY at this presentation boundary — never in
|
||||
//! a money path. All accounting math stays `Decimal`; the conversion happens
|
||||
//! when writing a cell, rounded to cents. Cells stay numeric (so a CPA can
|
||||
//! sum them) with accounting number formats: thousands separators,
|
||||
//! negatives in parentheses with the closing parenthesis hanging (so
|
||||
//! decimal points align), "$" on subtotals and totals.
|
||||
|
||||
use crate::report::{ReportMeta, RowKind, Statement};
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::Ledger;
|
||||
use ledger_core::reports::GeneralLedger;
|
||||
use rust_decimal::Decimal;
|
||||
use rust_xlsxwriter::{Format, FormatAlign, FormatBorder, Workbook, Worksheet};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Which report to render.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum XlsxReport<'a> {
|
||||
/// A laid-out statement (P&L, balance sheet, cash flow, trial balance,
|
||||
/// members' equity); see [`crate::report`].
|
||||
Statement(&'a Statement),
|
||||
/// The general ledger: by account, with running balances and splits.
|
||||
GeneralLedger(&'a GeneralLedger),
|
||||
/// The general journal: every posted entry in `[start, end]`, line by
|
||||
/// line, in date order.
|
||||
GeneralJournal(&'a Ledger, NaiveDate, NaiveDate),
|
||||
}
|
||||
|
||||
/// XLSX errors.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum XlsxError {
|
||||
/// The writer failed.
|
||||
#[error("xlsx write failed: {0}")]
|
||||
Write(String),
|
||||
}
|
||||
|
||||
impl From<rust_xlsxwriter::XlsxError> for XlsxError {
|
||||
fn from(e: rust_xlsxwriter::XlsxError) -> Self {
|
||||
XlsxError::Write(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Accounting number formats: the `_)` reserves a closing parenthesis's
|
||||
/// width on positives so decimal points line up with negatives.
|
||||
const PLAIN: &str = "#,##0.00_);(#,##0.00)";
|
||||
const DOLLARS: &str = "$#,##0.00_);($#,##0.00)";
|
||||
|
||||
struct Formats {
|
||||
bold: Format,
|
||||
company: Format,
|
||||
centered: Format,
|
||||
header: Format,
|
||||
header_right: Format,
|
||||
plain: Format,
|
||||
dollars_sum: Format,
|
||||
dollars_total: Format,
|
||||
line_label: Format,
|
||||
sub_label: Format,
|
||||
note: Format,
|
||||
}
|
||||
|
||||
impl Formats {
|
||||
fn new() -> Self {
|
||||
let bold = Format::new().set_bold();
|
||||
let ruled = |f: Format| f.set_border_top(FormatBorder::Thin);
|
||||
Self {
|
||||
company: Format::new()
|
||||
.set_font_size(14)
|
||||
.set_align(FormatAlign::Center),
|
||||
centered: Format::new().set_align(FormatAlign::Center),
|
||||
header: bold
|
||||
.clone()
|
||||
.set_font_size(9)
|
||||
.set_border_bottom(FormatBorder::Thin),
|
||||
header_right: bold
|
||||
.clone()
|
||||
.set_font_size(9)
|
||||
.set_border_bottom(FormatBorder::Thin)
|
||||
.set_align(FormatAlign::Right),
|
||||
plain: Format::new().set_num_format(PLAIN),
|
||||
dollars_sum: ruled(bold.clone().set_num_format(DOLLARS)),
|
||||
dollars_total: ruled(bold.clone().set_num_format(DOLLARS))
|
||||
.set_border_bottom(FormatBorder::Double),
|
||||
line_label: Format::new().set_indent(1),
|
||||
sub_label: Format::new().set_italic().set_indent(1),
|
||||
note: Format::new()
|
||||
.set_italic()
|
||||
.set_font_size(9)
|
||||
.set_font_color("#666666")
|
||||
.set_align(FormatAlign::Center),
|
||||
bold,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Company, title, and period centered over `width` columns; returns the
|
||||
/// next free row.
|
||||
fn header(
|
||||
sheet: &mut Worksheet,
|
||||
f: &Formats,
|
||||
meta: &ReportMeta,
|
||||
title: &str,
|
||||
period: &str,
|
||||
width: u16,
|
||||
) -> Result<u32, XlsxError> {
|
||||
let last = width.saturating_sub(1);
|
||||
sheet.merge_range(0, 0, 0, last, &meta.company, &f.company)?;
|
||||
sheet.merge_range(
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
last,
|
||||
title,
|
||||
&f.bold.clone().set_align(FormatAlign::Center),
|
||||
)?;
|
||||
sheet.merge_range(2, 0, 2, last, period, &f.centered)?;
|
||||
Ok(4)
|
||||
}
|
||||
|
||||
/// Basis, preparation date, and journal seq under the last row.
|
||||
fn footer(
|
||||
sheet: &mut Worksheet,
|
||||
f: &Formats,
|
||||
meta: &ReportMeta,
|
||||
row: u32,
|
||||
width: u16,
|
||||
) -> Result<(), XlsxError> {
|
||||
sheet.merge_range(
|
||||
row + 1,
|
||||
0,
|
||||
row + 1,
|
||||
width.saturating_sub(1),
|
||||
&meta.footer(),
|
||||
&f.note,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn number(
|
||||
sheet: &mut Worksheet,
|
||||
row: u32,
|
||||
col: u16,
|
||||
d: Decimal,
|
||||
fmt: &Format,
|
||||
) -> Result<(), XlsxError> {
|
||||
sheet.write_number_with_format(row, col, dec_to_f64(d), fmt)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Render a report to XLSX bytes.
|
||||
pub fn report_to_xlsx(report: &XlsxReport<'_>, meta: &ReportMeta) -> Result<Vec<u8>, XlsxError> {
|
||||
let mut workbook = Workbook::new();
|
||||
let sheet = workbook.add_worksheet();
|
||||
let f = Formats::new();
|
||||
match report {
|
||||
XlsxReport::Statement(st) => statement(sheet, &f, meta, st)?,
|
||||
XlsxReport::GeneralLedger(gl) => general_ledger(sheet, &f, meta, gl)?,
|
||||
XlsxReport::GeneralJournal(ledger, start, end) => {
|
||||
general_journal(sheet, &f, meta, ledger, *start, *end)?
|
||||
}
|
||||
}
|
||||
Ok(workbook.save_to_buffer()?)
|
||||
}
|
||||
|
||||
fn statement(
|
||||
sheet: &mut Worksheet,
|
||||
f: &Formats,
|
||||
meta: &ReportMeta,
|
||||
st: &Statement,
|
||||
) -> Result<(), XlsxError> {
|
||||
// Excel sheet names: at most 31 characters, no apostrophes at the ends.
|
||||
let name: String = st.title.replace('\'', "").chars().take(31).collect();
|
||||
sheet.set_name(name.trim())?;
|
||||
let width = 1 + st.columns.len() as u16;
|
||||
let mut row = header(sheet, f, meta, &st.title, &st.period, width)?;
|
||||
sheet.write_string_with_format(row, 0, "", &f.header)?;
|
||||
for (i, c) in st.columns.iter().enumerate() {
|
||||
sheet.write_string_with_format(row, 1 + i as u16, c.to_uppercase(), &f.header_right)?;
|
||||
}
|
||||
row += 1;
|
||||
let dollars_unruled = f.bold.clone().set_num_format(DOLLARS);
|
||||
for r in &st.rows {
|
||||
let (label_fmt, amount_fmt) = match r.kind {
|
||||
RowKind::Spacer => {
|
||||
row += 1;
|
||||
continue;
|
||||
}
|
||||
RowKind::Section => (&f.bold, &f.plain),
|
||||
RowKind::Subheading => (&f.sub_label, &f.plain),
|
||||
RowKind::Line => (&f.line_label, &f.plain),
|
||||
RowKind::Subtotal if r.rule_above => (&f.bold, &f.dollars_sum),
|
||||
RowKind::Subtotal => (&f.bold, &dollars_unruled),
|
||||
RowKind::Total => (&f.bold, &f.dollars_total),
|
||||
};
|
||||
sheet.write_string_with_format(row, 0, &r.label, label_fmt)?;
|
||||
for (i, a) in r.amounts.iter().enumerate() {
|
||||
match a {
|
||||
Some(d) => number(sheet, row, 1 + i as u16, *d, amount_fmt)?,
|
||||
None if r.rule_above => {
|
||||
sheet.write_blank(row, 1 + i as u16, amount_fmt)?;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
row += 1;
|
||||
}
|
||||
sheet.set_column_width(0, 58)?;
|
||||
for i in 0..st.columns.len() as u16 {
|
||||
sheet.set_column_width(1 + i, 16)?;
|
||||
}
|
||||
footer(sheet, f, meta, row, width)
|
||||
}
|
||||
|
||||
fn general_ledger(
|
||||
sheet: &mut Worksheet,
|
||||
f: &Formats,
|
||||
meta: &ReportMeta,
|
||||
gl: &GeneralLedger,
|
||||
) -> Result<(), XlsxError> {
|
||||
sheet.set_name("General Ledger")?;
|
||||
let period = crate::report::period_label(gl.start, gl.end);
|
||||
let cols = [
|
||||
"Date",
|
||||
"Entry",
|
||||
"Description",
|
||||
"Split",
|
||||
"Debit",
|
||||
"Credit",
|
||||
"Balance",
|
||||
];
|
||||
let mut row = header(sheet, f, meta, "General Ledger", &period, cols.len() as u16)?;
|
||||
for (i, h) in cols.iter().enumerate() {
|
||||
let fmt = if i >= 4 { &f.header_right } else { &f.header };
|
||||
sheet.write_string_with_format(row, i as u16, h.to_uppercase(), fmt)?;
|
||||
}
|
||||
row += 1;
|
||||
for a in &gl.accounts {
|
||||
let name = format!("{} {}", a.code, a.name);
|
||||
sheet.write_string_with_format(row, 0, &name, &f.bold)?;
|
||||
row += 1;
|
||||
sheet.write_string_with_format(row, 2, "Beginning balance", &f.line_label)?;
|
||||
number(sheet, row, 6, a.beginning_balance, &f.plain)?;
|
||||
row += 1;
|
||||
for p in &a.postings {
|
||||
sheet.write_string(row, 0, p.date.to_string())?;
|
||||
sheet.write_number(row, 1, p.entry_id as f64)?;
|
||||
sheet.write_string(row, 2, &p.description)?;
|
||||
sheet.write_string(row, 3, &p.split)?;
|
||||
if !p.debit.is_zero() {
|
||||
number(sheet, row, 4, p.debit, &f.plain)?;
|
||||
}
|
||||
if !p.credit.is_zero() {
|
||||
number(sheet, row, 5, p.credit, &f.plain)?;
|
||||
}
|
||||
number(sheet, row, 6, p.balance, &f.plain)?;
|
||||
row += 1;
|
||||
}
|
||||
sheet.write_string_with_format(row, 0, format!("Total for {name}"), &f.bold)?;
|
||||
for c in 1..4 {
|
||||
sheet.write_blank(row, c, &f.dollars_sum)?;
|
||||
}
|
||||
number(sheet, row, 4, a.total_debits, &f.dollars_sum)?;
|
||||
number(sheet, row, 5, a.total_credits, &f.dollars_sum)?;
|
||||
number(sheet, row, 6, a.ending_balance, &f.dollars_sum)?;
|
||||
row += 2;
|
||||
}
|
||||
for (c, w) in [
|
||||
(0, 12.0),
|
||||
(1, 7.0),
|
||||
(2, 46.0),
|
||||
(3, 34.0),
|
||||
(4, 13.0),
|
||||
(5, 13.0),
|
||||
(6, 13.0),
|
||||
] {
|
||||
sheet.set_column_width(c, w)?;
|
||||
}
|
||||
footer(sheet, f, meta, row, cols.len() as u16)
|
||||
}
|
||||
|
||||
fn general_journal(
|
||||
sheet: &mut Worksheet,
|
||||
f: &Formats,
|
||||
meta: &ReportMeta,
|
||||
ledger: &Ledger,
|
||||
start: NaiveDate,
|
||||
end: NaiveDate,
|
||||
) -> Result<(), XlsxError> {
|
||||
sheet.set_name("General Journal")?;
|
||||
let period = crate::report::period_label(start, end);
|
||||
let cols = [
|
||||
"Date",
|
||||
"Entry",
|
||||
"Description",
|
||||
"Account",
|
||||
"Class",
|
||||
"Debit",
|
||||
"Credit",
|
||||
];
|
||||
let mut row = header(
|
||||
sheet,
|
||||
f,
|
||||
meta,
|
||||
"General Journal",
|
||||
&period,
|
||||
cols.len() as u16,
|
||||
)?;
|
||||
for (i, h) in cols.iter().enumerate() {
|
||||
let fmt = if i >= 5 { &f.header_right } else { &f.header };
|
||||
sheet.write_string_with_format(row, i as u16, h.to_uppercase(), fmt)?;
|
||||
}
|
||||
row += 1;
|
||||
let mut entries: Vec<_> = ledger
|
||||
.posted_entries()
|
||||
.filter(|e| e.date() >= start && e.date() <= end)
|
||||
.collect();
|
||||
entries.sort_by_key(|e| (e.date(), e.id()));
|
||||
let (mut debits, mut credits) = (Decimal::ZERO, Decimal::ZERO);
|
||||
for entry in entries {
|
||||
for line in entry.lines() {
|
||||
let account = ledger
|
||||
.chart()
|
||||
.get(line.account)
|
||||
.map(|a| format!("{} {}", a.code, a.name))
|
||||
.unwrap_or_else(|| "?".to_string());
|
||||
let class = line
|
||||
.class_id
|
||||
.and_then(|cid| ledger.get_class(cid))
|
||||
.map(|c| c.name.as_str())
|
||||
.unwrap_or("");
|
||||
sheet.write_string(row, 0, entry.date().to_string())?;
|
||||
sheet.write_number(row, 1, entry.id() as f64)?;
|
||||
sheet.write_string(row, 2, entry.description())?;
|
||||
sheet.write_string(row, 3, &account)?;
|
||||
sheet.write_string(row, 4, class)?;
|
||||
match line.side {
|
||||
ledger_core::Side::Debit => {
|
||||
number(sheet, row, 5, line.amount.amount, &f.plain)?;
|
||||
debits += line.amount.amount;
|
||||
}
|
||||
ledger_core::Side::Credit => {
|
||||
number(sheet, row, 6, line.amount.amount, &f.plain)?;
|
||||
credits += line.amount.amount;
|
||||
}
|
||||
}
|
||||
row += 1;
|
||||
}
|
||||
}
|
||||
sheet.write_string_with_format(row, 0, "Total", &f.bold)?;
|
||||
for c in 1..5 {
|
||||
sheet.write_blank(row, c, &f.dollars_total)?;
|
||||
}
|
||||
number(sheet, row, 5, debits, &f.dollars_total)?;
|
||||
number(sheet, row, 6, credits, &f.dollars_total)?;
|
||||
for (c, w) in [
|
||||
(0, 12.0),
|
||||
(1, 7.0),
|
||||
(2, 50.0),
|
||||
(3, 38.0),
|
||||
(4, 12.0),
|
||||
(5, 13.0),
|
||||
(6, 13.0),
|
||||
] {
|
||||
sheet.set_column_width(c, w)?;
|
||||
}
|
||||
footer(sheet, f, meta, row + 1, cols.len() as u16)
|
||||
}
|
||||
|
||||
/// Decimal to f64 at the presentation boundary. Rounded to cents first.
|
||||
fn dec_to_f64(d: Decimal) -> f64 {
|
||||
use rust_decimal::prelude::ToPrimitive;
|
||||
d.round_dp(2).to_f64().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::report;
|
||||
use ledger_core::reports::{TrialBalance, TrialBalanceLine};
|
||||
|
||||
#[test]
|
||||
fn xlsx_is_a_zip() {
|
||||
let tb = TrialBalance {
|
||||
lines: vec![TrialBalanceLine {
|
||||
code: "1000".to_string(),
|
||||
name: "Cash".to_string(),
|
||||
debit: Decimal::new(10000, 2),
|
||||
credit: Decimal::ZERO,
|
||||
}],
|
||||
total_debits: Decimal::new(10000, 2),
|
||||
total_credits: Decimal::new(10000, 2),
|
||||
};
|
||||
let meta = ReportMeta {
|
||||
company: "Test Co".to_string(),
|
||||
basis: "Accrual".to_string(),
|
||||
prepared_on: NaiveDate::from_ymd_opt(2026, 10, 11).unwrap(),
|
||||
journal_seq: 7,
|
||||
};
|
||||
let st = report::trial_balance(&tb, NaiveDate::from_ymd_opt(2026, 10, 11).unwrap());
|
||||
let bytes = report_to_xlsx(&XlsxReport::Statement(&st), &meta).unwrap();
|
||||
// XLSX is a ZIP: starts with PK.
|
||||
assert_eq!(&bytes[0..2], b"PK");
|
||||
assert!(bytes.len() > 1000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// A financial statement: P&L, balance sheet, cash flow, trial balance, or
|
||||
// members' equity, in US small-business (QuickBooks-style) presentation.
|
||||
//
|
||||
// Everything arrives as data in sys.inputs.data (JSON), already laid out by
|
||||
// ledger-import::report, with amounts already split into exact parts. The
|
||||
// template only typesets: no arithmetic happens here, and no label is ever
|
||||
// parsed as markup.
|
||||
//
|
||||
// Shape: { company, title, period, footer, columns: ["Total"] | [...],
|
||||
// rows: [{ kind: "section" | "subheading" | "line" | "subtotal"
|
||||
// | "total" | "spacer",
|
||||
// label, rule (rule above), currency ("$" on amounts),
|
||||
// amounts: [null | { digits: "1,234.56", negative }] }] }
|
||||
//
|
||||
// Amounts: Inter's tabular figures; negatives in parentheses with the
|
||||
// closing parenthesis hanging (its width is reserved on every row, so
|
||||
// decimal points always line up); "$" attached on subtotals and totals.
|
||||
|
||||
#let d = json(bytes(sys.inputs.data))
|
||||
#let ncols = d.columns.len()
|
||||
#let ink = luma(25)
|
||||
#let quiet = luma(110)
|
||||
#let hair = 0.5pt + luma(160)
|
||||
#let firm = 0.6pt + ink
|
||||
|
||||
#set document(title: d.company + " — " + d.title, author: d.company)
|
||||
#set text(font: "Inter", size: 9pt, fill: ink, number-width: "tabular")
|
||||
#set page(
|
||||
paper: "us-letter",
|
||||
margin: (x: 0.75in, top: 0.75in, bottom: 0.75in),
|
||||
header: context {
|
||||
if counter(page).get().first() > 1 {
|
||||
set text(7.5pt, fill: quiet)
|
||||
[#d.company · #d.title #h(1fr) #d.period]
|
||||
}
|
||||
},
|
||||
footer: context {
|
||||
set text(7.5pt, fill: quiet)
|
||||
grid(
|
||||
columns: (1fr, auto, 1fr),
|
||||
[],
|
||||
d.footer,
|
||||
align(right)[Page #counter(page).display() of #counter(page).final().first()],
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
#align(center)[
|
||||
#text(15pt, fill: luma(45), d.company) \
|
||||
#v(4pt)
|
||||
#text(10.5pt, weight: "bold", d.title) \
|
||||
#v(2pt)
|
||||
#d.period
|
||||
]
|
||||
#v(12pt)
|
||||
|
||||
#let amount(a, currency) = {
|
||||
if a == none { return [] }
|
||||
let open = if a.negative { [(] } else { [] }
|
||||
let close = if a.negative { [)] } else { hide[)] }
|
||||
let sign = if currency [\$] else []
|
||||
[#open#sign#a.digits#close]
|
||||
}
|
||||
|
||||
#let full(body, ..args) = table.cell(colspan: ncols + 1, ..args, body)
|
||||
|
||||
#let row-cells(r) = {
|
||||
let bold = r.kind == "subtotal" or r.kind == "total"
|
||||
let stroke = if r.rule { (top: if r.kind == "total" { firm } else { hair }) } else { none }
|
||||
let label = if r.kind == "line" { pad(left: 1.4em, r.label) } else { r.label }
|
||||
let label = if bold { text(weight: "bold", label) } else { label }
|
||||
(
|
||||
table.cell(stroke: stroke, label),
|
||||
..r.amounts.map(a => table.cell(stroke: stroke, amount(a, r.currency))),
|
||||
)
|
||||
}
|
||||
|
||||
#table(
|
||||
columns: (1fr,) + (8em,) * ncols,
|
||||
stroke: none,
|
||||
inset: (x: 4pt, y: 3.6pt),
|
||||
align: (left + horizon,) + (right + horizon,) * ncols,
|
||||
table.header(
|
||||
table.cell(stroke: (top: firm, bottom: hair), []),
|
||||
..d.columns.map(c => table.cell(
|
||||
stroke: (top: firm, bottom: hair),
|
||||
text(7.5pt, weight: "bold", tracking: 0.04em, upper(c)),
|
||||
)),
|
||||
),
|
||||
..d
|
||||
.rows
|
||||
.map(r => {
|
||||
if r.kind == "spacer" {
|
||||
(full(v(2pt), inset: 2pt),)
|
||||
} else if r.kind == "section" {
|
||||
(full(text(weight: "semibold", r.label)),)
|
||||
} else if r.kind == "subheading" {
|
||||
(full(pad(left: 1.4em, emph(r.label))),)
|
||||
} else if r.kind == "total" {
|
||||
// The double rule under a statement total, full width.
|
||||
let double = full(
|
||||
inset: (x: 0pt, y: 0pt),
|
||||
stack(
|
||||
spacing: 1.6pt,
|
||||
line(length: 100%, stroke: firm),
|
||||
line(length: 100%, stroke: firm),
|
||||
),
|
||||
)
|
||||
row-cells(r) + (double,)
|
||||
} else {
|
||||
row-cells(r)
|
||||
}
|
||||
})
|
||||
.flatten(),
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
[package]
|
||||
name = "ledger-mcp"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "MCP server for wanderbooks: the AI-first bookkeeping interface over ledger-store."
|
||||
|
||||
[[bin]]
|
||||
name = "ledger-mcp"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
ledger-core = { path = "../ledger-core" }
|
||||
ledger-store = { path = "../ledger-store" }
|
||||
ledger-import = { path = "../ledger-import" }
|
||||
ledger-config = { path = "../ledger-config" }
|
||||
base64 = "0.22"
|
||||
mime_guess = "2"
|
||||
rust_decimal = "1"
|
||||
rmcp = { version = "3", features = ["server", "transport-io", "schemars", "transport-streamable-http-server"] }
|
||||
axum = "0.8"
|
||||
tower = "0.5"
|
||||
schemars = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
chrono = "0.4"
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "sync"] }
|
||||
zip = "9.0.1"
|
||||
toml = "1"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,178 @@
|
||||
//! `ledger-mcp`: the AI-first bookkeeping interface, over stdio.
|
||||
//!
|
||||
//! Usage: `ledger-mcp [--data-dir PATH] [--config PATH] [--books-toml PATH]`
|
||||
//!
|
||||
//! The data dir holds `journal.jsonl` (the source of truth), `wanderbooks.db`
|
||||
//! (the disposable SQLite projection), `blobs/` (content-addressed receipts),
|
||||
//! and optionally `config.json`. If no store exists, one is created with the
|
||||
//! configured base currency. Company settings (name, fiscal year start,
|
||||
//! receipt threshold) come from the books dir's `wanderbooks.toml`, by
|
||||
//! default the one next to the data dir.
|
||||
|
||||
use ledger_config as company;
|
||||
mod policy;
|
||||
mod server;
|
||||
mod types;
|
||||
|
||||
use ledger_core::Currency;
|
||||
use ledger_store::Store;
|
||||
use policy::ReceiptPolicy;
|
||||
use rmcp::{ServiceExt, transport::stdio};
|
||||
use serde::Deserialize;
|
||||
use server::BooksServer;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Server configuration, JSON. All fields optional.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct Config {
|
||||
/// ISO 4217 base currency for a new store. Default "USD".
|
||||
#[serde(default)]
|
||||
base_currency: Option<String>,
|
||||
/// Receipt policy. Overrides `wanderbooks.toml`'s receipt_threshold;
|
||||
/// with neither, receipts are required above $25.00.
|
||||
#[serde(default)]
|
||||
receipt_policy: Option<ReceiptPolicy>,
|
||||
}
|
||||
|
||||
fn default_data_dir() -> PathBuf {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
|
||||
PathBuf::from(home).join(".local/share/wanderbooks")
|
||||
}
|
||||
|
||||
fn print_usage() {
|
||||
eprintln!(
|
||||
"usage: ledger-mcp [--data-dir PATH] [--config PATH] [--books-toml PATH] [--http ADDR]"
|
||||
);
|
||||
eprintln!(" --data-dir directory holding journal.jsonl (default ~/.local/share/wanderbooks)");
|
||||
eprintln!(" --config JSON config file (default <data-dir>/config.json, optional)");
|
||||
eprintln!(
|
||||
" --books-toml company settings (default <data-dir>/../wanderbooks.toml, optional)"
|
||||
);
|
||||
eprintln!(
|
||||
" --http serve over Streamable HTTP at ADDR (e.g. 127.0.0.1:8080) instead of stdio"
|
||||
);
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut data_dir = default_data_dir();
|
||||
let mut config_path: Option<PathBuf> = None;
|
||||
let mut books_toml: Option<PathBuf> = None;
|
||||
let mut http_addr: Option<String> = None;
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--data-dir" => {
|
||||
data_dir = PathBuf::from(args.next().ok_or("--data-dir needs a path")?);
|
||||
}
|
||||
"--config" => {
|
||||
config_path = Some(PathBuf::from(args.next().ok_or("--config needs a path")?));
|
||||
}
|
||||
"--books-toml" => {
|
||||
books_toml = Some(PathBuf::from(
|
||||
args.next().ok_or("--books-toml needs a path")?,
|
||||
));
|
||||
}
|
||||
"--http" => {
|
||||
http_addr = Some(args.next().ok_or("--http needs an address")?);
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
print_usage();
|
||||
return Ok(());
|
||||
}
|
||||
other => {
|
||||
eprintln!("unknown argument: {other}");
|
||||
print_usage();
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
let config_path = config_path.unwrap_or_else(|| data_dir.join("config.json"));
|
||||
let config: Config = if config_path.exists() {
|
||||
let raw = std::fs::read_to_string(&config_path)?;
|
||||
serde_json::from_str(&raw)
|
||||
.map_err(|e| format!("bad config {}: {e}", config_path.display()))?
|
||||
} else {
|
||||
Config::default()
|
||||
};
|
||||
// Logs go to stderr: stdout is the MCP channel.
|
||||
eprintln!("ledger-mcp: data dir {}", data_dir.display());
|
||||
// An explicit --books-toml must exist; the default is optional.
|
||||
let settings = match books_toml {
|
||||
Some(path) => company::load(&path)?,
|
||||
None => match data_dir.parent().map(|p| p.join("wanderbooks.toml")) {
|
||||
Some(path) if path.exists() => company::load(&path)?,
|
||||
_ => company::BooksSettings::default(),
|
||||
},
|
||||
};
|
||||
eprintln!("ledger-mcp: company {:?}", settings.company.name);
|
||||
let policy = match (config.receipt_policy, settings.receipt_threshold) {
|
||||
(Some(policy), _) => policy,
|
||||
(None, Some(threshold)) => ReceiptPolicy {
|
||||
require_receipts_above: threshold,
|
||||
..ReceiptPolicy::default()
|
||||
},
|
||||
(None, None) => ReceiptPolicy::default(),
|
||||
};
|
||||
let company = settings.company;
|
||||
|
||||
let store = if data_dir.join(ledger_store::JOURNAL_FILE).exists() {
|
||||
Store::open(&data_dir)?
|
||||
} else {
|
||||
let code = config.base_currency.as_deref().unwrap_or("USD");
|
||||
let currency = Currency::new(code).map_err(|_| format!("bad base currency {code:?}"))?;
|
||||
eprintln!("ledger-mcp: creating new store (base currency {code})");
|
||||
Store::create(&data_dir, currency)?
|
||||
};
|
||||
// The journal is the source of truth: verify the chain and the
|
||||
// projection before serving a single tool call.
|
||||
store
|
||||
.verify()
|
||||
.map_err(|e| format!("store verify failed: {e}"))?;
|
||||
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
if let Some(addr) = http_addr {
|
||||
eprintln!("ledger-mcp: store verified, serving Streamable HTTP on {addr}");
|
||||
rt.block_on(serve_http(addr, store, policy, company))?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
eprintln!("ledger-mcp: store verified, serving over stdio");
|
||||
let server = BooksServer::new(store, policy).with_company(company);
|
||||
rt.block_on(async {
|
||||
let running = server.serve(stdio()).await?;
|
||||
let _ = running.waiting().await;
|
||||
Ok::<(), Box<dyn std::error::Error>>(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Serve MCP over Streamable HTTP (for network clients).
|
||||
async fn serve_http(
|
||||
addr: String,
|
||||
store: Store,
|
||||
policy: ReceiptPolicy,
|
||||
company: company::Company,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use rmcp::transport::streamable_http_server::{
|
||||
StreamableHttpService, session::local::LocalSessionManager,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
let store = Arc::new(std::sync::Mutex::new(store));
|
||||
let service = StreamableHttpService::new(
|
||||
move || {
|
||||
let server = server::BooksServer::new_shared(store.clone(), policy.clone())
|
||||
.with_company(company.clone());
|
||||
Ok(server)
|
||||
},
|
||||
Arc::new(LocalSessionManager::default()),
|
||||
Default::default(),
|
||||
);
|
||||
|
||||
let router = axum::Router::new().nest_service("/mcp", service);
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
eprintln!("ledger-mcp: listening on http://{addr}/mcp");
|
||||
axum::serve(listener, router).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
//! The receipt policy: server-side enforcement, not prose.
|
||||
//!
|
||||
//! Posting an entry whose debits are strictly greater than the configured
|
||||
//! threshold requires at least one attached receipt, unless every line is in
|
||||
//! an exempt account. Selected accounts can require receipts regardless of
|
||||
//! amount. Reversals are exempt: the original entry carries the evidence.
|
||||
|
||||
use ledger_core::{JournalEntry, Ledger, Side};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Server-side receipt policy, loaded from config.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ReceiptPolicy {
|
||||
/// Receipts required when an entry's debits are strictly greater than
|
||||
/// this amount. Decimal string, e.g. `"25.00"`.
|
||||
#[serde(default = "default_threshold")]
|
||||
pub require_receipts_above: String,
|
||||
/// Account codes that always require a receipt, regardless of amount.
|
||||
#[serde(default)]
|
||||
pub receipt_required_accounts: Vec<String>,
|
||||
/// Account codes exempt from the policy. The exemption applies only when
|
||||
/// *every* line of the entry is in an exempt account.
|
||||
#[serde(default)]
|
||||
pub receipt_exempt_accounts: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_threshold() -> String {
|
||||
"25.00".to_string()
|
||||
}
|
||||
|
||||
impl Default for ReceiptPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
require_receipts_above: default_threshold(),
|
||||
receipt_required_accounts: Vec::new(),
|
||||
receipt_exempt_accounts: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ReceiptPolicy {
|
||||
fn threshold(&self) -> Decimal {
|
||||
self.require_receipts_above
|
||||
.parse()
|
||||
.unwrap_or(Decimal::from(25))
|
||||
}
|
||||
|
||||
fn account_codes(&self, ledger: &Ledger, entry: &JournalEntry) -> Vec<String> {
|
||||
entry
|
||||
.lines()
|
||||
.iter()
|
||||
.map(|l| {
|
||||
ledger
|
||||
.chart()
|
||||
.get(l.account)
|
||||
.map(|a| a.code.clone())
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Total debits of the entry (one side of the balanced entry).
|
||||
pub fn entry_debits(entry: &JournalEntry) -> Decimal {
|
||||
entry
|
||||
.lines()
|
||||
.iter()
|
||||
.filter(|l| l.side == Side::Debit)
|
||||
.map(|l| l.amount.amount)
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// True when posting this entry requires at least one receipt.
|
||||
pub fn receipt_required(&self, ledger: &Ledger, entry: &JournalEntry) -> bool {
|
||||
let codes = self.account_codes(ledger, entry);
|
||||
let refs: Vec<&str> = codes.iter().map(|s| s.as_str()).collect();
|
||||
self.receipt_required_for(&refs, Self::entry_debits(entry))
|
||||
}
|
||||
|
||||
/// True when an entry touching these account codes with this debit total
|
||||
/// requires at least one receipt. The code-level check behind
|
||||
/// [`Self::receipt_required`], for postings (like disposals) computed
|
||||
/// without a draft entry in hand.
|
||||
pub fn receipt_required_for(&self, codes: &[&str], debits: Decimal) -> bool {
|
||||
// Per-account override: any line in a required account → required.
|
||||
if codes
|
||||
.iter()
|
||||
.any(|c| self.receipt_required_accounts.iter().any(|r| r == *c))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
// Strictly greater than the threshold (Stephen: ">$25").
|
||||
if debits <= self.threshold() {
|
||||
return false;
|
||||
}
|
||||
// Exempt only when every line is in an exempt account.
|
||||
!codes
|
||||
.iter()
|
||||
.all(|c| self.receipt_exempt_accounts.iter().any(|e| e == *c))
|
||||
}
|
||||
|
||||
/// Human-readable policy summary for previews and errors.
|
||||
pub fn describe(&self) -> String {
|
||||
format!(
|
||||
"receipts required when entry debits are strictly greater than {}",
|
||||
self.threshold()
|
||||
)
|
||||
}
|
||||
|
||||
/// Normalize a description for split detection.
|
||||
fn normalize_description(desc: &str) -> String {
|
||||
desc.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
/// Debit account ids of an entry, sorted.
|
||||
fn debit_accounts(entry: &JournalEntry) -> Vec<u64> {
|
||||
let mut ids: Vec<u64> = entry
|
||||
.lines()
|
||||
.iter()
|
||||
.filter(|l| l.side == Side::Debit)
|
||||
.map(|l| l.account)
|
||||
.collect();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
ids
|
||||
}
|
||||
|
||||
/// Detect a potential threshold split: posted entries on the same date
|
||||
/// with the same normalized description and debit accounts, whose
|
||||
/// combined debits with this entry exceed the threshold while this
|
||||
/// entry alone does not. Returns the matching posted entry ids.
|
||||
///
|
||||
/// This is the anti-gaming rule: splitting a $100 purchase into four
|
||||
/// $25 entries does not dodge the receipt policy. Same-day,
|
||||
/// same-description, same-accounts is the signature; recurring spending
|
||||
/// spread across days is not flagged.
|
||||
pub fn split_group(&self, ledger: &Ledger, entry_id: u64, entry: &JournalEntry) -> Vec<u64> {
|
||||
let debits = Self::entry_debits(entry);
|
||||
if debits > self.threshold() {
|
||||
return Vec::new(); // Normal policy already applies.
|
||||
}
|
||||
let desc = Self::normalize_description(entry.description());
|
||||
let accounts = Self::debit_accounts(entry);
|
||||
let date = entry.date();
|
||||
let mut group = Vec::new();
|
||||
let mut total = debits;
|
||||
for other in ledger.posted_entries() {
|
||||
if other.id() == entry_id {
|
||||
continue;
|
||||
}
|
||||
if other.date() != date {
|
||||
continue;
|
||||
}
|
||||
if Self::normalize_description(other.description()) != desc {
|
||||
continue;
|
||||
}
|
||||
if Self::debit_accounts(other) != accounts {
|
||||
continue;
|
||||
}
|
||||
// Reversals are exempt from the policy; don't count them.
|
||||
if other.reversal_of().is_some() {
|
||||
continue;
|
||||
}
|
||||
group.push(other.id());
|
||||
total += Self::entry_debits(other);
|
||||
}
|
||||
if !group.is_empty() && total > self.threshold() {
|
||||
group
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "ledger-store"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Durable persistence for wanderbooks: a hash-chained JSONL journal as the source of truth, content-addressed blob store, and a disposable SQLite projection."
|
||||
|
||||
[dependencies]
|
||||
ledger-core = { path = "../ledger-core" }
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
chrono = "0.4"
|
||||
rust_decimal = "1"
|
||||
thiserror = "2"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
rust_decimal = "1"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,86 @@
|
||||
//! The blob store: content-addressed, immutable bytes.
|
||||
//!
|
||||
//! Files are keyed by the SHA-256 of their exact bytes and laid out sharded
|
||||
//! by hash prefix: `<root>/<aa>/<bb>/<full-hex>`. Reads re-hash and compare,
|
||||
//! so corruption is detected, never served. Writes are atomic (temp file +
|
||||
//! rename) and fsync'd. Blobs are never modified or deleted by the
|
||||
//! application.
|
||||
|
||||
use crate::error::StoreError;
|
||||
use crate::journal::sha256_hex;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Content-addressed file store rooted at `root`.
|
||||
pub struct BlobStore {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl BlobStore {
|
||||
/// Open (creating) the blob store at `root`.
|
||||
pub fn open(root: &Path) -> Result<Self, StoreError> {
|
||||
fs::create_dir_all(root)?;
|
||||
Ok(Self {
|
||||
root: root.to_path_buf(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Open an existing blob store for reading, creating nothing. A
|
||||
/// missing directory just means no blobs yet.
|
||||
pub fn open_read_only(root: &Path) -> Self {
|
||||
Self {
|
||||
root: root.to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
fn path_for(&self, sha_hex: &str) -> PathBuf {
|
||||
self.root
|
||||
.join(&sha_hex[0..2])
|
||||
.join(&sha_hex[2..4])
|
||||
.join(sha_hex)
|
||||
}
|
||||
|
||||
/// Store bytes. Returns `(sha256_hex, deduplicated)`. Identical bytes
|
||||
/// store once: the second put is a no-op that reports `deduplicated`.
|
||||
pub fn put(&self, bytes: &[u8]) -> Result<(String, bool), StoreError> {
|
||||
let sha = sha256_hex(bytes);
|
||||
let path = self.path_for(&sha);
|
||||
if path.exists() {
|
||||
return Ok((sha, true));
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
// Atomic write: temp file in the same directory, fsync, rename.
|
||||
let tmp = path.with_extension("tmp");
|
||||
{
|
||||
let mut f = fs::File::create(&tmp)?;
|
||||
use std::io::Write;
|
||||
f.write_all(bytes)?;
|
||||
f.sync_all()?;
|
||||
}
|
||||
fs::rename(&tmp, &path)?;
|
||||
// Fsync the directory so the rename itself is durable.
|
||||
let dir = fs::File::open(path.parent().unwrap())?;
|
||||
dir.sync_all()?;
|
||||
Ok((sha, false))
|
||||
}
|
||||
|
||||
/// Read bytes, verifying the SHA-256. A mismatch is corruption, and it
|
||||
/// is reported — never served.
|
||||
pub fn get(&self, sha_hex: &str) -> Result<Vec<u8>, StoreError> {
|
||||
let sha_hex = sha_hex.to_lowercase();
|
||||
let bytes = fs::read(self.path_for(&sha_hex))?;
|
||||
let actual = sha256_hex(&bytes);
|
||||
if actual != sha_hex {
|
||||
return Err(StoreError::CorruptBlob(sha_hex));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// True when the blob exists (content not verified; use [`Self::get`]
|
||||
/// for a verifying read).
|
||||
pub fn exists(&self, sha_hex: &str) -> bool {
|
||||
self.path_for(&sha_hex.to_lowercase()).exists()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! Error type for the store.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Every way a store operation can fail.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum StoreError {
|
||||
/// Filesystem error.
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
/// SQLite error.
|
||||
#[error("sqlite error: {0}")]
|
||||
Rusqlite(#[from] rusqlite::Error),
|
||||
|
||||
/// JSON error.
|
||||
#[error("json error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
/// The engine rejected the operation. The journal is untouched.
|
||||
#[error("ledger error: {0}")]
|
||||
Ledger(#[from] ledger_core::LedgerError),
|
||||
|
||||
/// The journal failed integrity verification.
|
||||
#[error("journal corrupt: {0}")]
|
||||
JournalCorrupt(String),
|
||||
|
||||
/// Replay produced different ids than the journal recorded.
|
||||
#[error("replay mismatch: {0}")]
|
||||
ReplayMismatch(String),
|
||||
|
||||
/// [`crate::Store::verify`] found divergence.
|
||||
#[error("verify failed: {0}")]
|
||||
VerifyMismatch(String),
|
||||
|
||||
/// A blob's bytes did not match its SHA-256.
|
||||
#[error("blob corrupt: hash mismatch for {0}")]
|
||||
CorruptBlob(String),
|
||||
|
||||
/// Referenced attachment does not exist.
|
||||
#[error("unknown attachment {0}")]
|
||||
UnknownAttachment(u64),
|
||||
|
||||
/// The attachment is not linked to any entry.
|
||||
#[error("attachment {0} is not linked to an entry")]
|
||||
AttachmentNotLinked(u64),
|
||||
|
||||
/// The attachment is linked to an entry; unlink it first.
|
||||
#[error("attachment {0} is linked to entry {1}; unlink it first with books.unlink_receipt")]
|
||||
AttachmentLinked(u64, u64),
|
||||
|
||||
/// The attachment was withdrawn and can no longer be linked or changed.
|
||||
#[error("attachment {0} is withdrawn")]
|
||||
AttachmentWithdrawn(u64),
|
||||
|
||||
/// A correction to the evidence trail needs a reason.
|
||||
#[error("a non-empty reason is required")]
|
||||
ReasonRequired,
|
||||
|
||||
/// The store's schema is newer than this binary understands.
|
||||
#[error("schema version {0} newer than supported {1}")]
|
||||
SchemaTooNew(u32, u32),
|
||||
|
||||
/// `create` called where a store already exists.
|
||||
#[error("store already exists at {0}")]
|
||||
AlreadyExists(String),
|
||||
|
||||
/// `open` called where no store exists.
|
||||
#[error("store not found at {0}")]
|
||||
NotFound(String),
|
||||
|
||||
/// The requested base currency differs from the store's.
|
||||
#[error("base currency mismatch: store is {0}, requested {1}")]
|
||||
CurrencyMismatch(String, String),
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
//! The journal: the source of truth.
|
||||
//!
|
||||
//! An append-only file of JSON objects, one per line (JSONL), hash-chained:
|
||||
//! each line carries the SHA-256 of the previous line's canonical bytes.
|
||||
//! Bit rot, torn writes, and casual editing are detected on read — never
|
||||
//! silently absorbed.
|
||||
//!
|
||||
//! Canonical form is keys-sorted, whitespace-free JSON. The writer always
|
||||
//! emits canonical form, so parse → re-serialize round-trips byte-identically
|
||||
//! and the chain verifies.
|
||||
|
||||
use crate::error::StoreError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// `prev` value of the first journal line: 64 zeros.
|
||||
pub const GENESIS_PREV: &str = "0000000000000000000000000000000000000000000000000000000000000000";
|
||||
|
||||
/// One journal line: a sequenced, timestamped, hash-chained domain event.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct JournalLine {
|
||||
/// 1-based, gapless sequence number.
|
||||
pub seq: u64,
|
||||
/// UTC timestamp, RFC 3339. Recorded, never trusted for ordering.
|
||||
pub ts: String,
|
||||
/// Event type: `account.added`, `entry.created`, `entry.line_added`,
|
||||
/// `entry.posted`, `entry.reversed`, `entry.line_class_set`,
|
||||
/// `receipt.attached`, `receipt.linked`, `receipt.unlinked`,
|
||||
/// `document.filed`, `attachment.withdrawn`,
|
||||
/// `asset.registered`, `asset.depreciation_posted`, `asset.disposed`,
|
||||
/// `period.created`, `period.closed`, `line.cleared`, `customer.added`,
|
||||
/// `vendor.added`, `invoice.created`, `bill.created`,
|
||||
/// `invoice.payment_received`, `bill.paid`, `fx.rate_set`,
|
||||
/// `payroll.run`, `class.added`, `store.created`.
|
||||
#[serde(rename = "type")]
|
||||
pub line_type: String,
|
||||
/// The full domain facts. Everything a replay needs.
|
||||
pub payload: Value,
|
||||
/// Hex SHA-256 of the previous line's canonical bytes.
|
||||
pub prev: String,
|
||||
}
|
||||
|
||||
impl JournalLine {
|
||||
/// Canonical bytes of this line: keys sorted recursively, no whitespace.
|
||||
pub fn to_canonical(&self) -> String {
|
||||
canonical_json(&serde_json::to_value(self).expect("JournalLine serializes"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively key-sorted, whitespace-free JSON.
|
||||
pub fn canonical_json(value: &Value) -> String {
|
||||
serde_json::to_string(&sort_value(value)).expect("JSON serialization cannot fail")
|
||||
}
|
||||
|
||||
fn sort_value(v: &Value) -> Value {
|
||||
match v {
|
||||
Value::Object(map) => {
|
||||
let mut keys: Vec<&String> = map.keys().collect();
|
||||
keys.sort();
|
||||
let mut sorted = serde_json::Map::with_capacity(map.len());
|
||||
for k in keys {
|
||||
sorted.insert(k.clone(), sort_value(&map[k]));
|
||||
}
|
||||
Value::Object(sorted)
|
||||
}
|
||||
Value::Array(arr) => Value::Array(arr.iter().map(sort_value).collect()),
|
||||
_ => v.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Hex SHA-256 of bytes.
|
||||
pub fn sha256_hex(bytes: &[u8]) -> String {
|
||||
let mut h = Sha256::new();
|
||||
h.update(bytes);
|
||||
hex::encode(h.finalize())
|
||||
}
|
||||
|
||||
/// Append-only writer. Assigns `seq`, stamps `ts`, chains `prev`, fsyncs
|
||||
/// every append. The sequence only advances after a successful fsync, so a
|
||||
/// torn tail can never exist in a file written by this writer.
|
||||
pub struct JournalWriter {
|
||||
path: PathBuf,
|
||||
file: File,
|
||||
next_seq: u64,
|
||||
prev_hash: String,
|
||||
}
|
||||
|
||||
impl JournalWriter {
|
||||
/// Open an existing journal (verifying its chain) or create a new one.
|
||||
pub fn open(path: &Path) -> Result<Self, StoreError> {
|
||||
let lines = if path.exists() {
|
||||
read_and_verify_journal(path)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Self::from_lines(path, &lines)
|
||||
}
|
||||
|
||||
/// Build a writer from already-verified lines (avoids re-scanning).
|
||||
pub(crate) fn from_lines(path: &Path, lines: &[JournalLine]) -> Result<Self, StoreError> {
|
||||
let (next_seq, prev_hash) = match lines.last() {
|
||||
Some(last) => (last.seq + 1, sha256_hex(last.to_canonical().as_bytes())),
|
||||
None => (1, GENESIS_PREV.to_string()),
|
||||
};
|
||||
if !path.exists()
|
||||
&& let Some(parent) = path.parent()
|
||||
{
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let file = OpenOptions::new().create(true).append(true).open(path)?;
|
||||
Ok(Self {
|
||||
path: path.to_path_buf(),
|
||||
file,
|
||||
next_seq,
|
||||
prev_hash,
|
||||
})
|
||||
}
|
||||
|
||||
/// Append one event. Returns the journaled line.
|
||||
pub fn append(&mut self, line_type: &str, payload: Value) -> Result<JournalLine, StoreError> {
|
||||
let line = JournalLine {
|
||||
seq: self.next_seq,
|
||||
ts: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
|
||||
line_type: line_type.to_string(),
|
||||
payload,
|
||||
prev: self.prev_hash.clone(),
|
||||
};
|
||||
let bytes = line.to_canonical();
|
||||
self.file.write_all(bytes.as_bytes())?;
|
||||
self.file.write_all(b"\n")?;
|
||||
self.file.sync_all()?;
|
||||
self.prev_hash = sha256_hex(bytes.as_bytes());
|
||||
self.next_seq += 1;
|
||||
Ok(line)
|
||||
}
|
||||
|
||||
/// The seq the next append will use.
|
||||
pub fn next_seq(&self) -> u64 {
|
||||
self.next_seq
|
||||
}
|
||||
|
||||
/// Path of the journal file.
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
}
|
||||
|
||||
/// Read and fully verify a journal: every line parses, seqs are gapless from
|
||||
/// 1, and the hash chain is intact. Any failure names the offending seq.
|
||||
pub fn read_and_verify_journal(path: &Path) -> Result<Vec<JournalLine>, StoreError> {
|
||||
let file = File::open(path)?;
|
||||
let reader = BufReader::new(file);
|
||||
let mut lines = Vec::new();
|
||||
let mut expected_prev = GENESIS_PREV.to_string();
|
||||
for (i, raw) in reader.lines().enumerate() {
|
||||
let raw = raw?;
|
||||
let line: JournalLine = serde_json::from_str(&raw).map_err(|e| {
|
||||
StoreError::JournalCorrupt(format!("seq {}: unparseable line: {e}", i + 1))
|
||||
})?;
|
||||
let want_seq = i as u64 + 1;
|
||||
if line.seq != want_seq {
|
||||
return Err(StoreError::JournalCorrupt(format!(
|
||||
"seq gap: expected {want_seq}, found {}",
|
||||
line.seq
|
||||
)));
|
||||
}
|
||||
if line.prev != expected_prev {
|
||||
return Err(StoreError::JournalCorrupt(format!(
|
||||
"hash chain broken at seq {}",
|
||||
line.seq
|
||||
)));
|
||||
}
|
||||
expected_prev = sha256_hex(line.to_canonical().as_bytes());
|
||||
lines.push(line);
|
||||
}
|
||||
Ok(lines)
|
||||
}
|
||||
|
||||
/// Truncate a journal file to its last good line. For recovering *copies*
|
||||
/// with a torn tail (e.g. a backup taken mid-write): the projection never
|
||||
/// saw the torn line, so dropping it is safe. Returns the last good seq.
|
||||
/// The live journal written by [`JournalWriter`] cannot have a torn tail.
|
||||
pub fn truncate_to_last_good_seq(path: &Path) -> Result<u64, StoreError> {
|
||||
let raw = std::fs::read(path)?;
|
||||
let mut good_len = 0usize;
|
||||
let mut last_good_seq = 0u64;
|
||||
let mut expected_prev = GENESIS_PREV.to_string();
|
||||
// Walk raw bytes: a line is bytes up to and including b'\n'. Trailing
|
||||
// bytes without a newline are a torn tail and are dropped.
|
||||
let mut start = 0usize;
|
||||
for (i, &b) in raw.iter().enumerate() {
|
||||
if b != b'\n' {
|
||||
continue;
|
||||
}
|
||||
let line_bytes = &raw[start..i];
|
||||
start = i + 1;
|
||||
let line_str = std::str::from_utf8(line_bytes).map_err(|_| {
|
||||
StoreError::JournalCorrupt(format!("seq {}: invalid utf-8", last_good_seq + 1))
|
||||
})?;
|
||||
if line_str.trim().is_empty() {
|
||||
break; // blank line: our writer never emits these
|
||||
}
|
||||
match serde_json::from_str::<JournalLine>(line_str) {
|
||||
Ok(line) if line.seq == last_good_seq + 1 && line.prev == expected_prev => {
|
||||
expected_prev = sha256_hex(line.to_canonical().as_bytes());
|
||||
last_good_seq = line.seq;
|
||||
good_len = i + 1; // include the newline
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
let file = OpenOptions::new().write(true).open(path)?;
|
||||
file.set_len(good_len as u64)?;
|
||||
file.sync_all()?;
|
||||
Ok(last_good_seq)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//! `ledger-store`: durable persistence for the books.
|
||||
//!
|
||||
//! Three pieces, one job — keep the books safe:
|
||||
//!
|
||||
//! * [`journal`] — the source of truth. An append-only, hash-chained JSONL
|
||||
//! journal. Every state change is an immutable fact, fsync'd on append.
|
||||
//! * [`blob`] — the bytes. Content-addressed (SHA-256), immutable,
|
||||
//! deduplicated receipt and document storage.
|
||||
//! * [`Store`] — the SQLite projection plus the write path. SQLite is the
|
||||
//! query engine and is disposable by design: [`Store::rebuild`] drops it
|
||||
//! and replays the journal. [`Store::verify`] replays the journal into a
|
||||
//! fresh engine and diffs its reports against both the live ledger and
|
||||
//! the projection.
|
||||
//!
|
||||
//! The write path is singular: **validate → journal → mutate → apply**.
|
||||
//! Journal first means a failed write leaves nothing behind; a failed
|
||||
//! SQLite apply leaves the journal ahead, and the next open heals it via
|
||||
//! `last_applied_seq`. There is exactly one writer: `Store` is `!Sync` by
|
||||
//! construction (it owns a `rusqlite::Connection`).
|
||||
|
||||
pub mod blob;
|
||||
pub mod error;
|
||||
pub mod journal;
|
||||
pub mod schema;
|
||||
pub mod snapshot;
|
||||
pub mod store;
|
||||
|
||||
pub use blob::BlobStore;
|
||||
pub use error::StoreError;
|
||||
pub use journal::{GENESIS_PREV, JournalLine, JournalWriter};
|
||||
pub use snapshot::Snapshot;
|
||||
pub use store::{
|
||||
Attachment, DB_FILE, DepreciationRun, DisposalPreview, EntryRow, EventRow, JOURNAL_FILE,
|
||||
PayrollLineInput, Store,
|
||||
};
|
||||
@@ -0,0 +1,199 @@
|
||||
//! SQLite schema and migrations.
|
||||
//!
|
||||
//! The projection schema. Amounts are TEXT (decimal strings) — never REAL.
|
||||
//! Migrations are numbered SQL scripts applied in order; `SCHEMA_VERSION`
|
||||
//! is the current version.
|
||||
|
||||
/// Current schema version.
|
||||
pub const SCHEMA_VERSION: u32 = 7;
|
||||
|
||||
/// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`.
|
||||
pub const MIGRATIONS: &[&str] = &[
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS meta(
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS accounts(
|
||||
id INTEGER PRIMARY KEY,
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
parent INTEGER NULL,
|
||||
placeholder INTEGER NOT NULL,
|
||||
currency TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS entries(
|
||||
id INTEGER PRIMARY KEY,
|
||||
date TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
reversal_of INTEGER NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS lines(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entry_id INTEGER NOT NULL,
|
||||
account_id INTEGER NOT NULL,
|
||||
side TEXT NOT NULL,
|
||||
amount TEXT NOT NULL,
|
||||
currency TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS blobs(
|
||||
sha256 TEXT PRIMARY KEY,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
original_name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS attachments(
|
||||
id INTEGER PRIMARY KEY,
|
||||
blob_sha TEXT NOT NULL,
|
||||
entry_id INTEGER NULL,
|
||||
category TEXT NOT NULL,
|
||||
description TEXT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS events(
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_lines_entry ON lines(entry_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_date ON entries(date);
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_entry ON attachments(entry_id);
|
||||
"#,
|
||||
r#"
|
||||
-- v2: fixed-asset register and depreciation runs.
|
||||
CREATE TABLE IF NOT EXISTS assets(
|
||||
id INTEGER PRIMARY KEY,
|
||||
description TEXT NOT NULL,
|
||||
purchase_date TEXT NOT NULL,
|
||||
cost_amount TEXT NOT NULL,
|
||||
cost_currency TEXT NOT NULL,
|
||||
salvage_amount TEXT NOT NULL,
|
||||
salvage_currency TEXT NOT NULL,
|
||||
useful_life_months INTEGER NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
cost_account INTEGER NOT NULL,
|
||||
accum_depr_account INTEGER NOT NULL,
|
||||
expense_account INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
disposed_date TEXT NULL,
|
||||
proceeds_amount TEXT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS asset_depreciation(
|
||||
asset_id INTEGER NOT NULL,
|
||||
period TEXT NOT NULL,
|
||||
entry_id INTEGER NOT NULL,
|
||||
amount TEXT NOT NULL,
|
||||
PRIMARY KEY (asset_id, period)
|
||||
);
|
||||
"#,
|
||||
r#"
|
||||
-- v3: accounting periods, A/R + A/P, cleared-line flags, FX rates, and
|
||||
-- foreign-currency columns on lines. Amounts are TEXT, never REAL.
|
||||
CREATE TABLE IF NOT EXISTS periods(
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
start TEXT NOT NULL,
|
||||
end TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
closing_entry INTEGER NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS customers(
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS vendors(
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS invoices(
|
||||
id INTEGER PRIMARY KEY,
|
||||
customer_id INTEGER NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
due_date TEXT NOT NULL,
|
||||
lines_json TEXT NOT NULL,
|
||||
amount_paid TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
entry_id INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS bills(
|
||||
id INTEGER PRIMARY KEY,
|
||||
vendor_id INTEGER NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
due_date TEXT NOT NULL,
|
||||
lines_json TEXT NOT NULL,
|
||||
amount_paid TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
entry_id INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS cleared_lines(
|
||||
entry_id INTEGER NOT NULL,
|
||||
line_index INTEGER NOT NULL,
|
||||
PRIMARY KEY (entry_id, line_index)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS fx_rates(
|
||||
currency TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
rate TEXT NOT NULL,
|
||||
PRIMARY KEY (currency, date)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_invoices_customer ON invoices(customer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_bills_vendor ON bills(vendor_id);
|
||||
ALTER TABLE lines ADD COLUMN foreign_currency TEXT;
|
||||
ALTER TABLE lines ADD COLUMN foreign_amount TEXT;
|
||||
"#,
|
||||
r#"
|
||||
-- v4: classes, payroll runs, and class_id on lines. Amounts are TEXT,
|
||||
-- never REAL.
|
||||
CREATE TABLE IF NOT EXISTS classes(
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS payroll_runs(
|
||||
id INTEGER PRIMARY KEY,
|
||||
date TEXT NOT NULL,
|
||||
entry_id INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS payroll_lines(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
run_id INTEGER NOT NULL,
|
||||
employee_name TEXT NOT NULL,
|
||||
gross_pay TEXT NOT NULL,
|
||||
federal_withholding TEXT NOT NULL,
|
||||
state_withholding TEXT NOT NULL,
|
||||
ss_employee TEXT NOT NULL,
|
||||
medicare_employee TEXT NOT NULL,
|
||||
ss_employer TEXT NOT NULL,
|
||||
medicare_employer TEXT NOT NULL,
|
||||
other_deductions TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_payroll_lines_run ON payroll_lines(run_id);
|
||||
ALTER TABLE lines ADD COLUMN class_id INTEGER;
|
||||
"#,
|
||||
r#"
|
||||
-- v5: withdrawn attachments. Withdrawal retires a receipt or document from
|
||||
-- listings and linking; the bytes stay in the blob store and the journal
|
||||
-- keeps the filing and the withdrawal.
|
||||
ALTER TABLE attachments ADD COLUMN withdrawn_at TEXT;
|
||||
ALTER TABLE attachments ADD COLUMN withdrawn_reason TEXT;
|
||||
"#,
|
||||
r#"
|
||||
-- v6: tax categories on accounts (where each account lands on the return,
|
||||
-- and how much of it is deductible). Percent is TEXT, never REAL.
|
||||
ALTER TABLE accounts ADD COLUMN tax_category TEXT;
|
||||
ALTER TABLE accounts ADD COLUMN deductible_percent TEXT;
|
||||
"#,
|
||||
r#"
|
||||
-- v7: 1099 tracking: which return each vendor gets, whether its W-9 is on
|
||||
-- file (never the TIN itself), and the vendor each entry paid.
|
||||
ALTER TABLE vendors ADD COLUMN form_1099 TEXT;
|
||||
ALTER TABLE vendors ADD COLUMN tin_on_file INTEGER NOT NULL DEFAULT 0;
|
||||
CREATE TABLE IF NOT EXISTS entry_vendors(
|
||||
entry_id INTEGER PRIMARY KEY,
|
||||
vendor_id INTEGER NOT NULL
|
||||
);
|
||||
"#,
|
||||
];
|
||||
@@ -0,0 +1,175 @@
|
||||
//! [`Snapshot`]: a read-only view of the books, for viewers.
|
||||
//!
|
||||
//! A snapshot never writes to the data directory. It reads the journal,
|
||||
//! verifies the hash chain, replays it into its own [`Ledger`], and projects
|
||||
//! it into a private in-memory SQLite database with exactly the code the
|
||||
//! writer uses, so attachments and events read the same as through
|
||||
//! [`crate::Store`]. Blobs are read (and verified) straight from the blob
|
||||
//! store.
|
||||
//!
|
||||
//! [`Snapshot::refresh`] follows a live journal: it reads only the bytes
|
||||
//! appended since the last read, verifies they chain onto what it has, and
|
||||
//! applies them. A line the writer is still in the middle of appending
|
||||
//! (no newline yet) is left for the next refresh, never treated as
|
||||
//! corruption. If the file shrank or no longer chains, the snapshot reloads
|
||||
//! from scratch.
|
||||
|
||||
use crate::blob::BlobStore;
|
||||
use crate::error::StoreError;
|
||||
use crate::journal::{GENESIS_PREV, JournalLine, sha256_hex};
|
||||
use crate::store::{
|
||||
Attachment, BLOBS_DIR, JOURNAL_FILE, Store, apply_line_to_sqlite, query_attachments,
|
||||
read_attachment_bytes, replay_line_into_ledger,
|
||||
};
|
||||
use ledger_core::{Currency, EntryId, Ledger};
|
||||
use rusqlite::Connection;
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A read-only, live-refreshable view of a data directory.
|
||||
pub struct Snapshot {
|
||||
data_dir: PathBuf,
|
||||
conn: Connection,
|
||||
blobs: BlobStore,
|
||||
ledger: Ledger,
|
||||
base_currency: Currency,
|
||||
lines: Vec<JournalLine>,
|
||||
/// Bytes of the journal consumed (complete lines only).
|
||||
offset: u64,
|
||||
/// Hash of the last line read: what the next line must chain onto.
|
||||
head: String,
|
||||
}
|
||||
|
||||
impl Snapshot {
|
||||
/// Load the books in `data_dir`, read-only.
|
||||
pub fn load(data_dir: &Path) -> Result<Self, StoreError> {
|
||||
let journal_path = data_dir.join(JOURNAL_FILE);
|
||||
if !journal_path.exists() {
|
||||
return Err(StoreError::NotFound(data_dir.display().to_string()));
|
||||
}
|
||||
let conn = Connection::open_in_memory()?;
|
||||
Store::migrate(&conn)?;
|
||||
let mut snap = Self {
|
||||
data_dir: data_dir.to_path_buf(),
|
||||
conn,
|
||||
blobs: BlobStore::open_read_only(&data_dir.join(BLOBS_DIR)),
|
||||
ledger: Ledger::new(Currency::USD),
|
||||
base_currency: Currency::USD,
|
||||
lines: Vec::new(),
|
||||
offset: 0,
|
||||
head: GENESIS_PREV.to_string(),
|
||||
};
|
||||
snap.read_new()?;
|
||||
Ok(snap)
|
||||
}
|
||||
|
||||
/// Apply whatever the writer appended since the last read. Returns the
|
||||
/// number of new journal lines (0 when nothing changed).
|
||||
pub fn refresh(&mut self) -> Result<usize, StoreError> {
|
||||
let len = std::fs::metadata(self.data_dir.join(JOURNAL_FILE))?.len();
|
||||
if len < self.offset {
|
||||
// Rewritten underneath us (e.g. restored from a backup).
|
||||
*self = Self::load(&self.data_dir)?;
|
||||
return Ok(self.lines.len());
|
||||
}
|
||||
match self.read_new() {
|
||||
Ok(n) => Ok(n),
|
||||
Err(StoreError::JournalCorrupt(_)) => {
|
||||
// No longer chains onto what we have: start over, and let a
|
||||
// real corruption surface from the full load.
|
||||
*self = Self::load(&self.data_dir)?;
|
||||
Ok(self.lines.len())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_new(&mut self) -> Result<usize, StoreError> {
|
||||
let mut file = std::fs::File::open(self.data_dir.join(JOURNAL_FILE))?;
|
||||
file.seek(SeekFrom::Start(self.offset))?;
|
||||
let mut buf = Vec::new();
|
||||
file.read_to_end(&mut buf)?;
|
||||
// Complete lines only: a torn tail is a write still in progress.
|
||||
let Some(last_newline) = buf.iter().rposition(|&b| b == b'\n') else {
|
||||
return Ok(0);
|
||||
};
|
||||
let complete = &buf[..=last_newline];
|
||||
let mut added = 0;
|
||||
for raw in complete.split(|&b| b == b'\n').filter(|l| !l.is_empty()) {
|
||||
let line: JournalLine = serde_json::from_slice(raw).map_err(|e| {
|
||||
StoreError::JournalCorrupt(format!(
|
||||
"seq {}: unparseable line: {e}",
|
||||
self.lines.len() + 1
|
||||
))
|
||||
})?;
|
||||
let want_seq = self.lines.len() as u64 + 1;
|
||||
if line.seq != want_seq {
|
||||
return Err(StoreError::JournalCorrupt(format!(
|
||||
"seq gap: expected {want_seq}, found {}",
|
||||
line.seq
|
||||
)));
|
||||
}
|
||||
if line.prev != self.head {
|
||||
return Err(StoreError::JournalCorrupt(format!(
|
||||
"hash chain broken at seq {}",
|
||||
line.seq
|
||||
)));
|
||||
}
|
||||
if line.seq == 1 {
|
||||
let code = line.payload["base_currency"].as_str().unwrap_or("USD");
|
||||
self.base_currency = Currency::new(code)
|
||||
.map_err(|_| StoreError::JournalCorrupt(format!("bad base_currency {code}")))?;
|
||||
self.ledger = Ledger::new(self.base_currency);
|
||||
}
|
||||
replay_line_into_ledger(&mut self.ledger, &line, self.base_currency)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
self.head = sha256_hex(line.to_canonical().as_bytes());
|
||||
self.lines.push(line);
|
||||
added += 1;
|
||||
}
|
||||
self.offset += complete.len() as u64;
|
||||
Ok(added)
|
||||
}
|
||||
|
||||
/// The replayed ledger: the source for every report.
|
||||
pub fn ledger(&self) -> &Ledger {
|
||||
&self.ledger
|
||||
}
|
||||
|
||||
/// The books' base currency.
|
||||
pub fn base_currency(&self) -> Currency {
|
||||
self.base_currency
|
||||
}
|
||||
|
||||
/// The data directory.
|
||||
pub fn data_dir(&self) -> &Path {
|
||||
&self.data_dir
|
||||
}
|
||||
|
||||
/// Every journal line read so far, in order: the complete history,
|
||||
/// with timestamps and hashes.
|
||||
pub fn journal(&self) -> &[JournalLine] {
|
||||
&self.lines
|
||||
}
|
||||
|
||||
/// Sequence number of the last journal line read.
|
||||
pub fn journal_seq(&self) -> u64 {
|
||||
self.lines.len() as u64
|
||||
}
|
||||
|
||||
/// Hash of the last journal line: the head of the verified chain.
|
||||
pub fn head_hash(&self) -> &str {
|
||||
&self.head
|
||||
}
|
||||
|
||||
/// Attachments (receipts and documents, withdrawn ones included),
|
||||
/// optionally only one entry's.
|
||||
pub fn attachments(&self, entry_id: Option<EntryId>) -> Result<Vec<Attachment>, StoreError> {
|
||||
query_attachments(&self.conn, entry_id)
|
||||
}
|
||||
|
||||
/// An attachment's bytes, verified against its hash.
|
||||
pub fn attachment_bytes(&self, attachment_id: u64) -> Result<Vec<u8>, StoreError> {
|
||||
read_attachment_bytes(&self.conn, &self.blobs, attachment_id)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,235 @@
|
||||
//! Fixed-asset store tests: journaling, idempotency, disposal, and verify.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::{AccountType, Currency, Money, NewAsset, Side};
|
||||
use ledger_store::Store;
|
||||
use rust_decimal::Decimal;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn usd(amount: &str) -> Money {
|
||||
Money::new(amount.parse().unwrap(), Currency::USD)
|
||||
}
|
||||
|
||||
fn date(y: i32, m: u32, d: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, d).unwrap()
|
||||
}
|
||||
|
||||
fn open_store() -> (tempfile::TempDir, Store) {
|
||||
let dir = tempdir().unwrap();
|
||||
let store = Store::create(dir.path(), Currency::USD).unwrap();
|
||||
(dir, store)
|
||||
}
|
||||
|
||||
fn asset_accounts(store: &mut Store) -> (u64, u64, u64, u64, u64) {
|
||||
let cost = store
|
||||
.add_account("1500", "Equipment", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let accum = store
|
||||
.add_account(
|
||||
"1501",
|
||||
"Accumulated Depreciation",
|
||||
AccountType::Asset,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let expense = store
|
||||
.add_account(
|
||||
"6100",
|
||||
"Depreciation Expense",
|
||||
AccountType::Expense,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let bank = store
|
||||
.add_account("1000", "Bank", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let gain = store
|
||||
.add_account(
|
||||
"4900",
|
||||
"Gain on Disposal",
|
||||
AccountType::Revenue,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
(cost, accum, expense, bank, gain)
|
||||
}
|
||||
|
||||
fn van(store: &mut Store, dollars: u64, cost_acct: u64, accum_acct: u64, expense_acct: u64) -> u64 {
|
||||
store
|
||||
.register_asset(NewAsset {
|
||||
description: "Sprinter van".to_string(),
|
||||
purchase_date: date(2026, 1, 15),
|
||||
cost: usd(&dollars.to_string()),
|
||||
salvage_value: usd("0"),
|
||||
useful_life_months: 60,
|
||||
cost_account: cost_acct,
|
||||
accum_depr_account: accum_acct,
|
||||
expense_account: expense_acct,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_survives_reopen_with_stable_ids() {
|
||||
let (dir, mut store) = open_store();
|
||||
let (cost, accum, expense, _, _) = asset_accounts(&mut store);
|
||||
let id = van(&mut store, 60_000, cost, accum, expense);
|
||||
drop(store);
|
||||
|
||||
let mut store = Store::open(dir.path()).unwrap();
|
||||
let asset = store.ledger().get_asset(id).expect("asset must survive");
|
||||
assert_eq!(asset.description, "Sprinter van");
|
||||
assert_eq!(asset.cost.amount, Decimal::from(60_000));
|
||||
// Allocator stability: the next registration continues the sequence.
|
||||
let id2 = van(&mut store, 1_000, cost, accum, expense);
|
||||
assert_eq!(id2, id + 1);
|
||||
store.verify().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depreciation_run_is_idempotent() {
|
||||
let (_dir, mut store) = open_store();
|
||||
let (cost, accum, expense, _, _) = asset_accounts(&mut store);
|
||||
let id = van(&mut store, 60_000, cost, accum, expense);
|
||||
|
||||
let run1 = store.run_depreciation(id, "2026-01").unwrap();
|
||||
assert!(!run1.already_posted);
|
||||
assert_eq!(run1.amount, Decimal::new(54839, 2)); // 1000 * 17/31
|
||||
|
||||
// Second run of the same period is a no-op returning the same entry.
|
||||
let run2 = store.run_depreciation(id, "2026-01").unwrap();
|
||||
assert!(run2.already_posted);
|
||||
assert_eq!(run2.entry_id, run1.entry_id);
|
||||
assert_eq!(run2.amount, run1.amount);
|
||||
|
||||
// A later period posts its own entry.
|
||||
let run3 = store.run_depreciation(id, "2026-02").unwrap();
|
||||
assert!(!run3.already_posted);
|
||||
assert_eq!(run3.amount, Decimal::from(1_000));
|
||||
|
||||
// The depreciation entries hit the right accounts.
|
||||
let entry = store.ledger().get_entry(run3.entry_id).unwrap();
|
||||
assert!(entry.lines().iter().any(|l| l.account == expense
|
||||
&& l.side == Side::Debit
|
||||
&& l.amount.amount == Decimal::from(1_000)));
|
||||
store.verify().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depreciation_rejects_period_outside_life() {
|
||||
let (_dir, mut store) = open_store();
|
||||
let (cost, accum, expense, _, _) = asset_accounts(&mut store);
|
||||
let id = van(&mut store, 60_000, cost, accum, expense);
|
||||
// Before purchase.
|
||||
assert!(store.run_depreciation(id, "2025-12").is_err());
|
||||
// After the 60-month life ends (2031-01).
|
||||
assert!(store.run_depreciation(id, "2031-06").is_err());
|
||||
// Malformed period.
|
||||
assert!(store.run_depreciation(id, "2026-13").is_err());
|
||||
assert!(store.run_depreciation(id, "january").is_err());
|
||||
// Unknown asset.
|
||||
assert!(store.run_depreciation(999, "2026-01").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disposal_posts_balanced_entry_and_marks_asset() {
|
||||
let (_dir, mut store) = open_store();
|
||||
let (cost, accum, expense, bank, gain) = asset_accounts(&mut store);
|
||||
let id = van(&mut store, 60_000, cost, accum, expense);
|
||||
store.run_depreciation(id, "2026-01").unwrap();
|
||||
store.run_depreciation(id, "2026-02").unwrap();
|
||||
|
||||
// Book value after two months: 60000 − (548.39 + 1000) = 58451.61.
|
||||
// Sell for 60,000 → gain of 1548.39.
|
||||
let entry_id = store
|
||||
.dispose_asset(id, date(2026, 2, 28), usd("60000"), bank, gain)
|
||||
.unwrap();
|
||||
let entry = store.ledger().get_entry(entry_id).unwrap();
|
||||
let debits: Decimal = entry
|
||||
.lines()
|
||||
.iter()
|
||||
.filter(|l| l.side == Side::Debit)
|
||||
.map(|l| l.amount.amount)
|
||||
.sum();
|
||||
let credits: Decimal = entry
|
||||
.lines()
|
||||
.iter()
|
||||
.filter(|l| l.side == Side::Credit)
|
||||
.map(|l| l.amount.amount)
|
||||
.sum();
|
||||
assert_eq!(debits, credits, "disposal entry must balance");
|
||||
assert_eq!(debits, Decimal::new(154839, 2) + Decimal::from(60_000));
|
||||
|
||||
// Asset is marked disposed; further depreciation is refused.
|
||||
assert!(matches!(
|
||||
store.ledger().get_asset(id).unwrap().status,
|
||||
ledger_core::AssetStatus::Disposed { .. }
|
||||
));
|
||||
assert!(store.run_depreciation(id, "2026-03").is_err());
|
||||
assert!(
|
||||
store
|
||||
.dispose_asset(id, date(2026, 3, 1), usd("1"), bank, gain)
|
||||
.is_err()
|
||||
);
|
||||
store.verify().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disposal_at_loss_uses_debit_side() {
|
||||
let (_dir, mut store) = open_store();
|
||||
let (cost, accum, expense, bank, _) = asset_accounts(&mut store);
|
||||
let loss = store
|
||||
.add_account(
|
||||
"6900",
|
||||
"Loss on Disposal",
|
||||
AccountType::Expense,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let id = van(&mut store, 60_000, cost, accum, expense);
|
||||
// Dispose 2026-01-31 with no prior depreciation runs: the final-period
|
||||
// depreciation (Jan, 548.39) is auto-posted first. Book value 59451.61,
|
||||
// sell for 50,000 → loss 9451.61.
|
||||
let entry_id = store
|
||||
.dispose_asset(id, date(2026, 1, 31), usd("50000"), bank, loss)
|
||||
.unwrap();
|
||||
let entry = store.ledger().get_entry(entry_id).unwrap();
|
||||
let loss_line = entry.lines().iter().find(|l| l.account == loss).unwrap();
|
||||
assert_eq!(loss_line.side, Side::Debit);
|
||||
assert_eq!(loss_line.amount.amount, Decimal::new(945161, 2));
|
||||
// The auto-posted depreciation entry exists for the disposal month.
|
||||
let posted = store
|
||||
.ledger()
|
||||
.posted_entries()
|
||||
.filter(|e| e.description().contains("(final)"))
|
||||
.count();
|
||||
assert_eq!(posted, 1);
|
||||
store.verify().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_asset_registration_journals_nothing() {
|
||||
let (_dir, mut store) = open_store();
|
||||
let (cost, accum, expense, _, _) = asset_accounts(&mut store);
|
||||
let bad = NewAsset {
|
||||
description: "Bad".to_string(),
|
||||
purchase_date: date(2026, 1, 1),
|
||||
cost: usd("1000"),
|
||||
salvage_value: usd("1000"), // salvage >= cost: invalid
|
||||
useful_life_months: 12,
|
||||
cost_account: cost,
|
||||
accum_depr_account: accum,
|
||||
expense_account: expense,
|
||||
};
|
||||
assert!(store.register_asset(bad).is_err());
|
||||
// Journal holds only the setup lines; no asset.registered.
|
||||
let events = store.events_since(0, 100).unwrap();
|
||||
assert!(events.iter().all(|e| e.event_type != "asset.registered"));
|
||||
// And the allocator did not advance.
|
||||
let id = van(&mut store, 1_000, cost, accum, expense);
|
||||
assert_eq!(id, 1);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Snapshot: a read-only view that follows a live writer.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::reports::snapshot;
|
||||
use ledger_core::{AccountType, Currency, Money, Side};
|
||||
use ledger_store::{JOURNAL_FILE, Snapshot, Store};
|
||||
use rust_decimal::Decimal;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn test_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("wanderbooks-snap-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn usd(n: i64) -> Money {
|
||||
Money::new(Decimal::from(n), Currency::USD)
|
||||
}
|
||||
|
||||
fn post(store: &mut Store, day: u32, dr: u64, cr: u64, amount: i64) -> u64 {
|
||||
let d = NaiveDate::from_ymd_opt(2026, 10, day).unwrap();
|
||||
let e = store.create_entry(d, "x").unwrap();
|
||||
store.add_line(e, dr, Side::Debit, usd(amount)).unwrap();
|
||||
store.add_line(e, cr, Side::Credit, usd(amount)).unwrap();
|
||||
store.post_entry(e).unwrap();
|
||||
e
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_snapshot_follows_the_writer_and_never_writes() {
|
||||
let dir = test_dir("follow");
|
||||
let mut store = Store::create(&dir, Currency::USD).unwrap();
|
||||
let cash = store
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let equity = store
|
||||
.add_account("3000", "Equity", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let e = post(&mut store, 1, cash, equity, 1000);
|
||||
store
|
||||
.attach_receipt(Some(e), "deposit.png", "image/png", b"png-bytes", None)
|
||||
.unwrap();
|
||||
|
||||
let listing = |dir: &std::path::Path| {
|
||||
let mut v: Vec<_> = walk(dir);
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
let before = listing(&dir);
|
||||
let mut snap = Snapshot::load(&dir).unwrap();
|
||||
assert_eq!(listing(&dir), before, "loading writes nothing");
|
||||
assert_eq!(snap.journal_seq(), store.journal_seq());
|
||||
assert_eq!(
|
||||
snapshot(snap.ledger()).unwrap(),
|
||||
snapshot(store.ledger()).unwrap()
|
||||
);
|
||||
let atts = snap.attachments(Some(e)).unwrap();
|
||||
assert_eq!(atts.len(), 1);
|
||||
assert_eq!(snap.attachment_bytes(atts[0].id).unwrap(), b"png-bytes");
|
||||
|
||||
// The writer keeps going; refresh picks up exactly the new lines.
|
||||
assert_eq!(snap.refresh().unwrap(), 0);
|
||||
let seq = store.journal_seq();
|
||||
post(&mut store, 2, cash, equity, 250);
|
||||
let added = snap.refresh().unwrap();
|
||||
assert_eq!(added as u64, store.journal_seq() - seq);
|
||||
assert_eq!(
|
||||
snapshot(snap.ledger()).unwrap(),
|
||||
snapshot(store.ledger()).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_half_written_line_waits_for_the_next_refresh() {
|
||||
let dir = test_dir("torn");
|
||||
let mut store = Store::create(&dir, Currency::USD).unwrap();
|
||||
store
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
drop(store);
|
||||
let mut snap = Snapshot::load(&dir).unwrap();
|
||||
let seq = snap.journal_seq();
|
||||
// Simulate the writer mid-append: half a line, no newline yet.
|
||||
let journal = dir.join(JOURNAL_FILE);
|
||||
let full = std::fs::read(&journal).unwrap();
|
||||
let mut store = Store::open(&dir).unwrap();
|
||||
store
|
||||
.add_account("3000", "Equity", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
drop(store);
|
||||
let grown = std::fs::read(&journal).unwrap();
|
||||
let new_line = &grown[full.len()..];
|
||||
std::fs::write(&journal, &full).unwrap();
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&journal)
|
||||
.unwrap();
|
||||
f.write_all(&new_line[..new_line.len() / 2]).unwrap();
|
||||
f.flush().unwrap();
|
||||
assert_eq!(snap.refresh().unwrap(), 0, "torn tail is not corruption");
|
||||
f.write_all(&new_line[new_line.len() / 2..]).unwrap();
|
||||
f.flush().unwrap();
|
||||
assert_eq!(snap.refresh().unwrap(), 1);
|
||||
assert_eq!(snap.journal_seq(), seq + 1);
|
||||
assert!(snap.ledger().chart().find_by_code("3000").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rewritten_journal_reloads() {
|
||||
let dir = test_dir("rewrite");
|
||||
let mut store = Store::create(&dir, Currency::USD).unwrap();
|
||||
let cash = store
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let equity = store
|
||||
.add_account("3000", "Equity", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let journal = dir.join(JOURNAL_FILE);
|
||||
let early = std::fs::read(&journal).unwrap();
|
||||
post(&mut store, 1, cash, equity, 10);
|
||||
drop(store);
|
||||
let mut snap = Snapshot::load(&dir).unwrap();
|
||||
let later = snap.journal_seq();
|
||||
// Restored from an earlier backup: shorter file.
|
||||
std::fs::write(&journal, &early).unwrap();
|
||||
snap.refresh().unwrap();
|
||||
assert!(snap.journal_seq() < later);
|
||||
assert!(snap.ledger().posted_entries().next().is_none());
|
||||
}
|
||||
|
||||
fn walk(dir: &std::path::Path) -> Vec<(String, u64)> {
|
||||
let mut out = Vec::new();
|
||||
for e in std::fs::read_dir(dir).unwrap() {
|
||||
let e = e.unwrap();
|
||||
let m = e.metadata().unwrap();
|
||||
if m.is_dir() {
|
||||
out.extend(walk(&e.path()));
|
||||
} else {
|
||||
out.push((e.path().display().to_string(), m.len()));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,685 @@
|
||||
//! Store tests: the durability story, proven.
|
||||
//!
|
||||
//! Round-trip persistence, tamper detection, rebuild-from-journal healing,
|
||||
//! blob deduplication and hash verification.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::{AccountType, Currency, Money, Side};
|
||||
use ledger_store::{JournalWriter, Store, StoreError};
|
||||
use rust_decimal::Decimal;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn usd(amount: u64) -> Money {
|
||||
Money::new(Decimal::from(amount), Currency::USD)
|
||||
}
|
||||
|
||||
fn test_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("wanderbooks-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn build_books(dir: &std::path::Path) -> Store {
|
||||
let mut store = Store::create(dir, Currency::USD).unwrap();
|
||||
let cash = store
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let equity = store
|
||||
.add_account("3000", "Owner's Equity", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let revenue = store
|
||||
.add_account("4000", "Service Revenue", AccountType::Revenue, None, false)
|
||||
.unwrap();
|
||||
let rent = store
|
||||
.add_account("6000", "Rent Expense", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
|
||||
let e1 = store
|
||||
.create_entry(
|
||||
NaiveDate::from_ymd_opt(2026, 1, 2).unwrap(),
|
||||
"Owner investment",
|
||||
)
|
||||
.unwrap();
|
||||
store.add_line(e1, cash, Side::Debit, usd(10_000)).unwrap();
|
||||
store
|
||||
.add_line(e1, equity, Side::Credit, usd(10_000))
|
||||
.unwrap();
|
||||
store.post_entry(e1).unwrap();
|
||||
|
||||
let e2 = store
|
||||
.create_entry(
|
||||
NaiveDate::from_ymd_opt(2026, 1, 10).unwrap(),
|
||||
"Invoice client",
|
||||
)
|
||||
.unwrap();
|
||||
store.add_line(e2, cash, Side::Debit, usd(4_000)).unwrap();
|
||||
store
|
||||
.add_line(e2, revenue, Side::Credit, usd(4_000))
|
||||
.unwrap();
|
||||
store.post_entry(e2).unwrap();
|
||||
|
||||
let e3 = store
|
||||
.create_entry(
|
||||
NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
|
||||
"January rent",
|
||||
)
|
||||
.unwrap();
|
||||
store.add_line(e3, rent, Side::Debit, usd(800)).unwrap();
|
||||
store.add_line(e3, cash, Side::Credit, usd(800)).unwrap();
|
||||
store.post_entry(e3).unwrap();
|
||||
|
||||
// A receipt attached to the rent entry.
|
||||
let (_rid, sha, dedup) = store
|
||||
.attach_receipt(
|
||||
Some(e3),
|
||||
"rent-receipt.png",
|
||||
"image/png",
|
||||
b"fake-png-bytes",
|
||||
Some("January rent receipt"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!dedup);
|
||||
assert_eq!(sha.len(), 64);
|
||||
|
||||
// A reversal, to exercise the reversal path through the journal.
|
||||
store
|
||||
.reverse_entry(
|
||||
e3,
|
||||
NaiveDate::from_ymd_opt(2026, 1, 16).unwrap(),
|
||||
"Reverse rent",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
store
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_survives_reopen() {
|
||||
let dir = test_dir("roundtrip");
|
||||
let store = build_books(&dir);
|
||||
// Live ledger: rent was posted then reversed → net zero movement on rent.
|
||||
assert_eq!(
|
||||
store
|
||||
.ledger()
|
||||
.account_movement(store.ledger().chart().find_by_code("6000").unwrap().id),
|
||||
Decimal::from(0)
|
||||
);
|
||||
drop(store);
|
||||
|
||||
let store = Store::open(&dir).unwrap();
|
||||
// Same facts after reopen: cash = 10000 + 4000 = 14000.
|
||||
let cash_id = store.ledger().chart().find_by_code("1000").unwrap().id;
|
||||
assert_eq!(
|
||||
store.ledger().account_movement(cash_id),
|
||||
Decimal::from(14_000)
|
||||
);
|
||||
// Trial balance still balances.
|
||||
let tb = ledger_core::reports::trial_balance(store.ledger());
|
||||
assert!(tb.balanced());
|
||||
assert_eq!(tb.total_debits, Decimal::from(14_000));
|
||||
// Receipt survived with its bytes.
|
||||
let receipts = store.list_attachments(None).unwrap();
|
||||
assert_eq!(receipts.len(), 1);
|
||||
assert_eq!(receipts[0].file_name, "rent-receipt.png");
|
||||
assert_eq!(
|
||||
store.attachment_bytes(receipts[0].id).unwrap(),
|
||||
b"fake-png-bytes"
|
||||
);
|
||||
// Full verification passes.
|
||||
store.verify().unwrap();
|
||||
assert_eq!(store.verify_blobs().unwrap(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn journal_tamper_is_detected() {
|
||||
let dir = test_dir("tamper");
|
||||
let store = build_books(&dir);
|
||||
drop(store);
|
||||
|
||||
// Flip a byte in the middle of the journal.
|
||||
let journal_path = dir.join(ledger_store::JOURNAL_FILE);
|
||||
let mut bytes = std::fs::read(&journal_path).unwrap();
|
||||
let mid = bytes.len() / 2;
|
||||
bytes[mid] = if bytes[mid] == b'X' { b'Y' } else { b'X' };
|
||||
std::fs::write(&journal_path, &bytes).unwrap();
|
||||
|
||||
let err = match Store::open(&dir) {
|
||||
Ok(_) => panic!("expected the tampered journal to be rejected"),
|
||||
Err(e) => e,
|
||||
};
|
||||
assert!(
|
||||
format!("{err:?}").contains("JournalCorrupt"),
|
||||
"expected JournalCorrupt, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn torn_tail_is_truncatable() {
|
||||
let dir = test_dir("torntail");
|
||||
let store = build_books(&dir);
|
||||
let seq_before = {
|
||||
let w = JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE)).unwrap();
|
||||
w.next_seq() - 1
|
||||
};
|
||||
drop(store);
|
||||
|
||||
// Simulate a torn backup: append half a line.
|
||||
use std::io::Write;
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(dir.join(ledger_store::JOURNAL_FILE))
|
||||
.unwrap();
|
||||
f.write_all(b"{\"seq\": 999, \"trunca").unwrap();
|
||||
f.sync_all().unwrap();
|
||||
drop(f);
|
||||
|
||||
// Strict open now fails...
|
||||
assert!(Store::open(&dir).is_err());
|
||||
// ...but truncation recovers to the last good seq.
|
||||
let last_good =
|
||||
ledger_store::journal::truncate_to_last_good_seq(&dir.join(ledger_store::JOURNAL_FILE))
|
||||
.unwrap();
|
||||
assert_eq!(last_good, seq_before);
|
||||
let store = Store::open(&dir).unwrap();
|
||||
store.verify().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_heals_a_lost_database() {
|
||||
let dir = test_dir("rebuild");
|
||||
let mut store = build_books(&dir);
|
||||
let tb_before = ledger_core::reports::trial_balance(store.ledger());
|
||||
// Simulate total SQLite loss.
|
||||
store.rebuild().unwrap();
|
||||
let tb_after = ledger_core::reports::trial_balance(store.ledger());
|
||||
assert_eq!(tb_before.total_debits, tb_after.total_debits);
|
||||
assert_eq!(tb_before.total_credits, tb_after.total_credits);
|
||||
store.verify().unwrap();
|
||||
// Attachments survived (metadata re-applied from the journal).
|
||||
assert_eq!(store.list_attachments(None).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_database_heals_on_open() {
|
||||
let dir = test_dir("healopen");
|
||||
let store = build_books(&dir);
|
||||
drop(store);
|
||||
std::fs::remove_file(dir.join(ledger_store::DB_FILE)).unwrap();
|
||||
// No database at all: open rebuilds the projection from the journal.
|
||||
let store = Store::open(&dir).unwrap();
|
||||
let tb = ledger_core::reports::trial_balance(store.ledger());
|
||||
assert!(tb.balanced());
|
||||
store.verify().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_dedup_and_hash_verification() {
|
||||
let dir = test_dir("blobs");
|
||||
let mut store = Store::create(&dir, Currency::USD).unwrap();
|
||||
let bytes = b"same bytes twice";
|
||||
let (_, sha1, dedup1) = store
|
||||
.file_document("tax", "w2.pdf", "application/pdf", bytes, None)
|
||||
.unwrap();
|
||||
let (_, sha2, dedup2) = store
|
||||
.file_document("tax", "w2-copy.pdf", "application/pdf", bytes, None)
|
||||
.unwrap();
|
||||
assert!(!dedup1);
|
||||
assert!(dedup2);
|
||||
assert_eq!(sha1, sha2);
|
||||
// Only one blob row; two attachment rows.
|
||||
assert_eq!(store.verify_blobs().unwrap(), 1);
|
||||
assert_eq!(store.list_attachments(None).unwrap().len(), 2);
|
||||
|
||||
// Corrupt the blob file on disk → the next read detects it.
|
||||
let shard = dir
|
||||
.join("blobs")
|
||||
.join(&sha1[0..2])
|
||||
.join(&sha1[2..4])
|
||||
.join(&sha1);
|
||||
let mut raw = std::fs::read(&shard).unwrap();
|
||||
raw[0] ^= 0xff;
|
||||
std::fs::write(&shard, &raw).unwrap();
|
||||
let err = match store.verify_blobs() {
|
||||
Ok(_) => panic!("expected the corrupted blob to be detected"),
|
||||
Err(e) => e,
|
||||
};
|
||||
assert!(format!("{err:?}").contains("CorruptBlob"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbalanced_entry_never_reaches_the_journal() {
|
||||
let dir = test_dir("unbalanced");
|
||||
let mut store = Store::create(&dir, Currency::USD).unwrap();
|
||||
let cash = store
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let equity = store
|
||||
.add_account("3000", "Owner's Equity", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let seq_before = JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE))
|
||||
.unwrap()
|
||||
.next_seq();
|
||||
|
||||
let e = store
|
||||
.create_entry(NaiveDate::from_ymd_opt(2026, 1, 2).unwrap(), "Bad")
|
||||
.unwrap();
|
||||
store.add_line(e, cash, Side::Debit, usd(100)).unwrap();
|
||||
store.add_line(e, equity, Side::Credit, usd(99)).unwrap();
|
||||
assert!(store.post_entry(e).is_err());
|
||||
|
||||
// The failed post journaled nothing new: the draft creation and its
|
||||
// two lines were already journaled (durable drafts); entry.posted was not.
|
||||
let seq_after = JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE))
|
||||
.unwrap()
|
||||
.next_seq();
|
||||
assert_eq!(seq_after, seq_before + 3);
|
||||
// And a fresh open sees a clean, balanced book.
|
||||
drop(store);
|
||||
let store = Store::open(&dir).unwrap();
|
||||
store.verify().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_lines_survive_reopen() {
|
||||
// Drafts are durable: build a draft, close the store, reopen, and the
|
||||
// draft comes back with its lines intact.
|
||||
let dir = test_dir("draft-durable");
|
||||
let (cash, revenue);
|
||||
let draft_id;
|
||||
{
|
||||
let mut store = build_books(&dir);
|
||||
cash = store.ledger().chart().find_by_code("1000").unwrap().id;
|
||||
revenue = store.ledger().chart().find_by_code("4000").unwrap().id;
|
||||
draft_id = store
|
||||
.create_entry(
|
||||
NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(),
|
||||
"unposted sale",
|
||||
)
|
||||
.unwrap();
|
||||
store
|
||||
.add_line(draft_id, cash, Side::Debit, usd(250))
|
||||
.unwrap();
|
||||
store
|
||||
.add_line(draft_id, revenue, Side::Credit, usd(250))
|
||||
.unwrap();
|
||||
// Draft is not posted: trial balance only sees the posted entry.
|
||||
let tb = ledger_core::reports::trial_balance(store.ledger());
|
||||
assert_eq!(tb.total_debits, Decimal::from(14000));
|
||||
}
|
||||
// Reopen: the draft and its lines are back.
|
||||
let mut store = Store::open(&dir).unwrap();
|
||||
let draft = store.ledger().get_entry(draft_id).unwrap();
|
||||
assert_eq!(draft.lines().len(), 2);
|
||||
assert_eq!(draft.status(), ledger_core::EntryStatus::Draft);
|
||||
// Post it after reopen: works, and the books balance.
|
||||
store.post_entry(draft_id).unwrap();
|
||||
let tb = ledger_core::reports::trial_balance(store.ledger());
|
||||
assert_eq!(tb.total_debits, Decimal::from(14250));
|
||||
assert_eq!(tb.total_credits, Decimal::from(14250));
|
||||
store.verify().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_projection_is_quarantined_and_rebuilt() {
|
||||
let dir = test_dir("corrupt-projection");
|
||||
{
|
||||
let _store = build_books(&dir);
|
||||
}
|
||||
// Corrupt the SQLite file with garbage.
|
||||
let db_path = dir.join(ledger_store::DB_FILE);
|
||||
std::fs::write(&db_path, b"this is not a sqlite database at all").unwrap();
|
||||
// Reopen heals from the journal instead of failing.
|
||||
let store = Store::open(&dir).unwrap();
|
||||
// The damaged file was quarantined for forensics.
|
||||
let quarantined: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
e.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with("wanderbooks.corrupt.")
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(quarantined.len(), 1);
|
||||
// The books are intact and verify clean.
|
||||
let tb = ledger_core::reports::trial_balance(store.ledger());
|
||||
assert_eq!(tb.total_debits, Decimal::from(14000));
|
||||
store.verify().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receipt_corrections_are_journaled_and_survive_rebuild() {
|
||||
let dir = test_dir("receipt-corrections");
|
||||
let mut store = Store::create(&dir, Currency::USD).unwrap();
|
||||
let cash = store
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let rent = store
|
||||
.add_account("6000", "Rent Expense", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let e = store
|
||||
.create_entry(
|
||||
NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(),
|
||||
"February rent",
|
||||
)
|
||||
.unwrap();
|
||||
store.add_line(e, rent, Side::Debit, usd(800)).unwrap();
|
||||
store.add_line(e, cash, Side::Credit, usd(800)).unwrap();
|
||||
store.post_entry(e).unwrap();
|
||||
let (eml, _, _) = store
|
||||
.attach_receipt(Some(e), "rent.eml", "message/rfc822", b"eml-bytes", None)
|
||||
.unwrap();
|
||||
let (pdf, _, _) = store
|
||||
.attach_receipt(Some(e), "rent.pdf", "application/pdf", b"pdf-bytes", None)
|
||||
.unwrap();
|
||||
|
||||
// Every correction needs a reason.
|
||||
assert!(matches!(
|
||||
store.unlink_receipt(eml, " "),
|
||||
Err(StoreError::ReasonRequired)
|
||||
));
|
||||
// A linked receipt can't be withdrawn or moved: unlink it first.
|
||||
assert!(matches!(
|
||||
store.withdraw_attachment(eml, "dup"),
|
||||
Err(StoreError::AttachmentLinked(a, b)) if a == eml && b == e
|
||||
));
|
||||
assert!(matches!(
|
||||
store.link_receipt(pdf, e),
|
||||
Err(StoreError::AttachmentLinked(_, _))
|
||||
));
|
||||
|
||||
// Swap the .eml for the PDF: unlink, then withdraw.
|
||||
assert_eq!(
|
||||
store
|
||||
.unlink_receipt(eml, "replaced by the PDF render")
|
||||
.unwrap(),
|
||||
e
|
||||
);
|
||||
assert!(matches!(
|
||||
store.unlink_receipt(eml, "again"),
|
||||
Err(StoreError::AttachmentNotLinked(_))
|
||||
));
|
||||
store
|
||||
.withdraw_attachment(eml, "file PDFs, not .eml")
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
store.link_receipt(eml, e),
|
||||
Err(StoreError::AttachmentWithdrawn(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
store.withdraw_attachment(eml, "again"),
|
||||
Err(StoreError::AttachmentWithdrawn(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
store.unlink_receipt(999, "x"),
|
||||
Err(StoreError::UnknownAttachment(999))
|
||||
));
|
||||
|
||||
let check = |store: &Store| {
|
||||
let linked: Vec<u64> = store
|
||||
.list_attachments(Some(e))
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|a| a.id)
|
||||
.collect();
|
||||
assert_eq!(linked, vec![pdf]);
|
||||
let all = store.list_attachments(None).unwrap();
|
||||
let gone = all.iter().find(|a| a.id == eml).unwrap();
|
||||
assert_eq!(gone.entry_id, None);
|
||||
assert!(gone.withdrawn_at.is_some());
|
||||
assert_eq!(
|
||||
gone.withdrawn_reason.as_deref(),
|
||||
Some("file PDFs, not .eml")
|
||||
);
|
||||
// Withdrawn is not deleted: the bytes are still there, hash-verified.
|
||||
assert_eq!(store.attachment_bytes(eml).unwrap(), b"eml-bytes");
|
||||
};
|
||||
check(&store);
|
||||
|
||||
// The journal is the history: the attach, the unlink, and the withdrawal.
|
||||
let journal = std::fs::read_to_string(dir.join("journal.jsonl")).unwrap();
|
||||
for t in [
|
||||
"receipt.attached",
|
||||
"receipt.unlinked",
|
||||
"attachment.withdrawn",
|
||||
] {
|
||||
assert!(
|
||||
journal.contains(&format!("\"type\":\"{t}\"")),
|
||||
"missing {t}"
|
||||
);
|
||||
}
|
||||
|
||||
drop(store);
|
||||
let mut store = Store::open(&dir).unwrap();
|
||||
check(&store);
|
||||
store.rebuild().unwrap();
|
||||
check(&store);
|
||||
store.verify().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_close_into_members_capital_replays_exactly() {
|
||||
let dir = test_dir("members-close");
|
||||
let mut store = Store::create(&dir, Currency::USD).unwrap();
|
||||
let cash = store
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let alice = store
|
||||
.add_account("3000", "Capital — Alice", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let bob = store
|
||||
.add_account("3001", "Capital — Bob", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let revenue = store
|
||||
.add_account("4000", "Revenue", AccountType::Revenue, None, false)
|
||||
.unwrap();
|
||||
let jan15 = NaiveDate::from_ymd_opt(2026, 1, 15).unwrap();
|
||||
let e = store.create_entry(jan15, "Sale").unwrap();
|
||||
store
|
||||
.add_line(
|
||||
e,
|
||||
cash,
|
||||
Side::Debit,
|
||||
Money::new(Decimal::new(10001, 2), Currency::USD),
|
||||
)
|
||||
.unwrap();
|
||||
store
|
||||
.add_line(
|
||||
e,
|
||||
revenue,
|
||||
Side::Credit,
|
||||
Money::new(Decimal::new(10001, 2), Currency::USD),
|
||||
)
|
||||
.unwrap();
|
||||
store.post_entry(e).unwrap();
|
||||
let jan = store
|
||||
.create_period(
|
||||
"2026-01",
|
||||
NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
|
||||
NaiveDate::from_ymd_opt(2026, 1, 31).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let half = Decimal::new(5, 1);
|
||||
store
|
||||
.close_period_to(
|
||||
jan,
|
||||
&ledger_core::CloseTo::Members(vec![
|
||||
ledger_core::MemberClose {
|
||||
capital: alice,
|
||||
share: half,
|
||||
distributions: None,
|
||||
},
|
||||
ledger_core::MemberClose {
|
||||
capital: bob,
|
||||
share: half,
|
||||
distributions: None,
|
||||
},
|
||||
]),
|
||||
)
|
||||
.unwrap();
|
||||
// 100.01 splits 50.01 / 50.00 into the capital accounts.
|
||||
let balances = |s: &Store| {
|
||||
(
|
||||
s.ledger().account_balance(alice).unwrap(),
|
||||
s.ledger().account_balance(bob).unwrap(),
|
||||
)
|
||||
};
|
||||
let want = (Decimal::new(5001, 2), Decimal::new(5000, 2));
|
||||
assert_eq!(balances(&store), want);
|
||||
let journal = std::fs::read_to_string(dir.join("journal.jsonl")).unwrap();
|
||||
assert!(journal.contains("\"close_to\":{\"members\""));
|
||||
|
||||
drop(store);
|
||||
let mut store = Store::open(&dir).unwrap();
|
||||
assert_eq!(balances(&store), want);
|
||||
store.rebuild().unwrap();
|
||||
assert_eq!(balances(&store), want);
|
||||
store.verify().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedules_and_recurring_entries_replay_exactly() {
|
||||
let dir = test_dir("schedules");
|
||||
let mut store = Store::create(&dir, Currency::USD).unwrap();
|
||||
let cash = store
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let prepaid = store
|
||||
.add_account("1300", "Prepaid", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let ins = store
|
||||
.add_account("6300", "Insurance", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let rent = store
|
||||
.add_account("6600", "Rent", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let date = |m, d| NaiveDate::from_ymd_opt(2026, m, d).unwrap();
|
||||
let usd_s = |s: &str| Money::new(s.parse().unwrap(), Currency::USD);
|
||||
store
|
||||
.register_schedule(ledger_core::NewSchedule {
|
||||
description: "Insurance".into(),
|
||||
kind: ledger_core::ScheduleKind::PrepaidExpense,
|
||||
total: usd_s("1000"),
|
||||
start: date(1, 1),
|
||||
months: 3,
|
||||
balance_account: prepaid,
|
||||
pl_account: ins,
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
.register_recurring(ledger_core::NewRecurring {
|
||||
description: "Rent".into(),
|
||||
lines: vec![
|
||||
(rent, Side::Debit, usd_s("500")),
|
||||
(cash, Side::Credit, usd_s("500")),
|
||||
],
|
||||
frequency: ledger_core::Frequency::Monthly,
|
||||
start: date(1, 1),
|
||||
end: None,
|
||||
})
|
||||
.unwrap();
|
||||
// 333.33, 333.33 through February; rent drafts for Jan 1 and Feb 1.
|
||||
let posted = store.run_schedules(date(2, 28)).unwrap();
|
||||
assert_eq!(posted.len(), 2);
|
||||
let drafts = store.run_recurring(date(2, 28)).unwrap();
|
||||
assert_eq!(
|
||||
drafts.iter().map(|d| d.1).collect::<Vec<_>>(),
|
||||
[date(1, 1), date(2, 1)]
|
||||
);
|
||||
store.post_entry(drafts[0].2).unwrap();
|
||||
let state = |s: &Store| {
|
||||
(
|
||||
s.ledger().account_balance(ins).unwrap(),
|
||||
s.ledger().account_balance(rent).unwrap(),
|
||||
s.ledger().get_entry(drafts[1].2).unwrap().status(),
|
||||
)
|
||||
};
|
||||
let want = (
|
||||
Decimal::new(66666, 2),
|
||||
Decimal::new(500, 0),
|
||||
ledger_core::EntryStatus::Draft,
|
||||
);
|
||||
assert_eq!(state(&store), want);
|
||||
|
||||
drop(store);
|
||||
let mut store = Store::open(&dir).unwrap();
|
||||
assert_eq!(state(&store), want);
|
||||
store.rebuild().unwrap();
|
||||
assert_eq!(state(&store), want);
|
||||
store.verify().unwrap();
|
||||
// Idempotent after replay: nothing new is due through February.
|
||||
assert!(store.run_schedules(date(2, 28)).unwrap().is_empty());
|
||||
assert!(store.run_recurring(date(2, 28)).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vendor_1099_tags_replay_and_project() {
|
||||
use ledger_core::arap::DocumentLine;
|
||||
use ledger_core::{Form1099, vendor_1099_report};
|
||||
let dir = test_dir("form1099");
|
||||
let mut store = Store::create(&dir, Currency::USD).unwrap();
|
||||
let cash = store
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let labor = store
|
||||
.add_account("6000", "Contract Labor", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let dana = store.add_vendor("Dana").unwrap();
|
||||
store
|
||||
.set_vendor_1099(dana, Some(Form1099::Nec), true)
|
||||
.unwrap();
|
||||
let d = |m, day| NaiveDate::from_ymd_opt(2026, m, day).unwrap();
|
||||
let bill = store
|
||||
.create_bill(
|
||||
dana,
|
||||
d(3, 1),
|
||||
d(3, 31),
|
||||
vec![DocumentLine {
|
||||
description: "Design".into(),
|
||||
amount: Decimal::from(1500),
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
store
|
||||
.pay_bill(bill, d(3, 20), Decimal::from(1500), cash)
|
||||
.unwrap();
|
||||
let e = store.create_entry(d(6, 1), "Dana, June").unwrap();
|
||||
store.add_line(e, labor, Side::Debit, usd(700)).unwrap();
|
||||
store.add_line(e, cash, Side::Credit, usd(700)).unwrap();
|
||||
store.post_entry(e).unwrap();
|
||||
store.set_entry_vendor(e, Some(dana)).unwrap();
|
||||
let dup = store.create_entry(d(6, 2), "Dana, duplicate").unwrap();
|
||||
store.add_line(dup, labor, Side::Debit, usd(700)).unwrap();
|
||||
store.add_line(dup, cash, Side::Credit, usd(700)).unwrap();
|
||||
store.post_entry(dup).unwrap();
|
||||
store.set_entry_vendor(dup, Some(dana)).unwrap();
|
||||
store.reverse_entry(dup, d(6, 3), "duplicate").unwrap();
|
||||
let before = vendor_1099_report(store.ledger(), 2026, None);
|
||||
assert_eq!(before.vendors[0].paid, Decimal::from(2200));
|
||||
assert!(before.vendors[0].reportable);
|
||||
drop(store);
|
||||
|
||||
// Reopen (replay), then rebuild the projection from scratch.
|
||||
for rebuild in [false, true] {
|
||||
if rebuild {
|
||||
std::fs::remove_file(dir.join(ledger_store::DB_FILE)).unwrap();
|
||||
}
|
||||
let store = Store::open(&dir).unwrap();
|
||||
assert_eq!(vendor_1099_report(store.ledger(), 2026, None), before);
|
||||
let conn = rusqlite::Connection::open(dir.join(ledger_store::DB_FILE)).unwrap();
|
||||
let tagged: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM entry_vendors WHERE vendor_id = ?",
|
||||
[dana as i64],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
// Bill, its payment, June, the duplicate, and its reversal.
|
||||
assert_eq!(tagged, 5);
|
||||
let form: String = conn
|
||||
.query_row("SELECT form_1099 FROM vendors", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(form, "nec");
|
||||
}
|
||||
}
|
||||
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user