Compare commits
172
Commits
P11a
...
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/oracle.md` | How we prove correctness: golden files, property tests, GnuCash diffing |
|
||||
| `docs/parity.md` | Feature catalog and the non-regression floor |
|
||||
| `docs/capability-parity.md` | Parity vs the GSheet+GDrive system + the cutover rule |
|
||||
| `docs/mcp-interface.md` | The MCP tool surface: the primary interface spec |
|
||||
| `docs/receipt-store.md` | Blob store + `ledger-store` SQLite persistence spec |
|
||||
| `docs/glossary.md` | Debit/credit and friends, for agents new to accounting |
|
||||
| `plan/STATUS.md` | Where the project stands right now |
|
||||
| `plan/roadmap.md` | Phase plan: semantics → oracle harness → egui app → workflow parity |
|
||||
|
||||
Generated
+4779
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["crates/ledger-core"]
|
||||
members = ["crates/ledger-core", "crates/ledger-store", "crates/ledger-mcp", "crates/ledger-import", "crates/ledger-cli"]
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
|
||||
@@ -1,50 +1,72 @@
|
||||
# wanderbooks
|
||||
|
||||
Canonical double-entry bookkeeping, rebuilt from scratch — the storytold way:
|
||||
an engine verified against oracles, with automated parity tracking, on its
|
||||
way to becoming a self-hosted bookkeeping app that never charges rent.
|
||||
Canonical double-entry bookkeeping, rebuilt from scratch. The engine is
|
||||
verified against oracles, parity is tracked automatically, and the primary
|
||||
interface is AI-first: you talk to an agent, the agent drives the books
|
||||
through MCP. Self-hosted, no rent.
|
||||
|
||||
Private while it cooks.
|
||||
## What it is
|
||||
|
||||
- **`ledger-core`** — the semantics engine. Exact decimal money, five
|
||||
account types, balanced journal entries, immutable posted log with
|
||||
reversing entries, trial balance, P&L, balance sheet, indirect-method
|
||||
cash flow, fixed-asset register with straight-line depreciation.
|
||||
- **`ledger-store`** — durable storage. The source of truth is an
|
||||
append-only, hash-chained JSONL journal; SQLite is a disposable
|
||||
projection rebuilt from it; receipts live in a content-addressed blob
|
||||
store. Drafts are durable, corrupt projections self-heal.
|
||||
- **`ledger-mcp`** — the AI-first interface. 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
|
||||
|
||||
Phase 1: the semantics engine. `ledger-core` implements the accounting laws
|
||||
in Rust — exact decimal money, five account types, balanced journal entries,
|
||||
immutable posted log with reversing entries, trial balance, P&L, and balance
|
||||
sheet — with 14 invariant tests and golden-file report snapshots.
|
||||
Parity is 12 of 12 against the spreadsheet workflow it replaces
|
||||
(`docs/capability-parity.md`). The parallel-run plan
|
||||
(`docs/parallel-run.md`) is written; the Google Sheet stays the system of
|
||||
record until a clean month and explicit cutover approval.
|
||||
|
||||
See [`plan/STATUS.md`](plan/STATUS.md) for where things stand and
|
||||
[`plan/roadmap.md`](plan/roadmap.md) for where they're going.
|
||||
[`plan/roadmap.md`](plan/roadmap.md) for where it's going.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
cargo test
|
||||
cargo test # whole suite
|
||||
cargo run -p ledger-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
|
||||
```
|
||||
|
||||
That's the whole test suite: validation rejections, immutability, the
|
||||
accounting equation on a fixture company, a 300-entry randomized invariant
|
||||
test, and the golden oracle test.
|
||||
Then `cd ~/books/acme`: `mise trust && mise install`, `mise run build`,
|
||||
`mise run mcp-add`, and start `claude` — the agent onboards from AGENT.md.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
crates/ledger-core/ # the engine: money, accounts, journal, ledger, reports
|
||||
docs/ # requirements, architecture, oracle design, parity, glossary
|
||||
crates/ledger-core/ # engine: money, accounts, journal, ledger, reports
|
||||
crates/ledger-store/ # journal + SQLite projection + blob store
|
||||
crates/ledger-mcp/ # MCP server: 27 tools, resources, receipt policy
|
||||
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
|
||||
|
||||
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]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "1"
|
||||
@@ -84,40 +84,20 @@ pub struct ChartOfAccounts {
|
||||
impl ChartOfAccounts {
|
||||
/// Register an account. Codes must be unique and non-empty; a parent, if
|
||||
/// given, must already exist.
|
||||
pub fn add(
|
||||
&mut self,
|
||||
id: AccountId,
|
||||
code: &str,
|
||||
name: &str,
|
||||
account_type: AccountType,
|
||||
parent: Option<AccountId>,
|
||||
placeholder: bool,
|
||||
currency: Currency,
|
||||
) -> Result<(), LedgerError> {
|
||||
if code.is_empty() {
|
||||
pub fn add(&mut self, account: Account) -> Result<(), LedgerError> {
|
||||
if account.code.is_empty() {
|
||||
return Err(LedgerError::EmptyAccountCode);
|
||||
}
|
||||
if self.by_code.contains_key(code) {
|
||||
return Err(LedgerError::DuplicateAccountCode(code.to_string()));
|
||||
if self.by_code.contains_key(&account.code) {
|
||||
return Err(LedgerError::DuplicateAccountCode(account.code));
|
||||
}
|
||||
if let Some(p) = parent {
|
||||
if !self.accounts.contains_key(&p) {
|
||||
if let Some(p) = account.parent
|
||||
&& !self.accounts.contains_key(&p)
|
||||
{
|
||||
return Err(LedgerError::UnknownParentAccount(p));
|
||||
}
|
||||
}
|
||||
self.by_code.insert(code.to_string(), id);
|
||||
self.accounts.insert(
|
||||
id,
|
||||
Account {
|
||||
id,
|
||||
code: code.to_string(),
|
||||
name: name.to_string(),
|
||||
account_type,
|
||||
parent,
|
||||
placeholder,
|
||||
currency,
|
||||
},
|
||||
);
|
||||
self.by_code.insert(account.code.clone(), account.id);
|
||||
self.accounts.insert(account.id, account);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
#[error("cannot modify a posted entry; post a reversing entry instead")]
|
||||
PostedEntryImmutable,
|
||||
|
||||
/// The referenced fixed asset does not exist.
|
||||
#[error("unknown asset id {0}")]
|
||||
UnknownAsset(u64),
|
||||
|
||||
/// Asset descriptions must not be empty.
|
||||
#[error("asset description must not be empty")]
|
||||
EmptyAssetDescription,
|
||||
|
||||
/// Asset cost must be positive.
|
||||
#[error("asset cost must be positive, got {0}")]
|
||||
InvalidAssetCost(Decimal),
|
||||
|
||||
/// Salvage value must be non-negative and less than cost.
|
||||
#[error("salvage value must be between zero and cost, got {0}")]
|
||||
InvalidSalvageValue(Decimal),
|
||||
|
||||
/// Useful life must be at least one month.
|
||||
#[error("useful life must be at least one month, got {0}")]
|
||||
InvalidUsefulLife(u32),
|
||||
|
||||
/// The asset was already disposed.
|
||||
#[error("asset {0} is already disposed")]
|
||||
AssetAlreadyDisposed(u64),
|
||||
|
||||
/// Asset account links must have the right types: cost and accumulated
|
||||
/// depreciation are asset accounts, the depreciation expense account is
|
||||
/// an expense account.
|
||||
#[error("account '{code}' has type {found}, but the asset {role} account must be {expected}")]
|
||||
AssetAccountWrongType {
|
||||
/// Account code.
|
||||
code: String,
|
||||
/// Role the account was given: "cost", "accumulated depreciation", or
|
||||
/// "depreciation expense".
|
||||
role: &'static str,
|
||||
/// The type it must be.
|
||||
expected: &'static str,
|
||||
/// The type it has.
|
||||
found: &'static str,
|
||||
},
|
||||
|
||||
/// Disposal proceeds must not be negative.
|
||||
#[error("disposal proceeds must not be negative, got {0}")]
|
||||
NegativeProceeds(Decimal),
|
||||
|
||||
/// Disposal date must not precede the purchase date.
|
||||
#[error("disposal date {0} is before the purchase date")]
|
||||
DisposalBeforePurchase(String),
|
||||
|
||||
/// The three asset account links must be three distinct accounts.
|
||||
#[error("asset account links must be distinct; account id {0} is linked twice")]
|
||||
DuplicateAssetAccount(u64),
|
||||
|
||||
/// purchase_date + useful life overflows the date range.
|
||||
#[error("purchase date plus useful life overflows the calendar")]
|
||||
AssetDateOverflow,
|
||||
|
||||
/// 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
|
||||
//! is one-way; a posted entry can only be corrected by a reversing entry.
|
||||
|
||||
use crate::{AccountId, ChartOfAccounts, Currency, LedgerError, Money, Side};
|
||||
use crate::{AccountId, ChartOfAccounts, ClassId, Currency, LedgerError, Money, Side};
|
||||
use chrono::NaiveDate;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -29,8 +29,24 @@ pub struct JournalLine {
|
||||
pub account: AccountId,
|
||||
/// Which side of the entry this line is on.
|
||||
pub side: Side,
|
||||
/// The exact amount. Never zero (rejected at validation).
|
||||
/// The exact amount, in the ledger's base currency. Never zero (rejected
|
||||
/// at validation).
|
||||
pub amount: Money,
|
||||
/// For foreign-currency lines: the original currency. `None` for base-
|
||||
/// currency lines. When set, `amount` is the base-currency equivalent
|
||||
/// converted at the line's rate.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub foreign_currency: Option<Currency>,
|
||||
/// For foreign-currency lines: the original foreign amount. `None` for
|
||||
/// base-currency lines.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub foreign_amount: Option<Decimal>,
|
||||
/// Divisional tracking: which class (department, location, job) this
|
||||
/// line belongs to. `None` means unclassified. Set on drafts only via
|
||||
/// [`crate::Ledger::set_line_class`]; posted lines are immutable, so
|
||||
/// their classes are frozen.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub class_id: Option<ClassId>,
|
||||
}
|
||||
|
||||
/// A balanced set of journal lines with a date and a description.
|
||||
@@ -93,6 +109,12 @@ impl JournalEntry {
|
||||
self.lines.push(line);
|
||||
}
|
||||
|
||||
/// Tag a line with a class (or clear it). Only while drafted; only via
|
||||
/// [`crate::Ledger::set_line_class`].
|
||||
pub(crate) fn set_line_class(&mut self, index: usize, class_id: Option<ClassId>) {
|
||||
self.lines[index].class_id = class_id;
|
||||
}
|
||||
|
||||
/// Mark posted. Only via [`crate::Ledger`], after [`Self::validate`].
|
||||
pub(crate) fn mark_posted(&mut self) {
|
||||
self.status = EntryStatus::Posted;
|
||||
|
||||
+1455
-23
File diff suppressed because it is too large.
Load diff
@@ -17,15 +17,37 @@
|
||||
//! (`docs/oracle.md`) possible.
|
||||
|
||||
pub mod account;
|
||||
pub mod arap;
|
||||
pub mod assets;
|
||||
pub mod classes;
|
||||
pub mod error;
|
||||
pub mod journal;
|
||||
pub mod ledger;
|
||||
pub mod money;
|
||||
pub mod payroll;
|
||||
pub mod periods;
|
||||
pub mod reports;
|
||||
|
||||
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 journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
|
||||
pub use ledger::Ledger;
|
||||
pub use ledger::{Ledger, ReconciliationReport, UnclearedLine};
|
||||
pub use money::{Currency, Money};
|
||||
pub use reports::{BalanceSheet, ProfitAndLoss, TrialBalance, TrialBalanceLine};
|
||||
pub use 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 {
|
||||
/// US dollar.
|
||||
pub const USD: Currency = Currency([b'U', b'S', b'D']);
|
||||
pub const USD: Currency = Currency(*b"USD");
|
||||
/// Euro.
|
||||
pub const EUR: Currency = Currency([b'E', b'U', b'R']);
|
||||
pub const EUR: Currency = Currency(*b"EUR");
|
||||
/// British pound.
|
||||
pub const GBP: Currency = Currency([b'G', b'B', b'P']);
|
||||
pub const GBP: Currency = Currency(*b"GBP");
|
||||
/// Swiss franc.
|
||||
pub const CHF: Currency = Currency([b'C', b'H', b'F']);
|
||||
pub const CHF: Currency = Currency(*b"CHF");
|
||||
/// Japanese yen.
|
||||
pub const JPY: Currency = Currency([b'J', b'P', b'Y']);
|
||||
pub const JPY: Currency = Currency(*b"JPY");
|
||||
|
||||
/// Parse and validate a three-letter code. Input is uppercased, so
|
||||
/// `"usd"` and `"USD"` both work; `"US"` or `"USDD"` fail.
|
||||
@@ -123,3 +123,40 @@ impl fmt::Display for Money {
|
||||
write!(f, "{} {}", self.amount, self.currency)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn usd(amount: Decimal) -> Money {
|
||||
Money::new(amount, Currency::USD)
|
||||
}
|
||||
|
||||
fn d(cents: i64) -> Decimal {
|
||||
Decimal::new(cents, 2)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_arithmetic() {
|
||||
assert_eq!(
|
||||
usd(d(1050)).checked_add(usd(d(425))).unwrap().amount,
|
||||
d(1475)
|
||||
);
|
||||
assert_eq!(
|
||||
usd(d(1050)).checked_sub(usd(d(425))).unwrap().amount,
|
||||
d(625)
|
||||
);
|
||||
// Subtraction is not division, and not addition.
|
||||
assert_ne!(
|
||||
usd(d(1000)).checked_sub(usd(d(200))).unwrap().amount,
|
||||
d(500)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_arithmetic_rejects_currency_mismatch() {
|
||||
let eur = Money::new(d(100), Currency::EUR);
|
||||
assert!(usd(d(100)).checked_add(eur).is_err());
|
||||
assert!(usd(d(100)).checked_sub(eur).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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 { .. })));
|
||||
}
|
||||
}
|
||||
+1047
-10
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 ledger_core::reports::{balance_sheet, profit_and_loss, snapshot, trial_balance};
|
||||
use ledger_core::{AccountId, AccountType, Currency, EntryStatus, Ledger, LedgerError, Money, Side};
|
||||
use ledger_core::{
|
||||
AccountId, AccountType, Currency, EntryStatus, Ledger, LedgerError, Money, Side,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -49,15 +51,29 @@ struct Company {
|
||||
|
||||
fn fixture_company() -> Company {
|
||||
let mut ledger = Ledger::new(Currency::USD);
|
||||
let cash = ledger.add_account("1000", "Cash", AccountType::Asset, None, false).unwrap();
|
||||
let cash = ledger
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let ar = ledger
|
||||
.add_account("1200", "Accounts Receivable", AccountType::Asset, None, false)
|
||||
.add_account(
|
||||
"1200",
|
||||
"Accounts Receivable",
|
||||
AccountType::Asset,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let equipment = ledger
|
||||
.add_account("1500", "Equipment", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let ap = ledger
|
||||
.add_account("2000", "Accounts Payable", AccountType::Liability, None, false)
|
||||
.add_account(
|
||||
"2000",
|
||||
"Accounts Payable",
|
||||
AccountType::Liability,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let equity = ledger
|
||||
.add_account("3000", "Owner's Equity", AccountType::Equity, None, false)
|
||||
@@ -70,32 +86,51 @@ fn fixture_company() -> Company {
|
||||
.unwrap();
|
||||
|
||||
// Owner invests $10,000 cash.
|
||||
post(&mut ledger, date(2), "Owner investment", &[
|
||||
(cash, Side::Debit, 10_000),
|
||||
(equity, Side::Credit, 10_000),
|
||||
]);
|
||||
post(
|
||||
&mut ledger,
|
||||
date(2),
|
||||
"Owner investment",
|
||||
&[(cash, Side::Debit, 10_000), (equity, Side::Credit, 10_000)],
|
||||
);
|
||||
// Buy equipment for $2,500 cash.
|
||||
post(&mut ledger, date(5), "Buy equipment", &[
|
||||
(equipment, Side::Debit, 2_500),
|
||||
(cash, Side::Credit, 2_500),
|
||||
]);
|
||||
post(
|
||||
&mut ledger,
|
||||
date(5),
|
||||
"Buy equipment",
|
||||
&[(equipment, Side::Debit, 2_500), (cash, Side::Credit, 2_500)],
|
||||
);
|
||||
// Bill a client $4,000.
|
||||
post(&mut ledger, date(10), "Invoice client", &[
|
||||
(ar, Side::Debit, 4_000),
|
||||
(revenue, Side::Credit, 4_000),
|
||||
]);
|
||||
post(
|
||||
&mut ledger,
|
||||
date(10),
|
||||
"Invoice client",
|
||||
&[(ar, Side::Debit, 4_000), (revenue, Side::Credit, 4_000)],
|
||||
);
|
||||
// Pay January rent, $800.
|
||||
post(&mut ledger, date(15), "January rent", &[
|
||||
(rent, Side::Debit, 800),
|
||||
(cash, Side::Credit, 800),
|
||||
]);
|
||||
post(
|
||||
&mut ledger,
|
||||
date(15),
|
||||
"January rent",
|
||||
&[(rent, Side::Debit, 800), (cash, Side::Credit, 800)],
|
||||
);
|
||||
// Collect $1,500 of receivables.
|
||||
post(&mut ledger, date(20), "Client payment", &[
|
||||
(cash, Side::Debit, 1_500),
|
||||
(ar, Side::Credit, 1_500),
|
||||
]);
|
||||
post(
|
||||
&mut ledger,
|
||||
date(20),
|
||||
"Client payment",
|
||||
&[(cash, Side::Debit, 1_500), (ar, Side::Credit, 1_500)],
|
||||
);
|
||||
|
||||
Company { ledger, cash, ar, equipment, _ap: ap, equity, revenue, rent }
|
||||
Company {
|
||||
ledger,
|
||||
cash,
|
||||
ar,
|
||||
equipment,
|
||||
_ap: ap,
|
||||
equity,
|
||||
revenue,
|
||||
rent,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -108,7 +143,9 @@ fn unbalanced_entry_is_rejected() {
|
||||
let mut ledger = c.ledger;
|
||||
let id = ledger.create_entry(date(21), "Bad entry");
|
||||
ledger.add_line(id, c.cash, Side::Debit, usd(100)).unwrap();
|
||||
ledger.add_line(id, c.equity, Side::Credit, usd(99)).unwrap();
|
||||
ledger
|
||||
.add_line(id, c.equity, Side::Credit, usd(99))
|
||||
.unwrap();
|
||||
let err = ledger.post_entry(id).unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
@@ -135,7 +172,9 @@ fn zero_amount_line_is_rejected() {
|
||||
let id = ledger.create_entry(date(21), "Zero line");
|
||||
ledger.add_line(id, c.cash, Side::Debit, usd(100)).unwrap();
|
||||
ledger.add_line(id, c.cash, Side::Debit, usd(0)).unwrap();
|
||||
ledger.add_line(id, c.equity, Side::Credit, usd(100)).unwrap();
|
||||
ledger
|
||||
.add_line(id, c.equity, Side::Credit, usd(100))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ledger.post_entry(id).unwrap_err(),
|
||||
LedgerError::ZeroAmountLine
|
||||
@@ -148,7 +187,9 @@ fn unknown_account_is_rejected() {
|
||||
let mut ledger = c.ledger;
|
||||
let id = ledger.create_entry(date(21), "Ghost account");
|
||||
ledger.add_line(id, c.cash, Side::Debit, usd(100)).unwrap();
|
||||
ledger.add_line(id, 999_999, Side::Credit, usd(100)).unwrap();
|
||||
ledger
|
||||
.add_line(id, 999_999, Side::Credit, usd(100))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ledger.post_entry(id).unwrap_err(),
|
||||
LedgerError::UnknownAccount(999_999)
|
||||
@@ -164,7 +205,9 @@ fn placeholder_account_is_rejected() {
|
||||
.unwrap();
|
||||
let id = ledger.create_entry(date(21), "Post to header");
|
||||
ledger.add_line(id, header, Side::Debit, usd(100)).unwrap();
|
||||
ledger.add_line(id, c.equity, Side::Credit, usd(100)).unwrap();
|
||||
ledger
|
||||
.add_line(id, c.equity, Side::Credit, usd(100))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ledger.post_entry(id).unwrap_err(),
|
||||
LedgerError::PlaceholderAccount("100".to_string())
|
||||
@@ -207,12 +250,16 @@ fn posted_entry_is_immutable() {
|
||||
let mut ledger = c.ledger;
|
||||
let id = ledger.create_entry(date(21), "To post");
|
||||
ledger.add_line(id, c.cash, Side::Debit, usd(50)).unwrap();
|
||||
ledger.add_line(id, c.equity, Side::Credit, usd(50)).unwrap();
|
||||
ledger
|
||||
.add_line(id, c.equity, Side::Credit, usd(50))
|
||||
.unwrap();
|
||||
ledger.post_entry(id).unwrap();
|
||||
|
||||
// Cannot add lines after posting.
|
||||
assert_eq!(
|
||||
ledger.add_line(id, c.cash, Side::Debit, usd(1)).unwrap_err(),
|
||||
ledger
|
||||
.add_line(id, c.cash, Side::Debit, usd(1))
|
||||
.unwrap_err(),
|
||||
LedgerError::PostedEntryImmutable
|
||||
);
|
||||
// Cannot post twice.
|
||||
@@ -233,7 +280,9 @@ fn reversal_nets_to_zero_and_links_back() {
|
||||
ledger.post_entry(id).unwrap();
|
||||
assert_eq!(ledger.account_movement(c.rent), Decimal::from(1300));
|
||||
|
||||
let rev = ledger.reverse_entry(id, date(22), "Reverse mistake").unwrap();
|
||||
let rev = ledger
|
||||
.reverse_entry(id, date(22), "Reverse mistake")
|
||||
.unwrap();
|
||||
let rev_entry = ledger.get_entry(rev).unwrap();
|
||||
assert_eq!(rev_entry.reversal_of(), Some(id));
|
||||
assert_eq!(rev_entry.status(), EntryStatus::Posted);
|
||||
@@ -319,7 +368,15 @@ fn random_balanced_journals_preserve_invariants() {
|
||||
let mut rng = Lcg(0x1234_5678_9abc_def0);
|
||||
let c = fixture_company();
|
||||
let mut ledger = c.ledger;
|
||||
let accounts = [c.cash, c.ar, c.equipment, c._ap, c.equity, c.revenue, c.rent];
|
||||
let accounts = [
|
||||
c.cash,
|
||||
c.ar,
|
||||
c.equipment,
|
||||
c._ap,
|
||||
c.equity,
|
||||
c.revenue,
|
||||
c.rent,
|
||||
];
|
||||
|
||||
for i in 0..300u32 {
|
||||
let total = rng.next(100_000) + 100;
|
||||
@@ -362,3 +419,217 @@ fn golden_fixture_company_reports() {
|
||||
serde_json::from_str(include_str!("golden/fixture_company.json")).unwrap();
|
||||
assert_eq!(snap, golden);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_account_validation() {
|
||||
let mut ledger = Ledger::new(Currency::USD);
|
||||
// Unknown parent is rejected.
|
||||
let err = ledger
|
||||
.add_account("1100", "Child", AccountType::Asset, Some(999), false)
|
||||
.unwrap_err();
|
||||
assert_eq!(err, LedgerError::UnknownParentAccount(999));
|
||||
// A real parent works, and the child records it.
|
||||
let parent = ledger
|
||||
.add_account("1000", "Parent", AccountType::Asset, None, true)
|
||||
.unwrap();
|
||||
let child = ledger
|
||||
.add_account("1100", "Child", AccountType::Asset, Some(parent), false)
|
||||
.unwrap();
|
||||
assert_eq!(ledger.chart().get(child).unwrap().parent, Some(parent));
|
||||
// Chart accessors report honestly.
|
||||
assert!(Ledger::new(Currency::USD).chart().is_empty());
|
||||
assert!(!ledger.chart().is_empty());
|
||||
assert_eq!(ledger.chart().len(), 2);
|
||||
assert!(ledger.chart().find_by_code("1000").is_some());
|
||||
assert!(ledger.chart().find_by_code("nope").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_type_balance_sheet_predicate() {
|
||||
assert!(AccountType::Asset.is_balance_sheet());
|
||||
assert!(AccountType::Liability.is_balance_sheet());
|
||||
assert!(AccountType::Equity.is_balance_sheet());
|
||||
assert!(!AccountType::Revenue.is_balance_sheet());
|
||||
assert!(!AccountType::Expense.is_balance_sheet());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_movements_sum_to_zero() {
|
||||
let mut c = fixture_company();
|
||||
let cash = c.cash;
|
||||
let revenue = c.revenue;
|
||||
let id = c.ledger.create_entry(date(15), "sale");
|
||||
c.ledger.add_line(id, cash, Side::Debit, usd(1000)).unwrap();
|
||||
c.ledger
|
||||
.add_line(id, revenue, Side::Credit, usd(1000))
|
||||
.unwrap();
|
||||
c.ledger.post_entry(id).unwrap();
|
||||
let entry = c.ledger.get_entry(id).unwrap();
|
||||
let m = entry.movements();
|
||||
assert_eq!(m[&cash], Decimal::new(100000, 2));
|
||||
assert_eq!(m[&revenue], Decimal::new(-100000, 2));
|
||||
let total: Decimal = m.values().copied().sum();
|
||||
assert_eq!(total, Decimal::ZERO);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cash flow statement (indirect method).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use ledger_core::NewAsset;
|
||||
use ledger_core::reports::{CashFlowParams, cash_flow};
|
||||
|
||||
fn cash_flow_params(cash: AccountId) -> CashFlowParams {
|
||||
CashFlowParams {
|
||||
start: date(1),
|
||||
end: date(31),
|
||||
cash_accounts: vec![cash],
|
||||
financing_accounts: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cash_flow_reconciles_to_change_in_cash() {
|
||||
let c = fixture_company();
|
||||
let cf = cash_flow(&c.ledger, &cash_flow_params(c.cash)).unwrap();
|
||||
|
||||
// The three sections must sum to the net change in cash. This is the
|
||||
// accounting law the statement exists to uphold.
|
||||
let sections = cf.cash_from_operations + cf.cash_from_investing + cf.cash_from_financing;
|
||||
assert_eq!(sections, cf.net_change_in_cash);
|
||||
assert_eq!(cf.cash_ending - cf.cash_beginning, cf.net_change_in_cash);
|
||||
|
||||
// Hand-computed from the fixture:
|
||||
// Cash: +10,000 (invest) - 2,500 (equipment) - 800 (rent) + 1,500 (collect)
|
||||
// = 8,200. Beginning 0.
|
||||
assert_eq!(cf.cash_beginning, Decimal::ZERO);
|
||||
assert_eq!(cf.cash_ending, Decimal::new(820000, 2));
|
||||
assert_eq!(cf.net_change_in_cash, Decimal::new(820000, 2));
|
||||
|
||||
// Net income: 4,000 revenue - 800 rent = 3,200.
|
||||
assert_eq!(cf.net_income, Decimal::new(320000, 2));
|
||||
// No depreciation: the fixture's equipment is not a registered asset.
|
||||
assert_eq!(cf.depreciation, Decimal::ZERO);
|
||||
|
||||
// Operating: 3,200 net income
|
||||
// - 2,500 increase in AR (4,000 billed - 1,500 collected)
|
||||
// - 2,500 equipment purchase (not a registered asset: working capital)
|
||||
// + 10,000 owner investment is equity -> financing, not operating.
|
||||
// Financing: +10,000 owner contribution.
|
||||
// => operating = 3,200 - 2,500 - 2,500 = -1,800
|
||||
// => investing = 0, financing = 10,000. Sum = 8,200. Reconciles.
|
||||
assert_eq!(cf.cash_from_operations, Decimal::new(-180000, 2));
|
||||
assert_eq!(cf.cash_from_investing, Decimal::ZERO);
|
||||
assert_eq!(cf.cash_from_financing, Decimal::new(1000000, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cash_flow_with_registered_asset_and_disposal() {
|
||||
let mut ledger = Ledger::new(Currency::USD);
|
||||
let cash = ledger
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let cost = ledger
|
||||
.add_account("1500", "Equipment at cost", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let accum = ledger
|
||||
.add_account(
|
||||
"1501",
|
||||
"Accum. depr. — equip.",
|
||||
AccountType::Asset,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let depr_exp = ledger
|
||||
.add_account(
|
||||
"6000",
|
||||
"Depreciation expense",
|
||||
AccountType::Expense,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
ledger
|
||||
.add_account("4000", "Service Revenue", AccountType::Revenue, None, false)
|
||||
.unwrap();
|
||||
let gain = ledger
|
||||
.add_account("4100", "Gain on sale", AccountType::Revenue, None, false)
|
||||
.unwrap();
|
||||
let equity = ledger
|
||||
.add_account("3000", "Owner's Equity", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
|
||||
// Owner invests $100,000.
|
||||
post(
|
||||
&mut ledger,
|
||||
date(2),
|
||||
"Owner investment",
|
||||
&[
|
||||
(cash, Side::Debit, 100_000),
|
||||
(equity, Side::Credit, 100_000),
|
||||
],
|
||||
);
|
||||
// Register a $60,000 van, 60-month life, bought Jan 15.
|
||||
let asset = ledger
|
||||
.register_asset(NewAsset {
|
||||
description: "Van".to_string(),
|
||||
purchase_date: NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
|
||||
cost: usd(60_000),
|
||||
salvage_value: usd(0),
|
||||
useful_life_months: 60,
|
||||
cost_account: cost,
|
||||
accum_depr_account: accum,
|
||||
expense_account: depr_exp,
|
||||
})
|
||||
.unwrap();
|
||||
// Buy the van for cash.
|
||||
post(
|
||||
&mut ledger,
|
||||
date(15),
|
||||
"Buy van",
|
||||
&[(cost, Side::Debit, 60_000), (cash, Side::Credit, 60_000)],
|
||||
);
|
||||
// January depreciation: $1,000 * 17/31 = $548.39.
|
||||
post(
|
||||
&mut ledger,
|
||||
date(31),
|
||||
"January depreciation",
|
||||
&[(depr_exp, Side::Debit, 548), (accum, Side::Credit, 548)],
|
||||
);
|
||||
// Sell the van Jan 31 for $59,600 cash. Book value 60,000 - 548 = 59,452.
|
||||
// Gain = 59,600 - 59,452 = 148.
|
||||
// Note: amounts are whole dollars here to keep the fixture simple.
|
||||
let _ = asset;
|
||||
post(
|
||||
&mut ledger,
|
||||
date(31),
|
||||
"Sell van",
|
||||
&[
|
||||
(cash, Side::Debit, 59_600),
|
||||
(accum, Side::Debit, 548),
|
||||
(cost, Side::Credit, 60_000),
|
||||
(gain, Side::Credit, 148),
|
||||
],
|
||||
);
|
||||
|
||||
let cf = cash_flow(&ledger, &cash_flow_params(cash)).unwrap();
|
||||
|
||||
// Reconciliation is the law.
|
||||
let sections = cf.cash_from_operations + cf.cash_from_investing + cf.cash_from_financing;
|
||||
assert_eq!(sections, cf.net_change_in_cash);
|
||||
|
||||
// Cash: 100,000 - 60,000 + 59,600 = 99,600.
|
||||
assert_eq!(cf.cash_ending, Decimal::new(9960000, 2));
|
||||
// Net income: 148 gain - 548 depreciation = -400.
|
||||
assert_eq!(cf.net_income, Decimal::new(-40000, 2));
|
||||
// Depreciation added back: 548.
|
||||
assert_eq!(cf.depreciation, Decimal::new(54800, 2));
|
||||
// Operating: -400 + 548 - 148 (gain removed) = 0.
|
||||
assert_eq!(cf.cash_from_operations, Decimal::ZERO);
|
||||
// Investing: -60,000 purchase + 59,600 proceeds = -400.
|
||||
assert_eq!(cf.cash_from_investing, Decimal::new(-40000, 2));
|
||||
// Financing: +100,000 owner investment.
|
||||
assert_eq!(cf.cash_from_financing, Decimal::new(10000000, 2));
|
||||
// Sum: 0 - 400 + 100,000 = 99,600. Matches cash.
|
||||
}
|
||||
@@ -0,0 +1,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 |
|
||||
|---|---|---|
|
||||
| `ledger-store` | SQLite persistence of the entry log | `ledger-core` |
|
||||
| `ledger-import` | GnuCash XML read-only import | `ledger-core` |
|
||||
| `ledger-store` | Journal (hash-chained JSONL) + SQLite projection, blob store | `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` |
|
||||
| `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
|
||||
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
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
- **Narrow tools, explicit arguments.** No "do what I mean" tools. The agent
|
||||
@@ -56,52 +80,176 @@ layer.
|
||||
→ `{ lines: [...], totals: { debits, credits, balanced } }`. Running totals
|
||||
after every line: the agent watches the entry balance in real time.
|
||||
- `books.post_entry { entry_id, confirmed }` → without confirmation:
|
||||
`{ preview: { lines, resulting_balances } }`; with confirmation: validates,
|
||||
posts, returns `{ entry, balances_changed: [{ code, before, after }] }`.
|
||||
`{ preview: { lines, resulting_balances, receipts: { attached: [...],
|
||||
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
|
||||
preview/confirm pattern as post.
|
||||
- `books.get_entry { entry_id }` → full entry with lines.
|
||||
- `books.search_entries { account?, date_from?, date_to?, text?, limit?, offset? }`
|
||||
→ entries with lines, newest first. `limit` caps at 100.
|
||||
- `books.trial_balance {}` → canonical JSON (same shape as
|
||||
`reports::snapshot` trial-balance section).
|
||||
- `books.balance_sheet {}` → canonical JSON.
|
||||
- `books.profit_and_loss {}` → canonical JSON.
|
||||
- `books.trial_balance { as_of? }` → `as_of`, lines, totals, `balanced`
|
||||
(the trial balance as of the date, default today).
|
||||
- `books.balance_sheet { as_of? }` → totals (`assets`, `liabilities`,
|
||||
`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" }`
|
||||
→ `{ entries_posted: [...], totals_by_account: [...] }`. The human review
|
||||
surface: "what did the AI do with my books lately." The digest is the
|
||||
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
|
||||
|
||||
- `books.attach_receipt { entry_id?, file_name, mime_type, content_base64 }`
|
||||
→ `{ receipt_id, sha256, deduplicated, size_bytes }`. `entry_id` is
|
||||
optional: a receipt can be filed before its entry exists.
|
||||
- `books.link_receipt { receipt_id, entry_id }` → link a previously filed
|
||||
receipt to its entry.
|
||||
- `books.list_receipts { entry_id? }` → metadata list (no bytes).
|
||||
(unlinked, not withdrawn) receipt to its entry. A receipt linked elsewhere
|
||||
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.file_document { category: "formation" | "legal" | "tax" |
|
||||
"accounting" | "other", file_name, mime_type, content_base64,
|
||||
description? }` → `{ document_id, sha256, deduplicated }`. The Drive-folder
|
||||
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
|
||||
|
||||
- `books.export { format: "csv" | "json", scope: "journal" | "trial-balance" |
|
||||
"profit-and-loss" | "balance-sheet" | "chart" }` → `{ content_base64,
|
||||
file_name }`. CPA-ready output. GnuCash XML arrives with R-FUTURE-4.
|
||||
- `books.export { format, report, start?, end?, as_of?, cash_accounts?,
|
||||
financing_accounts? }` → `{ content, content_encoding? }`.
|
||||
- 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)
|
||||
|
||||
Hand-rolled on `ServerHandler` (rmcp 3.x has no resource macros):
|
||||
`list_resources` advertises, `read_resource` renders.
|
||||
|
||||
- `books://chart`
|
||||
- `books://reports/trial-balance`, `books://reports/balance-sheet`,
|
||||
`books://reports/profit-and-loss`
|
||||
- `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,
|
||||
auditors) never need tool credentials.
|
||||
auditors) never need tool credentials. Tested end-to-end over stdio.
|
||||
|
||||
## The agent operating contract
|
||||
|
||||
@@ -113,7 +261,8 @@ in the server's tool descriptions, so the model reads it at use time):
|
||||
in prose.
|
||||
3. **Attach receipts at record time.** A material entry without its source
|
||||
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
|
||||
wrong posting is a reversal. Never guess-post.
|
||||
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
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
**Layer 2 — Property tests (live now, growing).**
|
||||
`random_balanced_journals_preserve_invariants` posts hundreds of random
|
||||
balanced entries and asserts the accounting laws still hold: trial balance
|
||||
balances, the accounting equation holds, and the sum of all account movements
|
||||
is exactly zero. New invariants get new properties. (Later: graduate to
|
||||
`proptest` with shrinking.)
|
||||
`crates/ledger-core/tests/properties.rs` uses `proptest` (with shrinking)
|
||||
to assert the accounting laws over hundreds of random inputs: trial balance
|
||||
always balances, the accounting equation holds, reversals restore zero,
|
||||
posted entries are immutable and JSON-round-trip clean, and depreciation
|
||||
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
|
||||
will: import fixture books into GnuCash's XML format (or drive GnuCash
|
||||
headless), run both engines, and diff `snapshot()` output automatically —
|
||||
`cargo xtask oracle`. This is the direct analog of storytold's
|
||||
`cargo xtask corpus` / `oracle_diff`. Until it exists, Layers 1 and 2 are
|
||||
the oracle, and they are not optional.
|
||||
**Layer 3 — Mutation testing (live now).** `cargo mutants -p ledger-core`
|
||||
(228 mutants as of 2026-10-10: 159 caught, 28 unviable, 2 timeouts from
|
||||
induced infinite loops, 39 missed). Every *missed* mutant is triaged:
|
||||
real gaps get new tests (this pass added parent-account validation,
|
||||
wrong-typed asset links, zero-proceeds disposal, two-asset ids, 1-month
|
||||
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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
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 |
|
||||
|---|---|---|---|
|
||||
@@ -19,17 +19,38 @@ No other statuses.
|
||||
| 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 |
|
||||
| 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` |
|
||||
| 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 |
|
||||
| Multi-currency + FX translation | Multi-currency accts | todo | R-FUTURE-1 |
|
||||
| GnuCash XML import (read-only) | File format | todo | feeds oracle harness; R-FUTURE-4 |
|
||||
| A/R invoices + payments + aging | Business → Invoices | todo | R-FUTURE-5 |
|
||||
| A/P bills + payments + aging | Business → Bills | todo | R-FUTURE-5 |
|
||||
| Budgets | Budgets | todo | R-FUTURE-6 |
|
||||
| Foreign-currency lines (base + foreign stored) | Multi-currency accts | done | R-FUTURE-1 |
|
||||
| Period close (closing entry to Retained Earnings) | Close books | done | R-FUTURE-2 |
|
||||
| Bank reconciliation (cleared flags + report) | Reconcile | done | — |
|
||||
| QuickBooks CSV import (bank txns, chart of accounts) | Banking → Upload | done | R-FUTURE-4 |
|
||||
| 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 |
|
||||
| Automated GnuCash diff harness | — (our `xtask oracle`) | todo | roadmap phase 3 |
|
||||
|
||||
Workflow/UI parity (QuickBooks/Xero reference) gets its own catalog when the
|
||||
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).
|
||||
- **R-8 — Balance sheet.** Assets, liabilities, equity grouped by account
|
||||
type. The accounting equation holds exactly: assets = liabilities + equity.
|
||||
Because period close (R-FUTURE-2) doesn't exist yet, current earnings (net
|
||||
income to date) 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,
|
||||
with net income. Period filtering arrives with R-FUTURE-2.
|
||||
Current earnings (revenue minus expenses not yet closed to retained
|
||||
earnings) are folded into equity so the equation holds on a live book.
|
||||
`balance_sheet_as_of` gives the statement as of any date, a line per
|
||||
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
|
||||
core reports as deterministic JSON (decimal strings, sorted lines, fixed
|
||||
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.
|
||||
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
|
||||
|
||||
- **R-FUTURE-1 — Multi-currency.** Per-account currencies, entry-level FX
|
||||
rates, translation gains/losses, reporting in a presentation currency.
|
||||
- **R-FUTURE-2 — Periods and close.** Fiscal years/periods, closing entries
|
||||
that move P&L balances to retained earnings, locked periods.
|
||||
- **R-FUTURE-3 — Cash flow statement** (indirect method).
|
||||
- **R-FUTURE-4 — GnuCash XML import** (read-only; feeds the oracle harness).
|
||||
- **R-FUTURE-5 — A/R and A/P workflows** (invoices, bills, payments, aging).
|
||||
- **R-FUTURE-6 — Budgets and scheduled transactions.**
|
||||
- **R-FUTURE-6 — Budgets and scheduled transactions.** (Budgets explicitly
|
||||
declined as bloat; scheduled transactions remain future.)
|
||||
|
||||
## Promoted from future (done)
|
||||
|
||||
- **R-? — Cash flow statement** (indirect method). Was R-FUTURE-3.
|
||||
`ledger-core::reports::cash_flow` + `books.cash_flow`.
|
||||
- **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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Phase 1 (semantics core) is underway. The repo exists, is private, and the
|
||||
first engine slice is built, tested, and green.
|
||||
All ten natural next steps are done, tested, and pushed. The repo is now
|
||||
public. Parity is 12 of 12 against the spreadsheet workflow.
|
||||
|
||||
**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
|
||||
types with normal sides, chart of accounts (codes, hierarchy,
|
||||
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,
|
||||
reversal semantics, the accounting laws on a fixture company, a 300-entry
|
||||
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
|
||||
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.
|
||||
|
||||
## Next arcs (in order)
|
||||
|
||||
1. **Period close** (R-FUTURE-2): fiscal periods, closing entries moving P&L
|
||||
to retained earnings, locked periods. Parity: balance sheet then reports
|
||||
retained earnings instead of folded current earnings.
|
||||
2. **Cash flow, indirect method** (R-FUTURE-3): from P&L + balance-sheet
|
||||
deltas. Golden-covered.
|
||||
3. **Property tests graduate**: `proptest` with shrinking for the randomized
|
||||
invariant test; new properties for close and cash flow.
|
||||
4. **GnuCash XML import** (R-FUTURE-4, read-only): parse GnuCash files into
|
||||
fixture books. This unblocks the oracle harness.
|
||||
5. **Oracle harness** (`ledger-oracle`, `cargo xtask oracle`): run fixture
|
||||
books through our engine and GnuCash headless, diff snapshots
|
||||
automatically. Parity catalog becomes machine-checked; CI enforces the
|
||||
floor.
|
||||
6. **egui app shell** (`books-app`): command-driven UI, native + WASM,
|
||||
screenshot-in-the-loop per the storytold protocol. Workflow parity catalog
|
||||
(QuickBooks/Xero reference) starts here.
|
||||
The project went AI-first on 2026-10-10 (Stephen's call): the primary
|
||||
interface is MCP, not a cloned GUI. The first storage + MCP slice is done
|
||||
(arcs 1–3 below). What remains:
|
||||
|
||||
1. ~~**`ledger-store`**~~ done — journal + blob store + SQLite projection.
|
||||
2. ~~**Blob store**~~ done — folded into `ledger-store`.
|
||||
3. ~~**`ledger-mcp`**~~ done — 18 tools, receipt policy, digest, export.
|
||||
Remaining from the spec: MCP *resources* (rmcp 3.x has no resource
|
||||
macros; hand-roll `list_resources`/`read_resource` next), Streamable HTTP
|
||||
transport, XLSX/IIF export formats.
|
||||
4. **Capability parity** (`docs/capability-parity.md`): 11 of 12 rows
|
||||
covered; full CPA export (xlsx/iif) remains. Then the month-long parallel
|
||||
run against the GSheet; cutover per the cutover rule.
|
||||
5. Then: oracle harness (GnuCash diff), read-only GUI on the event log,
|
||||
workflow parity.
|
||||
|
||||
## 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.)
|
||||
- 2026-10-10: License MIT OR Apache-2.0, matching the storytold projects.
|
||||
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
|
||||
|
||||
## Phase 1 — Semantics core (now)
|
||||
## Phase 1 — Semantics core (done)
|
||||
|
||||
The engine: money, accounts, journal, ledger commands, trial balance, P&L,
|
||||
balance sheet, golden files, property tests, and the docs an agent needs to
|
||||
work here (AGENTS.md, requirements, architecture, oracle, parity, glossary).
|
||||
Done when the parity floor is 6+ and every `done` item is tested +
|
||||
golden-covered.
|
||||
balance sheet, cash flow, fixed assets with depreciation, golden files,
|
||||
property tests, mutation testing, fuzzing, and the docs an agent needs to
|
||||
work here. Parity floor: 12 done.
|
||||
|
||||
## Phase 2 — Close the accounting loop
|
||||
## Phase 2 — AI-first interface (done)
|
||||
|
||||
- Period close (fiscal periods, closing entries, locked periods)
|
||||
- Cash flow statement, indirect method
|
||||
- `proptest` with shrinking; properties for close and cash flow
|
||||
- Parity floor moves; goldens grow
|
||||
The primary interface is MCP, not a cloned GUI (Stephen's call, 2026-10-10).
|
||||
|
||||
## 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,
|
||||
automatic `snapshot()` diffing via `cargo xtask oracle`
|
||||
automatic `snapshot()` diffing.
|
||||
- Parity catalog becomes machine-readable; CI enforces the non-regression
|
||||
floor
|
||||
floor.
|
||||
- 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
|
||||
stack: egui/eframe, no Electron, no React)
|
||||
- Command-driven UI: every UI action goes through the `Ledger` command
|
||||
surface; the UI holds no accounting logic
|
||||
- storytold's screenshot-in-the-loop returns: agents verify UI changes
|
||||
visually, against reference screenshots of GnuCash/QuickBooks kept
|
||||
maintainer-local (never committed)
|
||||
- Workflow parity catalog starts (QuickBooks/Xero reference): invoices,
|
||||
bills, bank rec, chart management
|
||||
- Consumes the `ledger-store` event log: live updates — the answer to
|
||||
"watch its files to update live."
|
||||
- egui or web; a dumb client holding no accounting logic.
|
||||
- This is the trust layer for humans (Stephen, Kat, the CPA), not the
|
||||
primary interface. Read-only at first; editing stays with the agent.
|
||||
- Design references (Stephen, 2026-10-11; screenshots shared in session,
|
||||
described here):
|
||||
- **Reports with a date range and a column interval** (QuickBooks
|
||||
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
|
||||
|
||||
- A/R and A/P workflows, bank reconciliation, budgets, scheduled transactions
|
||||
- Multi-currency with FX translation
|
||||
- The goal: a self-hosted bookkeeping app with a tested claim to
|
||||
semantic parity with GnuCash and workflow parity with QuickBooks —
|
||||
no SaaS subscription required
|
||||
- A/R and A/P workflows, bank reconciliation, budgets, scheduled transactions.
|
||||
- Multi-currency with FX translation.
|
||||
- Period close.
|
||||
- The goal: a self-hosted bookkeeping system with a tested claim to
|
||||
semantic parity with GnuCash and capability parity with the old
|
||||
GSheet+GDrive workflow — no SaaS subscription required.
|
||||
|
||||
## Explicitly not on the roadmap
|
||||
|
||||
|
||||
Reference in new issue
Block a user