Compare commits
172
Commits
P12
...
8164abb2b8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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/architecture.md` | Crate layout, command pattern, dependency rules |
|
||||||
| `docs/oracle.md` | How we prove correctness: golden files, property tests, GnuCash diffing |
|
| `docs/oracle.md` | How we prove correctness: golden files, property tests, GnuCash diffing |
|
||||||
| `docs/parity.md` | Feature catalog and the non-regression floor |
|
| `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 |
|
| `docs/glossary.md` | Debit/credit and friends, for agents new to accounting |
|
||||||
| `plan/STATUS.md` | Where the project stands right now |
|
| `plan/STATUS.md` | Where the project stands right now |
|
||||||
| `plan/roadmap.md` | Phase plan: semantics → oracle harness → egui app → workflow parity |
|
| `plan/roadmap.md` | Phase plan: semantics → oracle harness → egui app → workflow parity |
|
||||||
|
|||||||
Generated
+4779
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = ["crates/ledger-core"]
|
members = ["crates/ledger-core", "crates/ledger-store", "crates/ledger-mcp", "crates/ledger-import", "crates/ledger-cli"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|||||||
@@ -1,50 +1,72 @@
|
|||||||
# wanderbooks
|
# wanderbooks
|
||||||
|
|
||||||
Canonical double-entry bookkeeping, rebuilt from scratch — the storytold way:
|
Canonical double-entry bookkeeping, rebuilt from scratch. The engine is
|
||||||
an engine verified against oracles, with automated parity tracking, on its
|
verified against oracles, parity is tracked automatically, and the primary
|
||||||
way to becoming a self-hosted bookkeeping app that never charges rent.
|
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. 27 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.
|
||||||
|
|
||||||
|
## 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
|
## Status
|
||||||
|
|
||||||
Phase 1: the semantics engine. `ledger-core` implements the accounting laws
|
Parity is 12 of 12 against the spreadsheet workflow it replaces
|
||||||
in Rust — exact decimal money, five account types, balanced journal entries,
|
(`docs/capability-parity.md`). The parallel-run plan
|
||||||
immutable posted log with reversing entries, trial balance, P&L, and balance
|
(`docs/parallel-run.md`) is written; the Google Sheet stays the system of
|
||||||
sheet — with 14 invariant tests and golden-file report snapshots.
|
record until a clean month and explicit cutover approval.
|
||||||
|
|
||||||
See [`plan/STATUS.md`](plan/STATUS.md) for where things stand and
|
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
|
## Quickstart
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo test
|
cargo test # whole suite
|
||||||
|
cargo run -p ledger-cli -- init ~/books/acme # scaffold a books dir: mise.toml + AGENT.md
|
||||||
|
cargo run -p ledger-mcp -- --data-dir /tmp/books # MCP server over stdio
|
||||||
```
|
```
|
||||||
|
Then `cd ~/books/acme`: `mise trust && mise install`, `mise run build`,
|
||||||
That's the whole test suite: validation rejections, immutability, the
|
`mise run mcp-add`, and start `claude` — the agent onboards from AGENT.md.
|
||||||
accounting equation on a fixture company, a 300-entry randomized invariant
|
|
||||||
test, and the golden oracle test.
|
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
crates/ledger-core/ # the engine: money, accounts, journal, ledger, reports
|
crates/ledger-core/ # engine: money, accounts, journal, ledger, reports
|
||||||
docs/ # requirements, architecture, oracle design, parity, glossary
|
crates/ledger-store/ # journal + SQLite projection + blob store
|
||||||
plan/ # STATUS.md, roadmap.md
|
crates/ledger-mcp/ # MCP server: 27 tools, resources, receipt policy
|
||||||
AGENTS.md # operating manual for agents working here
|
crates/ledger-import/ # QBO CSV import, IIF/XLSX export
|
||||||
|
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
|
## License
|
||||||
|
|
||||||
MIT OR Apache-2.0. See `LICENSE-MIT` and `LICENSE-APACHE`.
|
MIT OR Apache-2.0. See `LICENSE-MIT` and `LICENSE-APACHE`.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "ledger-cli"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
description = "wanderbooks CLI: scaffold new books directories (mise.toml + AGENT.md)."
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "wanderbooks"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
toml = "1"
|
||||||
|
tempfile = "3"
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
//! `wanderbooks` CLI — the bootstrap for AI-native bookkeeping.
|
||||||
|
//!
|
||||||
|
//! Usage: `wanderbooks init [DIR] [--force]`
|
||||||
|
//!
|
||||||
|
//! Scaffolds a new books directory: `mise.toml` (pinned toolchain + tasks),
|
||||||
|
//! `AGENT.md` (the agent operating contract), `wanderbooks.toml` (company
|
||||||
|
//! profile, filled in during onboarding), and an empty `books-data/` dir.
|
||||||
|
//! From there: `mise trust && mise install`, `mise run build`,
|
||||||
|
//! `mise run mcp-add`, then start `claude`/`codex` in the dir and let the
|
||||||
|
//! agent take the onboarding from AGENT.md.
|
||||||
|
|
||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
/// Absolute path of the wanderbooks repo checkout this binary was built from.
|
||||||
|
fn repo_root() -> PathBuf {
|
||||||
|
let raw = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||||
|
raw.canonicalize().unwrap_or(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
const MISE_TOML: &str = r#"# wanderbooks books dir — provisioned by `mise`.
|
||||||
|
# Bootstrap: `mise trust && mise install` then `mise run build && mise run mcp-add`.
|
||||||
|
|
||||||
|
[tools]
|
||||||
|
rust = "stable"
|
||||||
|
|
||||||
|
[tasks.build]
|
||||||
|
description = "Build ledger-mcp from the wanderbooks repo checkout"
|
||||||
|
dir = "{{REPO}}"
|
||||||
|
run = "cargo build --release -p ledger-mcp"
|
||||||
|
|
||||||
|
[tasks.mcp-add]
|
||||||
|
description = "Register wanderbooks with Claude Code (stdio transport; OS user boundary is the auth)"
|
||||||
|
run = 'claude mcp add wanderbooks -- "{{REPO}}/target/release/ledger-mcp" --data-dir "{{BOOKS}}/books-data"'
|
||||||
|
|
||||||
|
[tasks.backup]
|
||||||
|
description = "Commit the journal — the git history of this dir is the archive"
|
||||||
|
run = 'git add -A && (git commit -m "books backup $(date -u +%Y-%m-%dT%H:%M:%SZ)" || true)'
|
||||||
|
"#;
|
||||||
|
|
||||||
|
const AGENT_MD: &str = r#"# AGENT.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. There is no
|
||||||
|
bookkeeping UI; the numbers you show the human *are* the interface.
|
||||||
|
|
||||||
|
## Bootstrap (once, in order)
|
||||||
|
|
||||||
|
1. `mise trust && mise install` — installs the pinned Rust toolchain.
|
||||||
|
2. `mise run build` — builds `ledger-mcp` from the wanderbooks repo.
|
||||||
|
3. `mise run mcp-add` — registers the server with Claude Code over stdio.
|
||||||
|
(Codex: add the same command + `--data-dir` to your MCP config instead.)
|
||||||
|
4. Restart the session so the MCP tools load, then continue below.
|
||||||
|
|
||||||
|
## 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 $25 requires a receipt — the server enforces this, don't argue with it.
|
||||||
|
- **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:
|
||||||
|
|
||||||
|
1. Ask: company legal name, entity type, owners and equity split, base
|
||||||
|
currency, fiscal year start.
|
||||||
|
2. Set up the chart of accounts: propose a sensible default for the entity
|
||||||
|
type and let the human adjust, or import one they hand you.
|
||||||
|
3. Record opening balances as one balanced opening entry. Verify the trial
|
||||||
|
balance balances before moving on.
|
||||||
|
4. Write the answers into `wanderbooks.toml` under `[company]`.
|
||||||
|
5. If they're migrating from another system, import from wherever the books
|
||||||
|
live now — QBO/CSV via `ledger-import`, 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.
|
||||||
|
|
||||||
|
## Ongoing
|
||||||
|
|
||||||
|
- Every transaction: draft, preview, confirm with the human, post, attach
|
||||||
|
the receipt.
|
||||||
|
- `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: run `mise run backup` after every session that posted entries.
|
||||||
|
The git history of this directory is the archive; the journal file is the
|
||||||
|
system of record and the SQLite database is disposable.
|
||||||
|
"#;
|
||||||
|
|
||||||
|
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 = ""
|
||||||
|
entity_type = ""
|
||||||
|
owners = ""
|
||||||
|
base_currency = "USD"
|
||||||
|
fiscal_year_start = "01-01"
|
||||||
|
|
||||||
|
[books]
|
||||||
|
data_dir = "books-data"
|
||||||
|
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-*
|
||||||
|
"#;
|
||||||
|
|
||||||
|
fn print_usage() {
|
||||||
|
eprintln!("usage: wanderbooks init [DIR] [--force]");
|
||||||
|
eprintln!(" DIR books directory to create (default: current directory)");
|
||||||
|
eprintln!(" --force overwrite an existing wanderbooks.toml");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(template: &str, repo: &str, books: &str) -> String {
|
||||||
|
template
|
||||||
|
.replace("{{REPO}}", repo)
|
||||||
|
.replace("{{BOOKS}}", books)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `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 inside = Command::new("git")
|
||||||
|
.args(["rev-parse", "--is-inside-work-tree"])
|
||||||
|
.current_dir(dir)
|
||||||
|
.output()
|
||||||
|
.is_ok_and(|o| o.status.success());
|
||||||
|
if !inside {
|
||||||
|
let init_ok = Command::new("git")
|
||||||
|
.arg("init")
|
||||||
|
.current_dir(dir)
|
||||||
|
.output()
|
||||||
|
.is_ok_and(|o| o.status.success());
|
||||||
|
if !init_ok {
|
||||||
|
eprintln!("warning: git init failed; skipping scaffold commit");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let add = Command::new("git")
|
||||||
|
.args(["add", "-A"])
|
||||||
|
.current_dir(dir)
|
||||||
|
.output();
|
||||||
|
if !add.is_ok_and(|o| o.status.success()) {
|
||||||
|
eprintln!("warning: git add failed; skipping scaffold commit");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let _ = Command::new("git")
|
||||||
|
.args([
|
||||||
|
"-c",
|
||||||
|
"user.name=wanderbooks",
|
||||||
|
"-c",
|
||||||
|
"user.email=wanderbooks@localhost",
|
||||||
|
"commit",
|
||||||
|
"-m",
|
||||||
|
"init: wanderbooks books dir scaffold",
|
||||||
|
])
|
||||||
|
.current_dir(dir)
|
||||||
|
.output();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scaffold a books directory. Returns the dir on success.
|
||||||
|
fn init(dir: &Path, force: bool) -> 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()))?;
|
||||||
|
|
||||||
|
let marker = dir.join("wanderbooks.toml");
|
||||||
|
if marker.exists() && !force {
|
||||||
|
return Err(format!(
|
||||||
|
"{} is already a books dir (wanderbooks.toml exists); use --force to overwrite",
|
||||||
|
dir.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let repo = repo_root();
|
||||||
|
let repo_s = repo.to_string_lossy();
|
||||||
|
let books_s = dir.to_string_lossy();
|
||||||
|
|
||||||
|
let files: &[(&str, String)] = &[
|
||||||
|
("mise.toml", render(MISE_TOML, &repo_s, &books_s)),
|
||||||
|
("AGENT.md", AGENT_MD.to_string()),
|
||||||
|
("wanderbooks.toml", WANDERBOOKS_TOML.to_string()),
|
||||||
|
(".gitignore", GITIGNORE.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("books-data"))
|
||||||
|
.map_err(|e| format!("cannot create books-data: {e}"))?;
|
||||||
|
fs::write(dir.join("books-data").join(".gitkeep"), "")
|
||||||
|
.map_err(|e| format!("cannot write .gitkeep: {e}"))?;
|
||||||
|
|
||||||
|
git_bootstrap(&dir);
|
||||||
|
Ok(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args = env::args().skip(1);
|
||||||
|
let mut dir: Option<PathBuf> = None;
|
||||||
|
let mut force = false;
|
||||||
|
let mut cmd: Option<String> = None;
|
||||||
|
|
||||||
|
for arg in args {
|
||||||
|
match arg.as_str() {
|
||||||
|
"--help" | "-h" => {
|
||||||
|
print_usage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
"--force" => force = true,
|
||||||
|
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());
|
||||||
|
} else if dir.is_none() {
|
||||||
|
dir = Some(PathBuf::from(s));
|
||||||
|
} else {
|
||||||
|
eprintln!("unexpected argument: {s}");
|
||||||
|
print_usage();
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match cmd.as_deref() {
|
||||||
|
Some("init") => {
|
||||||
|
let dir = dir.unwrap_or_else(|| PathBuf::from("."));
|
||||||
|
match init(&dir, force) {
|
||||||
|
Ok(d) => {
|
||||||
|
println!("initialized books dir: {}", d.display());
|
||||||
|
println!("next:");
|
||||||
|
println!(" cd {}", d.display());
|
||||||
|
println!(" mise trust && mise install");
|
||||||
|
println!(" mise run build && mise run mcp-add");
|
||||||
|
println!("then start claude (or codex) here and follow AGENT.md");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("error: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
print_usage();
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
//! Integration tests for `wanderbooks init`: the scaffold lands complete,
|
||||||
|
//! the TOML parses, re-init is refused without `--force`.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn bin() -> PathBuf {
|
||||||
|
PathBuf::from(env!("CARGO_BIN_EXE_wanderbooks"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_init(dir: &std::path::Path, extra: &[&str]) -> (bool, String) {
|
||||||
|
let mut cmd = Command::new(bin());
|
||||||
|
cmd.arg("init").arg(dir);
|
||||||
|
for e in extra {
|
||||||
|
cmd.arg(e);
|
||||||
|
}
|
||||||
|
let out = cmd.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scaffold_lands_complete() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let books = tmp.path().join("acme-books");
|
||||||
|
let (ok, _) = run_init(&books, &[]);
|
||||||
|
assert!(ok, "init should succeed");
|
||||||
|
for f in ["mise.toml", "AGENT.md", "wanderbooks.toml", ".gitignore"] {
|
||||||
|
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 for the journal archive"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mise_toml_parses_and_names_repo_and_books() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let books = tmp.path().join("b");
|
||||||
|
let (ok, _) = run_init(&books, &[]);
|
||||||
|
assert!(ok);
|
||||||
|
let raw = fs::read_to_string(books.join("mise.toml")).unwrap();
|
||||||
|
let doc: toml::Value = toml::from_str(&raw).expect("mise.toml parses");
|
||||||
|
assert!(doc.get("tools").is_some(), "[tools] present");
|
||||||
|
let tasks = doc.get("tasks").expect("[tasks] present");
|
||||||
|
for t in ["build", "mcp-add", "backup"] {
|
||||||
|
assert!(tasks.get(t).is_some(), "task {t} present");
|
||||||
|
}
|
||||||
|
// The build task must point at the repo checkout; mcp-add at the books dir.
|
||||||
|
let build_run = tasks["build"]["run"].as_str().unwrap();
|
||||||
|
assert!(
|
||||||
|
build_run.contains("cargo build"),
|
||||||
|
"build runs cargo: {build_run}"
|
||||||
|
);
|
||||||
|
let mcp_run = tasks["mcp-add"]["run"].as_str().unwrap();
|
||||||
|
assert!(
|
||||||
|
mcp_run.contains("ledger-mcp") && mcp_run.contains("books-data"),
|
||||||
|
"mcp-add wires the server to this books dir: {mcp_run}"
|
||||||
|
);
|
||||||
|
let books_s = books.to_string_lossy();
|
||||||
|
assert!(
|
||||||
|
mcp_run.contains(books_s.as_ref()),
|
||||||
|
"mcp-add references the books dir path"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agent_md_carries_the_contract() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let books = tmp.path().join("b");
|
||||||
|
let (ok, _) = run_init(&books, &[]);
|
||||||
|
assert!(ok);
|
||||||
|
let md = fs::read_to_string(books.join("AGENT.md")).unwrap();
|
||||||
|
for phrase in [
|
||||||
|
"preview",
|
||||||
|
"Posted is forever",
|
||||||
|
"reversing entries",
|
||||||
|
"receipt",
|
||||||
|
"journal.jsonl",
|
||||||
|
"onboard",
|
||||||
|
"to the cent",
|
||||||
|
] {
|
||||||
|
assert!(md.contains(phrase), "AGENT.md mentions {phrase:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reinit_refused_without_force() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let books = tmp.path().join("b");
|
||||||
|
assert!(run_init(&books, &[]).0);
|
||||||
|
let (ok, out) = run_init(&books, &[]);
|
||||||
|
assert!(!ok, "second init without --force fails");
|
||||||
|
assert!(out.contains("--force"), "error points at --force: {out}");
|
||||||
|
let (ok, _) = run_init(&books, &["--force"]);
|
||||||
|
assert!(ok, "second init with --force succeeds");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn init_inside_existing_repo_skips_git_init() {
|
||||||
|
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, _) = run_init(&books, &[]);
|
||||||
|
assert!(ok);
|
||||||
|
// Still a valid repo (we didn't nest another one) and scaffold landed.
|
||||||
|
assert!(books.join("wanderbooks.toml").exists());
|
||||||
|
}
|
||||||
@@ -15,3 +15,6 @@ serde_json = "1"
|
|||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
proptest = "1"
|
||||||
@@ -84,40 +84,20 @@ pub struct ChartOfAccounts {
|
|||||||
impl ChartOfAccounts {
|
impl ChartOfAccounts {
|
||||||
/// Register an account. Codes must be unique and non-empty; a parent, if
|
/// Register an account. Codes must be unique and non-empty; a parent, if
|
||||||
/// given, must already exist.
|
/// given, must already exist.
|
||||||
pub fn add(
|
pub fn add(&mut self, account: Account) -> Result<(), LedgerError> {
|
||||||
&mut self,
|
if account.code.is_empty() {
|
||||||
id: AccountId,
|
|
||||||
code: &str,
|
|
||||||
name: &str,
|
|
||||||
account_type: AccountType,
|
|
||||||
parent: Option<AccountId>,
|
|
||||||
placeholder: bool,
|
|
||||||
currency: Currency,
|
|
||||||
) -> Result<(), LedgerError> {
|
|
||||||
if code.is_empty() {
|
|
||||||
return Err(LedgerError::EmptyAccountCode);
|
return Err(LedgerError::EmptyAccountCode);
|
||||||
}
|
}
|
||||||
if self.by_code.contains_key(code) {
|
if self.by_code.contains_key(&account.code) {
|
||||||
return Err(LedgerError::DuplicateAccountCode(code.to_string()));
|
return Err(LedgerError::DuplicateAccountCode(account.code));
|
||||||
}
|
}
|
||||||
if let Some(p) = parent {
|
if let Some(p) = account.parent
|
||||||
if !self.accounts.contains_key(&p) {
|
&& !self.accounts.contains_key(&p)
|
||||||
return Err(LedgerError::UnknownParentAccount(p));
|
{
|
||||||
}
|
return Err(LedgerError::UnknownParentAccount(p));
|
||||||
}
|
}
|
||||||
self.by_code.insert(code.to_string(), id);
|
self.by_code.insert(account.code.clone(), account.id);
|
||||||
self.accounts.insert(
|
self.accounts.insert(account.id, account);
|
||||||
id,
|
|
||||||
Account {
|
|
||||||
id,
|
|
||||||
code: code.to_string(),
|
|
||||||
name: name.to_string(),
|
|
||||||
account_type,
|
|
||||||
parent,
|
|
||||||
placeholder,
|
|
||||||
currency,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,399 @@
|
|||||||
|
//! 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,187 @@ pub enum LedgerError {
|
|||||||
/// Any attempt to mutate a posted entry: add lines, delete it, edit it.
|
/// Any attempt to mutate a posted entry: add lines, delete it, edit it.
|
||||||
#[error("cannot modify a posted entry; post a reversing entry instead")]
|
#[error("cannot modify a posted entry; post a reversing entry instead")]
|
||||||
PostedEntryImmutable,
|
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,
|
||||||
|
|
||||||
|
/// 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),
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
//! total credits. Entries are born as drafts, validated, then posted. Posting
|
//! 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.
|
//! 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 chrono::NaiveDate;
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -29,8 +29,24 @@ pub struct JournalLine {
|
|||||||
pub account: AccountId,
|
pub account: AccountId,
|
||||||
/// Which side of the entry this line is on.
|
/// Which side of the entry this line is on.
|
||||||
pub side: Side,
|
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,
|
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.
|
/// A balanced set of journal lines with a date and a description.
|
||||||
@@ -93,6 +109,12 @@ impl JournalEntry {
|
|||||||
self.lines.push(line);
|
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`].
|
/// Mark posted. Only via [`crate::Ledger`], after [`Self::validate`].
|
||||||
pub(crate) fn mark_posted(&mut self) {
|
pub(crate) fn mark_posted(&mut self) {
|
||||||
self.status = EntryStatus::Posted;
|
self.status = EntryStatus::Posted;
|
||||||
|
|||||||
+1455
-23
File diff suppressed because it is too large.
Load diff
@@ -17,15 +17,37 @@
|
|||||||
//! (`docs/oracle.md`) possible.
|
//! (`docs/oracle.md`) possible.
|
||||||
|
|
||||||
pub mod account;
|
pub mod account;
|
||||||
|
pub mod arap;
|
||||||
|
pub mod assets;
|
||||||
|
pub mod classes;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod journal;
|
pub mod journal;
|
||||||
pub mod ledger;
|
pub mod ledger;
|
||||||
pub mod money;
|
pub mod money;
|
||||||
|
pub mod payroll;
|
||||||
|
pub mod periods;
|
||||||
pub mod reports;
|
pub mod reports;
|
||||||
|
|
||||||
pub use account::{Account, AccountId, AccountType, ChartOfAccounts, Side};
|
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 error::LedgerError;
|
||||||
pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
|
pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
|
||||||
pub use ledger::Ledger;
|
pub use ledger::{Ledger, ReconciliationReport, UnclearedLine};
|
||||||
pub use money::{Currency, Money};
|
pub use money::{Currency, Money};
|
||||||
pub use reports::{BalanceSheet, ProfitAndLoss, TrialBalance, TrialBalanceLine};
|
pub use payroll::{PayrollLine, PayrollRun, PayrollRunId};
|
||||||
|
pub use periods::{CloseTo, 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,
|
||||||
|
};
|
||||||
@@ -15,15 +15,15 @@ pub struct Currency([u8; 3]);
|
|||||||
|
|
||||||
impl Currency {
|
impl Currency {
|
||||||
/// US dollar.
|
/// US dollar.
|
||||||
pub const USD: Currency = Currency([b'U', b'S', b'D']);
|
pub const USD: Currency = Currency(*b"USD");
|
||||||
/// Euro.
|
/// Euro.
|
||||||
pub const EUR: Currency = Currency([b'E', b'U', b'R']);
|
pub const EUR: Currency = Currency(*b"EUR");
|
||||||
/// British pound.
|
/// British pound.
|
||||||
pub const GBP: Currency = Currency([b'G', b'B', b'P']);
|
pub const GBP: Currency = Currency(*b"GBP");
|
||||||
/// Swiss franc.
|
/// Swiss franc.
|
||||||
pub const CHF: Currency = Currency([b'C', b'H', b'F']);
|
pub const CHF: Currency = Currency(*b"CHF");
|
||||||
/// Japanese yen.
|
/// 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
|
/// Parse and validate a three-letter code. Input is uppercased, so
|
||||||
/// `"usd"` and `"USD"` both work; `"US"` or `"USDD"` fail.
|
/// `"usd"` and `"USD"` both work; `"US"` or `"USDD"` fail.
|
||||||
@@ -123,3 +123,40 @@ impl fmt::Display for Money {
|
|||||||
write!(f, "{} {}", self.amount, self.currency)
|
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,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,236 @@
|
|||||||
|
//! 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.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub enum CloseTo {
|
||||||
|
/// One Retained Earnings account ([`RETAINED_EARNINGS_CODE`]): the
|
||||||
|
/// corporate convention.
|
||||||
|
RetainedEarnings,
|
||||||
|
/// Members' capital accounts, by share (shares sum to 1): the
|
||||||
|
/// partnership convention, where each member's capital account carries
|
||||||
|
/// their allocated earnings.
|
||||||
|
Members(Vec<(crate::AccountId, rust_decimal::Decimal)>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 { .. })));
|
||||||
|
}
|
||||||
|
}
|
||||||
+1050
-13
File diff suppressed because it is too large.
Load diff
@@ -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,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 chrono::NaiveDate;
|
||||||
use ledger_core::reports::{balance_sheet, profit_and_loss, snapshot, trial_balance};
|
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;
|
use rust_decimal::Decimal;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -49,15 +51,29 @@ struct Company {
|
|||||||
|
|
||||||
fn fixture_company() -> Company {
|
fn fixture_company() -> Company {
|
||||||
let mut ledger = Ledger::new(Currency::USD);
|
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
|
let ar = ledger
|
||||||
.add_account("1200", "Accounts Receivable", AccountType::Asset, None, false)
|
.add_account(
|
||||||
|
"1200",
|
||||||
|
"Accounts Receivable",
|
||||||
|
AccountType::Asset,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let equipment = ledger
|
let equipment = ledger
|
||||||
.add_account("1500", "Equipment", AccountType::Asset, None, false)
|
.add_account("1500", "Equipment", AccountType::Asset, None, false)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let ap = ledger
|
let ap = ledger
|
||||||
.add_account("2000", "Accounts Payable", AccountType::Liability, None, false)
|
.add_account(
|
||||||
|
"2000",
|
||||||
|
"Accounts Payable",
|
||||||
|
AccountType::Liability,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let equity = ledger
|
let equity = ledger
|
||||||
.add_account("3000", "Owner's Equity", AccountType::Equity, None, false)
|
.add_account("3000", "Owner's Equity", AccountType::Equity, None, false)
|
||||||
@@ -70,32 +86,51 @@ fn fixture_company() -> Company {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Owner invests $10,000 cash.
|
// Owner invests $10,000 cash.
|
||||||
post(&mut ledger, date(2), "Owner investment", &[
|
post(
|
||||||
(cash, Side::Debit, 10_000),
|
&mut ledger,
|
||||||
(equity, Side::Credit, 10_000),
|
date(2),
|
||||||
]);
|
"Owner investment",
|
||||||
|
&[(cash, Side::Debit, 10_000), (equity, Side::Credit, 10_000)],
|
||||||
|
);
|
||||||
// Buy equipment for $2,500 cash.
|
// Buy equipment for $2,500 cash.
|
||||||
post(&mut ledger, date(5), "Buy equipment", &[
|
post(
|
||||||
(equipment, Side::Debit, 2_500),
|
&mut ledger,
|
||||||
(cash, Side::Credit, 2_500),
|
date(5),
|
||||||
]);
|
"Buy equipment",
|
||||||
|
&[(equipment, Side::Debit, 2_500), (cash, Side::Credit, 2_500)],
|
||||||
|
);
|
||||||
// Bill a client $4,000.
|
// Bill a client $4,000.
|
||||||
post(&mut ledger, date(10), "Invoice client", &[
|
post(
|
||||||
(ar, Side::Debit, 4_000),
|
&mut ledger,
|
||||||
(revenue, Side::Credit, 4_000),
|
date(10),
|
||||||
]);
|
"Invoice client",
|
||||||
|
&[(ar, Side::Debit, 4_000), (revenue, Side::Credit, 4_000)],
|
||||||
|
);
|
||||||
// Pay January rent, $800.
|
// Pay January rent, $800.
|
||||||
post(&mut ledger, date(15), "January rent", &[
|
post(
|
||||||
(rent, Side::Debit, 800),
|
&mut ledger,
|
||||||
(cash, Side::Credit, 800),
|
date(15),
|
||||||
]);
|
"January rent",
|
||||||
|
&[(rent, Side::Debit, 800), (cash, Side::Credit, 800)],
|
||||||
|
);
|
||||||
// Collect $1,500 of receivables.
|
// Collect $1,500 of receivables.
|
||||||
post(&mut ledger, date(20), "Client payment", &[
|
post(
|
||||||
(cash, Side::Debit, 1_500),
|
&mut ledger,
|
||||||
(ar, Side::Credit, 1_500),
|
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 mut ledger = c.ledger;
|
||||||
let id = ledger.create_entry(date(21), "Bad entry");
|
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.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();
|
let err = ledger.post_entry(id).unwrap_err();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
err,
|
err,
|
||||||
@@ -135,7 +172,9 @@ fn zero_amount_line_is_rejected() {
|
|||||||
let id = ledger.create_entry(date(21), "Zero line");
|
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(100)).unwrap();
|
||||||
ledger.add_line(id, c.cash, Side::Debit, usd(0)).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!(
|
assert_eq!(
|
||||||
ledger.post_entry(id).unwrap_err(),
|
ledger.post_entry(id).unwrap_err(),
|
||||||
LedgerError::ZeroAmountLine
|
LedgerError::ZeroAmountLine
|
||||||
@@ -148,7 +187,9 @@ fn unknown_account_is_rejected() {
|
|||||||
let mut ledger = c.ledger;
|
let mut ledger = c.ledger;
|
||||||
let id = ledger.create_entry(date(21), "Ghost account");
|
let id = ledger.create_entry(date(21), "Ghost account");
|
||||||
ledger.add_line(id, c.cash, Side::Debit, usd(100)).unwrap();
|
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!(
|
assert_eq!(
|
||||||
ledger.post_entry(id).unwrap_err(),
|
ledger.post_entry(id).unwrap_err(),
|
||||||
LedgerError::UnknownAccount(999_999)
|
LedgerError::UnknownAccount(999_999)
|
||||||
@@ -164,7 +205,9 @@ fn placeholder_account_is_rejected() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let id = ledger.create_entry(date(21), "Post to header");
|
let id = ledger.create_entry(date(21), "Post to header");
|
||||||
ledger.add_line(id, header, Side::Debit, usd(100)).unwrap();
|
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!(
|
assert_eq!(
|
||||||
ledger.post_entry(id).unwrap_err(),
|
ledger.post_entry(id).unwrap_err(),
|
||||||
LedgerError::PlaceholderAccount("100".to_string())
|
LedgerError::PlaceholderAccount("100".to_string())
|
||||||
@@ -207,12 +250,16 @@ fn posted_entry_is_immutable() {
|
|||||||
let mut ledger = c.ledger;
|
let mut ledger = c.ledger;
|
||||||
let id = ledger.create_entry(date(21), "To post");
|
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.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();
|
ledger.post_entry(id).unwrap();
|
||||||
|
|
||||||
// Cannot add lines after posting.
|
// Cannot add lines after posting.
|
||||||
assert_eq!(
|
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
|
LedgerError::PostedEntryImmutable
|
||||||
);
|
);
|
||||||
// Cannot post twice.
|
// Cannot post twice.
|
||||||
@@ -233,7 +280,9 @@ fn reversal_nets_to_zero_and_links_back() {
|
|||||||
ledger.post_entry(id).unwrap();
|
ledger.post_entry(id).unwrap();
|
||||||
assert_eq!(ledger.account_movement(c.rent), Decimal::from(1300));
|
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();
|
let rev_entry = ledger.get_entry(rev).unwrap();
|
||||||
assert_eq!(rev_entry.reversal_of(), Some(id));
|
assert_eq!(rev_entry.reversal_of(), Some(id));
|
||||||
assert_eq!(rev_entry.status(), EntryStatus::Posted);
|
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 mut rng = Lcg(0x1234_5678_9abc_def0);
|
||||||
let c = fixture_company();
|
let c = fixture_company();
|
||||||
let mut ledger = c.ledger;
|
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 {
|
for i in 0..300u32 {
|
||||||
let total = rng.next(100_000) + 100;
|
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();
|
serde_json::from_str(include_str!("golden/fixture_company.json")).unwrap();
|
||||||
assert_eq!(snap, golden);
|
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,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,667 @@
|
|||||||
|
//! 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,
|
||||||
|
share: half,
|
||||||
|
},
|
||||||
|
MemberCapital {
|
||||||
|
name: "Bob".into(),
|
||||||
|
capital_account: bob,
|
||||||
|
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| (m.capital_account, m.share))
|
||||||
|
.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![(members[0].capital_account, cents("0.5"))]);
|
||||||
|
assert!(matches!(
|
||||||
|
l.close_period_to(feb, &bad),
|
||||||
|
Err(LedgerError::InvalidMembers(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
@@ -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,112 @@
|
|||||||
|
//! 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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse errors.
|
||||||
|
#[derive(Debug, Error, PartialEq)]
|
||||||
|
pub enum ChartCsvError {
|
||||||
|
/// Wrong column count.
|
||||||
|
#[error("expected 4 columns (code,name,type,parent_code), found {0}")]
|
||||||
|
BadColumnCount(usize),
|
||||||
|
/// Missing or bad header.
|
||||||
|
#[error("bad header: expected code,name,type,parent_code")]
|
||||||
|
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)
|
||||||
|
.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| 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();
|
||||||
|
if lower != ["code", "name", "type", "parent_code"] {
|
||||||
|
return Err(ChartCsvError::BadHeader);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if fields.len() != 4 {
|
||||||
|
return Err(ChartCsvError::BadColumnCount(fields.len()));
|
||||||
|
}
|
||||||
|
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())
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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 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,24 @@
|
|||||||
|
//! 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 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 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,635 @@
|
|||||||
|
//! 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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| l.account.as_deref() == Some(m.capital_code.as_str()));
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "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(),
|
||||||
|
},
|
||||||
|
Member {
|
||||||
|
name: "Bob".into(),
|
||||||
|
capital_code: "3001".into(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
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,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,33 @@
|
|||||||
|
[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" }
|
||||||
|
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,292 @@
|
|||||||
|
//! 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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>,
|
||||||
|
#[serde(default)]
|
||||||
|
members: Vec<MemberToml>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct MemberToml {
|
||||||
|
name: String,
|
||||||
|
share: String,
|
||||||
|
capital_account: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Deserialize)]
|
||||||
|
struct BooksSection {
|
||||||
|
receipt_threshold: Option<String>,
|
||||||
|
accounting_basis: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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%"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
Ok(BooksSettings {
|
||||||
|
company,
|
||||||
|
receipt_threshold,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 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()
|
||||||
|
},
|
||||||
|
Member {
|
||||||
|
name: "Katherine Pendergast".into(),
|
||||||
|
share: half,
|
||||||
|
capital_account: "3001".into()
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
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 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
mod 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,78 @@
|
|||||||
|
//! 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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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,33 @@
|
|||||||
|
//! `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 store;
|
||||||
|
|
||||||
|
pub use blob::BlobStore;
|
||||||
|
pub use error::StoreError;
|
||||||
|
pub use journal::{GENESIS_PREV, JournalLine, JournalWriter};
|
||||||
|
pub use store::{
|
||||||
|
Attachment, DB_FILE, DepreciationRun, DisposalPreview, EntryRow, EventRow, JOURNAL_FILE,
|
||||||
|
PayrollLineInput, Store,
|
||||||
|
};
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
//! 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 = 5;
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
"#,
|
||||||
|
];
|
||||||
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,528 @@
|
|||||||
|
//! 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![(alice, half), (bob, half)]),
|
||||||
|
)
|
||||||
|
.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();
|
||||||
|
}
|
||||||
@@ -54,8 +54,9 @@ The core is a pure domain library and stays that way.
|
|||||||
|
|
||||||
| Crate | Purpose | Depends on |
|
| Crate | Purpose | Depends on |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `ledger-store` | SQLite persistence of the entry log | `ledger-core` |
|
| `ledger-store` | Journal (hash-chained JSONL) + SQLite projection, blob store | `ledger-core` |
|
||||||
| `ledger-import` | GnuCash XML read-only import | `ledger-core` |
|
| `ledger-import` | QuickBooks CSV read-only import (bank transactions, chart of accounts) | `ledger-core` |
|
||||||
|
| `ledger-export` | Format renderers: CSV, XLSX, IIF journal entries (QuickBooks Desktop) | `ledger-core` |
|
||||||
| `ledger-oracle` | Diff harness: our engine vs GnuCash on fixture books | `ledger-core`, `ledger-import` |
|
| `ledger-oracle` | Diff harness: our engine vs GnuCash on fixture books | `ledger-core`, `ledger-import` |
|
||||||
| `books-app` | egui UI (native + WASM), command-driven | `ledger-core`, `ledger-store` |
|
| `books-app` | egui UI (native + WASM), command-driven | `ledger-core`, `ledger-store` |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Capability parity — GSheet+GDrive → wanderbooks
|
||||||
|
|
||||||
|
The parity target for the AI-first phase is **not QuickBooks**. It is the
|
||||||
|
system actually in use today: the "Wanderland Labs LLC — Books" Google Sheet
|
||||||
|
(double-entry accounts with live P&L, Balance Sheet, and Cash Flow tabs, plus
|
||||||
|
the Transaction Journal) together with the Drive folder, operated through an
|
||||||
|
AI assistant. Every capability below must be reachable through MCP tools
|
||||||
|
before the sheet retires.
|
||||||
|
|
||||||
|
Statuses: `covered` (built and tested), `partial` (built, known gaps listed),
|
||||||
|
`specced` (designed, not yet built), `future` (on the roadmap).
|
||||||
|
|
||||||
|
| # | Today's capability | wanderbooks equivalent | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Record a double-entry transaction (Transaction Journal row) | `books.draft_entry` / `add_line` / `post_entry` (engine: `Ledger`) | covered |
|
||||||
|
| 2 | Chart of accounts | engine + `books.chart` (with live balances) | covered |
|
||||||
|
| 3 | Live P&L tab | engine + `books.profit_and_loss` | covered |
|
||||||
|
| 4 | Live Balance Sheet tab | engine + `books.balance_sheet` | covered |
|
||||||
|
| 5 | Live Cash Flow tab | engine `cash_flow` (indirect) + `books.cash_flow` | covered |
|
||||||
|
| 6 | Transaction Journal as append-only log | engine posting log + `books.search_entries` | covered |
|
||||||
|
| 7 | Receipt storage in Drive | blob store + `books.attach_receipt` / `link_receipt` / `unlink_receipt` / `withdraw_attachment` | covered |
|
||||||
|
| 8 | Document filing by category (01 Formation / 02 Legal / 03 Tax / 04 Accounting) | `books.file_document` | covered |
|
||||||
|
| 9 | Ad-hoc questions ("what did we spend on X?") | `search_entries` + reports; the AI itself is the query layer | covered |
|
||||||
|
| 10 | Human review of what the assistant recorded | `books.digest` | covered |
|
||||||
|
| 11 | CPA-ready export of the books | `books.export` (csv/json/xlsx/iif; xlsx is base64) | covered |
|
||||||
|
| 12 | Company email filing (every company email → Drive folder) | out of books scope; the existing mail-filing automation keeps running unchanged | — |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Row 9 is the quiet superpower of the AI-first design. The sheet answers
|
||||||
|
questions with formulas the human must write; the MCP design answers them
|
||||||
|
with an agent that already knows how to read a trial balance. The
|
||||||
|
capability is "ask anything about the books," and the tool surface above
|
||||||
|
is what makes it true.
|
||||||
|
- Row 10 (`books.digest`) has no sheet equivalent — the sheet can't tell you
|
||||||
|
what changed. This is parity *plus*: the new system is more reviewable
|
||||||
|
than the old one, which is the point of the exercise.
|
||||||
|
- Row 12 is deliberately excluded. Email filing is a mail problem, not a
|
||||||
|
books problem; it stays where it is.
|
||||||
|
|
||||||
|
## Cutover rule
|
||||||
|
|
||||||
|
The sheet does not retire on a date. It retires when **all three** hold:
|
||||||
|
|
||||||
|
1. Every `specced` row above is `covered` (built, tested, golden-covered
|
||||||
|
where reports are involved).
|
||||||
|
2. A full month of parallel run — every real company transaction recorded in
|
||||||
|
both systems — diffs clean.
|
||||||
|
3. Stephen says so.
|
||||||
|
|
||||||
|
The parallel run is the acceptance test. Until then the sheet is the system
|
||||||
|
of record and wanderbooks is the shadow ledger; after, it inverts.
|
||||||
+254
-14
@@ -21,6 +21,19 @@ If a behavior can't be expressed through `ledger-core`'s public API, the
|
|||||||
core is missing something — fix the core, don't work around it in the MCP
|
core is missing something — fix the core, don't work around it in the MCP
|
||||||
layer.
|
layer.
|
||||||
|
|
||||||
|
## Company settings
|
||||||
|
|
||||||
|
`ledger-mcp` reads the books dir's `wanderbooks.toml` (default: next to
|
||||||
|
`--data-dir`; override with `--books-toml PATH`): `[company] name` heads
|
||||||
|
every report, `[company] fiscal_year_start` ("MM-DD", default "01-01";
|
||||||
|
the year's end is derived, never stored) defines fiscal-year-to-date for the
|
||||||
|
CPA package and the balance sheet's net income line, `[[company.members]]`
|
||||||
|
(`name`, `share` as "50%" or "0.5", `capital_account` code; shares must sum
|
||||||
|
to 100%) drives the statement of members' equity, `[books]
|
||||||
|
accounting_basis` ("accrual" or "cash", default accrual) labels report
|
||||||
|
footers, and `[books] receipt_threshold` sets the receipt policy unless
|
||||||
|
`config.json` sets `receipt_policy`.
|
||||||
|
|
||||||
## Transports
|
## Transports
|
||||||
|
|
||||||
1. **stdio** (build first). For Claude Code, Claude Desktop, local agents.
|
1. **stdio** (build first). For Claude Code, Claude Desktop, local agents.
|
||||||
@@ -29,6 +42,17 @@ layer.
|
|||||||
laptop). Bearer token, binds 127.0.0.1 or the tailnet interface only.
|
laptop). Bearer token, binds 127.0.0.1 or the tailnet interface only.
|
||||||
Binding 0.0.0.0 is an explicit human decision, never a default.
|
Binding 0.0.0.0 is an explicit human decision, never a default.
|
||||||
|
|
||||||
|
## Server instructions
|
||||||
|
|
||||||
|
The MCP `initialize` response carries an `instructions` string: the agent
|
||||||
|
operating contract (below) in condensed form. The model reads it at the start
|
||||||
|
of every session, before any tool call. Tool descriptions repeat the relevant
|
||||||
|
fragment at the point of use (`post_entry`'s description states the
|
||||||
|
preview-before-post rule; `attach_receipt`'s states the receipt rule). Prose
|
||||||
|
guidance is therefore delivered twice — once at session start, once at the
|
||||||
|
tool — but it is guidance, not enforcement. Enforcement is the receipt policy
|
||||||
|
below.
|
||||||
|
|
||||||
## Tool design principles
|
## Tool design principles
|
||||||
|
|
||||||
- **Narrow tools, explicit arguments.** No "do what I mean" tools. The agent
|
- **Narrow tools, explicit arguments.** No "do what I mean" tools. The agent
|
||||||
@@ -56,52 +80,176 @@ layer.
|
|||||||
→ `{ lines: [...], totals: { debits, credits, balanced } }`. Running totals
|
→ `{ lines: [...], totals: { debits, credits, balanced } }`. Running totals
|
||||||
after every line: the agent watches the entry balance in real time.
|
after every line: the agent watches the entry balance in real time.
|
||||||
- `books.post_entry { entry_id, confirmed }` → without confirmation:
|
- `books.post_entry { entry_id, confirmed }` → without confirmation:
|
||||||
`{ preview: { lines, resulting_balances } }`; with confirmation: validates,
|
`{ preview: { lines, resulting_balances, receipts: { attached: [...],
|
||||||
posts, returns `{ entry, balances_changed: [{ code, before, after }] }`.
|
required_but_missing: bool } } }`; with confirmation: validates (including
|
||||||
|
the receipt policy), posts, returns `{ entry, balances_changed: [{ code,
|
||||||
|
before, after }] }`.
|
||||||
- `books.reverse_entry { entry_id, date, description, confirmed }` → same
|
- `books.reverse_entry { entry_id, date, description, confirmed }` → same
|
||||||
preview/confirm pattern as post.
|
preview/confirm pattern as post.
|
||||||
- `books.get_entry { entry_id }` → full entry with lines.
|
- `books.get_entry { entry_id }` → full entry with lines.
|
||||||
- `books.search_entries { account?, date_from?, date_to?, text?, limit?, offset? }`
|
- `books.search_entries { account?, date_from?, date_to?, text?, limit?, offset? }`
|
||||||
→ entries with lines, newest first. `limit` caps at 100.
|
→ entries with lines, newest first. `limit` caps at 100.
|
||||||
- `books.trial_balance {}` → canonical JSON (same shape as
|
- `books.trial_balance { as_of? }` → `as_of`, lines, totals, `balanced`
|
||||||
`reports::snapshot` trial-balance section).
|
(the trial balance as of the date, default today).
|
||||||
- `books.balance_sheet {}` → canonical JSON.
|
- `books.balance_sheet { as_of? }` → totals (`assets`, `liabilities`,
|
||||||
- `books.profit_and_loss {}` → canonical JSON.
|
`equity`, `current_earnings`, `balanced`) plus `asset_accounts`,
|
||||||
|
`liability_accounts`, `equity_accounts` (`{code, name, amount}`), as of
|
||||||
|
the date (default today).
|
||||||
|
- `books.profit_and_loss { start?, end?, class? }` → totals plus
|
||||||
|
`revenue_accounts` and `expense_accounts` for the range. `end` defaults to
|
||||||
|
today, `start` to the first posted entry. Closing entries are excluded, so
|
||||||
|
a closed period still shows what it earned.
|
||||||
|
- `books.cash_flow { start?, end?, cash_accounts: ["1000"], financing_accounts? }`
|
||||||
|
→ indirect-method cash flow statement. `cash_accounts` are the reconciling
|
||||||
|
accounts (required); the three sections always sum to their net change.
|
||||||
|
Disposal gains/losses move from operating to investing automatically.
|
||||||
- `books.digest { since: "2026-10-01" }`
|
- `books.digest { since: "2026-10-01" }`
|
||||||
→ `{ entries_posted: [...], totals_by_account: [...] }`. The human review
|
→ `{ entries_posted: [...], totals_by_account: [...] }`. The human review
|
||||||
surface: "what did the AI do with my books lately." The digest is the
|
surface: "what did the AI do with my books lately." The digest is the
|
||||||
answer to "trust, but verify."
|
answer to "trust, but verify."
|
||||||
|
|
||||||
|
## Tools — fixed assets
|
||||||
|
|
||||||
|
Straight-line **book** depreciation only; tax depreciation stays with the
|
||||||
|
CPA. Partial first/final months prorated by days; the final month trues up
|
||||||
|
so the lifetime total is exactly (cost − salvage).
|
||||||
|
|
||||||
|
- `books.register_asset { description, purchase_date, cost, salvage_value?,
|
||||||
|
useful_life_months, cost_account, accum_depr_account, expense_account }`
|
||||||
|
→ `{ asset_id }`. Accounts resolved by id or code.
|
||||||
|
- `books.asset_schedule { asset_id }`
|
||||||
|
→ `{ status, schedule: [{ period, amount, accumulated, book_value }] }`.
|
||||||
|
- `books.list_assets {}` → assets with status and current book values.
|
||||||
|
- `books.run_depreciation { period: "YYYY-MM", asset_id? }`
|
||||||
|
→ per-asset `{ entry_id, amount, already_posted }`, plus `skipped` for
|
||||||
|
assets outside their useful life. Idempotent per asset and period.
|
||||||
|
- `books.dispose_asset { asset_id, date, proceeds, proceeds_account,
|
||||||
|
gain_loss_account, confirmed?, receipt_attachment_ids? }` → without
|
||||||
|
confirmation: `{ status: "preview", ... }` and posts nothing; with
|
||||||
|
`confirmed: true`: auto-posts missing final-period depreciation, then
|
||||||
|
posts the balanced disposal entry. Subject to receipt policy (bill of
|
||||||
|
sale); monthly depreciation is exempt as an allocation.
|
||||||
|
|
||||||
## Tools — receipts & documents
|
## Tools — receipts & documents
|
||||||
|
|
||||||
- `books.attach_receipt { entry_id?, file_name, mime_type, content_base64 }`
|
- `books.attach_receipt { entry_id?, file_name, mime_type, content_base64 }`
|
||||||
→ `{ receipt_id, sha256, deduplicated, size_bytes }`. `entry_id` is
|
→ `{ receipt_id, sha256, deduplicated, size_bytes }`. `entry_id` is
|
||||||
optional: a receipt can be filed before its entry exists.
|
optional: a receipt can be filed before its entry exists.
|
||||||
- `books.link_receipt { receipt_id, entry_id }` → link a previously filed
|
- `books.link_receipt { receipt_id, entry_id }` → link a previously filed
|
||||||
receipt to its entry.
|
(unlinked, not withdrawn) receipt to its entry. A receipt linked elsewhere
|
||||||
- `books.list_receipts { entry_id? }` → metadata list (no bytes).
|
must be unlinked first.
|
||||||
|
- `books.unlink_receipt { attachment_id, reason }` → `{ ok, entry_id,
|
||||||
|
remaining_receipts }`. Detaches a receipt from its entry; it stays filed,
|
||||||
|
unlinked. Journaled as `receipt.unlinked` with the reason. Subject to the
|
||||||
|
receipt policy: an entry that requires a receipt cannot be left with none
|
||||||
|
(`receipt_required` error, remedy `books.attach_receipt`), so replacing a
|
||||||
|
receipt is attach-new, then unlink-old.
|
||||||
|
- `books.withdraw_attachment { attachment_id, reason }` → `{ ok }`. Retires
|
||||||
|
an unlinked receipt or a filed document: hidden from listings, never
|
||||||
|
linkable again. Journaled as `attachment.withdrawn`; the bytes stay in the
|
||||||
|
blob store. Nothing in the evidence trail is ever erased — receipts change
|
||||||
|
by new events, the same way entries change by reversals.
|
||||||
|
- `books.list_receipts { entry_id?, include_withdrawn? }` → metadata list
|
||||||
|
(no bytes), each with `withdrawn` / `withdrawn_reason`. Withdrawn
|
||||||
|
attachments are omitted unless `include_withdrawn` is true.
|
||||||
- `books.get_receipt { receipt_id }` → `{ metadata, content_base64 }`.
|
- `books.get_receipt { receipt_id }` → `{ metadata, content_base64 }`.
|
||||||
- `books.file_document { category: "formation" | "legal" | "tax" |
|
- `books.file_document { category: "formation" | "legal" | "tax" |
|
||||||
"accounting" | "other", file_name, mime_type, content_base64,
|
"accounting" | "other", file_name, mime_type, content_base64,
|
||||||
description? }` → `{ document_id, sha256, deduplicated }`. The Drive-folder
|
description? }` → `{ document_id, sha256, deduplicated }`. The Drive-folder
|
||||||
replacement (today's 01 Formation / 02 Legal / 03 Tax / 04 Accounting).
|
replacement (today's 01 Formation / 02 Legal / 03 Tax / 04 Accounting).
|
||||||
|
|
||||||
|
## Receipt enforcement
|
||||||
|
|
||||||
|
Prose tells the model to attach receipts; the policy makes it true. The
|
||||||
|
engine stays policy-free (it knows nothing of receipts) — enforcement lives
|
||||||
|
in the MCP layer, which reads the `attachments` table before confirming a
|
||||||
|
posting.
|
||||||
|
|
||||||
|
- **Policy config** (server config file, not per-call): `require_receipts_above`
|
||||||
|
(default `"25.00"` in base currency), plus `receipt_required_accounts`
|
||||||
|
(account codes that always require receipts, e.g. meals and travel) and
|
||||||
|
`receipt_exempt_accounts` (e.g. automated bank fees). Human-edited and
|
||||||
|
versioned with the deployment.
|
||||||
|
- **Preview.** Every `post_entry` / `reverse_entry` preview includes the
|
||||||
|
receipt checklist (`attached: [...]`, `required_but_missing: bool`), so the
|
||||||
|
human sees it before confirming.
|
||||||
|
- **Hard error.** Confirming a posting that violates the policy fails with a
|
||||||
|
structured, machine-readable error (JSON in the tool-error string):
|
||||||
|
`{ "code": "receipt_required", "message": "…", "entry_id": 5,
|
||||||
|
"debits": "100.00", "policy": "strictly greater than $25.00",
|
||||||
|
"remedy_tool": "books.attach_receipt",
|
||||||
|
"remedy_hint": "…" }`. Clients match on `code`, not prose. The agent's job
|
||||||
|
then is to get the receipt attached (ask the human for it) and re-confirm
|
||||||
|
— not to shrink the amount or split the entry to dodge the threshold. The
|
||||||
|
tool description says this explicitly; gaming the policy is a correctness
|
||||||
|
violation, and the append-only audit log would show it anyway.
|
||||||
|
- **Anti-splitting.** Posting an entry triggers a split check: posted
|
||||||
|
entries on the same date with the same normalized description and debit
|
||||||
|
accounts are grouped, and if the group total exceeds the threshold while
|
||||||
|
the entry alone does not, a receipt is required. The preview surfaces
|
||||||
|
`split_detected: [...]`, and the error names the group. Same-day,
|
||||||
|
same-description, same-accounts is the signature; recurring spending
|
||||||
|
spread across days is not flagged.
|
||||||
|
- **Below the threshold**, receipts are still expected per the operating
|
||||||
|
contract ("a material entry without its source document is incomplete")
|
||||||
|
but not hard-required — the preview checklist keeps them visible.
|
||||||
|
|
||||||
## Tools — export
|
## Tools — export
|
||||||
|
|
||||||
- `books.export { format: "csv" | "json", scope: "journal" | "trial-balance" |
|
- `books.export { format, report, start?, end?, as_of?, cash_accounts?,
|
||||||
"profit-and-loss" | "balance-sheet" | "chart" }` → `{ content_base64,
|
financing_accounts? }` → `{ content, content_encoding? }`.
|
||||||
file_name }`. CPA-ready output. GnuCash XML arrives with R-FUTURE-4.
|
- Reports: `profit_and_loss`, `balance_sheet`, `cash_flow` (needs
|
||||||
|
`cash_accounts`), `trial_balance`, `members_equity` (statement of
|
||||||
|
changes in members' equity; members from `wanderbooks.toml`),
|
||||||
|
`general_ledger` (by account: beginning balance, postings with running
|
||||||
|
balance and split, ending balance), `general_journal` (alias
|
||||||
|
`journal`: every entry, line by line, in date order).
|
||||||
|
- Formats: `pdf` and `xlsx` (base64) for the five statements; `xlsx`,
|
||||||
|
`csv`, `json` for every report; `iif` for the journal (QuickBooks
|
||||||
|
Desktop).
|
||||||
|
- Dates: period reports take `start` (default: first entry) and `end`
|
||||||
|
(default: today, local time); `balance_sheet` and `trial_balance`
|
||||||
|
take `as_of`.
|
||||||
|
- Presentation (PDF and XLSX alike, QuickBooks-style): centered company,
|
||||||
|
title, and period; a column-header row; "$" on subtotals and totals;
|
||||||
|
negatives in parentheses with decimal points aligned; a rule above every
|
||||||
|
sum and a double rule under statement totals; a footer with the
|
||||||
|
accounting basis, preparation date, and journal seq. Multi-member LLCs
|
||||||
|
get "Members' equity" with members named; the balance sheet separates
|
||||||
|
retained earnings from the fiscal year's net income (loss).
|
||||||
|
|
||||||
|
## Tools — import
|
||||||
|
|
||||||
|
- `books.import_bank_csv { csv_text, bank_account_code, default_offset_code,
|
||||||
|
rules? }` → creates one draft per transaction (QBO upload layout).
|
||||||
|
`rules` maps description substrings to account codes.
|
||||||
|
- `books.import_chart_csv { csv_text }` → creates accounts from
|
||||||
|
`code,name,type,parent_code` CSV. All-or-nothing.
|
||||||
|
- `books.import_local_file { path, category: "receipt" | "document", ... }`
|
||||||
|
→ imports a file from the server's filesystem. The Drive backfill path:
|
||||||
|
the agent downloads from Google Drive via the CLI to a temp path, then
|
||||||
|
calls this. Keeps Drive credentials out of the server.
|
||||||
|
|
||||||
|
## Transports
|
||||||
|
|
||||||
|
- **stdio** (default): `ledger-mcp --data-dir PATH`. For local agents.
|
||||||
|
- **Streamable HTTP**: `ledger-mcp --data-dir PATH --http 127.0.0.1:8080`.
|
||||||
|
Serves MCP at `http://127.0.0.1:8080/mcp` via rmcp's tower integration.
|
||||||
|
For network clients; bind to loopback unless you know what you're doing.
|
||||||
|
|
||||||
## Resources (read-only)
|
## Resources (read-only)
|
||||||
|
|
||||||
|
Hand-rolled on `ServerHandler` (rmcp 3.x has no resource macros):
|
||||||
|
`list_resources` advertises, `read_resource` renders.
|
||||||
|
|
||||||
- `books://chart`
|
- `books://chart`
|
||||||
- `books://reports/trial-balance`, `books://reports/balance-sheet`,
|
- `books://reports/trial-balance`, `books://reports/balance-sheet`,
|
||||||
`books://reports/profit-and-loss`
|
`books://reports/profit-and-loss`
|
||||||
- `books://digest?since=2026-10-01`
|
- `books://digest?since=2026-10-01`
|
||||||
- `books://receipts/{id}` — receipt bytes with metadata
|
- `books://receipts/{id}` — receipt metadata + base64 bytes
|
||||||
|
|
||||||
Resources exist so read-only clients (dashboards, the future read-only GUI,
|
Resources exist so read-only clients (dashboards, the future read-only GUI,
|
||||||
auditors) never need tool credentials.
|
auditors) never need tool credentials. Tested end-to-end over stdio.
|
||||||
|
|
||||||
## The agent operating contract
|
## The agent operating contract
|
||||||
|
|
||||||
@@ -113,7 +261,8 @@ in the server's tool descriptions, so the model reads it at use time):
|
|||||||
in prose.
|
in prose.
|
||||||
3. **Attach receipts at record time.** A material entry without its source
|
3. **Attach receipts at record time.** A material entry without its source
|
||||||
document is an incomplete entry. File first, link later if the entry
|
document is an incomplete entry. File first, link later if the entry
|
||||||
isn't ready.
|
isn't ready. Postings at or above the receipt policy threshold *cannot*
|
||||||
|
post without one — the server refuses with `receipt_required`.
|
||||||
4. **When unsure of categorization, draft and ask.** A draft is cheap; a
|
4. **When unsure of categorization, draft and ask.** A draft is cheap; a
|
||||||
wrong posting is a reversal. Never guess-post.
|
wrong posting is a reversal. Never guess-post.
|
||||||
5. **The digest is the receipt for your work.** After a batch of bookkeeping,
|
5. **The digest is the receipt for your work.** After a batch of bookkeeping,
|
||||||
@@ -124,3 +273,94 @@ in the server's tool descriptions, so the model reads it at use time):
|
|||||||
Bank feeds, payment processing, payroll, tax filing, multi-user authN/Z, the
|
Bank feeds, payment processing, payroll, tax filing, multi-user authN/Z, the
|
||||||
GUI (it consumes the event log in a later phase). Cash flow reporting is
|
GUI (it consumes the event log in a later phase). Cash flow reporting is
|
||||||
R-FUTURE-3 and rides the engine roadmap, not the MCP layer.
|
R-FUTURE-3 and rides the engine roadmap, not the MCP layer.
|
||||||
|
|
||||||
|
## Tools — periods
|
||||||
|
|
||||||
|
- `books.create_period { name, start, end }` → `{ period_id }`.
|
||||||
|
Dates are YYYY-MM-DD, inclusive.
|
||||||
|
- `books.close_period { period_id, confirmed? }` → without confirmation:
|
||||||
|
`{ status: "preview", lines: [...] }`: the exact closing entry (the close
|
||||||
|
run on a copy of the ledger), posting nothing; with `confirmed: true`:
|
||||||
|
posts it and marks the period closed. The entry zeroes revenue and
|
||||||
|
expense balances as of the period end; the net goes to `3999` Retained
|
||||||
|
Earnings, or, with `[[company.members]]` in `wanderbooks.toml`, to each
|
||||||
|
member's capital account by share. Periods close in order. Closed periods
|
||||||
|
reject new entries.
|
||||||
|
- `books.list_periods {}` → `{ periods: [{ id, name, start, end, status,
|
||||||
|
closing_entry }] }`.
|
||||||
|
|
||||||
|
## Tools — bank reconciliation
|
||||||
|
|
||||||
|
- `books.mark_cleared { entry_id, line_index, cleared }` → `{ ok }`.
|
||||||
|
Only posted lines can be marked; the flag is workflow metadata, not
|
||||||
|
accounting data (posted entries stay immutable).
|
||||||
|
- `books.reconcile { account, statement_date, statement_balance }` →
|
||||||
|
`{ account_code, book_balance, cleared_balance, statement_balance,
|
||||||
|
difference, uncleared_lines: [{ entry_id, line_index, date, description,
|
||||||
|
side, amount }] }`. `difference` is cleared − statement; zero means
|
||||||
|
reconciled.
|
||||||
|
|
||||||
|
## Tools — A/R and A/P
|
||||||
|
|
||||||
|
- `books.add_customer { name }` → `{ id }`.
|
||||||
|
- `books.add_vendor { name }` → `{ id }`.
|
||||||
|
- `books.create_invoice { party_id, date, due_date, lines: [{ description,
|
||||||
|
amount }] }` → `{ id, total, entry_id }`. Auto-posts Dr `1200` Accounts
|
||||||
|
Receivable / Cr `4000` Revenue.
|
||||||
|
- `books.create_bill { party_id, date, due_date, lines }` → `{ id, total,
|
||||||
|
entry_id }`. Auto-posts Dr `6999` General Expenses / Cr `2000` Accounts
|
||||||
|
Payable.
|
||||||
|
- `books.receive_payment { document_id, date, amount, cash_account }` →
|
||||||
|
`{ entry_id, remaining, status }`. Posts Dr Cash / Cr A/R.
|
||||||
|
- `books.pay_bill { document_id, date, amount, cash_account }` →
|
||||||
|
`{ entry_id, remaining, status }`. Posts Dr A/P / Cr Cash.
|
||||||
|
- `books.ar_aging { as_of }` → `{ rows: [{ name, current, days_30, days_60,
|
||||||
|
days_90, days_90_plus, total }] }`.
|
||||||
|
- `books.ap_aging { as_of }` → same, per vendor.
|
||||||
|
|
||||||
|
## Tools — foreign exchange
|
||||||
|
|
||||||
|
- `books.add_line` gains optional `foreign_currency`, `foreign_amount`,
|
||||||
|
`fx_rate`. When set, the base amount is `foreign_amount * fx_rate`; the
|
||||||
|
foreign original is stored on the line for reference. Reports work in
|
||||||
|
base currency.
|
||||||
|
- `books.set_fx_rate { currency, date, rate }` → `{ ok }`. Records a
|
||||||
|
historical rate for reference; lines store their own rate at entry time.
|
||||||
|
|
||||||
|
## Tools — payroll (accounting only)
|
||||||
|
|
||||||
|
No tax tables: the caller supplies every number; the engine cuts correct
|
||||||
|
entries.
|
||||||
|
|
||||||
|
- `books.run_payroll { date, cash_account, lines: [{ employee_name,
|
||||||
|
gross_pay, federal_withholding, state_withholding, ss_employee,
|
||||||
|
medicare_employee, ss_employer, medicare_employer, other_deductions }] }`
|
||||||
|
→ `{ run_id, entry_id, total_gross, total_net }`. One balanced entry:
|
||||||
|
Dr 6100 Salary & Wage Expense (gross), Dr 6110 Payroll Tax Expense
|
||||||
|
(employer SS + Medicare), Cr 2110–2150 withholding payables, Cr cash
|
||||||
|
(net). Auto-creates the 61xx/21xx accounts. Respects closed periods.
|
||||||
|
- `books.remit_payroll_taxes { date, cash_account }` → `{ entry_id, total }`.
|
||||||
|
Clears every payroll payable to cash in one entry.
|
||||||
|
- `books.list_payroll_runs` → `{ runs: [{ id, date, employee_count,
|
||||||
|
total_gross, entry_id }] }`.
|
||||||
|
|
||||||
|
## Tools — classes (divisional tracking)
|
||||||
|
|
||||||
|
- `books.add_class { name }` → `{ id }`. E.g. "Rondo", "Coach vs Coach".
|
||||||
|
- `books.list_classes` → `{ classes: [{ id, name }] }`.
|
||||||
|
- `books.add_line` gains optional `class` (name or id): tags the new line.
|
||||||
|
Shared costs are split explicitly across lines at entry time.
|
||||||
|
- `books.profit_and_loss` gains optional `class` filter.
|
||||||
|
- `books.profit_and_loss_by_class { start, end }` → per-class revenue,
|
||||||
|
expenses, net income, plus an unclassified row. Reversals carry their
|
||||||
|
class so divisional P&L nets correctly.
|
||||||
|
|
||||||
|
## Tools — CPA handoff
|
||||||
|
|
||||||
|
- `books.export_cpa_package { as_of?, cash_accounts, financing_accounts? }`
|
||||||
|
→ `{ file_name, content_base64 }`. A zip with the full set, each
|
||||||
|
statement as PDF and XLSX: `profit-and-loss`, `balance-sheet`,
|
||||||
|
`cash-flow` (reconciled to `cash_accounts`), `members-equity`
|
||||||
|
(multi-member LLCs), `trial-balance`; plus `general-ledger.xlsx` and
|
||||||
|
`general-journal.xlsx`. Period reports cover the fiscal year to date.
|
||||||
|
What the CPA needs; they never log into the system.
|
||||||
+36
-11
@@ -26,18 +26,43 @@ recomputing the goldens *by hand from accounting principles* and documenting
|
|||||||
the recomputation in the commit. This is the seed of everything below.
|
the recomputation in the commit. This is the seed of everything below.
|
||||||
|
|
||||||
**Layer 2 — Property tests (live now, growing).**
|
**Layer 2 — Property tests (live now, growing).**
|
||||||
`random_balanced_journals_preserve_invariants` posts hundreds of random
|
`crates/ledger-core/tests/properties.rs` uses `proptest` (with shrinking)
|
||||||
balanced entries and asserts the accounting laws still hold: trial balance
|
to assert the accounting laws over hundreds of random inputs: trial balance
|
||||||
balances, the accounting equation holds, and the sum of all account movements
|
always balances, the accounting equation holds, reversals restore zero,
|
||||||
is exactly zero. New invariants get new properties. (Later: graduate to
|
posted entries are immutable and JSON-round-trip clean, and depreciation
|
||||||
`proptest` with shrinking.)
|
schedules are exact to the penny. This layer caught a real bug on its second
|
||||||
|
case: partial first/final months in different-length calendar months don't
|
||||||
|
complement, so the lifetime total drifted by cents (fixed with the
|
||||||
|
final-month true-up in `depreciation_schedule`).
|
||||||
|
|
||||||
**Layer 3 — GnuCash diff harness (roadmap phase 3).** A `ledger-oracle` crate
|
**Layer 3 — Mutation testing (live now).** `cargo mutants -p ledger-core`
|
||||||
will: import fixture books into GnuCash's XML format (or drive GnuCash
|
(228 mutants as of 2026-10-10: 159 caught, 28 unviable, 2 timeouts from
|
||||||
headless), run both engines, and diff `snapshot()` output automatically —
|
induced infinite loops, 39 missed). Every *missed* mutant is triaged:
|
||||||
`cargo xtask oracle`. This is the direct analog of storytold's
|
real gaps get new tests (this pass added parent-account validation,
|
||||||
`cargo xtask corpus` / `oracle_diff`. Until it exists, Layers 1 and 2 are
|
wrong-typed asset links, zero-proceeds disposal, two-asset ids, 1-month
|
||||||
the oracle, and they are not optional.
|
life, `Money` arithmetic, `is_balance_sheet`, `movements()`), equivalent
|
||||||
|
mutants are documented as such (e.g. `days_held > 0` vs `>=`: the zero
|
||||||
|
case is unreachable), and untestable-by-construction mutants are noted
|
||||||
|
(e.g. `TrialBalance::balanced -> true`: the engine cannot produce an
|
||||||
|
unbalanced ledger to falsify it).
|
||||||
|
|
||||||
|
**Layer 4 — Fuzzing (live now).** `cargo fuzz` targets in `fuzz/`
|
||||||
|
(nightly, libfuzzer): `fuzz_journal_line` (journal parsing never panics;
|
||||||
|
canonical form is a fixed point), `fuzz_entry_validate` (a validated entry
|
||||||
|
always balances exactly), `fuzz_op_sequence` (arbitrary op sequences via
|
||||||
|
`arbitrary`; the trial balance always balances). 11.5M execs, zero crashes
|
||||||
|
as of 2026-10-10. Corpora persist in `fuzz/corpus/` for regression.
|
||||||
|
|
||||||
|
**Layer 5 — GnuCash diff harness (roadmap phase 3).** A `ledger-oracle` crate
|
||||||
|
will run fixture books through our engine and through GnuCash headless, then
|
||||||
|
diff `snapshot()` output automatically — `cargo xtask oracle`. This is the
|
||||||
|
direct analog of storytold's `cargo xtask corpus` / `oracle_diff`. Note the
|
||||||
|
distinction: GnuCash is the *behavioral* oracle, not a file-format target
|
||||||
|
(Stephen: "for file format import/export I could not give a single shit
|
||||||
|
about GnuCash XML — QBO is all that matters"). The harness feeds fixtures to
|
||||||
|
GnuCash via whatever it imports reliably (QIF/OFX) or its Python bindings;
|
||||||
|
the exact mechanism is decided at implementation time. Until the harness
|
||||||
|
exists, Layers 1 and 2 are the oracle, and they are not optional.
|
||||||
|
|
||||||
## What the oracle does NOT cover (yet)
|
## What the oracle does NOT cover (yet)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Parallel-run readiness
|
||||||
|
|
||||||
|
The Google Sheet stays the system of record until the parallel run passes
|
||||||
|
and Stephen explicitly approves cutover. This doc is the plan; the run
|
||||||
|
itself needs Stephen's go-ahead.
|
||||||
|
|
||||||
|
## Prerequisites (all met)
|
||||||
|
|
||||||
|
- Capability parity 12 of 12 rows covered (`docs/capability-parity.md`).
|
||||||
|
- Engine semantics verified: 70 workspace tests, property tests, mutation
|
||||||
|
testing, 11.5M fuzz executions with zero crashes.
|
||||||
|
- Storage: hash-chained JSONL journal (source of truth), SQLite as
|
||||||
|
disposable projection with auto-rebuild, content-addressed blob store.
|
||||||
|
- Export: CSV/JSON/XLSX/IIF for CPA handoff and QuickBooks Desktop.
|
||||||
|
|
||||||
|
## The run
|
||||||
|
|
||||||
|
1. **Duration:** one full calendar month.
|
||||||
|
2. **Dual entry:** every transaction goes into BOTH the Sheet and
|
||||||
|
wanderbooks (via the MCP tools). Receipts attach in both.
|
||||||
|
3. **Month-end diff:** export trial balance, P&L, balance sheet, and cash
|
||||||
|
flow from wanderbooks (XLSX); compare against the Sheet's tabs.
|
||||||
|
Every line must match to the cent.
|
||||||
|
4. **Discrepancy protocol:** any mismatch is investigated before the next
|
||||||
|
month starts. If the engine is wrong, fix the engine (with a test).
|
||||||
|
If the Sheet is wrong, note it — the Sheet is the record until cutover.
|
||||||
|
5. **Cutover:** only after a clean month AND Stephen's explicit approval.
|
||||||
|
Cutover means: the Sheet becomes read-only history; wanderbooks becomes
|
||||||
|
the system of record. The journal's git backup becomes the archive.
|
||||||
|
|
||||||
|
## What the agent does during the run
|
||||||
|
|
||||||
|
- Daily: `books.digest` review of what was recorded.
|
||||||
|
- Weekly: trial-balance export, eyeball against the Sheet.
|
||||||
|
- Month-end: full four-statement export + line-by-line diff.
|
||||||
|
- Every discrepancy gets a written explanation before proceeding.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
If the run fails: the Sheet never stopped being the record. Delete the
|
||||||
|
wanderbooks data dir, fix the issues, restart the month. No data loss —
|
||||||
|
the Sheet has everything.
|
||||||
+28
-7
@@ -8,7 +8,7 @@ Statuses: `done` = implemented + invariant-tested + golden-covered.
|
|||||||
`partial` = implemented but missing tests or goldens. `todo` = not started.
|
`partial` = implemented but missing tests or goldens. `todo` = not started.
|
||||||
No other statuses.
|
No other statuses.
|
||||||
|
|
||||||
**Floor: 6 done** (2026-10-10, initial commit).
|
**Floor: 10 done** (2026-10-10; fixed-asset register added).
|
||||||
|
|
||||||
| Area | GnuCash reference | Status | Notes |
|
| Area | GnuCash reference | Status | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
@@ -19,17 +19,38 @@ No other statuses.
|
|||||||
| Trial balance | Reports → Trial Balance | done | golden-covered |
|
| Trial balance | Reports → Trial Balance | done | golden-covered |
|
||||||
| Balance sheet (A = L + E) | Reports → Balance Sheet | done | current earnings folded in pre-close; golden-covered |
|
| Balance sheet (A = L + E) | Reports → Balance Sheet | done | current earnings folded in pre-close; golden-covered |
|
||||||
| Profit & loss | Reports → Income Statement | done | golden-covered |
|
| Profit & loss | Reports → Income Statement | done | golden-covered |
|
||||||
|
| P&L for a date range, line per account (closing entries excluded) | Reports → Income Statement (custom dates) | partial | hand-verified fixture + property tests; no golden file yet |
|
||||||
|
| Balance sheet as of a date, line per account | Reports → Balance Sheet (as of) | partial | hand-verified fixture + property tests; no golden file yet |
|
||||||
|
| General ledger by account (running balance, split) | Reports → General Ledger | partial | hand-verified fixture, ties to trial balance; no golden file yet |
|
||||||
|
| Trial balance as of a date | Reports → Trial Balance (as of) | partial | hand-verified fixture; no golden file yet |
|
||||||
|
| Statement of changes in members' equity | Reports → Statement of Changes in Equity | partial | hand-verified two-member fixture incl. rounding and a close; no golden file yet |
|
||||||
| Reversing entries | Reverse transaction | done | linked via `reversal_of` |
|
| Reversing entries | Reverse transaction | done | linked via `reversal_of` |
|
||||||
| Canonical report snapshots (JSON) | — (our oracle format) | done | `reports::snapshot` |
|
| Canonical report snapshots (JSON) | — (our oracle format) | done | `reports::snapshot` |
|
||||||
| Cash flow statement (indirect) | Reports → Cash Flow | todo | R-FUTURE-3 |
|
| Fixed-asset register + straight-line depreciation | Fixed Asset register | done | `assets.rs`; golden-covered schedule; idempotent runs; disposal with gain/loss |
|
||||||
|
| Cash flow statement (indirect) | Reports → Cash Flow | done | R-FUTURE-3 |
|
||||||
| Period close / retained earnings | Close books | todo | R-FUTURE-2 |
|
| Period close / retained earnings | Close books | todo | R-FUTURE-2 |
|
||||||
| Multi-currency + FX translation | Multi-currency accts | todo | R-FUTURE-1 |
|
| Foreign-currency lines (base + foreign stored) | Multi-currency accts | done | R-FUTURE-1 |
|
||||||
| GnuCash XML import (read-only) | File format | todo | feeds oracle harness; R-FUTURE-4 |
|
| Period close (closing entry to Retained Earnings) | Close books | done | R-FUTURE-2 |
|
||||||
| A/R invoices + payments + aging | Business → Invoices | todo | R-FUTURE-5 |
|
| Bank reconciliation (cleared flags + report) | Reconcile | done | — |
|
||||||
| A/P bills + payments + aging | Business → Bills | todo | R-FUTURE-5 |
|
| QuickBooks CSV import (bank txns, chart of accounts) | Banking → Upload | done | R-FUTURE-4 |
|
||||||
| Budgets | Budgets | todo | R-FUTURE-6 |
|
| QuickBooks IIF + XLSX export | Export / reports | done | R-FUTURE-4 |
|
||||||
|
| A/R invoices + payments + aging | Business → Invoices | done | R-FUTURE-5 |
|
||||||
|
| A/P bills + payments + aging | Business → Bills | done | R-FUTURE-5 |
|
||||||
|
| Payroll runs (accounting only, no tax tables) + tax remittance | Payroll | done | — |
|
||||||
|
| Tracking classes (divisional P&L) | Class tracking | done | — |
|
||||||
|
| CPA handoff package (TB + GL xlsx, P&L + BS pdf, zip) | Reports → export | done | — |
|
||||||
|
| Budgets | Budgets | todo | R-FUTURE-6 (declined as bloat) |
|
||||||
| Scheduled transactions | Scheduled txns | todo | R-FUTURE-6 |
|
| Scheduled transactions | Scheduled txns | todo | R-FUTURE-6 |
|
||||||
| Automated GnuCash diff harness | — (our `xtask oracle`) | todo | roadmap phase 3 |
|
| Automated GnuCash diff harness | — (our `xtask oracle`) | todo | roadmap phase 3 |
|
||||||
|
|
||||||
Workflow/UI parity (QuickBooks/Xero reference) gets its own catalog when the
|
Workflow/UI parity (QuickBooks/Xero reference) gets its own catalog when the
|
||||||
egui phase starts; semantics come first.
|
egui phase starts; semantics come first.
|
||||||
|
|
||||||
|
## Capability parity (AI-first phase)
|
||||||
|
|
||||||
|
Semantic parity (above) is about *correctness*: the engine computes the
|
||||||
|
right numbers. Capability parity is about *usefulness*: can the new system
|
||||||
|
do everything the GSheet+GDrive system does today? Tracked separately in
|
||||||
|
`docs/capability-parity.md`, with its own cutover rule (parallel run +
|
||||||
|
Stephen's approval). The two catalogs converge: when both are green, the
|
||||||
|
sheet retires.
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# Receipt & document store + persistence spec
|
||||||
|
|
||||||
|
Three pieces: the **journal** (the source of truth), the **blob store**
|
||||||
|
(the bytes), and **SQLite** (the queryable projection). All immutable-first,
|
||||||
|
matching the ledger's philosophy: the books never forget, and neither does
|
||||||
|
the filing cabinet.
|
||||||
|
|
||||||
|
## The journal: the source of truth
|
||||||
|
|
||||||
|
SQLite is the query engine, not the source of truth. The source of truth is
|
||||||
|
an **append-only journal of domain events**: one JSON object per line
|
||||||
|
(JSONL), hash-chained. This is the deliberate answer to "insanely durable,
|
||||||
|
easily backupable, journable":
|
||||||
|
|
||||||
|
- **Durable.** Every state change is an immutable fact appended to
|
||||||
|
`journal.jsonl`, fsync'd per append. Each line carries the SHA-256 of the
|
||||||
|
previous line's canonical bytes (`prev`), forming a tamper-evident chain:
|
||||||
|
bit rot, torn writes, and casual editing are detected on read/verify, not
|
||||||
|
discovered at tax time. Honest scope: this detects corruption and casual
|
||||||
|
tampering. It does not stop a determined attacker with full write access
|
||||||
|
from rewriting the chain — nothing local does.
|
||||||
|
- **Backupable.** The backup is `cp -r $WANDERBOOKS_DATA /backup`: the
|
||||||
|
journal file plus the blob directory. Plain files, no WAL-file timing
|
||||||
|
windows, no special backup API. A torn tail from a mid-copy is detectable
|
||||||
|
(the last line fails hash/parse) and recoverable (truncate to the last
|
||||||
|
good seq — the projection never saw it). Better: the journal is
|
||||||
|
git-friendly, so an optional `wanderbooks backup` that commits it to a
|
||||||
|
private git repo gives versioned, distributed, deduplicated backup with
|
||||||
|
full history.
|
||||||
|
- **Journable.** It literally is a journal: `{seq, ts, type, payload, prev}`.
|
||||||
|
Greppable, diffable, replayable. The audit trail isn't a feature bolted
|
||||||
|
on — it's the storage format.
|
||||||
|
|
||||||
|
### Write path
|
||||||
|
|
||||||
|
There is exactly one write path: **append to the journal, fsync, then apply
|
||||||
|
to SQLite.** No dual writes, no background applier thread (the volume
|
||||||
|
doesn't justify it): the MCP server appends, fsyncs, applies the new lines
|
||||||
|
to the projection synchronously, and returns. A crash between append and
|
||||||
|
apply is safe — on startup the applier replays every journal line with
|
||||||
|
`seq > last_applied_seq`.
|
||||||
|
|
||||||
|
### Line format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"seq": 42, "ts": "2026-10-10T18:20:00Z", "type": "entry.posted",
|
||||||
|
"payload": { "...": "..." }, "prev": "abc123..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `seq`: 1-based, gapless, assigned by the writer.
|
||||||
|
- `ts`: UTC, RFC 3339. Recorded, never trusted for ordering (`seq` orders).
|
||||||
|
- `type`: `account.added`, `entry.posted`, `entry.reversed`,
|
||||||
|
`receipt.attached`, `receipt.linked`, `receipt.unlinked`,
|
||||||
|
`document.filed`, `attachment.withdrawn` (plus the A/R, A/P, asset,
|
||||||
|
period, payroll, and class events listed on `JournalLine`).
|
||||||
|
- `payload`: the full domain facts (account fields, entry with lines, blob
|
||||||
|
sha, ...). Everything the applier needs; nothing it must look up elsewhere.
|
||||||
|
- `prev`: hex SHA-256 of the previous line's canonical JSON (keys sorted, no
|
||||||
|
whitespace). The genesis line uses `"prev": "000...0"` (64 zeros).
|
||||||
|
|
||||||
|
### Drafts are journaled (durable)
|
||||||
|
|
||||||
|
Draft entries are durable: `entry.created` and `entry.line_added` journal
|
||||||
|
lines record the draft as the agent builds it, so drafts (and receipts
|
||||||
|
attached to them) survive server restarts. Drafts never touch the SQLite
|
||||||
|
projection until posted — reports only see posted entries. A draft that
|
||||||
|
never posts leaves its `created`/`line_added` lines in the journal, which
|
||||||
|
is correct: the journal records what happened, including abandoned work.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
`wanderbooks verify` (a `ledger-store` API, later a CLI subcommand):
|
||||||
|
re-read the journal, recompute the hash chain, replay every line into a fresh
|
||||||
|
in-memory `Ledger`, and compare its reports against the SQLite projection's.
|
||||||
|
Any mismatch is a loud failure. Run it on startup (cheap at this scale) and
|
||||||
|
before backups.
|
||||||
|
|
||||||
|
## Blob store
|
||||||
|
|
||||||
|
- **Content-addressed.** The key is SHA-256 of the exact bytes. Path:
|
||||||
|
`$DATA/blobs/<first2>/<next2>/<full-hex>`. Same bytes, same path, forever.
|
||||||
|
- **Immutable.** Blobs are never modified or deleted by the application. A
|
||||||
|
retention/deletion policy is an explicit future human decision, not an
|
||||||
|
accident of implementation.
|
||||||
|
- **Deduplicated.** Attaching the same receipt twice stores one blob and two
|
||||||
|
attachment rows. `attach_receipt` / `file_document` return
|
||||||
|
`deduplicated: true` when the blob already existed.
|
||||||
|
- **Limits.** 25 MB per file by default, configurable. MIME type is accepted
|
||||||
|
from the client and stored; server-side sniffing is future work.
|
||||||
|
- **Metadata** lives in SQLite (`blobs` table), not in the filesystem:
|
||||||
|
`sha256 PK, size_bytes, mime_type, original_name, created_at`.
|
||||||
|
|
||||||
|
## Attachments & documents
|
||||||
|
|
||||||
|
- `attachments`: `id PK, blob_sha FK, entry_id FK NULL, category,
|
||||||
|
description, created_at`.
|
||||||
|
- `entry_id` is nullable: a receipt can be filed before its journal entry
|
||||||
|
exists, then linked with `books.link_receipt`.
|
||||||
|
- `category` mirrors today's Drive layout: `formation`, `legal`, `tax`,
|
||||||
|
`accounting` (today's 01 Formation / 02 Legal / 03 Tax / 04 Accounting),
|
||||||
|
plus `receipts`.
|
||||||
|
- Documents filed via `books.file_document` and receipts via
|
||||||
|
`books.attach_receipt` share the blob store; they differ only in metadata.
|
||||||
|
- **Receipts change by events, never by edits.** `receipt.unlinked`
|
||||||
|
(`attachment_id`, `entry_id`, `reason`) detaches a receipt from its entry;
|
||||||
|
`attachment.withdrawn` (`attachment_id`, `reason`) retires an unlinked
|
||||||
|
receipt or a document (`withdrawn_at`, `withdrawn_reason` columns, schema
|
||||||
|
v5). Linking requires an unlinked, non-withdrawn attachment, so evidence
|
||||||
|
only leaves an entry through a recorded unlink. Blobs are untouched: a
|
||||||
|
withdrawn file is still hash-verified and readable, and the journal shows
|
||||||
|
when it was filed, linked, unlinked, and withdrawn, and why.
|
||||||
|
- The store is policy-free: it records attachments faithfully. Whether a
|
||||||
|
posting *requires* a receipt is decided by the MCP layer's receipt policy
|
||||||
|
(`docs/mcp-interface.md`), which reads the `attachments` table.
|
||||||
|
|
||||||
|
## `ledger-store`: the SQLite projection
|
||||||
|
|
||||||
|
SQLite is the read-optimized projection of the journal. Queries, reports,
|
||||||
|
the MCP server's reads, and the future GUI all hit SQLite — and SQLite is
|
||||||
|
**disposable by design**: `rebuild` drops it and replays the journal.
|
||||||
|
Suspected corruption is fixed by rebuild, never by forensics.
|
||||||
|
|
||||||
|
- **Location.** `$WANDERBOOKS_DATA/wanderbooks.db`, falling back to
|
||||||
|
`~/.local/share/wanderbooks/wanderbooks.db`. The journal lives next to it
|
||||||
|
at `$WANDERBOOKS_DATA/journal.jsonl`; blobs under
|
||||||
|
`$WANDERBOOKS_DATA/blobs/`. WAL mode on, `synchronous=FULL`,
|
||||||
|
`integrity_check` on startup.
|
||||||
|
- **Schema.** `meta(key, value)` — `schema_version`, `base_currency`,
|
||||||
|
`last_applied_seq`; `accounts`, `entries`, `lines`, `blobs`, `attachments`
|
||||||
|
as above; `events(seq, type, payload, created_at)` — the applier's output,
|
||||||
|
a queryable mirror of the journal stream and the feed the future GUI
|
||||||
|
tails. (Poll `WHERE seq > ?`; later SSE over the HTTP transport. This is
|
||||||
|
the correct answer to "watch its files to update live" — watch the event
|
||||||
|
stream, not the files.)
|
||||||
|
- **Amounts as TEXT, never REAL.** Unchanged and still load-bearing: a REAL
|
||||||
|
column would silently reintroduce binary floats into the money path.
|
||||||
|
- **Append-only, three layers.** (1) The engine's API never mutates posted
|
||||||
|
state. (2) The journal is append-only by file discipline plus the hash
|
||||||
|
chain. (3) The applier only ever INSERTs into the projection. The SQLite
|
||||||
|
triggers from the earlier draft are deliberately dropped: they protected
|
||||||
|
the projection, which is disposable — the journal is the trust anchor, and
|
||||||
|
piling mechanisms on the disposable layer adds complexity without adding
|
||||||
|
safety. Simplicity is a durability feature too.
|
||||||
|
- **Migrations.** Numbered SQL files, applied in order, bumping
|
||||||
|
`meta.schema_version`. Every migration is tested by rebuilding a
|
||||||
|
projection from the journal and comparing reports before/after. A newer
|
||||||
|
schema version than the binary understands is a hard error, not a guess.
|
||||||
|
|
||||||
|
## Migration from Google Drive
|
||||||
|
|
||||||
|
Backfill walks the Drive `Wanderland Labs LLC` folders and files each
|
||||||
|
document via the equivalent of `books.file_document` with its category.
|
||||||
|
The Drive folders stay exactly as they are until the capability-parity
|
||||||
|
cutover rule (see `docs/capability-parity.md`) is satisfied. Migration is an
|
||||||
|
implementation task, not part of this spec — but the spec is written so the
|
||||||
|
migration is a straightforward script, not a redesign.
|
||||||
+89
-12
@@ -34,10 +34,37 @@ requirement conflict, the requirement wins.
|
|||||||
total credits (the `balanced()` check).
|
total credits (the `balanced()` check).
|
||||||
- **R-8 — Balance sheet.** Assets, liabilities, equity grouped by account
|
- **R-8 — Balance sheet.** Assets, liabilities, equity grouped by account
|
||||||
type. The accounting equation holds exactly: assets = liabilities + equity.
|
type. The accounting equation holds exactly: assets = liabilities + equity.
|
||||||
Because period close (R-FUTURE-2) doesn't exist yet, current earnings (net
|
Current earnings (revenue minus expenses not yet closed to retained
|
||||||
income to date) are folded into equity so the equation holds on a live book.
|
earnings) are folded into equity so the equation holds on a live book.
|
||||||
- **R-9 — Profit and loss.** Revenue and expenses for the life of the ledger,
|
`balance_sheet_as_of` gives the statement as of any date, a line per
|
||||||
with net income. Period filtering arrives with R-FUTURE-2.
|
account, counting every entry dated on or before it (closing entries
|
||||||
|
included: after a close the earnings sit in retained earnings).
|
||||||
|
- **R-9 — Profit and loss.** Revenue and expenses with net income.
|
||||||
|
`profit_and_loss_for_period` scopes it to a date range (optionally one
|
||||||
|
class), a line per account, and **excludes closing entries**: a close moves
|
||||||
|
earnings into retained earnings and must not make a closed period read as
|
||||||
|
break-even. The cash flow statement excludes closing entries for the same
|
||||||
|
reason. Period P&Ls partition: split a range anywhere and the halves sum
|
||||||
|
to the whole, and a range's net income equals the change in current
|
||||||
|
earnings across it (property-tested).
|
||||||
|
- **R-9a — General ledger.** `general_ledger(start, end)`: by account in
|
||||||
|
code order, each with its beginning balance, every posting in the period
|
||||||
|
(date, entry, description, debit, credit, running balance on the account's
|
||||||
|
normal side), and its ending balance, which ties to the trial balance as
|
||||||
|
of `end`. Each posting names its *split*: the one account on the entry's
|
||||||
|
opposite side, or "-Split-" when that side has several (the QuickBooks
|
||||||
|
convention). The entry-ordered listing is the *general journal*, a
|
||||||
|
different report.
|
||||||
|
- **R-9b — Trial balance as of a date.** `trial_balance_as_of(as_of)`.
|
||||||
|
- **R-9c — Statement of changes in members' equity.** For a multi-member LLC
|
||||||
|
(partnership): each member's capital roll-forward — beginning,
|
||||||
|
contributions (credits to their capital account), allocated net income
|
||||||
|
(loss), distributions (debits), ending — plus a total that equals
|
||||||
|
balance-sheet equity. Earnings not yet distributed (retained earnings and
|
||||||
|
unclosed income) and the period's income are allocated by ownership share
|
||||||
|
to the cent, the last member absorbing rounding, so every row's total is
|
||||||
|
exact. Any equity change the roll-forward doesn't explain is shown as
|
||||||
|
"other", never hidden.
|
||||||
- **R-10 — Canonical snapshots.** `reports::snapshot()` renders the three
|
- **R-10 — Canonical snapshots.** `reports::snapshot()` renders the three
|
||||||
core reports as deterministic JSON (decimal strings, sorted lines, fixed
|
core reports as deterministic JSON (decimal strings, sorted lines, fixed
|
||||||
keys). Two engines fed the same books must produce identical output. This
|
keys). Two engines fed the same books must produce identical output. This
|
||||||
@@ -50,16 +77,66 @@ requirement conflict, the requirement wins.
|
|||||||
- **R-12 — Command surface.** All mutations go through `Ledger` methods.
|
- **R-12 — Command surface.** All mutations go through `Ledger` methods.
|
||||||
There is no second way to change the books.
|
There is no second way to change the books.
|
||||||
|
|
||||||
|
## Receipt policy (MCP phase)
|
||||||
|
|
||||||
|
- **R-13a — Receipt corrections.** Posted entries are immutable (R-3); their
|
||||||
|
evidence is not frozen but is append-only. A receipt is replaced by
|
||||||
|
attaching the new one and unlinking the old with a reason; unwanted
|
||||||
|
receipts and documents are withdrawn with a reason. Unlinking may never
|
||||||
|
leave an entry the policy (R-13) requires a receipt for with none. Every
|
||||||
|
step is a journal event; no blob is deleted.
|
||||||
|
- **R-13 — Receipt enforcement.** The MCP layer (not the engine — the engine
|
||||||
|
stays policy-free) enforces a configurable receipt policy: postings at or
|
||||||
|
above a threshold (`require_receipts_above`, default `"25.00"` in base
|
||||||
|
currency) must have at least one attached receipt, with per-account
|
||||||
|
overrides. `post_entry` with `confirmed: true` refuses with a typed
|
||||||
|
`receipt_required` error otherwise. The draft preview always includes the
|
||||||
|
receipt checklist (`attached: [...]`, `required_but_missing: bool`), so the
|
||||||
|
human sees it before confirming. Full spec: `docs/mcp-interface.md`.
|
||||||
|
|
||||||
## Explicitly future work
|
## Explicitly future work
|
||||||
|
|
||||||
- **R-FUTURE-1 — Multi-currency.** Per-account currencies, entry-level FX
|
- **R-FUTURE-6 — Budgets and scheduled transactions.** (Budgets explicitly
|
||||||
rates, translation gains/losses, reporting in a presentation currency.
|
declined as bloat; scheduled transactions remain future.)
|
||||||
- **R-FUTURE-2 — Periods and close.** Fiscal years/periods, closing entries
|
|
||||||
that move P&L balances to retained earnings, locked periods.
|
## Promoted from future (done)
|
||||||
- **R-FUTURE-3 — Cash flow statement** (indirect method).
|
|
||||||
- **R-FUTURE-4 — GnuCash XML import** (read-only; feeds the oracle harness).
|
- **R-? — Cash flow statement** (indirect method). Was R-FUTURE-3.
|
||||||
- **R-FUTURE-5 — A/R and A/P workflows** (invoices, bills, payments, aging).
|
`ledger-core::reports::cash_flow` + `books.cash_flow`.
|
||||||
- **R-FUTURE-6 — Budgets and scheduled transactions.**
|
- **R-? — QuickBooks-compatible import/export.** Was R-FUTURE-4.
|
||||||
|
`ledger-import` crate: CSV bank/chart import, IIF and XLSX export.
|
||||||
|
- **R-? — Multi-currency (foreign-currency lines).** Was R-FUTURE-1.
|
||||||
|
`JournalLine` carries optional foreign currency/amount; `amount` is the
|
||||||
|
base-currency equivalent. `Ledger::add_line_fx`, `Ledger::set_fx_rate`.
|
||||||
|
Reports work in base currency. (Full per-account currencies and translation
|
||||||
|
remain future.)
|
||||||
|
- **R-? — Periods and close.** Was R-FUTURE-2.
|
||||||
|
`ledger-core::periods`: `create_period`, `close_period_to(id, to)`; closed
|
||||||
|
periods reject new entries. The closing entry, dated at period end,
|
||||||
|
zeroes each revenue and expense account's balance **as of the period
|
||||||
|
end** (later-dated entries belong to later periods) and puts the net in
|
||||||
|
`to`: `CloseTo::RetainedEarnings` (`3999`, the corporate convention;
|
||||||
|
`close_period` is shorthand) or `CloseTo::Members` (each member's capital
|
||||||
|
account by share, to the cent, the last absorbing rounding: the
|
||||||
|
partnership convention). Periods close in order (`CloseOutOfOrder`). The
|
||||||
|
journal records the target, and replay re-derives the closing entry and
|
||||||
|
checks every line against the journal.
|
||||||
|
- **R-? — A/R and A/P workflows.** Was R-FUTURE-5.
|
||||||
|
`ledger-core::arap`: customers, vendors, invoices, bills, payments,
|
||||||
|
AR/AP aging. MCP tools for the full workflow.
|
||||||
|
- **R-? — Bank reconciliation.** Cleared flags on posted lines,
|
||||||
|
`reconciliation_report` with uncleared-line detail.
|
||||||
|
- **R-? — Payroll (accounting only).** `ledger-core::payroll`:
|
||||||
|
`run_payroll` posts one balanced entry (Dr 6100 gross, Dr 6110 employer
|
||||||
|
FICA, Cr 2110–2150 payables, Cr cash net); `remit_payroll_taxes` clears
|
||||||
|
the payables. No tax tables — the caller supplies every number.
|
||||||
|
- **R-? — Tracking classes (divisional P&L).** `ledger-core::classes`:
|
||||||
|
`JournalLine.class_id`, `add_class`, `set_line_class` (drafts only),
|
||||||
|
`profit_and_loss_filtered`, `profit_and_loss_by_class`. Shared costs are
|
||||||
|
split explicitly across lines at entry time.
|
||||||
|
- **R-? — CPA handoff package.** `books.export_cpa_package`: zip with
|
||||||
|
trial-balance XLSX, general-ledger XLSX (every posted line), P&L and
|
||||||
|
balance-sheet PDFs (printpdf, MIT).
|
||||||
|
|
||||||
## Non-goals (all phases of the engine)
|
## Non-goals (all phases of the engine)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
[package]
|
||||||
|
name = "ledger-core-fuzz"
|
||||||
|
version = "0.0.0"
|
||||||
|
publish = false
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
cargo-fuzz = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
libfuzzer-sys = "0.4"
|
||||||
|
arbitrary = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
rust_decimal = "1"
|
||||||
|
chrono = "0.4"
|
||||||
|
|
||||||
|
[dependencies.ledger-core]
|
||||||
|
path = "../crates/ledger-core"
|
||||||
|
|
||||||
|
[dependencies.ledger-store]
|
||||||
|
path = "../crates/ledger-store"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "fuzz_journal_line"
|
||||||
|
path = "fuzz_targets/fuzz_journal_line.rs"
|
||||||
|
test = false
|
||||||
|
doc = false
|
||||||
|
bench = false
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "fuzz_entry_validate"
|
||||||
|
path = "fuzz_targets/fuzz_entry_validate.rs"
|
||||||
|
test = false
|
||||||
|
doc = false
|
||||||
|
bench = false
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "fuzz_op_sequence"
|
||||||
|
path = "fuzz_targets/fuzz_op_sequence.rs"
|
||||||
|
test = false
|
||||||
|
doc = false
|
||||||
|
bench = false
|
||||||
|
|
||||||
|
[workspace]
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// Fuzz target: arbitrary JSON as a journal entry. If validation accepts
|
||||||
|
// it, the entry MUST balance exactly — validation is the enforcement
|
||||||
|
// point, and this pins it. Staged here; `cargo fuzz init` wires it up.
|
||||||
|
#![no_main]
|
||||||
|
|
||||||
|
use libfuzzer_sys::fuzz_target;
|
||||||
|
use ledger_core::{AccountType, Currency, JournalEntry, Ledger, Side};
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
|
||||||
|
fn test_chart() -> Ledger {
|
||||||
|
let mut ledger = Ledger::new(Currency::USD);
|
||||||
|
for (code, name, typ) in [
|
||||||
|
("1000", "Cash", AccountType::Asset),
|
||||||
|
("2000", "Payable", AccountType::Liability),
|
||||||
|
("3000", "Equity", AccountType::Equity),
|
||||||
|
("4000", "Revenue", AccountType::Revenue),
|
||||||
|
("6000", "Rent", AccountType::Expense),
|
||||||
|
] {
|
||||||
|
ledger.add_account(code, name, typ, None, false).unwrap();
|
||||||
|
}
|
||||||
|
ledger
|
||||||
|
}
|
||||||
|
|
||||||
|
fuzz_target!(|data: &[u8]| {
|
||||||
|
if let Ok(entry) = serde_json::from_slice::<JournalEntry>(data) {
|
||||||
|
let ledger = test_chart();
|
||||||
|
// Validation must never panic on hostile input.
|
||||||
|
if entry.validate(ledger.chart()).is_ok() {
|
||||||
|
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,
|
||||||
|
"validate() accepted an unbalanced entry: {entry:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
debits > Decimal::ZERO,
|
||||||
|
"validate() accepted a zero-total entry"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Fuzz target: journal line parsing must never panic, and the canonical
|
||||||
|
// form must be a fixed point: parse(canonical(x)) re-serializes to the
|
||||||
|
// same canonical bytes. Staged here; `cargo fuzz init` wires it up.
|
||||||
|
#![no_main]
|
||||||
|
|
||||||
|
use libfuzzer_sys::fuzz_target;
|
||||||
|
use ledger_store::JournalLine;
|
||||||
|
|
||||||
|
fuzz_target!(|data: &[u8]| {
|
||||||
|
// Garbage must return Err, never panic.
|
||||||
|
if let Ok(line) = serde_json::from_slice::<JournalLine>(data) {
|
||||||
|
let canon = line.to_canonical();
|
||||||
|
let line2: JournalLine =
|
||||||
|
serde_json::from_str(&canon).expect("canonical form must re-parse");
|
||||||
|
assert_eq!(
|
||||||
|
line2.to_canonical(),
|
||||||
|
canon,
|
||||||
|
"canonical form is not a fixed point"
|
||||||
|
);
|
||||||
|
// Sequence numbers survive the round trip.
|
||||||
|
assert_eq!(line.seq, line2.seq);
|
||||||
|
assert_eq!(line.line_type, line2.line_type);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// Fuzz target: arbitrary operation sequences against a Ledger. Errors
|
||||||
|
// are fine (invalid ops are rejected); the invariant is that the trial
|
||||||
|
// balance ALWAYS balances afterward, no matter the sequence. This is the
|
||||||
|
// stateful counterpart of the proptest properties — libfuzzer explores
|
||||||
|
// deeper interleavings. Staged here; `cargo fuzz init` wires it up.
|
||||||
|
#![no_main]
|
||||||
|
|
||||||
|
use arbitrary::Arbitrary;
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use ledger_core::{AccountType, Currency, Ledger, Money, Side};
|
||||||
|
use libfuzzer_sys::fuzz_target;
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
|
||||||
|
#[derive(Arbitrary, Debug, Clone, Copy)]
|
||||||
|
enum Op {
|
||||||
|
AddAccount { code: u8, typ: u8 },
|
||||||
|
CreateEntry { day: u8 },
|
||||||
|
AddLine {
|
||||||
|
entry: u8,
|
||||||
|
account: u8,
|
||||||
|
debit: bool,
|
||||||
|
cents: u32,
|
||||||
|
},
|
||||||
|
PostEntry { entry: u8 },
|
||||||
|
ReverseEntry { entry: u8 },
|
||||||
|
}
|
||||||
|
|
||||||
|
fn account_type(t: u8) -> AccountType {
|
||||||
|
match t % 5 {
|
||||||
|
0 => AccountType::Asset,
|
||||||
|
1 => AccountType::Liability,
|
||||||
|
2 => AccountType::Equity,
|
||||||
|
3 => AccountType::Revenue,
|
||||||
|
_ => AccountType::Expense,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fuzz_target!(|ops: Vec<Op>| {
|
||||||
|
let mut ledger = Ledger::new(Currency::USD);
|
||||||
|
let mut entry_ids: Vec<u64> = Vec::new();
|
||||||
|
let mut next_code: u32 = 1000;
|
||||||
|
|
||||||
|
for op in ops {
|
||||||
|
match op {
|
||||||
|
Op::AddAccount { code, typ } => {
|
||||||
|
// Unique codes by construction; duplicates are rejected.
|
||||||
|
let c = format!("{:04}", next_code + (code as u32 % 50));
|
||||||
|
next_code += 1;
|
||||||
|
let _ = ledger.add_account(&c, "fuzz", account_type(typ), None, false);
|
||||||
|
}
|
||||||
|
Op::CreateEntry { day } => {
|
||||||
|
let date =
|
||||||
|
NaiveDate::from_ymd_opt(2026, 1, 1 + (day as u32 % 28)).unwrap();
|
||||||
|
let id = ledger.create_entry(date, "fuzz");
|
||||||
|
entry_ids.push(id);
|
||||||
|
}
|
||||||
|
Op::AddLine {
|
||||||
|
entry,
|
||||||
|
account,
|
||||||
|
debit,
|
||||||
|
cents,
|
||||||
|
} => {
|
||||||
|
if entry_ids.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let eid = entry_ids[entry as usize % entry_ids.len()];
|
||||||
|
// Account ids are 1-based; may not exist — rejection is fine.
|
||||||
|
let aid = (account as u64 % 60) + 1;
|
||||||
|
let side = if debit { Side::Debit } else { Side::Credit };
|
||||||
|
let amount = Decimal::new((cents % 1_000_000) as i64, 2);
|
||||||
|
let _ = ledger.add_line(eid, aid, side, Money::new(amount, Currency::USD));
|
||||||
|
}
|
||||||
|
Op::PostEntry { entry } => {
|
||||||
|
if entry_ids.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let eid = entry_ids[entry as usize % entry_ids.len()];
|
||||||
|
let _ = ledger.post_entry(eid);
|
||||||
|
}
|
||||||
|
Op::ReverseEntry { entry } => {
|
||||||
|
if entry_ids.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let eid = entry_ids[entry as usize % entry_ids.len()];
|
||||||
|
let _ = ledger.reverse_entry(
|
||||||
|
eid,
|
||||||
|
NaiveDate::from_ymd_opt(2026, 12, 31).unwrap(),
|
||||||
|
"fuzz reversal",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// THE invariant: no sequence of operations, valid or rejected, may
|
||||||
|
// leave the trial balance out of balance.
|
||||||
|
let tb = ledger_core::reports::trial_balance(&ledger);
|
||||||
|
assert_eq!(
|
||||||
|
tb.total_debits, tb.total_credits,
|
||||||
|
"trial balance broken after fuzzed op sequence"
|
||||||
|
);
|
||||||
|
});
|
||||||
+185
-21
@@ -1,12 +1,12 @@
|
|||||||
# STATUS — 2026-10-10
|
# STATUS — 2026-10-11 (all 10 natural next steps done; repo now public)
|
||||||
|
|
||||||
## Where we are
|
## Where we are
|
||||||
|
|
||||||
Phase 1 (semantics core) is underway. The repo exists, is private, and the
|
All ten natural next steps are done, tested, and pushed. The repo is now
|
||||||
first engine slice is built, tested, and green.
|
public. Parity is 12 of 12 against the spreadsheet workflow.
|
||||||
|
|
||||||
**Done:**
|
**Done:**
|
||||||
- Repo `swaits/wanderbooks` created on git.swaits.com (private).
|
- Repo `swaits/wanderbooks` on git.swaits.com (public as of 2026-10-11).
|
||||||
- `ledger-core` 0.1.0: `Money` (exact Decimal + ISO currency), five account
|
- `ledger-core` 0.1.0: `Money` (exact Decimal + ISO currency), five account
|
||||||
types with normal sides, chart of accounts (codes, hierarchy,
|
types with normal sides, chart of accounts (codes, hierarchy,
|
||||||
placeholders), journal entries with full validation, `Ledger` command
|
placeholders), journal entries with full validation, `Ledger` command
|
||||||
@@ -17,29 +17,67 @@ first engine slice is built, tested, and green.
|
|||||||
- 14 tests green: validation rejections, posted-entry immutability,
|
- 14 tests green: validation rejections, posted-entry immutability,
|
||||||
reversal semantics, the accounting laws on a fixture company, a 300-entry
|
reversal semantics, the accounting laws on a fixture company, a 300-entry
|
||||||
randomized invariant test, and the first golden file.
|
randomized invariant test, and the first golden file.
|
||||||
|
- `ledger-store` 0.1.0: the source of truth is an append-only, hash-chained
|
||||||
|
JSONL journal (fsync per append; tamper-evident); content-addressed
|
||||||
|
SHA-256 blob store (immutable, deduplicated, hash-verified on read);
|
||||||
|
SQLite as a *disposable* projection (TEXT amounts, WAL, `synchronous=FULL`,
|
||||||
|
`integrity_check` on open). Single write path: validate → journal →
|
||||||
|
mutate → apply. `verify` replays the journal and diffs reports three ways;
|
||||||
|
`rebuild` drops SQLite and replays; a missing database heals on open.
|
||||||
|
7 tests green: round-trip, tamper detection, torn-tail truncation,
|
||||||
|
rebuild, blob dedup/corruption detection, unbalanced-post rejection.
|
||||||
|
- `ledger-mcp` 0.1.0: stdio server on official rmcp 3.5 (23 narrow typed
|
||||||
|
tools, amounts as decimal strings, `initialize.instructions` carrying the
|
||||||
|
agent operating contract). Draft → add lines → preview → confirmed post;
|
||||||
|
server-side receipt policy (strictly greater than $25, per-account
|
||||||
|
overrides, typed `receipt_required` rejection); digest; search; CSV/JSON
|
||||||
|
export; period P&L and as-of balance sheet; fixed-asset tools (register,
|
||||||
|
schedule, list, run-depreciation, dispose with preview). End-to-end stdio
|
||||||
|
protocol tests green (initialize → tools → draft → preview →
|
||||||
|
receipt_required → attach → post → reports → digest → reverse; and a full
|
||||||
|
asset flow: register → schedule → idempotent runs → disposal preview →
|
||||||
|
receipt_required → bill-of-sale → confirmed disposal → gain posted).
|
||||||
|
- **Fixed assets** (`ledger-core` + `ledger-store` + `ledger-mcp`):
|
||||||
|
fixed-asset register (description, purchase date, cost, salvage, useful
|
||||||
|
life, linked cost/accumulated-depreciation/expense accounts), straight-line
|
||||||
|
book depreciation with day-prorated partial months and a final-month
|
||||||
|
true-up so the lifetime total is exactly (cost − salvage), idempotent
|
||||||
|
monthly runs, disposal with auto-posted final depreciation and gain/loss.
|
||||||
|
Tax depreciation stays with the CPA. Tests: 10 asset unit tests
|
||||||
|
(ledger-core), 6 store tests, 7 property tests, MCP e2e.
|
||||||
|
- **Testing initiative** (in progress): `proptest` property suite on
|
||||||
|
`ledger-core` (trial balance always balances, A = L + E, reversals restore
|
||||||
|
zero, posted-entry immutability, JSON round-trip, depreciation exactness —
|
||||||
|
the last caught a real month-length-drift bug, fixed with the final-month
|
||||||
|
true-up); `cargo-mutants` run on `ledger-core` (228 mutants); `cargo-fuzz`
|
||||||
|
targets (journal-line parsing, entry validation, op sequences) scaffolded.
|
||||||
|
- 120+ tests green workspace-wide; `cargo fmt` clean; `cargo clippy
|
||||||
|
--workspace --all-targets` clean, zero warnings (a hard rule: Stephen,
|
||||||
|
2026-10-11).
|
||||||
- Docs: AGENTS.md (operating manual), requirements, architecture, oracle
|
- Docs: AGENTS.md (operating manual), requirements, architecture, oracle
|
||||||
design, parity catalog (floor: 6 done), glossary. Plan: this file + roadmap.
|
design, parity catalog (floor: 10 done), glossary, mcp-interface (incl. the
|
||||||
|
5 asset tools), receipt-store (journal design), capability-parity
|
||||||
|
(10 of 12 rows covered). Plan: this file + roadmap.
|
||||||
|
|
||||||
**In progress:** nothing — the next arcs are queued below, unstarted.
|
**In progress:** nothing — the next arcs are queued below, unstarted.
|
||||||
|
|
||||||
## Next arcs (in order)
|
## Next arcs (in order)
|
||||||
|
|
||||||
1. **Period close** (R-FUTURE-2): fiscal periods, closing entries moving P&L
|
The project went AI-first on 2026-10-10 (Stephen's call): the primary
|
||||||
to retained earnings, locked periods. Parity: balance sheet then reports
|
interface is MCP, not a cloned GUI. The first storage + MCP slice is done
|
||||||
retained earnings instead of folded current earnings.
|
(arcs 1–3 below). What remains:
|
||||||
2. **Cash flow, indirect method** (R-FUTURE-3): from P&L + balance-sheet
|
|
||||||
deltas. Golden-covered.
|
1. ~~**`ledger-store`**~~ done — journal + blob store + SQLite projection.
|
||||||
3. **Property tests graduate**: `proptest` with shrinking for the randomized
|
2. ~~**Blob store**~~ done — folded into `ledger-store`.
|
||||||
invariant test; new properties for close and cash flow.
|
3. ~~**`ledger-mcp`**~~ done — 18 tools, receipt policy, digest, export.
|
||||||
4. **GnuCash XML import** (R-FUTURE-4, read-only): parse GnuCash files into
|
Remaining from the spec: MCP *resources* (rmcp 3.x has no resource
|
||||||
fixture books. This unblocks the oracle harness.
|
macros; hand-roll `list_resources`/`read_resource` next), Streamable HTTP
|
||||||
5. **Oracle harness** (`ledger-oracle`, `cargo xtask oracle`): run fixture
|
transport, XLSX/IIF export formats.
|
||||||
books through our engine and GnuCash headless, diff snapshots
|
4. **Capability parity** (`docs/capability-parity.md`): 11 of 12 rows
|
||||||
automatically. Parity catalog becomes machine-checked; CI enforces the
|
covered; full CPA export (xlsx/iif) remains. Then the month-long parallel
|
||||||
floor.
|
run against the GSheet; cutover per the cutover rule.
|
||||||
6. **egui app shell** (`books-app`): command-driven UI, native + WASM,
|
5. Then: oracle harness (GnuCash diff), read-only GUI on the event log,
|
||||||
screenshot-in-the-loop per the storytold protocol. Workflow parity catalog
|
workflow parity.
|
||||||
(QuickBooks/Xero reference) starts here.
|
|
||||||
|
|
||||||
## Decisions log
|
## Decisions log
|
||||||
|
|
||||||
@@ -51,3 +89,129 @@ first engine slice is built, tested, and green.
|
|||||||
never prints "-0". (Caught by the golden test on day one; the oracle works.)
|
never prints "-0". (Caught by the golden test on day one; the oracle works.)
|
||||||
- 2026-10-10: License MIT OR Apache-2.0, matching the storytold projects.
|
- 2026-10-10: License MIT OR Apache-2.0, matching the storytold projects.
|
||||||
GnuCash clean-room boundary documented in `docs/oracle.md`.
|
GnuCash clean-room boundary documented in `docs/oracle.md`.
|
||||||
|
- 2026-10-10: AI-first pivot (Stephen). Primary interface is MCP
|
||||||
|
(`docs/mcp-interface.md`), not a cloned GUI. Receipts/documents become a
|
||||||
|
content-addressed blob store (`docs/receipt-store.md`). Parity target for
|
||||||
|
the phase is the GSheet+GDrive system (`docs/capability-parity.md`), with
|
||||||
|
a parallel-run cutover rule. GUI drops to read-only, event-log-driven,
|
||||||
|
phase 4.
|
||||||
|
- 2026-10-10: Import/export targets QuickBooks formats (CSV, IIF, XLSX) —
|
||||||
|
"could not give a single shit about GnuCash XML." GnuCash stays the
|
||||||
|
*behavioral* oracle only. Receipt enforcement is server-side policy in the
|
||||||
|
MCP layer (threshold + per-account overrides, typed `receipt_required`
|
||||||
|
error, checklist in every preview); the engine stays policy-free.
|
||||||
|
- 2026-10-10: Storage durability (Stephen). SQLite is the query engine, not
|
||||||
|
the source of truth: the source of truth is an append-only, hash-chained
|
||||||
|
JSONL journal (fsync per append; tamper-evident; git-backup-friendly),
|
||||||
|
SQLite is a disposable projection rebuilt from it, blobs stay
|
||||||
|
content-addressed. Single write path: journal → fsync → apply to SQLite.
|
||||||
|
- 2026-10-10: Draft durability (house decision). Drafts are **durable**, not
|
||||||
|
ephemeral: `entry.created` + `entry.line_added` journal lines record the
|
||||||
|
draft as the agent builds it, so drafts and their attached receipts survive
|
||||||
|
restarts. Drafts never touch the SQLite projection until posted. Rationale:
|
||||||
|
the journal is the source of truth (phantom `entry.created` lines without
|
||||||
|
draft lines were the worst of both worlds), and the draft → attach receipts
|
||||||
|
→ post workflow spans multiple MCP calls.
|
||||||
|
- 2026-10-10: Corrupt SQLite heals itself. If the projection fails
|
||||||
|
`integrity_check` (or isn't a database at all), `Store::open` quarantines
|
||||||
|
it to `wanderbooks.corrupt.<timestamp>` and rebuilds from the journal,
|
||||||
|
instead of failing open. WAL/SHM are dropped as derived state.
|
||||||
|
- 2026-10-10: Cash flow statement (R-FUTURE-3) done. Engine
|
||||||
|
`reports::cash_flow` (indirect method) + `books.cash_flow` MCP tool (24
|
||||||
|
tools total). Disposals identified structurally; gains/losses move from
|
||||||
|
operating to investing; the three sections always reconcile to the net
|
||||||
|
change in the designated cash accounts. Two invariant tests, including a
|
||||||
|
full purchase → depreciate → dispose cycle.
|
||||||
|
- 2026-10-10: MCP resources done (hand-rolled `list_resources` /
|
||||||
|
`read_resource`: chart, trial-balance, balance-sheet, profit-and-loss,
|
||||||
|
digest, receipts). `#[tool_handler]` must sit on the `impl ServerHandler`
|
||||||
|
block, not the inherent impl — misplacing it silently drops all tools.
|
||||||
|
- 2026-10-10: QuickBooks interop (R-FUTURE-4) done. New `ledger-import`
|
||||||
|
crate: QBO bank CSV parse (3-col and 4-col layouts), chart CSV parse,
|
||||||
|
IIF journal generation, XLSX report generation (rust_xlsxwriter, MIT).
|
||||||
|
MCP: `books.import_bank_csv` (drafts + categorization rules),
|
||||||
|
`books.import_chart_csv` (all-or-nothing), `books.export` gains `xlsx`
|
||||||
|
(base64) and `iif` formats plus `cash_flow` report. 26 tools total.
|
||||||
|
Capability parity 12 of 12 rows covered.
|
||||||
|
- 2026-10-10: Streamable HTTP transport done. `ledger-mcp --http ADDR`
|
||||||
|
serves MCP at `/mcp` via rmcp's tower StreamableHttpService (axum).
|
||||||
|
27 tools total. Verified with curl: initialize returns SSE stream.
|
||||||
|
- 2026-10-10: Drive backfill path done. `books.import_local_file` reads a
|
||||||
|
file from the server's filesystem into the blob store (receipt/document).
|
||||||
|
The agent downloads from Drive via the CLI, then calls this — Drive
|
||||||
|
credentials never enter the server.
|
||||||
|
- 2026-10-11: `wanderbooks init` done. New `ledger-cli` crate (binary
|
||||||
|
`wanderbooks`, std-only): `init [DIR]` scaffolds a books dir — `mise.toml`
|
||||||
|
(pinned Rust toolchain; tasks: build, mcp-add, backup), `AGENT.md` (agent
|
||||||
|
operating contract: bootstrap, MCP contract, first-run onboarding incl.
|
||||||
|
migration via conversation, ongoing rules), `wanderbooks.toml` (company
|
||||||
|
profile, filled during onboarding), `books-data/` + `.gitignore` (SQLite
|
||||||
|
projection ignored; journal is the record). Fresh dirs get `git init` and
|
||||||
|
a scaffold commit so the journal archive starts on day one. One generic
|
||||||
|
init by design — no per-situation flags; the agent is the import layer
|
||||||
|
(QBO/CSV/email/sheets, conversational). 5 integration tests green; clippy
|
||||||
|
clean. Bootstrap: `mise trust && mise install`, `mise run build`,
|
||||||
|
`mise run mcp-add`, then `claude` in the dir.
|
||||||
|
- 2026-10-11: Receipts are mutable through events, never edits (Stephen:
|
||||||
|
"the journal is just a history of every single thing that happens").
|
||||||
|
`books.unlink_receipt` and `books.withdraw_attachment` (both require a
|
||||||
|
reason) journal `receipt.unlinked` / `attachment.withdrawn`; nothing is
|
||||||
|
erased from the journal or blob store. Unlinking can't strand an entry the
|
||||||
|
receipt policy requires evidence for (attach the replacement first).
|
||||||
|
`link_receipt` now refuses an already-linked or withdrawn attachment, which
|
||||||
|
closes a silent path for moving a receipt off a posted entry; disposals
|
||||||
|
check their receipt ids before posting. Schema v5. First real use: swapping
|
||||||
|
the migrated Wanderland Labs `.eml` receipts for their PDF renders.
|
||||||
|
- 2026-10-11: The cash flow statement is a first-class output everywhere the
|
||||||
|
other two statements are: `cash_flow_pdf` in ledger-import, a `pdf`
|
||||||
|
format on `books.export` (P&L, balance sheet, cash flow), and
|
||||||
|
`cash-flow.pdf` in the CPA package, which now requires `cash_accounts`
|
||||||
|
(the statement reconciles to them). Known gaps, next: the P&L and balance
|
||||||
|
sheet PDF/XLSX ignore start/end/as_of (all-time balances under the
|
||||||
|
requested label; core needs period-scoped statements); P&L and balance
|
||||||
|
sheet XLSX are totals-only; PDF amounts don't align (padding in a
|
||||||
|
proportional font), no company name, negatives as "-x".
|
||||||
|
- 2026-10-11: Every statement export (csv, json, xlsx, pdf, CPA package)
|
||||||
|
now renders core's period statements: the date range and as-of date are
|
||||||
|
real, with a line per account. One layout (`ledger-import::report`:
|
||||||
|
sections, lines, subtotals, totals; exact "(1,234.56)" formatting) feeds
|
||||||
|
every renderer. The server reads `wanderbooks.toml`: company name for
|
||||||
|
report headers, `fiscal_year_start` for fiscal YTD (end derived), and
|
||||||
|
`receipt_threshold` (it was silently ignored before). Reports carry
|
||||||
|
"Prepared from journal through seq N".
|
||||||
|
- 2026-10-11: PDFs are typeset with Typst (typst 0.15 via typst-as-lib,
|
||||||
|
fonts bundled by typst-assets; printpdf removed). One template,
|
||||||
|
`ledger-import/templates/statement.typ`, takes the laid-out statement as
|
||||||
|
JSON data (labels are never markup). US accounting presentation in PDF
|
||||||
|
and XLSX alike: tabular figures, "$" on each section's first amount and
|
||||||
|
on totals, negatives in parentheses with the closing paren hanging so
|
||||||
|
decimal points align, zero as a dash, single rule over sums, double rule
|
||||||
|
under totals, amounts never bold (bold figures are wider). Pages flow
|
||||||
|
with a repeated header and "Page n of m". Core's cash flow now orders
|
||||||
|
lines by account code (it followed HashMap order, so line order could
|
||||||
|
change between runs).
|
||||||
|
- 2026-10-11: The canonical report set. Core gained the general ledger by
|
||||||
|
account (running balances; QuickBooks' "split" = the opposite side of the
|
||||||
|
entry, "-Split-" when several accounts), the trial balance as of a date,
|
||||||
|
and the statement of changes in members' equity (capital roll-forward per
|
||||||
|
member, allocation by share to the cent, ties to balance-sheet equity).
|
||||||
|
The entry-ordered listing is now correctly called the general journal.
|
||||||
|
Reports render QuickBooks-style (Stephen's reference): Inter (bundled,
|
||||||
|
OFL) instead of the typst-assets serif fonts, centered title block, a
|
||||||
|
column-header row, multi-column statements (trial balance Debit/Credit,
|
||||||
|
one column per member), "$" on sums, rules above sums, a double rule
|
||||||
|
under totals, zero as 0.00, entity terms (Members' equity, Net income
|
||||||
|
(loss), standard cash-flow wording) and an "Accrual basis · Prepared …"
|
||||||
|
footer. "Today" is local time, not UTC (evening exports were dated
|
||||||
|
tomorrow).
|
||||||
|
- 2026-10-11: Period close fixed and made partnership-aware. The close swept
|
||||||
|
every revenue/expense posting ever made, so a period closed after later
|
||||||
|
entries existed swallowed them (closing January in March closed February
|
||||||
|
and March too); it now zeroes balances as of the period end, refuses to
|
||||||
|
close out of order, and its preview is the real close run on a copy of
|
||||||
|
the ledger. A multi-member LLC closes into each member's capital account
|
||||||
|
by share (CloseTo::Members), the partnership convention, so capital
|
||||||
|
accounts carry allocated earnings; the members'-equity statement treats
|
||||||
|
those closes as income, not contributions, and the balance sheet shows
|
||||||
|
only unallocated earnings. Replay now checks closing lines, not just ids.
|
||||||
|
|
||||||
+68
-30
@@ -1,49 +1,87 @@
|
|||||||
# Roadmap
|
# Roadmap
|
||||||
|
|
||||||
## Phase 1 — Semantics core (now)
|
## Phase 1 — Semantics core (done)
|
||||||
|
|
||||||
The engine: money, accounts, journal, ledger commands, trial balance, P&L,
|
The engine: money, accounts, journal, ledger commands, trial balance, P&L,
|
||||||
balance sheet, golden files, property tests, and the docs an agent needs to
|
balance sheet, cash flow, fixed assets with depreciation, golden files,
|
||||||
work here (AGENTS.md, requirements, architecture, oracle, parity, glossary).
|
property tests, mutation testing, fuzzing, and the docs an agent needs to
|
||||||
Done when the parity floor is 6+ and every `done` item is tested +
|
work here. Parity floor: 12 done.
|
||||||
golden-covered.
|
|
||||||
|
|
||||||
## Phase 2 — Close the accounting loop
|
## Phase 2 — AI-first interface (done)
|
||||||
|
|
||||||
- Period close (fiscal periods, closing entries, locked periods)
|
The primary interface is MCP, not a cloned GUI (Stephen's call, 2026-10-10).
|
||||||
- Cash flow statement, indirect method
|
|
||||||
- `proptest` with shrinking; properties for close and cash flow
|
|
||||||
- Parity floor moves; goldens grow
|
|
||||||
|
|
||||||
## Phase 3 — Oracle harness
|
- `ledger-store`: append-only hash-chained JSONL journal (source of truth),
|
||||||
|
SQLite as disposable projection with auto-rebuild, content-addressed
|
||||||
|
blob store, durable drafts.
|
||||||
|
- `ledger-mcp`: 27 tools over stdio and Streamable HTTP, preview-before-post,
|
||||||
|
server-side receipt policy (>$25) with anti-splitting, asset management,
|
||||||
|
digest for human review, read-only resources.
|
||||||
|
- `ledger-import`: QuickBooks CSV import (bank, chart), IIF and XLSX export.
|
||||||
|
- Capability parity 12 of 12 vs the GSheet+GDrive system.
|
||||||
|
- Parallel run with the sheet; cutover per the cutover rule
|
||||||
|
(`docs/parallel-run.md`).
|
||||||
|
|
||||||
|
## Phase 3 — Oracle harness (next)
|
||||||
|
|
||||||
- `ledger-import`: GnuCash XML read-only import
|
|
||||||
- `ledger-oracle`: fixture books through our engine and GnuCash headless,
|
- `ledger-oracle`: fixture books through our engine and GnuCash headless,
|
||||||
automatic `snapshot()` diffing via `cargo xtask oracle`
|
automatic `snapshot()` diffing.
|
||||||
- Parity catalog becomes machine-readable; CI enforces the non-regression
|
- Parity catalog becomes machine-readable; CI enforces the non-regression
|
||||||
floor
|
floor.
|
||||||
- Done when: a fixture book produces byte-identical snapshots from both
|
- Done when: a fixture book produces byte-identical snapshots from both
|
||||||
engines with zero manual steps
|
engines with zero manual steps.
|
||||||
|
|
||||||
## Phase 4 — App shell (egui)
|
## Phase 4 — Read-only GUI
|
||||||
|
|
||||||
- `books-app`: one Rust codebase, native desktop + WASM (the storytold
|
- Consumes the `ledger-store` event log: live updates — the answer to
|
||||||
stack: egui/eframe, no Electron, no React)
|
"watch its files to update live."
|
||||||
- Command-driven UI: every UI action goes through the `Ledger` command
|
- egui or web; a dumb client holding no accounting logic.
|
||||||
surface; the UI holds no accounting logic
|
- This is the trust layer for humans (Stephen, Kat, the CPA), not the
|
||||||
- storytold's screenshot-in-the-loop returns: agents verify UI changes
|
primary interface. Read-only at first; editing stays with the agent.
|
||||||
visually, against reference screenshots of GnuCash/QuickBooks kept
|
- Design references (Stephen, 2026-10-11; screenshots shared in session,
|
||||||
maintainer-local (never committed)
|
described here):
|
||||||
- Workflow parity catalog starts (QuickBooks/Xero reference): invoices,
|
- **Reports with a date range and a column interval** (QuickBooks
|
||||||
bills, bank rec, chart management
|
Desktop's P&L: From/To plus Columns = Year/Quarter/Month/...): any
|
||||||
|
statement split into one column per interval plus TOTAL, to see the
|
||||||
|
books over time. The multi-column statement layout in
|
||||||
|
`ledger-import::report` already renders this shape; the GUI (and the
|
||||||
|
exports) need a per-interval builder over the period-scoped core
|
||||||
|
statements.
|
||||||
|
- **Financial dashboard** (Coupler.io's QuickBooks dashboard): P&L,
|
||||||
|
balance sheet, and cash flow overview tables with Last month / Month to
|
||||||
|
date / Year to date columns, each beside charts — income vs. COGS +
|
||||||
|
expenses vs. earnings bars; stacked assets vs. liabilities + equity;
|
||||||
|
cash at end of period; net cash change.
|
||||||
|
- **Business overview** (QuickBooks Online home): cards for cash balance
|
||||||
|
with a trend, expenses by category (donut), profit and loss (income
|
||||||
|
vs. expenses), invoices (unpaid, overdue, paid), sales trend, and bank
|
||||||
|
account balances.
|
||||||
|
- **Revenue dashboard** (Coupler.io): KPI tiles (paid, overdue, open
|
||||||
|
invoices this month), an invoice table (number, customer, dates,
|
||||||
|
currency, home and foreign amounts, balance, totals), expenses by
|
||||||
|
category by month, invoices by status per month.
|
||||||
|
- **QuickBooks Online dashboard**: invoices (unpaid in the last year,
|
||||||
|
overdue vs. not yet due; paid in the last 30 days, deposited vs. not),
|
||||||
|
expenses by category (donut, last month), bank account balances (book
|
||||||
|
vs. bank, items to review), profit and loss (net income, income vs.
|
||||||
|
expenses bars), sales trend (line, by quarter).
|
||||||
|
- **KPI tiles with comparison** (QuickBooks accounting dashboard): income,
|
||||||
|
cost of goods sold, expenses, net profit, each with % change vs. the
|
||||||
|
previous 30 days; an auto date-range picker; cash balance over time
|
||||||
|
with a trend line; revenue by month, stacked.
|
||||||
|
- **Cash-flow dashboard**: bank balance, debtors, burn rate, cash runway
|
||||||
|
(days) tiles; income vs. expenses for the last six months; a net cash
|
||||||
|
flow forecast with a confidence band; A/P and A/R aging by days past
|
||||||
|
due (the engine already computes both agings); total burn over time.
|
||||||
|
|
||||||
## Phase 5 — Workflow parity
|
## Phase 5 — Workflow parity
|
||||||
|
|
||||||
- A/R and A/P workflows, bank reconciliation, budgets, scheduled transactions
|
- A/R and A/P workflows, bank reconciliation, budgets, scheduled transactions.
|
||||||
- Multi-currency with FX translation
|
- Multi-currency with FX translation.
|
||||||
- The goal: a self-hosted bookkeeping app with a tested claim to
|
- Period close.
|
||||||
semantic parity with GnuCash and workflow parity with QuickBooks —
|
- The goal: a self-hosted bookkeeping system with a tested claim to
|
||||||
no SaaS subscription required
|
semantic parity with GnuCash and capability parity with the old
|
||||||
|
GSheet+GDrive workflow — no SaaS subscription required.
|
||||||
|
|
||||||
## Explicitly not on the roadmap
|
## Explicitly not on the roadmap
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user