Compare commits
128
Commits
8164abb2b8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a82791789e
|
||
|
|
6bd921c4c8
|
||
|
|
920b15ba82
|
||
|
|
3b1cf472e2
|
||
|
|
c6fea172ec
|
||
|
|
2aaba56b38
|
||
|
|
b0b92235cc
|
||
|
|
94338b76cb
|
||
|
|
a833229d87
|
||
|
|
b292a505b1
|
||
|
|
ee1a372179
|
||
|
|
71c345d39c
|
||
|
|
349d8a79cd
|
||
|
|
391634a0a5
|
||
|
|
c9772b70c1
|
||
|
|
b22a034746
|
||
|
|
aec55b8d12
|
||
|
|
754d616a9d
|
||
|
|
188c90ea99
|
||
|
|
a968d7a19a
|
||
|
|
579ab743d4 | ||
|
|
a44a8873df | ||
|
|
9927e56431 | ||
|
|
2275948a64 | ||
|
|
4c74169d18 | ||
|
|
df3fc3f4e4 | ||
|
|
ae927c3f73 | ||
|
|
e265ed4339 | ||
|
|
0a62353767 | ||
|
|
4826a39a88 | ||
|
|
27e78a0ed5 | ||
|
|
6e6f9d65b2 | ||
|
|
49c49aef33 | ||
|
|
2e14c702c0 | ||
|
|
ee7e205f5c | ||
|
|
767fd469cd | ||
|
|
1771d87e0e | ||
|
|
efa370dfa3 | ||
|
|
ca3a279219 | ||
|
|
7a6cc154ae | ||
|
|
612d807f98 | ||
|
|
c1f92ed3e8 | ||
|
|
f0fc725ca2 | ||
|
|
d1cbd845c0 | ||
|
|
2fa84a37ff | ||
|
|
47d301bcfe | ||
|
|
eba85f9c77 | ||
|
|
8ac4e5438d | ||
|
|
122fed0a7a | ||
|
|
8de1965707 | ||
|
|
9a1f009f98 | ||
|
|
fe9625502f | ||
|
|
13a52c5849 | ||
|
|
50f22af695 | ||
|
|
9837c62117 | ||
|
|
e6c69a9d34 | ||
|
|
9110cd888d | ||
|
|
1291547fc4 | ||
|
|
3f20df9700 | ||
|
|
5b8910a1cd | ||
|
|
ccec4e9439 | ||
|
|
abba6241e7 | ||
|
|
11e33edc60 | ||
|
|
9f81fc0817 | ||
|
|
38a24c7609 | ||
|
|
e52b88433c | ||
|
|
ea180d1d8e | ||
|
|
c72a762ef1 | ||
|
|
f1bdac4bca | ||
|
|
7559b4c808 | ||
|
|
d67e897f1b | ||
|
|
0cf8fb842f | ||
|
|
db00b74e82 | ||
|
|
4d8d5daf37 | ||
|
|
8a49f0ff0e | ||
|
|
2cbb6a263f | ||
|
|
fb3b4ab476 | ||
|
|
f4381f170c | ||
|
|
37824e55d3 | ||
|
|
819cd09e0d | ||
|
|
08d1a9c230 | ||
|
|
0dcf865ff8 | ||
|
|
4af2f6c0bb | ||
|
|
22d1a3d449 | ||
|
|
1212367138 | ||
|
|
725b9beaac | ||
|
|
d0490bf106 | ||
|
|
8c8ba19471 | ||
|
|
c539004d1a | ||
|
|
8a9a2485cf | ||
|
|
c9810ea0a3 | ||
|
|
cece4c4554 | ||
|
|
beb1fcf233 | ||
|
|
0d4a5f8f53 | ||
|
|
925c3ba523 | ||
|
|
df38dcccf9 | ||
|
|
e2bebce051 | ||
|
|
ad572ae89d | ||
|
|
0340f316ee | ||
|
|
a09c1fb218 | ||
|
|
3609bf065e | ||
|
|
d5c4e38b18 | ||
|
|
bea2ee2dc2 | ||
|
|
a6566e801f | ||
|
|
12308fe3e9 | ||
|
|
ea996f6e7e | ||
|
|
9eba3180b4 | ||
|
|
799aee58ea | ||
|
|
b38c9e39cc | ||
|
|
024c9896f2
|
||
|
|
475d08b28e
|
||
|
|
8637c1b05f
|
||
|
|
17eed43ab9
|
||
|
|
a9850b6b05
|
||
|
|
3f20858df3
|
||
|
|
2a00f4ef33
|
||
|
|
54d2775a33
|
||
|
|
01d41c0daf
|
||
|
|
005a96055a
|
||
|
|
45784266b5
|
||
|
|
9694d2a89a
|
||
|
|
714867ed58
|
||
|
|
105fd52694
|
||
|
|
b48addd0e0
|
||
|
|
d65f9aa840
|
||
|
|
ddb67a2ca2
|
||
|
|
dbdc8e671f
|
||
|
|
f858fb4cfe
|
No files matched your search
@@ -0,0 +1,7 @@
|
||||
# Test books (a few KB each, every journal append synced) go in the system
|
||||
# temp dir, /tmp, even when TMPDIR points elsewhere: `just mutants*` sets
|
||||
# TMPDIR to ~/.cache for cargo-mutants' multi-GB tree copies, and cargo
|
||||
# passes [env] to the tests it runs, not to cargo-mutants itself. On disk
|
||||
# the synced appends made each mutant's tests several times slower.
|
||||
[env]
|
||||
TMPDIR = { value = "/tmp", force = true }
|
||||
@@ -0,0 +1,15 @@
|
||||
# Every change: format, clippy and rustdoc with zero warnings, every test.
|
||||
# Tools come from mise.toml, the same as on a laptop.
|
||||
name: check
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: jdx/mise-action@v2
|
||||
- name: GUI build dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y libxkbcommon-dev libgtk-3-dev
|
||||
- run: just check
|
||||
@@ -0,0 +1,82 @@
|
||||
# Weekly, and on demand: the slow layers (docs/testing.md).
|
||||
name: scheduled
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 6 * * 1"
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
prove:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: jdx/mise-action@v2
|
||||
- run: just prove
|
||||
oracle:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: jdx/mise-action@v2
|
||||
- run: just oracle
|
||||
fuzz:
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target: [fuzz_journal_line, fuzz_entry_validate, fuzz_op_sequence, fuzz_core_serde, fuzz_store_open, fuzz_store_replay, fuzz_settings_replay, fuzz_import_csv, fuzz_config_parse]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: jdx/mise-action@v2
|
||||
- run: just fuzz ${{ matrix.target }} 600
|
||||
mutants:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: jdx/mise-action@v2
|
||||
- run: just mutants
|
||||
- run: just mutants-store
|
||||
- if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mutants
|
||||
path: |
|
||||
mutants.out
|
||||
mutants-store/mutants.out
|
||||
mutants-import:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: jdx/mise-action@v2
|
||||
- run: just mutants-import
|
||||
- run: just mutants-config
|
||||
- if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mutants-import
|
||||
path: |
|
||||
mutants-import/mutants.out
|
||||
mutants-config/mutants.out
|
||||
mutants-mcp:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 350
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: jdx/mise-action@v2
|
||||
- run: just mutants-mcp
|
||||
- if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mutants-mcp
|
||||
path: mutants-mcp/mutants.out
|
||||
mutants-gui:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 350
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: jdx/mise-action@v2
|
||||
- run: sudo apt-get update && sudo apt-get install -y libxkbcommon-dev libgtk-3-dev
|
||||
- run: just mutants-gui
|
||||
- if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mutants-gui
|
||||
path: mutants-gui/mutants.out
|
||||
+5
-1
@@ -1,4 +1,8 @@
|
||||
/target/
|
||||
Cargo.lock
|
||||
*.orig
|
||||
.DS_Store
|
||||
site/dist/
|
||||
.netlify/
|
||||
# cargo-mutants output (just mutants*)
|
||||
/mutants.out*/
|
||||
/mutants-*/
|
||||
@@ -12,9 +12,13 @@ what they haven't demonstrated.
|
||||
semantics, built to become a full bookkeeping application (engine now, UI
|
||||
later). The reference points:
|
||||
|
||||
- **Semantic oracle:** GnuCash. It is open source with open file formats
|
||||
(XML/SQLite), which makes it both the spec and the reference implementation
|
||||
we diff against. See `docs/oracle.md`.
|
||||
- **Spec:** accounting principles, written down as proofs
|
||||
(`docs/verification.md`). That is the only authority.
|
||||
- **Cross-check oracles:** GnuCash (open source, open file formats), the
|
||||
old Google Sheet, QBO/Xero exports. Useful to diff against, *never*
|
||||
trusted to be right: a disagreement is adjudicated from principles, and
|
||||
nothing may rest on another program's behavior alone. See
|
||||
`docs/oracle.md`, "Who is right".
|
||||
- **Workflow reference:** QuickBooks Online and Xero — for *what the UI must
|
||||
do*, never for code. We observe behavior; we write our own implementation.
|
||||
- **Method reference:** storytold/ArtCraft's app-cloning pipeline
|
||||
@@ -37,6 +41,10 @@ later). The reference points:
|
||||
IRS publications for tax-adjacent logic later. GnuCash itself is GPL —
|
||||
read its docs, never copy its code. This repo is MIT OR Apache-2.0.
|
||||
Never disassemble or copy from proprietary software.
|
||||
6. **Accounting laws are proved, not just tested.** Arithmetic that money
|
||||
flows through lives in `kernel.rs` and carries Kani proofs; the laws
|
||||
it serves carry Lean proofs. `just prove` stays green. See
|
||||
"Formal methods" below.
|
||||
|
||||
## Verification protocol (read this twice)
|
||||
|
||||
@@ -60,6 +68,68 @@ numbers**. The analog of storytold's screenshot-in-the-loop:
|
||||
- The randomized invariant test (`random_balanced_journals_preserve_invariants`)
|
||||
is the always-on property oracle. Extend it when you add invariants.
|
||||
|
||||
## Formal methods: the bar (read this three times)
|
||||
|
||||
Tests check the cases someone thought of; proofs check all of them. The
|
||||
first formal pass on this engine (2026-10, `docs/verification.md`) paid
|
||||
for itself several times over, on code that already had 190+ tests,
|
||||
property tests, mutation testing, and 11.5M fuzz executions:
|
||||
|
||||
- **Negative line amounts were accepted by the engine.** Only the MCP
|
||||
layer refused them. Any other caller could post them.
|
||||
- **A large enough amount crashed the engine** (a `Decimal` overflow
|
||||
panic while summing lines) instead of returning an error.
|
||||
- **Currency conversion could crash** the same way.
|
||||
- **The verifier itself had a soundness bug.** Kani 0.68 mis-models
|
||||
`copy_from_slice` of symbolic length (model-checking/kani#4904): it
|
||||
reported a reversal that doesn't net to zero. We didn't wave it away.
|
||||
Replaying the counterexample natively, then shrinking it to a 15-line
|
||||
crate, proved it was the tool, not the books. Now a canary harness
|
||||
watches for the fix, and covers prove no proof is vacuous.
|
||||
- **Writing the spec simplified the code.** `credit_line` and
|
||||
`closing_line` turned out to be the same function. Lean also proved
|
||||
that no draft can exist in a closed period, the invariant that lets
|
||||
the store journal a post before applying it.
|
||||
|
||||
So the bar, for every agent, every change:
|
||||
|
||||
0. **Specs come from authorities, not memory.** Every rule the engine
|
||||
enforces is in `docs/spec/rules.md` with its source: Pacioli for the
|
||||
mechanics of double entry, the IRS (vendored verbatim, public domain)
|
||||
for records, periods, 1099s, capitalization, partnerships, and payroll,
|
||||
FASB's Codification (cited, never copied) for GAAP. A rule no authority
|
||||
states is marked CONVENTION or DECISION, with who decided and when.
|
||||
Spec blocks in the code (```text GOAL/SOURCE/INVARIANTS/SCENARIOS)
|
||||
carry a `SOURCE:` line. Never write a spec from what the code happens
|
||||
to do, or from what you remember; find the document, or ask.
|
||||
|
||||
1. **Money arithmetic goes in `kernel.rs`, generic over `Amount`.** If
|
||||
you write a sum, a sign flip, a split, or a rounding plug anywhere
|
||||
else, move it into the kernel. The engine calls the kernel. It never
|
||||
gets a second copy.
|
||||
2. **Every kernel function gets a Kani harness** in `src/proofs.rs`, on
|
||||
fixed arrays (no symbolic-length `Vec` growth, no `copy_from_slice`),
|
||||
with a `kani::cover!` showing the interesting case is reached.
|
||||
3. **Every new law gets a Lean theorem** in `verify/lean`. Laws about
|
||||
the books over time are theorems about `Reachable` states. A new
|
||||
`Ledger` command is a new `Step` constructor, with its preservation
|
||||
proof. No `sorry`, ever; `Audit.lean` lists every headline theorem.
|
||||
4. **Every kernel instantiation gets a bridge property** in
|
||||
`tests/kernel_bridge.rs`, so what Kani proved on `i64` holds on
|
||||
`Decimal`.
|
||||
5. **A failing proof is a finding, not an obstacle.** Get the
|
||||
counterexample, replay it natively, and decide on evidence: bug in
|
||||
our code, bug in the spec, or bug in the tool. Never weaken an
|
||||
assertion, raise a bound, or delete a harness to make red go green
|
||||
without writing down why in `docs/verification.md`.
|
||||
6. **More, not less.** When you touch an area the proofs don't cover
|
||||
yet (A/R and A/P documents, payroll, depreciation, FX, persistence),
|
||||
leave it with more formal coverage than you found it. Proptest,
|
||||
mutation testing, fuzzing, Kani, and Lean all stack. Use all of them.
|
||||
|
||||
`just check` and `just prove` both pass before every commit that touches
|
||||
`ledger-core`.
|
||||
|
||||
## Parity tracking
|
||||
|
||||
`docs/parity.md` is the parity catalog: every semantic feature we intend to
|
||||
@@ -85,8 +155,11 @@ clone, with status. Rules:
|
||||
|
||||
## Working here
|
||||
|
||||
- Rust stable, edition 2024. `cargo test` must pass; `cargo clippy` should be
|
||||
clean; `cargo fmt` before committing.
|
||||
- `mise install` sets up every tool (pinned in `mise.toml`): Rust,
|
||||
just, Kani, Lean, cargo-mutants, cargo-fuzz. If a recipe needs a tool,
|
||||
it goes in `mise.toml`; nothing gets installed by hand.
|
||||
- Rust stable, edition 2024. `just check` must pass: fmt, clippy with zero
|
||||
warnings, every test. `just prove` must pass for core changes.
|
||||
- Conventional commits: `feat:`, `fix:`, `docs:`, `test:`, `refactor:`.
|
||||
One logical change per commit. The commit message explains the *accounting*
|
||||
reasoning, not just the code.
|
||||
@@ -102,7 +175,9 @@ clone, with status. Rules:
|
||||
|---|---|
|
||||
| `docs/requirements.md` | What the engine must do (R-1…), what is future work |
|
||||
| `docs/architecture.md` | Crate layout, command pattern, dependency rules |
|
||||
| `docs/oracle.md` | How we prove correctness: golden files, property tests, GnuCash diffing |
|
||||
| `docs/oracle.md` | How we check correctness: golden files, property tests, fuzzing, cross-checks against other implementations (and who is right) |
|
||||
| `docs/spec/` | The authorities every rule comes from (`rules.md`), with public-domain sources vendored verbatim |
|
||||
| `docs/verification.md` | The formal proofs: every accounting law, its Kani harness and Lean theorem, what they found |
|
||||
| `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 |
|
||||
|
||||
Generated
+4151
-79
File diff suppressed because it is too large.
Load diff
+3
-1
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["crates/ledger-core", "crates/ledger-store", "crates/ledger-mcp", "crates/ledger-import", "crates/ledger-cli"]
|
||||
members = ["crates/ledger-core", "crates/ledger-store", "crates/ledger-config", "crates/ledger-mcp", "crates/ledger-import", "crates/ledger-cli", "crates/ledger-gui", "crates/ledger-vault"]
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
@@ -9,3 +9,5 @@ repository = "https://git.swaits.com/swaits/wanderbooks"
|
||||
|
||||
[workspace.lints.rust]
|
||||
missing_docs = "warn"
|
||||
# `cargo kani` sets cfg(kani) for the proof harnesses.
|
||||
unexpected_cfgs = { level = "warn", check-cfg = ["cfg(kani)"] }
|
||||
@@ -15,25 +15,44 @@ through MCP. Self-hosted, no rent.
|
||||
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
|
||||
- **`ledger-mcp`** — the AI-first interface. 64 MCP tools over stdio or
|
||||
Streamable HTTP: draft → preview → post workflow, server-side receipt
|
||||
policy (>$25), anti-splitting detection, asset management, digest for
|
||||
human review, plus read-only resources for dashboards.
|
||||
- **`ledger-import`** — QuickBooks interop. Import bank/credit-card CSVs
|
||||
and chart-of-accounts CSVs; export IIF journal entries (for QuickBooks
|
||||
Desktop) and XLSX reports.
|
||||
- **`ledger-config`** — the company settings in a books dir's
|
||||
`wanderbooks.toml`.
|
||||
- **`ledger-gui`** — the viewer: a read-only, live window on the books for owners and their
|
||||
accountant (egui). Answers first (cash, runway, profit, what needs
|
||||
attention), then every number drills down to its lines, its entry, the
|
||||
rendered receipt, and the journal history. Follows the journal live:
|
||||
a change the agent makes appears in milliseconds. `--demo` opens a
|
||||
realistic demo company.
|
||||
|
||||
## Verification
|
||||
|
||||
This is the storytold way applied to accounting: agents verify against
|
||||
oracles, never ship what they haven't demonstrated.
|
||||
|
||||
- 70 workspace tests: invariant tests encoding accounting law, golden-file
|
||||
- 190+ workspace tests: invariant tests encoding accounting law, golden-file
|
||||
report snapshots, property tests (proptest), end-to-end MCP stdio tests.
|
||||
- **Formal proofs of the accounting laws** (`just prove`,
|
||||
`docs/verification.md`). Kani model-checks the engine's arithmetic
|
||||
kernel exhaustively within bounds. Lean 4 proves that, after any
|
||||
sequence of commands, the trial balance balances, the accounting
|
||||
equation holds, posted entries are forever, closed periods stay
|
||||
frozen, and a close moves exactly the net income to equity. The first
|
||||
pass found three engine bugs that every test, fuzzer, and mutant had
|
||||
missed: negative amounts accepted, and two crashes on oversized
|
||||
amounts. It also caught a soundness bug in the model checker itself.
|
||||
- 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).
|
||||
- GnuCash is *a* behavioral cross-check, never the authority and never
|
||||
a code source (clean room, MIT OR Apache-2.0). Accounting principles,
|
||||
proved, are the spec. Where any other program disagrees, we work out
|
||||
from principles who is right.
|
||||
|
||||
## Status
|
||||
|
||||
@@ -49,19 +68,40 @@ See [`plan/STATUS.md`](plan/STATUS.md) for where things stand and
|
||||
|
||||
```bash
|
||||
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
|
||||
cargo run -p ledger-gui -- --demo # the viewer, on a demo company
|
||||
```
|
||||
New books, one command (installs Rust and wanderbooks if needed, makes the
|
||||
books dir with the MCP server registered for it, and starts the agent):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://wanderbooks.ai/install.sh | sh -s -- ~/Books/Acme
|
||||
```
|
||||
|
||||
Already installed: `wanderbooks new ~/Books/Acme` (add `--codex` to keep the
|
||||
books with Codex instead of Claude Code). In a books dir, `just`
|
||||
lists the shortcuts (`just gui`, `just agent`, `just backup`).
|
||||
|
||||
## Developing
|
||||
|
||||
Every tool the recipes use (Rust, just, Kani, Lean, cargo-mutants,
|
||||
cargo-fuzz, Node for deploys) is pinned in `mise.toml`:
|
||||
|
||||
```sh
|
||||
mise install
|
||||
just # lists the recipes: check, build, prove, demo, ...
|
||||
```
|
||||
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/ # 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-mcp/ # MCP server: 108 tools, resources, handbook, receipt policy
|
||||
crates/ledger-import/ # QBO CSV import, IIF/XLSX export
|
||||
crates/ledger-config/ # wanderbooks.toml company settings
|
||||
crates/ledger-cli/ # `wanderbooks new`: one-command books dir + agent
|
||||
crates/ledger-gui/ # the viewer: read-only, live (egui)
|
||||
docs/ # requirements, architecture, oracle, parity, glossary
|
||||
plan/ # STATUS.md, roadmap.md
|
||||
AGENTS.md # operating manual for agents working here
|
||||
|
||||
@@ -4,13 +4,28 @@ version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "wanderbooks CLI: scaffold new books directories (mise.toml + AGENT.md)."
|
||||
description = "wanderbooks CLI: one command from nothing to a working books directory."
|
||||
|
||||
[[bin]]
|
||||
name = "wanderbooks"
|
||||
path = "src/main.rs"
|
||||
|
||||
[features]
|
||||
default = ["gui"]
|
||||
# The viewer. Off in the server-only build for another machine (plan
|
||||
# D-22, P9), so a headless machine carries no window libraries.
|
||||
gui = ["dep:ledger-gui"]
|
||||
|
||||
[dependencies]
|
||||
ledger-mcp = { path = "../ledger-mcp" }
|
||||
ledger-store = { path = "../ledger-store" }
|
||||
ledger-gui = { path = "../ledger-gui", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
chrono = "0.4"
|
||||
ledger-config = { path = "../ledger-config" }
|
||||
ledger-core = { path = "../ledger-core" }
|
||||
serde_json = "1"
|
||||
toml = "1"
|
||||
tempfile = "3"
|
||||
|
||||
|
||||
+856
-144
File diff suppressed because it is too large.
Load diff
+390
-54
@@ -1,109 +1,211 @@
|
||||
//! Integration tests for `wanderbooks init`: the scaffold lands complete,
|
||||
//! the TOML parses, re-init is refused without `--force`.
|
||||
//! Integration tests for `wanderbooks new`: one command lands a complete
|
||||
//! books dir with the MCP server registered for it, the TOML and JSON
|
||||
//! parse, and re-running is refused without `--force` (which never
|
||||
//! touches the company profile).
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{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);
|
||||
/// Stand-ins for the installed binaries, so tests never run cargo install.
|
||||
fn fake_bins(tmp: &Path) -> PathBuf {
|
||||
let dir = tmp.join("bin");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
for b in ["ledger-mcp", "ledger-gui"] {
|
||||
fs::write(dir.join(b), "#!/bin/sh\n").unwrap();
|
||||
}
|
||||
let out = cmd.output().expect("run wanderbooks");
|
||||
dir
|
||||
}
|
||||
|
||||
fn run(tmp: &Path, args: &[&str]) -> (bool, String) {
|
||||
let path = format!(
|
||||
"{}:{}",
|
||||
fake_bins(tmp).display(),
|
||||
std::env::var("PATH").unwrap_or_default()
|
||||
);
|
||||
let out = Command::new(bin())
|
||||
.args(args)
|
||||
.arg("--no-start")
|
||||
.env("PATH", path)
|
||||
.output()
|
||||
.expect("run wanderbooks");
|
||||
let mut s = String::from_utf8_lossy(&out.stdout).into_owned();
|
||||
s.push_str(&String::from_utf8_lossy(&out.stderr));
|
||||
(out.status.success(), s)
|
||||
}
|
||||
|
||||
fn new(tmp: &Path, books: &Path, extra: &[&str]) -> (bool, String) {
|
||||
let mut args = vec!["new", books.to_str().unwrap()];
|
||||
args.extend(extra);
|
||||
run(tmp, &args)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaffold_lands_complete() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let books = tmp.path().join("acme-books");
|
||||
let (ok, _) = run_init(&books, &[]);
|
||||
assert!(ok, "init should succeed");
|
||||
for f in ["mise.toml", "AGENT.md", "wanderbooks.toml", ".gitignore"] {
|
||||
let (ok, out) = new(tmp.path(), &books, &[]);
|
||||
assert!(ok, "new should succeed: {out}");
|
||||
for f in [
|
||||
"justfile",
|
||||
"AGENTS.md",
|
||||
"CLAUDE.md",
|
||||
"wanderbooks.toml",
|
||||
".gitignore",
|
||||
".mcp.json",
|
||||
".claude/settings.json",
|
||||
".codex/hooks.json",
|
||||
] {
|
||||
let p = books.join(f);
|
||||
assert!(p.exists(), "{f} exists");
|
||||
assert!(!fs::read_to_string(&p).unwrap().is_empty(), "{f} non-empty");
|
||||
}
|
||||
assert!(books.join("books-data").is_dir(), "books-data dir exists");
|
||||
assert!(books.join(".git").is_dir(), "fresh dir gets git init");
|
||||
let log = Command::new("git")
|
||||
.args(["log", "--oneline"])
|
||||
.current_dir(&books)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
books.join(".git").is_dir(),
|
||||
"fresh dir gets git init for the journal archive"
|
||||
String::from_utf8_lossy(&log.stdout).contains("init"),
|
||||
"scaffold committed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mise_toml_parses_and_names_repo_and_books() {
|
||||
fn the_server_is_registered_for_this_dir() {
|
||||
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");
|
||||
assert!(new(tmp.path(), &books, &[]).0);
|
||||
let books = books.canonicalize().unwrap();
|
||||
let mcp: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(books.join(".mcp.json")).unwrap()).unwrap();
|
||||
let server = &mcp["mcpServers"]["wanderbooks"];
|
||||
assert!(server["command"].as_str().unwrap().ends_with("ledger-mcp"));
|
||||
assert_eq!(
|
||||
server["args"],
|
||||
serde_json::json!(["--data-dir", books.join("books-data").to_string_lossy()])
|
||||
);
|
||||
let settings: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(books.join(".claude/settings.json")).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
settings["enabledMcpjsonServers"],
|
||||
serde_json::json!(["wanderbooks"])
|
||||
);
|
||||
// Every session starts by checking the viewer against open_at_start.
|
||||
let hook = settings["hooks"]["SessionStart"][0]["hooks"][0]["command"]
|
||||
.as_str()
|
||||
.expect("SessionStart hook command");
|
||||
assert!(hook.ends_with("just session-start"), "{hook}");
|
||||
let codex: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(books.join(".codex/hooks.json")).unwrap())
|
||||
.unwrap();
|
||||
let hook = codex["hooks"]["SessionStart"][0]["hooks"][0]["command"]
|
||||
.as_str()
|
||||
.expect("Codex SessionStart hook command");
|
||||
assert!(hook.ends_with("just session-start"), "{hook}");
|
||||
// The justfile opens this dir's books in the GUI.
|
||||
let just = fs::read_to_string(books.join("justfile")).unwrap();
|
||||
assert!(just.contains("ledger-gui\" --data-dir \"{{justfile_directory()}}/books-data\""));
|
||||
for recipe in [
|
||||
"agent:",
|
||||
"gui:",
|
||||
"gui-fg:",
|
||||
"gui-status:",
|
||||
"session-start:",
|
||||
"backup:",
|
||||
"update:",
|
||||
] {
|
||||
assert!(just.contains(recipe), "justfile has {recipe}");
|
||||
}
|
||||
// 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"
|
||||
assert_eq!(
|
||||
fs::read_to_string(books.join("CLAUDE.md")).unwrap().trim(),
|
||||
"@AGENTS.md"
|
||||
);
|
||||
let toml: toml::Value =
|
||||
toml::from_str(&fs::read_to_string(books.join("wanderbooks.toml")).unwrap()).unwrap();
|
||||
assert_eq!(toml["books"]["data_dir"].as_str(), Some("books-data"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_md_carries_the_contract() {
|
||||
fn agents_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();
|
||||
assert!(new(tmp.path(), &books, &[]).0);
|
||||
let md = fs::read_to_string(books.join("AGENTS.md")).unwrap();
|
||||
for phrase in [
|
||||
"preview",
|
||||
"Posted is forever",
|
||||
"reversing entries",
|
||||
"receipt",
|
||||
"journal.jsonl",
|
||||
"onboard",
|
||||
"chart_template",
|
||||
"to the cent",
|
||||
"just gui",
|
||||
"just gui-status",
|
||||
"open_at_start",
|
||||
] {
|
||||
assert!(md.contains(phrase), "AGENT.md mentions {phrase:?}");
|
||||
assert!(md.contains(phrase), "AGENTS.md mentions {phrase:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The agent reaches the books only through the MCP tools. The scaffolded
|
||||
/// AGENTS.md once said "the journal is the evidence — read it", and an
|
||||
/// agent `cat`ed the journal file instead of asking the books.
|
||||
#[test]
|
||||
fn agents_md_reaches_the_books_only_through_tools() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let books = tmp.path().join("b");
|
||||
assert!(new(tmp.path(), &books, &[]).0);
|
||||
let md = fs::read_to_string(books.join("AGENTS.md")).unwrap();
|
||||
for phrase in [
|
||||
"only through the",
|
||||
"wanderbooks MCP tools",
|
||||
"Never open, list or parse",
|
||||
"books.get_entry",
|
||||
"books.search_entries",
|
||||
"general_ledger",
|
||||
"can't answer, say so.",
|
||||
] {
|
||||
assert!(md.contains(phrase), "AGENTS.md says {phrase:?}");
|
||||
}
|
||||
// Nothing points the agent at the books' files.
|
||||
for path in ["books-data", "journal.jsonl", "wanderbooks.db", "read it"] {
|
||||
assert!(!md.contains(path), "AGENTS.md never mentions {path:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reinit_refused_without_force() {
|
||||
fn rerun_refused_without_force_and_never_clobbers_the_profile() {
|
||||
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!(new(tmp.path(), &books, &[]).0);
|
||||
let (ok, out) = new(tmp.path(), &books, &[]);
|
||||
assert!(!ok, "second new without --force fails");
|
||||
assert!(out.contains("--force"), "error points at --force: {out}");
|
||||
let (ok, _) = run_init(&books, &["--force"]);
|
||||
assert!(ok, "second init with --force succeeds");
|
||||
fs::write(
|
||||
books.join("wanderbooks.toml"),
|
||||
"[company]\nname = \"Acme LLC\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let (ok, _) = new(tmp.path(), &books, &["--force"]);
|
||||
assert!(ok, "second new with --force succeeds");
|
||||
assert!(
|
||||
fs::read_to_string(books.join("wanderbooks.toml"))
|
||||
.unwrap()
|
||||
.contains("Acme LLC"),
|
||||
"--force rewrites the scaffold, never the company profile"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_inside_existing_repo_skips_git_init() {
|
||||
fn init_is_an_alias_and_respects_an_existing_repo() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let books = tmp.path().join("b");
|
||||
fs::create_dir_all(&books).unwrap();
|
||||
@@ -115,8 +217,242 @@ fn init_inside_existing_repo_skips_git_init() {
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
let (ok, _) = run_init(&books, &[]);
|
||||
assert!(ok);
|
||||
// Still a valid repo (we didn't nest another one) and scaffold landed.
|
||||
let (ok, out) = run(tmp.path(), &["init", books.to_str().unwrap()]);
|
||||
assert!(ok, "{out}");
|
||||
assert!(books.join("wanderbooks.toml").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_gets_its_own_named_server() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let bin = fake_bins(tmp.path());
|
||||
let log = tmp.path().join("codex-args");
|
||||
// A stand-in codex that records how it was called.
|
||||
fs::write(
|
||||
bin.join("codex"),
|
||||
format!("#!/bin/sh\necho \"$@\" > {}\n", log.display()),
|
||||
)
|
||||
.unwrap();
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(bin.join("codex"), fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let books = tmp.path().join("Acme Books");
|
||||
let (ok, out) = new(tmp.path(), &books, &["--codex"]);
|
||||
assert!(ok, "{out}");
|
||||
let args = fs::read_to_string(&log).unwrap();
|
||||
let books = books.canonicalize().unwrap();
|
||||
assert!(
|
||||
args.starts_with("mcp add wanderbooks-acme-books -- "),
|
||||
"{args}"
|
||||
);
|
||||
assert!(
|
||||
args.trim_end().ends_with(&format!(
|
||||
"--data-dir {}",
|
||||
books.join("books-data").display()
|
||||
)),
|
||||
"{args}"
|
||||
);
|
||||
let just = fs::read_to_string(books.join("justfile")).unwrap();
|
||||
assert!(just.contains("codex mcp add \"wanderbooks-acme-books\""));
|
||||
assert!(out.contains("just codex"), "{out}");
|
||||
}
|
||||
|
||||
/// The `wanderbooks.toml` a fresh `new` writes.
|
||||
fn scaffolded_toml(tmp: &Path) -> (PathBuf, String) {
|
||||
let books = tmp.join("b");
|
||||
assert!(new(tmp, &books, &[]).0);
|
||||
let path = books.join("wanderbooks.toml");
|
||||
let text = fs::read_to_string(&path).unwrap();
|
||||
(path, text)
|
||||
}
|
||||
|
||||
/// The example a commented line holds, if it is one: `# key = value`, or
|
||||
/// `# [section]` / `# [[section]]`. Prose comments are not examples.
|
||||
fn example(line: &str) -> Option<&str> {
|
||||
let rest = line.strip_prefix("# ")?;
|
||||
let is_key = |k: &str| !k.is_empty() && k.bytes().all(|b| b.is_ascii_lowercase() || b == b'_');
|
||||
let header = rest
|
||||
.trim_start_matches('[')
|
||||
.trim_end_matches(']')
|
||||
.split('.')
|
||||
.all(is_key);
|
||||
let assignment = rest
|
||||
.split_once(" = ")
|
||||
.is_some_and(|(k, v)| is_key(k) && !v.trim().is_empty());
|
||||
((rest.starts_with('[') && rest.ends_with(']') && header) || assignment).then_some(rest)
|
||||
}
|
||||
|
||||
/// The template with the examples `pick` chooses uncommented.
|
||||
fn uncomment(text: &str, pick: impl Fn(&str) -> bool) -> String {
|
||||
text.lines()
|
||||
.map(|l| match example(l) {
|
||||
Some(ex) if pick(ex) => ex,
|
||||
_ => l,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scaffolded_toml_loads_with_the_defaults() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (path, text) = scaffolded_toml(tmp.path());
|
||||
let s = ledger_config::load(&path).expect("scaffolded wanderbooks.toml loads");
|
||||
let c = &s.company;
|
||||
assert_eq!(c.name, "Unnamed company", "name is left for onboarding");
|
||||
assert_eq!(c.fiscal_year_start, (1, 1));
|
||||
assert_eq!(c.basis, "Accrual");
|
||||
assert!(c.members.is_empty());
|
||||
assert_eq!(c.tax_form, None);
|
||||
assert_eq!(c.close_into, None);
|
||||
assert!(c.close_sweep.is_empty());
|
||||
assert_eq!(
|
||||
c.profile,
|
||||
[
|
||||
("base_currency".to_string(), "USD".to_string()),
|
||||
("fiscal_year_start".to_string(), "01-01".to_string()),
|
||||
]
|
||||
);
|
||||
assert_eq!(s.receipt_threshold.as_deref(), Some("25.00"));
|
||||
assert!(s.cash_accounts.is_empty());
|
||||
assert!(s.financing_accounts.is_empty());
|
||||
assert!(s.investing_accounts.is_empty());
|
||||
let toml: toml::Value = toml::from_str(&text).unwrap();
|
||||
assert_eq!(toml["books"]["data_dir"].as_str(), Some("books-data"));
|
||||
// The justfile's session-start reads this line with sed, from column 0.
|
||||
assert!(
|
||||
text.lines().any(|l| l == "open_at_start = \"ask\""),
|
||||
"{text}"
|
||||
);
|
||||
assert!(text.contains("SSN"), "the template warns off storing SSNs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_example_in_the_toml_is_valid() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (_, text) = scaffolded_toml(tmp.path());
|
||||
let examples = text.lines().filter_map(example).count();
|
||||
assert!(examples >= 20, "only {examples} examples found");
|
||||
// close_into is for a single owner and refused with members, so it
|
||||
// gets a pass of its own; together the two passes cover every example.
|
||||
let single_owner = |ex: &str| ex.starts_with("close_into ");
|
||||
let all = ledger_config::parse(&uncomment(&text, |ex| !single_owner(ex)))
|
||||
.unwrap_or_else(|e| panic!("examples (without close_into) parse: {e}"));
|
||||
let c = &all.company;
|
||||
assert_eq!(c.name, "Acme Widgets LLC");
|
||||
assert_eq!(c.tax_form, Some(ledger_core::TaxForm::Form1065));
|
||||
assert_eq!(c.members.len(), 2);
|
||||
assert_eq!(c.members[1].distribution_account.as_deref(), Some("3101"));
|
||||
assert_eq!(c.close_sweep, ["3100"]);
|
||||
assert!(c.profile.iter().any(|(k, _)| k == "ein"));
|
||||
assert_eq!(all.cash_accounts, ["1000", "1010"]);
|
||||
assert_eq!(all.financing_accounts, ["2100"]);
|
||||
assert_eq!(all.investing_accounts, ["1400"]);
|
||||
let single = ledger_config::parse(&uncomment(&text, single_owner))
|
||||
.unwrap_or_else(|e| panic!("close_into example parses: {e}"));
|
||||
assert_eq!(single.company.close_into.as_deref(), Some("3000"));
|
||||
assert!(
|
||||
ledger_config::parse(&uncomment(&text, |_| true)).is_err(),
|
||||
"close_into with members is refused, as the template says"
|
||||
);
|
||||
}
|
||||
|
||||
/// The serde field names of `struct name` in ledger-config's source.
|
||||
fn fields(src: &str, name: &str) -> Vec<String> {
|
||||
let start = src
|
||||
.find(&format!("struct {name} {{"))
|
||||
.unwrap_or_else(|| panic!("ledger-config has struct {name}"));
|
||||
let body = &src[start..];
|
||||
let body = &body[..body.find("\n}").expect("struct ends")];
|
||||
let found: Vec<String> = body
|
||||
.lines()
|
||||
.skip(1)
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.starts_with("//") && !l.starts_with("#["))
|
||||
.filter_map(|l| l.split_once(':'))
|
||||
.map(|(k, _)| k.trim_start_matches("pub ").to_string())
|
||||
.collect();
|
||||
assert!(!found.is_empty(), "struct {name} has fields");
|
||||
found
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_toml_shows_every_setting_the_config_reads() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (_, text) = scaffolded_toml(tmp.path());
|
||||
// (section, key) for every assignment, examples included.
|
||||
let mut section = String::new();
|
||||
let mut present = Vec::new();
|
||||
for line in uncomment(&text, |_| true).lines() {
|
||||
if line.starts_with('[') {
|
||||
section = line.trim_matches(['[', ']']).to_string();
|
||||
} else if let Some((k, _)) = line.split_once(" = ") {
|
||||
present.push((section.clone(), k.to_string()));
|
||||
}
|
||||
}
|
||||
let has = |s: &str, k: &str| present.iter().any(|(ps, pk)| ps == s && pk == k);
|
||||
// Read from the config crate's serde structs, so a new setting there
|
||||
// fails here until the template shows it.
|
||||
let src = include_str!("../../ledger-config/src/lib.rs");
|
||||
assert_eq!(fields(src, "BooksToml"), ["company", "books", "vault"]);
|
||||
for (strukt, sect) in [
|
||||
("CompanyToml", "company"),
|
||||
("MemberToml", "company.members"),
|
||||
("BooksSection", "books"),
|
||||
("VaultSection", "vault"),
|
||||
] {
|
||||
for key in fields(src, strukt) {
|
||||
if strukt == "CompanyToml" && key == "members" {
|
||||
assert!(text.contains("# [[company.members]]"));
|
||||
continue;
|
||||
}
|
||||
assert!(has(sect, &key), "[{sect}] {key} is in the template");
|
||||
}
|
||||
}
|
||||
// Read elsewhere: the justfile's session-start (main.rs), and the two
|
||||
// the scaffold has always written live.
|
||||
for (sect, key) in [
|
||||
("viewer", "open_at_start"),
|
||||
("company", "base_currency"),
|
||||
("books", "data_dir"),
|
||||
] {
|
||||
assert!(has(sect, key), "[{sect}] {key} is in the template");
|
||||
}
|
||||
}
|
||||
|
||||
/// Moving the scaffolded settings file, and every example in it, into
|
||||
/// books: each imports, reads as the file does (the import refuses
|
||||
/// otherwise), and exporting it back and importing the export changes
|
||||
/// nothing (plan/served.md P1: toml_round_trip_zero_events).
|
||||
#[test]
|
||||
fn the_toml_and_its_examples_import_and_round_trip() {
|
||||
use ledger_core::settings::{Settings, Source};
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (_, text) = scaffolded_toml(tmp.path());
|
||||
let single_owner = |ex: &str| ex.starts_with("close_into ");
|
||||
let source = Source {
|
||||
decided_by: "onboarding".into(),
|
||||
decided_on: chrono_date(),
|
||||
citation: None,
|
||||
note: None,
|
||||
};
|
||||
for file in [
|
||||
text.clone(),
|
||||
uncomment(&text, |ex| !single_owner(ex)),
|
||||
uncomment(&text, single_owner),
|
||||
] {
|
||||
let import = ledger_config::codec::import(&file, None, &Settings::default(), "USD")
|
||||
.unwrap_or_else(|e| panic!("imports: {e}\n{file}"));
|
||||
assert!(import.unknown.is_empty(), "{:?}", import.unknown);
|
||||
let mut books = Settings::default();
|
||||
let changes = books.check_changes(&import.changes, &source).unwrap();
|
||||
books.apply(&changes, &source, 1).unwrap();
|
||||
let export = ledger_config::codec::export(&books, "USD");
|
||||
let again = ledger_config::codec::import(&export, None, &books, "USD")
|
||||
.unwrap_or_else(|e| panic!("the export imports: {e}\n{export}"));
|
||||
assert!(again.changes.is_empty(), "{:?}\n{export}", again.changes);
|
||||
}
|
||||
}
|
||||
|
||||
fn chrono_date() -> chrono::NaiveDate {
|
||||
chrono::NaiveDate::from_ymd_opt(2026, 10, 17).unwrap()
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
//! One executable (plan D-22): `wanderbooks` runs the books server
|
||||
//! (`dev-stdio`) and the viewer (`gui`) as modes of itself, each with its
|
||||
//! own flags, and the server-only build has no viewer.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
fn wanderbooks() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
|
||||
}
|
||||
|
||||
/// One JSON-RPC request over the child's stdio, its response parsed.
|
||||
fn ask(
|
||||
stdin: &mut std::process::ChildStdin,
|
||||
stdout: &mut BufReader<std::process::ChildStdout>,
|
||||
id: u64,
|
||||
method: &str,
|
||||
params: Value,
|
||||
) -> Value {
|
||||
let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params});
|
||||
writeln!(stdin, "{msg}").unwrap();
|
||||
stdin.flush().unwrap();
|
||||
let mut line = String::new();
|
||||
stdout.read_line(&mut line).expect("read a response");
|
||||
let resp: Value = serde_json::from_str(&line).expect("parse the response");
|
||||
assert_eq!(resp["id"], id, "{resp}");
|
||||
resp
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dev_stdio_is_the_books_server() {
|
||||
let dir = tempfile::Builder::new()
|
||||
.prefix("wb-one-exe-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let data = dir.path().join("books").join("books-data");
|
||||
// An import root may not be inside the books dir (the data dir's
|
||||
// parent), so it is a sibling of it.
|
||||
let inbox = dir.path().join("inbox");
|
||||
std::fs::create_dir(&inbox).unwrap();
|
||||
let mut child = wanderbooks()
|
||||
.arg("dev-stdio")
|
||||
.arg("--data-dir")
|
||||
.arg(&data)
|
||||
.arg("--import-root")
|
||||
.arg(&inbox)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("spawn wanderbooks dev-stdio");
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let mut stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
let init = ask(
|
||||
&mut stdin,
|
||||
&mut stdout,
|
||||
1,
|
||||
"initialize",
|
||||
json!({"protocolVersion": "2025-06-18", "capabilities": {},
|
||||
"clientInfo": {"name": "one-executable", "version": "0"}}),
|
||||
);
|
||||
assert!(init["result"]["serverInfo"].is_object(), "{init}");
|
||||
writeln!(
|
||||
stdin,
|
||||
"{}",
|
||||
json!({"jsonrpc": "2.0", "method": "notifications/initialized"})
|
||||
)
|
||||
.unwrap();
|
||||
let tools = ask(&mut stdin, &mut stdout, 2, "tools/list", json!({}));
|
||||
let names: Vec<&str> = tools["result"]["tools"]
|
||||
.as_array()
|
||||
.expect("a tool list")
|
||||
.iter()
|
||||
.filter_map(|t| t["name"].as_str())
|
||||
.collect();
|
||||
assert!(names.contains(&"books.start"), "{names:?}");
|
||||
// The books it serves are real books: a fresh store was created.
|
||||
let tb = ask(
|
||||
&mut stdin,
|
||||
&mut stdout,
|
||||
3,
|
||||
"tools/call",
|
||||
json!({"name": "books.trial_balance", "arguments": {}}),
|
||||
);
|
||||
assert!(tb.get("error").is_none(), "{tb}");
|
||||
assert!(data.join("journal.jsonl").is_file());
|
||||
drop(stdin);
|
||||
let status = child.wait().unwrap();
|
||||
assert!(status.success(), "{status}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_mode_names_itself_in_its_usage() {
|
||||
let out = wanderbooks()
|
||||
.args(["dev-stdio", "--help"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success());
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
err.contains("usage: wanderbooks dev-stdio [--data-dir PATH]"),
|
||||
"{err}"
|
||||
);
|
||||
|
||||
let out = wanderbooks().arg("--help").output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
for line in [
|
||||
"wanderbooks gui [DIR",
|
||||
"wanderbooks dev-stdio",
|
||||
"dev-stdio run the books server",
|
||||
] {
|
||||
assert!(err.contains(line), "{line:?} missing from {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "gui")]
|
||||
#[test]
|
||||
fn the_viewer_names_itself_in_its_usage() {
|
||||
let out = wanderbooks().args(["gui", "--help"]).output().unwrap();
|
||||
assert!(out.status.success());
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(
|
||||
text.contains("usage: wanderbooks gui [--data-dir PATH]"),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mode_refuses_what_it_does_not_know() {
|
||||
let modes: &[&str] = if cfg!(feature = "gui") {
|
||||
&["dev-stdio", "gui"]
|
||||
} else {
|
||||
&["dev-stdio"]
|
||||
};
|
||||
for &mode in modes {
|
||||
let out = wanderbooks()
|
||||
.args([mode, "--no-such-flag"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(out.status.code(), Some(2), "{mode}: {out:?}");
|
||||
}
|
||||
// A mode's flags are its own: the CLI's `--force` means nothing there.
|
||||
let out = wanderbooks()
|
||||
.args(["dev-stdio", "--force"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(out.status.code(), Some(2), "{out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mode_comes_first() {
|
||||
for mode in ["gui", "dev-stdio"] {
|
||||
let out = wanderbooks().args(["--codex", mode]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(2), "{mode}: {out:?}");
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(err.contains(&format!("`{mode}` must come first")), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_that_cannot_start_says_why_and_exits_1() {
|
||||
// A flag missing its value is an error, not a usage slip (exit 2).
|
||||
let out = wanderbooks()
|
||||
.args(["dev-stdio", "--data-dir"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(out.status.code(), Some(1), "{out:?}");
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(err.contains("error: --data-dir needs a path"), "{err}");
|
||||
}
|
||||
|
||||
/// With no display to open a window on, the viewer fails and says so
|
||||
/// (Linux only: elsewhere there is always a display, and a window would
|
||||
/// open).
|
||||
#[cfg(all(feature = "gui", target_os = "linux"))]
|
||||
#[test]
|
||||
fn a_viewer_that_cannot_open_a_window_exits_1() {
|
||||
let dir = tempfile::Builder::new()
|
||||
.prefix("wb-one-exe-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let mut child = wanderbooks()
|
||||
.args(["gui", "--data-dir"])
|
||||
.arg(dir.path().join("books-data"))
|
||||
.env_remove("DISPLAY")
|
||||
.env_remove("WAYLAND_DISPLAY")
|
||||
.env_remove("WAYLAND_SOCKET")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
|
||||
let status = loop {
|
||||
if let Some(status) = child.try_wait().unwrap() {
|
||||
break status;
|
||||
}
|
||||
if std::time::Instant::now() > deadline {
|
||||
let _ = child.kill();
|
||||
panic!("the viewer neither opened nor failed within a minute");
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
};
|
||||
assert_eq!(status.code(), Some(1), "{status}");
|
||||
let mut err = String::new();
|
||||
std::io::Read::read_to_string(&mut child.stderr.take().unwrap(), &mut err).unwrap();
|
||||
assert!(err.contains("error: "), "{err}");
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "gui"))]
|
||||
#[test]
|
||||
fn the_server_only_build_has_no_viewer() {
|
||||
let out = wanderbooks().args(["gui", "--help"]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(2), "{out:?}");
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
err.contains("the server-only build: it has no viewer"),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//! `wanderbooks setup`: the books' private folder, made from the command
|
||||
//! line, with its location never printed.
|
||||
#![cfg(unix)]
|
||||
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::process::Command;
|
||||
|
||||
fn wanderbooks() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
|
||||
}
|
||||
|
||||
fn mode(path: &std::path::Path) -> u32 {
|
||||
std::fs::metadata(path).unwrap().permissions().mode() & 0o777
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_makes_the_private_folder_and_never_names_it() {
|
||||
let home = tempfile::Builder::new()
|
||||
.prefix("wb-setup-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let root = home.path().join(".local/share/wanderbooks");
|
||||
let out = wanderbooks()
|
||||
.args(["setup", "--home"])
|
||||
.arg(home.path())
|
||||
.env_remove("XDG_DATA_HOME")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(text.contains("The books' private folder: made"), "{text}");
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stderr).contains("--home isn't this account's home"),
|
||||
"{out:?}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains(home.path().to_str().unwrap()),
|
||||
"names the folder: {text}"
|
||||
);
|
||||
assert!(!String::from_utf8_lossy(&out.stderr).contains(home.path().to_str().unwrap()));
|
||||
assert_eq!(mode(&root), 0o700);
|
||||
for sub in ["books", "run", "legacy"] {
|
||||
assert_eq!(mode(&root.join(sub)), 0o700, "{sub}");
|
||||
}
|
||||
// Again: already there.
|
||||
let out = wanderbooks()
|
||||
.args(["setup", "--home"])
|
||||
.arg(home.path())
|
||||
.env_remove("XDG_DATA_HOME")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stdout).contains("already there."),
|
||||
"{out:?}"
|
||||
);
|
||||
// Others could open it: made private again.
|
||||
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let out = wanderbooks()
|
||||
.args(["setup", "--home"])
|
||||
.arg(home.path())
|
||||
.env_remove("XDG_DATA_HOME")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stdout).contains("made private again"),
|
||||
"{out:?}"
|
||||
);
|
||||
assert_eq!(mode(&root), 0o700);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_root_elsewhere_is_noted_and_xdg_is_honoured() {
|
||||
let home = tempfile::Builder::new()
|
||||
.prefix("wb-setup-xdg-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let xdg = home.path().join("xdg");
|
||||
let out = wanderbooks()
|
||||
.args(["setup", "--home"])
|
||||
.arg(home.path())
|
||||
.env("XDG_DATA_HOME", &xdg)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stdout).contains("noted for next time"),
|
||||
"{out:?}"
|
||||
);
|
||||
assert_eq!(mode(&xdg.join("wanderbooks")), 0o700);
|
||||
let noted =
|
||||
std::fs::read_to_string(home.path().join(".local/share/wanderbooks.location")).unwrap();
|
||||
assert_eq!(
|
||||
noted.trim_end(),
|
||||
xdg.join("wanderbooks").display().to_string()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_refuses_what_it_does_not_know_and_unsafe_folders() {
|
||||
let out = wanderbooks().args(["setup", "--bogus"]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(2));
|
||||
let out = wanderbooks().args(["setup", "--root"]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("--root needs a path"));
|
||||
let home = tempfile::Builder::new()
|
||||
.prefix("wb-setup-link-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let real = home.path().join("real");
|
||||
std::fs::create_dir(&real).unwrap();
|
||||
std::os::unix::fs::symlink(&real, home.path().join("link")).unwrap();
|
||||
let out = wanderbooks()
|
||||
.args(["setup", "--home"])
|
||||
.arg(home.path())
|
||||
.arg("--root")
|
||||
.arg(home.path().join("link"))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(out.status.code(), Some(1), "{out:?}");
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("is a symlink"));
|
||||
// A mode comes first.
|
||||
let out = wanderbooks().args(["--force", "setup"]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(2));
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "ledger-config"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Company settings for wanderbooks: the books dir's wanderbooks.toml."
|
||||
|
||||
[dependencies]
|
||||
ledger-core = { path = "../ledger-core" }
|
||||
chrono = "0.4"
|
||||
rust_decimal = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = { version = "1", features = ["preserve_order"] }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "1"
|
||||
tempfile = "3"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,766 @@
|
||||
//! Settings files as text in and out of the books (plan/served.md 5.4).
|
||||
//!
|
||||
//! Books keep their settings in the journal (`ledger_core::settings`).
|
||||
//! A `wanderbooks.toml` is text to import from and export to: the server
|
||||
//! never reads one once the books hold their settings.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: move a company's settings file into its books without changing
|
||||
//! how a single setting reads, and write them back out as a file.
|
||||
//! SOURCE: plan/served.md I-3, 5.4 (toml_round_trip_zero_events); the
|
||||
//! settings file's own rules (crate::parse).
|
||||
//! INVARIANTS:
|
||||
//! 1. An import is exactly the changes that make the books' settings
|
||||
//! what the file (and config.json's receipt policy, if given) says:
|
||||
//! keys the file sets and the books don't, or set differently, are
|
||||
//! set; keys the books set and the file doesn't are cleared. Nothing
|
||||
//! else.
|
||||
//! 2. NEVER an import that reads differently than the file: the
|
||||
//! settings after it give the same company, receipt policy, cash
|
||||
//! flow roles, asset tag prefix, party policy and time zone that
|
||||
//! the file gives the server today (crate::parse,
|
||||
//! crate::effective_receipt_policy_text), or the import is refused.
|
||||
//! 3. Nothing in the file is dropped silently: what isn't a books
|
||||
//! setting is a note (where it goes instead) or unknown (not
|
||||
//! imported; the caller shows it before anything is journaled).
|
||||
//! 4. Round trip: importing an export changes nothing (identity values
|
||||
//! are exported masked, and a masked value imports as "unchanged").
|
||||
//! ```
|
||||
|
||||
use crate::{BooksSettings, ReceiptPolicy};
|
||||
use ledger_core::settings::{self, Change, KEYS, Kind, Sensitivity, Settings, Source};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt::Write as _;
|
||||
|
||||
/// What importing a settings file would do.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Import {
|
||||
/// The changes that make the books' settings what the file says
|
||||
/// (INVARIANT 1); empty when they already are.
|
||||
pub changes: Vec<Change>,
|
||||
/// What the file says that isn't a books setting, and where it goes
|
||||
/// instead (INVARIANT 3).
|
||||
pub notes: Vec<String>,
|
||||
/// Entries the books don't know: not imported (INVARIANT 3).
|
||||
pub unknown: Vec<String>,
|
||||
/// `[vault] key_file`, if the file sets one: the server's to use
|
||||
/// (`--vault-key-file`), not a books setting.
|
||||
pub vault_key_file: Option<String>,
|
||||
/// The file's comments, in order: a settings file's notes (where a
|
||||
/// value came from, what it waits on) are not settings, so they are
|
||||
/// listed for the caller to keep elsewhere (a convention, a decision, a
|
||||
/// setting's note) rather than dropped unseen (INVARIANT 3).
|
||||
pub comments: Vec<String>,
|
||||
}
|
||||
|
||||
/// The comments of TOML text: what follows each `#` outside a string, in
|
||||
/// order, trimmed, blank ones left out.
|
||||
pub fn comments(text: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for line in text.lines() {
|
||||
let mut quote: Option<char> = None;
|
||||
let mut escaped = false;
|
||||
for (i, c) in line.char_indices() {
|
||||
match quote {
|
||||
Some(q) => {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if c == '\\' && q == '"' {
|
||||
escaped = true;
|
||||
} else if c == q {
|
||||
quote = None;
|
||||
}
|
||||
}
|
||||
None if c == '"' || c == '\'' => quote = Some(c),
|
||||
None if c == '#' => {
|
||||
let rest = line[i + 1..].trim();
|
||||
if !rest.is_empty() {
|
||||
out.push(rest.to_string());
|
||||
}
|
||||
break;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A TOML value as the JSON a setting takes: strings stay strings; a
|
||||
/// number, boolean or date in a text setting reads as its TOML spelling;
|
||||
/// arrays and tables convert element by element.
|
||||
fn json_of(v: &toml::Value) -> Value {
|
||||
match v {
|
||||
toml::Value::String(s) => json!(s),
|
||||
toml::Value::Integer(i) => json!(i.to_string()),
|
||||
toml::Value::Float(f) => json!(f.to_string()),
|
||||
toml::Value::Boolean(b) => json!(b.to_string()),
|
||||
toml::Value::Datetime(d) => json!(d.to_string()),
|
||||
toml::Value::Array(a) => Value::Array(a.iter().map(json_of).collect()),
|
||||
toml::Value::Table(t) => {
|
||||
Value::Object(t.iter().map(|(k, v)| (k.clone(), json_of(v))).collect())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn key_at(table: &str, name: &str) -> Option<&'static settings::Key> {
|
||||
KEYS.iter().find(|k| k.toml == (table, name))
|
||||
}
|
||||
|
||||
/// What importing `text` (a `wanderbooks.toml`) and, if given,
|
||||
/// `config_json` (its `receipt_policy` wins over the file's receipt
|
||||
/// settings, as it does for the server) would change in books holding
|
||||
/// `current`, kept in `base_currency`.
|
||||
pub fn import(
|
||||
text: &str,
|
||||
config_json: Option<&str>,
|
||||
current: &Settings,
|
||||
base_currency: &str,
|
||||
) -> Result<Import, String> {
|
||||
// The file as the server reads it today: what the import must match.
|
||||
let legacy = crate::parse(text)?;
|
||||
let legacy_policy = crate::effective_receipt_policy_text(config_json, &legacy)?;
|
||||
let table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
|
||||
let mut out = Import {
|
||||
comments: comments(text),
|
||||
..Import::default()
|
||||
};
|
||||
let mut wanted: BTreeMap<&'static str, Value> = BTreeMap::new();
|
||||
for (section, body) in &table {
|
||||
let toml::Value::Table(body) = body else {
|
||||
out.unknown.push(format!("{section} (outside any table)"));
|
||||
continue;
|
||||
};
|
||||
for (name, v) in body {
|
||||
match (section.as_str(), name.as_str()) {
|
||||
("company", "base_currency") => {
|
||||
let c = v.as_str().unwrap_or_default().trim().to_uppercase();
|
||||
if c != base_currency {
|
||||
return Err(format!(
|
||||
"[company] base_currency {c:?}: these books are kept in {base_currency}, fixed when they were created"
|
||||
));
|
||||
}
|
||||
out.notes.push(format!(
|
||||
"[company] base_currency: the books keep it themselves ({base_currency}, fixed when they were created)"
|
||||
));
|
||||
}
|
||||
("books", "data_dir") => out.notes.push(
|
||||
"[books] data_dir: where the books live is the server's to know, not a setting; dropped".into(),
|
||||
),
|
||||
("vault", "key_file") => {
|
||||
out.vault_key_file = v.as_str().map(str::to_string);
|
||||
out.notes.push(
|
||||
"[vault] key_file: the server's to know (start it with --vault-key-file), not a books setting; dropped".into(),
|
||||
);
|
||||
}
|
||||
("viewer", n) => out.notes.push(format!(
|
||||
"[viewer] {n}: the agent's preference, not a books setting; dropped (record it as a convention if it matters)"
|
||||
)),
|
||||
(s, n) => match key_at(s, n) {
|
||||
Some(k) => {
|
||||
wanted.insert(k.name, json_of(v));
|
||||
}
|
||||
None => out.unknown.push(format!("[{s}] {n}")),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(raw) = config_json {
|
||||
let j: Value = serde_json::from_str(raw).map_err(|e| format!("bad config: {e}"))?;
|
||||
let obj = j.as_object().ok_or("bad config: not a JSON object")?;
|
||||
for (k, v) in obj {
|
||||
match k.as_str() {
|
||||
"receipt_policy" => {
|
||||
// Its policy replaces the file's, whole (crate::
|
||||
// effective_receipt_policy_text).
|
||||
let p: ReceiptPolicy = serde_json::from_value(v.clone())
|
||||
.map_err(|e| format!("bad config: {e}"))?;
|
||||
wanted.insert("policy.evidence.threshold", json!(p.require_receipts_above));
|
||||
wanted.insert(
|
||||
"policy.evidence.required_accounts",
|
||||
json!(p.receipt_required_accounts),
|
||||
);
|
||||
wanted.insert(
|
||||
"policy.evidence.exempt_accounts",
|
||||
json!(p.receipt_exempt_accounts),
|
||||
);
|
||||
}
|
||||
"base_currency" => {
|
||||
let c = v.as_str().unwrap_or_default().trim().to_uppercase();
|
||||
if c != base_currency {
|
||||
return Err(format!(
|
||||
"config.json base_currency {c:?}: these books are kept in {base_currency}"
|
||||
));
|
||||
}
|
||||
}
|
||||
other => out.unknown.push(format!("config.json {other}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
// Canonical values, and the diff against what the books hold. An
|
||||
// identity value exported masked ("**-***5893") never imports: it is
|
||||
// what the books hold already when its last four match, else a note,
|
||||
// and either way the books' own value stays (INVARIANT 4).
|
||||
let mut canon: BTreeMap<&'static str, Value> = BTreeMap::new();
|
||||
let mut keep: Vec<&'static str> = Vec::new();
|
||||
for (name, v) in &wanted {
|
||||
let k = settings::key(name).expect("taken from KEYS");
|
||||
let masked =
|
||||
k.sensitivity == Sensitivity::Identity && v.as_str().is_some_and(|s| s.contains('*'));
|
||||
if masked {
|
||||
let same = current.get(name).is_some_and(|r| {
|
||||
v.as_str() == Some(settings::display(k, &r.value, false).as_str())
|
||||
});
|
||||
if !same {
|
||||
out.notes.push(format!(
|
||||
"[{}] {}: masked in this file, so not imported; set it with books.set_setting",
|
||||
k.toml.0, k.toml.1
|
||||
));
|
||||
}
|
||||
keep.push(k.name);
|
||||
continue;
|
||||
}
|
||||
let value =
|
||||
settings::check(k, v).map_err(|e| format!("[{}] {}: {e}", k.toml.0, k.toml.1))?;
|
||||
// An empty list is the same as no list: not a change.
|
||||
if value.as_array().is_some_and(Vec::is_empty) && k.kind != Kind::Members {
|
||||
continue;
|
||||
}
|
||||
canon.insert(k.name, value);
|
||||
}
|
||||
for k in KEYS {
|
||||
let have = current.get(k.name).map(|r| &r.value);
|
||||
match (canon.get(k.name), have) {
|
||||
(Some(want), Some(have)) if want == have => {}
|
||||
(Some(want), _) => out.changes.push(Change {
|
||||
key: k.name.into(),
|
||||
value: Some(want.clone()),
|
||||
}),
|
||||
(None, Some(_)) if keep.contains(&k.name) => {}
|
||||
(None, Some(_)) => out.changes.push(Change {
|
||||
key: k.name.into(),
|
||||
value: None,
|
||||
}),
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
// INVARIANT 2: the books, after the import, read as the file does.
|
||||
let mut next = current.clone();
|
||||
let probe = Source {
|
||||
decided_by: "import".into(),
|
||||
decided_on: chrono::NaiveDate::from_ymd_opt(2000, 1, 1).expect("a date"),
|
||||
citation: None,
|
||||
note: None,
|
||||
};
|
||||
next.apply(&out.changes, &probe, u64::MAX)?;
|
||||
next.check_whole()?;
|
||||
let ours = books_settings(&next, base_currency);
|
||||
let our_policy = receipt_policy(&next);
|
||||
let differs = differences(&legacy, &legacy_policy, &ours, &our_policy);
|
||||
if !differs.is_empty() {
|
||||
return Err(format!(
|
||||
"importing would read these settings differently than the file does: {}",
|
||||
differs.join(", ")
|
||||
));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Where two readings of the settings differ, by field name.
|
||||
fn differences(
|
||||
a: &BooksSettings,
|
||||
ap: &ReceiptPolicy,
|
||||
b: &BooksSettings,
|
||||
bp: &ReceiptPolicy,
|
||||
) -> Vec<&'static str> {
|
||||
let (ca, cb) = (&a.company, &b.company);
|
||||
let threshold = |p: &ReceiptPolicy| crate::decimal(p.require_receipts_above.trim());
|
||||
[
|
||||
("company name", ca.name == cb.name),
|
||||
(
|
||||
"fiscal year start",
|
||||
ca.fiscal_year_start == cb.fiscal_year_start,
|
||||
),
|
||||
("members", ca.members == cb.members),
|
||||
("accounting basis", ca.basis == cb.basis),
|
||||
("tax form", ca.tax_form == cb.tax_form),
|
||||
("close_into", ca.close_into == cb.close_into),
|
||||
("close_sweep", ca.close_sweep == cb.close_sweep),
|
||||
("receipt threshold", threshold(ap) == threshold(bp)),
|
||||
(
|
||||
"receipt required accounts",
|
||||
ap.receipt_required_accounts == bp.receipt_required_accounts,
|
||||
),
|
||||
(
|
||||
"receipt exempt accounts",
|
||||
ap.receipt_exempt_accounts == bp.receipt_exempt_accounts,
|
||||
),
|
||||
("cash accounts", a.cash_accounts == b.cash_accounts),
|
||||
(
|
||||
"financing accounts",
|
||||
a.financing_accounts == b.financing_accounts,
|
||||
),
|
||||
(
|
||||
"investing accounts",
|
||||
a.investing_accounts == b.investing_accounts,
|
||||
),
|
||||
(
|
||||
"asset tag prefix",
|
||||
a.asset_tag_prefix() == b.asset_tag_prefix(),
|
||||
),
|
||||
("parties", a.parties == b.parties),
|
||||
("time zone", a.timezone == b.timezone),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|(_, same)| !same)
|
||||
.map(|(n, _)| n)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The settings as the server and the viewer use them, from the books'
|
||||
/// own (`ledger_core::settings`): what [`crate::parse`] gives for a
|
||||
/// settings file. `[vault] key_file` is never a books setting.
|
||||
pub fn books_settings(s: &Settings, base_currency: &str) -> BooksSettings {
|
||||
BooksSettings {
|
||||
company: s.company(base_currency),
|
||||
receipt_threshold: s.receipt_threshold().map(str::to_string),
|
||||
receipt_required_accounts: s.receipt_required_accounts(),
|
||||
receipt_exempt_accounts: s.receipt_exempt_accounts(),
|
||||
cash_accounts: s.cash_accounts(),
|
||||
financing_accounts: s.financing_accounts(),
|
||||
investing_accounts: s.investing_accounts(),
|
||||
asset_tag_prefix: s.asset_tag_prefix().map(str::to_string),
|
||||
parties: s.parties(),
|
||||
vault_key_file: None,
|
||||
timezone: s.timezone().map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
/// The receipt policy the books' own settings set.
|
||||
pub fn receipt_policy(s: &Settings) -> ReceiptPolicy {
|
||||
ReceiptPolicy {
|
||||
require_receipts_above: s
|
||||
.receipt_threshold()
|
||||
.unwrap_or(settings::DEFAULT_THRESHOLD)
|
||||
.to_string(),
|
||||
receipt_required_accounts: s.receipt_required_accounts(),
|
||||
receipt_exempt_accounts: s.receipt_exempt_accounts(),
|
||||
}
|
||||
}
|
||||
|
||||
/// One TOML line `name = value`, the value spelled as TOML.
|
||||
fn toml_line(name: &str, v: &Value) -> String {
|
||||
let tv = match v {
|
||||
Value::String(s) => toml::Value::String(s.clone()),
|
||||
Value::Array(a) => toml::Value::Array(
|
||||
a.iter()
|
||||
.map(|x| toml::Value::String(x.as_str().unwrap_or_default().to_string()))
|
||||
.collect(),
|
||||
),
|
||||
other => toml::Value::String(other.to_string()),
|
||||
};
|
||||
let mut t = toml::Table::new();
|
||||
t.insert(name.to_string(), tv);
|
||||
toml::to_string(&t)
|
||||
.unwrap_or_default()
|
||||
.trim_end()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn provenance(out: &mut String, r: &settings::Recorded) {
|
||||
let s = &r.source;
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"# {}, {} (journal line {})",
|
||||
s.decided_by, s.decided_on, r.seq
|
||||
);
|
||||
for extra in [&s.citation, &s.note].into_iter().flatten() {
|
||||
for line in extra.lines() {
|
||||
let _ = writeln!(out, "# {line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The books' settings as a settings file (`wanderbooks.toml` text):
|
||||
/// every setting set, under its table, with who decided it and when as a
|
||||
/// comment; identity values masked (INVARIANT 4).
|
||||
pub fn export(s: &Settings, base_currency: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"# wanderbooks settings, exported from the books{}.",
|
||||
s.last_seq()
|
||||
.map(|q| format!(" (through journal line {q})"))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
out.push_str(
|
||||
"# The books keep their own settings: this file is a copy, and editing it\n# changes nothing until it is imported (books.import_settings).\n",
|
||||
);
|
||||
for table in ["company", "books"] {
|
||||
let _ = write!(out, "\n[{table}]\n");
|
||||
if table == "company" {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"# Fixed when the books were created.\nbase_currency = {:?}",
|
||||
base_currency
|
||||
);
|
||||
}
|
||||
let mut members = None;
|
||||
for (k, r) in s.iter().filter(|(k, _)| k.toml.0 == table) {
|
||||
if k.kind == Kind::Members {
|
||||
members = Some((k, r));
|
||||
continue;
|
||||
}
|
||||
provenance(&mut out, r);
|
||||
let shown = match k.sensitivity {
|
||||
Sensitivity::Identity => Value::String(settings::display(k, &r.value, false)),
|
||||
Sensitivity::Public => r.value.clone(),
|
||||
};
|
||||
let _ = writeln!(out, "{}", toml_line(k.toml.1, &shown));
|
||||
}
|
||||
if let Some((_, r)) = members {
|
||||
let _ = writeln!(out);
|
||||
provenance(&mut out, r);
|
||||
for m in r.value.as_array().into_iter().flatten() {
|
||||
out.push_str("[[company.members]]\n");
|
||||
for f in [
|
||||
"name",
|
||||
"share",
|
||||
"capital_account",
|
||||
"distribution_account",
|
||||
"files_jointly_with",
|
||||
] {
|
||||
if let Some(v) = m.get(f) {
|
||||
let _ = writeln!(out, "{}", toml_line(f, v));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const WANDERLAND: &str = r#"
|
||||
[company]
|
||||
name = "Wanderland Labs LLC"
|
||||
entity_type = "LLC, multi-member, member-managed"
|
||||
tax_classification = "partnership"
|
||||
base_currency = "USD"
|
||||
fiscal_year_start = "01-01"
|
||||
formation_state = "UT"
|
||||
formed = "2026-10-04"
|
||||
state_entity_number = "14745117-0160"
|
||||
ein = "43-2215893"
|
||||
irs_name_control = "WALN"
|
||||
duns = "149976972"
|
||||
address = "15281 S Tall Woods Dr, Draper, UT 84020"
|
||||
registered_agent = "Stephen Waits"
|
||||
partnership_representative = "Stephen Waits"
|
||||
cpa = "Krista Baker"
|
||||
|
||||
[[company.members]]
|
||||
name = "Stephen Waits"
|
||||
share = "50%"
|
||||
capital_account = "3000"
|
||||
files_jointly_with = "Katherine Pendergast"
|
||||
|
||||
[[company.members]]
|
||||
name = "Katherine Pendergast"
|
||||
share = "50%"
|
||||
capital_account = "3001"
|
||||
files_jointly_with = "Stephen Waits"
|
||||
|
||||
[books]
|
||||
data_dir = "books-data"
|
||||
accounting_basis = "accrual"
|
||||
receipt_threshold = "0.00"
|
||||
cash_accounts = ["1000"]
|
||||
financing_accounts = ["2100"]
|
||||
parties = "require"
|
||||
|
||||
[viewer]
|
||||
open_at_start = "ask"
|
||||
"#;
|
||||
|
||||
fn src() -> Source {
|
||||
Source {
|
||||
decided_by: "Owner".into(),
|
||||
decided_on: chrono::NaiveDate::from_ymd_opt(2026, 10, 17).unwrap(),
|
||||
citation: None,
|
||||
note: Some("imported".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn imported(text: &str) -> Settings {
|
||||
let i = import(text, None, &Settings::default(), "USD").unwrap();
|
||||
let mut s = Settings::default();
|
||||
let changes = s.check_changes(&i.changes, &src()).unwrap();
|
||||
s.apply(&changes, &src(), 1).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// INVARIANTS 1-3 on a real company's file.
|
||||
#[test]
|
||||
fn a_real_file_imports_whole() {
|
||||
let i = import(WANDERLAND, None, &Settings::default(), "USD").unwrap();
|
||||
assert!(i.unknown.is_empty(), "{:?}", i.unknown);
|
||||
assert_eq!(i.notes.len(), 3, "{:?}", i.notes);
|
||||
// 14 company facts, the members, and 5 [books] settings.
|
||||
assert_eq!(i.changes.len(), 20);
|
||||
let s = imported(WANDERLAND);
|
||||
assert_eq!(s.get("company.ein").unwrap().value, json!("43-2215893"));
|
||||
assert_eq!(s.parties(), crate::PartyPolicy::Require);
|
||||
// Already imported: nothing to change.
|
||||
let again = import(WANDERLAND, None, &s, "USD").unwrap();
|
||||
assert!(again.changes.is_empty(), "{:?}", again.changes);
|
||||
}
|
||||
|
||||
/// INVARIANT 4: an export imports as no change at all.
|
||||
#[test]
|
||||
fn export_round_trips() {
|
||||
let s = imported(WANDERLAND);
|
||||
let text = export(&s, "USD");
|
||||
assert!(text.contains("ein = \"**-***5893\""), "{text}");
|
||||
assert!(!text.contains("2215893"), "{text}");
|
||||
let back = import(&text, None, &s, "USD").unwrap();
|
||||
assert!(back.changes.is_empty(), "{:?}\n{text}", back.changes);
|
||||
assert!(back.unknown.is_empty(), "{:?}", back.unknown);
|
||||
// And the export reads, as a settings file, as the books do.
|
||||
assert_eq!(
|
||||
crate::parse(&text).unwrap().company.name,
|
||||
"Wanderland Labs LLC"
|
||||
);
|
||||
}
|
||||
|
||||
/// INVARIANT 1: keys the file leaves out are cleared.
|
||||
#[test]
|
||||
fn what_the_file_leaves_out_is_cleared() {
|
||||
let s = imported(WANDERLAND);
|
||||
let i = import(
|
||||
"[company]\nname = \"Wanderland Labs LLC\"\n",
|
||||
None,
|
||||
&s,
|
||||
"USD",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
i.changes
|
||||
.iter()
|
||||
.any(|c| c.key == "company.members" && c.value.is_none())
|
||||
);
|
||||
assert!(!i.changes.iter().any(|c| c.key == "company.name"));
|
||||
}
|
||||
|
||||
/// INVARIANT 3, and the currency is the books'.
|
||||
#[test]
|
||||
fn unknown_and_currency() {
|
||||
let i = import(
|
||||
"[company]\nwebsite = \"x\"\n[extra]\na = 1\n",
|
||||
None,
|
||||
&Settings::default(),
|
||||
"USD",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(i.unknown, ["[company] website", "[extra] a"]);
|
||||
assert!(
|
||||
import(
|
||||
"[company]\nbase_currency = \"EUR\"\n",
|
||||
None,
|
||||
&Settings::default(),
|
||||
"USD"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
/// config.json's receipt policy wins, whole, as it does for the server.
|
||||
#[test]
|
||||
fn config_json_policy_wins() {
|
||||
let j = r#"{"receipt_policy": {"require_receipts_above": "10.00", "receipt_exempt_accounts": ["6999"]}}"#;
|
||||
let i = import(
|
||||
"[books]\nreceipt_threshold = \"0.00\"\n",
|
||||
Some(j),
|
||||
&Settings::default(),
|
||||
"USD",
|
||||
)
|
||||
.unwrap();
|
||||
let mut s = Settings::default();
|
||||
s.apply(&i.changes, &src(), 1).unwrap();
|
||||
let p = receipt_policy(&s);
|
||||
assert_eq!(p.require_receipts_above, "10.00");
|
||||
assert_eq!(p.receipt_exempt_accounts, ["6999"]);
|
||||
}
|
||||
|
||||
/// INVARIANT 2: a value the books would read differently is refused
|
||||
/// (here: a time zone the file names that isn't one).
|
||||
#[test]
|
||||
fn never_reads_differently() {
|
||||
assert!(
|
||||
import(
|
||||
"[company]\ntimezone = \"Nowhere/Land\"\n",
|
||||
None,
|
||||
&Settings::default(),
|
||||
"USD"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
import(
|
||||
"[company]\nfiscal_year_start = \"07-01\"\n",
|
||||
None,
|
||||
&Settings::default(),
|
||||
"USD"
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
/// A comment is what follows a `#` outside a string: a `#` inside a
|
||||
/// basic string (even after an escaped quote) or a literal string
|
||||
/// (where a backslash escapes nothing) is text; blank comments are
|
||||
/// left out.
|
||||
#[test]
|
||||
fn comments_are_what_follows_a_hash_outside_strings() {
|
||||
let text = "# top\n\
|
||||
name = \"a # not\" # one\n\
|
||||
esc = \"q \\\" # still\" # two\n\
|
||||
lit = 'c:\\' # three\n\
|
||||
#\n\
|
||||
x = 1 # \n\
|
||||
y = 2#five\n";
|
||||
assert_eq!(comments(text), ["top", "one", "two", "three", "five"]);
|
||||
assert!(comments("a = 'x' \"y\"\n").is_empty());
|
||||
}
|
||||
|
||||
/// The import lists the file's comments (INVARIANT 3).
|
||||
#[test]
|
||||
fn an_import_lists_the_comments() {
|
||||
let i = import(
|
||||
"# Kept settings\n[company]\nname = \"X LLC\" # from the certificate\n",
|
||||
None,
|
||||
&Settings::default(),
|
||||
"USD",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(i.comments, ["Kept settings", "from the certificate"]);
|
||||
}
|
||||
|
||||
/// config.json's base_currency must be the books'.
|
||||
#[test]
|
||||
fn config_json_currency_is_the_books() {
|
||||
let ok = import(
|
||||
"",
|
||||
Some(r#"{"base_currency": " usd "}"#),
|
||||
&Settings::default(),
|
||||
"USD",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(ok.changes.is_empty() && ok.unknown.is_empty(), "{ok:?}");
|
||||
let err = import(
|
||||
"",
|
||||
Some(r#"{"base_currency": "EUR"}"#),
|
||||
&Settings::default(),
|
||||
"USD",
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.contains("config.json base_currency \"EUR\""), "{err}");
|
||||
}
|
||||
|
||||
/// INVARIANT 4: a masked identity value never imports. It keeps the
|
||||
/// books' own value either way, with a note only when its last four
|
||||
/// differ from what the books hold.
|
||||
#[test]
|
||||
fn a_masked_identity_keeps_the_books_value() {
|
||||
let s = imported(WANDERLAND);
|
||||
let masked =
|
||||
|ein: &str| WANDERLAND.replace("ein = \"43-2215893\"", &format!("ein = \"{ein}\""));
|
||||
let note =
|
||||
"[company] ein: masked in this file, so not imported; set it with books.set_setting";
|
||||
let same = import(&masked("**-***5893"), None, &s, "USD").unwrap();
|
||||
assert!(same.changes.is_empty(), "{:?}", same.changes);
|
||||
assert!(!same.notes.iter().any(|n| n == note), "{:?}", same.notes);
|
||||
let other = import(&masked("**-***0000"), None, &s, "USD").unwrap();
|
||||
assert!(other.changes.is_empty(), "{:?}", other.changes);
|
||||
assert!(other.notes.iter().any(|n| n == note), "{:?}", other.notes);
|
||||
// Into books that hold none: still not imported, and noted.
|
||||
let empty = import(
|
||||
"[company]\nein = \"**-***5893\"\n",
|
||||
None,
|
||||
&Settings::default(),
|
||||
"USD",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(empty.changes.is_empty(), "{:?}", empty.changes);
|
||||
assert_eq!(empty.notes, [note]);
|
||||
}
|
||||
|
||||
/// An empty list is no list: not a change.
|
||||
#[test]
|
||||
fn an_empty_list_is_no_change() {
|
||||
let i = import(
|
||||
"[books]\ncash_accounts = []\nfinancing_accounts = []\n",
|
||||
None,
|
||||
&Settings::default(),
|
||||
"USD",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(i.changes.is_empty(), "{:?}", i.changes);
|
||||
}
|
||||
|
||||
/// A value the file changes is a change, exactly that one.
|
||||
#[test]
|
||||
fn a_changed_value_is_one_change() {
|
||||
let s = imported(WANDERLAND);
|
||||
let text = WANDERLAND.replace("\"Wanderland Labs LLC\"", "\"Other Labs LLC\"");
|
||||
let i = import(&text, None, &s, "USD").unwrap();
|
||||
assert_eq!(
|
||||
i.changes,
|
||||
[Change {
|
||||
key: "company.name".into(),
|
||||
value: Some(json!("Other Labs LLC")),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
/// INVARIANT 2's check names each field read differently.
|
||||
#[test]
|
||||
fn differences_name_each_field() {
|
||||
let a = BooksSettings::default();
|
||||
let p = ReceiptPolicy::default();
|
||||
assert!(differences(&a, &p, &a, &p).is_empty());
|
||||
let mut b = a.clone();
|
||||
b.company.name = "Other".into();
|
||||
b.timezone = Some("America/Denver".into());
|
||||
let mut q = p.clone();
|
||||
q.require_receipts_above = format!("{}0", p.require_receipts_above);
|
||||
// The default asset tag prefix is made from the company's name.
|
||||
let named = ["company name", "asset tag prefix", "time zone"];
|
||||
assert_eq!(differences(&a, &p, &b, &q), named);
|
||||
q.require_receipts_above = "1.00".into();
|
||||
assert_eq!(
|
||||
differences(&a, &p, &b, &q),
|
||||
[
|
||||
"company name",
|
||||
"receipt threshold",
|
||||
"asset tag prefix",
|
||||
"time zone"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// An export says, for each setting, who decided it, when, and its
|
||||
/// note.
|
||||
#[test]
|
||||
fn an_export_keeps_each_settings_source() {
|
||||
let text = export(&imported(WANDERLAND), "USD");
|
||||
assert!(
|
||||
text.contains("\n# Owner, 2026-10-17 (journal line 1)\n# imported\n"),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,728 @@
|
||||
//! 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.
|
||||
|
||||
pub mod codec;
|
||||
pub mod receipt_policy;
|
||||
pub use ledger_core::settings::{Company, Member, PartyPolicy, decimal};
|
||||
pub use receipt_policy::ReceiptPolicy;
|
||||
|
||||
use rust_decimal::Decimal;
|
||||
use serde::Deserialize;
|
||||
use std::path::Path;
|
||||
|
||||
/// What the server takes from `wanderbooks.toml`.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct BooksSettings {
|
||||
/// `[company]`.
|
||||
pub company: Company,
|
||||
/// `[books] receipt_threshold`, validated as a decimal string.
|
||||
pub receipt_threshold: Option<String>,
|
||||
/// `[books] receipt_required_accounts`: account codes whose entries
|
||||
/// always need a receipt (unless `config.json` sets a policy).
|
||||
pub receipt_required_accounts: Vec<String>,
|
||||
/// `[books] receipt_exempt_accounts`: account codes exempt from the
|
||||
/// receipt rule (unless `config.json` sets a policy).
|
||||
pub receipt_exempt_accounts: Vec<String>,
|
||||
/// `[books] cash_accounts`: account codes that hold cash (bank,
|
||||
/// savings, cash on hand), for cash reports and dashboards. Empty:
|
||||
/// callers decide (e.g. by account name).
|
||||
pub cash_accounts: Vec<String>,
|
||||
/// `[books] financing_accounts`: liability account codes the cash flow
|
||||
/// statement treats as financing (loans, and what owners advanced:
|
||||
/// ASC 230-10-45-14(b)). Empty: callers decide.
|
||||
pub financing_accounts: Vec<String>,
|
||||
/// `[books] investing_accounts`: asset account codes the cash flow
|
||||
/// statement treats as investing (loans the company makes, investments
|
||||
/// it buys: ASC 230-10-20). Registered fixed assets need not be listed.
|
||||
pub investing_accounts: Vec<String>,
|
||||
/// `[books] asset_tag_prefix`: the prefix of generated asset tags
|
||||
/// ("WL" for "WL-0001"). Unset: [`BooksSettings::asset_tag_prefix`]
|
||||
/// derives it from the company name.
|
||||
pub asset_tag_prefix: Option<String>,
|
||||
/// `[books] parties`: how hard the agent asks for each entry's
|
||||
/// counterparty. Unset: [`PartyPolicy::Suggest`].
|
||||
pub parties: PartyPolicy,
|
||||
/// `[vault] key_file`: the TIN vault's key file. Unset: the vault's
|
||||
/// default (`~/.config/wanderbooks/vault.key`).
|
||||
pub vault_key_file: Option<std::path::PathBuf>,
|
||||
/// `[company] timezone`: the company's time zone (an IANA name), what
|
||||
/// "today" means for the books. Unset: the computer's.
|
||||
pub timezone: Option<String>,
|
||||
}
|
||||
|
||||
impl BooksSettings {
|
||||
/// The prefix generated asset tags use: `[books] asset_tag_prefix`, or
|
||||
/// else the initials of the company name's words, legal-form words
|
||||
/// (LLC, Inc, Corp, ...) left out, at most 8 ("Wanderland Labs LLC" →
|
||||
/// "WL"); "FA" (fixed asset) when the name gives none.
|
||||
pub fn asset_tag_prefix(&self) -> String {
|
||||
if let Some(p) = &self.asset_tag_prefix {
|
||||
return p.clone();
|
||||
}
|
||||
const FORMS: &[&str] = &[
|
||||
"llc",
|
||||
"l",
|
||||
"c",
|
||||
"inc",
|
||||
"incorporated",
|
||||
"corp",
|
||||
"corporation",
|
||||
"co",
|
||||
"company",
|
||||
"ltd",
|
||||
"limited",
|
||||
"llp",
|
||||
"lp",
|
||||
"pllc",
|
||||
"pc",
|
||||
"pa",
|
||||
"the",
|
||||
"and",
|
||||
"of",
|
||||
];
|
||||
let initials: String = self
|
||||
.company
|
||||
.name
|
||||
.split(|c: char| !c.is_ascii_alphanumeric())
|
||||
.filter(|w| !w.is_empty() && !FORMS.contains(&w.to_ascii_lowercase().as_str()))
|
||||
.filter_map(|w| w.chars().next())
|
||||
.filter(char::is_ascii_alphabetic)
|
||||
.map(|c| c.to_ascii_uppercase())
|
||||
.take(8)
|
||||
.collect();
|
||||
if initials.is_empty() || self.company.name == Company::default().name {
|
||||
"FA".to_string()
|
||||
} else {
|
||||
initials
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BooksToml {
|
||||
#[serde(default)]
|
||||
company: CompanyToml,
|
||||
#[serde(default)]
|
||||
books: BooksSection,
|
||||
#[serde(default)]
|
||||
vault: VaultSection,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
struct VaultSection {
|
||||
key_file: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
struct CompanyToml {
|
||||
name: Option<String>,
|
||||
fiscal_year_start: Option<String>,
|
||||
tax_form: Option<String>,
|
||||
tax_classification: Option<String>,
|
||||
timezone: Option<String>,
|
||||
#[serde(default)]
|
||||
members: Vec<MemberToml>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MemberToml {
|
||||
name: String,
|
||||
share: String,
|
||||
capital_account: Option<String>,
|
||||
distribution_account: Option<String>,
|
||||
files_jointly_with: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
struct BooksSection {
|
||||
receipt_threshold: Option<String>,
|
||||
#[serde(default)]
|
||||
receipt_required_accounts: Vec<String>,
|
||||
#[serde(default)]
|
||||
receipt_exempt_accounts: Vec<String>,
|
||||
accounting_basis: Option<String>,
|
||||
close_into: Option<String>,
|
||||
#[serde(default)]
|
||||
close_sweep: Vec<String>,
|
||||
#[serde(default)]
|
||||
cash_accounts: Vec<String>,
|
||||
#[serde(default)]
|
||||
financing_accounts: Vec<String>,
|
||||
#[serde(default)]
|
||||
investing_accounts: Vec<String>,
|
||||
asset_tag_prefix: Option<String>,
|
||||
parties: 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"`: the first day of a month (a fiscal year ends on the
|
||||
/// last day of a month: IRS Pub. 538, docs/spec/sources/
|
||||
/// irs-periods-and-methods.md).
|
||||
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())?);
|
||||
if !(1..=12).contains(&m) {
|
||||
return Err(bad());
|
||||
}
|
||||
if d != 1 {
|
||||
return Err(format!(
|
||||
"fiscal_year_start {s:?}: a fiscal year starts on the first of a month (it ends on the last day of one: IRS Pub. 538)"
|
||||
));
|
||||
}
|
||||
Ok((m, d))
|
||||
}
|
||||
|
||||
/// Account codes as written, trimmed; a blank one is refused.
|
||||
fn codes(key: &str, list: Vec<String>) -> Result<Vec<String>, String> {
|
||||
list.into_iter()
|
||||
.map(|c| {
|
||||
let c = c.trim().to_string();
|
||||
if c.is_empty() {
|
||||
Err(format!("{key}: an account code is blank"))
|
||||
} else {
|
||||
Ok(c)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The receipt policy in force: `config_json`'s `receipt_policy` when the
|
||||
/// file sets one, else `[books] receipt_threshold`, else the default
|
||||
/// ($25.00); validated (crate::receipt_policy INVARIANT 4). The server and
|
||||
/// the viewer both call this, so they enforce and show one rule.
|
||||
pub fn effective_receipt_policy(
|
||||
config_json: &Path,
|
||||
settings: &BooksSettings,
|
||||
) -> Result<ReceiptPolicy, String> {
|
||||
let raw = if config_json.exists() {
|
||||
Some(
|
||||
std::fs::read_to_string(config_json)
|
||||
.map_err(|e| format!("{}: {e}", config_json.display()))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
effective_receipt_policy_text(raw.as_deref(), settings).map_err(|e| {
|
||||
e.replace(
|
||||
"bad config",
|
||||
&format!("bad config {}", config_json.display()),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// [`effective_receipt_policy`] from `config.json`'s text (`None`: no
|
||||
/// such file).
|
||||
pub fn effective_receipt_policy_text(
|
||||
config_json: Option<&str>,
|
||||
settings: &BooksSettings,
|
||||
) -> Result<ReceiptPolicy, String> {
|
||||
#[derive(Deserialize)]
|
||||
struct ConfigJson {
|
||||
#[serde(default)]
|
||||
receipt_policy: Option<ReceiptPolicy>,
|
||||
}
|
||||
let from_json = match config_json {
|
||||
Some(raw) => {
|
||||
serde_json::from_str::<ConfigJson>(raw)
|
||||
.map_err(|e| format!("bad config: {e}"))?
|
||||
.receipt_policy
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let policy = match from_json {
|
||||
Some(p) => p,
|
||||
None => ReceiptPolicy {
|
||||
require_receipts_above: settings
|
||||
.receipt_threshold
|
||||
.clone()
|
||||
.unwrap_or_else(|| ledger_core::settings::DEFAULT_THRESHOLD.to_string()),
|
||||
receipt_required_accounts: settings.receipt_required_accounts.clone(),
|
||||
receipt_exempt_accounts: settings.receipt_exempt_accounts.clone(),
|
||||
},
|
||||
};
|
||||
policy.validate()?;
|
||||
Ok(policy)
|
||||
}
|
||||
|
||||
/// Parse `wanderbooks.toml` text.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: read the company's settings, refusing any the books can't honor
|
||||
/// rather than guessing.
|
||||
/// SOURCE: IRS Pub. 538 (docs/spec/sources/irs-periods-and-methods.md:
|
||||
/// a fiscal year ends on the last day of a month); Treas. Reg.
|
||||
/// §301.7701-3 and the Schedule C instructions
|
||||
/// (docs/spec/sources/irs-entity-classification.md); IRC §704(a)
|
||||
/// (shares from the agreement: docs/spec/sources/irs-partnerships.md).
|
||||
/// INVARIANTS:
|
||||
/// 1. Every setting is optional; an empty file is a calendar-year,
|
||||
/// accrual-basis "Unnamed company" with no members.
|
||||
/// 2. fiscal_year_start MUST be the first of a month ("MM-01").
|
||||
/// 3. Members: each MUST have a name and a capital account, names and
|
||||
/// capital accounts MUST be distinct (and no member's distributions
|
||||
/// account another's capital), and shares (each in (0, 1], "50%" or
|
||||
/// "0.5") MUST sum to exactly 1.
|
||||
/// 4. tax_form wins over tax_classification (which is then not read);
|
||||
/// a classification is read by whole words, ignoring case, and MUST
|
||||
/// name exactly one return (source table). tax_form, basis, and
|
||||
/// classification values ignore case.
|
||||
/// 5. receipt_threshold MUST be a decimal at least zero.
|
||||
/// 6. Account codes (cash, financing, investing, close_into,
|
||||
/// close_sweep, members' capital and distributions) are trimmed; a
|
||||
/// blank one is refused (a blank distributions account means none). close_into is
|
||||
/// refused with members (a close goes to their capital accounts).
|
||||
/// 7. profile holds every simple [company] value in file order:
|
||||
/// strings as written, numbers, booleans, and dates in TOML's
|
||||
/// spelling; tables and arrays (members) are left out.
|
||||
/// 8. NEVER accept a file that would be read differently than written:
|
||||
/// an unknown basis, tax form, or classification is refused.
|
||||
/// ```
|
||||
pub fn parse(text: &str) -> Result<BooksSettings, String> {
|
||||
let raw: BooksToml = toml::from_str(text).map_err(|e| e.to_string())?;
|
||||
let mut company = Company::default();
|
||||
if let Some(name) = raw.company.name.filter(|n| !n.trim().is_empty()) {
|
||||
company.name = name.trim().to_string();
|
||||
}
|
||||
if let Some(fy) = raw.company.fiscal_year_start {
|
||||
company.fiscal_year_start = parse_month_day(fy.trim())?;
|
||||
}
|
||||
for m in raw.company.members {
|
||||
let share = parse_share(&m.name, &m.share)?;
|
||||
let capital_account = m
|
||||
.capital_account
|
||||
.filter(|c| !c.trim().is_empty())
|
||||
.ok_or_else(|| format!("member {}: capital_account is required", m.name))?;
|
||||
company.members.push(Member {
|
||||
name: m.name.trim().to_string(),
|
||||
share,
|
||||
capital_account: capital_account.trim().to_string(),
|
||||
distribution_account: m
|
||||
.distribution_account
|
||||
.map(|d| d.trim().to_string())
|
||||
.filter(|d| !d.is_empty()),
|
||||
files_jointly_with: m
|
||||
.files_jointly_with
|
||||
.map(|d| d.trim().to_string())
|
||||
.filter(|d| !d.is_empty()),
|
||||
});
|
||||
}
|
||||
for (i, m) in company.members.iter().enumerate() {
|
||||
if m.name.is_empty() {
|
||||
return Err(format!("member {}: name is blank", i + 1));
|
||||
}
|
||||
for other in &company.members[..i] {
|
||||
if other.name == m.name {
|
||||
return Err(format!("member {} is listed twice", m.name));
|
||||
}
|
||||
if other.capital_account == m.capital_account {
|
||||
return Err(format!(
|
||||
"members {} and {} share capital account {}",
|
||||
other.name, m.name, m.capital_account
|
||||
));
|
||||
}
|
||||
}
|
||||
for other in &company.members {
|
||||
if other.distribution_account.as_deref() == Some(m.capital_account.as_str()) {
|
||||
return Err(format!(
|
||||
"{}'s distributions account {} is {}'s capital account",
|
||||
other.name, m.capital_account, m.name
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
// files_jointly_with names another member, and both say so.
|
||||
for m in &company.members {
|
||||
if let Some(j) = &m.files_jointly_with {
|
||||
if *j == m.name {
|
||||
return Err(format!(
|
||||
"member {}: files_jointly_with names themself",
|
||||
m.name
|
||||
));
|
||||
}
|
||||
match company.members.iter().find(|o| o.name == *j) {
|
||||
None => {
|
||||
return Err(format!(
|
||||
"member {}: files_jointly_with {j:?} isn't a member",
|
||||
m.name
|
||||
));
|
||||
}
|
||||
Some(o) if o.files_jointly_with.as_deref() != Some(m.name.as_str()) => {
|
||||
return Err(format!(
|
||||
"member {} files jointly with {j}, but {j}'s files_jointly_with doesn't name {}: set it on both",
|
||||
m.name, m.name
|
||||
));
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !company.members.is_empty() {
|
||||
let total: Decimal = company.members.iter().map(|m| m.share).sum();
|
||||
if total != Decimal::ONE {
|
||||
return Err(format!("member shares sum to {total}, not 100%"));
|
||||
}
|
||||
}
|
||||
company.tax_form =
|
||||
match (&raw.company.tax_form, &raw.company.tax_classification) {
|
||||
(Some(f), _) => Some(ledger_core::TaxForm::parse(f).ok_or_else(|| {
|
||||
format!("tax_form {f:?}: expected schedule_c, 1065, 1120s, or 1120")
|
||||
})?),
|
||||
(None, Some(c)) => Some(ledger_core::settings::form_for_classification(c).ok_or_else(|| {
|
||||
format!(
|
||||
"tax_classification {c:?}: expected partnership, S corporation, C corporation, or disregarded entity (or set tax_form)"
|
||||
)
|
||||
})?),
|
||||
(None, None) => None,
|
||||
};
|
||||
if !company.members.is_empty() && raw.books.close_into.is_some() {
|
||||
return Err(
|
||||
"close_into is for single-owner businesses; with [[company.members]] a close goes to each member's capital account"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
company.close_into = match raw.books.close_into {
|
||||
Some(c) => Some(codes("close_into", vec![c])?.remove(0)),
|
||||
None => None,
|
||||
};
|
||||
company.close_sweep = codes("close_sweep", raw.books.close_sweep)?;
|
||||
if let Some(basis) = raw.books.accounting_basis {
|
||||
company.basis = match basis.trim().to_lowercase().as_str() {
|
||||
"accrual" => "Accrual".to_string(),
|
||||
"cash" => "Cash".to_string(),
|
||||
other => {
|
||||
return Err(format!(
|
||||
"accounting_basis {other:?}: expected accrual or cash"
|
||||
));
|
||||
}
|
||||
};
|
||||
}
|
||||
let receipt_threshold = match raw.books.receipt_threshold {
|
||||
Some(t) => {
|
||||
let v = decimal(t.trim())
|
||||
.ok_or_else(|| format!("receipt_threshold {t:?} is not a decimal"))?;
|
||||
if v.is_sign_negative() && !v.is_zero() {
|
||||
return Err(format!("receipt_threshold {t:?} is below zero"));
|
||||
}
|
||||
Some(t.trim().to_string())
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
|
||||
if let Some(toml::Value::Table(c)) = table.get("company") {
|
||||
company.profile = c
|
||||
.iter()
|
||||
.filter_map(|(k, v)| {
|
||||
let s = match v {
|
||||
toml::Value::String(s) => s.clone(),
|
||||
toml::Value::Integer(i) => i.to_string(),
|
||||
toml::Value::Float(f) => f.to_string(),
|
||||
toml::Value::Boolean(b) => b.to_string(),
|
||||
toml::Value::Datetime(d) => d.to_string(),
|
||||
_ => return None,
|
||||
};
|
||||
Some((k.clone(), s))
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
let timezone = match raw.company.timezone {
|
||||
Some(z) => {
|
||||
let key = ledger_core::settings::key("company.timezone").expect("a known key");
|
||||
let v = ledger_core::settings::check(key, &toml_json(&z))?;
|
||||
v.as_str().map(str::to_string)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
Ok(BooksSettings {
|
||||
company,
|
||||
receipt_threshold,
|
||||
receipt_required_accounts: codes(
|
||||
"receipt_required_accounts",
|
||||
raw.books.receipt_required_accounts,
|
||||
)?,
|
||||
receipt_exempt_accounts: codes(
|
||||
"receipt_exempt_accounts",
|
||||
raw.books.receipt_exempt_accounts,
|
||||
)?,
|
||||
timezone,
|
||||
cash_accounts: codes("cash_accounts", raw.books.cash_accounts)?,
|
||||
financing_accounts: codes("financing_accounts", raw.books.financing_accounts)?,
|
||||
investing_accounts: codes("investing_accounts", raw.books.investing_accounts)?,
|
||||
vault_key_file: match raw.vault.key_file {
|
||||
Some(p) if p.trim().is_empty() => {
|
||||
return Err("[vault] key_file is empty".into());
|
||||
}
|
||||
Some(p) => Some(std::path::PathBuf::from(match p.strip_prefix("~/") {
|
||||
Some(rest) => format!("{}/{rest}", std::env::var("HOME").unwrap_or_default()),
|
||||
None => p,
|
||||
})),
|
||||
None => None,
|
||||
},
|
||||
asset_tag_prefix: match raw.books.asset_tag_prefix {
|
||||
Some(p)
|
||||
if !p.is_empty()
|
||||
&& p.len() <= 8
|
||||
&& p.chars().all(|c| c.is_ascii_alphanumeric()) =>
|
||||
{
|
||||
Some(p)
|
||||
}
|
||||
Some(p) => {
|
||||
return Err(format!(
|
||||
"[books] asset_tag_prefix {p:?}: 1 to 8 letters or digits"
|
||||
));
|
||||
}
|
||||
None => None,
|
||||
},
|
||||
parties: match raw.books.parties.as_deref() {
|
||||
None | Some("suggest") => PartyPolicy::Suggest,
|
||||
Some("off") => PartyPolicy::Off,
|
||||
Some("require") | Some("required") => PartyPolicy::Require,
|
||||
Some(other) => {
|
||||
return Err(format!(
|
||||
"[books] parties {other:?}: \"off\", \"suggest\", or \"require\""
|
||||
));
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn toml_json(s: &str) -> serde_json::Value {
|
||||
serde_json::Value::String(s.to_string())
|
||||
}
|
||||
|
||||
/// 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::*;
|
||||
use chrono::NaiveDate;
|
||||
|
||||
/// files_jointly_with: names another member, on both sides.
|
||||
#[test]
|
||||
fn files_jointly_with() {
|
||||
let m = |a: &str, b: &str| {
|
||||
format!(
|
||||
"[company]\nname = \"X\"\n[[company.members]]\nname = \"A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n{a}\n[[company.members]]\nname = \"B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n{b}\n"
|
||||
)
|
||||
};
|
||||
let ok = parse(&m(
|
||||
"files_jointly_with = \"B\"",
|
||||
"files_jointly_with = \"A\"",
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ok.company.members[0].files_jointly_with.as_deref(),
|
||||
Some("B")
|
||||
);
|
||||
assert!(
|
||||
parse(&m("files_jointly_with = \"B\"", ""))
|
||||
.unwrap_err()
|
||||
.contains("set it on both")
|
||||
);
|
||||
assert!(
|
||||
parse(&m("files_jointly_with = \"C\"", ""))
|
||||
.unwrap_err()
|
||||
.contains("isn't a member")
|
||||
);
|
||||
assert!(
|
||||
parse(&m("files_jointly_with = \"A\"", ""))
|
||||
.unwrap_err()
|
||||
.contains("themself")
|
||||
);
|
||||
assert_eq!(
|
||||
parse(&m("", "")).unwrap().company.members[1].files_jointly_with,
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
/// `[books] parties`: suggest by default; off, suggest, require (and
|
||||
/// "required") parse; anything else is refused, naming the choices.
|
||||
#[test]
|
||||
fn parties_policy() {
|
||||
let base = "[company]\nname = \"Acme LLC\"\n[books]\n";
|
||||
assert_eq!(parse(base).unwrap().parties, PartyPolicy::Suggest);
|
||||
for (v, want) in [
|
||||
("off", PartyPolicy::Off),
|
||||
("suggest", PartyPolicy::Suggest),
|
||||
("require", PartyPolicy::Require),
|
||||
("required", PartyPolicy::Require),
|
||||
] {
|
||||
let s = parse(&format!("{base}parties = \"{v}\"\n")).unwrap();
|
||||
assert_eq!(s.parties, want, "{v}");
|
||||
}
|
||||
let e = parse(&format!("{base}parties = \"always\"\n")).unwrap_err();
|
||||
assert!(e.contains("require"), "{e}");
|
||||
}
|
||||
|
||||
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calendar_year_by_default() {
|
||||
let c = parse("").unwrap().company;
|
||||
assert_eq!(c.fiscal_year_start, (1, 1));
|
||||
assert_eq!(
|
||||
c.fiscal_year_containing(d(2026, 10, 11)),
|
||||
(d(2026, 1, 1), d(2026, 12, 31))
|
||||
);
|
||||
assert_eq!(
|
||||
c.fiscal_year_containing(d(2026, 1, 1)),
|
||||
(d(2026, 1, 1), d(2026, 12, 31))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_july_fiscal_year_spans_two_calendar_years() {
|
||||
let c = parse("[company]\nfiscal_year_start = \"07-01\"\n")
|
||||
.unwrap()
|
||||
.company;
|
||||
assert_eq!(
|
||||
c.fiscal_year_containing(d(2026, 3, 15)),
|
||||
(d(2025, 7, 1), d(2026, 6, 30))
|
||||
);
|
||||
assert_eq!(
|
||||
c.fiscal_year_containing(d(2026, 7, 1)),
|
||||
(d(2026, 7, 1), d(2027, 6, 30))
|
||||
);
|
||||
assert_eq!(
|
||||
c.fiscal_year_containing(d(2026, 6, 30)),
|
||||
(d(2025, 7, 1), d(2026, 6, 30))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_the_scaffolded_file() {
|
||||
let s = parse(
|
||||
"[company]\nname = \"Wanderland Labs LLC\"\nentity_type = \"x\"\nfiscal_year_start = \"01-01\"\n\n[books]\ndata_dir = \"books-data\"\nreceipt_threshold = \"25.00\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.company.name, "Wanderland Labs LLC");
|
||||
assert_eq!(s.receipt_threshold.as_deref(), Some("25.00"));
|
||||
// An empty name (fresh scaffold) keeps the placeholder.
|
||||
assert_eq!(
|
||||
parse("[company]\nname = \"\"\n").unwrap().company.name,
|
||||
"Unnamed company"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_the_whole_profile_in_order() {
|
||||
let s = parse(
|
||||
"[company]\nname = \"Acme LLC\"\nein = \"12-3456789\"\nformed = \"2026-10-04\"\n[books]\ncash_accounts = [\"1000\", \"1010\"]\nfinancing_accounts = [\"2100\"]\ninvesting_accounts = [\"1400\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.company.profile,
|
||||
[
|
||||
("name".to_string(), "Acme LLC".to_string()),
|
||||
("ein".to_string(), "12-3456789".to_string()),
|
||||
("formed".to_string(), "2026-10-04".to_string()),
|
||||
]
|
||||
);
|
||||
assert_eq!(s.cash_accounts, ["1000", "1010"]);
|
||||
assert_eq!(s.financing_accounts, ["2100"]);
|
||||
assert_eq!(s.investing_accounts, ["1400"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_members_and_basis() {
|
||||
let s = parse(
|
||||
"[company]\n[[company.members]]\nname = \"Stephen Waits\"\nshare = \"50%\"\ncapital_account = \"3000\"\n[[company.members]]\nname = \"Katherine Pendergast\"\nshare = \"0.5\"\ncapital_account = \"3001\"\n[books]\naccounting_basis = \"cash\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let half = Decimal::new(5, 1);
|
||||
assert_eq!(
|
||||
s.company.members,
|
||||
vec![
|
||||
Member {
|
||||
name: "Stephen Waits".into(),
|
||||
share: half,
|
||||
capital_account: "3000".into(),
|
||||
distribution_account: None,
|
||||
files_jointly_with: None,
|
||||
},
|
||||
Member {
|
||||
name: "Katherine Pendergast".into(),
|
||||
share: half,
|
||||
capital_account: "3001".into(),
|
||||
distribution_account: None,
|
||||
files_jointly_with: None,
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(s.company.basis, "Cash");
|
||||
assert_eq!(parse("").unwrap().company.basis, "Accrual");
|
||||
// Shares must add up; every member needs a capital account.
|
||||
let two = |a: &str, b: &str| {
|
||||
format!(
|
||||
"[company]\n[[company.members]]\nname = \"A\"\nshare = \"{a}\"\ncapital_account = \"3000\"\n[[company.members]]\nname = \"B\"\nshare = \"{b}\"\ncapital_account = \"3001\"\n"
|
||||
)
|
||||
};
|
||||
assert!(parse(&two("50%", "40%")).is_err());
|
||||
assert!(parse(&two("60%", "40%")).is_ok());
|
||||
assert!(parse("[company]\n[[company.members]]\nname = \"A\"\nshare = \"100%\"\n").is_err());
|
||||
assert!(parse("[books]\naccounting_basis = \"modified\"\n").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tax_form_from_setting_or_classification() {
|
||||
use ledger_core::TaxForm;
|
||||
let form = |toml: &str| parse(toml).unwrap().company.tax_form;
|
||||
assert_eq!(
|
||||
form("[company]\ntax_form = \"1120s\"\n"),
|
||||
Some(TaxForm::Form1120S)
|
||||
);
|
||||
assert_eq!(
|
||||
form("[company]\ntax_classification = \"partnership\"\n"),
|
||||
Some(TaxForm::Form1065)
|
||||
);
|
||||
assert_eq!(
|
||||
form("[company]\ntax_classification = \"S corporation\"\n"),
|
||||
Some(TaxForm::Form1120S)
|
||||
);
|
||||
assert_eq!(
|
||||
form("[company]\ntax_classification = \"disregarded entity\"\n"),
|
||||
Some(TaxForm::ScheduleC)
|
||||
);
|
||||
assert_eq!(
|
||||
form("[company]\ntax_classification = \"partnership\"\ntax_form = \"1120\"\n"),
|
||||
Some(TaxForm::Form1120)
|
||||
);
|
||||
assert_eq!(form(""), None);
|
||||
assert!(parse("[company]\ntax_form = \"990\"\n").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_settings() {
|
||||
assert!(parse("[company]\nfiscal_year_start = \"02-29\"\n").is_err());
|
||||
assert!(parse("[company]\nfiscal_year_start = \"13-01\"\n").is_err());
|
||||
assert!(parse("[company]\nfiscal_year_start = \"Jan 1\"\n").is_err());
|
||||
assert!(parse("[books]\nreceipt_threshold = \"25 dollars\"\n").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: no entry posts without the document that supports it.
|
||||
//! SOURCE: docs/spec/sources/irs-records.md (Pub. 583: "you must keep
|
||||
//! supporting documents"; they "support the entries in your
|
||||
//! books"); DECISION (Stephen: the threshold, strictly greater
|
||||
//! than; Wanderland's is 0.00, every entry, 2026-10-12).
|
||||
//! INVARIANTS:
|
||||
//! 1. An entry touching a required account always needs a receipt.
|
||||
//! 2. Otherwise it needs one exactly when its debits are strictly
|
||||
//! greater than the threshold and some line is outside the exempt
|
||||
//! accounts; at threshold 0.00 every entry with debits needs one.
|
||||
//! 3. Splitting does not dodge it: posted entries the same day with the
|
||||
//! same description (whitespace and case aside) and the same debit
|
||||
//! accounts count together (split_group), reversals aside.
|
||||
//! 4. A threshold that isn't a decimal, or is below zero, is refused at
|
||||
//! startup (validate).
|
||||
//! ```
|
||||
|
||||
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 {
|
||||
/// Refuse a policy the server can't honor: a threshold that isn't a
|
||||
/// decimal, or is below zero. Checked at startup, so a typo can't
|
||||
/// quietly become some other threshold.
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
match crate::decimal(self.require_receipts_above.trim()) {
|
||||
Some(t) if !t.is_sign_negative() || t.is_zero() => Ok(()),
|
||||
_ => Err(format!(
|
||||
"receipt threshold {:?} must be a decimal at least zero",
|
||||
self.require_receipts_above
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The threshold: receipts are required strictly above it.
|
||||
pub fn threshold(&self) -> Decimal {
|
||||
// validate() runs at startup; an unvalidated policy (tests) that
|
||||
// fails to parse requires a receipt on everything.
|
||||
crate::decimal(self.require_receipts_above.trim()).unwrap_or(Decimal::ZERO)
|
||||
}
|
||||
|
||||
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). A
|
||||
/// draft's lines are as journaled, of any size (rules.md B-28): a total
|
||||
/// past exact arithmetic is refused (TooLargeToCompute), never a panic.
|
||||
pub fn entry_debits(entry: &JournalEntry) -> Result<Decimal, ledger_core::LedgerError> {
|
||||
entry
|
||||
.lines()
|
||||
.iter()
|
||||
.filter(|l| l.side == Side::Debit)
|
||||
.try_fold(Decimal::ZERO, |sum, l| sum.checked_add(l.amount.amount))
|
||||
.ok_or_else(|| {
|
||||
ledger_core::LedgerError::TooLargeToCompute(format!(
|
||||
"entry {}'s debits",
|
||||
entry.id()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// True when posting this entry requires at least one receipt. Debits
|
||||
/// past exact arithmetic are past any threshold.
|
||||
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();
|
||||
let debits = Self::entry_debits(entry).unwrap_or(Decimal::MAX);
|
||||
self.receipt_required_for(&refs, debits)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
if self.threshold().is_zero() {
|
||||
"receipts required on every entry".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"receipts required when entry debits are strictly greater than {}",
|
||||
self.threshold()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The receipt rule in force, phrased for the agent's operating
|
||||
/// instructions, exactly: the threshold as configured, in the books'
|
||||
/// base currency (`currency`, e.g. "USD"), never rounded; the accounts
|
||||
/// that always need a receipt and those exempt; and that reversals
|
||||
/// need none (the original carries the evidence).
|
||||
pub fn rule(&self, currency: &str) -> String {
|
||||
self.rule_listing(currency, RULE_CODES)
|
||||
}
|
||||
|
||||
/// [`Self::rule`], listing at most `codes` account codes of each kind
|
||||
/// (the rest counted, books.settings listing them); with `codes` 0,
|
||||
/// only how many accounts of each kind there are, and where they are
|
||||
/// listed: for a text with a budget.
|
||||
pub fn rule_listing(&self, currency: &str, codes: usize) -> String {
|
||||
let mut rule = if self.threshold().is_zero() {
|
||||
"every entry needs at least one receipt".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"any entry moving more than {} {} in debits needs at least one receipt",
|
||||
self.threshold(),
|
||||
plain_code(currency)
|
||||
)
|
||||
};
|
||||
let (required, exempt) = (
|
||||
self.receipt_required_accounts.len(),
|
||||
self.receipt_exempt_accounts.len(),
|
||||
);
|
||||
if codes == 0 && required + exempt > 0 {
|
||||
rule.push_str(&format!(
|
||||
"; books.settings lists {required} account(s) whose entries always need one and {exempt} whose entries need none"
|
||||
));
|
||||
} else {
|
||||
if required > 0 {
|
||||
rule.push_str(&format!(
|
||||
"; so does any entry touching {}, whatever its amount",
|
||||
accounts(&self.receipt_required_accounts, codes)
|
||||
));
|
||||
}
|
||||
if exempt > 0 {
|
||||
rule.push_str(&format!(
|
||||
"; an entry whose every line is in {} needs none{}",
|
||||
accounts(&self.receipt_exempt_accounts, codes),
|
||||
if required == 0 {
|
||||
""
|
||||
} else {
|
||||
" (unless it touches a required one)"
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
rule.push_str("; reversals need none (the original carries the evidence)");
|
||||
rule
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
// Debits past exact arithmetic are past the threshold too.
|
||||
let debits = Self::entry_debits(entry).unwrap_or(Decimal::MAX);
|
||||
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());
|
||||
// A sum past exact arithmetic is past the threshold.
|
||||
total = Self::entry_debits(other)
|
||||
.ok()
|
||||
.and_then(|d| total.checked_add(d))
|
||||
.unwrap_or(Decimal::MAX);
|
||||
}
|
||||
if !group.is_empty() && total > self.threshold() {
|
||||
group
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The most account codes [`ReceiptPolicy::rule`] lists, and the most
|
||||
/// characters of each: the rule goes into a connect text with a budget.
|
||||
const RULE_CODES: usize = 6;
|
||||
const RULE_CODE_CHARS: usize = 12;
|
||||
|
||||
/// A code as plain text for the rule: no control or format characters
|
||||
/// (a code comes from the settings file, and must not carry text that
|
||||
/// reads as the server's own), cut on a character.
|
||||
fn plain_code(code: &str) -> String {
|
||||
let plain: String = code
|
||||
.chars()
|
||||
.filter(|c| !c.is_control() && !c.is_whitespace() && !is_format_char(*c))
|
||||
.collect();
|
||||
if plain.chars().count() > RULE_CODE_CHARS {
|
||||
let cut: String = plain.chars().take(RULE_CODE_CHARS - 1).collect();
|
||||
format!("{cut}…")
|
||||
} else {
|
||||
plain
|
||||
}
|
||||
}
|
||||
|
||||
/// "account 6000", "accounts 6000, 6010", "accounts 6000, ... and 3 more
|
||||
/// (books.settings lists them)": at most `max` codes, `max` at least 1.
|
||||
fn accounts(codes: &[String], max: usize) -> String {
|
||||
let max = max.max(1);
|
||||
let shown: Vec<String> = codes.iter().take(max).map(|c| plain_code(c)).collect();
|
||||
let more = codes.len().saturating_sub(max);
|
||||
let list = shown.join(", ");
|
||||
match (codes.len(), more) {
|
||||
(1, _) => format!("account {list}"),
|
||||
(_, 0) => format!("accounts {list}"),
|
||||
_ => format!("accounts {list} and {more} more (books.settings lists them)"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `c` is a Unicode format character (general category Cf):
|
||||
/// invisible, but it reorders or joins the text around it (bidi
|
||||
/// overrides, zero-width spaces and joiners, the byte-order mark).
|
||||
/// SOURCE: Unicode 15.1, UnicodeData.txt, category Cf.
|
||||
pub fn is_format_char(c: char) -> bool {
|
||||
matches!(
|
||||
c as u32,
|
||||
0x00AD
|
||||
| 0x0600..=0x0605
|
||||
| 0x061C
|
||||
| 0x06DD
|
||||
| 0x070F
|
||||
| 0x0890..=0x0891
|
||||
| 0x08E2
|
||||
| 0x180E
|
||||
| 0x200B..=0x200F
|
||||
| 0x202A..=0x202E
|
||||
| 0x2060..=0x2064
|
||||
| 0x2066..=0x206F
|
||||
| 0xFEFF
|
||||
| 0xFFF9..=0xFFFB
|
||||
| 0x110BD
|
||||
| 0x110CD
|
||||
| 0x13430..=0x1343F
|
||||
| 0x1BCA0..=0x1BCA3
|
||||
| 0x1D173..=0x1D17A
|
||||
| 0xE0001
|
||||
| 0xE0020..=0xE007F
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod rule_tests {
|
||||
use super::*;
|
||||
|
||||
fn policy(t: &str, required: &[&str], exempt: &[&str]) -> ReceiptPolicy {
|
||||
ReceiptPolicy {
|
||||
require_receipts_above: t.to_string(),
|
||||
receipt_required_accounts: required.iter().map(|s| s.to_string()).collect(),
|
||||
receipt_exempt_accounts: exempt.iter().map(|s| s.to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The rule the agent is told is the rule enforced: the threshold
|
||||
/// exactly as configured (0.001 stays 0.001, never "$0.00"), in the
|
||||
/// base currency; the required and exempt accounts; reversals exempt.
|
||||
#[test]
|
||||
fn the_rule_told_is_the_rule_enforced() {
|
||||
let p = policy("0.001", &[], &[]);
|
||||
assert_eq!(
|
||||
p.rule("EUR"),
|
||||
"any entry moving more than 0.001 EUR in debits needs at least one receipt; \
|
||||
reversals need none (the original carries the evidence)"
|
||||
);
|
||||
// And it is the threshold receipt_required_for applies.
|
||||
assert!(!p.receipt_required_for(&["6000"], Decimal::new(1, 3)));
|
||||
assert!(p.receipt_required_for(&["6000"], Decimal::new(2, 3)));
|
||||
let p = policy("25.00", &["6000"], &["1500", "1510"]);
|
||||
let rule = p.rule("USD");
|
||||
for must in [
|
||||
"more than 25.00 USD in debits",
|
||||
"so does any entry touching account 6000, whatever its amount",
|
||||
"an entry whose every line is in accounts 1500, 1510 needs none (unless it touches a required one)",
|
||||
"reversals need none",
|
||||
] {
|
||||
assert!(rule.contains(must), "{must:?}: {rule}");
|
||||
}
|
||||
assert!(!rule.contains('$'), "{rule}");
|
||||
assert_eq!(
|
||||
policy("0.00", &[], &[]).rule("USD"),
|
||||
"every entry needs at least one receipt; reversals need none (the original carries the evidence)"
|
||||
);
|
||||
}
|
||||
|
||||
/// A long list is cut, saying books.settings lists them (it does,
|
||||
/// every code uncut, in receipt_required_accounts and
|
||||
/// receipt_exempt_accounts: ledger-mcp's
|
||||
/// every_receipt_account_left_out_of_the_connect_text_is_in_books_settings),
|
||||
/// and a code carries no control or format characters.
|
||||
#[test]
|
||||
fn a_long_or_odd_account_list_stays_short_and_plain() {
|
||||
let many: Vec<String> = (0..20).map(|i| format!("{}", 6000 + i)).collect();
|
||||
let refs: Vec<&str> = many.iter().map(String::as_str).collect();
|
||||
let rule = policy("1", &refs, &[]).rule("USD");
|
||||
assert!(
|
||||
rule.contains("6005 and 14 more (books.settings lists them)"),
|
||||
"{rule}"
|
||||
);
|
||||
assert!(!rule.contains("6006"), "{rule}");
|
||||
let rule = policy("1", &refs, &refs[..2]).rule_listing("USD", 0);
|
||||
assert!(
|
||||
rule.contains(
|
||||
"; books.settings lists 20 account(s) whose entries always need one and 2 whose entries need none;"
|
||||
),
|
||||
"{rule}"
|
||||
);
|
||||
let rule = policy("1", &["60\n00\u{202e}\u{200b}XYZ-VERY-LONG-CODE"], &[]).rule("U\nSD");
|
||||
assert!(
|
||||
!rule.chars().any(|c| c.is_control() || is_format_char(c)),
|
||||
"{rule:?}"
|
||||
);
|
||||
assert!(rule.contains("account 6000XYZ-VER…"), "{rule}");
|
||||
assert!(rule.contains("1 USD"), "{rule}");
|
||||
// A code of exactly the most characters is kept whole; one more
|
||||
// is cut.
|
||||
let twelve = "ABCDEFGHIJKL";
|
||||
assert_eq!(twelve.chars().count(), RULE_CODE_CHARS);
|
||||
let rule = policy("1", &[twelve], &[]).rule("USD");
|
||||
assert!(
|
||||
rule.contains(&format!("account {twelve}, whatever")),
|
||||
"{rule}"
|
||||
);
|
||||
let rule = policy("1", &["ABCDEFGHIJKLM"], &[]).rule("USD");
|
||||
assert!(rule.contains("account ABCDEFGHIJK…, whatever"), "{rule}");
|
||||
}
|
||||
|
||||
/// The short form (no codes) counts each kind of account, whichever
|
||||
/// kinds there are, and says nothing of accounts when there are none.
|
||||
#[test]
|
||||
fn the_short_rule_counts_each_kind_of_account() {
|
||||
for (required, exempt, counts) in [
|
||||
(&["6000"][..], &[][..], Some((1, 0))),
|
||||
(&[][..], &["1500", "1510"][..], Some((0, 2))),
|
||||
(&["6000", "6010"][..], &["1500"][..], Some((2, 1))),
|
||||
(&[][..], &[][..], None),
|
||||
] {
|
||||
let rule = policy("1", required, exempt).rule_listing("USD", 0);
|
||||
let tail = "; reversals need none (the original carries the evidence)";
|
||||
let want = match counts {
|
||||
Some((r, e)) => format!(
|
||||
"any entry moving more than 1 USD in debits needs at least one receipt; books.settings lists {r} account(s) whose entries always need one and {e} whose entries need none{tail}"
|
||||
),
|
||||
None => format!(
|
||||
"any entry moving more than 1 USD in debits needs at least one receipt{tail}"
|
||||
),
|
||||
};
|
||||
assert_eq!(rule, want);
|
||||
}
|
||||
}
|
||||
|
||||
/// A draft an earlier version journaled may hold debits exact
|
||||
/// arithmetic can't sum (rules.md B-28): its total is refused, never a
|
||||
/// panic, and for the policy it is past any threshold: a receipt is
|
||||
/// required, and it is no part of a split.
|
||||
#[test]
|
||||
fn debits_past_exact_arithmetic_are_past_any_threshold() {
|
||||
use ledger_core::{AccountType, Currency, Money, Side};
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let cash = l
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let rent = l
|
||||
.add_account("6000", "Rent", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let usd = |a: Decimal| Money::new(a, Currency::USD);
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2026, 3, 1).unwrap();
|
||||
let huge = l.create_entry(date, "huge");
|
||||
for _ in 0..2 {
|
||||
l.replay_add_line(huge, rent, Side::Debit, usd(Decimal::MAX))
|
||||
.unwrap();
|
||||
}
|
||||
let p = policy("1000000", &[], &["6000"]);
|
||||
let entry = l.get_entry(huge).unwrap().clone();
|
||||
assert!(matches!(
|
||||
ReceiptPolicy::entry_debits(&entry),
|
||||
Err(ledger_core::LedgerError::TooLargeToCompute(_))
|
||||
));
|
||||
// Exempt accounts only, yet past any threshold: still exempt.
|
||||
assert!(!p.receipt_required(&l, &entry));
|
||||
let p = policy("1000000", &[], &[]);
|
||||
assert!(p.receipt_required(&l, &entry));
|
||||
assert!(p.split_group(&l, huge, &entry).is_empty());
|
||||
// Two posted entries of the same signature, one of them huge: the
|
||||
// group's total is past the threshold.
|
||||
let mut post = |amount: Decimal| {
|
||||
let e = l.create_entry(date, "Coffee");
|
||||
l.add_line(e, rent, Side::Debit, usd(amount)).unwrap();
|
||||
l.add_line(e, cash, Side::Credit, usd(amount)).unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
e
|
||||
};
|
||||
let small = post(Decimal::new(500, 2));
|
||||
let big = post(ledger_core::kernel::MAX_LINE_AMOUNT);
|
||||
let draft = l.create_entry(date, "coffee");
|
||||
l.add_line(draft, rent, Side::Debit, usd(Decimal::new(500, 2)))
|
||||
.unwrap();
|
||||
l.add_line(draft, cash, Side::Credit, usd(Decimal::new(500, 2)))
|
||||
.unwrap();
|
||||
let entry = l.get_entry(draft).unwrap().clone();
|
||||
let p = policy("10.00", &[], &[]);
|
||||
assert_eq!(p.split_group(&l, draft, &entry), vec![small, big]);
|
||||
assert_eq!(
|
||||
ReceiptPolicy::entry_debits(&entry),
|
||||
Ok(Decimal::new(500, 2))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# Seeds for failure cases proptest has generated in the past. It is
|
||||
# automatically read and these particular cases re-run before any
|
||||
# novel cases are generated.
|
||||
#
|
||||
# It is recommended to check this file in to source control so that
|
||||
# everyone who runs the test benefits from these saved cases.
|
||||
cc 71eaf212cfff55fb9489fddf65627997660881f193be6bcbb21bc2a2add6a420 # shrinks to picks = [(44, false)]
|
||||
@@ -0,0 +1,122 @@
|
||||
//! The settings file codec over settings built at random from valid and
|
||||
//! refused changes (plan/served.md P1: toml_round_trip_zero_events).
|
||||
//!
|
||||
//! ```text
|
||||
//! INVARIANTS (crate::codec):
|
||||
//! 1. An export imports into the same books as no change at all.
|
||||
//! 2. An export imports into empty books as exactly those settings,
|
||||
//! identity values aside (exported masked: they stay unset).
|
||||
//! 3. The export reads, as a settings file, as the books do.
|
||||
//! ```
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_config::codec;
|
||||
use ledger_core::settings::{Change, Settings, Source};
|
||||
use proptest::prelude::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn src() -> Source {
|
||||
Source {
|
||||
decided_by: "Pat Rivera".into(),
|
||||
decided_on: NaiveDate::from_ymd_opt(2026, 10, 17).unwrap(),
|
||||
citation: Some("Operating agreement §4.1\nsecond line".into()),
|
||||
note: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn candidates() -> Vec<(&'static str, Value)> {
|
||||
vec![
|
||||
("company.name", json!("Acme \"Widgets\" LLC")),
|
||||
("company.name", json!("Ünïcödé Holdings")),
|
||||
("company.entity_type", json!("LLC, multi-member")),
|
||||
("company.tax_classification", json!("S corporation")),
|
||||
("company.tax_classification", json!("partnership")),
|
||||
("company.tax_form", json!("1065")),
|
||||
("company.fiscal_year_start", json!("07-01")),
|
||||
("company.timezone", json!("America/Denver")),
|
||||
("company.formation_state", json!("UT")),
|
||||
("company.formed", json!("2026-10-04")),
|
||||
("company.state_entity_number", json!("14745117-0160")),
|
||||
("company.ein", json!("43-2215893")),
|
||||
("company.irs_name_control", json!("waln")),
|
||||
("company.duns", json!("149-976-972")),
|
||||
("company.address", json!("1 Main St\nSuite 2, Draper, UT")),
|
||||
("company.registered_agent", json!("Pat Rivera")),
|
||||
("company.partnership_representative", json!("Pat Rivera")),
|
||||
("company.cpa", json!("Krista Baker")),
|
||||
(
|
||||
"company.members",
|
||||
json!([{"name": "A", "share": "50%", "capital_account": "3000",
|
||||
"distribution_account": "3100", "files_jointly_with": "B"},
|
||||
{"name": "B", "share": "0.5", "capital_account": "3001",
|
||||
"files_jointly_with": "A"}]),
|
||||
),
|
||||
(
|
||||
"company.members",
|
||||
json!([{"name": "Solo", "share": "100%", "capital_account": "3000"}]),
|
||||
),
|
||||
("books.accounting_basis", json!("cash")),
|
||||
("books.cash_accounts", json!(["1000", "1010"])),
|
||||
("books.financing_accounts", json!(["2100"])),
|
||||
("books.investing_accounts", json!(["1400"])),
|
||||
("books.close_into", json!("3000")),
|
||||
("books.close_sweep", json!(["3100"])),
|
||||
("books.asset_tag_prefix", json!("WL")),
|
||||
("policy.evidence.threshold", json!("0.00")),
|
||||
("policy.evidence.threshold", json!("25.5")),
|
||||
("policy.evidence.required_accounts", json!(["6200"])),
|
||||
("policy.evidence.exempt_accounts", json!(["6999", "6998"])),
|
||||
("policy.parties", json!("require")),
|
||||
("policy.approval_rules", json!("enforce")),
|
||||
]
|
||||
}
|
||||
|
||||
fn build(picks: &[(usize, bool)]) -> Settings {
|
||||
let pool = candidates();
|
||||
let mut s = Settings::default();
|
||||
for (seq, (i, clear)) in picks.iter().enumerate() {
|
||||
let (k, v) = &pool[i % pool.len()];
|
||||
let change = Change {
|
||||
key: (*k).into(),
|
||||
value: (!clear).then(|| v.clone()),
|
||||
};
|
||||
// Refused changes (settings that wouldn't hold together) are
|
||||
// skipped, as the books skip them.
|
||||
if let Ok(made) = s.check_changes(&[change], &src()) {
|
||||
s.apply(&made, &src(), seq as u64 + 1).unwrap();
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(512))]
|
||||
|
||||
#[test]
|
||||
fn exports_round_trip(picks in proptest::collection::vec((0usize..64, prop::bool::weighted(0.15)), 0..30)) {
|
||||
let books = build(&picks);
|
||||
let text = codec::export(&books, "USD");
|
||||
// INVARIANT 3: the export reads as a settings file.
|
||||
let file = ledger_config::parse(&text)
|
||||
.unwrap_or_else(|e| panic!("the export reads as a settings file: {e}\n{text}"));
|
||||
prop_assert_eq!(&file.company.name, &books.company("USD").name);
|
||||
// INVARIANT 1.
|
||||
let same = codec::import(&text, None, &books, "USD")
|
||||
.unwrap_or_else(|e| panic!("{e}\n{text}"));
|
||||
prop_assert!(same.changes.is_empty(), "{:?}\n{}", same.changes, text);
|
||||
prop_assert!(same.unknown.is_empty(), "{:?}", same.unknown);
|
||||
// INVARIANT 2.
|
||||
let fresh = codec::import(&text, None, &Settings::default(), "USD")
|
||||
.unwrap_or_else(|e| panic!("{e}\n{text}"));
|
||||
let mut other = Settings::default();
|
||||
other.apply(&fresh.changes, &src(), 1).unwrap();
|
||||
for (k, r) in books.iter() {
|
||||
if k.name == "company.ein" {
|
||||
prop_assert!(other.get(k.name).is_none());
|
||||
} else {
|
||||
prop_assert_eq!(other.get(k.name).map(|o| &o.value), Some(&r.value), "{}", k.name);
|
||||
}
|
||||
}
|
||||
prop_assert_eq!(other.iter().count() + usize::from(books.get("company.ein").is_some()), books.iter().count());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! The receipt policy in force when the settings come from files:
|
||||
//! config.json's when it sets one, else the settings file's threshold.
|
||||
|
||||
use ledger_config::{BooksSettings, effective_receipt_policy};
|
||||
|
||||
/// A test's dir, removed when the test ends, pass or panic.
|
||||
struct TestDir(std::path::PathBuf);
|
||||
|
||||
impl std::ops::Deref for TestDir {
|
||||
type Target = std::path::Path;
|
||||
fn deref(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn dir(tag: &str) -> TestDir {
|
||||
let d = std::env::temp_dir().join(format!("ledger-config-policy-{}-{tag}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
TestDir(d)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_json_wins_else_the_settings_file() {
|
||||
let d = dir("files");
|
||||
let json = d.join("config.json");
|
||||
let settings = BooksSettings {
|
||||
receipt_threshold: Some("0.00".into()),
|
||||
..BooksSettings::default()
|
||||
};
|
||||
// No config.json: the settings file's threshold.
|
||||
let p = effective_receipt_policy(&json, &settings).unwrap();
|
||||
assert_eq!(p.require_receipts_above, "0.00");
|
||||
// config.json's policy, whole.
|
||||
std::fs::write(
|
||||
&json,
|
||||
r#"{"receipt_policy": {"require_receipts_above": "10.00", "receipt_exempt_accounts": ["6999"]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let p = effective_receipt_policy(&json, &settings).unwrap();
|
||||
assert_eq!(p.require_receipts_above, "10.00");
|
||||
assert_eq!(p.receipt_exempt_accounts, ["6999"]);
|
||||
// A broken one is refused, by name.
|
||||
std::fs::write(&json, "{ not json").unwrap();
|
||||
let err = effective_receipt_policy(&json, &settings).unwrap_err();
|
||||
assert!(
|
||||
err.starts_with(&format!("bad config {}", json.display())),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -12,9 +12,17 @@ thiserror = "2"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# company.timezone: IANA names, with the zone database built in so every
|
||||
# computer reads a name the same way.
|
||||
jiff = { version = "0.2.35", default-features = false, features = ["std", "tz-system", "tzdb-bundle-always"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = "0.8.2"
|
||||
proptest = "1"
|
||||
|
||||
[[bench]]
|
||||
name = "books"
|
||||
harness = false
|
||||
@@ -0,0 +1,94 @@
|
||||
//! Benchmarks for the hot paths (rank 6: a budget, never correctness).
|
||||
//!
|
||||
//! `cargo bench -p ledger-core --bench books`. Books of 1k and 10k entries
|
||||
//! over a 40-account chart: posting, cloning (the store clones the ledger
|
||||
//! to rehearse every command), trial balance, balance sheet, and a
|
||||
//! period's P&L. Baselines are recorded in docs/testing.md; there is no
|
||||
//! budget yet, so nothing gates on them.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};
|
||||
use ledger_core::reports::{balance_sheet_as_of, profit_and_loss_for_period, trial_balance};
|
||||
use ledger_core::{AccountType, Currency, Ledger, Money, Side};
|
||||
use rust_decimal::Decimal;
|
||||
use std::hint::black_box;
|
||||
|
||||
const TYPES: [AccountType; 5] = [
|
||||
AccountType::Asset,
|
||||
AccountType::Liability,
|
||||
AccountType::Equity,
|
||||
AccountType::Revenue,
|
||||
AccountType::Expense,
|
||||
];
|
||||
|
||||
/// `entries` balanced two-line entries across 2026 over 40 accounts.
|
||||
fn books(entries: u64) -> Ledger {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let ids: Vec<u64> = (0..40)
|
||||
.map(|i| {
|
||||
l.add_account(
|
||||
&format!("{}", 1000 + i),
|
||||
"a",
|
||||
TYPES[i as usize % 5],
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
let mut x: u64 = 0x9e37_79b9;
|
||||
for n in 0..entries {
|
||||
x = x.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
|
||||
let date =
|
||||
NaiveDate::from_ymd_opt(2026, 1, 1).unwrap() + chrono::Days::new(n * 365 / entries);
|
||||
let e = l.create_entry(date, "bench");
|
||||
let m = Money::new(Decimal::new((x % 1_000_000) as i64 + 1, 2), Currency::USD);
|
||||
l.add_line(e, ids[(x >> 8) as usize % 40], Side::Debit, m)
|
||||
.unwrap();
|
||||
l.add_line(e, ids[(x >> 20) as usize % 40], Side::Credit, m)
|
||||
.unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
}
|
||||
l
|
||||
}
|
||||
|
||||
fn bench(c: &mut Criterion) {
|
||||
let end = NaiveDate::from_ymd_opt(2026, 12, 31).unwrap();
|
||||
for n in [1_000u64, 10_000] {
|
||||
let l = books(n);
|
||||
let mut g = c.benchmark_group(format!("{n} entries"));
|
||||
g.sample_size(20);
|
||||
g.bench_function(BenchmarkId::new("clone (store rehearsal)", n), |b| {
|
||||
b.iter(|| black_box(l.clone()))
|
||||
});
|
||||
g.bench_function(BenchmarkId::new("post one more entry", n), |b| {
|
||||
b.iter_batched(
|
||||
|| l.clone(),
|
||||
|mut l| {
|
||||
let e = l.create_entry(end, "one more");
|
||||
let m = Money::new(Decimal::new(100, 2), Currency::USD);
|
||||
l.add_line(e, 1, Side::Debit, m).unwrap();
|
||||
l.add_line(e, 2, Side::Credit, m).unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
l
|
||||
},
|
||||
criterion::BatchSize::LargeInput,
|
||||
)
|
||||
});
|
||||
g.bench_function(BenchmarkId::new("trial balance", n), |b| {
|
||||
b.iter(|| black_box(trial_balance(&l)))
|
||||
});
|
||||
g.bench_function(BenchmarkId::new("balance sheet", n), |b| {
|
||||
b.iter(|| black_box(balance_sheet_as_of(&l, end).unwrap()))
|
||||
});
|
||||
g.bench_function(BenchmarkId::new("one month's P&L", n), |b| {
|
||||
let start = NaiveDate::from_ymd_opt(2026, 6, 1).unwrap();
|
||||
let stop = NaiveDate::from_ymd_opt(2026, 6, 30).unwrap();
|
||||
b.iter(|| black_box(profit_and_loss_for_period(&l, start, stop, None).unwrap()))
|
||||
});
|
||||
g.finish();
|
||||
}
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench);
|
||||
criterion_main!(benches);
|
||||
@@ -6,6 +6,7 @@
|
||||
//! grouping on type, so getting this right is the whole game.
|
||||
|
||||
use crate::{Currency, LedgerError};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -56,7 +57,7 @@ impl AccountType {
|
||||
}
|
||||
|
||||
/// A single account in the chart of accounts.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Account {
|
||||
/// Opaque handle.
|
||||
pub id: AccountId,
|
||||
@@ -72,10 +73,18 @@ pub struct Account {
|
||||
pub placeholder: bool,
|
||||
/// Day one: always the ledger's base currency (see R-5).
|
||||
pub currency: Currency,
|
||||
/// Tax category key (see [`crate::tax`]), for revenue and expense
|
||||
/// accounts.
|
||||
#[serde(default)]
|
||||
pub tax_category: Option<String>,
|
||||
/// Percent deductible, overriding the category's default (e.g. 50 for
|
||||
/// meals, 0 for penalties).
|
||||
#[serde(default)]
|
||||
pub deductible_percent: Option<Decimal>,
|
||||
}
|
||||
|
||||
/// The registry of accounts. Owns id allocation for accounts it holds.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ChartOfAccounts {
|
||||
accounts: HashMap<AccountId, Account>,
|
||||
by_code: HashMap<String, AccountId>,
|
||||
@@ -116,6 +125,40 @@ impl ChartOfAccounts {
|
||||
self.accounts.values()
|
||||
}
|
||||
|
||||
/// Give an account a new code. The code must be non-empty and free.
|
||||
pub fn set_code(&mut self, id: AccountId, code: &str) -> Result<(), LedgerError> {
|
||||
if code.trim().is_empty() {
|
||||
return Err(LedgerError::EmptyAccountCode);
|
||||
}
|
||||
if self.by_code.contains_key(code) {
|
||||
return Err(LedgerError::DuplicateAccountCode(code.to_string()));
|
||||
}
|
||||
let a = self
|
||||
.accounts
|
||||
.get_mut(&id)
|
||||
.ok_or(LedgerError::UnknownAccount(id))?;
|
||||
self.by_code.remove(&a.code);
|
||||
a.code = code.to_string();
|
||||
self.by_code.insert(a.code.clone(), id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set (or clear) an account's tax category and deductible percent.
|
||||
pub fn set_tax(
|
||||
&mut self,
|
||||
id: AccountId,
|
||||
category: Option<String>,
|
||||
deductible_percent: Option<Decimal>,
|
||||
) -> Result<(), LedgerError> {
|
||||
let a = self
|
||||
.accounts
|
||||
.get_mut(&id)
|
||||
.ok_or(LedgerError::UnknownAccount(id))?;
|
||||
a.tax_category = category;
|
||||
a.deductible_percent = deductible_percent;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Number of accounts in the chart.
|
||||
pub fn len(&self) -> usize {
|
||||
self.accounts.len()
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
//! Time series over the books, for dashboards and interval-column reports.
|
||||
//!
|
||||
//! Statements answer "what was true for this range"; these answer "how did
|
||||
//! it change": a range split into months, quarters, or years; balances of a
|
||||
//! set of accounts on a series of dates; profit and loss per interval. They
|
||||
//! are built from the same posted entries and statements as everything
|
||||
//! else, so a number on a chart always ties to a number on a statement.
|
||||
|
||||
use crate::reports::profit_and_loss_for_period;
|
||||
use crate::{AccountId, AccountType, Ledger, LedgerError, Side};
|
||||
use chrono::{Datelike, Days, Months, NaiveDate};
|
||||
use rust_decimal::Decimal;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// How to split a date range into columns.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Interval {
|
||||
/// Calendar months.
|
||||
Month,
|
||||
/// Calendar quarters.
|
||||
Quarter,
|
||||
/// Calendar years.
|
||||
Year,
|
||||
}
|
||||
|
||||
impl Interval {
|
||||
fn months(self) -> u32 {
|
||||
match self {
|
||||
Interval::Month => 1,
|
||||
Interval::Quarter => 3,
|
||||
Interval::Year => 12,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The first day of the calendar interval containing `d`.
|
||||
pub fn interval_start(d: NaiveDate, interval: Interval) -> NaiveDate {
|
||||
let month0 = (d.month0() / interval.months()) * interval.months();
|
||||
// The first of a month of `d`'s own year: every year chrono holds has
|
||||
// all twelve.
|
||||
NaiveDate::from_ymd_opt(d.year(), month0 + 1, 1).expect("valid")
|
||||
}
|
||||
|
||||
/// `[start, end]` split on calendar interval boundaries, first and last
|
||||
/// pieces clipped to the range. Empty when `end < start`. Never panics:
|
||||
/// an interval that would end past chrono's last day ends at `end`
|
||||
/// (rules.md B-28: books an earlier version wrote may hold such dates).
|
||||
pub fn intervals(
|
||||
start: NaiveDate,
|
||||
end: NaiveDate,
|
||||
interval: Interval,
|
||||
) -> Vec<(NaiveDate, NaiveDate)> {
|
||||
let mut out = Vec::new();
|
||||
let mut from = interval_start(start, interval);
|
||||
while from <= end {
|
||||
let Some(next) = from.checked_add_months(Months::new(interval.months())) else {
|
||||
// The interval holding chrono's last day: nothing after it.
|
||||
out.push((from.max(start), end));
|
||||
break;
|
||||
};
|
||||
let to = (next - Days::new(1)).min(end);
|
||||
out.push((from.max(start), to));
|
||||
from = next;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The combined normal-side balance of `accounts` at the end of each date
|
||||
/// in `dates` (which must be ascending). Assets and expenses count debits
|
||||
/// as positive; liabilities, equity, and revenue count credits. Mixing
|
||||
/// types is allowed but rarely meaningful.
|
||||
pub fn balance_series(
|
||||
ledger: &Ledger,
|
||||
accounts: &[AccountId],
|
||||
dates: &[NaiveDate],
|
||||
) -> Vec<Decimal> {
|
||||
let set: HashSet<AccountId> = accounts.iter().copied().collect();
|
||||
let mut postings: Vec<(NaiveDate, Decimal)> = Vec::new();
|
||||
for entry in ledger.posted_entries() {
|
||||
for line in entry.lines() {
|
||||
if !set.contains(&line.account) {
|
||||
continue;
|
||||
}
|
||||
let Some(account) = ledger.chart().get(line.account) else {
|
||||
continue;
|
||||
};
|
||||
let signed = if line.side == account.account_type.normal_side() {
|
||||
line.amount.amount
|
||||
} else {
|
||||
-line.amount.amount
|
||||
};
|
||||
postings.push((entry.date(), signed));
|
||||
}
|
||||
}
|
||||
postings.sort_by_key(|(d, _)| *d);
|
||||
let mut out = Vec::with_capacity(dates.len());
|
||||
let mut running = Decimal::ZERO;
|
||||
let mut i = 0;
|
||||
for date in dates {
|
||||
while i < postings.len() && postings[i].0 <= *date {
|
||||
running += postings[i].1;
|
||||
i += 1;
|
||||
}
|
||||
out.push(running);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Every date `accounts` moved, with the combined balance at the end of
|
||||
/// that day: the exact shape of a balance-over-time chart.
|
||||
pub fn balance_history(ledger: &Ledger, accounts: &[AccountId]) -> Vec<(NaiveDate, Decimal)> {
|
||||
let set: HashSet<AccountId> = accounts.iter().copied().collect();
|
||||
let mut dates: Vec<NaiveDate> = ledger
|
||||
.posted_entries()
|
||||
.filter(|e| e.lines().iter().any(|l| set.contains(&l.account)))
|
||||
.map(|e| e.date())
|
||||
.collect();
|
||||
dates.sort();
|
||||
dates.dedup();
|
||||
let balances = balance_series(ledger, accounts, &dates);
|
||||
dates.into_iter().zip(balances).collect()
|
||||
}
|
||||
|
||||
/// One interval's profit and loss.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct IntervalPnl {
|
||||
/// First day.
|
||||
pub start: NaiveDate,
|
||||
/// Last day.
|
||||
pub end: NaiveDate,
|
||||
/// Total revenue.
|
||||
pub revenue: Decimal,
|
||||
/// Total expenses.
|
||||
pub expenses: Decimal,
|
||||
}
|
||||
|
||||
impl IntervalPnl {
|
||||
/// Revenue minus expenses.
|
||||
pub fn net(&self) -> Decimal {
|
||||
self.revenue - self.expenses
|
||||
}
|
||||
}
|
||||
|
||||
/// Profit and loss per interval across `[start, end]` (closing entries
|
||||
/// excluded, as on every period statement).
|
||||
pub fn pnl_by_interval(
|
||||
ledger: &Ledger,
|
||||
start: NaiveDate,
|
||||
end: NaiveDate,
|
||||
interval: Interval,
|
||||
) -> Result<Vec<IntervalPnl>, LedgerError> {
|
||||
intervals(start, end, interval)
|
||||
.into_iter()
|
||||
.map(|(s, e)| {
|
||||
let pl = profit_and_loss_for_period(ledger, s, e, None)?;
|
||||
Ok(IntervalPnl {
|
||||
start: s,
|
||||
end: e,
|
||||
revenue: pl.total_revenue,
|
||||
expenses: pl.total_expenses,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Money in and out of `accounts` per interval: (inflows, outflows), each
|
||||
/// positive. Each entry counts by its net debit (in) or credit (out) to the
|
||||
/// accounts together: for cash, deposits and payments; a transfer between
|
||||
/// two of them (checking to savings) is neither; with a credit card in the
|
||||
/// set, a charge is money out and paying the card moves nothing.
|
||||
pub fn flows_by_interval(
|
||||
ledger: &Ledger,
|
||||
accounts: &[AccountId],
|
||||
start: NaiveDate,
|
||||
end: NaiveDate,
|
||||
interval: Interval,
|
||||
) -> Vec<(NaiveDate, NaiveDate, Decimal, Decimal)> {
|
||||
let set: HashSet<AccountId> = accounts.iter().copied().collect();
|
||||
let mut out: Vec<(NaiveDate, NaiveDate, Decimal, Decimal)> = intervals(start, end, interval)
|
||||
.into_iter()
|
||||
.map(|(s, e)| (s, e, Decimal::ZERO, Decimal::ZERO))
|
||||
.collect();
|
||||
for entry in ledger.posted_entries() {
|
||||
let d = entry.date();
|
||||
let Some(slot) = out.iter_mut().find(|(s, e, _, _)| *s <= d && d <= *e) else {
|
||||
continue;
|
||||
};
|
||||
// Debits in, credits out, for every account in the set: cash
|
||||
// received is a debit to cash, and a charge on a card in the set is
|
||||
// a credit to the card.
|
||||
let mut net = Decimal::ZERO;
|
||||
for line in entry.lines().iter().filter(|l| set.contains(&l.account)) {
|
||||
match line.side {
|
||||
Side::Debit => net += line.amount.amount,
|
||||
Side::Credit => net -= line.amount.amount,
|
||||
}
|
||||
}
|
||||
if net > Decimal::ZERO {
|
||||
slot.2 += net;
|
||||
} else {
|
||||
slot.3 -= net;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Accounts of the given types, in chart order.
|
||||
pub fn accounts_of_type(ledger: &Ledger, types: &[AccountType]) -> Vec<AccountId> {
|
||||
ledger
|
||||
.chart()
|
||||
.all()
|
||||
.filter(|a| types.contains(&a.account_type))
|
||||
.map(|a| a.id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intervals_clip_to_the_range() {
|
||||
assert_eq!(
|
||||
intervals(d(2026, 1, 15), d(2026, 3, 10), Interval::Month),
|
||||
[
|
||||
(d(2026, 1, 15), d(2026, 1, 31)),
|
||||
(d(2026, 2, 1), d(2026, 2, 28)),
|
||||
(d(2026, 3, 1), d(2026, 3, 10)),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
intervals(d(2026, 2, 1), d(2026, 12, 31), Interval::Quarter),
|
||||
[
|
||||
(d(2026, 2, 1), d(2026, 3, 31)),
|
||||
(d(2026, 4, 1), d(2026, 6, 30)),
|
||||
(d(2026, 7, 1), d(2026, 9, 30)),
|
||||
(d(2026, 10, 1), d(2026, 12, 31)),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
intervals(d(2025, 6, 1), d(2026, 2, 1), Interval::Year),
|
||||
[
|
||||
(d(2025, 6, 1), d(2025, 12, 31)),
|
||||
(d(2026, 1, 1), d(2026, 2, 1)),
|
||||
]
|
||||
);
|
||||
assert!(intervals(d(2026, 2, 1), d(2026, 1, 1), Interval::Month).is_empty());
|
||||
}
|
||||
|
||||
/// At chrono's last day (books an earlier version wrote may hold it):
|
||||
/// the last interval ends there, never a panic (rules.md B-28).
|
||||
#[test]
|
||||
fn intervals_reach_chronos_last_day() {
|
||||
let y = NaiveDate::MAX.year();
|
||||
assert_eq!(
|
||||
intervals(d(y, 11, 20), NaiveDate::MAX, Interval::Month),
|
||||
[(d(y, 11, 20), d(y, 11, 30)), (d(y, 12, 1), NaiveDate::MAX)]
|
||||
);
|
||||
assert_eq!(
|
||||
intervals(d(y, 2, 2), NaiveDate::MAX, Interval::Year),
|
||||
[(d(y, 2, 2), NaiveDate::MAX)]
|
||||
);
|
||||
assert_eq!(
|
||||
intervals(d(y, 12, 31), NaiveDate::MAX, Interval::Quarter),
|
||||
[(NaiveDate::MAX, NaiveDate::MAX)]
|
||||
);
|
||||
assert_eq!(
|
||||
intervals(
|
||||
NaiveDate::MIN,
|
||||
d(NaiveDate::MIN.year(), 2, 3),
|
||||
Interval::Month
|
||||
),
|
||||
[
|
||||
(NaiveDate::MIN, d(NaiveDate::MIN.year(), 1, 31)),
|
||||
(
|
||||
d(NaiveDate::MIN.year(), 2, 1),
|
||||
d(NaiveDate::MIN.year(), 2, 3)
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
+171
-25
@@ -27,7 +27,7 @@ pub type InvoiceId = u64;
|
||||
pub type BillId = u64;
|
||||
|
||||
/// A customer: someone we invoice.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Customer {
|
||||
/// Opaque handle.
|
||||
pub id: CustomerId,
|
||||
@@ -36,12 +36,20 @@ pub struct Customer {
|
||||
}
|
||||
|
||||
/// A vendor: someone who bills us.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Vendor {
|
||||
/// Opaque handle.
|
||||
pub id: VendorId,
|
||||
/// Display name.
|
||||
pub name: String,
|
||||
/// The information return this vendor gets, if any (see
|
||||
/// [`crate::form1099`]).
|
||||
#[serde(default)]
|
||||
pub form_1099: Option<crate::form1099::Form1099>,
|
||||
/// A W-9 (taxpayer identification number) is on file. The number
|
||||
/// itself is never stored here.
|
||||
#[serde(default)]
|
||||
pub tin_on_file: bool,
|
||||
}
|
||||
|
||||
/// Payment status of an invoice or bill.
|
||||
@@ -56,7 +64,7 @@ pub enum DocumentStatus {
|
||||
}
|
||||
|
||||
/// One line on an invoice or bill.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct DocumentLine {
|
||||
/// Description of the goods/services.
|
||||
pub description: String,
|
||||
@@ -65,7 +73,7 @@ pub struct DocumentLine {
|
||||
}
|
||||
|
||||
/// A sales invoice: we billed a customer.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Invoice {
|
||||
/// Opaque handle.
|
||||
pub id: InvoiceId,
|
||||
@@ -85,20 +93,42 @@ pub struct Invoice {
|
||||
pub entry_id: u64,
|
||||
}
|
||||
|
||||
/// The sum of a document's lines; refused (TooLargeToCompute), never a
|
||||
/// panic, past exact arithmetic. A document is restored from its journal
|
||||
/// line as written (rules.md B-28), so its lines are bounded only by what
|
||||
/// the version that wrote it checked.
|
||||
pub fn document_total(lines: &[DocumentLine], what: &str) -> Result<Decimal, LedgerError> {
|
||||
lines
|
||||
.iter()
|
||||
.try_fold(Decimal::ZERO, |sum, l| sum.checked_add(l.amount))
|
||||
.ok_or_else(|| LedgerError::TooLargeToCompute(format!("{what}'s total")))
|
||||
}
|
||||
|
||||
/// What is still owed on a document: its total less what was paid.
|
||||
fn document_balance(total: Decimal, paid: Decimal, what: &str) -> Result<Decimal, LedgerError> {
|
||||
total
|
||||
.checked_sub(paid)
|
||||
.ok_or_else(|| LedgerError::TooLargeToCompute(format!("{what}'s balance")))
|
||||
}
|
||||
|
||||
impl Invoice {
|
||||
/// Total of all lines.
|
||||
pub fn total(&self) -> Decimal {
|
||||
self.lines.iter().map(|l| l.amount).sum()
|
||||
/// Total of all lines ([`document_total`]).
|
||||
pub fn total(&self) -> Result<Decimal, LedgerError> {
|
||||
document_total(&self.lines, &format!("invoice {}", self.id))
|
||||
}
|
||||
|
||||
/// Amount still owed.
|
||||
pub fn balance(&self) -> Decimal {
|
||||
self.total() - self.amount_paid
|
||||
pub fn balance(&self) -> Result<Decimal, LedgerError> {
|
||||
document_balance(
|
||||
self.total()?,
|
||||
self.amount_paid,
|
||||
&format!("invoice {}", self.id),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A vendor bill: we owe a vendor.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Bill {
|
||||
/// Opaque handle.
|
||||
pub id: BillId,
|
||||
@@ -119,18 +149,24 @@ pub struct Bill {
|
||||
}
|
||||
|
||||
impl Bill {
|
||||
/// Total of all lines.
|
||||
pub fn total(&self) -> Decimal {
|
||||
self.lines.iter().map(|l| l.amount).sum()
|
||||
/// Total of all lines ([`document_total`]).
|
||||
pub fn total(&self) -> Result<Decimal, LedgerError> {
|
||||
document_total(&self.lines, &format!("bill {}", self.id))
|
||||
}
|
||||
|
||||
/// Amount still owed.
|
||||
pub fn balance(&self) -> Decimal {
|
||||
self.total() - self.amount_paid
|
||||
pub fn balance(&self) -> Result<Decimal, LedgerError> {
|
||||
document_balance(
|
||||
self.total()?,
|
||||
self.amount_paid,
|
||||
&format!("bill {}", self.id),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate document lines: at least one, all positive amounts.
|
||||
/// Validate document lines: at least one, all positive amounts, each in a
|
||||
/// journal line's range. For new documents; replay restores them as
|
||||
/// journaled (rules.md B-28).
|
||||
pub fn validate_lines(lines: &[DocumentLine]) -> Result<(), LedgerError> {
|
||||
if lines.is_empty() {
|
||||
return Err(LedgerError::EmptyDocumentLines);
|
||||
@@ -139,12 +175,24 @@ pub fn validate_lines(lines: &[DocumentLine]) -> Result<(), LedgerError> {
|
||||
if line.amount <= Decimal::ZERO {
|
||||
return Err(LedgerError::InvalidDocumentLineAmount(line.amount));
|
||||
}
|
||||
// No larger than a journal line may be (kernel::MAX_LINE_AMOUNT):
|
||||
// the document's total is summed from them (P0 review round 3, S1).
|
||||
if !crate::kernel::Amount::in_line_range(line.amount) {
|
||||
return Err(LedgerError::ValueOutOfRange(format!(
|
||||
"document line amount {}",
|
||||
line.amount
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate that due date is not before the document date.
|
||||
/// Validate that due date is not before the document date, and both are
|
||||
/// in the calendar the books keep (P0 review round 3, S1). For new
|
||||
/// documents; replay restores them as journaled (rules.md B-28).
|
||||
pub fn validate_due_date(date: NaiveDate, due_date: NaiveDate) -> Result<(), LedgerError> {
|
||||
crate::calendar::check(date)?;
|
||||
crate::calendar::check(due_date)?;
|
||||
if due_date < date {
|
||||
return Err(LedgerError::DueBeforeDate(due_date.to_string()));
|
||||
}
|
||||
@@ -180,13 +228,59 @@ pub struct AgingRow {
|
||||
}
|
||||
|
||||
impl AgingRow {
|
||||
/// Total outstanding.
|
||||
pub fn total(&self) -> Decimal {
|
||||
self.current + self.days_30 + self.days_60 + self.days_90 + self.days_90_plus
|
||||
/// Total outstanding; refused past exact arithmetic.
|
||||
pub fn total(&self) -> Result<Decimal, LedgerError> {
|
||||
[self.days_30, self.days_60, self.days_90, self.days_90_plus]
|
||||
.into_iter()
|
||||
.try_fold(self.current, Decimal::checked_add)
|
||||
.ok_or_else(|| LedgerError::TooLargeToCompute(format!("{}'s aging total", self.name)))
|
||||
}
|
||||
|
||||
/// Add `other`'s buckets to this row's; refused past exact arithmetic.
|
||||
pub(crate) fn add(&mut self, other: &AgingRow) -> Result<(), LedgerError> {
|
||||
let too_large = || LedgerError::TooLargeToCompute(format!("{}'s aging", self.name));
|
||||
let sum = |a: Decimal, b: Decimal| a.checked_add(b).ok_or_else(too_large);
|
||||
let (current, days_30, days_60, days_90, days_90_plus) = (
|
||||
sum(self.current, other.current)?,
|
||||
sum(self.days_30, other.days_30)?,
|
||||
sum(self.days_60, other.days_60)?,
|
||||
sum(self.days_90, other.days_90)?,
|
||||
sum(self.days_90_plus, other.days_90_plus)?,
|
||||
);
|
||||
(
|
||||
self.current,
|
||||
self.days_30,
|
||||
self.days_60,
|
||||
self.days_90,
|
||||
self.days_90_plus,
|
||||
) = (current, days_30, days_60, days_90, days_90_plus);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Bucket a past-due balance by days overdue.
|
||||
/// Put an open balance in its aging bucket.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: say how overdue a balance is, as of a date.
|
||||
/// SOURCE: docs/spec/rules.md B-18 (bucket edges: CONVENTION, ASC
|
||||
/// 326-20-55-38 illustrative; due today is Current: DECISION,
|
||||
/// Stephen 2026-10-13).
|
||||
/// INVARIANTS:
|
||||
/// 1. MUST put the whole balance in exactly one bucket; the others are
|
||||
/// zero.
|
||||
/// 2. NEVER moves a balance to a younger bucket as `as_of` advances.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF `as_of` is on or before the due date:
|
||||
/// - SHOULD be Current
|
||||
/// - NEVER past due
|
||||
/// BECAUSE a document due today is not yet overdue (QuickBooks'
|
||||
/// convention; Stephen, 2026-10-13).
|
||||
/// 2. IF it is 1 to 30 days past due: SHOULD be 1-30.
|
||||
/// 3. IF 31 to 60: SHOULD be 31-60.
|
||||
/// 4. IF 61 to 90: SHOULD be 61-90.
|
||||
/// 5. IF more than 90: SHOULD be 90+.
|
||||
/// BECAUSE these are the standard 30-day aging buckets.
|
||||
/// ```
|
||||
pub fn aging_bucket(due_date: NaiveDate, as_of: NaiveDate, balance: Decimal) -> AgingRow {
|
||||
let mut row = AgingRow {
|
||||
name: String::new(),
|
||||
@@ -196,7 +290,7 @@ pub fn aging_bucket(due_date: NaiveDate, as_of: NaiveDate, balance: Decimal) ->
|
||||
days_90: Decimal::ZERO,
|
||||
days_90_plus: Decimal::ZERO,
|
||||
};
|
||||
if due_date > as_of {
|
||||
if due_date >= as_of {
|
||||
row.current = balance;
|
||||
} else {
|
||||
let days = (as_of - due_date).num_days();
|
||||
@@ -241,7 +335,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
let invoice = l.get_invoice(inv).unwrap();
|
||||
assert_eq!(invoice.total(), "1000.00".parse().unwrap());
|
||||
assert_eq!(invoice.total().unwrap(), "1000.00".parse().unwrap());
|
||||
assert_eq!(invoice.status, DocumentStatus::Open);
|
||||
|
||||
// A/R has a debit balance of 1000.
|
||||
@@ -259,7 +353,7 @@ mod tests {
|
||||
.unwrap();
|
||||
let invoice = l.get_invoice(inv).unwrap();
|
||||
assert_eq!(invoice.status, DocumentStatus::Partial);
|
||||
assert_eq!(invoice.balance(), "600.00".parse().unwrap());
|
||||
assert_eq!(invoice.balance().unwrap(), "600.00".parse().unwrap());
|
||||
|
||||
// Full payment.
|
||||
l.receive_payment(
|
||||
@@ -340,6 +434,58 @@ mod tests {
|
||||
assert!(matches!(err, Err(LedgerError::Overpayment(_, _))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aging_boundaries() {
|
||||
// Spec scenarios 1-5, at every edge.
|
||||
let due = NaiveDate::from_ymd_opt(2026, 3, 31).unwrap();
|
||||
let b = Decimal::from(7);
|
||||
let bucket = |days: i64| {
|
||||
let r = aging_bucket(due, due + chrono::Duration::days(days), b);
|
||||
[r.current, r.days_30, r.days_60, r.days_90, r.days_90_plus]
|
||||
.iter()
|
||||
.position(|x| !x.is_zero())
|
||||
.unwrap()
|
||||
};
|
||||
for (days, want) in [
|
||||
(-10, 0),
|
||||
(0, 0),
|
||||
(1, 1),
|
||||
(30, 1),
|
||||
(31, 2),
|
||||
(60, 2),
|
||||
(61, 3),
|
||||
(90, 3),
|
||||
(91, 4),
|
||||
(4000, 4),
|
||||
] {
|
||||
assert_eq!(bucket(days), want, "{days} days past due");
|
||||
}
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
/// Invariants 1 and 2: one bucket holds the whole balance, and
|
||||
/// time only ever ages it.
|
||||
#[test]
|
||||
fn aging_is_exclusive_and_only_ages(
|
||||
due_off in 0i64..2000, a in 0i64..2000, step in 0i64..400, cents in 1i64..10_000_000,
|
||||
) {
|
||||
let base = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
|
||||
let due = base + chrono::Duration::days(due_off);
|
||||
let b = Decimal::new(cents, 2);
|
||||
let idx = |as_of: NaiveDate| {
|
||||
let r = aging_bucket(due, as_of, b);
|
||||
let cells = [r.current, r.days_30, r.days_60, r.days_90, r.days_90_plus];
|
||||
let held: Vec<usize> = (0..5).filter(|&i| !cells[i].is_zero()).collect();
|
||||
proptest::prop_assert_eq!(held.len(), 1);
|
||||
proptest::prop_assert_eq!(cells[held[0]], b);
|
||||
Ok(held[0])
|
||||
};
|
||||
let early = base + chrono::Duration::days(a);
|
||||
let later = early + chrono::Duration::days(step);
|
||||
proptest::prop_assert!(idx(early)? <= idx(later)?);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ar_aging_buckets_correctly() {
|
||||
let mut l = arap_ledger();
|
||||
@@ -367,7 +513,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
let as_of = NaiveDate::from_ymd_opt(2026, 10, 1).unwrap();
|
||||
let aging = l.ar_aging(as_of);
|
||||
let aging = l.ar_aging(as_of).unwrap();
|
||||
assert_eq!(aging.len(), 1);
|
||||
assert_eq!(aging[0].current, "100".parse().unwrap());
|
||||
assert_eq!(aging[0].days_60, "200".parse().unwrap());
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! 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
|
||||
//! The register is master data owned by [`Ledger`](crate::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
|
||||
@@ -68,6 +68,223 @@ pub struct Asset {
|
||||
pub expense_account: AccountId,
|
||||
/// Lifecycle state.
|
||||
pub status: AssetStatus,
|
||||
/// The label on the thing itself, e.g. "WL-0001" (see
|
||||
/// [`check_asset_tag`]); `None` until one is set.
|
||||
#[serde(default)]
|
||||
pub tag: Option<String>,
|
||||
/// The manufacturer's serial number, if recorded.
|
||||
#[serde(default)]
|
||||
pub serial: Option<String>,
|
||||
/// The posted entry that put it on the books (its purchase, or a
|
||||
/// member's contribution of it), if linked
|
||||
/// ([`crate::Ledger::set_asset_acquisition`]).
|
||||
#[serde(default)]
|
||||
pub acquired_by: Option<crate::EntryId>,
|
||||
/// Its federal tax basis, if recorded ([`crate::Ledger::set_asset_tax_basis`]).
|
||||
/// Book cost is [`Asset::cost`]; tax basis can differ (a member's
|
||||
/// contribution carries the member's basis, not its market value).
|
||||
#[serde(default)]
|
||||
pub tax: Option<TaxBasis>,
|
||||
}
|
||||
|
||||
/// Corrected economics for a registered asset ([`crate::Ledger::correct_asset`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct AssetCorrection {
|
||||
/// Capitalized cost.
|
||||
pub cost: Money,
|
||||
/// Residual value.
|
||||
pub salvage_value: Money,
|
||||
/// Useful life in months.
|
||||
pub useful_life_months: u32,
|
||||
/// In-service date (depreciation starts here).
|
||||
pub purchase_date: NaiveDate,
|
||||
}
|
||||
|
||||
/// Which rule sets an asset's federal tax basis.
|
||||
///
|
||||
/// ```text
|
||||
/// SOURCE: docs/spec/sources/irs-partnership-basis.md: IRC §1012(a) (cost),
|
||||
/// IRC §723 (property a partner contributes keeps the partner's
|
||||
/// adjusted basis), Treas. Reg. §1.168(i)-4(b) (property converted
|
||||
/// from personal use depreciates from the lesser of its fair market
|
||||
/// value and its adjusted basis), IRC §704(c)(1)(C) (built-in loss).
|
||||
/// ```
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TaxBasisRule {
|
||||
/// Bought by the company: its cost (IRC §1012(a)), the book cost.
|
||||
Cost,
|
||||
/// Contributed by a member from business use: the member's adjusted
|
||||
/// basis carries over (IRC §723).
|
||||
Carryover,
|
||||
/// Contributed by a member from personal use: depreciable basis is the
|
||||
/// lesser of fair market value (the book cost) and the member's
|
||||
/// adjusted basis (Treas. Reg. §1.168(i)-4(b), with IRC §723).
|
||||
PersonalUseConversion,
|
||||
}
|
||||
|
||||
impl TaxBasisRule {
|
||||
/// Its wire name: `cost`, `carryover`, `personal_use_conversion`.
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
TaxBasisRule::Cost => "cost",
|
||||
TaxBasisRule::Carryover => "carryover",
|
||||
TaxBasisRule::PersonalUseConversion => "personal_use_conversion",
|
||||
}
|
||||
}
|
||||
|
||||
/// The rule named `key`.
|
||||
pub fn parse(key: &str) -> Option<Self> {
|
||||
[Self::Cost, Self::Carryover, Self::PersonalUseConversion]
|
||||
.into_iter()
|
||||
.find(|r| r.key() == key)
|
||||
}
|
||||
}
|
||||
|
||||
/// An asset's federal tax basis: the rule and, for a member's
|
||||
/// contribution, the member's adjusted basis at the time.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TaxBasis {
|
||||
/// The rule.
|
||||
pub rule: TaxBasisRule,
|
||||
/// The contributing member's adjusted basis at contribution (cost less
|
||||
/// any depreciation they took). `None` for [`TaxBasisRule::Cost`].
|
||||
pub contributor_basis: Option<Decimal>,
|
||||
}
|
||||
|
||||
impl TaxBasis {
|
||||
/// Whether the record fits its rule: Cost takes no contributor basis;
|
||||
/// Carryover and PersonalUseConversion require a non-negative one in
|
||||
/// cents at most, and no larger than a cost may be
|
||||
/// (kernel::MAX_LINE_AMOUNT: the register sums them; P0 review round 3,
|
||||
/// S1). Returns why not. Needs no asset, so a caller can check before
|
||||
/// registering one. Replay checks only [`Self::check_journaled`].
|
||||
pub fn check(&self) -> Result<(), String> {
|
||||
self.check_journaled()?;
|
||||
match self.contributor_basis {
|
||||
Some(b) if b > crate::kernel::MAX_LINE_AMOUNT => {
|
||||
Err("a basis is at most one quadrillion".into())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The checks every journaled `asset.tax_basis_set` passed when it was
|
||||
/// written (the rule through 2026-10-16): [`Self::check`] without the
|
||||
/// upper bound. Replay applies exactly these (rules.md B-28).
|
||||
pub fn check_journaled(&self) -> Result<(), String> {
|
||||
use TaxBasisRule::*;
|
||||
match (self.rule, self.contributor_basis) {
|
||||
(Cost, Some(_)) => {
|
||||
Err("a purchase's tax basis is its cost; give no contributor basis".into())
|
||||
}
|
||||
(Carryover | PersonalUseConversion, None) => {
|
||||
Err("a contribution needs the contributing member's adjusted basis".into())
|
||||
}
|
||||
(_, Some(b)) if b.is_sign_negative() && !b.is_zero() => {
|
||||
Err("a basis can't be negative".into())
|
||||
}
|
||||
(_, Some(b)) if b.normalize().scale() > 2 => Err("a basis is in cents at most".into()),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Asset {
|
||||
/// Its depreciable federal tax basis, by its rule, or `None` if not
|
||||
/// recorded.
|
||||
///
|
||||
/// ```text
|
||||
/// INVARIANTS:
|
||||
/// 1. Cost: the book cost.
|
||||
/// 2. Carryover: the contributor's adjusted basis.
|
||||
/// 3. PersonalUseConversion: min(book cost, contributor's basis); the
|
||||
/// book cost of a contribution is its fair market value.
|
||||
/// ```
|
||||
pub fn tax_basis(&self) -> Option<Decimal> {
|
||||
let t = self.tax?;
|
||||
Some(match (t.rule, t.contributor_basis) {
|
||||
(TaxBasisRule::Cost, _) => self.cost.amount,
|
||||
(TaxBasisRule::Carryover, Some(b)) => b,
|
||||
(TaxBasisRule::PersonalUseConversion, Some(b)) => b.min(self.cost.amount),
|
||||
(_, None) => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Built-in gain (positive) or loss (negative) at contribution, for
|
||||
/// IRC §704(c): fair market value (the book cost) less the contributor's
|
||||
/// adjusted basis. `None` for a purchase or when not recorded. A
|
||||
/// built-in loss counts only for the contributing member
|
||||
/// (§704(c)(1)(C)).
|
||||
pub fn built_in_gain(&self) -> Option<Decimal> {
|
||||
let b = self.tax?.contributor_basis?;
|
||||
Some(self.cost.amount - b)
|
||||
}
|
||||
}
|
||||
|
||||
/// The longest asset tag.
|
||||
pub const MAX_ASSET_TAG_LEN: usize = 32;
|
||||
/// The longest serial number.
|
||||
pub const MAX_SERIAL_LEN: usize = 64;
|
||||
|
||||
/// Check an asset tag's form: 1 to [`MAX_ASSET_TAG_LEN`] characters, each
|
||||
/// an ASCII letter or digit or one of `-` `_` `.` `/`, at least one of
|
||||
/// them a letter or digit. Returns why not.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: a tag prints on a small label, reads aloud, scans from a QR code,
|
||||
/// and types back in exactly.
|
||||
/// SOURCE: DECISION (Stephen, 2026-10-15): generated tags ("WL-0001") or
|
||||
/// the human's own.
|
||||
/// INVARIANTS:
|
||||
/// 1. Accepted tags contain no spaces, no control or non-ASCII
|
||||
/// characters, and at least one letter or digit.
|
||||
/// ```
|
||||
pub fn check_asset_tag(tag: &str) -> Result<(), String> {
|
||||
if tag.is_empty() {
|
||||
return Err("a tag can't be empty".into());
|
||||
}
|
||||
if tag.len() > MAX_ASSET_TAG_LEN {
|
||||
return Err(format!("a tag is at most {MAX_ASSET_TAG_LEN} characters"));
|
||||
}
|
||||
if let Some(c) = tag
|
||||
.chars()
|
||||
.find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/')))
|
||||
{
|
||||
return Err(format!(
|
||||
"{c:?} can't be in a tag (letters, digits, - _ . / only)"
|
||||
));
|
||||
}
|
||||
if !tag.chars().any(|c| c.is_ascii_alphanumeric()) {
|
||||
return Err("a tag needs a letter or digit".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check a serial number's form: 1 to [`MAX_SERIAL_LEN`] characters, no
|
||||
/// control characters, no leading or trailing space. Returns why not.
|
||||
pub fn check_serial(serial: &str) -> Result<(), String> {
|
||||
if serial.trim().is_empty() {
|
||||
return Err("a serial number can't be empty".into());
|
||||
}
|
||||
if serial.chars().count() > MAX_SERIAL_LEN {
|
||||
return Err(format!(
|
||||
"a serial number is at most {MAX_SERIAL_LEN} characters"
|
||||
));
|
||||
}
|
||||
if serial.trim() != serial {
|
||||
return Err("a serial number can't start or end with a space".into());
|
||||
}
|
||||
if serial.chars().any(char::is_control) {
|
||||
return Err("a serial number can't contain control characters".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The key tags are compared by: tags are unique ignoring case
|
||||
/// ("wl-0001" is "WL-0001").
|
||||
pub fn tag_key(tag: &str) -> String {
|
||||
tag.to_ascii_uppercase()
|
||||
}
|
||||
|
||||
/// Parameters for registering an asset. A struct (not eight arguments)
|
||||
@@ -105,14 +322,23 @@ pub struct ScheduleLine {
|
||||
pub book_value: Decimal,
|
||||
}
|
||||
|
||||
/// The days in a month of a year chrono holds, without stepping to the
|
||||
/// next month, so chrono's last month (December 262142, whose next month
|
||||
/// chrono can't hold) has its 31 days too (rules.md B-28: a schedule an
|
||||
/// earlier version accepted is computed, never a panic).
|
||||
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
|
||||
match month {
|
||||
2 if NaiveDate::from_ymd_opt(year, 2, 29).is_some() => 29,
|
||||
2 => 28,
|
||||
4 | 6 | 9 | 11 => 30,
|
||||
_ => 31,
|
||||
}
|
||||
}
|
||||
|
||||
/// The first day of the month after `month_start`'s, or `None` past
|
||||
/// chrono's last day (the month containing NaiveDate::MAX).
|
||||
fn next_month_start(month_start: NaiveDate) -> Option<NaiveDate> {
|
||||
month_start.checked_add_months(Months::new(1))
|
||||
}
|
||||
|
||||
/// The last day depreciation accrues: purchase + life, or disposal, whichever
|
||||
@@ -128,8 +354,26 @@ fn depreciation_end(asset: &Asset) -> NaiveDate {
|
||||
}
|
||||
}
|
||||
|
||||
/// Straight-line depreciation schedule from the purchase month through the
|
||||
/// month containing `through` (clamped to the depreciation end).
|
||||
/// Straight-line depreciation schedule from the purchase month through
|
||||
/// `through` (clamped to the depreciation end): one line per month, the
|
||||
/// last prorated by days when `through` falls mid-month.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: how much of an asset's cost has been used up, month by month.
|
||||
/// SOURCE: docs/spec/rules.md B-15 (straight line; salvage never
|
||||
/// depreciated; partial months by days); DECISION (Stephen,
|
||||
/// 2026-10-13): a schedule through a mid-month date prorates that
|
||||
/// month by days, as partial first and last months do.
|
||||
/// INVARIANTS:
|
||||
/// 1. A full schedule MUST sum to exactly cost - salvage, to the cent.
|
||||
/// 2. Accumulated depreciation NEVER falls; book value NEVER rises and
|
||||
/// NEVER drops below salvage.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF $12,000 over 12 months from Jan 1, through Jun 15:
|
||||
/// - SHOULD end with June at 500.00 (15 of 30 days), accumulated
|
||||
/// 5,500.00
|
||||
/// BECAUSE depreciation accrues by the day within a month.
|
||||
/// ```
|
||||
///
|
||||
/// 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
|
||||
@@ -158,18 +402,18 @@ pub fn depreciation_schedule(asset: &Asset, through: NaiveDate) -> Vec<ScheduleL
|
||||
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");
|
||||
// Past chrono's last month there is no month to walk, and nothing left
|
||||
// to accrue: accrual_end is at most NaiveDate::MAX.
|
||||
while let Some(month_start) = NaiveDate::from_ymd_opt(year, month, 1) {
|
||||
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.
|
||||
// Days held this month: overlap of [purchase_date, accrual_end) with
|
||||
// the month. In chrono's last month there is no next month to end
|
||||
// at, and accrual_end (at most NaiveDate::MAX) is the bound.
|
||||
let held_start = asset.purchase_date.max(month_start);
|
||||
let held_end = accrual_end.min(month_end_excl);
|
||||
let held_end = next_month_start(month_start).map_or(accrual_end, |n| accrual_end.min(n));
|
||||
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);
|
||||
@@ -196,9 +440,12 @@ pub fn depreciation_schedule(asset: &Asset, through: NaiveDate) -> Vec<ScheduleL
|
||||
// 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.
|
||||
// The final month named as the lines name theirs: chrono's "%Y" signs
|
||||
// a year past 9999 ("+10004"), and the lines don't, so books an
|
||||
// earlier version wrote with such a life true up too (rules.md B-28).
|
||||
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()
|
||||
&& last.period == format!("{:04}-{:02}", day_before_end.year(), day_before_end.month())
|
||||
{
|
||||
let plug = depreciable - accumulated_rounded;
|
||||
last.amount += plug;
|
||||
@@ -234,6 +481,8 @@ pub struct DisposalAmounts {
|
||||
}
|
||||
|
||||
/// Compute disposal amounts. Pure math; posting is the caller's job.
|
||||
/// A disposal may fall on the purchase date (one day held, accrual being
|
||||
/// inclusive, B-15 in docs/spec/rules.md); one before it is an error.
|
||||
pub fn disposal_amounts(
|
||||
asset: &Asset,
|
||||
date: NaiveDate,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
//! The calendar the books keep: four-digit years, 0001-01-01 through
|
||||
//! 9999-12-31.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: no date the books hold, or derive (a schedule's last month, an
|
||||
//! asset's last day of depreciation, a fiscal year's end), is one
|
||||
//! date arithmetic can't reach, or one a report can't print.
|
||||
//! SOURCE: CONVENTION: ISO 8601's basic calendar dates have four-digit
|
||||
//! years (0000-9999; year 0 is no Gregorian year, so 0001 is the
|
||||
//! first), as do the spreadsheet and PDF formats the books export
|
||||
//! to; DECISION (P0 review round 3, 2026-10-16, finding S1): a date
|
||||
//! outside them is refused, never a crash.
|
||||
//! INVARIANTS:
|
||||
//! 1. in_calendar(d) iff FIRST_DAY <= d <= LAST_DAY.
|
||||
//! 2. Every date from the calendar plus a year or 600 months less than
|
||||
//! ten thousand years is a date chrono represents (its range is about
|
||||
//! +-262,000 years), so arithmetic on calendar dates never overflows.
|
||||
//! ```
|
||||
|
||||
use crate::LedgerError;
|
||||
use chrono::NaiveDate;
|
||||
|
||||
/// The first day the books keep.
|
||||
pub const FIRST_DAY: NaiveDate = match NaiveDate::from_ymd_opt(1, 1, 1) {
|
||||
Some(d) => d,
|
||||
None => panic!("0001-01-01 is a date"),
|
||||
};
|
||||
|
||||
/// The last day the books keep.
|
||||
pub const LAST_DAY: NaiveDate = match NaiveDate::from_ymd_opt(9999, 12, 31) {
|
||||
Some(d) => d,
|
||||
None => panic!("9999-12-31 is a date"),
|
||||
};
|
||||
|
||||
/// Whether `d` is in the calendar the books keep (INVARIANT 1).
|
||||
pub fn in_calendar(d: NaiveDate) -> bool {
|
||||
(FIRST_DAY..=LAST_DAY).contains(&d)
|
||||
}
|
||||
|
||||
/// `d`, if it is in the calendar; else [`LedgerError::DateOutOfRange`].
|
||||
pub fn check(d: NaiveDate) -> Result<NaiveDate, LedgerError> {
|
||||
if in_calendar(d) {
|
||||
Ok(d)
|
||||
} else {
|
||||
Err(LedgerError::DateOutOfRange(d))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_calendar_is_four_digit_years() {
|
||||
assert!(in_calendar(FIRST_DAY));
|
||||
assert!(in_calendar(LAST_DAY));
|
||||
assert!(!in_calendar(FIRST_DAY.pred_opt().unwrap()));
|
||||
assert!(!in_calendar(LAST_DAY.succ_opt().unwrap()));
|
||||
assert!(!in_calendar(NaiveDate::MAX));
|
||||
assert!(!in_calendar(NaiveDate::MIN));
|
||||
assert_eq!(FIRST_DAY.to_string(), "0001-01-01");
|
||||
assert_eq!(LAST_DAY.to_string(), "9999-12-31");
|
||||
assert_eq!(check(LAST_DAY), Ok(LAST_DAY));
|
||||
assert_eq!(
|
||||
check(NaiveDate::MAX),
|
||||
Err(LedgerError::DateOutOfRange(NaiveDate::MAX))
|
||||
);
|
||||
}
|
||||
|
||||
/// INVARIANT 2: the arithmetic the books do on calendar dates (a year
|
||||
/// on, 600 months on, a day back) stays inside chrono's range.
|
||||
#[test]
|
||||
fn arithmetic_on_calendar_dates_never_overflows() {
|
||||
for d in [FIRST_DAY, LAST_DAY] {
|
||||
assert!(d.checked_add_months(chrono::Months::new(600)).is_some());
|
||||
assert!(d.checked_sub_months(chrono::Months::new(600)).is_some());
|
||||
assert!(NaiveDate::from_ymd_opt(chrono::Datelike::year(&d) + 1, 1, 1).is_some());
|
||||
assert!(NaiveDate::from_ymd_opt(chrono::Datelike::year(&d) - 1, 12, 31).is_some());
|
||||
assert!(d.pred_opt().is_some() && d.succ_opt().is_some());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,7 +180,7 @@ mod tests {
|
||||
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();
|
||||
let plain = crate::reports::unclosed_earnings(&ledger).unwrap();
|
||||
assert_eq!((all.revenue, all.expenses), (plain.revenue, plain.expenses));
|
||||
assert_eq!((all.revenue, all.expenses), (d(170_000), d(55_000)));
|
||||
}
|
||||
@@ -205,7 +205,7 @@ mod tests {
|
||||
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 total = crate::reports::unclosed_earnings(&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));
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -26,6 +26,48 @@ pub enum LedgerError {
|
||||
#[error("journal line has a zero amount")]
|
||||
ZeroAmountLine,
|
||||
|
||||
/// Line amounts are positive; the side says which way the money moves.
|
||||
#[error("journal line has a negative amount; use the other side instead")]
|
||||
NegativeAmountLine,
|
||||
|
||||
/// A line larger than one quadrillion, or with more than ten decimal
|
||||
/// places, is a mistake, and one that could overflow exact arithmetic.
|
||||
#[error(
|
||||
"journal line amount is out of range (at most one quadrillion, with ten decimal places)"
|
||||
)]
|
||||
AmountOutOfRange,
|
||||
|
||||
/// A date outside the calendar the books keep (`crate::calendar`):
|
||||
/// four-digit years, 0001-01-01 through 9999-12-31.
|
||||
#[error("date {0} is outside the calendar the books keep (0001-01-01 through 9999-12-31)")]
|
||||
DateOutOfRange(chrono::NaiveDate),
|
||||
|
||||
/// An amount other than a journal line's (a document line, a payout, a
|
||||
/// payroll figure, a statement balance, a share, a basis) larger than
|
||||
/// one quadrillion or with more than ten decimal places: a mistake, and
|
||||
/// one that could overflow exact arithmetic.
|
||||
#[error("{0} is out of range (at most one quadrillion, with ten decimal places)")]
|
||||
ValueOutOfRange(String),
|
||||
|
||||
/// A figure the books derive (a sum, or a share of one) too large for
|
||||
/// exact arithmetic: refused, never rounded or wrapped.
|
||||
#[error("{0} is too large to compute exactly")]
|
||||
TooLargeToCompute(String),
|
||||
|
||||
/// An amount finer than its currency's smallest unit (ISO 4217 minor
|
||||
/// units): no one can pay a fraction of a cent (rules.md B-3).
|
||||
#[error(
|
||||
"amount {amount} {currency} has more than {places} decimal places; round it to the {currency} minor unit first"
|
||||
)]
|
||||
FinerThanMinorUnit {
|
||||
/// The amount as given.
|
||||
amount: rust_decimal::Decimal,
|
||||
/// Its currency code.
|
||||
currency: String,
|
||||
/// The decimal places the currency allows.
|
||||
places: u32,
|
||||
},
|
||||
|
||||
/// The referenced account does not exist in the chart of accounts.
|
||||
#[error("unknown account id {0}")]
|
||||
UnknownAccount(u64),
|
||||
@@ -56,6 +98,197 @@ pub enum LedgerError {
|
||||
#[error("entry {0} is not posted yet")]
|
||||
EntryNotPosted(u64),
|
||||
|
||||
/// An entry is reversed at most once: a second reversal would undo it
|
||||
/// twice. (Reversing the reversal re-instates it.)
|
||||
#[error("entry {0} is already reversed by entry {1}")]
|
||||
EntryAlreadyReversed(u64, u64),
|
||||
|
||||
/// A period's closing entry is never reversed: that would leave the
|
||||
/// period closed with its earnings unswept.
|
||||
#[error("entry {0} closes a period and cannot be reversed")]
|
||||
ClosingEntryIrreversible(u64),
|
||||
|
||||
/// The engine posts invoices, bills, payroll, and closes to accounts it
|
||||
/// finds by code (1200 receivables, 2000 payables, 4000 revenue, ...).
|
||||
/// An account with that code exists but is the wrong type, or a
|
||||
/// placeholder, so the engine refuses rather than post there or force
|
||||
/// the chart into its numbering (Stephen, 2026-10-13). The bookkeeper
|
||||
/// decides with the human how to handle it.
|
||||
#[error(
|
||||
"account {code} \"{name}\" is {}, but {role} needs account {code} to be a postable {expected:?} account; \
|
||||
wanderbooks won't post {role} there. Decide with the books' owner how to handle it: \
|
||||
assign {role} to an account of theirs, or renumber that account so {code} is free",
|
||||
if *placeholder { format!("a placeholder {found:?} account") } else { format!("a {found:?} account") }
|
||||
)]
|
||||
ControlAccountMismatch {
|
||||
/// The code the engine looks for.
|
||||
code: String,
|
||||
/// The existing account's name.
|
||||
name: String,
|
||||
/// What the engine wanted it for, e.g. "accounts receivable".
|
||||
role: String,
|
||||
/// The type that role needs.
|
||||
expected: crate::AccountType,
|
||||
/// The existing account's type.
|
||||
found: crate::AccountType,
|
||||
/// Whether the existing account is a placeholder.
|
||||
placeholder: bool,
|
||||
},
|
||||
|
||||
/// An asset tag of the wrong form (see `assets::check_asset_tag`).
|
||||
#[error("bad asset tag {tag:?}: {why}")]
|
||||
InvalidAssetTag {
|
||||
/// The tag given.
|
||||
tag: String,
|
||||
/// Why it was refused.
|
||||
why: String,
|
||||
},
|
||||
|
||||
/// Asset tags are never shared or reused: another asset holds, or
|
||||
/// held, this one.
|
||||
#[error("asset tag {tag:?} belongs to asset {asset}; tags are never reused, so choose another")]
|
||||
AssetTagTaken {
|
||||
/// The tag given.
|
||||
tag: String,
|
||||
/// The asset that holds or held it.
|
||||
asset: u64,
|
||||
},
|
||||
|
||||
/// An asset's acquisition entry must be a posted, unreversed entry
|
||||
/// that debits the asset's cost account.
|
||||
#[error("entry {entry} can't be asset {asset}'s acquisition: {why}")]
|
||||
AcquisitionEntryMismatch {
|
||||
/// The asset.
|
||||
asset: u64,
|
||||
/// The entry given.
|
||||
entry: u64,
|
||||
/// Why not.
|
||||
why: String,
|
||||
},
|
||||
|
||||
/// An asset's tax basis record doesn't fit its rule (see
|
||||
/// `Ledger::set_asset_tax_basis`).
|
||||
#[error("bad tax basis: {0}")]
|
||||
InvalidTaxBasis(String),
|
||||
|
||||
/// No party has this id.
|
||||
#[error("unknown party {0}")]
|
||||
UnknownParty(u64),
|
||||
|
||||
/// A party command that breaks a rule of the directory
|
||||
/// (`crate::parties`).
|
||||
#[error("{0}")]
|
||||
InvalidParty(String),
|
||||
|
||||
/// A serial number of the wrong form (see `assets::check_serial`).
|
||||
#[error("bad serial number: {0}")]
|
||||
InvalidSerial(String),
|
||||
|
||||
/// No control role has that name (see `ledger_core::control_accounts`).
|
||||
#[error(
|
||||
"no role {0:?}; the roles are receivables, revenue, payables, general_expense, \
|
||||
retained_earnings, salary_expense, payroll_tax_expense, federal_withholding, \
|
||||
state_withholding, social_security_payable, medicare_payable, other_deductions_payable"
|
||||
)]
|
||||
UnknownRole(String),
|
||||
|
||||
/// One account per role: an account already filling a role can't take
|
||||
/// a second (its balance would mix what the two control).
|
||||
#[error("account {code} \"{name}\" already holds {role}; give each role its own account")]
|
||||
RoleTaken {
|
||||
/// The account's code.
|
||||
code: String,
|
||||
/// The account's name.
|
||||
name: String,
|
||||
/// The role it fills, e.g. "accounts receivable".
|
||||
role: String,
|
||||
},
|
||||
|
||||
/// A balance sheet role (receivables, payables, withholdings, retained
|
||||
/// earnings) can't move while its account holds a balance: the control
|
||||
/// account must keep agreeing with what it controls.
|
||||
#[error(
|
||||
"{role} is in account {code} \"{name}\", which holds {balance}; move that balance to the \
|
||||
new account with a journal entry first, then assign the role"
|
||||
)]
|
||||
RoleHoldsBalance {
|
||||
/// The role, e.g. "accounts receivable".
|
||||
role: String,
|
||||
/// The code of the account filling it now.
|
||||
code: String,
|
||||
/// That account's name.
|
||||
name: String,
|
||||
/// Its balance.
|
||||
balance: rust_decimal::Decimal,
|
||||
},
|
||||
|
||||
/// A rule set by law (a dollar threshold, a rate) isn't known to
|
||||
/// wanderbooks for the year asked; the engine never guesses (see
|
||||
/// [`crate::rules`]). The text names the authority, never this repo:
|
||||
/// it reaches agents.
|
||||
#[error(
|
||||
"wanderbooks knows the {rule} only through {known_through}, not for {year}. \
|
||||
Find the {year} figure from the official source, {authority}, or ask the books' \
|
||||
owner or CPA, and pass it explicitly. Then tell the human the figure and where \
|
||||
it came from, so the rule can be updated for everyone."
|
||||
)]
|
||||
RuleNotKnown {
|
||||
/// The rule's plain name.
|
||||
rule: String,
|
||||
/// The year asked for.
|
||||
year: i32,
|
||||
/// The last year the rule is known for.
|
||||
known_through: i32,
|
||||
/// The official source of the rule (`DatedRule::authority`).
|
||||
authority: String,
|
||||
},
|
||||
|
||||
/// A child account subdivides its parent, so it must be the same type
|
||||
/// (Stephen, 2026-10-13).
|
||||
#[error(
|
||||
"account {code} is {found:?}, but its parent {parent_code} is {expected:?}; a child account must be the same type as its parent"
|
||||
)]
|
||||
ParentTypeMismatch {
|
||||
/// The new account's code.
|
||||
code: String,
|
||||
/// The parent's code.
|
||||
parent_code: String,
|
||||
/// The parent's type, which the child must share.
|
||||
expected: crate::AccountType,
|
||||
/// The type the child was given.
|
||||
found: crate::AccountType,
|
||||
},
|
||||
|
||||
/// Periods nest (a month in a quarter in a year) but never partially
|
||||
/// overlap (Stephen, 2026-10-13).
|
||||
#[error(
|
||||
"period {name} ({start} to {end}) partially overlaps period {other} ({other_start} to {other_end}); periods may nest, but not overlap"
|
||||
)]
|
||||
PeriodsOverlap {
|
||||
/// The new period's name.
|
||||
name: String,
|
||||
/// Its first day.
|
||||
start: chrono::NaiveDate,
|
||||
/// Its last day.
|
||||
end: chrono::NaiveDate,
|
||||
/// The existing period's name.
|
||||
other: String,
|
||||
/// The existing period's first day.
|
||||
other_start: chrono::NaiveDate,
|
||||
/// The existing period's last day.
|
||||
other_end: chrono::NaiveDate,
|
||||
},
|
||||
|
||||
/// A payment can't apply to an invoice or bill dated after it (Stephen,
|
||||
/// 2026-10-13); a real prepayment is a deposit, recorded differently.
|
||||
#[error("a payment dated {date} can't apply to a document dated {document_date}")]
|
||||
PaymentBeforeDocument {
|
||||
/// The payment's date.
|
||||
date: chrono::NaiveDate,
|
||||
/// The invoice's or bill's date.
|
||||
document_date: chrono::NaiveDate,
|
||||
},
|
||||
|
||||
/// The referenced journal entry does not exist.
|
||||
#[error("entry {0} does not exist")]
|
||||
UnknownEntry(u64),
|
||||
@@ -68,6 +301,11 @@ pub enum LedgerError {
|
||||
#[error("account code must not be empty")]
|
||||
EmptyAccountCode,
|
||||
|
||||
/// An account's name is empty or only spaces: a chart lists every
|
||||
/// account by number and title.
|
||||
#[error("account name must not be empty")]
|
||||
EmptyAccountName,
|
||||
|
||||
/// The referenced parent account does not exist.
|
||||
#[error("unknown parent account id {0}")]
|
||||
UnknownParentAccount(u64),
|
||||
@@ -132,6 +370,20 @@ pub enum LedgerError {
|
||||
#[error("purchase date plus useful life overflows the calendar")]
|
||||
AssetDateOverflow,
|
||||
|
||||
/// An amortization schedule or recurring entry is invalid, unknown, or
|
||||
/// already posted for that date.
|
||||
#[error("invalid schedule: {0}")]
|
||||
InvalidSchedule(String),
|
||||
|
||||
/// A payout doesn't reconcile or uses the wrong accounts.
|
||||
#[error("invalid payout: {0}")]
|
||||
InvalidPayout(String),
|
||||
|
||||
/// A tax category doesn't exist, doesn't fit the account, or has a bad
|
||||
/// deductible percent.
|
||||
#[error("invalid tax category: {0}")]
|
||||
InvalidTaxCategory(String),
|
||||
|
||||
/// Partnership members are misconfigured (shares, capital accounts).
|
||||
#[error("invalid members: {0}")]
|
||||
InvalidMembers(String),
|
||||
@@ -231,8 +483,9 @@ pub enum LedgerError {
|
||||
#[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")]
|
||||
/// A payroll run must pay some wages: at least one employee line with
|
||||
/// gross pay.
|
||||
#[error("payroll run must have at least one employee with gross pay")]
|
||||
EmptyPayroll,
|
||||
|
||||
/// Payroll amounts must not be negative.
|
||||
@@ -258,4 +511,36 @@ pub enum LedgerError {
|
||||
/// The referenced class does not exist.
|
||||
#[error("unknown class id {0}")]
|
||||
UnknownClass(u32),
|
||||
|
||||
/// A setting change the books can't honor (crate::settings): an
|
||||
/// unknown key, a value its key refuses, or settings that wouldn't
|
||||
/// hold together.
|
||||
#[error("{0}")]
|
||||
InvalidSetting(String),
|
||||
|
||||
/// A convention, decision, open item or approval the books can't
|
||||
/// take (crate::conventions).
|
||||
#[error("{0}")]
|
||||
InvalidConvention(String),
|
||||
|
||||
/// Approval rules are enforced (`policy.approval_rules = enforce`) and
|
||||
/// rules cover this entry that its approvers haven't approved as it
|
||||
/// stands.
|
||||
#[error(
|
||||
"entry {entry} needs approval before it posts: {missing}; record each with books.approve_entry"
|
||||
)]
|
||||
ApprovalRequired {
|
||||
/// The draft.
|
||||
entry: u64,
|
||||
/// Each rule and its approver, e.g. `over-500 (approver: Pat)`.
|
||||
missing: String,
|
||||
},
|
||||
|
||||
/// Approval rules are enforced, and the entry an operation would post
|
||||
/// at once (an invoice, a bill, a payment, payroll, a disposal) is one
|
||||
/// a rule covers: it has no draft to approve.
|
||||
#[error(
|
||||
"approval rules cover the entry this would post at once ({0}): draft it instead (books.draft_entry), have it approved (books.approve_entry), then post it"
|
||||
)]
|
||||
ApprovalNeedsDraft(String),
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
//! 1099 tracking: what was paid to each vendor in a calendar year.
|
||||
//!
|
||||
//! A vendor is marked with the information return it gets (1099-NEC for
|
||||
//! nonemployee compensation, 1099-MISC for rents and the like) and whether
|
||||
//! its W-9 is on file. Entries are tagged with the vendor they paid
|
||||
//! (`pay_bill` tags automatically). The amount paid is what left the
|
||||
//! company's payment sources on the vendor's tagged entries: credits minus
|
||||
//! debits to them ([`PaymentAccounts`]). The sources are the cash accounts
|
||||
//! (bank, cash on hand) and the financing liabilities (what owners or
|
||||
//! lenders paid on the company's behalf, e.g. Due to Shareholders): the
|
||||
//! company is the payer either way. A card payment (Dr expense, Cr credit
|
||||
//! card) leaves neither, so it is excluded, as it should be: card payments
|
||||
//! are reported by the card processor on 1099-K, not by the payer. A bill
|
||||
//! (Cr payables) is not a payment until it is paid, and a prepayment
|
||||
//! (Dr prepaid, Cr cash) counts in full when made: §6041 reports payments
|
||||
//! made in the year. An entry with several parties (a payment shared
|
||||
//! between a contractor and a member) counts only the vendor's share of
|
||||
//! that payment ([`vendor_1099_report_with_members`]).
|
||||
//!
|
||||
//! Thresholds are set by law and change; [`default_threshold`] encodes
|
||||
//! them and the report takes an override. Confirm with the CPA.
|
||||
|
||||
use crate::{AccountType, Ledger, LedgerError, VendorId};
|
||||
use chrono::Datelike;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The information return a vendor gets.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Form1099 {
|
||||
/// 1099-NEC: nonemployee compensation (contractors, attorneys' fees).
|
||||
Nec,
|
||||
/// 1099-MISC: rents, royalties, prizes, other income.
|
||||
Misc,
|
||||
}
|
||||
|
||||
impl Form1099 {
|
||||
/// Parse "nec", "1099-NEC", "misc", "1099-MISC".
|
||||
pub fn parse(s: &str) -> Option<Form1099> {
|
||||
let s = s.trim().to_lowercase();
|
||||
match s.strip_prefix("1099-").unwrap_or(&s) {
|
||||
"nec" => Some(Form1099::Nec),
|
||||
"misc" => Some(Form1099::Misc),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// "1099-NEC" or "1099-MISC".
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Form1099::Nec => "1099-NEC",
|
||||
Form1099::Misc => "1099-MISC",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The reporting threshold for payments made in `year`.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: the dollar amount at which a vendor's payments become reportable.
|
||||
/// SOURCE: docs/spec/rules.md B-19 (26 U.S.C. §6041(a), (h), §6041A(a)(2),
|
||||
/// Pub. L. 119-21 §70433; sources/irs-1099-thresholds.md).
|
||||
/// SCENARIOS:
|
||||
/// 1. IF `year` <= 2025: SHOULD be $600.
|
||||
/// 2. IF `year` == 2026: SHOULD be $2,000.
|
||||
/// 3. IF `year` > rules::THRESHOLD_1099.known_through:
|
||||
/// - SHOULD refuse with RuleNotKnown
|
||||
/// - NEVER guess $2,000
|
||||
/// BECAUSE the amount is indexed for inflation after 2026 and not
|
||||
/// yet known (Stephen, 2026-10-13: never apply stale rules).
|
||||
/// ```
|
||||
///
|
||||
/// Royalties (1099-MISC, $10) and attorneys' gross proceeds ($600) are not
|
||||
/// modeled. Pass an explicit threshold to [`vendor_1099_report`] to
|
||||
/// override.
|
||||
pub fn default_threshold(year: i32) -> Result<Decimal, LedgerError> {
|
||||
let rule = crate::rules::THRESHOLD_1099;
|
||||
if year > rule.known_through {
|
||||
return Err(LedgerError::RuleNotKnown {
|
||||
rule: rule.name.to_string(),
|
||||
year,
|
||||
known_through: rule.known_through,
|
||||
authority: rule.authority.to_string(),
|
||||
});
|
||||
}
|
||||
Ok(if year <= 2025 {
|
||||
Decimal::new(600, 0)
|
||||
} else {
|
||||
Decimal::new(2000, 0)
|
||||
})
|
||||
}
|
||||
|
||||
/// One vendor's year.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Vendor1099Line {
|
||||
/// The vendor.
|
||||
pub vendor_id: VendorId,
|
||||
/// Name.
|
||||
pub name: String,
|
||||
/// The return it gets, if marked.
|
||||
pub form: Option<Form1099>,
|
||||
/// W-9 on file.
|
||||
pub tin_on_file: bool,
|
||||
/// Cash paid in the year (net of refunds and reversals).
|
||||
pub paid: Decimal,
|
||||
/// Marked for a 1099 and paid at least the threshold.
|
||||
pub reportable: bool,
|
||||
}
|
||||
|
||||
/// The year's 1099 report.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Vendor1099Report {
|
||||
/// Calendar year.
|
||||
pub year: i32,
|
||||
/// The threshold applied.
|
||||
pub threshold: Decimal,
|
||||
/// Every vendor marked for a 1099 or paid in the year, by name.
|
||||
pub vendors: Vec<Vendor1099Line>,
|
||||
/// Entries naming several parties whose shares didn't resolve, in
|
||||
/// posting order: each counted in full toward its vendor until its
|
||||
/// shares are fixed ([`vendor_1099_report_with_members`] INVARIANT 4).
|
||||
pub unresolved: Vec<crate::EntryId>,
|
||||
}
|
||||
|
||||
/// Cash paid per vendor in calendar `year`, from posted entries tagged
|
||||
/// with the vendor.
|
||||
pub fn vendor_1099_report(
|
||||
ledger: &Ledger,
|
||||
year: i32,
|
||||
threshold: Option<Decimal>,
|
||||
) -> Result<Vendor1099Report, LedgerError> {
|
||||
vendor_1099_report_with(ledger, year, threshold, &PaymentAccounts::default())
|
||||
}
|
||||
|
||||
/// The accounts payments to vendors come out of, for
|
||||
/// [`vendor_1099_report_with`]: the books' `[books] cash_accounts` and
|
||||
/// `[books] financing_accounts`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct PaymentAccounts {
|
||||
/// Cash accounts: bank, savings, cash on hand.
|
||||
pub cash: Vec<crate::AccountId>,
|
||||
/// Financing liabilities: owner advances, loans (paid on the company's
|
||||
/// behalf). Never a payment card.
|
||||
pub financing: Vec<crate::AccountId>,
|
||||
}
|
||||
|
||||
impl PaymentAccounts {
|
||||
/// Whether `account` is a payment source. With no cash accounts given,
|
||||
/// every asset account is one (books that haven't named their cash).
|
||||
fn includes(&self, ledger: &Ledger, account: crate::AccountId) -> bool {
|
||||
if self.cash.is_empty() {
|
||||
ledger
|
||||
.chart()
|
||||
.get(account)
|
||||
.is_some_and(|a| a.account_type == AccountType::Asset)
|
||||
|| self.financing.contains(&account)
|
||||
} else {
|
||||
self.cash.contains(&account) || self.financing.contains(&account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`vendor_1099_report`], counting payments out of `sources`, with no
|
||||
/// members: [`vendor_1099_report_with_members`] with none (an entry naming
|
||||
/// several parties then needs amount or percent shares to resolve, which
|
||||
/// a vendor beside anyone always does, since a vendor is not a member).
|
||||
pub fn vendor_1099_report_with(
|
||||
ledger: &Ledger,
|
||||
year: i32,
|
||||
threshold: Option<Decimal>,
|
||||
sources: &PaymentAccounts,
|
||||
) -> Result<Vendor1099Report, LedgerError> {
|
||||
vendor_1099_report_with_members(ledger, year, threshold, sources, &[])
|
||||
}
|
||||
|
||||
/// What an entry paid out of `sources`: credits minus debits to them.
|
||||
fn payment(ledger: &Ledger, entry: &crate::JournalEntry, sources: &PaymentAccounts) -> Decimal {
|
||||
entry
|
||||
.lines()
|
||||
.iter()
|
||||
.filter(|l| sources.includes(ledger, l.account))
|
||||
.map(|l| match l.side {
|
||||
crate::Side::Credit => l.amount.amount,
|
||||
crate::Side::Debit => -l.amount.amount,
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// The vendor's part of `payment` when the entry names several parties:
|
||||
/// its resolved share scaled from the share target to the payment (the
|
||||
/// SCENARIOS of [`vendor_1099_report_with_members`], INVARIANT 3).
|
||||
/// Refused, never wrapped or rounded early, when payment × share is
|
||||
/// beyond exact arithmetic (both near the line limit; P0 review round 3,
|
||||
/// S1).
|
||||
fn vendor_part(payment: Decimal, share: Decimal, target: Decimal) -> Result<Decimal, LedgerError> {
|
||||
if payment == target {
|
||||
Ok(share)
|
||||
} else if target.is_zero() {
|
||||
Ok(Decimal::ZERO)
|
||||
} else {
|
||||
let scaled = payment
|
||||
.checked_mul(share)
|
||||
.and_then(|x| x.checked_div(target))
|
||||
.ok_or_else(|| {
|
||||
LedgerError::TooLargeToCompute(format!(
|
||||
"a vendor's share ({share}) of a payment of {payment}"
|
||||
))
|
||||
})?;
|
||||
Ok(scaled.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero))
|
||||
}
|
||||
}
|
||||
|
||||
/// [`vendor_1099_report`], counting payments out of `sources`, each
|
||||
/// vendor's part of an entry with several parties resolved with `members`
|
||||
/// (the company's members, from the settings).
|
||||
///
|
||||
/// ```text
|
||||
/// SOURCE: docs/spec/rules.md B-19 (26 U.S.C. §6041(a): payments made in
|
||||
/// the year; card payments go on 1099-K); crate::shares (an entry
|
||||
/// with several parties, each with their share; DECISION, Stephen,
|
||||
/// 2026-10-15).
|
||||
/// DEFINITIONS:
|
||||
/// payment(e): credits minus debits to `sources` on entry e.
|
||||
/// money: crate::parties::money_accounts(ledger, sources.cash), what the
|
||||
/// shares resolve against (the MCP server checks them with the same).
|
||||
/// target(e): crate::shares::share_target(e, money): the entry_flow net,
|
||||
/// else the total debits.
|
||||
/// INVARIANTS:
|
||||
/// 1. Only posted entries dated in `year` and tagged with a vendor
|
||||
/// (Ledger::entry_vendor) count, toward that vendor.
|
||||
/// 2. An entry naming at most one party (Ledger::entry_parties): the
|
||||
/// vendor's amount is payment(e), exactly as before shares.
|
||||
/// 3. An entry naming several parties (one of them the vendor's party,
|
||||
/// crate::shares INVARIANT 5): the vendor's amount is its resolved
|
||||
/// share s (crate::shares::resolve with money and `members`) scaled
|
||||
/// to the payment: s when payment(e) = target(e) (no rounding: an
|
||||
/// expense paid from cash or by a member), else
|
||||
/// payment(e) × s / target(e) rounded to the nearest cent, half away
|
||||
/// from zero (0 when the payment is 0: charged to a card; 0 when the
|
||||
/// target is 0). The other parties' parts are not anyone's 1099.
|
||||
/// 4. An entry naming several parties whose shares don't resolve (the
|
||||
/// members changed in the settings, say): counts payment(e) in full
|
||||
/// toward the vendor, the conservative answer, and is listed in
|
||||
/// `unresolved` so the human fixes the shares.
|
||||
/// SCENARIOS:
|
||||
/// 1. Paid from cash, or by a member through a financing liability:
|
||||
/// counts. Charged to a card, or billed and unpaid: doesn't.
|
||||
/// 2. A prepayment (Dr prepaid, Cr cash) counts in full; a refund to
|
||||
/// cash and a reversal subtract.
|
||||
/// 3. $1,000.00 from the bank (Dr contract labor / Cr 1000) shared 60/40
|
||||
/// by amount between a contractor and a member: the contractor's
|
||||
/// 1099 counts 600.00.
|
||||
/// 4. $100.01 from the bank, 50% / 50% contractor first: the
|
||||
/// contractor gets 50.01 (the leftover cent goes to the first party,
|
||||
/// crate::shares INVARIANT 8); named second, 50.00.
|
||||
/// 5. $1,000.00 split 60/40, 600.00 of it from the bank and 400.00 on
|
||||
/// the card: payment 600.00, target 1,000.00, the contractor counts
|
||||
/// 600.00 × 600.00 / 1,000.00 = 360.00.
|
||||
/// 6. Reversing scenario 3: the reversal carries the shares, resolves
|
||||
/// to −600.00, and the year nets to 0.00.
|
||||
/// ```
|
||||
pub fn vendor_1099_report_with_members(
|
||||
ledger: &Ledger,
|
||||
year: i32,
|
||||
threshold: Option<Decimal>,
|
||||
sources: &PaymentAccounts,
|
||||
members: &[crate::PartyMember],
|
||||
) -> Result<Vendor1099Report, LedgerError> {
|
||||
let threshold = match threshold {
|
||||
Some(t) => t,
|
||||
None => default_threshold(year)?,
|
||||
};
|
||||
let money = crate::parties::money_accounts(ledger, &sources.cash);
|
||||
let mut paid: std::collections::HashMap<VendorId, Decimal> = Default::default();
|
||||
let mut unresolved = Vec::new();
|
||||
for entry in ledger.posted_entries() {
|
||||
if entry.date().year() != year {
|
||||
continue;
|
||||
}
|
||||
let Some(vendor) = ledger.entry_vendor(entry.id()) else {
|
||||
continue;
|
||||
};
|
||||
let cash = payment(ledger, entry, sources);
|
||||
let shares = ledger.entry_parties(entry.id());
|
||||
let amount = if shares.len() < 2 {
|
||||
cash
|
||||
} else {
|
||||
let party = ledger.party_of_vendor(vendor);
|
||||
match crate::shares::resolve(entry, shares, &money, members) {
|
||||
Ok(parts) => {
|
||||
match parts.into_iter().find(|(p, _)| Some(*p) == party) {
|
||||
Some((_, share)) => {
|
||||
vendor_part(cash, share, crate::shares::share_target(entry, &money)?)?
|
||||
}
|
||||
// Tagged by hand with a vendor none of the parties
|
||||
// is: nothing to scale by, so the whole payment.
|
||||
None => {
|
||||
unresolved.push(entry.id());
|
||||
cash
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
unresolved.push(entry.id());
|
||||
cash
|
||||
}
|
||||
}
|
||||
};
|
||||
let total = paid.entry(vendor).or_default();
|
||||
*total = total.checked_add(amount).ok_or_else(|| {
|
||||
LedgerError::TooLargeToCompute("a vendor's payments for the year".into())
|
||||
})?;
|
||||
}
|
||||
let mut vendors: Vec<Vendor1099Line> = ledger
|
||||
.list_vendors()
|
||||
.into_iter()
|
||||
.filter(|v| v.form_1099.is_some() || paid.get(&v.id).is_some_and(|p| !p.is_zero()))
|
||||
.map(|v| {
|
||||
let amount = paid.get(&v.id).copied().unwrap_or_default();
|
||||
Vendor1099Line {
|
||||
vendor_id: v.id,
|
||||
name: v.name.clone(),
|
||||
form: v.form_1099,
|
||||
// On file by the vendor's flag or its party's W-9 facts.
|
||||
tin_on_file: v.tin_on_file
|
||||
|| ledger
|
||||
.party_of_vendor(v.id)
|
||||
.and_then(|p| ledger.get_party(p))
|
||||
.is_some_and(|p| p.w9.is_some()),
|
||||
paid: amount,
|
||||
reportable: v.form_1099.is_some() && amount >= threshold,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
vendors.sort_by(|a, b| a.name.cmp(&b.name).then(a.vendor_id.cmp(&b.vendor_id)));
|
||||
Ok(Vendor1099Report {
|
||||
year,
|
||||
threshold,
|
||||
vendors,
|
||||
unresolved,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// vendor_part: the vendor's share scaled from the share target to the
|
||||
/// payment, exactly, rounded to the cent; refused, never a panic, when
|
||||
/// payment x share is past exact arithmetic (P0 review round 3, S1).
|
||||
#[test]
|
||||
fn a_vendor_part_is_scaled_exactly_or_refused() {
|
||||
let d = |s: &str| s.parse::<Decimal>().unwrap();
|
||||
assert_eq!(vendor_part(d("100"), d("40"), d("100")), Ok(d("40")));
|
||||
assert_eq!(vendor_part(d("50"), d("40"), d("100")), Ok(d("20")));
|
||||
assert_eq!(vendor_part(d("1"), d("1"), d("3")), Ok(d("0.33")));
|
||||
assert_eq!(vendor_part(d("2"), d("1"), d("3")), Ok(d("0.67")));
|
||||
assert_eq!(vendor_part(d("5"), d("1"), d("0")), Ok(Decimal::ZERO));
|
||||
let near = d("450000000000000");
|
||||
assert!(matches!(
|
||||
vendor_part(near * d("2"), near, near - d("1")),
|
||||
Err(LedgerError::TooLargeToCompute(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
vendor_part(Decimal::MAX, Decimal::MAX, d("1")),
|
||||
Err(LedgerError::TooLargeToCompute(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +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::kernel::{self, LineError};
|
||||
use crate::{AccountId, ChartOfAccounts, ClassId, Currency, LedgerError, Money, Side};
|
||||
use chrono::NaiveDate;
|
||||
use rust_decimal::Decimal;
|
||||
@@ -23,7 +24,7 @@ pub enum EntryStatus {
|
||||
}
|
||||
|
||||
/// One debit or credit line of a journal entry.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct JournalLine {
|
||||
/// The account this line posts to.
|
||||
pub account: AccountId,
|
||||
@@ -50,7 +51,7 @@ pub struct JournalLine {
|
||||
}
|
||||
|
||||
/// A balanced set of journal lines with a date and a description.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct JournalEntry {
|
||||
id: EntryId,
|
||||
date: NaiveDate,
|
||||
@@ -126,14 +127,11 @@ impl JournalEntry {
|
||||
}
|
||||
|
||||
/// Validate the entry against the chart of accounts. This is the heart of
|
||||
/// R-1: at least two lines, no zero amounts, no placeholder or unknown
|
||||
/// accounts, one currency throughout, and debits exactly equal to credits.
|
||||
/// R-1: at least two lines, every amount positive and within line range,
|
||||
/// no placeholder or unknown accounts, one currency throughout, and
|
||||
/// debits exactly equal to credits. The arithmetic is
|
||||
/// [`kernel::check_lines`].
|
||||
pub fn validate(&self, chart: &ChartOfAccounts) -> Result<(), LedgerError> {
|
||||
if self.lines.len() < 2 {
|
||||
return Err(LedgerError::TooFewLines);
|
||||
}
|
||||
let mut debits = Decimal::ZERO;
|
||||
let mut credits = Decimal::ZERO;
|
||||
let mut currency: Option<Currency> = None;
|
||||
for line in &self.lines {
|
||||
let account = chart
|
||||
@@ -142,9 +140,6 @@ impl JournalEntry {
|
||||
if account.placeholder {
|
||||
return Err(LedgerError::PlaceholderAccount(account.code.clone()));
|
||||
}
|
||||
if line.amount.is_zero() {
|
||||
return Err(LedgerError::ZeroAmountLine);
|
||||
}
|
||||
match currency {
|
||||
None => currency = Some(line.amount.currency),
|
||||
Some(c) if c != line.amount.currency => {
|
||||
@@ -161,28 +156,33 @@ impl JournalEntry {
|
||||
found: line.amount.currency.code().to_string(),
|
||||
});
|
||||
}
|
||||
match line.side {
|
||||
Side::Debit => debits += line.amount.amount,
|
||||
Side::Credit => credits += line.amount.amount,
|
||||
}
|
||||
let sides: Vec<(Side, Decimal)> = self
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| (l.side, l.amount.amount))
|
||||
.collect();
|
||||
kernel::check_lines(&sides).map_err(|e| match e {
|
||||
LineError::TooFewLines => LedgerError::TooFewLines,
|
||||
LineError::ZeroAmount => LedgerError::ZeroAmountLine,
|
||||
LineError::NegativeAmount => LedgerError::NegativeAmountLine,
|
||||
LineError::OutOfRange => LedgerError::AmountOutOfRange,
|
||||
LineError::Unbalanced { debits, credits } => {
|
||||
LedgerError::UnbalancedEntry { debits, credits }
|
||||
}
|
||||
}
|
||||
if debits != credits {
|
||||
return Err(LedgerError::UnbalancedEntry { debits, credits });
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Net movement per account within this entry: debits minus credits.
|
||||
/// Sums to zero across accounts for any valid entry.
|
||||
pub fn movements(&self) -> HashMap<AccountId, Decimal> {
|
||||
let mut out = HashMap::new();
|
||||
for line in &self.lines {
|
||||
let delta = match line.side {
|
||||
Side::Debit => line.amount.amount,
|
||||
Side::Credit => -line.amount.amount,
|
||||
};
|
||||
*out.entry(line.account).or_insert(Decimal::ZERO) += delta;
|
||||
}
|
||||
out
|
||||
kernel::movements(
|
||||
self.lines
|
||||
.iter()
|
||||
.map(|l| (l.account, l.side, l.amount.amount)),
|
||||
)
|
||||
.expect("posted lines are in range, so their sums are")
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,898 @@
|
||||
//! The members' K-1 worksheet: each member's capital account for the
|
||||
//! year, as Schedule K-1 (Form 1065) Item L reports it, with property
|
||||
//! contributed apart from cash.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: hand the CPA, per member, what the K-1 needs from the books:
|
||||
//! beginning capital, contributions (cash and property separately),
|
||||
//! their share of income, distributions, ending capital.
|
||||
//! SOURCE: Form 1065 Schedule K-1 instructions, Item L ("Partner's capital
|
||||
//! account analysis", on the partnership's books); B-13;
|
||||
//! DECISION (Stephen, 2026-10-15): the CPA package carries a K-1
|
||||
//! worksheet per member.
|
||||
//! INVARIANTS:
|
||||
//! 1. Every figure but the contribution split is the statement of
|
||||
//! changes in members' equity's (reports::members_equity) for the
|
||||
//! same span, so the two never disagree.
|
||||
//! 2. contributions_cash + contributions_property = contributions,
|
||||
//! exactly, per member.
|
||||
//! 3. Property is what an entry crediting a member's capital account
|
||||
//! debits to asset accounts that are neither cash (`cash`) nor
|
||||
//! receivables: equipment, a vehicle, software, or the unexpired part
|
||||
//! of a service contract (AppleCare+, a prepaid subscription) that a
|
||||
//! member owned and hands the company, each with its own basis
|
||||
//! (§723). The rest of a contribution is cash: deposits, and what a
|
||||
//! member paid for the company (an expense booked straight to
|
||||
//! capital, or a debt they took on, e.g. Dr 2100 / Cr capital). A
|
||||
//! member who pays a vendor for the company's prepaid service is
|
||||
//! booked through Due to Shareholders (Dr prepaid / Cr 2100), then
|
||||
//! converted (Dr 2100 / Cr capital): cash. A prepaid credited
|
||||
//! straight to capital is a contract they handed over: property.
|
||||
//! (DECISION, Stephen, 2026-10-15, after the AppleCare+ contributed
|
||||
//! with the MacBook read as cash.)
|
||||
//! 4. Within one entry, the property (at most the members' credits in
|
||||
//! it) is shared among the members credited in proportion to their
|
||||
//! credits, to the cent, leftover cents by largest remainder
|
||||
//! (kernel::apportion, ties to the earlier member): each takes at
|
||||
//! most its own credit, and the split depends on the order the
|
||||
//! members are given only for a tied leftover cent (never on line
|
||||
//! order). Joint property is joint (B-13's
|
||||
//! shares, the house convention that joint funds are 50/50).
|
||||
//! 5. k1_detail's lines explain each row: per member, its cash lines
|
||||
//! sum to contributions_cash, its property lines to
|
||||
//! contributions_property, and its distribution lines to
|
||||
//! −distributions, exactly (the same entries members_equity reads:
|
||||
//! posted, in the span, not a period's closing entry, not cancelled
|
||||
//! by its reversal in the span).
|
||||
//! 6. An entry and its reversal both dated in the span cancel
|
||||
//! (reports::cancelled_in_span; members_equity INVARIANT 6): neither
|
||||
//! gives a line, so a reversed contribution is neither a contribution
|
||||
//! nor a distribution, and a reversed distribution neither a
|
||||
//! distribution nor a contribution.
|
||||
//! SCENARIOS:
|
||||
//! 1. A joint 1,000.00 contribution (Dr 1000 / Cr 3000 500, Cr 3001
|
||||
//! 500) reversed the next day: each member 0.00 contributed, 0.00
|
||||
//! distributed.
|
||||
//! 2. Members pay a vendor ahead for the company's annual service from
|
||||
//! joint funds, through Due to Shareholders (Dr 1300 Prepaid 3,000 /
|
||||
//! Cr 2100 3,000), later converted to capital (Dr 2100 3,000 / Cr
|
||||
//! 3000 1,500, Cr 3001 1,500): 1,500.00 cash each, no property.
|
||||
//! 3. A joint mixed contribution (Dr 1000 600, Dr 1500 400 / Cr 3000
|
||||
//! 500, Cr 3001 500): each member 300.00 cash and 200.00 property,
|
||||
//! whichever member is listed first.
|
||||
//! 4. Members hand the company a service contract they already owned,
|
||||
//! straight to capital (Dr 1300 Prepaid 3,000 / Cr 3000 1,500, Cr
|
||||
//! 3001 1,500): 1,500.00 property each, no cash.
|
||||
//! ```
|
||||
|
||||
use crate::reports::{MemberCapital, members_equity};
|
||||
use crate::{AccountId, AccountType, EntryId, Ledger, LedgerError, PartyId, Side};
|
||||
use chrono::NaiveDate;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
/// One member's row on the K-1 worksheet.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct K1Row {
|
||||
/// The member's name, as in wanderbooks.toml.
|
||||
pub name: String,
|
||||
/// Share of income, e.g. 0.5.
|
||||
pub share: Decimal,
|
||||
/// Capital at the start of the span.
|
||||
pub beginning: Decimal,
|
||||
/// Contributed in cash (or paid for the company) in the span.
|
||||
pub contributions_cash: Decimal,
|
||||
/// Contributed as property (book value credited) in the span.
|
||||
pub contributions_property: Decimal,
|
||||
/// Their share of the span's net income (loss).
|
||||
pub net_income: Decimal,
|
||||
/// Distributions in the span (positive, subtracted).
|
||||
pub distributions: Decimal,
|
||||
/// Any other change, as on the statement of members' equity.
|
||||
pub other: Decimal,
|
||||
/// Capital at the end of the span.
|
||||
pub ending: Decimal,
|
||||
}
|
||||
|
||||
/// How one entry moved one member's capital ([`K1Line`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum K1Kind {
|
||||
/// The cash part of a contribution (INVARIANT 3).
|
||||
CashContribution,
|
||||
/// The property part of a contribution (INVARIANTS 3-4).
|
||||
PropertyContribution,
|
||||
/// Taken out: debits to capital, net debits to distributions.
|
||||
Distribution,
|
||||
/// A period close allocating income into capital. Informational:
|
||||
/// the row's `net_income` is the roll-forward's (INVARIANT 1), which
|
||||
/// also counts income not yet closed.
|
||||
IncomeClose,
|
||||
}
|
||||
|
||||
/// One entry's effect on one member's capital in the span, by kind
|
||||
/// ([`k1_detail`]).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct K1Line {
|
||||
/// The member's index in the `members` given.
|
||||
pub member: usize,
|
||||
/// The entry.
|
||||
pub entry: EntryId,
|
||||
/// What it was.
|
||||
pub kind: K1Kind,
|
||||
/// Signed: positive raises their capital, negative lowers it.
|
||||
pub amount: Decimal,
|
||||
}
|
||||
|
||||
/// The K-1 worksheet with the entries behind it ([`k1_detail`]).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct K1Detail {
|
||||
/// One row per member, in order: [`k1_worksheet`]'s.
|
||||
pub rows: Vec<K1Row>,
|
||||
/// Every entry dated in the span that moved a member's capital
|
||||
/// account (or distributions account), in journal order, a kind per
|
||||
/// line (an entry with more than one kind for a member is more than
|
||||
/// one line). Per member (INVARIANT 5):
|
||||
/// Σ CashContribution = contributions_cash,
|
||||
/// Σ PropertyContribution = contributions_property, and
|
||||
/// −Σ Distribution = distributions, exactly.
|
||||
pub lines: Vec<K1Line>,
|
||||
}
|
||||
|
||||
/// The K-1 worksheet for `start..=end`: one row per member, in order
|
||||
/// (INVARIANTS 1-4). `cash` are the company's cash accounts; it must not
|
||||
/// be empty (without it a bank deposit would read as property).
|
||||
pub fn k1_worksheet(
|
||||
ledger: &Ledger,
|
||||
start: NaiveDate,
|
||||
end: NaiveDate,
|
||||
members: &[MemberCapital],
|
||||
cash: &[AccountId],
|
||||
) -> Result<Vec<K1Row>, LedgerError> {
|
||||
k1_detail(ledger, start, end, members, cash).map(|d| d.rows)
|
||||
}
|
||||
|
||||
/// [`k1_worksheet`] with the per-entry lines behind each row's
|
||||
/// contributions and distributions (INVARIANT 5): the one source the
|
||||
/// viewer's member capital statement reads, so it never disagrees with
|
||||
/// the CPA package.
|
||||
pub fn k1_detail(
|
||||
ledger: &Ledger,
|
||||
start: NaiveDate,
|
||||
end: NaiveDate,
|
||||
members: &[MemberCapital],
|
||||
cash: &[AccountId],
|
||||
) -> Result<K1Detail, LedgerError> {
|
||||
if cash.is_empty() {
|
||||
return Err(LedgerError::InvalidMembers(
|
||||
"the K-1 worksheet needs the cash accounts".into(),
|
||||
));
|
||||
}
|
||||
let me = members_equity(ledger, start, end, members)?;
|
||||
let receivables = ledger.role_account(&crate::RECEIVABLES);
|
||||
let closings = ledger.closing_entry_ids();
|
||||
let cancelled = crate::reports::cancelled_in_span(ledger, start, end);
|
||||
let property_account = |id: AccountId| {
|
||||
!cash.contains(&id)
|
||||
&& Some(id) != receivables
|
||||
&& ledger
|
||||
.chart()
|
||||
.get(id)
|
||||
.is_some_and(|a| a.account_type == AccountType::Asset)
|
||||
};
|
||||
let sum = |e: &crate::JournalEntry, side: Side, account: AccountId| -> Decimal {
|
||||
e.lines()
|
||||
.iter()
|
||||
.filter(|l| l.side == side && l.account == account)
|
||||
.map(|l| l.amount.amount)
|
||||
.sum()
|
||||
};
|
||||
let mut lines = Vec::new();
|
||||
for e in ledger
|
||||
.posted_entries()
|
||||
.filter(|e| e.date() >= start && e.date() <= end && !cancelled.contains(&e.id()))
|
||||
{
|
||||
let line = |member, kind, amount| K1Line {
|
||||
member,
|
||||
entry: e.id(),
|
||||
kind,
|
||||
amount,
|
||||
};
|
||||
if closings.contains(&e.id()) {
|
||||
for (i, m) in members.iter().enumerate() {
|
||||
let effect: Decimal = std::iter::once(m.capital_account)
|
||||
.chain(m.distribution_account)
|
||||
.map(|a| sum(e, Side::Credit, a) - sum(e, Side::Debit, a))
|
||||
.sum();
|
||||
if !effect.is_zero() {
|
||||
lines.push(line(i, K1Kind::IncomeClose, effect));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let pool: Decimal = e
|
||||
.lines()
|
||||
.iter()
|
||||
.filter(|l| l.side == Side::Debit && property_account(l.account))
|
||||
.map(|l| l.amount.amount)
|
||||
.sum();
|
||||
// Contributions are credits to capital (members_equity's).
|
||||
let credits: Vec<Decimal> = members
|
||||
.iter()
|
||||
.map(|m| sum(e, Side::Credit, m.capital_account))
|
||||
.collect();
|
||||
let takes = share_property(pool, &credits)?;
|
||||
for (i, m) in members.iter().enumerate() {
|
||||
let (credit, take) = (credits[i], takes[i]);
|
||||
if !(credit - take).is_zero() {
|
||||
lines.push(line(i, K1Kind::CashContribution, credit - take));
|
||||
}
|
||||
if !take.is_zero() {
|
||||
lines.push(line(i, K1Kind::PropertyContribution, take));
|
||||
}
|
||||
// Distributions: debits to capital, net debits to the
|
||||
// distributions account (members_equity's).
|
||||
let taken = sum(e, Side::Debit, m.capital_account)
|
||||
+ m.distribution_account.map_or(Decimal::ZERO, |d| {
|
||||
sum(e, Side::Debit, d) - sum(e, Side::Credit, d)
|
||||
});
|
||||
if !taken.is_zero() {
|
||||
lines.push(line(i, K1Kind::Distribution, -taken));
|
||||
}
|
||||
}
|
||||
}
|
||||
let total = |i: usize, k: K1Kind| -> Decimal {
|
||||
lines
|
||||
.iter()
|
||||
.filter(|l| l.member == i && l.kind == k)
|
||||
.map(|l| l.amount)
|
||||
.sum()
|
||||
};
|
||||
let rows = me
|
||||
.members
|
||||
.into_iter()
|
||||
.zip(members)
|
||||
.enumerate()
|
||||
.map(|(i, (col, m))| {
|
||||
// Each entry's property is at most that member's credit in it,
|
||||
// and contributions are those credits' sum: property never
|
||||
// exceeds contributions, and is never negative.
|
||||
let prop = total(i, K1Kind::PropertyContribution);
|
||||
K1Row {
|
||||
name: col.name,
|
||||
share: m.share,
|
||||
beginning: col.beginning,
|
||||
contributions_cash: col.contributions - prop,
|
||||
contributions_property: prop,
|
||||
net_income: col.net_income,
|
||||
distributions: col.distributions,
|
||||
other: col.other,
|
||||
ending: col.ending,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(K1Detail { rows, lines })
|
||||
}
|
||||
|
||||
/// One entry's property shared among the members' credits in it
|
||||
/// (INVARIANT 4): at most their total credit, in proportion to each
|
||||
/// credit, rounded toward zero to the cent, the leftover cents by largest
|
||||
/// remainder. Each part is at most its own credit.
|
||||
/// Refused when whole × credit is beyond exact arithmetic (credits near
|
||||
/// the line limit; P0 review round 3, S1).
|
||||
fn share_property(pool: Decimal, credits: &[Decimal]) -> Result<Vec<Decimal>, LedgerError> {
|
||||
use rust_decimal::RoundingStrategy::ToZero;
|
||||
let too_large =
|
||||
|| LedgerError::TooLargeToCompute("a member's share of contributed property".into());
|
||||
let total = credits
|
||||
.iter()
|
||||
.try_fold(Decimal::ZERO, |t, c| t.checked_add(*c))
|
||||
.ok_or_else(too_large)?;
|
||||
let whole = pool.min(total);
|
||||
if whole <= Decimal::ZERO || total <= Decimal::ZERO {
|
||||
return Ok(vec![Decimal::ZERO; credits.len()]);
|
||||
}
|
||||
// whole × credit is exact; ÷ total is exact whenever the share is a
|
||||
// whole number of cents, so truncating never loses a cent it is owed.
|
||||
let exact: Vec<Decimal> = credits
|
||||
.iter()
|
||||
.map(|c| whole.checked_mul(*c).and_then(|x| x.checked_div(total)))
|
||||
.collect::<Option<_>>()
|
||||
.ok_or_else(too_large)?;
|
||||
let mut parts: Vec<Decimal> = exact
|
||||
.iter()
|
||||
.map(|x| x.round_dp_with_strategy(2, ToZero))
|
||||
.collect();
|
||||
let remainders: Vec<Decimal> = exact.iter().zip(&parts).map(|(x, p)| x - p).collect();
|
||||
crate::kernel::apportion(whole, &mut parts, &remainders, Decimal::new(1, 2))
|
||||
.expect("each part is short by under a cent, so the shortfall is under a cent per member");
|
||||
Ok(parts)
|
||||
}
|
||||
|
||||
/// The party a member (by name, as in wanderbooks.toml) is in the
|
||||
/// directory: the party not archived with that name, ignoring case and
|
||||
/// surrounding space ([`Ledger::party_by_name`]), and only when it has
|
||||
/// the member role. The same rule as ledger-config's
|
||||
/// `Company::party_members`, so the K-1 worksheet and the viewer's
|
||||
/// capital statement always name the same party. A display name never
|
||||
/// matches, and a party without the member role is never a member's: a
|
||||
/// vendor whose display name is a member's name must never put its TIN
|
||||
/// on that member's K-1 (the caller reports the member's TIN as a gap).
|
||||
pub fn member_party(ledger: &Ledger, name: &str) -> Option<PartyId> {
|
||||
ledger
|
||||
.party_by_name(name)
|
||||
.filter(|p| p.roles.contains(&crate::Role::Member))
|
||||
.map(|p| p.id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{AccountType, Currency, Money};
|
||||
use std::str::FromStr;
|
||||
|
||||
fn d(s: &str) -> Decimal {
|
||||
Decimal::from_str(s).unwrap()
|
||||
}
|
||||
|
||||
/// Hand-worked: A contributes 300 cash and a 700 laptop in one entry
|
||||
/// (Dr 1000 300, Dr 1500 700 / Cr 3000 1000); B contributes a 200
|
||||
/// receivable-backed promise (Dr 1200 200 / Cr 3001 200, cash by
|
||||
/// INVARIANT 3) and pays a 50 expense (Dr 6999 50 / Cr 3001 50).
|
||||
#[test]
|
||||
fn contributions_split_into_cash_and_property() {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let add =
|
||||
|l: &mut Ledger, code: &str, t| l.add_account(code, code, t, None, false).unwrap();
|
||||
let cash = add(&mut l, "1000", AccountType::Asset);
|
||||
let ar = add(&mut l, "1200", AccountType::Asset);
|
||||
let equip = add(&mut l, "1500", AccountType::Asset);
|
||||
let a = add(&mut l, "3000", AccountType::Equity);
|
||||
let b = add(&mut l, "3001", AccountType::Equity);
|
||||
let exp = add(&mut l, "6999", AccountType::Expense);
|
||||
assert_eq!(
|
||||
l.role_account(&crate::RECEIVABLES),
|
||||
Some(ar),
|
||||
"1200 by default"
|
||||
);
|
||||
let day = NaiveDate::from_ymd_opt(2026, 2, 1).unwrap();
|
||||
let mut post = |lines: &[(AccountId, Side, &str)]| {
|
||||
let e = l.create_entry(day, "x");
|
||||
for (acct, side, amt) in lines {
|
||||
l.add_line(e, *acct, *side, Money::new(d(amt), Currency::USD))
|
||||
.unwrap();
|
||||
}
|
||||
l.post_entry(e).unwrap();
|
||||
};
|
||||
post(&[
|
||||
(cash, Side::Debit, "300"),
|
||||
(equip, Side::Debit, "700"),
|
||||
(a, Side::Credit, "1000"),
|
||||
]);
|
||||
post(&[(ar, Side::Debit, "200"), (b, Side::Credit, "200")]);
|
||||
post(&[(exp, Side::Debit, "50"), (b, Side::Credit, "50")]);
|
||||
let members = [
|
||||
MemberCapital {
|
||||
name: "A".into(),
|
||||
capital_account: a,
|
||||
distribution_account: None,
|
||||
share: d("0.5"),
|
||||
},
|
||||
MemberCapital {
|
||||
name: "B".into(),
|
||||
capital_account: b,
|
||||
distribution_account: None,
|
||||
share: d("0.5"),
|
||||
},
|
||||
];
|
||||
let start = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
|
||||
let end = NaiveDate::from_ymd_opt(2026, 12, 31).unwrap();
|
||||
let rows = k1_worksheet(&l, start, end, &members, &[cash]).unwrap();
|
||||
assert_eq!(
|
||||
(rows[0].contributions_cash, rows[0].contributions_property),
|
||||
(d("300"), d("700"))
|
||||
);
|
||||
assert_eq!(
|
||||
(rows[1].contributions_cash, rows[1].contributions_property),
|
||||
(d("250"), d("0"))
|
||||
);
|
||||
// Net income -50, split -25 each.
|
||||
assert_eq!(rows[0].ending, d("975"));
|
||||
assert_eq!(rows[1].ending, d("225"));
|
||||
assert!(k1_worksheet(&l, start, end, &members, &[]).is_err());
|
||||
}
|
||||
|
||||
/// Hand-worked: A (capital 3000, distributions 3100) contributes 100
|
||||
/// cash and 40 of equipment in one entry and takes 30 through 3100; B
|
||||
/// takes 10 straight from 3001; revenue 60; Jan closed 50/50 (30
|
||||
/// each, A's 3100 zeroed into 3000). A: 140 in (100 cash, 40
|
||||
/// property), 30 out, 30 income, ending 140. B: 10 out, 30 income,
|
||||
/// ending 20. The lines explain the rows (INVARIANT 5).
|
||||
#[test]
|
||||
fn detail_lines_explain_each_row() {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let add =
|
||||
|l: &mut Ledger, code: &str, t| l.add_account(code, code, t, None, false).unwrap();
|
||||
let cash = add(&mut l, "1000", AccountType::Asset);
|
||||
let equip = add(&mut l, "1500", AccountType::Asset);
|
||||
let a = add(&mut l, "3000", AccountType::Equity);
|
||||
let b = add(&mut l, "3001", AccountType::Equity);
|
||||
let da = add(&mut l, "3100", AccountType::Equity);
|
||||
let rev = add(&mut l, "4000", AccountType::Revenue);
|
||||
let day = |m, d| NaiveDate::from_ymd_opt(2026, m, d).unwrap();
|
||||
let mut post = |date, lines: &[(AccountId, Side, &str)]| {
|
||||
let e = l.create_entry(date, "x");
|
||||
for (acct, side, amt) in lines {
|
||||
l.add_line(e, *acct, *side, Money::new(d(amt), Currency::USD))
|
||||
.unwrap();
|
||||
}
|
||||
l.post_entry(e).unwrap();
|
||||
e
|
||||
};
|
||||
let put_in = post(
|
||||
day(1, 2),
|
||||
&[
|
||||
(cash, Side::Debit, "100"),
|
||||
(equip, Side::Debit, "40"),
|
||||
(a, Side::Credit, "140"),
|
||||
],
|
||||
);
|
||||
let a_out = post(
|
||||
day(1, 3),
|
||||
&[(da, Side::Debit, "30"), (cash, Side::Credit, "30")],
|
||||
);
|
||||
let b_out = post(
|
||||
day(1, 4),
|
||||
&[(b, Side::Debit, "10"), (cash, Side::Credit, "10")],
|
||||
);
|
||||
post(
|
||||
day(1, 5),
|
||||
&[(cash, Side::Debit, "60"), (rev, Side::Credit, "60")],
|
||||
);
|
||||
let jan = l.create_period("Jan", day(1, 1), day(1, 31)).unwrap();
|
||||
let close = l
|
||||
.close_period_to(
|
||||
jan,
|
||||
&crate::periods::CloseTo::Members(vec![
|
||||
crate::MemberClose {
|
||||
capital: a,
|
||||
share: d("0.5"),
|
||||
distributions: Some(da),
|
||||
},
|
||||
crate::MemberClose {
|
||||
capital: b,
|
||||
share: d("0.5"),
|
||||
distributions: None,
|
||||
},
|
||||
]),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let members = [
|
||||
MemberCapital {
|
||||
name: "A".into(),
|
||||
capital_account: a,
|
||||
distribution_account: Some(da),
|
||||
share: d("0.5"),
|
||||
},
|
||||
MemberCapital {
|
||||
name: "B".into(),
|
||||
capital_account: b,
|
||||
distribution_account: None,
|
||||
share: d("0.5"),
|
||||
},
|
||||
];
|
||||
let (start, end) = (day(1, 1), day(12, 31));
|
||||
let detail = k1_detail(&l, start, end, &members, &[cash]).unwrap();
|
||||
assert_eq!(
|
||||
detail.rows,
|
||||
k1_worksheet(&l, start, end, &members, &[cash]).unwrap()
|
||||
);
|
||||
let line = |member, entry, kind, amount: &str| K1Line {
|
||||
member,
|
||||
entry,
|
||||
kind,
|
||||
amount: d(amount),
|
||||
};
|
||||
assert_eq!(
|
||||
detail.lines,
|
||||
vec![
|
||||
line(0, put_in, K1Kind::CashContribution, "100"),
|
||||
line(0, put_in, K1Kind::PropertyContribution, "40"),
|
||||
line(0, a_out, K1Kind::Distribution, "-30"),
|
||||
line(1, b_out, K1Kind::Distribution, "-10"),
|
||||
line(0, close, K1Kind::IncomeClose, "30"),
|
||||
line(1, close, K1Kind::IncomeClose, "30"),
|
||||
]
|
||||
);
|
||||
let ra = &detail.rows[0];
|
||||
assert_eq!(
|
||||
(
|
||||
ra.beginning,
|
||||
ra.contributions_cash,
|
||||
ra.contributions_property
|
||||
),
|
||||
(d("0"), d("100"), d("40"))
|
||||
);
|
||||
assert_eq!(
|
||||
(ra.net_income, ra.distributions, ra.ending),
|
||||
(d("30"), d("30"), d("140"))
|
||||
);
|
||||
let rb = &detail.rows[1];
|
||||
assert_eq!(
|
||||
(rb.net_income, rb.distributions, rb.ending),
|
||||
(d("30"), d("10"), d("20"))
|
||||
);
|
||||
for (i, row) in detail.rows.iter().enumerate() {
|
||||
let sum = |k| -> Decimal {
|
||||
detail
|
||||
.lines
|
||||
.iter()
|
||||
.filter(|x| x.member == i && x.kind == k)
|
||||
.map(|x| x.amount)
|
||||
.sum()
|
||||
};
|
||||
assert_eq!(sum(K1Kind::CashContribution), row.contributions_cash);
|
||||
assert_eq!(
|
||||
sum(K1Kind::PropertyContribution),
|
||||
row.contributions_property
|
||||
);
|
||||
assert_eq!(-sum(K1Kind::Distribution), row.distributions);
|
||||
assert_eq!(
|
||||
row.beginning
|
||||
+ row.contributions_cash
|
||||
+ row.contributions_property
|
||||
+ row.net_income
|
||||
- row.distributions
|
||||
+ row.other,
|
||||
row.ending
|
||||
);
|
||||
}
|
||||
// February alone: nothing moved; the capital carries over.
|
||||
let feb = k1_detail(&l, day(2, 1), day(2, 28), &members, &[cash]).unwrap();
|
||||
assert!(feb.lines.is_empty());
|
||||
assert_eq!(
|
||||
(feb.rows[0].beginning, feb.rows[0].ending),
|
||||
(d("140"), d("140"))
|
||||
);
|
||||
}
|
||||
|
||||
/// Two members A (3000) and B (3001), cash 1000, equipment 1500,
|
||||
/// prepaid 1300, and a `post` that returns the entry.
|
||||
struct Books {
|
||||
l: Ledger,
|
||||
cash: AccountId,
|
||||
equip: AccountId,
|
||||
prepaid: AccountId,
|
||||
a: AccountId,
|
||||
b: AccountId,
|
||||
}
|
||||
|
||||
fn books() -> Books {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let mut add =
|
||||
|code: &str, name: &str, t| l.add_account(code, name, t, None, false).unwrap();
|
||||
let cash = add("1000", "Bluevine", AccountType::Asset);
|
||||
let prepaid = add("1300", "Prepaid Expenses", AccountType::Asset);
|
||||
let equip = add("1500", "Equipment", AccountType::Asset);
|
||||
let a = add("3000", "Capital A", AccountType::Equity);
|
||||
let b = add("3001", "Capital B", AccountType::Equity);
|
||||
Books {
|
||||
l,
|
||||
cash,
|
||||
equip,
|
||||
prepaid,
|
||||
a,
|
||||
b,
|
||||
}
|
||||
}
|
||||
|
||||
fn post(l: &mut Ledger, m: u32, day: u32, lines: &[(AccountId, Side, &str)]) -> EntryId {
|
||||
let e = l.create_entry(NaiveDate::from_ymd_opt(2026, m, day).unwrap(), "x");
|
||||
for (acct, side, amt) in lines {
|
||||
l.add_line(e, *acct, *side, Money::new(d(amt), Currency::USD))
|
||||
.unwrap();
|
||||
}
|
||||
l.post_entry(e).unwrap();
|
||||
e
|
||||
}
|
||||
|
||||
fn members(bk: &Books) -> [MemberCapital; 2] {
|
||||
[
|
||||
MemberCapital {
|
||||
name: "A".into(),
|
||||
capital_account: bk.a,
|
||||
distribution_account: None,
|
||||
share: d("0.5"),
|
||||
},
|
||||
MemberCapital {
|
||||
name: "B".into(),
|
||||
capital_account: bk.b,
|
||||
distribution_account: None,
|
||||
share: d("0.5"),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// (cash, property, distributions) per member for 2026.
|
||||
fn split(bk: &Books, members: &[MemberCapital]) -> Vec<(Decimal, Decimal, Decimal)> {
|
||||
let year = |m, day| NaiveDate::from_ymd_opt(2026, m, day).unwrap();
|
||||
let detail = k1_detail(&bk.l, year(1, 1), year(12, 31), members, &[bk.cash]).unwrap();
|
||||
detail
|
||||
.rows
|
||||
.iter()
|
||||
.map(|r| {
|
||||
(
|
||||
r.contributions_cash,
|
||||
r.contributions_property,
|
||||
r.distributions,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// INVARIANT 6 and SCENARIO 1, by hand. Case 1: a joint 1,000 cash
|
||||
/// contribution reversed the next day: 0 in, 0 out each (not 500 of
|
||||
/// each). Case 2: a 700 laptop contribution reversed: A 0 property, 0
|
||||
/// out. Case 3: a real 500 + 500 contribution, then a 100 + 100
|
||||
/// distribution reversed: 500 cash each, 0 out (not 600 in, 100 out).
|
||||
#[test]
|
||||
fn a_reversed_contribution_or_distribution_cancels() {
|
||||
let mut bk = books();
|
||||
let (cash, equip, a, b) = (bk.cash, bk.equip, bk.a, bk.b);
|
||||
let joint = post(
|
||||
&mut bk.l,
|
||||
1,
|
||||
2,
|
||||
&[
|
||||
(cash, Side::Debit, "1000"),
|
||||
(a, Side::Credit, "500"),
|
||||
(b, Side::Credit, "500"),
|
||||
],
|
||||
);
|
||||
let r =
|
||||
bk.l.reverse_entry(joint, NaiveDate::from_ymd_opt(2026, 1, 3).unwrap(), "x")
|
||||
.unwrap();
|
||||
let ms = members(&bk);
|
||||
let zero = (d("0"), d("0"), d("0"));
|
||||
assert_eq!(split(&bk, &ms), vec![zero, zero]);
|
||||
let year = |m, day| NaiveDate::from_ymd_opt(2026, m, day).unwrap();
|
||||
let detail = k1_detail(&bk.l, year(1, 1), year(12, 31), &ms, &[cash]).unwrap();
|
||||
assert!(
|
||||
detail
|
||||
.lines
|
||||
.iter()
|
||||
.all(|l| l.entry != joint && l.entry != r),
|
||||
"{:?}",
|
||||
detail.lines
|
||||
);
|
||||
// The statement of members' equity agrees (INVARIANT 1).
|
||||
let me = members_equity(&bk.l, year(1, 1), year(12, 31), &ms).unwrap();
|
||||
assert_eq!(
|
||||
(me.members[0].contributions, me.members[0].distributions),
|
||||
(d("0"), d("0"))
|
||||
);
|
||||
// Only the reversal in the span: by side, a distribution.
|
||||
let feb_on = members_equity(&bk.l, year(1, 3), year(12, 31), &ms).unwrap();
|
||||
assert_eq!(feb_on.members[0].distributions, d("500"));
|
||||
|
||||
let laptop = post(
|
||||
&mut bk.l,
|
||||
2,
|
||||
1,
|
||||
&[(equip, Side::Debit, "700"), (a, Side::Credit, "700")],
|
||||
);
|
||||
bk.l.reverse_entry(laptop, year(2, 2), "x").unwrap();
|
||||
assert_eq!(split(&bk, &ms), vec![zero, zero]);
|
||||
|
||||
post(
|
||||
&mut bk.l,
|
||||
3,
|
||||
1,
|
||||
&[
|
||||
(cash, Side::Debit, "1000"),
|
||||
(a, Side::Credit, "500"),
|
||||
(b, Side::Credit, "500"),
|
||||
],
|
||||
);
|
||||
let out = post(
|
||||
&mut bk.l,
|
||||
3,
|
||||
2,
|
||||
&[
|
||||
(a, Side::Debit, "100"),
|
||||
(b, Side::Debit, "100"),
|
||||
(cash, Side::Credit, "200"),
|
||||
],
|
||||
);
|
||||
let undo = bk.l.reverse_entry(out, year(3, 3), "x").unwrap();
|
||||
let five = (d("500"), d("0"), d("0"));
|
||||
assert_eq!(split(&bk, &ms), vec![five, five]);
|
||||
// Reversing the reversal reinstates the distribution.
|
||||
bk.l.reverse_entry(undo, year(3, 4), "x").unwrap();
|
||||
let back = (d("500"), d("0"), d("100"));
|
||||
assert_eq!(split(&bk, &ms), vec![back, back]);
|
||||
}
|
||||
|
||||
/// INVARIANT 3, by hand: a contract the members hand over (Dr 1300
|
||||
/// Prepaid 25 / Cr 3000 12.50, Cr 3001 12.50, the AppleCare+ with the
|
||||
/// MacBook) is property, 12.50 each. Members paying a 3,000 annual
|
||||
/// service for the company go through 2100: Dr 1300 / Cr 2100 3,000,
|
||||
/// then Dr 2100 / Cr 3000 1,500, Cr 3001 1,500: 1,500 cash each.
|
||||
#[test]
|
||||
fn a_contract_handed_over_is_property_a_service_paid_for_is_cash() {
|
||||
let mut bk = books();
|
||||
let (prepaid, a, b) = (bk.prepaid, bk.a, bk.b);
|
||||
post(
|
||||
&mut bk.l,
|
||||
1,
|
||||
5,
|
||||
&[
|
||||
(prepaid, Side::Debit, "25"),
|
||||
(a, Side::Credit, "12.50"),
|
||||
(b, Side::Credit, "12.50"),
|
||||
],
|
||||
);
|
||||
let ms = members(&bk);
|
||||
let handed = (d("0"), d("12.50"), d("0"));
|
||||
assert_eq!(split(&bk, &ms), vec![handed, handed]);
|
||||
let due =
|
||||
bk.l.add_account(
|
||||
"2100",
|
||||
"Due to Members",
|
||||
AccountType::Liability,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
post(
|
||||
&mut bk.l,
|
||||
2,
|
||||
1,
|
||||
&[(prepaid, Side::Debit, "3000"), (due, Side::Credit, "3000")],
|
||||
);
|
||||
post(
|
||||
&mut bk.l,
|
||||
2,
|
||||
2,
|
||||
&[
|
||||
(due, Side::Debit, "3000"),
|
||||
(a, Side::Credit, "1500"),
|
||||
(b, Side::Credit, "1500"),
|
||||
],
|
||||
);
|
||||
let each = (d("1500"), d("12.50"), d("0"));
|
||||
assert_eq!(split(&bk, &ms), vec![each, each]);
|
||||
}
|
||||
|
||||
/// SCENARIOS 2 and 4 exactly as the module doc states them: 3,000 of
|
||||
/// prepaid service through 2100 is 1,500.00 cash each; the same 3,000
|
||||
/// straight to capital is 1,500.00 property each.
|
||||
#[test]
|
||||
fn scenarios_2_and_4_prepaid_through_2100_is_cash_straight_to_capital_is_property() {
|
||||
let mut bk = books();
|
||||
let (prepaid, a, b) = (bk.prepaid, bk.a, bk.b);
|
||||
let due =
|
||||
bk.l.add_account(
|
||||
"2100",
|
||||
"Due to Shareholders",
|
||||
AccountType::Liability,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
post(
|
||||
&mut bk.l,
|
||||
1,
|
||||
5,
|
||||
&[(prepaid, Side::Debit, "3000"), (due, Side::Credit, "3000")],
|
||||
);
|
||||
post(
|
||||
&mut bk.l,
|
||||
1,
|
||||
6,
|
||||
&[
|
||||
(due, Side::Debit, "3000"),
|
||||
(a, Side::Credit, "1500"),
|
||||
(b, Side::Credit, "1500"),
|
||||
],
|
||||
);
|
||||
let ms = members(&bk);
|
||||
let cash = (d("1500"), d("0"), d("0"));
|
||||
assert_eq!(split(&bk, &ms), vec![cash, cash]);
|
||||
post(
|
||||
&mut bk.l,
|
||||
2,
|
||||
1,
|
||||
&[
|
||||
(prepaid, Side::Debit, "3000"),
|
||||
(a, Side::Credit, "1500"),
|
||||
(b, Side::Credit, "1500"),
|
||||
],
|
||||
);
|
||||
let both = (d("1500"), d("1500"), d("0"));
|
||||
assert_eq!(split(&bk, &ms), vec![both, both]);
|
||||
}
|
||||
|
||||
/// INVARIANT 4 and SCENARIO 3, by hand: Dr 1000 600, Dr 1500 400 /
|
||||
/// Cr 3000 500, Cr 3001 500. Property 400 split by credit (500:500):
|
||||
/// 200 each; cash 300 each, whichever member is listed first. Unequal
|
||||
/// credits (Cr 3000 750, Cr 3001 250, property 401): 401 × 0.75 =
|
||||
/// 300.75, 401 × 0.25 = 100.25, exact. Odd cents: property 0.01 on
|
||||
/// equal credits goes to the earlier member on a tie.
|
||||
#[test]
|
||||
fn joint_property_is_shared_by_credit() {
|
||||
let mut bk = books();
|
||||
let (cash, equip, a, b) = (bk.cash, bk.equip, bk.a, bk.b);
|
||||
post(
|
||||
&mut bk.l,
|
||||
1,
|
||||
5,
|
||||
&[
|
||||
(cash, Side::Debit, "600"),
|
||||
(equip, Side::Debit, "400"),
|
||||
(a, Side::Credit, "500"),
|
||||
(b, Side::Credit, "500"),
|
||||
],
|
||||
);
|
||||
let ms = members(&bk);
|
||||
let each = (d("300"), d("200"), d("0"));
|
||||
assert_eq!(split(&bk, &ms), vec![each, each]);
|
||||
let mut flipped = ms.clone();
|
||||
flipped.reverse();
|
||||
assert_eq!(split(&bk, &flipped), vec![each, each]);
|
||||
assert_eq!(
|
||||
share_property(d("401"), &[d("750"), d("250")]).unwrap(),
|
||||
vec![d("300.75"), d("100.25")]
|
||||
);
|
||||
assert_eq!(
|
||||
share_property(d("0.01"), &[d("5"), d("5")]).unwrap(),
|
||||
vec![d("0.01"), d("0.00")]
|
||||
);
|
||||
// More property than credit (part financed): at most the credits.
|
||||
assert_eq!(
|
||||
share_property(d("900"), &[d("300"), d("100")]).unwrap(),
|
||||
vec![d("300"), d("100")]
|
||||
);
|
||||
assert_eq!(
|
||||
share_property(d("50"), &[d("0"), d("0")]).unwrap(),
|
||||
vec![d("0"), d("0")]
|
||||
);
|
||||
// Credits near the line limit: whole x credit is past exact
|
||||
// arithmetic, so the split is refused, never a panic (P0 review
|
||||
// round 3, S1); at a quarter of it, it still splits exactly.
|
||||
let near = d("900000000000000");
|
||||
assert!(matches!(
|
||||
share_property(near, &[near, near]),
|
||||
Err(LedgerError::TooLargeToCompute(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
share_property(near, &[Decimal::MAX, Decimal::MAX]),
|
||||
Err(LedgerError::TooLargeToCompute(_))
|
||||
));
|
||||
let quarter = d("250000000000");
|
||||
assert_eq!(
|
||||
share_property(quarter, &[quarter, quarter]).unwrap(),
|
||||
vec![d("125000000000"), d("125000000000")]
|
||||
);
|
||||
}
|
||||
|
||||
/// member_party doc: the party of that name with the member role only.
|
||||
/// A contractor whose display name is the member's name, or a party
|
||||
/// of the name without the member role, is never the member's.
|
||||
#[test]
|
||||
fn member_party_needs_the_member_role_and_the_name() {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let ke = l
|
||||
.add_party("KE Consulting LLC", &[crate::Role::Contractor])
|
||||
.unwrap();
|
||||
l.update_party(
|
||||
ke,
|
||||
crate::PartyField::DisplayName(Some("Kat Example".into())),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(member_party(&l, "Kat Example"), None);
|
||||
let plain = l.add_party("Kat Example", &[]).unwrap();
|
||||
assert_eq!(member_party(&l, "kat example "), None, "no member role");
|
||||
l.set_party_role(plain, crate::Role::Member, true).unwrap();
|
||||
assert_eq!(member_party(&l, "kat example "), Some(plain));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
//! The accounting kernel: the arithmetic every bookkeeping invariant rests
|
||||
//! on, in one place.
|
||||
//!
|
||||
//! Each function here is small, pure, and generic over [`Amount`]. The
|
||||
//! engine runs it on [`Decimal`]; the Kani harnesses in `proofs.rs` run the
|
||||
//! very same code on machine integers and prove it correct for every input
|
||||
//! within their bounds; and the Lean model in `verify/lean` proves the
|
||||
//! laws it implements hold for books of any size. See
|
||||
//! `docs/verification.md` for which proof covers which law.
|
||||
//!
|
||||
//! The laws, in the order they appear (each traced to its authority in
|
||||
//! `docs/spec/rules.md`):
|
||||
//!
|
||||
//! * a line's signed amount is `+amount` for a debit, `-amount` for a credit
|
||||
//! (B-2);
|
||||
//! * an entry is acceptable when it has two or more lines, every amount is
|
||||
//! positive and in range, and its debits equal its credits exactly (B-1,
|
||||
//! B-2, B-21);
|
||||
//! * an acceptable entry nets to zero, account by account summed (B-1);
|
||||
//! * a reversal flips every side, so it is acceptable exactly when the
|
||||
//! original is, and the two together net every account to zero (B-9);
|
||||
//! * an account's balance is its movement read on its normal side, so the
|
||||
//! balances satisfy the accounting equation whenever movements net to
|
||||
//! zero (B-5, B-7);
|
||||
//! * a trial balance puts each movement in exactly one column, so its
|
||||
//! columns differ by the net of all movements (B-6);
|
||||
//! * a closing line zeroes the account it closes (B-11);
|
||||
//! * an allocation's parts sum to the whole, to the last unit, by largest
|
||||
//! remainder (B-13).
|
||||
|
||||
use crate::{AccountType, Side};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
/// What the kernel needs of a money amount: exact, ordered, and checked
|
||||
/// arithmetic that reports overflow instead of rounding or wrapping.
|
||||
pub trait Amount: Copy + Ord {
|
||||
/// Zero.
|
||||
const ZERO: Self;
|
||||
/// `self + rhs`, or `None` when it cannot be represented exactly.
|
||||
fn checked_add(self, rhs: Self) -> Option<Self>;
|
||||
/// `-self`, or `None` when it cannot be represented.
|
||||
fn checked_neg(self) -> Option<Self>;
|
||||
/// True when the amount is small and precise enough for a journal line
|
||||
/// (see [`MAX_LINE_AMOUNT`] and [`MAX_LINE_SCALE`] for [`Decimal`]).
|
||||
fn in_line_range(self) -> bool;
|
||||
|
||||
/// `self - rhs`, or `None` when it cannot be represented exactly.
|
||||
fn checked_sub(self, rhs: Self) -> Option<Self> {
|
||||
self.checked_add(rhs.checked_neg()?)
|
||||
}
|
||||
}
|
||||
|
||||
/// The largest amount one journal line may carry: one quadrillion units of
|
||||
/// the base currency. Far beyond any real transaction, and far enough
|
||||
/// below [`Decimal::MAX`] that no realistic sum of lines can overflow.
|
||||
pub const MAX_LINE_AMOUNT: Decimal = Decimal::from_parts(0xA4C6_8000, 0x0003_8D7E, 0, false, 0);
|
||||
|
||||
/// The most decimal places a journal line may carry. Cents need two;
|
||||
/// foreign-currency lines (amount times rate) need more; ten keeps every
|
||||
/// sum of lines exact as long as the total stays under 7.9 quintillion.
|
||||
pub const MAX_LINE_SCALE: u32 = 10;
|
||||
|
||||
impl Amount for Decimal {
|
||||
const ZERO: Self = Decimal::ZERO;
|
||||
fn checked_add(self, rhs: Self) -> Option<Self> {
|
||||
Decimal::checked_add(self, rhs)
|
||||
}
|
||||
fn checked_neg(self) -> Option<Self> {
|
||||
Some(-self)
|
||||
}
|
||||
fn in_line_range(self) -> bool {
|
||||
self.abs() <= MAX_LINE_AMOUNT && self.normalize().scale() <= MAX_LINE_SCALE
|
||||
}
|
||||
}
|
||||
|
||||
impl Amount for i64 {
|
||||
const ZERO: Self = 0;
|
||||
fn checked_add(self, rhs: Self) -> Option<Self> {
|
||||
i64::checked_add(self, rhs)
|
||||
}
|
||||
fn checked_neg(self) -> Option<Self> {
|
||||
i64::checked_neg(self)
|
||||
}
|
||||
fn in_line_range(self) -> bool {
|
||||
// Scaled down with the same headroom as Decimal's range: lines
|
||||
// this size can be summed many times over without overflow.
|
||||
self.unsigned_abs() <= (1 << 40)
|
||||
}
|
||||
}
|
||||
|
||||
/// Why the kernel rejected an entry's lines.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum LineError<T> {
|
||||
/// Fewer than two lines.
|
||||
TooFewLines,
|
||||
/// A line with a zero amount.
|
||||
ZeroAmount,
|
||||
/// A line with a negative amount: direction is the side's job.
|
||||
NegativeAmount,
|
||||
/// A line too large or too precise for a journal line.
|
||||
OutOfRange,
|
||||
/// Debits and credits differ.
|
||||
Unbalanced {
|
||||
/// Sum of debit lines.
|
||||
debits: T,
|
||||
/// Sum of credit lines.
|
||||
credits: T,
|
||||
},
|
||||
}
|
||||
|
||||
/// The other side.
|
||||
pub fn flip(side: Side) -> Side {
|
||||
match side {
|
||||
Side::Debit => Side::Credit,
|
||||
Side::Credit => Side::Debit,
|
||||
}
|
||||
}
|
||||
|
||||
/// A line's signed amount: positive for a debit, negative for a credit.
|
||||
pub fn signed<T: Amount>(side: Side, amount: T) -> Option<T> {
|
||||
match side {
|
||||
Side::Debit => Some(amount),
|
||||
Side::Credit => amount.checked_neg(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Total debits and total credits of some lines.
|
||||
pub fn totals<T: Amount>(lines: impl IntoIterator<Item = (Side, T)>) -> Option<(T, T)> {
|
||||
let mut debits = T::ZERO;
|
||||
let mut credits = T::ZERO;
|
||||
for (side, amount) in lines {
|
||||
match side {
|
||||
Side::Debit => debits = debits.checked_add(amount)?,
|
||||
Side::Credit => credits = credits.checked_add(amount)?,
|
||||
}
|
||||
}
|
||||
Some((debits, credits))
|
||||
}
|
||||
|
||||
/// The net of some lines: debits minus credits.
|
||||
pub fn net<T: Amount>(lines: impl IntoIterator<Item = (Side, T)>) -> Option<T> {
|
||||
let mut total = T::ZERO;
|
||||
for (side, amount) in lines {
|
||||
total = total.checked_add(signed(side, amount)?)?;
|
||||
}
|
||||
Some(total)
|
||||
}
|
||||
|
||||
/// Check an entry's lines: two or more, every amount positive and within
|
||||
/// line range, debits equal to credits. Accounts and currencies are the
|
||||
/// caller's to check; this is the arithmetic.
|
||||
pub fn check_lines<T: Amount>(lines: &[(Side, T)]) -> Result<(), LineError<T>> {
|
||||
if lines.len() < 2 {
|
||||
return Err(LineError::TooFewLines);
|
||||
}
|
||||
for &(_, amount) in lines {
|
||||
if amount == T::ZERO {
|
||||
return Err(LineError::ZeroAmount);
|
||||
}
|
||||
if amount < T::ZERO {
|
||||
return Err(LineError::NegativeAmount);
|
||||
}
|
||||
if !amount.in_line_range() {
|
||||
return Err(LineError::OutOfRange);
|
||||
}
|
||||
}
|
||||
let (debits, credits) = totals(lines.iter().copied()).ok_or(LineError::OutOfRange)?;
|
||||
if debits != credits {
|
||||
return Err(LineError::Unbalanced { debits, credits });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Net movement per account (debits minus credits), in order of first
|
||||
/// appearance.
|
||||
pub fn movements<K: Copy + Eq, T: Amount>(
|
||||
lines: impl IntoIterator<Item = (K, Side, T)>,
|
||||
) -> Option<Vec<(K, T)>> {
|
||||
let lines: Vec<_> = lines.into_iter().collect();
|
||||
let Some(&(first, _, _)) = lines.first() else {
|
||||
return Some(Vec::new());
|
||||
};
|
||||
// At most one account per line.
|
||||
let mut out = vec![(first, T::ZERO); lines.len()];
|
||||
let n = movements_into(&lines, &mut out)?;
|
||||
out.truncate(n);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// [`movements`] into a caller's buffer, without allocating: returns how
|
||||
/// many accounts it wrote, or `None` on overflow or if `out` is too small.
|
||||
pub fn movements_into<K: Copy + Eq, T: Amount>(
|
||||
lines: &[(K, Side, T)],
|
||||
out: &mut [(K, T)],
|
||||
) -> Option<usize> {
|
||||
let mut n = 0;
|
||||
for &(account, side, amount) in lines {
|
||||
let delta = signed(side, amount)?;
|
||||
match out[..n].iter_mut().find(|(k, _)| *k == account) {
|
||||
Some((_, total)) => *total = total.checked_add(delta)?,
|
||||
None => {
|
||||
*out.get_mut(n)? = (account, delta);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(n)
|
||||
}
|
||||
|
||||
/// The lines of a reversing entry: every side flipped, accounts and
|
||||
/// amounts unchanged.
|
||||
pub fn reversal<K: Copy, T: Amount>(lines: &[(K, Side, T)]) -> Vec<(K, Side, T)> {
|
||||
lines.iter().map(|&line| reverse_line(line)).collect()
|
||||
}
|
||||
|
||||
/// One line of a reversing entry: the side flipped, account and amount
|
||||
/// unchanged.
|
||||
pub fn reverse_line<K, T: Amount>((account, side, amount): (K, Side, T)) -> (K, Side, T) {
|
||||
(account, flip(side), amount)
|
||||
}
|
||||
|
||||
/// An account's balance read on its normal side: positive is a normal
|
||||
/// balance, negative a contra balance.
|
||||
pub fn normal_balance<T: Amount>(account_type: AccountType, movement: T) -> Option<T> {
|
||||
match account_type.normal_side() {
|
||||
Side::Debit => Some(movement),
|
||||
Side::Credit => movement.checked_neg(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A movement in trial-balance columns: `(debit, credit)`, one of them
|
||||
/// zero, both non-negative.
|
||||
pub fn columns<T: Amount>(movement: T) -> Option<(T, T)> {
|
||||
if movement > T::ZERO {
|
||||
Some((movement, T::ZERO))
|
||||
} else if movement == T::ZERO {
|
||||
// Never a negative zero: money never prints "-0".
|
||||
Some((T::ZERO, T::ZERO))
|
||||
} else {
|
||||
Some((T::ZERO, movement.checked_neg()?))
|
||||
}
|
||||
}
|
||||
|
||||
/// The line that zeroes an account with this movement: the opposite side,
|
||||
/// for the same amount. `None` for a zero movement (nothing to close).
|
||||
pub fn closing_line<T: Amount>(movement: T) -> Option<(Side, T)> {
|
||||
if movement > T::ZERO {
|
||||
Some((Side::Credit, movement))
|
||||
} else if movement < T::ZERO {
|
||||
Some((Side::Debit, movement.checked_neg()?))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The line that credits `credit` to an account (a negative credit is a
|
||||
/// debit). `None` for zero. This is [`closing_line`]: crediting `c` and
|
||||
/// zeroing a movement of `c` are the same line.
|
||||
pub fn credit_line<T: Amount>(credit: T) -> Option<(Side, T)> {
|
||||
closing_line(credit)
|
||||
}
|
||||
|
||||
/// Make rounded parts sum to the whole: the last part becomes `whole`
|
||||
/// minus the others, absorbing every unit of rounding. Returns `None` on
|
||||
/// overflow, leaving `parts` unchanged.
|
||||
pub fn plug_last<T: Amount>(whole: T, parts: &mut [T]) -> Option<()> {
|
||||
let Some((last, rest)) = parts.split_last_mut() else {
|
||||
return Some(());
|
||||
};
|
||||
let mut others = T::ZERO;
|
||||
for &p in rest.iter() {
|
||||
others = others.checked_add(p)?;
|
||||
}
|
||||
*last = whole.checked_sub(others)?;
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// Largest-remainder apportionment: make rounded-down `parts` sum to
|
||||
/// `whole`, handing out the shortfall one `unit` at a time, largest
|
||||
/// `remainders` first (ties to the earlier part). Any final fraction of a
|
||||
/// unit (when `whole` isn't a whole number of units) goes to the first
|
||||
/// part in that order.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: split a whole into parts, each a whole number of units where
|
||||
/// possible, summing to the whole exactly, without favoring anyone.
|
||||
/// SOURCE: docs/spec/rules.md B-13 (shares: 26 U.S.C. §704(a); cent
|
||||
/// rounding: DECISION, Stephen 2026-10-13), B-21 (exact totals).
|
||||
/// INVARIANTS:
|
||||
/// 1. MUST make the parts sum to `whole` exactly.
|
||||
/// 2. MUST change each part by zero or one unit (the first in order
|
||||
/// may also take the sub-unit fraction).
|
||||
/// 3. NEVER gives a unit to a part while a part with a strictly larger
|
||||
/// remainder goes without.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the shortfall is negative, or `n` units or more (the parts
|
||||
/// weren't rounded down from shares of `whole`):
|
||||
/// - SHOULD return None, leaving `parts` unchanged
|
||||
/// BECAUSE apportioning only corrects rounding.
|
||||
/// 2. IF $100.00 splits three equal ways:
|
||||
/// - SHOULD be 33.34, 33.33, 33.33
|
||||
/// - NEVER the same partner absorbing every odd cent by position
|
||||
/// BECAUSE the cent goes to the largest remainder, ties to the first
|
||||
/// (Stephen, 2026-10-13).
|
||||
/// ```
|
||||
///
|
||||
/// Allocation-free and O(n^2): it ranks each part by counting the parts
|
||||
/// ahead of it, so the model checker can prove it outright.
|
||||
pub fn apportion<T: Amount>(whole: T, parts: &mut [T], remainders: &[T], unit: T) -> Option<()> {
|
||||
if parts.len() != remainders.len() || unit <= T::ZERO {
|
||||
return None;
|
||||
}
|
||||
if parts.is_empty() {
|
||||
// Nothing to give the whole to.
|
||||
return (whole == T::ZERO).then_some(());
|
||||
}
|
||||
let mut shortfall = whole;
|
||||
for &p in parts.iter() {
|
||||
shortfall = shortfall.checked_sub(p)?;
|
||||
}
|
||||
if shortfall < T::ZERO {
|
||||
return None;
|
||||
}
|
||||
// Whole units short, by subtraction: no division in `Amount`. A
|
||||
// shortfall of a unit per part or more isn't rounding; refuse it.
|
||||
let mut units = 0;
|
||||
while shortfall >= unit {
|
||||
shortfall = shortfall.checked_sub(unit)?;
|
||||
units += 1;
|
||||
if units == parts.len() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
// A part's rank: the parts ahead of it (larger remainder, or equal and
|
||||
// earlier).
|
||||
let rank = |i: usize| {
|
||||
(0..remainders.len())
|
||||
.filter(|&j| remainders[j] > remainders[i] || (remainders[j] == remainders[i] && j < i))
|
||||
.count()
|
||||
};
|
||||
for (i, part) in parts.iter_mut().enumerate() {
|
||||
let r = rank(i);
|
||||
let mut add = T::ZERO;
|
||||
if r < units {
|
||||
add = unit;
|
||||
}
|
||||
if r == 0 {
|
||||
add = add.checked_add(shortfall)?;
|
||||
}
|
||||
*part = part.checked_add(add)?;
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn max_line_amount_is_one_quadrillion() {
|
||||
assert_eq!(MAX_LINE_AMOUNT, Decimal::from(1_000_000_000_000_000u64));
|
||||
assert!(MAX_LINE_AMOUNT.in_line_range());
|
||||
assert!(!(MAX_LINE_AMOUNT + Decimal::ONE).in_line_range());
|
||||
assert!("0.0000000001".parse::<Decimal>().unwrap().in_line_range());
|
||||
assert!(!"0.00000000001".parse::<Decimal>().unwrap().in_line_range());
|
||||
// Trailing zeros are not precision.
|
||||
assert!("1.000000000000".parse::<Decimal>().unwrap().in_line_range());
|
||||
}
|
||||
|
||||
/// Regression (Kani `apportion_is_exact`, 2026-10-13): a shortfall of
|
||||
/// two units and a bit, with one part, was accepted and handed almost
|
||||
/// two units to that part. Spec scenario 1: more than one unit short
|
||||
/// per part is refused.
|
||||
#[test]
|
||||
fn apportion_refuses_a_shortfall_of_a_unit_per_part_or_more() {
|
||||
let mut parts = [0i64];
|
||||
assert_eq!(apportion(1805, &mut parts, &[0], 903), None);
|
||||
assert_eq!(parts, [0]);
|
||||
let mut parts = [0i64, 0];
|
||||
assert_eq!(apportion(1806, &mut parts, &[0, 0], 903), None);
|
||||
// Just under: accepted.
|
||||
let mut parts = [0i64, 0];
|
||||
assert_eq!(apportion(1805, &mut parts, &[1, 0], 903), Some(()));
|
||||
assert_eq!(parts, [1805, 0]);
|
||||
}
|
||||
|
||||
/// The i64 model's line range: up to 2^40 either way, inclusive.
|
||||
#[test]
|
||||
fn i64_line_range_is_two_to_the_fortieth() {
|
||||
assert!((1i64 << 40).in_line_range());
|
||||
assert!((-(1i64 << 40)).in_line_range());
|
||||
assert!(!((1i64 << 40) + 1).in_line_range());
|
||||
assert!(!i64::MIN.in_line_range());
|
||||
}
|
||||
|
||||
/// B-9: a reversal flips every side and keeps accounts and amounts, so
|
||||
/// with the original it nets every account to zero.
|
||||
#[test]
|
||||
fn a_reversal_flips_every_line_and_nets_to_zero() {
|
||||
let lines = [
|
||||
(1u8, Side::Debit, 70i64),
|
||||
(2, Side::Credit, 50),
|
||||
(3, Side::Credit, 20),
|
||||
];
|
||||
let rev = reversal(&lines);
|
||||
assert_eq!(rev.len(), lines.len());
|
||||
for (o, r) in lines.iter().zip(&rev) {
|
||||
assert_eq!((r.0, r.1, r.2), (o.0, flip(o.1), o.2));
|
||||
}
|
||||
let both = movements(lines.iter().chain(&rev).copied()).unwrap();
|
||||
assert_eq!(both, vec![(1, 0), (2, 0), (3, 0)]);
|
||||
}
|
||||
|
||||
/// Spec scenario 1's preconditions: parts and remainders pair up, and
|
||||
/// the unit is positive; otherwise nothing is apportioned.
|
||||
#[test]
|
||||
fn apportion_refuses_mismatched_inputs_or_a_nonpositive_unit() {
|
||||
let mut parts = [0i64, 0];
|
||||
assert_eq!(apportion(0, &mut parts, &[0], 1), None);
|
||||
assert_eq!(parts, [0, 0]);
|
||||
let mut none: [i64; 0] = [];
|
||||
assert_eq!(apportion(0, &mut none, &[], 0), None);
|
||||
assert_eq!(apportion(0, &mut none, &[], -1), None);
|
||||
}
|
||||
|
||||
/// With no parts, only a zero whole is fully apportioned.
|
||||
#[test]
|
||||
fn apportion_to_no_parts_accepts_only_zero() {
|
||||
let mut none: [i64; 0] = [];
|
||||
assert_eq!(apportion(0, &mut none, &[], 1), Some(()));
|
||||
assert_eq!(apportion(5, &mut none, &[], 1), None);
|
||||
}
|
||||
|
||||
/// A zero movement's columns are both a plain zero, never "-0" (found
|
||||
/// by mutation triage, 2026-10-13: it returned a negative-zero credit).
|
||||
#[test]
|
||||
fn a_zero_movement_has_no_negative_zero_column() {
|
||||
let (d, c) = columns(Decimal::ZERO).unwrap();
|
||||
assert_eq!(
|
||||
(d.to_string(), c.to_string()),
|
||||
("0".to_string(), "0".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credit_line_sides() {
|
||||
assert_eq!(credit_line(5i64), Some((Side::Credit, 5)));
|
||||
assert_eq!(credit_line(-5i64), Some((Side::Debit, 5)));
|
||||
assert_eq!(credit_line(0i64), None);
|
||||
}
|
||||
}
|
||||
+3858
-242
File diff suppressed because it is too large.
Load diff
@@ -12,21 +12,36 @@
|
||||
//! * All mutations go through [`Ledger`] commands (`create_entry`, `add_line`,
|
||||
//! `post_entry`, `reverse_entry`). Posted entries are immutable; corrections
|
||||
//! are reversing entries.
|
||||
//! * Reports ([`trial_balance`], [`balance_sheet`], [`profit_and_loss`]) are
|
||||
//! * Reports ([`trial_balance`](reports::trial_balance), [`balance_sheet`](reports::balance_sheet), [`unclosed_earnings`](reports::unclosed_earnings)) are
|
||||
//! pure functions of a ledger, which is what makes the semantic oracle
|
||||
//! (`docs/oracle.md`) possible.
|
||||
|
||||
pub mod account;
|
||||
pub mod analysis;
|
||||
pub mod arap;
|
||||
pub mod assets;
|
||||
pub mod calendar;
|
||||
pub mod classes;
|
||||
pub mod conventions;
|
||||
pub mod error;
|
||||
pub mod form1099;
|
||||
pub mod journal;
|
||||
pub mod k1;
|
||||
pub mod kernel;
|
||||
pub mod ledger;
|
||||
pub mod money;
|
||||
pub mod parties;
|
||||
pub mod payouts;
|
||||
pub mod payroll;
|
||||
pub mod periods;
|
||||
#[cfg(kani)]
|
||||
mod proofs;
|
||||
pub mod reports;
|
||||
pub mod rules;
|
||||
pub mod schedules;
|
||||
pub mod settings;
|
||||
pub mod shares;
|
||||
pub mod tax;
|
||||
|
||||
pub use account::{Account, AccountId, AccountType, ChartOfAccounts, Side};
|
||||
pub use arap::{
|
||||
@@ -34,20 +49,39 @@ pub use arap::{
|
||||
Vendor, VendorId,
|
||||
};
|
||||
pub use assets::{
|
||||
Asset, AssetId, AssetStatus, DepreciationMethod, DisposalAmounts, NewAsset, ScheduleLine,
|
||||
accumulated_depreciation, book_value, depreciation_schedule, disposal_amounts,
|
||||
Asset, AssetCorrection, AssetId, AssetStatus, DepreciationMethod, DisposalAmounts, NewAsset,
|
||||
ScheduleLine, TaxBasis, TaxBasisRule, 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, ReconciliationReport, UnclearedLine};
|
||||
pub use money::{Currency, Money};
|
||||
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,
|
||||
pub use form1099::{
|
||||
Form1099, PaymentAccounts, Vendor1099Line, Vendor1099Report, vendor_1099_report,
|
||||
vendor_1099_report_with, vendor_1099_report_with_members,
|
||||
};
|
||||
pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
|
||||
pub use ledger::{
|
||||
ControlAccount, GENERAL_EXPENSES, Ledger, PAYABLES, PAYROLL, RECEIVABLES, RETAINED_EARNINGS,
|
||||
REVENUE, ReconciliationReport, UnclearedLine, control_account, control_accounts,
|
||||
};
|
||||
pub use money::{Currency, Money};
|
||||
pub use parties::{Party, PartyField, PartyId, Role, TinFacts, TinType, W4Facts, W9Facts};
|
||||
pub use payouts::{Payout, PayoutAdjustment, PayoutLine, PayoutTotals, payout_plan};
|
||||
pub use payroll::{PayrollLine, PayrollRun, PayrollRunId};
|
||||
pub use periods::{
|
||||
CloseTo, MemberClose, MemberCloseRecord, Period, PeriodId, PeriodStatus, RETAINED_EARNINGS_CODE,
|
||||
};
|
||||
pub use reports::{
|
||||
AssetRegister, AssetRegisterRow, BalanceSheet, BalanceSheetStatement, CashFlowLine,
|
||||
CashFlowParams, CashFlowStatement, ClassProfitAndLoss, GeneralLedger, LedgerAccount,
|
||||
LedgerPosting, MemberCapital, MemberEquityColumn, MembersEquityStatement, ProfitAndLoss,
|
||||
ProfitAndLossStatement, StatementLine, TrialBalance, TrialBalanceLine, balance_sheet_as_of,
|
||||
cash_flow, fixed_asset_register, general_ledger, members_equity, profit_and_loss_for_period,
|
||||
trial_balance_as_of,
|
||||
};
|
||||
pub use schedules::{
|
||||
AmortizationSchedule, Frequency, NewRecurring, NewSchedule, RecurringEntry, RecurringId,
|
||||
ScheduleId, ScheduleKind, TemplateLine,
|
||||
};
|
||||
pub use shares::{PartyMember, PartyShare, Share};
|
||||
pub use tax::{TaxCategory, TaxForm, TaxKind, TaxSummary, TaxSummaryLine, tax_summary};
|
||||
@@ -39,6 +39,24 @@ impl Currency {
|
||||
Ok(Currency(upper))
|
||||
}
|
||||
|
||||
/// The currency's minor units: how many decimal places its smallest
|
||||
/// unit has (2 for USD's cents, 0 for JPY, 3 for KWD), or `None` for
|
||||
/// codes ISO 4217 gives none (gold, SDRs). Unlisted codes are 2.
|
||||
///
|
||||
/// SOURCE: ISO 4217 list one, published 2026-09-17
|
||||
/// (docs/spec/sources/iso-4217.md); generated from it, not typed in.
|
||||
pub fn minor_units(&self) -> Option<u32> {
|
||||
match self.code() {
|
||||
"BIF" | "CLP" | "DJF" | "GNF" | "ISK" | "JPY" | "KMF" | "KRW" | "PYG" | "RWF"
|
||||
| "UGX" | "UYI" | "VND" | "VUV" | "XAF" | "XOF" | "XPF" => Some(0),
|
||||
"BHD" | "IQD" | "JOD" | "KWD" | "LYD" | "OMR" | "TND" => Some(3),
|
||||
"CLF" | "UYW" => Some(4),
|
||||
"XAG" | "XAU" | "XBA" | "XBB" | "XBC" | "XBD" | "XDR" | "XPD" | "XPT" | "XSU"
|
||||
| "XTS" | "XUA" | "XXX" => None,
|
||||
_ => Some(2),
|
||||
}
|
||||
}
|
||||
|
||||
/// The three-letter code, e.g. `"USD"`.
|
||||
pub fn code(&self) -> &str {
|
||||
std::str::from_utf8(&self.0).expect("currency codes are ASCII by construction")
|
||||
@@ -153,6 +171,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// ISO 4217 list one (docs/spec/sources/iso-4217.md): one code from
|
||||
/// each exponent class, and the codes with no minor unit.
|
||||
#[test]
|
||||
fn minor_units_follow_iso_4217() {
|
||||
let units = |c: &str| Currency::new(c).unwrap().minor_units();
|
||||
assert_eq!(units("USD"), Some(2));
|
||||
assert_eq!(units("JPY"), Some(0));
|
||||
assert_eq!(units("BHD"), Some(3));
|
||||
assert_eq!(units("KWD"), Some(3));
|
||||
assert_eq!(units("CLF"), Some(4));
|
||||
assert_eq!(units("UYW"), Some(4));
|
||||
assert_eq!(units("XAU"), None);
|
||||
assert_eq!(units("XDR"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn currency_and_money_print_their_code_and_amount() {
|
||||
assert_eq!(Currency::new("usd").unwrap().to_string(), "USD");
|
||||
assert_eq!(format!("{:?}", Currency::EUR), "Currency(EUR)");
|
||||
assert_eq!(usd(d(1250)).to_string(), "12.50 USD");
|
||||
assert_eq!(format!("{:?}", usd(d(-5))), "-0.05 USD");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_zero_means_exactly_zero() {
|
||||
assert!(Money::zero(Currency::USD).is_zero());
|
||||
assert!(usd(d(0)).is_zero());
|
||||
assert!(!usd(d(1)).is_zero());
|
||||
assert!(!usd(d(-1)).is_zero());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_arithmetic_rejects_currency_mismatch() {
|
||||
let eur = Money::new(d(100), Currency::EUR);
|
||||
|
||||
@@ -0,0 +1,771 @@
|
||||
//! Parties: everyone the company deals with, in one directory.
|
||||
//!
|
||||
//! A party is a vendor, a contractor, a customer, an employee, a member, a
|
||||
//! payout platform, a tax agency, a bank or card issuer, a landlord, or
|
||||
//! several of these. Vendor and customer *records* (bills, invoices,
|
||||
//! payments, aging) stay where they are and link to their party; a party
|
||||
//! adds who they are (names, facts) and links entries to them.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: answer "who is this, and what has the company done with them?"
|
||||
//! for anyone the books touch, without a CRM's sprawl.
|
||||
//! SOURCE: docs/parties-and-attachments.md; DECISION (Stephen,
|
||||
//! 2026-10-15); IRS truncated TINs: Treas. Reg. §301.6109-4.
|
||||
//! INVARIANTS:
|
||||
//! 1. No party field holds a full taxpayer identification number: any
|
||||
//! run of nine digits (single spaces or hyphens between them allowed)
|
||||
//! is refused. A W-9's TIN is kept as its last four digits only.
|
||||
//! 2. W-9 facts need a 1099 role (contractor or landlord); W-4 facts need
|
||||
//! the employee role. Taxpayer-ID facts (`TinFacts`: the TIN's type,
|
||||
//! last four, and vault record) are allowed for any role: members
|
||||
//! need theirs for K-1s.
|
||||
//! 2a. A party's TIN facts and W-9 facts never disagree: both on file
|
||||
//! means the same last four (DECISION, Stephen, 2026-10-15).
|
||||
//! 3. Names are unique, ignoring case and surrounding space, among parties
|
||||
//! not archived.
|
||||
//! 4. Every vendor and customer record links to exactly one party.
|
||||
//! ```
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// Opaque party handle.
|
||||
pub type PartyId = u64;
|
||||
|
||||
/// What a party is to the company.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Role {
|
||||
/// Sells to the company (has a vendor record: bills, payments).
|
||||
Vendor,
|
||||
/// A vendor paid for services who gets a 1099-NEC.
|
||||
Contractor,
|
||||
/// Buys from the company (has a customer record: invoices).
|
||||
Customer,
|
||||
/// Works for the company on payroll.
|
||||
Employee,
|
||||
/// A member (owner) of the company, as in wanderbooks.toml.
|
||||
Member,
|
||||
/// Pays the company out of sales (an app store, a payment processor).
|
||||
PayoutPlatform,
|
||||
/// Taxes the company (the IRS, a state).
|
||||
TaxAgency,
|
||||
/// Regulates or registers the company without taxing it (a secretary
|
||||
/// of state, a division of corporations, a licensing board).
|
||||
GovernmentAgency,
|
||||
/// Holds the company's money or credit (a bank, a card issuer).
|
||||
FinancialInstitution,
|
||||
/// Rents property to the company (gets a 1099-MISC for rent).
|
||||
Landlord,
|
||||
}
|
||||
|
||||
impl Role {
|
||||
/// Every role, in display order.
|
||||
pub const ALL: [Role; 10] = [
|
||||
Role::Vendor,
|
||||
Role::Contractor,
|
||||
Role::Customer,
|
||||
Role::Employee,
|
||||
Role::Member,
|
||||
Role::PayoutPlatform,
|
||||
Role::TaxAgency,
|
||||
Role::GovernmentAgency,
|
||||
Role::FinancialInstitution,
|
||||
Role::Landlord,
|
||||
];
|
||||
|
||||
/// Its wire name, e.g. `payout_platform`.
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
Role::Vendor => "vendor",
|
||||
Role::Contractor => "contractor",
|
||||
Role::Customer => "customer",
|
||||
Role::Employee => "employee",
|
||||
Role::Member => "member",
|
||||
Role::PayoutPlatform => "payout_platform",
|
||||
Role::TaxAgency => "tax_agency",
|
||||
Role::GovernmentAgency => "government_agency",
|
||||
Role::FinancialInstitution => "financial_institution",
|
||||
Role::Landlord => "landlord",
|
||||
}
|
||||
}
|
||||
|
||||
/// The role named `key`.
|
||||
pub fn parse(key: &str) -> Option<Role> {
|
||||
Role::ALL.into_iter().find(|r| r.key() == key)
|
||||
}
|
||||
|
||||
/// Whether this role files a 1099 (and so may carry W-9 facts).
|
||||
pub fn gets_1099(self) -> bool {
|
||||
matches!(self, Role::Contractor | Role::Landlord)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a W-9 said, without the number itself.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct W9Facts {
|
||||
/// When it was received.
|
||||
pub received: NaiveDate,
|
||||
/// Line 3 federal tax classification, e.g. "individual/sole proprietor".
|
||||
pub entity_type: String,
|
||||
/// Line 4 exempt payee code, if any.
|
||||
#[serde(default)]
|
||||
pub exempt_payee_code: Option<String>,
|
||||
/// Line 4 FATCA exemption code, if any.
|
||||
#[serde(default)]
|
||||
pub fatca_code: Option<String>,
|
||||
/// The TIN's last four digits (exactly four digits).
|
||||
pub tin_last4: String,
|
||||
/// Where the form itself is kept, e.g. "wanderbooks vault" or "CPA portal".
|
||||
pub kept_at: String,
|
||||
/// SHA-256 of the vault record holding the full TIN, when one exists:
|
||||
/// proves the vault held it without the books holding it.
|
||||
#[serde(default)]
|
||||
pub vault_record: Option<String>,
|
||||
}
|
||||
|
||||
/// A taxpayer identification number's type.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TinType {
|
||||
/// Social Security number (an individual).
|
||||
Ssn,
|
||||
/// Employer identification number (a business).
|
||||
Ein,
|
||||
/// IRS individual taxpayer identification number.
|
||||
Itin,
|
||||
}
|
||||
|
||||
impl TinType {
|
||||
/// Its wire name: ssn, ein, or itin.
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
TinType::Ssn => "ssn",
|
||||
TinType::Ein => "ein",
|
||||
TinType::Itin => "itin",
|
||||
}
|
||||
}
|
||||
|
||||
/// How a form names it: SSN, EIN, or ITIN.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
TinType::Ssn => "SSN",
|
||||
TinType::Ein => "EIN",
|
||||
TinType::Itin => "ITIN",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the books know of a party's taxpayer identification number,
|
||||
/// without the number: its type, its last four digits, and the hash of
|
||||
/// the TIN vault record that holds it in full. Any role may have one
|
||||
/// (a member's SSN for their K-1, a contractor's from their W-9).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TinFacts {
|
||||
/// SSN, EIN, or ITIN.
|
||||
pub tin_type: TinType,
|
||||
/// The last four digits (exactly four digits).
|
||||
pub last4: String,
|
||||
/// SHA-256 of the vault record holding the full TIN; `None` once the
|
||||
/// record is purged (or when the vault never held it).
|
||||
#[serde(default)]
|
||||
pub vault_record: Option<String>,
|
||||
/// When and why the vault's copy was purged, if it was.
|
||||
#[serde(default)]
|
||||
pub purged: Option<String>,
|
||||
}
|
||||
|
||||
impl TinFacts {
|
||||
/// Check the facts' form. Returns why not.
|
||||
pub fn check(&self) -> Result<(), String> {
|
||||
if self.last4.len() != 4 || !self.last4.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Err("a TIN's last four is exactly four digits".into());
|
||||
}
|
||||
if let Some(h) = &self.vault_record
|
||||
&& (h.len() != 64 || !h.chars().all(|c| c.is_ascii_hexdigit()))
|
||||
{
|
||||
return Err("vault_record is a SHA-256 in hex".into());
|
||||
}
|
||||
if let Some(t) = &self.purged {
|
||||
check_text("purged", t)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// What a W-4 said, without the SSN.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct W4Facts {
|
||||
/// When it was received.
|
||||
pub received: NaiveDate,
|
||||
/// The work state, two letters.
|
||||
pub state: String,
|
||||
/// Where the form itself is kept.
|
||||
pub kept_at: String,
|
||||
}
|
||||
|
||||
/// One party.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Party {
|
||||
/// Opaque handle.
|
||||
pub id: PartyId,
|
||||
/// Legal or trade name, as on documents.
|
||||
pub name: String,
|
||||
/// A shorter name to show, if any.
|
||||
#[serde(default)]
|
||||
pub display_name: Option<String>,
|
||||
/// Roles set by hand (vendor and customer also follow their records).
|
||||
pub roles: BTreeSet<Role>,
|
||||
/// Website.
|
||||
#[serde(default)]
|
||||
pub website: Option<String>,
|
||||
/// Business address.
|
||||
#[serde(default)]
|
||||
pub address: Option<String>,
|
||||
/// Notes.
|
||||
#[serde(default)]
|
||||
pub notes: Option<String>,
|
||||
/// No longer dealt with; history stays.
|
||||
#[serde(default)]
|
||||
pub archived: bool,
|
||||
/// W-9 facts (contractor, landlord).
|
||||
#[serde(default)]
|
||||
pub w9: Option<W9Facts>,
|
||||
/// W-4 facts (employee).
|
||||
#[serde(default)]
|
||||
pub w4: Option<W4Facts>,
|
||||
/// Taxpayer-ID facts (any role): the TIN's type and last four, and
|
||||
/// its vault record. The full number is only ever in the vault.
|
||||
#[serde(default)]
|
||||
pub tin: Option<TinFacts>,
|
||||
/// Its vendor record, if it bills the company.
|
||||
#[serde(default)]
|
||||
pub vendor: Option<crate::VendorId>,
|
||||
/// Its customer record, if the company invoices it.
|
||||
#[serde(default)]
|
||||
pub customer: Option<crate::CustomerId>,
|
||||
}
|
||||
|
||||
impl Party {
|
||||
/// The name to show: the display name, else the name.
|
||||
pub fn label(&self) -> &str {
|
||||
self.display_name.as_deref().unwrap_or(&self.name)
|
||||
}
|
||||
|
||||
/// Its roles: the ones set by hand, plus vendor and customer when it
|
||||
/// has those records.
|
||||
pub fn all_roles(&self) -> BTreeSet<Role> {
|
||||
let mut r = self.roles.clone();
|
||||
if self.vendor.is_some() {
|
||||
r.insert(Role::Vendor);
|
||||
}
|
||||
if self.customer.is_some() {
|
||||
r.insert(Role::Customer);
|
||||
}
|
||||
r
|
||||
}
|
||||
}
|
||||
|
||||
/// The key names are compared by: case and surrounding space ignored.
|
||||
pub fn name_key(name: &str) -> String {
|
||||
name.trim().to_lowercase()
|
||||
}
|
||||
|
||||
/// The characters that may sit between a TIN's digits: space, hyphen,
|
||||
/// dot, slash, underscore, and the Unicode dashes and no-break space
|
||||
/// (`571-93-2846`, `571.93.2846`, `571/93/2846`, `571 - 93 - 2846`).
|
||||
fn tin_separator(c: char) -> bool {
|
||||
matches!(
|
||||
c,
|
||||
' ' | '-'
|
||||
| '.'
|
||||
| '/'
|
||||
| '_'
|
||||
| '\u{a0}'
|
||||
| '\u{2010}'
|
||||
| '\u{2011}'
|
||||
| '\u{2012}'
|
||||
| '\u{2013}'
|
||||
| '\u{2014}'
|
||||
| '\u{2212}'
|
||||
)
|
||||
}
|
||||
|
||||
/// The most separators that may sit between two of a TIN's digits
|
||||
/// (`" - "` is three).
|
||||
const MAX_TIN_GAP: usize = 3;
|
||||
|
||||
/// Each run in `chars` that holds nine digits or more, as (first, last)
|
||||
/// char indices of its digits: digits with at most [`MAX_TIN_GAP`]
|
||||
/// separators ([`tin_separator`]) between neighbors.
|
||||
fn full_tin_runs(chars: &[char]) -> Vec<(usize, usize)> {
|
||||
let mut runs = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
if !chars[i].is_numeric() {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let (mut digits, mut last, mut j) = (1, i, i + 1);
|
||||
while j < chars.len() {
|
||||
if chars[j].is_numeric() {
|
||||
digits += 1;
|
||||
last = j;
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
let gap = chars[j..].iter().take_while(|c| tin_separator(**c)).count();
|
||||
if (1..=MAX_TIN_GAP).contains(&gap)
|
||||
&& chars.get(j + gap).is_some_and(|c| c.is_numeric())
|
||||
{
|
||||
j += gap;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if digits >= 9 {
|
||||
runs.push((i, last));
|
||||
}
|
||||
i = last + 1;
|
||||
}
|
||||
runs
|
||||
}
|
||||
|
||||
/// Whether `text` holds a run of nine digits, with at most three
|
||||
/// separators (space, hyphen, dot, slash, underscore, a Unicode dash or
|
||||
/// no-break space) between neighbors: an SSN, EIN, or ITIN however it's
|
||||
/// written (`571-93-2846`, `571.93.2846`, `571 - 93 - 2846`). Any Unicode
|
||||
/// numeral counts as a digit (full-width, Arabic-Indic, ...), so a number
|
||||
/// can't slip through in another script.
|
||||
pub fn has_full_tin(text: &str) -> bool {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
!full_tin_runs(&chars).is_empty()
|
||||
}
|
||||
|
||||
/// `text` with every run [`has_full_tin`] finds masked: each of its
|
||||
/// digits becomes `•`, separators kept. For refusals that quote what they
|
||||
/// were given, so no error echoes a full TIN (docs/spec/rules.md B-26).
|
||||
pub fn redact_full_tins(text: &str) -> std::borrow::Cow<'_, str> {
|
||||
let mut chars: Vec<char> = text.chars().collect();
|
||||
let runs = full_tin_runs(&chars);
|
||||
if runs.is_empty() {
|
||||
return std::borrow::Cow::Borrowed(text);
|
||||
}
|
||||
for (first, last) in runs {
|
||||
for c in &mut chars[first..=last] {
|
||||
if c.is_numeric() {
|
||||
*c = '•';
|
||||
}
|
||||
}
|
||||
}
|
||||
std::borrow::Cow::Owned(chars.into_iter().collect())
|
||||
}
|
||||
|
||||
/// Check one party text field: not a full TIN, at most 500 characters, no
|
||||
/// control characters except newline (addresses and notes run several
|
||||
/// lines). Returns why not.
|
||||
pub fn check_text(field: &str, text: &str) -> Result<(), String> {
|
||||
if has_full_tin(text) {
|
||||
return Err(format!(
|
||||
"{field} looks like it holds a full taxpayer identification number; \
|
||||
the books keep only its last four digits (the full number belongs in the vault)"
|
||||
));
|
||||
}
|
||||
if text.chars().count() > 500 {
|
||||
return Err(format!("{field} is at most 500 characters"));
|
||||
}
|
||||
if text.chars().any(|c| c.is_control() && c != '\n') {
|
||||
return Err(format!("{field} can't contain control characters"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl W9Facts {
|
||||
/// Check the facts' form. Returns why not.
|
||||
pub fn check(&self) -> Result<(), String> {
|
||||
if self.tin_last4.len() != 4 || !self.tin_last4.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Err("tin_last4 is exactly four digits".into());
|
||||
}
|
||||
if self.entity_type.trim().is_empty() {
|
||||
return Err("a W-9's entity type (line 3) is required".into());
|
||||
}
|
||||
if self.kept_at.trim().is_empty() {
|
||||
return Err("say where the W-9 itself is kept".into());
|
||||
}
|
||||
for (f, t) in [
|
||||
("entity_type", Some(self.entity_type.as_str())),
|
||||
("exempt_payee_code", self.exempt_payee_code.as_deref()),
|
||||
("fatca_code", self.fatca_code.as_deref()),
|
||||
("kept_at", Some(self.kept_at.as_str())),
|
||||
] {
|
||||
if let Some(t) = t {
|
||||
check_text(f, t)?;
|
||||
}
|
||||
}
|
||||
if let Some(h) = &self.vault_record
|
||||
&& (h.len() != 64 || !h.chars().all(|c| c.is_ascii_hexdigit()))
|
||||
{
|
||||
return Err("vault_record is a SHA-256 in hex".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl W4Facts {
|
||||
/// Check the facts' form. Returns why not.
|
||||
pub fn check(&self) -> Result<(), String> {
|
||||
if self.state.len() != 2 || !self.state.chars().all(|c| c.is_ascii_uppercase()) {
|
||||
return Err("state is two capital letters, e.g. UT".into());
|
||||
}
|
||||
if self.kept_at.trim().is_empty() {
|
||||
return Err("say where the W-4 itself is kept".into());
|
||||
}
|
||||
check_text("kept_at", &self.kept_at)
|
||||
}
|
||||
}
|
||||
|
||||
/// One fact of a party to change ([`crate::Ledger::update_party`]).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "field", content = "value")]
|
||||
pub enum PartyField {
|
||||
/// The legal or trade name.
|
||||
Name(String),
|
||||
/// The display name (`None` clears it).
|
||||
DisplayName(Option<String>),
|
||||
/// The website.
|
||||
Website(Option<String>),
|
||||
/// The business address.
|
||||
Address(Option<String>),
|
||||
/// Notes.
|
||||
Notes(Option<String>),
|
||||
/// Archived (no longer dealt with) or not.
|
||||
Archived(bool),
|
||||
}
|
||||
|
||||
/// What the books did with one party over a span of dates
|
||||
/// ([`activity`]).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Activity {
|
||||
/// The party's entries, drafts included, by id.
|
||||
pub entries: BTreeSet<crate::EntryId>,
|
||||
/// Paid to it in the span (net of refunds and reversals of payments).
|
||||
pub paid: rust_decimal::Decimal,
|
||||
/// Received from it in the span (net of reversals of receipts).
|
||||
pub received: rust_decimal::Decimal,
|
||||
/// Posted entries in the span naming it among several parties whose
|
||||
/// shares don't resolve against the entry and the members given
|
||||
/// ([`crate::shares::resolve`]): counted in neither total.
|
||||
pub unresolved: BTreeSet<crate::EntryId>,
|
||||
}
|
||||
|
||||
/// The accounts [`entry_flow`] reads money from: the cash accounts (every
|
||||
/// asset account but receivables when `cash` is empty), and the
|
||||
/// liabilities other than payables and the payroll withholdings (cards,
|
||||
/// what members paid for the company and are owed back, loans).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct MoneyAccounts {
|
||||
/// Cash: bank, savings, cash on hand.
|
||||
pub cash: std::collections::HashSet<crate::AccountId>,
|
||||
/// Liabilities that pay for the company or are paid down.
|
||||
pub liabilities: std::collections::HashSet<crate::AccountId>,
|
||||
}
|
||||
|
||||
/// The [`MoneyAccounts`] of `ledger`, with `cash` the books' cash accounts.
|
||||
pub fn money_accounts(ledger: &crate::Ledger, cash: &[crate::AccountId]) -> MoneyAccounts {
|
||||
use crate::AccountType;
|
||||
let not_money: std::collections::HashSet<crate::AccountId> = crate::PAYROLL[2..]
|
||||
.iter()
|
||||
.chain([&crate::PAYABLES, &crate::RECEIVABLES])
|
||||
.filter_map(|c| ledger.role_account(c))
|
||||
.collect();
|
||||
let mut out = MoneyAccounts::default();
|
||||
for a in ledger.chart().all().filter(|a| !not_money.contains(&a.id)) {
|
||||
match a.account_type {
|
||||
AccountType::Asset if cash.is_empty() || cash.contains(&a.id) => {
|
||||
out.cash.insert(a.id);
|
||||
}
|
||||
AccountType::Liability => {
|
||||
out.liabilities.insert(a.id);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// What one entry moved with its counterparty ([`entry_flow`]).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct EntryFlow {
|
||||
/// Paid to the counterparty (positive) or received from it (negative).
|
||||
pub net: rust_decimal::Decimal,
|
||||
/// What for, by account (debits − credits): positive is what the money
|
||||
/// paid for, negative where money received came from. Sums to `net`.
|
||||
pub what_for: Vec<(crate::AccountId, rust_decimal::Decimal)>,
|
||||
}
|
||||
|
||||
/// The money one entry moved with its counterparty, and what for.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: "how much did we pay them" right however it was paid: from the
|
||||
/// bank, on a card, by a member for the company, or by paying down
|
||||
/// what the company owes them (a reimbursement, a card payment).
|
||||
/// SOURCE: DECISION (Stephen, 2026-10-15), after a reimbursement to the
|
||||
/// members netted to zero under the earlier rule (cash and
|
||||
/// liabilities alike as money).
|
||||
/// DEFINITIONS (over the entry's lines, in `money`):
|
||||
/// cash_cr, cash_dr: credits and debits on cash accounts;
|
||||
/// liab_cr, liab_dr: credits and debits on money liabilities;
|
||||
/// down = min(liab_dr, cash_cr): cash paying down a liability (its
|
||||
/// creditor is paid; the liability is what for);
|
||||
/// up = min(liab_cr, cash_dr): a liability raising cash (borrowing: the
|
||||
/// lender pays the company);
|
||||
/// left(a), for each money liability a: its debits less what of them
|
||||
/// `down` takes, less its credits less what of them `up` takes (`down`
|
||||
/// and `up` taken from its lines in line order), so one account's
|
||||
/// debits and credits offset first;
|
||||
/// across = min(Σ left(a) > 0, Σ −left(a) < 0): one liability paying
|
||||
/// down another (a member paying the card from their own funds: Dr
|
||||
/// 2050 / Cr 2100; a loan paying off a card): the debited liability's
|
||||
/// creditor is paid, by the credited one's.
|
||||
/// INVARIANTS:
|
||||
/// 1. net = (cash_cr − cash_dr) + (liab_cr − liab_dr) + down − up
|
||||
/// + across.
|
||||
/// 2. what_for: every line not on a money account (debits − credits),
|
||||
/// plus `down` on the debited liabilities and −`up` on the credited
|
||||
/// ones (in line order), plus `across` on the liabilities with
|
||||
/// left(a) > 0 (in order of first appearance), zero sums left out,
|
||||
/// accounts merged; its sum is net exactly, because the entry
|
||||
/// balances.
|
||||
/// SCENARIOS:
|
||||
/// 1. Expense from the bank, on a card, or paid by a member for the
|
||||
/// company (Dr expense / Cr 1000, 2050, or 2100): net is the amount.
|
||||
/// 2. Reimbursing members (Dr 2100 / Cr 1000) or paying a card (Dr 2050 /
|
||||
/// Cr 1000): net is the amount, what for is the liability.
|
||||
/// 3. A loan (Dr 1000 / Cr loan): net is minus the amount (received).
|
||||
/// 4. A refund (Dr 1000 or Dr card / Cr expense): negative net. A charge
|
||||
/// and a refund on the same card in one entry net on that card: no
|
||||
/// card paid down.
|
||||
/// 5. A transfer between the company's own cash accounts: zero.
|
||||
/// 6. A member contributing property (Dr equipment / Cr capital): zero.
|
||||
/// 7. A member paying the card bill from their own funds (Dr 2050 / Cr
|
||||
/// 2100), or a loan paying it off (Dr 2050 / Cr loan): net is the
|
||||
/// amount, what for is the card, as if paid from the bank.
|
||||
/// ```
|
||||
pub fn entry_flow(
|
||||
entry: &crate::JournalEntry,
|
||||
money: &MoneyAccounts,
|
||||
) -> Result<EntryFlow, crate::LedgerError> {
|
||||
use crate::Side;
|
||||
use rust_decimal::Decimal;
|
||||
// A draft's lines are as journaled, of any size (rules.md B-28): every
|
||||
// sum is checked, and one past exact arithmetic is refused.
|
||||
let too_large =
|
||||
|| crate::LedgerError::TooLargeToCompute(format!("entry {}'s money flow", entry.id()));
|
||||
let plus = |a: Decimal, b: Decimal| a.checked_add(b).ok_or_else(too_large);
|
||||
let minus = |a: Decimal, b: Decimal| a.checked_sub(b).ok_or_else(too_large);
|
||||
let (mut cash_cr, mut cash_dr, mut liab_cr, mut liab_dr) =
|
||||
(Decimal::ZERO, Decimal::ZERO, Decimal::ZERO, Decimal::ZERO);
|
||||
for l in entry.lines() {
|
||||
let a = l.amount.amount;
|
||||
match (
|
||||
money.cash.contains(&l.account),
|
||||
money.liabilities.contains(&l.account),
|
||||
l.side,
|
||||
) {
|
||||
(true, _, Side::Credit) => cash_cr = plus(cash_cr, a)?,
|
||||
(true, _, Side::Debit) => cash_dr = plus(cash_dr, a)?,
|
||||
(false, true, Side::Credit) => liab_cr = plus(liab_cr, a)?,
|
||||
(false, true, Side::Debit) => liab_dr = plus(liab_dr, a)?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let down = liab_dr.min(cash_cr);
|
||||
let up = liab_cr.min(cash_dr);
|
||||
let mut by: std::collections::BTreeMap<crate::AccountId, Decimal> = Default::default();
|
||||
let (mut down_left, mut up_left) = (down, up);
|
||||
let mut order: Vec<crate::AccountId> = Vec::new();
|
||||
// left(a) per money liability, in order of first appearance.
|
||||
let mut left: Vec<(crate::AccountId, Decimal)> = Vec::new();
|
||||
let add = |by: &mut std::collections::BTreeMap<crate::AccountId, Decimal>,
|
||||
order: &mut Vec<crate::AccountId>,
|
||||
account: crate::AccountId,
|
||||
part: Decimal|
|
||||
-> Result<(), crate::LedgerError> {
|
||||
if !part.is_zero() {
|
||||
if !by.contains_key(&account) {
|
||||
order.push(account);
|
||||
}
|
||||
let sum = by.entry(account).or_default();
|
||||
*sum = plus(*sum, part)?;
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
for l in entry.lines() {
|
||||
let a = l.amount.amount;
|
||||
let part = if money.cash.contains(&l.account) {
|
||||
Decimal::ZERO
|
||||
} else if money.liabilities.contains(&l.account) {
|
||||
let (part, rest) = match l.side {
|
||||
Side::Debit => {
|
||||
let take = a.min(down_left);
|
||||
down_left -= take;
|
||||
(take, a - take)
|
||||
}
|
||||
Side::Credit => {
|
||||
let take = a.min(up_left);
|
||||
up_left -= take;
|
||||
(-take, -(a - take))
|
||||
}
|
||||
};
|
||||
match left.iter_mut().find(|(x, _)| *x == l.account) {
|
||||
Some((_, v)) => *v = plus(*v, rest)?,
|
||||
None => left.push((l.account, rest)),
|
||||
}
|
||||
part
|
||||
} else {
|
||||
match l.side {
|
||||
Side::Debit => a,
|
||||
Side::Credit => -a,
|
||||
}
|
||||
};
|
||||
add(&mut by, &mut order, l.account, part)?;
|
||||
}
|
||||
let owed_down = left
|
||||
.iter()
|
||||
.map(|(_, v)| *v)
|
||||
.filter(|v| *v > Decimal::ZERO)
|
||||
.try_fold(Decimal::ZERO, &plus)?;
|
||||
let owed_up = -left
|
||||
.iter()
|
||||
.map(|(_, v)| *v)
|
||||
.filter(|v| *v < Decimal::ZERO)
|
||||
.try_fold(Decimal::ZERO, &plus)?;
|
||||
let across = owed_down.min(owed_up);
|
||||
let mut across_left = across;
|
||||
for (account, v) in left {
|
||||
if v > Decimal::ZERO && !across_left.is_zero() {
|
||||
let take = v.min(across_left);
|
||||
across_left -= take;
|
||||
add(&mut by, &mut order, account, take)?;
|
||||
}
|
||||
}
|
||||
let net = plus(
|
||||
plus(minus(cash_cr, cash_dr)?, minus(liab_cr, liab_dr)?)?,
|
||||
plus(minus(down, up)?, across)?,
|
||||
)?;
|
||||
Ok(EntryFlow {
|
||||
net,
|
||||
what_for: order
|
||||
.into_iter()
|
||||
.map(|a| (a, by[&a]))
|
||||
.filter(|(_, v)| !v.is_zero())
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
/// One party's entries, and the money paid to and received from it in
|
||||
/// `start..=end`, with no members ([`activity_with`] with none: an entry
|
||||
/// naming several parties then needs explicit shares to count).
|
||||
pub fn activity(
|
||||
ledger: &crate::Ledger,
|
||||
party: PartyId,
|
||||
span: (NaiveDate, NaiveDate),
|
||||
cash: &[crate::AccountId],
|
||||
) -> Activity {
|
||||
activity_with(ledger, party, span, cash, &[])
|
||||
}
|
||||
|
||||
/// One party's entries, and its share of the money paid to and received
|
||||
/// from the entries' parties in `start..=end`.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: one answer to "what did we pay them this year?" for the MCP
|
||||
/// server and the viewer alike, right when an entry was with
|
||||
/// several parties.
|
||||
/// SOURCE: docs/parties-and-attachments.md (Parties, Shares); DECISION
|
||||
/// (Stephen, 2026-10-15).
|
||||
/// INVARIANTS:
|
||||
/// 1. entries: every entry Ledger::entry_parties names the party in
|
||||
/// (hand tags, vendor tags, bills and their payments, invoices and
|
||||
/// their payments, and reversals of any of these carry it).
|
||||
/// 2. Each posted entry dated in the span whose entry_flow net is not
|
||||
/// zero counts the party's resolved share of it
|
||||
/// (crate::shares::resolve with `members`; the whole net for a sole
|
||||
/// party): positive is a payment, negative a receipt. A reversal
|
||||
/// counts on its root original's side (the first entry of
|
||||
/// Ledger::reversal_chain), so a reversal subtracts and a reversal
|
||||
/// of that reversal adds back, on the same side.
|
||||
/// 3. An entry moving no money (net zero, like property contributed)
|
||||
/// counts nothing. An entry whose shares don't resolve counts
|
||||
/// nothing and is listed in `unresolved`.
|
||||
/// 4. Over all the parties an entry names, the shares counted sum to its
|
||||
/// net exactly (crate::shares INVARIANT 6).
|
||||
/// 5. A card charge counts as paid (the company paid them, by card); a
|
||||
/// bill doesn't until it is paid. Not the 1099 amount: that is
|
||||
/// form1099::vendor_1099_report_with.
|
||||
/// ```
|
||||
pub fn activity_with(
|
||||
ledger: &crate::Ledger,
|
||||
party: PartyId,
|
||||
(start, end): (NaiveDate, NaiveDate),
|
||||
cash: &[crate::AccountId],
|
||||
members: &[crate::PartyMember],
|
||||
) -> Activity {
|
||||
use rust_decimal::Decimal;
|
||||
let entries: BTreeSet<crate::EntryId> = (0..ledger.next_entry_id())
|
||||
.filter(|&id| ledger.entry_names_party(id, party))
|
||||
.collect();
|
||||
let money = money_accounts(ledger, cash);
|
||||
// A posted entry's lines are in a line's range, so its flow computes;
|
||||
// one that doesn't counts nothing and is listed as unresolved.
|
||||
let net_out = |e: &crate::JournalEntry| entry_flow(e, &money).map(|f| f.net);
|
||||
let (mut paid, mut received) = (Decimal::ZERO, Decimal::ZERO);
|
||||
let mut unresolved = BTreeSet::new();
|
||||
for &id in &entries {
|
||||
let Some(e) = ledger.get_entry(id) else {
|
||||
continue;
|
||||
};
|
||||
if e.status() != crate::EntryStatus::Posted || e.date() < start || e.date() > end {
|
||||
continue;
|
||||
}
|
||||
let Ok(net) = net_out(e) else {
|
||||
unresolved.insert(id);
|
||||
continue;
|
||||
};
|
||||
if net.is_zero() {
|
||||
continue;
|
||||
}
|
||||
let share = match crate::shares::resolve(e, ledger.entry_parties(id), &money, members) {
|
||||
Ok(parts) => parts
|
||||
.into_iter()
|
||||
.find(|(p, _)| *p == party)
|
||||
.map(|(_, v)| v)
|
||||
.unwrap_or_default(),
|
||||
Err(_) => {
|
||||
unresolved.insert(id);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let side = match e.reversal_of() {
|
||||
Some(_) => ledger
|
||||
.reversal_chain(id)
|
||||
.first()
|
||||
.and_then(|&root| ledger.get_entry(root))
|
||||
.map_or(Ok(net), net_out)
|
||||
.unwrap_or(net),
|
||||
None => net,
|
||||
};
|
||||
if side > Decimal::ZERO {
|
||||
paid += share;
|
||||
} else {
|
||||
received -= share;
|
||||
}
|
||||
}
|
||||
Activity {
|
||||
entries,
|
||||
paid,
|
||||
received,
|
||||
unresolved,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
//! Payouts: a platform deposit grossed up into what really happened.
|
||||
//!
|
||||
//! App stores, payment processors, and marketplaces collect revenue, keep
|
||||
//! their fees, and deposit the difference. Booking only the deposit as
|
||||
//! revenue understates income and hides the fees; the correct entry grosses
|
||||
//! it up:
|
||||
//!
|
||||
//! ```text
|
||||
//! Dr bank net deposit
|
||||
//! Dr platform fees fees withheld
|
||||
//! Dr/Cr adjustments refunds, chargebacks, reserves, tax withheld, ...
|
||||
//! Cr revenue gross sales (one line per product or class)
|
||||
//! ```
|
||||
//!
|
||||
//! [`payout_plan`] builds those lines and refuses unless gross − fees ±
|
||||
//! adjustments equals the deposit exactly, naming the difference. It knows
|
||||
//! no platform: callers (or a format-specific parser) supply the numbers
|
||||
//! from the payout statement, which is the entry's receipt.
|
||||
|
||||
use crate::{AccountId, AccountType, ChartOfAccounts, ClassId, LedgerError, Side};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
/// A revenue or fee line of a payout.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PayoutLine {
|
||||
/// The account.
|
||||
pub account: AccountId,
|
||||
/// Positive amount.
|
||||
pub amount: Decimal,
|
||||
/// Optional class (product line, app, store).
|
||||
pub class: Option<ClassId>,
|
||||
}
|
||||
|
||||
/// Anything else on the statement: refunds, chargebacks, reserves held or
|
||||
/// released, taxes withheld, currency conversion. A debit reduces the
|
||||
/// deposit; a credit increases it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PayoutAdjustment {
|
||||
/// The account.
|
||||
pub account: AccountId,
|
||||
/// Debit (reduces the deposit) or credit (increases it).
|
||||
pub side: Side,
|
||||
/// Positive amount.
|
||||
pub amount: Decimal,
|
||||
/// Optional class.
|
||||
pub class: Option<ClassId>,
|
||||
}
|
||||
|
||||
/// A payout to gross up.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Payout {
|
||||
/// Where the money landed.
|
||||
pub deposit_account: AccountId,
|
||||
/// The net deposit. Negative when the platform debited the account
|
||||
/// (fees and refunds exceeded sales).
|
||||
pub deposit: Decimal,
|
||||
/// Gross revenue, credited.
|
||||
pub revenue: Vec<PayoutLine>,
|
||||
/// Fees withheld, debited.
|
||||
pub fees: Vec<PayoutLine>,
|
||||
/// Everything else.
|
||||
pub adjustments: Vec<PayoutAdjustment>,
|
||||
}
|
||||
|
||||
/// The payout's totals.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PayoutTotals {
|
||||
/// Sum of revenue lines.
|
||||
pub gross: Decimal,
|
||||
/// Sum of fee lines.
|
||||
pub fees: Decimal,
|
||||
/// Net effect of adjustments on the deposit (credits − debits).
|
||||
pub adjustments: Decimal,
|
||||
/// The deposit.
|
||||
pub deposit: Decimal,
|
||||
}
|
||||
|
||||
/// One planned line: account, side, amount, class.
|
||||
pub type PayoutPlanLine = (AccountId, Side, Decimal, Option<ClassId>);
|
||||
|
||||
/// Validate a payout and build its entry's lines: the deposit first, then
|
||||
/// fees, adjustments, and revenue. Errors unless every amount is positive,
|
||||
/// every account is postable and fits its role (revenue lines credit
|
||||
/// revenue or liability accounts, e.g. deferred revenue or sales tax
|
||||
/// collected; fees debit expense accounts), and the payout reconciles to
|
||||
/// the cent.
|
||||
/// A zero deposit (fees and adjustments ate the sales exactly) has no
|
||||
/// deposit line: every planned line is positive (B-17, docs/spec/rules.md).
|
||||
pub fn payout_plan(
|
||||
payout: &Payout,
|
||||
chart: &ChartOfAccounts,
|
||||
) -> Result<(Vec<PayoutPlanLine>, PayoutTotals), LedgerError> {
|
||||
let bad = |m: String| Err(LedgerError::InvalidPayout(m));
|
||||
if payout.revenue.is_empty() {
|
||||
return bad("a payout needs at least one revenue line".into());
|
||||
}
|
||||
let check = |account: AccountId,
|
||||
amount: Decimal,
|
||||
role: &str,
|
||||
allowed: &[AccountType]|
|
||||
-> Result<(), LedgerError> {
|
||||
let a = chart
|
||||
.get(account)
|
||||
.ok_or(LedgerError::UnknownAccount(account))?;
|
||||
if a.placeholder {
|
||||
return Err(LedgerError::InvalidPayout(format!(
|
||||
"{role} account {} is a placeholder",
|
||||
a.code
|
||||
)));
|
||||
}
|
||||
if !allowed.is_empty() && !allowed.contains(&a.account_type) {
|
||||
return Err(LedgerError::InvalidPayout(format!(
|
||||
"{role} account {} is {:?}; expected {}",
|
||||
a.code,
|
||||
a.account_type,
|
||||
allowed
|
||||
.iter()
|
||||
.map(|t| format!("{t:?}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" or ")
|
||||
)));
|
||||
}
|
||||
if amount <= Decimal::ZERO {
|
||||
return Err(LedgerError::InvalidPayout(format!(
|
||||
"{role} amounts must be positive, got {amount} on {}",
|
||||
a.code
|
||||
)));
|
||||
}
|
||||
// No larger than a journal line may be: the payout's totals are
|
||||
// sums of them (P0 review round 3, S1).
|
||||
if !crate::kernel::Amount::in_line_range(amount) {
|
||||
return Err(LedgerError::ValueOutOfRange(format!(
|
||||
"{role} amount {amount} on {}",
|
||||
a.code
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
if !crate::kernel::Amount::in_line_range(payout.deposit) {
|
||||
return Err(LedgerError::ValueOutOfRange(format!(
|
||||
"deposit {}",
|
||||
payout.deposit
|
||||
)));
|
||||
}
|
||||
let deposit_account = chart
|
||||
.get(payout.deposit_account)
|
||||
.ok_or(LedgerError::UnknownAccount(payout.deposit_account))?;
|
||||
if deposit_account.placeholder || deposit_account.account_type != AccountType::Asset {
|
||||
return bad(format!(
|
||||
"deposit account {} must be a postable asset account",
|
||||
deposit_account.code
|
||||
));
|
||||
}
|
||||
for l in &payout.revenue {
|
||||
check(
|
||||
l.account,
|
||||
l.amount,
|
||||
"revenue",
|
||||
&[AccountType::Revenue, AccountType::Liability],
|
||||
)?;
|
||||
}
|
||||
for l in &payout.fees {
|
||||
check(l.account, l.amount, "fee", &[AccountType::Expense])?;
|
||||
}
|
||||
for a in &payout.adjustments {
|
||||
check(a.account, a.amount, "adjustment", &[])?;
|
||||
}
|
||||
let gross: Decimal = payout.revenue.iter().map(|l| l.amount).sum();
|
||||
let fees: Decimal = payout.fees.iter().map(|l| l.amount).sum();
|
||||
let adjustments: Decimal = payout
|
||||
.adjustments
|
||||
.iter()
|
||||
.map(|a| match a.side {
|
||||
Side::Credit => a.amount,
|
||||
Side::Debit => -a.amount,
|
||||
})
|
||||
.sum();
|
||||
let expected = gross - fees + adjustments;
|
||||
if expected != payout.deposit {
|
||||
return bad(format!(
|
||||
"payout doesn't reconcile: gross {gross} − fees {fees} + adjustments \
|
||||
{adjustments} = {expected}, but the deposit is {} (off by {})",
|
||||
payout.deposit,
|
||||
payout.deposit - expected
|
||||
));
|
||||
}
|
||||
let mut plan = Vec::new();
|
||||
if payout.deposit > Decimal::ZERO {
|
||||
plan.push((payout.deposit_account, Side::Debit, payout.deposit, None));
|
||||
} else if payout.deposit < Decimal::ZERO {
|
||||
plan.push((payout.deposit_account, Side::Credit, -payout.deposit, None));
|
||||
}
|
||||
plan.extend(
|
||||
payout
|
||||
.fees
|
||||
.iter()
|
||||
.map(|l| (l.account, Side::Debit, l.amount, l.class)),
|
||||
);
|
||||
plan.extend(
|
||||
payout
|
||||
.adjustments
|
||||
.iter()
|
||||
.map(|a| (a.account, a.side, a.amount, a.class)),
|
||||
);
|
||||
plan.extend(
|
||||
payout
|
||||
.revenue
|
||||
.iter()
|
||||
.map(|l| (l.account, Side::Credit, l.amount, l.class)),
|
||||
);
|
||||
Ok((
|
||||
plan,
|
||||
PayoutTotals {
|
||||
gross,
|
||||
fees,
|
||||
adjustments,
|
||||
deposit: payout.deposit,
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -24,6 +24,10 @@ pub type PayrollRunId = u64;
|
||||
pub struct PayrollLine {
|
||||
/// Employee name, for the run record.
|
||||
pub employee_name: String,
|
||||
/// The employee's party, if named (new runs should; runs from before
|
||||
/// the party directory carry the name only).
|
||||
#[serde(default)]
|
||||
pub employee: Option<crate::PartyId>,
|
||||
/// Gross pay for the period.
|
||||
pub gross_pay: Decimal,
|
||||
/// Federal income tax withheld.
|
||||
@@ -44,14 +48,20 @@ pub struct PayrollLine {
|
||||
|
||||
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
|
||||
/// you cannot withhold more than the employee earned. Refused
|
||||
/// (TooLargeToCompute), never a panic, past exact arithmetic: a run is
|
||||
/// restored as journaled (rules.md B-28).
|
||||
pub fn net_pay(&self) -> Result<Decimal, LedgerError> {
|
||||
[
|
||||
self.federal_withholding,
|
||||
self.state_withholding,
|
||||
self.ss_employee,
|
||||
self.medicare_employee,
|
||||
self.other_deductions,
|
||||
]
|
||||
.into_iter()
|
||||
.try_fold(self.gross_pay, Decimal::checked_sub)
|
||||
.ok_or_else(|| LedgerError::TooLargeToCompute(format!("{}'s net pay", self.employee_name)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +101,10 @@ pub struct PayrollAccounts {
|
||||
/// 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() {
|
||||
// No wages at all is no payroll: refused before anything changes
|
||||
// (found by the spec-blind tests, 2026-10-13: an all-zero run passed
|
||||
// validation, then failed at post after creating accounts).
|
||||
if lines.is_empty() || lines.iter().all(|l| l.gross_pay.is_zero()) {
|
||||
return Err(LedgerError::EmptyPayroll);
|
||||
}
|
||||
for line in lines {
|
||||
@@ -109,13 +122,40 @@ pub fn validate_payroll_lines(lines: &[PayrollLine]) -> Result<(), LedgerError>
|
||||
return Err(LedgerError::NegativePayrollAmount(amount));
|
||||
}
|
||||
}
|
||||
if line.net_pay() < Decimal::ZERO {
|
||||
check_payroll_ranges(std::slice::from_ref(line))?;
|
||||
if line.net_pay()? < Decimal::ZERO {
|
||||
return Err(LedgerError::NegativeNetPay(line.employee_name.clone()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every amount of every line no larger than a journal line may be
|
||||
/// (kernel::MAX_LINE_AMOUNT, ten decimal places), so net pay and the run's
|
||||
/// totals are exact (P0 review round 3, S1). For new runs only: replay
|
||||
/// restores a run as journaled (rules.md B-28).
|
||||
pub fn check_payroll_ranges(lines: &[PayrollLine]) -> Result<(), LedgerError> {
|
||||
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 !crate::kernel::Amount::in_line_range(amount) {
|
||||
return Err(LedgerError::ValueOutOfRange(format!(
|
||||
"payroll amount {amount}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
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.
|
||||
@@ -124,21 +164,28 @@ pub fn validate_payroll_lines(lines: &[PayrollLine]) -> Result<(), LedgerError>
|
||||
/// 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.
|
||||
/// Refused (TooLargeToCompute) when a total is past exact arithmetic.
|
||||
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());
|
||||
) -> Result<Vec<(AccountId, Side, Money)>, LedgerError> {
|
||||
let too_large = || LedgerError::TooLargeToCompute("the payroll run's totals".into());
|
||||
let sum = |f: &dyn Fn(&PayrollLine) -> Option<Decimal>| {
|
||||
lines
|
||||
.iter()
|
||||
.try_fold(Decimal::ZERO, |t, l| t.checked_add(f(l)?))
|
||||
.ok_or_else(too_large)
|
||||
};
|
||||
let gross = sum(&|l| Some(l.gross_pay))?;
|
||||
let employer = sum(&|l| l.ss_employer.checked_add(l.medicare_employer))?;
|
||||
let federal = sum(&|l| Some(l.federal_withholding))?;
|
||||
let state = sum(&|l| Some(l.state_withholding))?;
|
||||
let ss = sum(&|l| l.ss_employee.checked_add(l.ss_employer))?;
|
||||
let medicare = sum(&|l| l.medicare_employee.checked_add(l.medicare_employer))?;
|
||||
let other = sum(&|l| Some(l.other_deductions))?;
|
||||
let net = sum(&|l| l.net_pay().ok())?;
|
||||
let mut plan = Vec::new();
|
||||
let mut push = |account: AccountId, side: Side, amount: Decimal| {
|
||||
if !amount.is_zero() {
|
||||
@@ -153,7 +200,7 @@ pub fn payroll_entry_plan(
|
||||
push(accounts.medicare_payable, Side::Credit, medicare);
|
||||
push(accounts.other_payable, Side::Credit, other);
|
||||
push(cash_account, Side::Credit, net);
|
||||
plan
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
/// The remittance entry's lines: Dr each payable with a non-zero balance,
|
||||
@@ -191,6 +238,7 @@ mod tests {
|
||||
fn line(name: &str) -> PayrollLine {
|
||||
PayrollLine {
|
||||
employee_name: name.to_string(),
|
||||
employee: None,
|
||||
gross_pay: d(500_000), // 5000.00
|
||||
federal_withholding: d(75_000), // 750.00
|
||||
state_withholding: d(25_000), // 250.00
|
||||
@@ -214,7 +262,7 @@ mod tests {
|
||||
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));
|
||||
assert_eq!(l.net_pay().unwrap(), d(351_750));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -228,7 +276,7 @@ mod tests {
|
||||
);
|
||||
let mut over = line("Cat");
|
||||
over.other_deductions = d(99_999_999);
|
||||
assert!(over.net_pay() < Decimal::ZERO);
|
||||
assert!(over.net_pay().unwrap() < Decimal::ZERO);
|
||||
assert_eq!(
|
||||
validate_payroll_lines(std::slice::from_ref(&over)),
|
||||
Err(LedgerError::NegativeNetPay("Cat".to_string()))
|
||||
@@ -252,7 +300,7 @@ mod tests {
|
||||
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);
|
||||
let plan = payroll_entry_plan(&[l], accounts, cash, Currency::USD).unwrap();
|
||||
assert_eq!(plan.len(), 6); // gross, employer tax, fed, ss, medicare, cash
|
||||
let mut debits = Decimal::ZERO;
|
||||
let mut credits = Decimal::ZERO;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! 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
|
||||
//! Once closed, [`Ledger::try_create_entry`](crate::Ledger::try_create_entry) and [`Ledger::post_entry`](crate::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).
|
||||
|
||||
@@ -29,32 +29,96 @@ pub enum PeriodStatus {
|
||||
Closed,
|
||||
}
|
||||
|
||||
/// Where a period close puts the period's net income.
|
||||
/// Where a period close puts the period's net income, and which owner
|
||||
/// accounts (draws, distributions, dividends) it zeroes into equity too.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum CloseTo {
|
||||
/// One Retained Earnings account ([`RETAINED_EARNINGS_CODE`]): the
|
||||
/// corporate convention.
|
||||
RetainedEarnings,
|
||||
/// Members' capital accounts, by share (shares sum to 1): the
|
||||
/// The Retained Earnings account ([`RETAINED_EARNINGS_CODE`], created if
|
||||
/// missing): the corporate convention. `sweep` lists dividend or
|
||||
/// distribution accounts to close into it.
|
||||
RetainedEarnings {
|
||||
/// Equity accounts zeroed into Retained Earnings.
|
||||
sweep: Vec<crate::AccountId>,
|
||||
},
|
||||
/// One equity account, e.g. a sole proprietor's capital, with `sweep`
|
||||
/// (e.g. the owner's draws) zeroed into it.
|
||||
Account {
|
||||
/// The equity account receiving the net.
|
||||
into: crate::AccountId,
|
||||
/// Equity accounts zeroed into it.
|
||||
sweep: Vec<crate::AccountId>,
|
||||
},
|
||||
/// Members' capital accounts by share (shares sum to 1): the
|
||||
/// partnership convention, where each member's capital account carries
|
||||
/// their allocated earnings.
|
||||
Members(Vec<(crate::AccountId, rust_decimal::Decimal)>),
|
||||
/// their allocated earnings and absorbs their distributions. Odd cents
|
||||
/// go by largest remainder ([`allocate`]), cumulatively: a run of
|
||||
/// member closes with the same members and shares gives each member,
|
||||
/// in total, their share of the run's total to the cent, so rounding
|
||||
/// never piles up close after close (rules.md B-13; since 2026-10-13).
|
||||
Members(Vec<MemberClose>),
|
||||
/// [`CloseTo::Members`] rounding each close on its own (largest
|
||||
/// remainder), the rule for closes journaled on 2026-10-13 before the
|
||||
/// cumulative one. Only replay of those journals produces it.
|
||||
MembersPerClose(Vec<MemberClose>),
|
||||
/// [`CloseTo::Members`] under the rounding closes used before
|
||||
/// 2026-10-13 ([`allocate_last_absorbs`]). Only replay of those journals
|
||||
/// produces it; new closes use `Members`.
|
||||
MembersLastAbsorbs(Vec<MemberClose>),
|
||||
}
|
||||
|
||||
/// Split `amount` by `shares` to the cent; the last share absorbs rounding,
|
||||
/// so the parts always sum to `amount` exactly.
|
||||
/// One member in a partnership close.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MemberClose {
|
||||
/// The member's capital account.
|
||||
pub capital: crate::AccountId,
|
||||
/// Their share of net income.
|
||||
pub share: rust_decimal::Decimal,
|
||||
/// Their distributions account, zeroed into their capital, if they
|
||||
/// have one.
|
||||
pub distributions: Option<crate::AccountId>,
|
||||
}
|
||||
|
||||
/// Split `amount` by `shares` (which sum to 1) to the cent, by largest
|
||||
/// remainder: every part is its exact share rounded toward zero to the
|
||||
/// cent, and the leftover cents go one each to the largest remainders,
|
||||
/// ties to the earlier member ([`crate::kernel::apportion`]). The parts
|
||||
/// always sum to `amount` exactly, and no member is favored by position
|
||||
/// (Stephen, 2026-10-13). A loss splits as the negation of the same gain.
|
||||
pub fn allocate(
|
||||
amount: rust_decimal::Decimal,
|
||||
shares: &[rust_decimal::Decimal],
|
||||
) -> Vec<rust_decimal::Decimal> {
|
||||
use rust_decimal::{Decimal, RoundingStrategy::ToZero};
|
||||
let magnitude = amount.abs();
|
||||
let exact: Vec<Decimal> = shares.iter().map(|s| magnitude * s).collect();
|
||||
let mut parts: Vec<Decimal> = exact
|
||||
.iter()
|
||||
.map(|e| e.round_dp_with_strategy(2, ToZero))
|
||||
.collect();
|
||||
let remainders: Vec<Decimal> = exact.iter().zip(&parts).map(|(e, p)| e - p).collect();
|
||||
crate::kernel::apportion(magnitude, &mut parts, &remainders, Decimal::new(1, 2))
|
||||
.expect("shares sum to 1, so the shortfall is under one cent per member");
|
||||
if amount.is_sign_negative() {
|
||||
for p in &mut parts {
|
||||
*p = -*p;
|
||||
}
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
/// The rule closes used before 2026-10-13: each share rounded half away
|
||||
/// from zero to the cent, the last absorbing the rounding. Kept only so
|
||||
/// closes journaled under it replay exactly ([`CloseTo::MembersLastAbsorbs`]).
|
||||
pub fn allocate_last_absorbs(
|
||||
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>();
|
||||
}
|
||||
crate::kernel::plug_last(amount, &mut parts).expect("allocated amounts are in range");
|
||||
parts
|
||||
}
|
||||
|
||||
@@ -63,7 +127,7 @@ pub fn allocate(
|
||||
pub const RETAINED_EARNINGS_CODE: &str = "3999";
|
||||
|
||||
/// A named accounting period: a month, quarter, or year.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Period {
|
||||
/// Opaque handle.
|
||||
pub id: PeriodId,
|
||||
@@ -77,6 +141,23 @@ pub struct Period {
|
||||
pub status: PeriodStatus,
|
||||
/// The closing entry's id, once closed.
|
||||
pub closing_entry: Option<u64>,
|
||||
/// The order it closed in among all closes (1 for the first), once
|
||||
/// closed.
|
||||
#[serde(default)]
|
||||
pub close_seq: Option<u64>,
|
||||
/// What a cumulative members close allocated, once closed that way.
|
||||
#[serde(default)]
|
||||
pub member_close: Option<MemberCloseRecord>,
|
||||
}
|
||||
|
||||
/// A cumulative members close ([`CloseTo::Members`]): who shared, and the
|
||||
/// net it closed. The next close with the same members continues the run.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct MemberCloseRecord {
|
||||
/// (capital account, share), in the order given.
|
||||
pub members: Vec<(crate::AccountId, rust_decimal::Decimal)>,
|
||||
/// The net income this close allocated (negative for a loss).
|
||||
pub net: rust_decimal::Decimal,
|
||||
}
|
||||
|
||||
impl Period {
|
||||
@@ -87,7 +168,18 @@ impl Period {
|
||||
}
|
||||
|
||||
/// Validate a period's date range before creation.
|
||||
/// [`validate_period_order`], and both ends inside the calendar the books
|
||||
/// keep (crate::calendar; P0 review round 3, S1). For new periods; replay
|
||||
/// checks only the order (rules.md B-28).
|
||||
pub fn validate_period_range(start: NaiveDate, end: NaiveDate) -> Result<(), LedgerError> {
|
||||
crate::calendar::check(start)?;
|
||||
crate::calendar::check(end)?;
|
||||
validate_period_order(start, end)
|
||||
}
|
||||
|
||||
/// The check every journaled `period.created` passed when it was written
|
||||
/// (the rule through 2026-10-16): the end is not before the start.
|
||||
pub fn validate_period_order(start: NaiveDate, end: NaiveDate) -> Result<(), LedgerError> {
|
||||
if end < start {
|
||||
return Err(LedgerError::InvalidPeriodRange {
|
||||
start: start.to_string(),
|
||||
|
||||
@@ -0,0 +1,568 @@
|
||||
//! Kani proof harnesses for the accounting kernel.
|
||||
//!
|
||||
//! `cargo kani -p ledger-core` (or `just prove`) model-checks each
|
||||
//! harness: CBMC explores *every* input within the stated bounds, not a
|
||||
//! sample, and fails if any one of them breaks an assertion, panics, or
|
||||
//! overflows. The code under proof is the engine's own [`crate::kernel`],
|
||||
//! instantiated at `i64` (exact integer money, in the smallest unit); the
|
||||
//! engine runs the same functions on `Decimal`, and the `kernel_bridge`
|
||||
//! property test checks the two instantiations agree.
|
||||
//!
|
||||
//! Bounds: entries of up to [`N`] lines over up to [`K`] accounts, and
|
||||
//! amounts up to 2^40 in magnitude. The Lean model in `verify/lean`
|
||||
//! proves the same laws for any number of lines and accounts.
|
||||
|
||||
use crate::kernel::{self, Amount, LineError};
|
||||
use crate::{AccountType, Side};
|
||||
|
||||
/// Most lines per entry the harnesses explore.
|
||||
const N: usize = 4;
|
||||
/// Accounts the harnesses' lines draw from.
|
||||
const K: u8 = 3;
|
||||
|
||||
fn any_side() -> Side {
|
||||
if kani::any() {
|
||||
Side::Debit
|
||||
} else {
|
||||
Side::Credit
|
||||
}
|
||||
}
|
||||
|
||||
fn any_type() -> AccountType {
|
||||
// An assumption, not `% 5`: modulo is costly for the SAT solver.
|
||||
let t: u8 = kani::any();
|
||||
kani::assume(t < 5);
|
||||
match t {
|
||||
0 => AccountType::Asset,
|
||||
1 => AccountType::Liability,
|
||||
2 => AccountType::Equity,
|
||||
3 => AccountType::Revenue,
|
||||
_ => AccountType::Expense,
|
||||
}
|
||||
}
|
||||
|
||||
/// Any amount a journal line could carry, sign included.
|
||||
fn any_amount() -> i64 {
|
||||
let a: i64 = kani::any();
|
||||
kani::assume(a.in_line_range());
|
||||
a
|
||||
}
|
||||
|
||||
/// Any count up to `N`. Harnesses fill fixed arrays and take a prefix of
|
||||
/// this length: fixed arrays keep the model free of allocation (which is
|
||||
/// also why they call `movements_into` rather than `movements`).
|
||||
fn any_len() -> usize {
|
||||
let n: usize = kani::any();
|
||||
kani::assume(n <= N);
|
||||
n
|
||||
}
|
||||
|
||||
/// `N` lines, any sides, any in-range amounts (use a prefix).
|
||||
fn any_lines() -> [(Side, i64); N] {
|
||||
core::array::from_fn(|_| (any_side(), any_amount()))
|
||||
}
|
||||
|
||||
/// `N` lines over `K` accounts (use a prefix).
|
||||
fn any_account_lines() -> [(u8, Side, i64); N] {
|
||||
core::array::from_fn(|_| {
|
||||
let k: u8 = kani::any();
|
||||
kani::assume(k < K);
|
||||
(k, any_side(), any_amount())
|
||||
})
|
||||
}
|
||||
|
||||
/// The mathematical signed amount. Amounts are at most 2^40 and no
|
||||
/// harness sums more than eight, so `i64` holds every spec sum exactly
|
||||
/// (and Kani would flag it if not).
|
||||
fn wide(side: Side, amount: i64) -> i64 {
|
||||
match side {
|
||||
Side::Debit => amount,
|
||||
Side::Credit => -(amount),
|
||||
}
|
||||
}
|
||||
|
||||
fn wide_net(lines: &[(Side, i64)]) -> i64 {
|
||||
lines.iter().map(|&(s, a)| wide(s, a)).sum()
|
||||
}
|
||||
|
||||
/// The sides and amounts of some account lines, into a fixed array.
|
||||
fn sides(lines: &[(u8, Side, i64)]) -> [(Side, i64); N] {
|
||||
let mut out = [(Side::Debit, 0); N];
|
||||
for (o, &(_, s, a)) in out.iter_mut().zip(lines) {
|
||||
*o = (s, a);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lines and entries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Flipping a side negates its signed amount, and flipping twice is the
|
||||
/// identity.
|
||||
#[kani::proof]
|
||||
fn flip_negates() {
|
||||
let side = any_side();
|
||||
let a = any_amount();
|
||||
assert_eq!(kernel::flip(kernel::flip(side)), side);
|
||||
assert_ne!(kernel::flip(side), side);
|
||||
assert_eq!(kernel::signed(kernel::flip(side), a), Some(-wide(side, a)));
|
||||
}
|
||||
|
||||
/// In-range lines never overflow the kernel's sums: `totals` and `net`
|
||||
/// always succeed, and agree with the mathematical sums. (`movements`
|
||||
/// is covered by its own harnesses.)
|
||||
#[kani::proof]
|
||||
#[kani::unwind(6)]
|
||||
fn sums_are_exact() {
|
||||
let all = any_account_lines();
|
||||
let lines = &all[..any_len()];
|
||||
let all_sides = sides(lines);
|
||||
let s = &all_sides[..lines.len()];
|
||||
let (debits, credits) = kernel::totals(s.iter().copied()).expect("no overflow");
|
||||
let net = kernel::net(s.iter().copied()).expect("no overflow");
|
||||
assert_eq!(net, wide_net(s));
|
||||
assert_eq!(debits - credits, wide_net(s));
|
||||
}
|
||||
|
||||
/// The kernel accepts an entry exactly when it has two or more lines,
|
||||
/// every amount is positive and in range, and debits equal credits.
|
||||
#[kani::proof]
|
||||
#[kani::unwind(6)]
|
||||
fn check_lines_is_exact() {
|
||||
let all = any_lines();
|
||||
let lines = &all[..any_len()];
|
||||
let positive = lines.iter().all(|&(_, a)| a > 0);
|
||||
let debits: i64 = lines
|
||||
.iter()
|
||||
.filter(|l| l.0 == Side::Debit)
|
||||
.map(|l| l.1)
|
||||
.sum();
|
||||
let credits: i64 = lines
|
||||
.iter()
|
||||
.filter(|l| l.0 == Side::Credit)
|
||||
.map(|l| l.1)
|
||||
.sum();
|
||||
let spec = lines.len() >= 2 && positive && debits == credits;
|
||||
let got = kernel::check_lines(lines);
|
||||
assert_eq!(got.is_ok(), spec);
|
||||
if let Err(LineError::Unbalanced {
|
||||
debits: d,
|
||||
credits: c,
|
||||
}) = got
|
||||
{
|
||||
assert_eq!((d, c), (debits, credits));
|
||||
}
|
||||
}
|
||||
|
||||
/// An out-of-range amount is always rejected.
|
||||
#[kani::proof]
|
||||
fn out_of_range_is_rejected() {
|
||||
let big: i64 = kani::any();
|
||||
kani::assume(big > 0 && !big.in_line_range());
|
||||
let side = any_side();
|
||||
let lines = [(side, big), (kernel::flip(side), big)];
|
||||
assert_eq!(kernel::check_lines(&lines), Err(LineError::OutOfRange));
|
||||
}
|
||||
|
||||
/// An accepted entry nets to zero.
|
||||
#[kani::proof]
|
||||
#[kani::unwind(6)]
|
||||
fn accepted_entry_nets_to_zero() {
|
||||
let all = any_lines();
|
||||
let lines = &all[..any_len()];
|
||||
if kernel::check_lines(lines).is_ok() {
|
||||
assert_eq!(kernel::net(lines.iter().copied()), Some(0));
|
||||
kani::cover!(lines.len() == N, "an accepted four-line entry");
|
||||
}
|
||||
}
|
||||
|
||||
/// Movements group lines by account without losing or inventing money:
|
||||
/// any account appears at most once, and its movement is the net of its
|
||||
/// own lines (zero if it has none).
|
||||
#[kani::proof]
|
||||
#[kani::unwind(6)]
|
||||
fn movements_are_per_account() {
|
||||
let all = any_account_lines();
|
||||
let lines = &all[..any_len()];
|
||||
let mut out = [(0u8, 0i64); N];
|
||||
let n = kernel::movements_into(lines, &mut out).expect("no overflow, room for all");
|
||||
let moves = &out[..n];
|
||||
let k: u8 = kani::any();
|
||||
kani::assume(k < K);
|
||||
let mut own = 0i64;
|
||||
let mut has = false;
|
||||
for &(a, s, x) in lines {
|
||||
if a == k {
|
||||
own += wide(s, x);
|
||||
has = true;
|
||||
}
|
||||
}
|
||||
let mut found = 0;
|
||||
let mut movement = 0i64;
|
||||
for &(a, m) in moves {
|
||||
if a == k {
|
||||
found += 1;
|
||||
movement = m;
|
||||
}
|
||||
}
|
||||
assert_eq!(found, usize::from(has));
|
||||
assert_eq!(movement, own);
|
||||
kani::cover!(n == 3, "three distinct accounts");
|
||||
}
|
||||
|
||||
/// The movements sum to the lines' net: zero for an accepted entry.
|
||||
#[kani::proof]
|
||||
#[kani::unwind(6)]
|
||||
fn movements_sum_to_the_net() {
|
||||
let all = any_account_lines();
|
||||
let lines = &all[..any_len()];
|
||||
let mut out = [(0u8, 0i64); N];
|
||||
let n = kernel::movements_into(lines, &mut out).expect("no overflow, room for all");
|
||||
let total: i64 = out[..n].iter().map(|&(_, m)| m).sum();
|
||||
let all_sides = sides(lines);
|
||||
let s = &all_sides[..lines.len()];
|
||||
assert_eq!(total, wide_net(s));
|
||||
if kernel::check_lines(s).is_ok() {
|
||||
assert_eq!(total, 0);
|
||||
kani::cover!(
|
||||
n >= 2 && lines.len() == N,
|
||||
"an accepted entry over several accounts"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A reversal flips every side and keeps every account and amount, so it
|
||||
/// is accepted exactly when the original is. (`kernel::reversal` is
|
||||
/// `reverse_line` mapped over the lines; proving it line by line keeps
|
||||
/// the model free of allocation.)
|
||||
#[kani::proof]
|
||||
#[kani::unwind(6)]
|
||||
fn reversal_mirrors() {
|
||||
let all = any_account_lines();
|
||||
let lines = &all[..any_len()];
|
||||
let mut rev = [(0u8, Side::Debit, 0i64); N];
|
||||
for i in 0..lines.len() {
|
||||
rev[i] = kernel::reverse_line(lines[i]);
|
||||
assert_eq!((rev[i].0, rev[i].2), (lines[i].0, lines[i].2));
|
||||
assert_eq!(rev[i].1, kernel::flip(lines[i].1));
|
||||
}
|
||||
let n = lines.len();
|
||||
assert_eq!(
|
||||
kernel::check_lines(&sides(&rev[..n])[..n]).is_ok(),
|
||||
kernel::check_lines(&sides(lines)[..n]).is_ok()
|
||||
);
|
||||
kani::cover!(
|
||||
n == N && kernel::check_lines(&sides(lines)[..n]).is_ok(),
|
||||
"an accepted four-line entry, reversed"
|
||||
);
|
||||
}
|
||||
|
||||
/// An entry and its reversal together net every account to zero.
|
||||
#[kani::proof]
|
||||
#[kani::unwind(10)]
|
||||
fn reversal_cancels() {
|
||||
let all = any_account_lines();
|
||||
let n = any_len();
|
||||
let lines = &all[..n];
|
||||
// Element by element, never `copy_from_slice`: Kani 0.68 mis-models a
|
||||
// bulk copy of symbolic length (see `kani_symbolic_copy_canary`).
|
||||
let mut both = [(0u8, Side::Debit, 0i64); 2 * N];
|
||||
for i in 0..n {
|
||||
let (k, s, a) = lines[i];
|
||||
both[i] = lines[i];
|
||||
both[n + i] = kernel::reverse_line((k, s, a));
|
||||
}
|
||||
let mut out = [(0u8, 0i64); 2 * N];
|
||||
let m = kernel::movements_into(&both[..2 * n], &mut out).expect("no overflow");
|
||||
for &(_, total) in &out[..m] {
|
||||
assert_eq!(total, 0);
|
||||
}
|
||||
// Not vacuous: an account with several lines on both sides is reached.
|
||||
kani::cover!(n == N && m == 1, "four lines on one account, reversed");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Balances and statements
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A normal balance is the movement for assets and expenses, and its
|
||||
/// negation for liabilities, equity, and revenue. This is the fact the
|
||||
/// accounting equation rests on: the Lean theorem `accounting_equation`
|
||||
/// proves it gives Assets = Liabilities + Equity + (Revenue − Expenses)
|
||||
/// for any number of accounts whose movements net to zero.
|
||||
#[kani::proof]
|
||||
fn normal_balance_is_signed() {
|
||||
let t = any_type();
|
||||
let m = any_amount();
|
||||
let b = kernel::normal_balance(t, m).expect("in range");
|
||||
match t {
|
||||
AccountType::Asset | AccountType::Expense => assert_eq!(b, m),
|
||||
AccountType::Liability | AccountType::Equity | AccountType::Revenue => {
|
||||
assert_eq!(b, -m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The accounting equation end to end on the code, for up to three
|
||||
/// accounts: whenever movements net to zero,
|
||||
/// Assets = Liabilities + Equity + (Revenue − Expenses). (Regrouping a
|
||||
/// sum of signed 64-bit values is hard for a SAT solver, so the bound is
|
||||
/// small here; `normal_balance_is_signed` and the Lean proof cover the
|
||||
/// rest.)
|
||||
#[kani::proof]
|
||||
#[kani::unwind(4)]
|
||||
fn accounting_equation() {
|
||||
const EQ: usize = 3;
|
||||
let all: [(AccountType, i64); EQ] = core::array::from_fn(|_| (any_type(), any_amount()));
|
||||
let n: usize = kani::any();
|
||||
kani::assume(n <= EQ);
|
||||
let accounts = &all[..n];
|
||||
let (mut net, mut a, mut l, mut e, mut r, mut x) = (0i64, 0i64, 0i64, 0i64, 0i64, 0i64);
|
||||
for &(t, m) in accounts {
|
||||
net += m;
|
||||
let b = kernel::normal_balance(t, m).expect("in range");
|
||||
match t {
|
||||
AccountType::Asset => a += b,
|
||||
AccountType::Liability => l += b,
|
||||
AccountType::Equity => e += b,
|
||||
AccountType::Revenue => r += b,
|
||||
AccountType::Expense => x += b,
|
||||
}
|
||||
}
|
||||
assert_eq!(a - (l + e + (r - x)), net);
|
||||
if net == 0 {
|
||||
assert_eq!(a, l + e + (r - x));
|
||||
kani::cover!(n == EQ, "a balanced three-account chart");
|
||||
}
|
||||
}
|
||||
|
||||
/// A normal-side line raises an account's balance by its amount; an
|
||||
/// opposite-side line lowers it.
|
||||
#[kani::proof]
|
||||
fn normal_side_increases() {
|
||||
let t = any_type();
|
||||
let a = any_amount();
|
||||
let side = any_side();
|
||||
let movement = kernel::signed(side, a).expect("in range");
|
||||
let bal = kernel::normal_balance(t, movement).expect("in range");
|
||||
if side == t.normal_side() {
|
||||
assert_eq!(bal, a);
|
||||
} else {
|
||||
assert_eq!(bal, -a);
|
||||
}
|
||||
}
|
||||
|
||||
/// A trial balance puts each movement in exactly one non-negative column,
|
||||
/// so its columns balance exactly when movements net to zero.
|
||||
#[kani::proof]
|
||||
#[kani::unwind(6)]
|
||||
fn trial_balance_columns() {
|
||||
let all: [i64; N] = core::array::from_fn(|_| any_amount());
|
||||
let moves = &all[..any_len()];
|
||||
let (mut debits, mut credits) = (0i64, 0i64);
|
||||
for &m in moves {
|
||||
let (d, c) = kernel::columns(m).expect("in range");
|
||||
assert!(d >= 0 && c >= 0);
|
||||
assert!(d == 0 || c == 0);
|
||||
assert_eq!(d - c, m);
|
||||
debits += d;
|
||||
credits += c;
|
||||
}
|
||||
let net: i64 = moves.iter().map(|&m| m).sum();
|
||||
assert_eq!(debits - credits, net);
|
||||
assert_eq!(debits == credits, net == 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Closing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A closing line zeroes its account: opposite side, same amount, nothing
|
||||
/// for a zero balance.
|
||||
#[kani::proof]
|
||||
fn closing_line_zeroes() {
|
||||
let m = any_amount();
|
||||
match kernel::closing_line(m) {
|
||||
None => assert_eq!(m, 0),
|
||||
Some((side, amount)) => {
|
||||
assert!(amount > 0);
|
||||
assert_eq!(m + wide(side, amount), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `credit_line` credits exactly what it is asked to.
|
||||
#[kani::proof]
|
||||
fn credit_line_credits() {
|
||||
let c = any_amount();
|
||||
match kernel::credit_line(c) {
|
||||
None => assert_eq!(c, 0),
|
||||
Some((side, amount)) => {
|
||||
assert!(amount > 0);
|
||||
assert_eq!(wide(side, amount), -c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A period close balances and zeroes every revenue and expense account:
|
||||
/// one closing line per nonzero nominal account, plus the net credited to
|
||||
/// equity (whole, or split among members): the entry's lines net to
|
||||
/// zero, and each nominal account ends at zero.
|
||||
#[kani::proof]
|
||||
#[kani::unwind(6)]
|
||||
fn closing_entry_balances() {
|
||||
const NOMINAL: usize = 3;
|
||||
let nominal: [i64; NOMINAL] = core::array::from_fn(|_| any_amount());
|
||||
// Each nominal account's closing line zeroes it.
|
||||
let mut entry = [(Side::Debit, 0i64); NOMINAL + 2];
|
||||
let mut len = 0;
|
||||
for &m in &nominal {
|
||||
if let Some((side, amount)) = kernel::closing_line(m) {
|
||||
assert_eq!(m + wide(side, amount), 0);
|
||||
entry[len] = (side, amount);
|
||||
len += 1;
|
||||
} else {
|
||||
assert_eq!(m, 0);
|
||||
}
|
||||
}
|
||||
let net = kernel::net(entry[..len].iter().copied()).expect("no overflow");
|
||||
// Split the net between two members, the first share rounded any way.
|
||||
let mut parts = [any_amount(), 0];
|
||||
kernel::plug_last(net, &mut parts).expect("no overflow");
|
||||
for p in parts {
|
||||
if let Some(line) = kernel::credit_line(p) {
|
||||
entry[len] = line;
|
||||
len += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(wide_net(&entry[..len]), 0);
|
||||
kani::cover!(len == NOMINAL + 2, "three accounts closed to two members");
|
||||
}
|
||||
|
||||
/// An allocation's parts sum to the whole exactly, whatever the rounding
|
||||
/// of the others, and only the last part changes.
|
||||
#[kani::proof]
|
||||
#[kani::unwind(6)]
|
||||
fn plug_last_sums_exactly() {
|
||||
let whole = any_amount();
|
||||
let n = any_len();
|
||||
let mut all: [i64; N] = core::array::from_fn(|_| any_amount());
|
||||
let before = all;
|
||||
let parts = &mut all[..n];
|
||||
kernel::plug_last(whole, parts).expect("no overflow");
|
||||
if n > 0 {
|
||||
let sum: i64 = parts.iter().map(|&p| p).sum();
|
||||
assert_eq!(sum, whole);
|
||||
for i in 0..n - 1 {
|
||||
assert_eq!(parts[i], before[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Most parts the apportionment harnesses explore (its ranking is
|
||||
/// quadratic, so the bound is smaller than `N`).
|
||||
const AP: usize = 3;
|
||||
|
||||
/// Inputs for `kernel::apportion`: rounded-down parts, remainders, a unit,
|
||||
/// and a whole some shortfall above the parts' sum.
|
||||
fn any_apportionment() -> (usize, [i64; AP], [i64; AP], i64, i64, i64) {
|
||||
let n: usize = kani::any();
|
||||
kani::assume(n <= AP);
|
||||
let before: [i64; AP] = core::array::from_fn(|_| any_amount());
|
||||
let rems: [i64; AP] = core::array::from_fn(|_| any_amount());
|
||||
let unit: i64 = kani::any();
|
||||
kani::assume(unit > 0 && unit <= 1000);
|
||||
let shortfall: i64 = kani::any();
|
||||
kani::assume(shortfall.unsigned_abs() <= 4000);
|
||||
let mut whole = shortfall;
|
||||
for &p in &before[..n] {
|
||||
whole += p;
|
||||
}
|
||||
(n, before, rems, unit, shortfall, whole)
|
||||
}
|
||||
|
||||
/// `kernel::apportion` succeeds exactly when the parts are short of the
|
||||
/// whole by less than one unit per part; then the parts sum to the whole,
|
||||
/// and each gains nothing or one unit, except that one part may also take
|
||||
/// the sub-unit fraction.
|
||||
#[kani::proof]
|
||||
#[kani::unwind(5)]
|
||||
fn apportion_is_exact() {
|
||||
let (n, before, rems, unit, shortfall, whole) = any_apportionment();
|
||||
let mut parts = before;
|
||||
let ok = kernel::apportion(whole, &mut parts[..n], &rems[..n], unit);
|
||||
let in_range = if n == 0 {
|
||||
whole == 0
|
||||
} else {
|
||||
shortfall >= 0 && shortfall < n as i64 * unit
|
||||
};
|
||||
assert_eq!(ok.is_some(), in_range);
|
||||
if ok.is_none() {
|
||||
return;
|
||||
}
|
||||
let mut sum = 0i64;
|
||||
let mut fraction_takers = 0;
|
||||
for i in 0..n {
|
||||
sum += parts[i];
|
||||
let gain = parts[i] - before[i];
|
||||
assert!(gain >= 0 && gain < 2 * unit);
|
||||
if gain != 0 && gain != unit {
|
||||
fraction_takers += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(sum, whole);
|
||||
assert!(fraction_takers <= 1);
|
||||
kani::cover!(
|
||||
n == AP && shortfall >= 2 * unit,
|
||||
"three parts, two units short"
|
||||
);
|
||||
}
|
||||
|
||||
/// `kernel::apportion` never gives a unit to a part while a part with a
|
||||
/// strictly larger remainder goes without (Stephen, 2026-10-13: no member
|
||||
/// favored by position).
|
||||
#[kani::proof]
|
||||
#[kani::unwind(5)]
|
||||
fn apportion_is_fair() {
|
||||
let (n, before, rems, unit, _, whole) = any_apportionment();
|
||||
let mut parts = before;
|
||||
if kernel::apportion(whole, &mut parts[..n], &rems[..n], unit).is_none() {
|
||||
return;
|
||||
}
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
if rems[i] > rems[j] {
|
||||
assert!(parts[i] - before[i] >= unit || parts[j] - before[j] < unit);
|
||||
}
|
||||
}
|
||||
}
|
||||
kani::cover!(
|
||||
n == AP && rems[0] < rems[2],
|
||||
"a later part with a larger remainder"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The checker itself
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A canary for a known Kani bug: Kani 0.68 (CBMC 6.11) mis-models a bulk
|
||||
/// copy whose length is symbolic, so this correct copy "fails"
|
||||
/// (model-checking/kani#4904, closed as a duplicate, unfixed in 0.68). The
|
||||
/// bug is unsound in both directions, so no harness above copies a slice
|
||||
/// of symbolic length. This harness is expected to fail; when a Kani
|
||||
/// upgrade fixes the bug it will start passing, `should_panic` will then
|
||||
/// report it, and this canary and the comment in `reversal_cancels` can go.
|
||||
#[kani::proof]
|
||||
#[kani::unwind(10)]
|
||||
#[kani::should_panic]
|
||||
fn kani_symbolic_copy_canary() {
|
||||
let src: [i64; N] = kani::any();
|
||||
let n = any_len();
|
||||
let mut dst = [0i64; 2 * N];
|
||||
dst[..n].copy_from_slice(&src[..n]);
|
||||
for i in 0..n {
|
||||
assert_eq!(dst[i], src[i]);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Reports: pure functions of a [`Ledger`](crate::Ledger).
|
||||
//! Reports: pure functions of a [`Ledger`].
|
||||
//!
|
||||
//! Reports never mutate and never guess. They are what the semantic oracle
|
||||
//! (`docs/oracle.md`) diffs: the same fixture books through two engines must
|
||||
@@ -44,6 +44,14 @@ impl TrialBalance {
|
||||
}
|
||||
|
||||
/// Build the trial balance from all posted entries.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: the trial balance over every posted entry (see trial_balance_as_of).
|
||||
/// SOURCE: docs/spec/rules.md B-6; R-7.
|
||||
/// INVARIANTS:
|
||||
/// 1. MUST equal trial_balance_as_of on or after the last entry's date.
|
||||
/// 2. MUST balance (total debits == total credits).
|
||||
/// ```
|
||||
pub fn trial_balance(ledger: &Ledger) -> TrialBalance {
|
||||
trial_balance_where(ledger, &|_| true)
|
||||
}
|
||||
@@ -51,6 +59,29 @@ pub fn trial_balance(ledger: &Ledger) -> TrialBalance {
|
||||
/// The trial balance as of a date: every posted entry dated on or before
|
||||
/// it (closing entries included, so after a close it is the post-closing
|
||||
/// trial balance).
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: every account's balance, in a debit or a credit column, from the
|
||||
/// posted entries dated on or before `as_of`.
|
||||
/// SOURCE: docs/spec/rules.md B-6 (Pacioli ch. 36; IRS Pub. 583); R-7, R-9b.
|
||||
/// INVARIANTS:
|
||||
/// 1. MUST have total debits equal total credits, for any books built
|
||||
/// through the Ledger commands.
|
||||
/// 2. MUST list each non-placeholder account with a nonzero balance
|
||||
/// exactly once, in code order; NEVER list a placeholder or a zero
|
||||
/// balance.
|
||||
/// 3. MUST put each balance in exactly one column, never negative.
|
||||
/// 4. NEVER count a draft, or an entry dated after `as_of`; closing
|
||||
/// entries dated on or before it count (after a close this is the
|
||||
/// post-closing trial balance).
|
||||
/// SCENARIOS:
|
||||
/// 1. IF no entries are posted by `as_of`:
|
||||
/// - SHOULD be empty with zero totals.
|
||||
/// 2. IF an asset was debited 100 and credited 30:
|
||||
/// - SHOULD show it with debit 70, credit 0.
|
||||
/// BECAUSE the trial balance shows each account's net balance on the
|
||||
/// side it falls.
|
||||
/// ```
|
||||
pub fn trial_balance_as_of(ledger: &Ledger, as_of: NaiveDate) -> TrialBalance {
|
||||
trial_balance_where(ledger, &|e| e.date() <= as_of)
|
||||
}
|
||||
@@ -59,10 +90,8 @@ fn trial_balance_where(ledger: &Ledger, include: &dyn Fn(&JournalEntry) -> bool)
|
||||
let mut movements: BTreeMap<crate::AccountId, Decimal> = BTreeMap::new();
|
||||
for entry in ledger.posted_entries().filter(|e| include(e)) {
|
||||
for line in entry.lines() {
|
||||
let signed = match line.side {
|
||||
crate::Side::Debit => line.amount.amount,
|
||||
crate::Side::Credit => -line.amount.amount,
|
||||
};
|
||||
let signed = crate::kernel::signed(line.side, line.amount.amount)
|
||||
.expect("Decimal negation is exact");
|
||||
*movements.entry(line.account).or_default() += signed;
|
||||
}
|
||||
}
|
||||
@@ -80,11 +109,7 @@ fn trial_balance_where(ledger: &Ledger, include: &dyn Fn(&JournalEntry) -> bool)
|
||||
if movement.is_zero() {
|
||||
continue;
|
||||
}
|
||||
let (debit, credit) = if movement > Decimal::ZERO {
|
||||
(movement, Decimal::ZERO)
|
||||
} else {
|
||||
(Decimal::ZERO, -movement)
|
||||
};
|
||||
let (debit, credit) = crate::kernel::columns(movement).expect("Decimal negation is exact");
|
||||
total_debits += debit;
|
||||
total_credits += credit;
|
||||
lines.push(TrialBalanceLine {
|
||||
@@ -101,8 +126,9 @@ fn trial_balance_where(ledger: &Ledger, include: &dyn Fn(&JournalEntry) -> bool)
|
||||
}
|
||||
}
|
||||
|
||||
/// Profit and loss: revenue and expenses for the life of the ledger. For a
|
||||
/// date range, with a line per account, use [`profit_and_loss_for_period`].
|
||||
/// Revenue and expense totals: from [`unclosed_earnings`], what the books
|
||||
/// hold now (closed periods already moved to equity); for a date range,
|
||||
/// with a line per account, use [`profit_and_loss_for_period`].
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ProfitAndLoss {
|
||||
/// Total revenue (credit-normal balances, reported positive).
|
||||
@@ -118,15 +144,19 @@ impl ProfitAndLoss {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the profit and loss statement from all posted entries.
|
||||
pub fn profit_and_loss(ledger: &Ledger) -> Result<ProfitAndLoss, LedgerError> {
|
||||
/// Earnings not yet closed: revenue and expenses over every posted entry,
|
||||
/// closing entries included, so a closed period's income (already moved
|
||||
/// to equity) nets out. This is the balance sheet's current-earnings line,
|
||||
/// not a profit and loss for any period (docs/testing.md #3; OpenStax,
|
||||
/// Principles of Financial Accounting 5.1).
|
||||
pub fn unclosed_earnings(ledger: &Ledger) -> Result<ProfitAndLoss, LedgerError> {
|
||||
profit_and_loss_filtered(ledger, None)
|
||||
}
|
||||
|
||||
/// Build the profit and loss statement, optionally filtered to one class.
|
||||
///
|
||||
/// With `None` every line counts and the result is identical to
|
||||
/// [`profit_and_loss`]. With `Some(class)` only lines tagged with that
|
||||
/// [`unclosed_earnings`]. With `Some(class)` only lines tagged with that
|
||||
/// class count. Unlike the account-at-a-time accumulation this works line
|
||||
/// by line, because classes live on lines: each line contributes its signed
|
||||
/// amount (positive on the account's normal side) to its account type's
|
||||
@@ -156,7 +186,17 @@ pub struct ClassProfitAndLoss {
|
||||
/// sorted by class name; the unclassified row comes last.
|
||||
///
|
||||
/// The rows partition the ledger: summing every row's revenue and expenses
|
||||
/// reproduces [`profit_and_loss`].
|
||||
/// reproduces [`unclosed_earnings`].
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: a profit and loss per class (department, location, job), plus the
|
||||
/// unclassified lines.
|
||||
/// SOURCE: docs/spec/rules.md B-14; R-? Tracking classes.
|
||||
/// INVARIANTS:
|
||||
/// 1. The per-class P&Ls (with unclassified) MUST sum to the whole P&L,
|
||||
/// account by account (conservation).
|
||||
/// 2. NEVER count a line in two classes.
|
||||
/// ```
|
||||
pub fn profit_and_loss_by_class(ledger: &Ledger) -> Result<Vec<ClassProfitAndLoss>, LedgerError> {
|
||||
// Which classes actually have P&L lines, and whether any P&L line is
|
||||
// unclassified. Balance-sheet-only classes get no row.
|
||||
@@ -278,7 +318,7 @@ pub fn balance_sheet(ledger: &Ledger) -> Result<BalanceSheet, LedgerError> {
|
||||
AccountType::Revenue | AccountType::Expense => {}
|
||||
}
|
||||
}
|
||||
let current_earnings = profit_and_loss(ledger)?.net_income();
|
||||
let current_earnings = unclosed_earnings(ledger)?.net_income();
|
||||
equity += current_earnings;
|
||||
Ok(BalanceSheet {
|
||||
assets,
|
||||
@@ -405,6 +445,26 @@ fn statement_lines(
|
||||
/// counts every line). Closing entries are excluded: they move a period's
|
||||
/// earnings into retained earnings, so counting them would report a closed
|
||||
/// period as breaking even.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: revenue, expenses, and net income for the entries dated from
|
||||
/// `start` through `end` (both inclusive), optionally one class's lines.
|
||||
/// SOURCE: docs/spec/rules.md B-14, B-11; R-9.
|
||||
/// INVARIANTS:
|
||||
/// 1. MUST count only revenue and expense accounts, a line per account
|
||||
/// with its normal-side amount, and net income = revenue - expenses.
|
||||
/// 2. NEVER count a closing entry (a close moves earnings; it doesn't
|
||||
/// earn or spend), a draft, or an entry outside the range.
|
||||
/// 3. MUST partition: for start <= m < end, the period's net income equals
|
||||
/// [start, m] plus [m+1, end].
|
||||
/// 4. MUST equal the change in current earnings (balance sheet) across
|
||||
/// the range when no close falls inside it.
|
||||
/// 5. With a class: MUST count only lines tagged with that class.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF a month was closed: SHOULD still report that month's earnings.
|
||||
/// BECAUSE closing doesn't make a period break even (R-9).
|
||||
/// 2. IF `start` is after `end`: SHOULD refuse. (DESIGN: no standard governs a reversed range; docs/testing.md #1)
|
||||
/// ```
|
||||
pub fn profit_and_loss_for_period(
|
||||
ledger: &Ledger,
|
||||
start: NaiveDate,
|
||||
@@ -449,6 +509,23 @@ pub fn profit_and_loss_for_period(
|
||||
/// Revenue and expense not yet closed to retained earnings appear as
|
||||
/// current earnings inside equity, so the accounting equation holds on
|
||||
/// every date, before and after closes.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: assets, liabilities, and equity as of a date, a line per account.
|
||||
/// SOURCE: docs/spec/rules.md B-7 (CON 8 ¶E62), B-11; R-8.
|
||||
/// INVARIANTS:
|
||||
/// 1. MUST balance: assets = liabilities + equity + current earnings,
|
||||
/// exactly.
|
||||
/// 2. MUST count every posted entry dated on or before `as_of`, closing
|
||||
/// entries included (after a close, earnings sit in retained earnings
|
||||
/// or members' capital, and current earnings is zero for closed time).
|
||||
/// 3. Current earnings MUST be revenue minus expenses not yet closed.
|
||||
/// 4. NEVER list revenue or expense accounts as lines.
|
||||
/// 5. Lines use each account's normal-side balance (a contra balance is
|
||||
/// negative).
|
||||
/// SCENARIOS:
|
||||
/// 1. IF nothing is posted by `as_of`: SHOULD be empty and balanced.
|
||||
/// ```
|
||||
pub fn balance_sheet_as_of(
|
||||
ledger: &Ledger,
|
||||
as_of: NaiveDate,
|
||||
@@ -544,6 +621,22 @@ pub struct GeneralLedger {
|
||||
/// posting to its account, with the entry's other side as the split, and a
|
||||
/// running balance from the account's balance before `start`. Closing
|
||||
/// entries are postings like any other.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: every posting from `start` through `end`, by account in code
|
||||
/// order, with running balances.
|
||||
/// SOURCE: docs/spec/rules.md B-6; R-9a (the QuickBooks general ledger).
|
||||
/// INVARIANTS:
|
||||
/// 1. Each account MUST show its beginning balance (entries before
|
||||
/// `start`), every posting in the range in date order, and an ending
|
||||
/// balance equal to beginning plus the postings, on the account's
|
||||
/// normal side.
|
||||
/// 2. Each account's ending balance MUST equal its trial-balance balance
|
||||
/// as of `end` (normal side).
|
||||
/// 3. Each posting MUST name its split: the one account on the entry's
|
||||
/// other side, or "-Split-" when the other side has several.
|
||||
/// 4. NEVER include drafts.
|
||||
/// ```
|
||||
pub fn general_ledger(
|
||||
ledger: &Ledger,
|
||||
start: NaiveDate,
|
||||
@@ -671,6 +764,10 @@ pub struct MemberCapital {
|
||||
/// Their capital account (equity): contributions credit it,
|
||||
/// distributions debit it.
|
||||
pub capital_account: crate::AccountId,
|
||||
/// Their distributions account (contra-equity), if distributions are
|
||||
/// recorded there rather than directly against capital. Part of their
|
||||
/// capital on every statement; zeroed into capital at close.
|
||||
pub distribution_account: Option<crate::AccountId>,
|
||||
/// Share of income, e.g. 0.5. The shares must sum to exactly 1.
|
||||
pub share: Decimal,
|
||||
}
|
||||
@@ -712,14 +809,78 @@ pub struct MembersEquityStatement {
|
||||
pub total: MemberEquityColumn,
|
||||
}
|
||||
|
||||
/// The posted entries in `[start, end]` that cancel: each reversal dated
|
||||
/// in the span with the original it reverses, when that is dated in the
|
||||
/// span too. Paired in journal order, an entry at most once, so in a chain
|
||||
/// (an entry, its reversal, the reversal's reversal) the first two cancel
|
||||
/// and the third, which reinstates the original, counts.
|
||||
pub fn cancelled_in_span(
|
||||
ledger: &Ledger,
|
||||
start: NaiveDate,
|
||||
end: NaiveDate,
|
||||
) -> BTreeSet<crate::EntryId> {
|
||||
let in_span = |d: NaiveDate| d >= start && d <= end;
|
||||
let mut out = BTreeSet::new();
|
||||
for e in ledger.posted_entries().filter(|e| in_span(e.date())) {
|
||||
if let Some(o) = e.reversal_of()
|
||||
&& ledger
|
||||
.get_entry(o)
|
||||
.is_some_and(|x| x.status() == crate::EntryStatus::Posted && in_span(x.date()))
|
||||
&& !out.contains(&o)
|
||||
&& !out.contains(&e.id())
|
||||
{
|
||||
out.insert(o);
|
||||
out.insert(e.id());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Build the statement of changes in members' equity for `[start, end]`.
|
||||
///
|
||||
/// Each member's capital account supplies contributions (credits) and
|
||||
/// distributions (debits). Earnings are partnership earnings: net income
|
||||
/// for the period, and earnings from before it not yet distributed (retained
|
||||
/// earnings plus unclosed income), are allocated by share. Allocation is to
|
||||
/// the cent with the last member absorbing rounding, so every row's total
|
||||
/// is exact and the ending total equals balance-sheet equity.
|
||||
/// the cent, odd cents by largest remainder, so every row's total is exact
|
||||
/// and the ending total equals balance-sheet equity.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: each member's capital roll-forward for a partnership over a
|
||||
/// period: beginning, contributions, allocated net income, distributions,
|
||||
/// ending; with a total.
|
||||
/// SOURCE: docs/spec/rules.md B-13 (26 U.S.C. §704(a); Form 1065 Schedule
|
||||
/// M-2), B-11; R-9c.
|
||||
/// INVARIANTS:
|
||||
/// 1. Each row MUST roll forward exactly: beginning + contributions +
|
||||
/// income - distributions + other = ending.
|
||||
/// 2. The total ending MUST equal balance-sheet equity at `end`.
|
||||
/// 3. Undistributed earnings MUST be split by share, to the cent, by
|
||||
/// largest remainder (DECISION, Stephen 2026-10-13), cumulatively as
|
||||
/// of each date: a member's beginning is their capital and share as
|
||||
/// of the day before `start`, their ending as of `end`.
|
||||
/// 3a. A member's ending on one statement MUST equal their beginning on
|
||||
/// the next, exactly (a capital account is one running balance: Form
|
||||
/// 1065 Schedule K-1 instructions, Item L, "beginning capital account
|
||||
/// and ending capital account ... based on the partnership's books").
|
||||
/// 3b. A member's income is the roll-forward's remainder: their share of
|
||||
/// the period's income, off by less than two cents (under one cent of
|
||||
/// rounding at each end of the cumulative split); the members'
|
||||
/// incomes MUST sum to the period's net income. (Earnings a close has
|
||||
/// already moved into capital were rounded by that close, B-13.)
|
||||
/// 4. NEVER hide an unexplained change: it goes in "other".
|
||||
/// 5. An entry dated `start` is period activity, not beginning capital.
|
||||
/// 6. An entry and its reversal both dated in the span cancel
|
||||
/// ([`cancelled_in_span`]): neither is a contribution or a
|
||||
/// distribution, since the correction undoes the original (B-9: "it
|
||||
/// is just as if you had written nothing"). A reversal whose original
|
||||
/// is outside the span counts by side, like any entry.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF shares don't sum to 1, or are not positive: SHOULD refuse.
|
||||
/// 2. A joint 1,000.00 contribution (500.00 each) reversed the next day:
|
||||
/// contributions 0.00 and distributions 0.00 for each member, not
|
||||
/// 500.00 of each.
|
||||
/// ```
|
||||
pub fn members_equity(
|
||||
ledger: &Ledger,
|
||||
start: NaiveDate,
|
||||
@@ -738,15 +899,17 @@ pub fn members_equity(
|
||||
}
|
||||
let mut seen = BTreeSet::new();
|
||||
for m in members {
|
||||
let account = ledger
|
||||
.chart()
|
||||
.get(m.capital_account)
|
||||
.ok_or(LedgerError::UnknownAccount(m.capital_account))?;
|
||||
if account.account_type != AccountType::Equity || !seen.insert(m.capital_account) {
|
||||
return Err(LedgerError::InvalidMembers(format!(
|
||||
"{}: capital account {} must be a distinct equity account",
|
||||
m.name, account.code
|
||||
)));
|
||||
for id in std::iter::once(m.capital_account).chain(m.distribution_account) {
|
||||
let account = ledger
|
||||
.chart()
|
||||
.get(id)
|
||||
.ok_or(LedgerError::UnknownAccount(id))?;
|
||||
if account.account_type != AccountType::Equity || !seen.insert(id) {
|
||||
return Err(LedgerError::InvalidMembers(format!(
|
||||
"{}: account {} must be a distinct equity account",
|
||||
m.name, account.code
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let day_before = start.pred_opt().ok_or(LedgerError::InvalidDateRange {
|
||||
@@ -773,12 +936,15 @@ pub fn members_equity(
|
||||
// Contributions and distributions move a capital account; a closing
|
||||
// entry allocating earnings into it is income, already counted below.
|
||||
let closings = ledger.closing_entry_ids();
|
||||
let cancelled = cancelled_in_span(ledger, start, end);
|
||||
let flows = |id: crate::AccountId| -> (Decimal, Decimal) {
|
||||
let (mut credits, mut debits) = (Decimal::ZERO, Decimal::ZERO);
|
||||
for e in ledger
|
||||
.posted_entries()
|
||||
.filter(|e| e.date() >= start && e.date() <= end && !closings.contains(&e.id()))
|
||||
{
|
||||
for e in ledger.posted_entries().filter(|e| {
|
||||
e.date() >= start
|
||||
&& e.date() <= end
|
||||
&& !closings.contains(&e.id())
|
||||
&& !cancelled.contains(&e.id())
|
||||
}) {
|
||||
for l in e.lines().iter().filter(|l| l.account == id) {
|
||||
match l.side {
|
||||
crate::Side::Credit => credits += l.amount.amount,
|
||||
@@ -788,17 +954,63 @@ pub fn members_equity(
|
||||
}
|
||||
(credits, debits)
|
||||
};
|
||||
// A member's capital is their capital account plus their distributions
|
||||
// account (a debit balance, so it reduces capital).
|
||||
let capital: Vec<Decimal> = members
|
||||
.iter()
|
||||
.map(|m| capital_before(m.capital_account))
|
||||
.map(|m| {
|
||||
capital_before(m.capital_account)
|
||||
+ m.distribution_account.map_or(Decimal::ZERO, capital_before)
|
||||
})
|
||||
.collect();
|
||||
// (contributions, distributions): credits to capital are
|
||||
// contributions; debits to capital and net debits to the distributions
|
||||
// account are distributions.
|
||||
let flow: Vec<(Decimal, Decimal)> = members
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let (credits, debits) = flows(m.capital_account);
|
||||
let (dc, dd) = m
|
||||
.distribution_account
|
||||
.map_or((Decimal::ZERO, Decimal::ZERO), flows);
|
||||
(credits, debits + dd - dc)
|
||||
})
|
||||
.collect();
|
||||
let flow: Vec<(Decimal, Decimal)> = members.iter().map(|m| flows(m.capital_account)).collect();
|
||||
|
||||
// Everything in equity that isn't a capital account is partnership
|
||||
// earnings not yet distributed: allocate it by share.
|
||||
let undistributed = begin_bs.total_equity - capital.iter().sum::<Decimal>();
|
||||
let begin_alloc = allocate(undistributed, &shares);
|
||||
let income_alloc = allocate(pnl.net_income(), &shares);
|
||||
// earnings not yet distributed. Each member's share of it is allocated
|
||||
// cumulatively, as of a date, so a member's capital on a date never
|
||||
// depends on which statement shows it: one statement's ending is the
|
||||
// next one's beginning, to the cent (a capital account is one running
|
||||
// balance: Form 1065 Schedule K-1 instructions, Item L).
|
||||
let capital_through = |id: crate::AccountId, through: NaiveDate| -> Decimal {
|
||||
let mut m = Decimal::ZERO;
|
||||
for e in ledger.posted_entries().filter(|e| e.date() <= through) {
|
||||
for l in e.lines().iter().filter(|l| l.account == id) {
|
||||
match l.side {
|
||||
crate::Side::Credit => m += l.amount.amount,
|
||||
crate::Side::Debit => m -= l.amount.amount,
|
||||
}
|
||||
}
|
||||
}
|
||||
m
|
||||
};
|
||||
let capital_end: Vec<Decimal> = members
|
||||
.iter()
|
||||
.map(|m| {
|
||||
capital_through(m.capital_account, end)
|
||||
+ m.distribution_account
|
||||
.map_or(Decimal::ZERO, |d| capital_through(d, end))
|
||||
})
|
||||
.collect();
|
||||
let begin_alloc = allocate(
|
||||
begin_bs.total_equity - capital.iter().sum::<Decimal>(),
|
||||
&shares,
|
||||
);
|
||||
let end_alloc = allocate(
|
||||
end_bs.total_equity - capital_end.iter().sum::<Decimal>(),
|
||||
&shares,
|
||||
);
|
||||
let explained = flow.iter().map(|(c, d)| c - d).sum::<Decimal>() + pnl.net_income();
|
||||
let other_alloc = allocate(
|
||||
end_bs.total_equity - begin_bs.total_equity - explained,
|
||||
@@ -808,13 +1020,17 @@ pub fn members_equity(
|
||||
let mut cols = Vec::with_capacity(members.len());
|
||||
for (i, m) in members.iter().enumerate() {
|
||||
let beginning = capital[i] + begin_alloc[i];
|
||||
let ending = capital_end[i] + end_alloc[i];
|
||||
let (contributions, distributions) = flow[i];
|
||||
let ending = beginning + contributions - distributions + income_alloc[i] + other_alloc[i];
|
||||
// The member's income is what the roll-forward leaves: their share
|
||||
// of the period's income, give or take the rounding cent that keeps
|
||||
// the running balance exact. The members' incomes sum to net income.
|
||||
let income = ending - beginning - contributions + distributions - other_alloc[i];
|
||||
cols.push(MemberEquityColumn {
|
||||
name: m.name.clone(),
|
||||
beginning: zero_clean(beginning),
|
||||
contributions,
|
||||
net_income: zero_clean(income_alloc[i]),
|
||||
net_income: zero_clean(income),
|
||||
distributions,
|
||||
other: zero_clean(other_alloc[i]),
|
||||
ending: zero_clean(ending),
|
||||
@@ -845,31 +1061,47 @@ pub fn members_equity(
|
||||
/// are sorted by account code, and object keys are fixed. Two engines fed the
|
||||
/// same fixture books must produce byte-identical output — that is the whole
|
||||
/// point (see `docs/oracle.md`).
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: the trial balance, P&L, and balance sheet as deterministic JSON,
|
||||
/// the interchange format for cross-checking against other
|
||||
/// implementations.
|
||||
/// SOURCE: R-10; docs/oracle.md.
|
||||
/// INVARIANTS:
|
||||
/// 1. MUST be identical for the same books, whatever order accounts were
|
||||
/// added in (decimal strings, sorted lines, fixed keys).
|
||||
/// 2. NEVER contain floats.
|
||||
/// ```
|
||||
pub fn snapshot(ledger: &Ledger) -> Result<serde_json::Value, LedgerError> {
|
||||
let tb = trial_balance(ledger);
|
||||
let pl = profit_and_loss(ledger)?;
|
||||
let pl = unclosed_earnings(ledger)?;
|
||||
let bs = balance_sheet(ledger)?;
|
||||
// By value, not by written scale: a sum taken in hash-map order can
|
||||
// write the same zero as "0" or "0.00", and verify compared the
|
||||
// strings, so two equal ledgers could differ at random (found
|
||||
// 2026-10-15 by a flaky store test).
|
||||
let v = |d: Decimal| d.normalize().to_string();
|
||||
Ok(serde_json::json!({
|
||||
"trial_balance": {
|
||||
"lines": tb.lines.iter().map(|l| serde_json::json!({
|
||||
"code": l.code,
|
||||
"name": l.name,
|
||||
"debit": l.debit.to_string(),
|
||||
"credit": l.credit.to_string(),
|
||||
"debit": v(l.debit),
|
||||
"credit": v(l.credit),
|
||||
})).collect::<Vec<_>>(),
|
||||
"total_debits": tb.total_debits.to_string(),
|
||||
"total_credits": tb.total_credits.to_string(),
|
||||
"total_debits": v(tb.total_debits),
|
||||
"total_credits": v(tb.total_credits),
|
||||
},
|
||||
"profit_and_loss": {
|
||||
"revenue": pl.revenue.to_string(),
|
||||
"expenses": pl.expenses.to_string(),
|
||||
"net_income": pl.net_income().to_string(),
|
||||
"revenue": v(pl.revenue),
|
||||
"expenses": v(pl.expenses),
|
||||
"net_income": v(pl.net_income()),
|
||||
},
|
||||
"balance_sheet": {
|
||||
"assets": bs.assets.to_string(),
|
||||
"liabilities": bs.liabilities.to_string(),
|
||||
"equity": bs.equity.to_string(),
|
||||
"current_earnings": bs.current_earnings.to_string(),
|
||||
"assets": v(bs.assets),
|
||||
"liabilities": v(bs.liabilities),
|
||||
"equity": v(bs.equity),
|
||||
"current_earnings": v(bs.current_earnings),
|
||||
"balanced": bs.balanced(),
|
||||
},
|
||||
}))
|
||||
@@ -901,9 +1133,16 @@ pub struct CashFlowParams {
|
||||
pub end: chrono::NaiveDate,
|
||||
/// Accounts treated as cash. Their net change is the reconciling total.
|
||||
pub cash_accounts: Vec<crate::AccountId>,
|
||||
/// Liability accounts treated as financing (debt). Default empty: all
|
||||
/// liabilities are operating working capital, all equity is financing.
|
||||
/// Liability accounts treated as financing (debt, including what the
|
||||
/// owners advanced: ASC 230-10-45-14(b) "other short- or long-term
|
||||
/// borrowing"). Default empty: all liabilities are operating working
|
||||
/// capital. All equity is financing.
|
||||
pub financing_accounts: Vec<crate::AccountId>,
|
||||
/// Asset accounts treated as investing: loans the entity makes and
|
||||
/// investments it buys (ASC 230-10-20; 230-10-45-13). Registered fixed
|
||||
/// assets are investing without being listed. Default empty: other
|
||||
/// assets are operating working capital.
|
||||
pub investing_accounts: Vec<crate::AccountId>,
|
||||
}
|
||||
|
||||
/// Cash flow statement, indirect method.
|
||||
@@ -952,8 +1191,43 @@ pub struct CashFlowStatement {
|
||||
///
|
||||
/// Pure function of the ledger: net income plus non-cash depreciation plus
|
||||
/// working-capital changes gives operating cash flow; fixed-asset purchases
|
||||
/// and disposal proceeds give investing; equity and designated debt give
|
||||
/// financing. The three sections sum to the net change in `cash_accounts`.
|
||||
/// and disposal proceeds, and the accounts named as investing, give
|
||||
/// investing; equity and the debt named as financing give financing. The
|
||||
/// three sections sum to the net change in `cash_accounts`.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: the statement of cash flows by the indirect method: net income
|
||||
/// adjusted to operating cash, plus investing and financing, for a
|
||||
/// period.
|
||||
/// SOURCE: docs/spec/rules.md B-14 (ASC 230-10-45-28 indirect method;
|
||||
/// 230-10-45-16 operating inflows; 230-10-45-7/8 gross over net;
|
||||
/// 230-10-45-13/14/15 investing and financing flows; 230-10-20
|
||||
/// investing activities).
|
||||
/// INVARIANTS:
|
||||
/// 1. Operating + investing + financing MUST equal the net change in the
|
||||
/// cash accounts over the period, exactly.
|
||||
/// 2. Operating MUST start from the period's net income (closing entries
|
||||
/// excluded) and adjust for changes in non-cash working-capital
|
||||
/// accounts.
|
||||
/// 3. Investing MUST hold fixed-asset purchases and disposal proceeds
|
||||
/// (a disposal's gain or loss moves out of operating into investing)
|
||||
/// and the changes in the accounts named as investing.
|
||||
/// 4. Financing MUST hold changes in equity (other than earnings) and in
|
||||
/// the liabilities named as financing.
|
||||
/// 4a. Investing and financing MUST be gross (ASC 230-10-45-7: gross
|
||||
/// receipts and payments are "more relevant" than net): per account,
|
||||
/// cash in and cash out are separate lines (contributions apart from
|
||||
/// distributions, borrowing apart from repayment), netted only within
|
||||
/// one entry.
|
||||
/// 5. NEVER count closing entries.
|
||||
/// 6. The period is `[start, end]`, inclusive: an entry dated `start` is
|
||||
/// period activity, not part of the beginning cash balance.
|
||||
/// 7. Every line's amount is its cash effect: positive is cash in. Its
|
||||
/// label names the direction ("Increase in" / "Decrease in" for
|
||||
/// working capital; "Proceeds from" / "Repayment of" for financing
|
||||
/// debt; "Owner contributions" / "Owner distributions" for equity;
|
||||
/// "Payments for" / "Collections on" for investing accounts).
|
||||
/// ```
|
||||
pub fn cash_flow(
|
||||
ledger: &Ledger,
|
||||
params: &CashFlowParams,
|
||||
@@ -983,6 +1257,8 @@ pub fn cash_flow(
|
||||
let cash_set: HashSet<crate::AccountId> = params.cash_accounts.iter().copied().collect();
|
||||
let financing_set: HashSet<crate::AccountId> =
|
||||
params.financing_accounts.iter().copied().collect();
|
||||
let investing_set: HashSet<crate::AccountId> =
|
||||
params.investing_accounts.iter().copied().collect();
|
||||
|
||||
// Movement helpers: debits minus credits over a date predicate. Closing
|
||||
// entries are skipped: a close moves earnings into retained earnings,
|
||||
@@ -1064,6 +1340,12 @@ pub fn cash_flow(
|
||||
if cash_set.contains(&id) || cost_accounts.contains(&id) || accum_accounts.contains(&id) {
|
||||
continue; // cash reconciles; fixed assets are investing (gross, below)
|
||||
}
|
||||
if account.account_type == AccountType::Equity
|
||||
|| financing_set.contains(&id)
|
||||
|| investing_set.contains(&id)
|
||||
{
|
||||
continue; // financing and investing roles are gross, below
|
||||
}
|
||||
let begin = normal(id, begin_movement(id));
|
||||
let end = normal(id, period_movement(id) + begin_movement(id));
|
||||
let delta = end - begin;
|
||||
@@ -1083,14 +1365,6 @@ pub fn cash_flow(
|
||||
};
|
||||
(format!("{dir} {}", display(id)), cash_effect)
|
||||
}
|
||||
AccountType::Liability if financing_set.contains(&id) => {
|
||||
let dir = if delta > Decimal::ZERO {
|
||||
"Proceeds from"
|
||||
} else {
|
||||
"Repayment of"
|
||||
};
|
||||
(format!("{dir} {}", display(id)), cash_effect)
|
||||
}
|
||||
AccountType::Liability => {
|
||||
let dir = if delta > Decimal::ZERO {
|
||||
"Increase in"
|
||||
@@ -1099,28 +1373,88 @@ pub fn cash_flow(
|
||||
};
|
||||
(format!("{dir} {}", display(id)), cash_effect)
|
||||
}
|
||||
AccountType::Equity => {
|
||||
let dir = if delta > Decimal::ZERO {
|
||||
"Owner contributions"
|
||||
} else {
|
||||
"Owner distributions"
|
||||
};
|
||||
(format!("{dir} — {}", display(id)), cash_effect)
|
||||
}
|
||||
_ => continue, // revenue/expense handled via net income
|
||||
};
|
||||
let target = match account.account_type {
|
||||
AccountType::Equity => &mut financing,
|
||||
AccountType::Liability if financing_set.contains(&id) => &mut financing,
|
||||
_ => &mut working_capital,
|
||||
};
|
||||
target.push(CashFlowLine {
|
||||
working_capital.push(CashFlowLine {
|
||||
label,
|
||||
amount: line,
|
||||
account: Some(account.code.clone()),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Financing and investing accounts, gross (ASC 230-10-45-7) ---
|
||||
// Per account, cash in and cash out as separate lines; an entry's own
|
||||
// debits and credits to the account net first (one transaction, one
|
||||
// flow).
|
||||
let mut roles: Vec<&crate::Account> = ledger
|
||||
.chart()
|
||||
.all()
|
||||
.filter(|a| {
|
||||
!a.placeholder
|
||||
&& !cash_set.contains(&a.id)
|
||||
&& !cost_accounts.contains(&a.id)
|
||||
&& !accum_accounts.contains(&a.id)
|
||||
&& (a.account_type == AccountType::Equity
|
||||
|| financing_set.contains(&a.id)
|
||||
|| investing_set.contains(&a.id))
|
||||
})
|
||||
.collect();
|
||||
roles.sort_by(|a, b| a.code.cmp(&b.code));
|
||||
for account in roles {
|
||||
let (mut cash_in, mut cash_out) = (Decimal::ZERO, Decimal::ZERO);
|
||||
for entry in ledger.posted_entries() {
|
||||
let d = entry.date();
|
||||
if d < params.start || d > params.end || closings.contains(&entry.id()) {
|
||||
continue;
|
||||
}
|
||||
// Cash effect of this entry's change to the account: minus its
|
||||
// movement (debits - credits), for assets and claims alike.
|
||||
let effect: Decimal = entry
|
||||
.lines()
|
||||
.iter()
|
||||
.filter(|l| l.account == account.id)
|
||||
.map(|l| match l.side {
|
||||
crate::Side::Debit => -l.amount.amount,
|
||||
crate::Side::Credit => l.amount.amount,
|
||||
})
|
||||
.sum();
|
||||
if effect > Decimal::ZERO {
|
||||
cash_in += effect;
|
||||
} else {
|
||||
cash_out += effect;
|
||||
}
|
||||
}
|
||||
let name = display(account.id);
|
||||
let (in_label, out_label, target) = if investing_set.contains(&account.id) {
|
||||
(
|
||||
format!("Collections on {name}"),
|
||||
format!("Payments for {name}"),
|
||||
&mut investing,
|
||||
)
|
||||
} else if account.account_type == AccountType::Equity {
|
||||
(
|
||||
format!("Owner contributions — {name}"),
|
||||
format!("Owner distributions — {name}"),
|
||||
&mut financing,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
format!("Proceeds from {name}"),
|
||||
format!("Repayment of {name}"),
|
||||
&mut financing,
|
||||
)
|
||||
};
|
||||
for (label, amount) in [(in_label, cash_in), (out_label, cash_out)] {
|
||||
if !amount.is_zero() {
|
||||
target.push(CashFlowLine {
|
||||
label,
|
||||
amount,
|
||||
account: Some(account.code.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Investing, gross: purchases and disposal proceeds ---
|
||||
// A disposal entry credits a cost account and debits its linked accum.
|
||||
let mut disposal_gain_loss = Decimal::ZERO; // +gain / -loss, to remove from operating
|
||||
@@ -1172,10 +1506,8 @@ pub fn cash_flow(
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let book_value = cost_amount - accum_debit;
|
||||
// Sanity: the entry balances, so proceeds + accum debit = cost + gain - loss.
|
||||
debug_assert!((proceeds + accum_debit - cost_amount - gain + loss).is_zero());
|
||||
let _ = book_value;
|
||||
disposal_gain_loss += gain - loss;
|
||||
investing.push(CashFlowLine {
|
||||
label: format!("Sale of fixed assets — {proceeds_label}"),
|
||||
@@ -1235,3 +1567,203 @@ pub fn cash_flow(
|
||||
cash_ending,
|
||||
})
|
||||
}
|
||||
|
||||
/// One asset on the fixed asset register.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AssetRegisterRow {
|
||||
/// Asset id.
|
||||
pub asset_id: crate::AssetId,
|
||||
/// Its tag, if it has one.
|
||||
pub tag: Option<String>,
|
||||
/// What it is.
|
||||
pub description: String,
|
||||
/// The manufacturer's serial number, if recorded.
|
||||
pub serial: Option<String>,
|
||||
/// Placed in service (depreciation starts).
|
||||
pub in_service: NaiveDate,
|
||||
/// Capitalized cost.
|
||||
pub cost: Decimal,
|
||||
/// Salvage value.
|
||||
pub salvage: Decimal,
|
||||
/// Useful life in months.
|
||||
pub useful_life_months: u32,
|
||||
/// Accumulated depreciation by its schedule through the date (zero once
|
||||
/// disposed: it left the books with the asset).
|
||||
pub accumulated: Decimal,
|
||||
/// Cost less accumulated (zero once disposed).
|
||||
pub book_value: Decimal,
|
||||
/// Disposal date and proceeds, if disposed by the date.
|
||||
pub disposed: Option<(NaiveDate, Decimal)>,
|
||||
/// The federal tax basis rule, if recorded.
|
||||
pub tax_rule: Option<crate::TaxBasisRule>,
|
||||
/// The contributing member's adjusted basis (a contribution only).
|
||||
pub contributor_basis: Option<Decimal>,
|
||||
/// The depreciable federal tax basis (`Asset::tax_basis`), if recorded.
|
||||
pub tax_basis: Option<Decimal>,
|
||||
/// Built-in gain (+) or loss (−) at contribution, IRC §704(c)
|
||||
/// (`Asset::built_in_gain`).
|
||||
pub built_in_gain: Option<Decimal>,
|
||||
}
|
||||
|
||||
/// The fixed asset register as of a date, reconciled to the general ledger.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AssetRegister {
|
||||
/// The date.
|
||||
pub as_of: NaiveDate,
|
||||
/// Every asset registered by the date, in registration order; disposed
|
||||
/// ones carry their disposal and no cost on the books.
|
||||
pub rows: Vec<AssetRegisterRow>,
|
||||
/// Total cost of the assets still held.
|
||||
pub total_cost: Decimal,
|
||||
/// Their accumulated depreciation by schedule.
|
||||
pub total_accumulated: Decimal,
|
||||
/// Their book value.
|
||||
pub total_book_value: Decimal,
|
||||
/// The total depreciable tax basis of the assets still held that have
|
||||
/// one recorded.
|
||||
pub total_tax_basis: Decimal,
|
||||
/// How many assets still held have no tax basis recorded.
|
||||
pub missing_tax_basis: usize,
|
||||
/// The general ledger's balance (debit positive) of the assets' cost
|
||||
/// accounts on the date.
|
||||
pub ledger_cost: Decimal,
|
||||
/// The general ledger's accumulated depreciation (credit positive) in
|
||||
/// the assets' accumulated depreciation accounts on the date.
|
||||
pub ledger_accumulated: Decimal,
|
||||
}
|
||||
|
||||
impl AssetRegister {
|
||||
/// Depreciation the schedules say is due by the date but the books
|
||||
/// don't carry yet (run `run_depreciation`); negative if the books
|
||||
/// carry more.
|
||||
pub fn unposted_depreciation(&self) -> Decimal {
|
||||
self.total_accumulated - self.ledger_accumulated
|
||||
}
|
||||
|
||||
/// Cost in the ledger's cost accounts that no held asset accounts for
|
||||
/// (equipment bought but not registered), or negative if registered
|
||||
/// cost isn't in the books.
|
||||
pub fn unregistered_cost(&self) -> Decimal {
|
||||
self.ledger_cost - self.total_cost
|
||||
}
|
||||
}
|
||||
|
||||
/// The fixed asset register as of a date.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: one list of everything the company owns as a fixed asset: what,
|
||||
/// which label, cost, depreciation, book value, and whether the
|
||||
/// books agree.
|
||||
/// SOURCE: docs/spec/rules.md B-15 (depreciation); DECISION (Stephen,
|
||||
/// 2026-10-15): a fully featured asset register.
|
||||
/// INVARIANTS:
|
||||
/// 1. Rows: the assets registered on or before as_of, in registration
|
||||
/// order; an asset disposed on or before as_of shows its disposal,
|
||||
/// zero accumulated and book value, and counts in no total.
|
||||
/// 2. accumulated is the asset's schedule through as_of
|
||||
/// (assets::accumulated_depreciation); book_value = cost −
|
||||
/// accumulated.
|
||||
/// 3. Totals are the sums over the held assets, exactly; tax basis
|
||||
/// totals over those with one recorded, and missing_tax_basis counts
|
||||
/// the rest.
|
||||
/// 5. Each row's tax fields are the asset's (Asset::tax_basis,
|
||||
/// Asset::built_in_gain), never re-derived here.
|
||||
/// 4. ledger_cost and ledger_accumulated are the general ledger's
|
||||
/// balances on as_of of the distinct cost and accumulated
|
||||
/// depreciation accounts of the registered assets, so
|
||||
/// unposted_depreciation and unregistered_cost are zero exactly when
|
||||
/// register and books agree.
|
||||
/// 6. NEVER panics: a contributor basis is as journaled, of any size
|
||||
/// (rules.md B-28), and a total past exact arithmetic is refused
|
||||
/// (TooLargeToCompute).
|
||||
/// ```
|
||||
pub fn fixed_asset_register(
|
||||
ledger: &Ledger,
|
||||
as_of: NaiveDate,
|
||||
) -> Result<AssetRegister, LedgerError> {
|
||||
let too_large = |what: &str| LedgerError::TooLargeToCompute(format!("the register's {what}"));
|
||||
use crate::AssetStatus;
|
||||
let mut rows = Vec::new();
|
||||
let (mut cost, mut accumulated) = (Decimal::ZERO, Decimal::ZERO);
|
||||
let (mut tax_total, mut tax_missing) = (Decimal::ZERO, 0usize);
|
||||
let mut cost_accounts = std::collections::BTreeSet::new();
|
||||
let mut accum_accounts = std::collections::BTreeSet::new();
|
||||
for a in ledger
|
||||
.registered_assets()
|
||||
.filter(|a| a.purchase_date <= as_of)
|
||||
{
|
||||
cost_accounts.insert(a.cost_account);
|
||||
accum_accounts.insert(a.accum_depr_account);
|
||||
let disposed = match &a.status {
|
||||
AssetStatus::Disposed { date, proceeds } if *date <= as_of => {
|
||||
Some((*date, proceeds.amount))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let acc = if disposed.is_some() {
|
||||
Decimal::ZERO
|
||||
} else {
|
||||
crate::assets::accumulated_depreciation(a, as_of)
|
||||
};
|
||||
if disposed.is_none() {
|
||||
cost = cost
|
||||
.checked_add(a.cost.amount)
|
||||
.ok_or_else(|| too_large("cost"))?;
|
||||
accumulated = accumulated
|
||||
.checked_add(acc)
|
||||
.ok_or_else(|| too_large("accumulated depreciation"))?;
|
||||
match a.tax_basis() {
|
||||
Some(t) => {
|
||||
tax_total = tax_total
|
||||
.checked_add(t)
|
||||
.ok_or_else(|| too_large("tax basis"))?
|
||||
}
|
||||
None => tax_missing += 1,
|
||||
}
|
||||
}
|
||||
rows.push(AssetRegisterRow {
|
||||
asset_id: a.id,
|
||||
tag: a.tag.clone(),
|
||||
description: a.description.clone(),
|
||||
serial: a.serial.clone(),
|
||||
in_service: a.purchase_date,
|
||||
cost: a.cost.amount,
|
||||
salvage: a.salvage_value.amount,
|
||||
useful_life_months: a.useful_life_months,
|
||||
accumulated: acc,
|
||||
book_value: if disposed.is_some() {
|
||||
Decimal::ZERO
|
||||
} else {
|
||||
a.cost.amount - acc
|
||||
},
|
||||
disposed,
|
||||
tax_rule: a.tax.map(|t| t.rule),
|
||||
contributor_basis: a.tax.and_then(|t| t.contributor_basis),
|
||||
tax_basis: a.tax_basis(),
|
||||
built_in_gain: a.built_in_gain(),
|
||||
});
|
||||
}
|
||||
let tb = trial_balance_as_of(ledger, as_of);
|
||||
let net = |id: crate::AccountId| -> Decimal {
|
||||
let code = match ledger.chart().get(id) {
|
||||
Some(a) => &a.code,
|
||||
None => return Decimal::ZERO,
|
||||
};
|
||||
tb.lines
|
||||
.iter()
|
||||
.find(|l| &l.code == code)
|
||||
.map(|l| l.debit - l.credit)
|
||||
.unwrap_or(Decimal::ZERO)
|
||||
};
|
||||
Ok(AssetRegister {
|
||||
as_of,
|
||||
rows,
|
||||
total_cost: cost,
|
||||
total_accumulated: accumulated,
|
||||
total_book_value: cost - accumulated,
|
||||
total_tax_basis: tax_total,
|
||||
missing_tax_basis: tax_missing,
|
||||
ledger_cost: cost_accounts.iter().map(|&id| net(id)).sum(),
|
||||
ledger_accumulated: -accum_accounts.iter().map(|&id| net(id)).sum::<Decimal>(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Rules set by law that change over time, and how far wanderbooks knows
|
||||
//! them.
|
||||
//!
|
||||
//! The engine has no clock, so it never decides on its own that a rule is
|
||||
//! out of date. Instead each dated rule says which years it is known for
|
||||
//! and when it is due for review; callers that know today's date (the MCP
|
||||
//! server, the viewer) ask [`stale_rules`] and surface what is due.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: never apply a legal amount for a year it isn't known for, and say
|
||||
//! so before anyone needs it.
|
||||
//! SOURCE: DECISION (Stephen, 2026-10-13): when what wanderbooks knows is
|
||||
//! stale, research it or ask the owner or CPA, and update the rule
|
||||
//! for everyone (a pull request, or at least an issue, on
|
||||
//! github.com/swaits/wanderbooks).
|
||||
//! INVARIANTS:
|
||||
//! 1. NEVER returns a rule's value for a year past `known_through`.
|
||||
//! 2. MUST report a rule as stale from its review date on.
|
||||
//! ```
|
||||
//!
|
||||
//! To update a rule: vendor the new official text beside the old in its
|
||||
//! `source` file (docs/spec/README.md, "Vendoring rules"), extend the
|
||||
//! rule's table and `known_through`, move `review_after` to when the next
|
||||
//! change is expected, and say so in the commit.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
|
||||
/// A rule set by law whose value changes over time.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct DatedRule {
|
||||
/// Plain name, e.g. "1099 reporting threshold".
|
||||
pub name: &'static str,
|
||||
/// Its entry in docs/spec/rules.md, e.g. "B-19".
|
||||
pub catalog: &'static str,
|
||||
/// The vendored official text it comes from (a path in this repo,
|
||||
/// for the people who update the rule).
|
||||
pub source: &'static str,
|
||||
/// The authority itself, as an agent or a human can find it: the
|
||||
/// statute or publication, with its official address. What every
|
||||
/// agent-facing text names, never the repo's copy.
|
||||
pub authority: &'static str,
|
||||
/// The last year wanderbooks knows the rule for.
|
||||
pub known_through: i32,
|
||||
/// When to look again, as (year, month, day): the day the next value
|
||||
/// is expected to be published or to take effect.
|
||||
pub review: (i32, u32, u32),
|
||||
/// Why that date, and where the next value will come from.
|
||||
pub review_why: &'static str,
|
||||
}
|
||||
|
||||
impl DatedRule {
|
||||
/// The review date.
|
||||
pub fn review_after(&self) -> NaiveDate {
|
||||
let (y, m, d) = self.review;
|
||||
NaiveDate::from_ymd_opt(y, m, d).expect("rule review dates are valid")
|
||||
}
|
||||
|
||||
/// Whether the rule is due for review on `today`.
|
||||
pub fn is_stale(&self, today: NaiveDate) -> bool {
|
||||
today >= self.review_after()
|
||||
}
|
||||
}
|
||||
|
||||
/// The 1099-NEC / 1099-MISC reporting threshold (rules.md B-19): $600 for
|
||||
/// payments through 2025, $2,000 in 2026, then indexed for inflation.
|
||||
pub const THRESHOLD_1099: DatedRule = DatedRule {
|
||||
name: "1099 reporting threshold",
|
||||
catalog: "B-19",
|
||||
source: "docs/spec/sources/irs-1099-thresholds.md",
|
||||
authority: "26 U.S.C. §6041(a) and (h) and the IRS's yearly figure in the Instructions for Forms 1099-MISC and 1099-NEC (https://www.irs.gov/instructions/i1099mec)",
|
||||
known_through: 2026,
|
||||
review: (2026, 11, 1),
|
||||
review_why: "26 U.S.C. §6041(h) indexes the threshold for calendar years after 2026, \
|
||||
rounded to the nearest $100; the IRS publishes each year's inflation \
|
||||
adjustments late in the year before (CONVENTION: the usual timing, not a \
|
||||
cited date), so look for the 2027 figure from November 2026.",
|
||||
};
|
||||
|
||||
/// Every dated rule the engine applies.
|
||||
pub const DATED_RULES: &[DatedRule] = &[THRESHOLD_1099];
|
||||
|
||||
/// The dated rules due for review on `today`.
|
||||
pub fn stale_rules(today: NaiveDate) -> Vec<&'static DatedRule> {
|
||||
DATED_RULES.iter().filter(|r| r.is_stale(today)).collect()
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
//! Things that happen on a schedule.
|
||||
//!
|
||||
//! Two kinds, both master data owned by [`crate::Ledger`] and journaled:
|
||||
//!
|
||||
//! - **Amortization schedules** spread a prepaid expense or deferred revenue
|
||||
//! evenly over the months it covers: a year of insurance paid up front is
|
||||
//! an asset expensed 1/12 a month; a year of subscription revenue
|
||||
//! collected up front is a liability earned 1/12 a month. Like
|
||||
//! depreciation, the schedule is the evidence for its postings, so they
|
||||
//! need no receipts. Each month posts once (idempotent runs).
|
||||
//! - **Recurring entries** (rent, subscriptions, payroll) generate *drafts*
|
||||
//! on their dates, never posted entries: the agent attaches the actual
|
||||
//! receipt and posts through the normal preview, so the receipt policy is
|
||||
//! never bypassed.
|
||||
|
||||
use crate::{AccountId, AccountType, ChartOfAccounts, LedgerError, Money, Side};
|
||||
use chrono::{Datelike, Days, Months, NaiveDate};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// Opaque handle for an amortization schedule.
|
||||
pub type ScheduleId = u64;
|
||||
/// Opaque handle for a recurring entry.
|
||||
pub type RecurringId = u64;
|
||||
/// One line of a template entry: account, side, amount.
|
||||
pub type TemplateLine = (AccountId, Side, Money);
|
||||
|
||||
/// What an amortization schedule recognizes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ScheduleKind {
|
||||
/// A prepaid expense: each month debits the expense account and
|
||||
/// credits the prepaid (asset) account.
|
||||
PrepaidExpense,
|
||||
/// Deferred (unearned) revenue: each month debits the deferred revenue
|
||||
/// (liability) account and credits the revenue account.
|
||||
DeferredRevenue,
|
||||
}
|
||||
|
||||
/// An amortization schedule.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AmortizationSchedule {
|
||||
/// Opaque handle.
|
||||
pub id: ScheduleId,
|
||||
/// What it is, e.g. "2027 general liability insurance".
|
||||
pub description: String,
|
||||
/// Prepaid expense or deferred revenue.
|
||||
pub kind: ScheduleKind,
|
||||
/// The amount to spread.
|
||||
pub total: Money,
|
||||
/// Any date in the first month covered.
|
||||
pub start: NaiveDate,
|
||||
/// Months covered.
|
||||
pub months: u32,
|
||||
/// The balance-sheet account: prepaid (asset) or deferred revenue
|
||||
/// (liability).
|
||||
pub balance_account: AccountId,
|
||||
/// The P&L account: expense or revenue.
|
||||
pub pl_account: AccountId,
|
||||
/// Months already posted, as their month-end dates.
|
||||
pub posted: BTreeSet<NaiveDate>,
|
||||
}
|
||||
|
||||
/// Parameters for a new schedule.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NewSchedule {
|
||||
/// What it is.
|
||||
pub description: String,
|
||||
/// Prepaid expense or deferred revenue.
|
||||
pub kind: ScheduleKind,
|
||||
/// The amount to spread (positive).
|
||||
pub total: Money,
|
||||
/// Any date in the first month covered.
|
||||
pub start: NaiveDate,
|
||||
/// Months covered (at least 1).
|
||||
pub months: u32,
|
||||
/// Prepaid (asset) or deferred revenue (liability) account.
|
||||
pub balance_account: AccountId,
|
||||
/// Expense or revenue account.
|
||||
pub pl_account: AccountId,
|
||||
}
|
||||
|
||||
/// The last day of `d`'s month: in chrono's last month (December 262142),
|
||||
/// NaiveDate::MAX, never a panic.
|
||||
pub fn month_end(d: NaiveDate) -> NaiveDate {
|
||||
d.with_day(1)
|
||||
.and_then(|first| first.checked_add_months(Months::new(1)))
|
||||
.and_then(|next| next.checked_sub_days(Days::new(1)))
|
||||
.unwrap_or(NaiveDate::MAX)
|
||||
}
|
||||
|
||||
/// The smallest total whose plan isn't computed exactly in cents (10^26):
|
||||
/// 26 integer digits and 2 decimals fill Decimal's 28 significant digits.
|
||||
pub const EXACT_LIMIT: Decimal =
|
||||
Decimal::from_parts(0xe400_0000, 0xdcc8_0cd2, 0x0052_b7d2, false, 0);
|
||||
|
||||
/// The schedule's monthly postings: (month-end date, amount). Equal
|
||||
/// amounts to the cent, the last month absorbing rounding, so they sum to
|
||||
/// the total exactly.
|
||||
///
|
||||
/// ```text
|
||||
/// INVARIANTS:
|
||||
/// 1. The amounts sum to `total` exactly; all but the last are equal.
|
||||
/// 2. NEVER panics: a schedule an earlier version journaled whose months
|
||||
/// run past chrono's last day is refused with DateOutOfRange, and one
|
||||
/// whose amounts exact arithmetic can't hold with TooLargeToCompute
|
||||
/// (rules.md B-28). `months` is at least 1 (validate_schedule_shape).
|
||||
/// 3. NEVER rounds silently: a total of EXACT_LIMIT or more is refused
|
||||
/// with TooLargeToCompute. Below it every amount in cents, and every
|
||||
/// sum of them (none exceeds the total), fits Decimal's 28 significant
|
||||
/// digits, so INVARIANT 1 holds exactly; at it, Decimal's checked
|
||||
/// operations round instead of failing.
|
||||
/// ```
|
||||
///
|
||||
/// Replay is unaffected: a posted schedule month passed the line limit
|
||||
/// (kernel::MAX_LINE_AMOUNT), so no journaled schedule.posted belongs to a
|
||||
/// schedule with a total anywhere near EXACT_LIMIT.
|
||||
pub fn amortization_plan(
|
||||
total: Decimal,
|
||||
start: NaiveDate,
|
||||
months: u32,
|
||||
) -> Result<Vec<(NaiveDate, Decimal)>, LedgerError> {
|
||||
let too_large = || LedgerError::TooLargeToCompute(format!("a {months}-month plan of {total}"));
|
||||
if total.abs() >= EXACT_LIMIT {
|
||||
return Err(too_large());
|
||||
}
|
||||
let each = total
|
||||
.checked_div(Decimal::from(months))
|
||||
.ok_or_else(too_large)?
|
||||
.round_dp(2);
|
||||
let first = start
|
||||
.with_day(1)
|
||||
.ok_or(LedgerError::DateOutOfRange(start))?;
|
||||
(0..months)
|
||||
.map(|i| {
|
||||
let month = first
|
||||
.checked_add_months(Months::new(i))
|
||||
.ok_or(LedgerError::DateOutOfRange(start))?;
|
||||
let amount = if i + 1 == months {
|
||||
each.checked_mul(Decimal::from(months - 1))
|
||||
.and_then(|before| total.checked_sub(before))
|
||||
.ok_or_else(too_large)?
|
||||
} else {
|
||||
each
|
||||
};
|
||||
Ok((month_end(month), amount))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Validate a new schedule against the chart: [`validate_schedule_shape`],
|
||||
/// the total in range and in cents, and its first and last months inside
|
||||
/// the calendar the books keep (crate::calendar; P0 review round 3, S1),
|
||||
/// so every month end of the plan is a date.
|
||||
pub fn validate_schedule(new: &NewSchedule, chart: &ChartOfAccounts) -> Result<(), LedgerError> {
|
||||
validate_schedule_shape(new, chart)?;
|
||||
crate::calendar::check(new.start)?;
|
||||
if !new
|
||||
.start
|
||||
.checked_add_months(Months::new(new.months - 1))
|
||||
.is_some_and(crate::calendar::in_calendar)
|
||||
{
|
||||
return Err(LedgerError::InvalidSchedule(format!(
|
||||
"{} months from {} run past 9999-12-31, the last day the books keep",
|
||||
new.months, new.start
|
||||
)));
|
||||
}
|
||||
// Every month's posting derives from the total: one no line could
|
||||
// carry, or finer than a cent, is refused here, not at a month end
|
||||
// (found by the store's refusal property, 2026-10-13).
|
||||
if new.total.amount > crate::kernel::MAX_LINE_AMOUNT {
|
||||
return Err(LedgerError::AmountOutOfRange);
|
||||
}
|
||||
crate::ledger::check_minor_units(new.total)
|
||||
}
|
||||
|
||||
/// [`validate_schedule`] without the total's range and minor-unit checks
|
||||
/// added on 2026-10-13 or the calendar bound added in P0: the checks every
|
||||
/// journaled `schedule.registered` passed when it was written, for
|
||||
/// replaying journals written before them (rules.md B-28).
|
||||
pub fn validate_schedule_shape(
|
||||
new: &NewSchedule,
|
||||
chart: &ChartOfAccounts,
|
||||
) -> Result<(), LedgerError> {
|
||||
let bad = |m: String| Err(LedgerError::InvalidSchedule(m));
|
||||
if new.description.trim().is_empty() {
|
||||
return bad("description is required".into());
|
||||
}
|
||||
if new.total.amount <= Decimal::ZERO {
|
||||
return bad(format!("total must be positive, got {}", new.total.amount));
|
||||
}
|
||||
if new.months == 0 || new.months > 600 {
|
||||
return bad(format!("months must be 1 to 600, got {}", new.months));
|
||||
}
|
||||
let (want_balance, want_pl) = match new.kind {
|
||||
ScheduleKind::PrepaidExpense => (AccountType::Asset, AccountType::Expense),
|
||||
ScheduleKind::DeferredRevenue => (AccountType::Liability, AccountType::Revenue),
|
||||
};
|
||||
for (id, want) in [
|
||||
(new.balance_account, want_balance),
|
||||
(new.pl_account, want_pl),
|
||||
] {
|
||||
let a = chart.get(id).ok_or(LedgerError::UnknownAccount(id))?;
|
||||
if a.account_type != want || a.placeholder {
|
||||
return bad(format!(
|
||||
"account {} must be a postable {want:?} account for a {:?} schedule",
|
||||
a.code, new.kind
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl AmortizationSchedule {
|
||||
/// The postings for one month: (debit account, credit account).
|
||||
pub fn accounts(&self) -> (AccountId, AccountId) {
|
||||
match self.kind {
|
||||
ScheduleKind::PrepaidExpense => (self.pl_account, self.balance_account),
|
||||
ScheduleKind::DeferredRevenue => (self.balance_account, self.pl_account),
|
||||
}
|
||||
}
|
||||
|
||||
/// Its monthly postings ([`amortization_plan`]); refused, never a
|
||||
/// panic, for one that can't be computed.
|
||||
pub fn plan(&self) -> Result<Vec<(NaiveDate, Decimal)>, LedgerError> {
|
||||
amortization_plan(self.total.amount, self.start, self.months)
|
||||
}
|
||||
|
||||
/// Months due through `through` and not yet posted.
|
||||
pub fn due(&self, through: NaiveDate) -> Result<Vec<(NaiveDate, Decimal)>, LedgerError> {
|
||||
Ok(self
|
||||
.plan()?
|
||||
.into_iter()
|
||||
.filter(|(d, _)| *d <= through && !self.posted.contains(d))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The amount not yet recognized: the sum of the plan's unposted
|
||||
/// months.
|
||||
pub fn remaining(&self) -> Result<Decimal, LedgerError> {
|
||||
self.plan()?
|
||||
.into_iter()
|
||||
.filter(|(d, _)| !self.posted.contains(d))
|
||||
.try_fold(Decimal::ZERO, |sum, (_, a)| sum.checked_add(a))
|
||||
.ok_or_else(|| {
|
||||
LedgerError::TooLargeToCompute(format!("schedule {}'s remaining amount", self.id))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// How often a recurring entry happens.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Frequency {
|
||||
/// Every 7 days.
|
||||
Weekly,
|
||||
/// Every month, on the start date's day (clamped to month end).
|
||||
Monthly,
|
||||
/// Every three months.
|
||||
Quarterly,
|
||||
/// Every year.
|
||||
Yearly,
|
||||
}
|
||||
|
||||
impl Frequency {
|
||||
/// Parse "weekly", "monthly", "quarterly", "yearly" ("annually").
|
||||
pub fn parse(s: &str) -> Option<Frequency> {
|
||||
match s.trim().to_lowercase().as_str() {
|
||||
"weekly" => Some(Frequency::Weekly),
|
||||
"monthly" => Some(Frequency::Monthly),
|
||||
"quarterly" => Some(Frequency::Quarterly),
|
||||
"yearly" | "annually" | "annual" => Some(Frequency::Yearly),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `k`th occurrence from `start`, computed from the start each
|
||||
/// time so month-end dates don't drift (Jan 31 → Feb 28 → Mar 31).
|
||||
pub fn nth(&self, start: NaiveDate, k: u32) -> Option<NaiveDate> {
|
||||
match self {
|
||||
Frequency::Weekly => start.checked_add_days(Days::new(7 * k as u64)),
|
||||
Frequency::Monthly => start.checked_add_months(Months::new(k)),
|
||||
Frequency::Quarterly => start.checked_add_months(Months::new(3 * k)),
|
||||
Frequency::Yearly => start.checked_add_months(Months::new(12 * k)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A recurring entry: a balanced template and when it happens.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RecurringEntry {
|
||||
/// Opaque handle.
|
||||
pub id: RecurringId,
|
||||
/// Description for each generated entry.
|
||||
pub description: String,
|
||||
/// The balanced lines.
|
||||
pub lines: Vec<TemplateLine>,
|
||||
/// How often.
|
||||
pub frequency: Frequency,
|
||||
/// First occurrence.
|
||||
pub start: NaiveDate,
|
||||
/// Last date an occurrence may fall on, if it ends.
|
||||
pub end: Option<NaiveDate>,
|
||||
/// Occurrences already generated, by date.
|
||||
pub generated: BTreeSet<NaiveDate>,
|
||||
}
|
||||
|
||||
/// Parameters for a new recurring entry.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NewRecurring {
|
||||
/// Description for each generated entry.
|
||||
pub description: String,
|
||||
/// The balanced lines.
|
||||
pub lines: Vec<TemplateLine>,
|
||||
/// How often.
|
||||
pub frequency: Frequency,
|
||||
/// First occurrence.
|
||||
pub start: NaiveDate,
|
||||
/// Optional last date.
|
||||
pub end: Option<NaiveDate>,
|
||||
}
|
||||
|
||||
/// Validate a new recurring entry: [`validate_recurring_journaled`], its
|
||||
/// dates inside the calendar the books keep, and no line larger than a
|
||||
/// journal line may be (P0 review round 3, S1).
|
||||
pub fn validate_recurring(new: &NewRecurring, chart: &ChartOfAccounts) -> Result<(), LedgerError> {
|
||||
// The size first: a line past the limit is out of range, whatever its
|
||||
// sum would be.
|
||||
if new
|
||||
.lines
|
||||
.iter()
|
||||
.any(|(_, _, money)| money.amount > crate::kernel::MAX_LINE_AMOUNT)
|
||||
{
|
||||
return Err(LedgerError::AmountOutOfRange);
|
||||
}
|
||||
validate_recurring_journaled(new, chart)?;
|
||||
crate::calendar::check(new.start)?;
|
||||
if let Some(end) = new.end {
|
||||
crate::calendar::check(end)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The checks every journaled `recurring.registered` passed when it was
|
||||
/// written (the rule through 2026-10-16): at least two lines, positive
|
||||
/// amounts, postable accounts, balanced, and an end not before the start.
|
||||
/// Replay applies exactly these (rules.md B-28). Sums too large for exact
|
||||
/// arithmetic are refused (TooLargeToCompute), never a panic.
|
||||
pub fn validate_recurring_journaled(
|
||||
new: &NewRecurring,
|
||||
chart: &ChartOfAccounts,
|
||||
) -> Result<(), LedgerError> {
|
||||
let bad = |m: String| Err(LedgerError::InvalidSchedule(m));
|
||||
if new.description.trim().is_empty() {
|
||||
return bad("description is required".into());
|
||||
}
|
||||
if new.lines.len() < 2 {
|
||||
return bad("a recurring entry needs at least two lines".into());
|
||||
}
|
||||
if new.end.is_some_and(|e| e < new.start) {
|
||||
return bad("end is before start".into());
|
||||
}
|
||||
let (mut debits, mut credits) = (Decimal::ZERO, Decimal::ZERO);
|
||||
for (account, side, money) in &new.lines {
|
||||
let a = chart
|
||||
.get(*account)
|
||||
.ok_or(LedgerError::UnknownAccount(*account))?;
|
||||
if a.placeholder {
|
||||
return bad(format!("account {} is a placeholder", a.code));
|
||||
}
|
||||
if money.amount <= Decimal::ZERO {
|
||||
return bad(format!(
|
||||
"line amounts must be positive, got {}",
|
||||
money.amount
|
||||
));
|
||||
}
|
||||
let total = match side {
|
||||
Side::Debit => &mut debits,
|
||||
Side::Credit => &mut credits,
|
||||
};
|
||||
*total = total
|
||||
.checked_add(money.amount)
|
||||
.ok_or_else(|| LedgerError::TooLargeToCompute("the recurring entry's total".into()))?;
|
||||
}
|
||||
if debits != credits {
|
||||
return bad(format!(
|
||||
"lines don't balance: debits {debits}, credits {credits}"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl RecurringEntry {
|
||||
/// Occurrences on or before `through` (and the end), not yet generated.
|
||||
pub fn due(&self, through: NaiveDate) -> Vec<NaiveDate> {
|
||||
let last = self.end.map_or(through, |e| e.min(through));
|
||||
(0..)
|
||||
.map_while(|k| self.frequency.nth(self.start, k).filter(|d| *d <= last))
|
||||
.filter(|d| !self.generated.contains(d))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plans_sum_exactly_and_land_on_month_ends() {
|
||||
let plan = amortization_plan(Decimal::new(100_000, 2), d(2026, 11, 15), 3).unwrap();
|
||||
// 1,000.00 / 3 = 333.33, 333.33, 333.34.
|
||||
assert_eq!(
|
||||
plan,
|
||||
[
|
||||
(d(2026, 11, 30), Decimal::new(33_333, 2)),
|
||||
(d(2026, 12, 31), Decimal::new(33_333, 2)),
|
||||
(d(2027, 1, 31), Decimal::new(33_334, 2)),
|
||||
]
|
||||
);
|
||||
let leap = amortization_plan(Decimal::new(1200, 0), d(2028, 2, 1), 1).unwrap();
|
||||
assert_eq!(leap, [(d(2028, 2, 29), Decimal::new(1200, 0))]);
|
||||
}
|
||||
|
||||
/// A schedule an earlier version journaled, at chrono's last month or
|
||||
/// with Decimal's largest total: its plan is computed where it can be,
|
||||
/// refused where it can't, never a panic (rules.md B-28).
|
||||
#[test]
|
||||
fn plans_at_the_edges_are_computed_or_refused() {
|
||||
assert_eq!(month_end(NaiveDate::MAX), NaiveDate::MAX);
|
||||
assert_eq!(month_end(d(262_142, 12, 1)), NaiveDate::MAX);
|
||||
assert_eq!(month_end(d(262_142, 11, 5)), d(262_142, 11, 30));
|
||||
assert_eq!(month_end(NaiveDate::MIN), d(NaiveDate::MIN.year(), 1, 31));
|
||||
// The last month chrono holds is a month of the plan.
|
||||
assert_eq!(
|
||||
amortization_plan(Decimal::new(300, 0), d(262_142, 11, 9), 2).unwrap(),
|
||||
[
|
||||
(d(262_142, 11, 30), Decimal::new(150, 0)),
|
||||
(NaiveDate::MAX, Decimal::new(150, 0)),
|
||||
]
|
||||
);
|
||||
// One month further is none.
|
||||
assert_eq!(
|
||||
amortization_plan(Decimal::new(300, 0), d(262_142, 11, 9), 3),
|
||||
Err(LedgerError::DateOutOfRange(d(262_142, 11, 9)))
|
||||
);
|
||||
// Past the line limit (replay takes any positive total): exact, the
|
||||
// last month the remainder, right up to just under EXACT_LIMIT.
|
||||
for big in ["2000000000000000.00", "99999999999999999999999999.99"] {
|
||||
let big: Decimal = big.parse().unwrap();
|
||||
let plan = amortization_plan(big, d(2026, 1, 1), 600).unwrap();
|
||||
assert_eq!(plan.len(), 600);
|
||||
assert_eq!(
|
||||
plan.iter()
|
||||
.try_fold(Decimal::ZERO, |s, (_, a)| s.checked_add(*a)),
|
||||
Some(big)
|
||||
);
|
||||
}
|
||||
// At EXACT_LIMIT and beyond, Decimal would round: refused, never
|
||||
// rounded (INVARIANT 3), in either sign.
|
||||
assert_eq!(
|
||||
EXACT_LIMIT,
|
||||
Decimal::from_i128_with_scale(10_i128.pow(26), 0)
|
||||
);
|
||||
for total in [EXACT_LIMIT, -EXACT_LIMIT, Decimal::MAX, Decimal::MIN] {
|
||||
assert!(matches!(
|
||||
amortization_plan(total, d(2026, 1, 1), 600),
|
||||
Err(LedgerError::TooLargeToCompute(_))
|
||||
));
|
||||
}
|
||||
let s = AmortizationSchedule {
|
||||
id: 1,
|
||||
description: "edge".into(),
|
||||
kind: ScheduleKind::PrepaidExpense,
|
||||
total: Money::new(Decimal::new(300, 0), crate::Currency::USD),
|
||||
start: d(262_142, 11, 9),
|
||||
months: 3,
|
||||
balance_account: 1,
|
||||
pl_account: 2,
|
||||
posted: BTreeSet::new(),
|
||||
};
|
||||
assert!(s.due(NaiveDate::MAX).is_err());
|
||||
assert!(s.remaining().is_err());
|
||||
let s = AmortizationSchedule { months: 2, ..s };
|
||||
assert_eq!(s.due(NaiveDate::MAX).unwrap().len(), 2);
|
||||
assert_eq!(s.remaining(), Ok(Decimal::new(300, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monthly_occurrences_do_not_drift() {
|
||||
let f = Frequency::Monthly;
|
||||
let dates: Vec<NaiveDate> = (0..4).map(|k| f.nth(d(2026, 1, 31), k).unwrap()).collect();
|
||||
assert_eq!(
|
||||
dates,
|
||||
[
|
||||
d(2026, 1, 31),
|
||||
d(2026, 2, 28),
|
||||
d(2026, 3, 31),
|
||||
d(2026, 4, 30)
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
Frequency::Quarterly.nth(d(2026, 1, 15), 3),
|
||||
Some(d(2026, 10, 15))
|
||||
);
|
||||
assert_eq!(
|
||||
Frequency::Weekly.nth(d(2026, 1, 1), 2),
|
||||
Some(d(2026, 1, 15))
|
||||
);
|
||||
assert_eq!(Frequency::parse("Annually"), Some(Frequency::Yearly));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,363 @@
|
||||
//! Party shares: an entry with more than one party, each with their part of
|
||||
//! what it moved.
|
||||
//!
|
||||
//! An entry names its counterparties as a list of [`PartyShare`]s. Most
|
||||
//! name one party, who had all of it. A joint transaction names several:
|
||||
//! the two members of a partnership reimbursed together, a payment split
|
||||
//! between two contractors, a contribution from both members' joint funds.
|
||||
//! Each share is an exact amount, a percentage, or derived from the entry
|
||||
//! itself ([`Share::Derived`]).
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: "what did we pay each of them?" right when one entry was with
|
||||
//! several parties, with apportioning the human controls exactly.
|
||||
//! SOURCE: DECISION (Stephen, 2026-10-15): no "joint" party; a joint
|
||||
//! transaction is with both members, each with their share, and
|
||||
//! shares can be apportioned explicitly ("do you allow for
|
||||
//! controlled apportioning?" Yes). docs/parties-and-attachments.md
|
||||
//! "Shares".
|
||||
//! DEFINITIONS:
|
||||
//! flow: crate::parties::entry_flow(entry, money).
|
||||
//! target: flow.net when it is not zero, else the entry's total debits
|
||||
//! (a member contributing property moves no money but is still all
|
||||
//! theirs). magnitude = |target|; sign = the sign of target (positive
|
||||
//! when net is zero).
|
||||
//! A member: a PartyMember (party, income share, capital account,
|
||||
//! distributions account), passed in by the caller from the company's
|
||||
//! settings ([[company.members]]); core doesn't read the settings.
|
||||
//! INVARIANTS (of an entry's shares, checked by check_shares, journaled):
|
||||
//! 1. No party appears twice; every party is in the directory.
|
||||
//! 2. All shares are one kind: all Amount, all Percent, or all Derived.
|
||||
//! 3. An Amount is positive with at most two decimal places (cents).
|
||||
//! 4. A Percent is positive, at most 100, and the percents sum to exactly
|
||||
//! 100.
|
||||
//! 5. At most one party has a vendor record: a 1099 counts a payment
|
||||
//! toward a single vendor.
|
||||
//! INVARIANTS (of resolve, against the entry's lines and the members):
|
||||
//! 6. Resolved amounts sum to target exactly, and each has target's sign.
|
||||
//! 7. Amount: each party gets sign × its amount; refused unless the
|
||||
//! amounts sum to magnitude.
|
||||
//! 8. Percent: each party gets magnitude × percent / 100 rounded toward
|
||||
//! zero to the cent; the cents left over (fewer than one per party)
|
||||
//! go to the first party in order. Then the sign.
|
||||
//! 9. Derived, one party: it gets target.
|
||||
//! 10. Derived, several parties: every one must be a member (else
|
||||
//! refused: give amounts or percents). If the entry has a line on any
|
||||
//! named member's capital or distributions account, each member gets
|
||||
//! |credits − debits| on their own two accounts, and those must sum
|
||||
//! to magnitude (else refused). Otherwise each gets their income share
|
||||
//! of magnitude, the shares rescaled to the members named, rounded as
|
||||
//! in 8 (remainder to the first).
|
||||
//! SCENARIOS:
|
||||
//! 1. Reimbursing both members $100.00 (Dr 2100 / Cr 1000), both named,
|
||||
//! derived, 50/50: $50.00 each.
|
||||
//! 2. A joint contribution (Dr 1000 300 / Cr 3000 200 / Cr 3001 100):
|
||||
//! derived from the capital lines, Stephen −200.00, Katherine −100.00
|
||||
//! (received).
|
||||
//! 3. A property contribution (Dr Equipment 1000 / Cr 3000 500 / Cr 3001
|
||||
//! 500): net is zero; target is the total, 500.00 each.
|
||||
//! 4. $100.00 split 1/3 each three ways by percent 33.33, 33.33, 33.34:
|
||||
//! 33.33, 33.33, 33.34. By 33.333/33.333/33.334: 33.34, 33.33, 33.33
|
||||
//! (the leftover cent to the first).
|
||||
//! 5. Amounts of $60.00 and $30.00 on a $100.00 payment: refused.
|
||||
//! ```
|
||||
|
||||
use crate::{AccountId, JournalEntry, PartyId, Side};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// What part of an entry one party had.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Share {
|
||||
/// Exactly this much of what the entry moved (a positive amount, in
|
||||
/// cents; the sign follows the entry).
|
||||
Amount(Decimal),
|
||||
/// This percent of what the entry moved (0 < p ≤ 100).
|
||||
Percent(Decimal),
|
||||
/// From the entry itself: all of it for a sole party; for members,
|
||||
/// their capital lines or their income share (see the module's
|
||||
/// INVARIANT 10).
|
||||
Derived,
|
||||
}
|
||||
|
||||
/// One party named on an entry, with their share.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PartyShare {
|
||||
/// The party.
|
||||
#[serde(rename = "party_id")]
|
||||
pub party: PartyId,
|
||||
/// Their share.
|
||||
pub share: Share,
|
||||
}
|
||||
|
||||
impl PartyShare {
|
||||
/// The sole party, with all of it.
|
||||
pub fn whole(party: PartyId) -> Self {
|
||||
PartyShare {
|
||||
party,
|
||||
share: Share::Derived,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A member of the company as shares see them: their party, their share of
|
||||
/// income (a fraction, e.g. 0.50), and their capital and distributions
|
||||
/// accounts. Built by the caller from the settings' members.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct PartyMember {
|
||||
/// The member's party in the directory.
|
||||
pub party: PartyId,
|
||||
/// Their share of income, as a fraction (the members' sum to 1).
|
||||
pub share: Decimal,
|
||||
/// Their capital account.
|
||||
pub capital: AccountId,
|
||||
/// Their distributions account, if they have one.
|
||||
pub distributions: Option<AccountId>,
|
||||
}
|
||||
|
||||
/// The structural checks on an entry's shares (the module's INVARIANTS 1–4;
|
||||
/// 5 needs the directory and is [`crate::Ledger::check_set_entry_parties`]'s).
|
||||
/// `known` says whether a party is in the directory. Returns why not.
|
||||
///
|
||||
/// For new shares: [`check_shares_journaled`], and no amount larger than a
|
||||
/// line may be (kernel::MAX_LINE_AMOUNT; P0 review round 3, S1). Replay
|
||||
/// checks only [`check_shares_journaled`] (rules.md B-28).
|
||||
pub fn check_shares(shares: &[PartyShare], known: impl Fn(PartyId) -> bool) -> Result<(), String> {
|
||||
check_shares_journaled(shares, known)?;
|
||||
for s in shares {
|
||||
if let Share::Amount(a) = s.share
|
||||
&& a > crate::kernel::MAX_LINE_AMOUNT
|
||||
{
|
||||
return Err(format!(
|
||||
"party {}'s amount {a} is larger than any entry may move (at most one quadrillion)",
|
||||
s.party
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The checks every journaled `entry.parties_set` passed when it was
|
||||
/// written (the rule through 2026-10-16): [`check_shares`] without the
|
||||
/// amount's upper bound. Replay applies exactly these, so books an earlier
|
||||
/// version wrote always open (rules.md B-28).
|
||||
pub fn check_shares_journaled(
|
||||
shares: &[PartyShare],
|
||||
known: impl Fn(PartyId) -> bool,
|
||||
) -> Result<(), String> {
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for s in shares {
|
||||
if !known(s.party) {
|
||||
return Err(format!("unknown party {}", s.party));
|
||||
}
|
||||
if !seen.insert(s.party) {
|
||||
return Err(format!("party {} is named twice", s.party));
|
||||
}
|
||||
}
|
||||
let kind = |s: &Share| match s {
|
||||
Share::Amount(_) => 0,
|
||||
Share::Percent(_) => 1,
|
||||
Share::Derived => 2,
|
||||
};
|
||||
if let Some(first) = shares.first()
|
||||
&& shares.iter().any(|s| kind(&s.share) != kind(&first.share))
|
||||
{
|
||||
return Err(
|
||||
"give every party the same kind of share: all amounts, all percents, or none (derived)"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let mut percent = Decimal::ZERO;
|
||||
let mut any_percent = false;
|
||||
for s in shares {
|
||||
match s.share {
|
||||
Share::Amount(a) => {
|
||||
if a <= Decimal::ZERO {
|
||||
return Err(format!("party {}'s amount must be positive", s.party));
|
||||
}
|
||||
if a.normalize().scale() > 2 {
|
||||
return Err(format!(
|
||||
"party {}'s amount {a} is finer than a cent",
|
||||
s.party
|
||||
));
|
||||
}
|
||||
}
|
||||
Share::Percent(p) => {
|
||||
if p <= Decimal::ZERO || p > Decimal::ONE_HUNDRED {
|
||||
return Err(format!(
|
||||
"party {}'s percent must be more than 0 and at most 100",
|
||||
s.party
|
||||
));
|
||||
}
|
||||
any_percent = true;
|
||||
percent += p;
|
||||
}
|
||||
Share::Derived => {}
|
||||
}
|
||||
}
|
||||
if any_percent && percent != Decimal::ONE_HUNDRED {
|
||||
return Err(format!("the percents sum to {percent}, not 100"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What the shares are split over: the entry's flow net, else its total
|
||||
/// debits (the module's DEFINITIONS).
|
||||
/// Refused (TooLargeToCompute), never a panic, when a sum is past exact
|
||||
/// arithmetic: a draft's lines and an entry's shares are as journaled, of
|
||||
/// any size (rules.md B-28).
|
||||
pub fn share_target(
|
||||
entry: &JournalEntry,
|
||||
money: &crate::parties::MoneyAccounts,
|
||||
) -> Result<Decimal, crate::LedgerError> {
|
||||
let net = crate::parties::entry_flow(entry, money)?.net;
|
||||
if net.is_zero() {
|
||||
entry
|
||||
.lines()
|
||||
.iter()
|
||||
.filter(|l| l.side == Side::Debit)
|
||||
.try_fold(Decimal::ZERO, |s, l| s.checked_add(l.amount.amount))
|
||||
.ok_or_else(|| too_large(format!("entry {}'s debits", entry.id())))
|
||||
} else {
|
||||
Ok(net)
|
||||
}
|
||||
}
|
||||
|
||||
/// The refusal of a sum past exact arithmetic.
|
||||
fn too_large(what: String) -> crate::LedgerError {
|
||||
crate::LedgerError::TooLargeToCompute(what)
|
||||
}
|
||||
|
||||
/// The sum of `values`, or None past exact arithmetic.
|
||||
fn checked_sum(values: impl IntoIterator<Item = Decimal>) -> Option<Decimal> {
|
||||
values
|
||||
.into_iter()
|
||||
.try_fold(Decimal::ZERO, |s, v| s.checked_add(v))
|
||||
}
|
||||
|
||||
/// Split `magnitude` (non-negative) by `weights` (positive, any sum): each
|
||||
/// part rounded toward zero to the cent, the cents left over to the first.
|
||||
/// None when a product or sum is past exact arithmetic.
|
||||
fn split_first(magnitude: Decimal, weights: &[Decimal]) -> Option<Vec<Decimal>> {
|
||||
use rust_decimal::RoundingStrategy::ToZero;
|
||||
let total = checked_sum(weights.iter().copied())?;
|
||||
if weights.is_empty() || total.is_zero() {
|
||||
return Some(vec![Decimal::ZERO; weights.len()]);
|
||||
}
|
||||
let mut parts: Vec<Decimal> = weights
|
||||
.iter()
|
||||
.map(|w| {
|
||||
magnitude
|
||||
.checked_mul(*w)
|
||||
.and_then(|x| x.checked_div(total))
|
||||
.map(|x| x.round_dp_with_strategy(2, ToZero))
|
||||
})
|
||||
.collect::<Option<_>>()?;
|
||||
let left = magnitude.checked_sub(checked_sum(parts.iter().copied())?)?;
|
||||
parts[0] = parts[0].checked_add(left)?;
|
||||
Some(parts)
|
||||
}
|
||||
|
||||
/// Each party's exact part of what `entry` moved (the module's INVARIANTS
|
||||
/// 6–10), in the shares' order. Refused, with why, when the shares can't
|
||||
/// resolve against these lines and members.
|
||||
pub fn resolve(
|
||||
entry: &JournalEntry,
|
||||
shares: &[PartyShare],
|
||||
money: &crate::parties::MoneyAccounts,
|
||||
members: &[PartyMember],
|
||||
) -> Result<Vec<(PartyId, Decimal)>, String> {
|
||||
let target = share_target(entry, money).map_err(|e| e.to_string())?;
|
||||
let past_exact = || too_large(format!("entry {}'s shares", entry.id())).to_string();
|
||||
let magnitude = target.abs();
|
||||
let signed = |v: Decimal| if target.is_sign_negative() { -v } else { v };
|
||||
let Some(first) = shares.first() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let parts: Vec<Decimal> = match first.share {
|
||||
Share::Amount(_) => {
|
||||
let amounts: Vec<Decimal> = shares
|
||||
.iter()
|
||||
.map(|s| match s.share {
|
||||
Share::Amount(a) => a,
|
||||
_ => Decimal::ZERO,
|
||||
})
|
||||
.collect();
|
||||
let sum = checked_sum(amounts.iter().copied()).ok_or_else(past_exact)?;
|
||||
if sum != magnitude {
|
||||
return Err(format!(
|
||||
"the parties' amounts sum to {sum}, but entry {} moved {magnitude}",
|
||||
entry.id()
|
||||
));
|
||||
}
|
||||
amounts
|
||||
}
|
||||
Share::Percent(_) => {
|
||||
let weights: Vec<Decimal> = shares
|
||||
.iter()
|
||||
.map(|s| match s.share {
|
||||
Share::Percent(p) => p,
|
||||
_ => Decimal::ZERO,
|
||||
})
|
||||
.collect();
|
||||
split_first(magnitude, &weights).ok_or_else(past_exact)?
|
||||
}
|
||||
Share::Derived if shares.len() == 1 => vec![magnitude],
|
||||
Share::Derived => {
|
||||
let named: Vec<&PartyMember> = shares
|
||||
.iter()
|
||||
.map(|s| {
|
||||
members.iter().find(|m| m.party == s.party).ok_or_else(|| {
|
||||
format!(
|
||||
"party {} isn't a member, so its share can't be derived: give each party an amount or a percent",
|
||||
s.party
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect::<Result<_, _>>()?;
|
||||
let on = |m: &PartyMember, a: AccountId| a == m.capital || Some(a) == m.distributions;
|
||||
let touches = entry
|
||||
.lines()
|
||||
.iter()
|
||||
.any(|l| named.iter().any(|m| on(m, l.account)));
|
||||
if touches {
|
||||
let parts: Vec<Decimal> = named
|
||||
.iter()
|
||||
.map(|m| {
|
||||
checked_sum(entry.lines().iter().filter(|l| on(m, l.account)).map(|l| {
|
||||
match l.side {
|
||||
Side::Credit => l.amount.amount,
|
||||
Side::Debit => -l.amount.amount,
|
||||
}
|
||||
}))
|
||||
.map(|s| s.abs())
|
||||
})
|
||||
.collect::<Option<_>>()
|
||||
.ok_or_else(past_exact)?;
|
||||
let sum = checked_sum(parts.iter().copied()).ok_or_else(past_exact)?;
|
||||
if sum != magnitude {
|
||||
return Err(format!(
|
||||
"the members' capital lines on entry {} come to {sum}, but it moved {magnitude}: give each member an amount",
|
||||
entry.id()
|
||||
));
|
||||
}
|
||||
parts
|
||||
} else {
|
||||
let weights: Vec<Decimal> = named.iter().map(|m| m.share).collect();
|
||||
if weights.iter().any(|w| *w <= Decimal::ZERO) {
|
||||
return Err(
|
||||
"a member named has no income share: give each party an amount or a percent"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
split_first(magnitude, &weights).ok_or_else(past_exact)?
|
||||
}
|
||||
}
|
||||
};
|
||||
Ok(shares
|
||||
.iter()
|
||||
.zip(parts)
|
||||
.map(|(s, v)| (s.party, signed(v)))
|
||||
.collect())
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
//! Tax categories: where each revenue and expense account lands on the
|
||||
//! business's income tax return, and how much of it is deductible.
|
||||
//!
|
||||
//! The books stay book-basis; this is the bridge a CPA (or tax software)
|
||||
//! walks across. Categories are *stable semantic keys* ("meals",
|
||||
//! "guaranteed_payments") rather than form line numbers, which move from
|
||||
//! year to year; each category says which returns it applies to. A
|
||||
//! deductible percentage (meals 50%, penalties 0%) turns book amounts into
|
||||
//! deductible amounts and surfaces the book-to-tax differences (Schedule
|
||||
//! M-1 items) instead of hiding them.
|
||||
//!
|
||||
//! Scope is deliberate, like fixed assets: no tax depreciation (MACRS,
|
||||
//! Section 179, bonus), no basis computations, no filing. Not tax advice.
|
||||
|
||||
use crate::{AccountType, Ledger, LedgerError};
|
||||
use chrono::NaiveDate;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// The federal income tax return a business files.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TaxForm {
|
||||
/// Sole proprietorship or single-member LLC: Schedule C (Form 1040).
|
||||
ScheduleC,
|
||||
/// Partnership or multi-member LLC: Form 1065.
|
||||
Form1065,
|
||||
/// S corporation: Form 1120-S.
|
||||
Form1120S,
|
||||
/// C corporation: Form 1120.
|
||||
Form1120,
|
||||
}
|
||||
|
||||
impl TaxForm {
|
||||
/// Every form.
|
||||
pub const ALL: [TaxForm; 4] = [
|
||||
TaxForm::ScheduleC,
|
||||
TaxForm::Form1065,
|
||||
TaxForm::Form1120S,
|
||||
TaxForm::Form1120,
|
||||
];
|
||||
|
||||
/// "Form 1065", "Schedule C", ...
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
TaxForm::ScheduleC => "Schedule C",
|
||||
TaxForm::Form1065 => "Form 1065",
|
||||
TaxForm::Form1120S => "Form 1120-S",
|
||||
TaxForm::Form1120 => "Form 1120",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse "schedule_c", "1065", "1120s", "1120" (and their labels).
|
||||
pub fn parse(s: &str) -> Option<TaxForm> {
|
||||
let k: String = s
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric())
|
||||
.collect();
|
||||
match k.as_str() {
|
||||
"schedulec" | "c" => Some(TaxForm::ScheduleC),
|
||||
"1065" | "form1065" => Some(TaxForm::Form1065),
|
||||
"1120s" | "form1120s" => Some(TaxForm::Form1120S),
|
||||
"1120" | "form1120" => Some(TaxForm::Form1120),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a category sits on the return.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TaxKind {
|
||||
/// Income (revenue accounts).
|
||||
Income,
|
||||
/// Cost of goods sold (expense accounts).
|
||||
CostOfGoods,
|
||||
/// A deduction (expense accounts).
|
||||
Deduction,
|
||||
}
|
||||
|
||||
/// One tax category.
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
pub struct TaxCategory {
|
||||
/// Stable key, e.g. `"meals"`.
|
||||
pub key: &'static str,
|
||||
/// Human label, e.g. `"Meals"`.
|
||||
pub label: &'static str,
|
||||
/// Income, COGS, or deduction.
|
||||
pub kind: TaxKind,
|
||||
/// The returns it appears on.
|
||||
pub forms: &'static [TaxForm],
|
||||
/// Percent deductible by default (100 for most; meals 50).
|
||||
pub default_deductible_percent: u32,
|
||||
/// What to know about it, for the agent and the CPA.
|
||||
pub note: &'static str,
|
||||
}
|
||||
|
||||
const ALL: &[TaxForm] = &TaxForm::ALL;
|
||||
const CORPS: &[TaxForm] = &[TaxForm::Form1120S, TaxForm::Form1120];
|
||||
/// Every return but Schedule C.
|
||||
const ENTITIES: &[TaxForm] = &[TaxForm::Form1065, TaxForm::Form1120S, TaxForm::Form1120];
|
||||
|
||||
const fn cat(
|
||||
key: &'static str,
|
||||
label: &'static str,
|
||||
kind: TaxKind,
|
||||
forms: &'static [TaxForm],
|
||||
pct: u32,
|
||||
note: &'static str,
|
||||
) -> TaxCategory {
|
||||
TaxCategory {
|
||||
key,
|
||||
label,
|
||||
kind,
|
||||
forms,
|
||||
default_deductible_percent: pct,
|
||||
note,
|
||||
}
|
||||
}
|
||||
|
||||
use TaxKind::{CostOfGoods, Deduction, Income};
|
||||
|
||||
/// The catalog.
|
||||
pub const CATEGORIES: &[TaxCategory] = &[
|
||||
cat(
|
||||
"gross_receipts",
|
||||
"Gross receipts or sales",
|
||||
Income,
|
||||
ALL,
|
||||
100,
|
||||
"Sales recorded gross (platform and processor fees are a separate deduction).",
|
||||
),
|
||||
cat(
|
||||
"returns_allowances",
|
||||
"Returns and allowances",
|
||||
Income,
|
||||
ALL,
|
||||
100,
|
||||
"Refunds and allowances; reduces gross receipts.",
|
||||
),
|
||||
cat(
|
||||
"interest_income",
|
||||
"Interest income",
|
||||
Income,
|
||||
ALL,
|
||||
100,
|
||||
"Portfolio interest: partnerships and S corps state it separately (Schedule K). Interest earned in the business itself (on customer accounts, say) is ordinary income: use other_income.",
|
||||
),
|
||||
cat("other_income", "Other income", Income, ALL, 100, ""),
|
||||
cat(
|
||||
"cost_of_goods_sold",
|
||||
"Cost of goods sold",
|
||||
CostOfGoods,
|
||||
ALL,
|
||||
100,
|
||||
"Inventory and direct costs of what was sold.",
|
||||
),
|
||||
cat("advertising", "Advertising", Deduction, ALL, 100, ""),
|
||||
cat(
|
||||
"bad_debts",
|
||||
"Bad debts",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"Only debts previously included in income (accrual basis).",
|
||||
),
|
||||
cat(
|
||||
"car_truck",
|
||||
"Car and truck expenses",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"Actual costs or the standard mileage rate, not both.",
|
||||
),
|
||||
cat(
|
||||
"charitable_contributions",
|
||||
"Charitable contributions",
|
||||
Deduction,
|
||||
ENTITIES,
|
||||
100,
|
||||
"Separately stated to owners for partnerships and S corps (Schedule K); limited for C corps. Not on Schedule C (\"Do not include charitable contributions\"): a sole proprietor's gift is personal, an owner draw.",
|
||||
),
|
||||
cat(
|
||||
"commissions_fees",
|
||||
"Commissions and fees",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"Includes app store, marketplace, and payment processor fees.",
|
||||
),
|
||||
cat(
|
||||
"contract_labor",
|
||||
"Contract labor",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"Payments to non-employees; see 1099 reporting.",
|
||||
),
|
||||
cat(
|
||||
"depreciation",
|
||||
"Depreciation (book)",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"Book depreciation; tax depreciation (MACRS, Section 179, bonus) is computed separately by the CPA.",
|
||||
),
|
||||
cat(
|
||||
"employee_benefits",
|
||||
"Employee benefit programs",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"",
|
||||
),
|
||||
cat(
|
||||
"guaranteed_payments",
|
||||
"Guaranteed payments to partners",
|
||||
Deduction,
|
||||
&[TaxForm::Form1065],
|
||||
100,
|
||||
"Payments to partners for services or capital, regardless of profit; reported on each partner's K-1.",
|
||||
),
|
||||
cat(
|
||||
"insurance",
|
||||
"Insurance (other than health)",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"",
|
||||
),
|
||||
cat("interest_expense", "Interest", Deduction, ALL, 100, ""),
|
||||
cat(
|
||||
"legal_professional",
|
||||
"Legal and professional services",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"Attorneys, accountants, consultants.",
|
||||
),
|
||||
cat(
|
||||
"meals",
|
||||
"Meals",
|
||||
Deduction,
|
||||
ALL,
|
||||
50,
|
||||
"Generally 50% deductible; the other half is a book-to-tax difference.",
|
||||
),
|
||||
cat("office_expense", "Office expense", Deduction, ALL, 100, ""),
|
||||
cat(
|
||||
"officer_compensation",
|
||||
"Compensation of officers",
|
||||
Deduction,
|
||||
CORPS,
|
||||
100,
|
||||
"Wages paid to officer-shareholders through payroll.",
|
||||
),
|
||||
cat(
|
||||
"organization_startup",
|
||||
"Organizational and start-up costs",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"Tax: up to $5,000 each deductible in the first year, the rest amortized over 180 months; the CPA computes it.",
|
||||
),
|
||||
cat(
|
||||
"pension_profit_sharing",
|
||||
"Pension and profit-sharing plans",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"",
|
||||
),
|
||||
cat(
|
||||
"rent_equipment",
|
||||
"Rent or lease: vehicles, machinery, equipment",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"",
|
||||
),
|
||||
cat(
|
||||
"rent_property",
|
||||
"Rent or lease: other business property",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"",
|
||||
),
|
||||
cat(
|
||||
"repairs",
|
||||
"Repairs and maintenance",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"",
|
||||
),
|
||||
cat(
|
||||
"salaries_wages",
|
||||
"Salaries and wages",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"Employees (not owners of a partnership, whose pay is guaranteed payments).",
|
||||
),
|
||||
cat("supplies", "Supplies", Deduction, ALL, 100, ""),
|
||||
cat(
|
||||
"taxes_licenses",
|
||||
"Taxes and licenses",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"Payroll taxes, business licenses, state fees; not federal income tax.",
|
||||
),
|
||||
cat(
|
||||
"travel",
|
||||
"Travel",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"Lodging and transportation away from home; meals go under Meals.",
|
||||
),
|
||||
cat("utilities", "Utilities", Deduction, ALL, 100, ""),
|
||||
cat(
|
||||
"other_deductions",
|
||||
"Other deductions",
|
||||
Deduction,
|
||||
ALL,
|
||||
100,
|
||||
"Software, subscriptions, bank fees, dues, and the rest; the CPA itemizes.",
|
||||
),
|
||||
cat(
|
||||
"nondeductible",
|
||||
"Nondeductible expenses",
|
||||
Deduction,
|
||||
ALL,
|
||||
0,
|
||||
"Fines, penalties, political contributions, federal income tax: a book expense with no deduction.",
|
||||
),
|
||||
];
|
||||
|
||||
/// Look up a category by key.
|
||||
pub fn category(key: &str) -> Option<&'static TaxCategory> {
|
||||
CATEGORIES.iter().find(|c| c.key == key)
|
||||
}
|
||||
|
||||
/// Check that `key` can tag an account of `account_type` and, when a form
|
||||
/// is known, that it appears on that form.
|
||||
pub fn check_category(
|
||||
key: &str,
|
||||
account_type: AccountType,
|
||||
form: Option<TaxForm>,
|
||||
) -> Result<&'static TaxCategory, LedgerError> {
|
||||
let c = category(key)
|
||||
.ok_or_else(|| LedgerError::InvalidTaxCategory(format!("unknown tax category {key:?}")))?;
|
||||
let fits = match c.kind {
|
||||
TaxKind::Income => matches!(account_type, AccountType::Revenue | AccountType::Expense),
|
||||
TaxKind::CostOfGoods | TaxKind::Deduction => account_type == AccountType::Expense,
|
||||
};
|
||||
if !fits {
|
||||
return Err(LedgerError::InvalidTaxCategory(format!(
|
||||
"{key} is for {} accounts, not {account_type:?}",
|
||||
if c.kind == TaxKind::Income {
|
||||
"revenue"
|
||||
} else {
|
||||
"expense"
|
||||
}
|
||||
)));
|
||||
}
|
||||
if let Some(f) = form
|
||||
&& !c.forms.contains(&f)
|
||||
{
|
||||
return Err(LedgerError::InvalidTaxCategory(format!(
|
||||
"{key} does not appear on {}",
|
||||
f.label()
|
||||
)));
|
||||
}
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
/// One category's line on the tax summary.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct TaxSummaryLine {
|
||||
/// Category key.
|
||||
pub category: String,
|
||||
/// Category label.
|
||||
pub label: String,
|
||||
/// The accounts that roll into it, `"CODE Name"`.
|
||||
pub accounts: Vec<String>,
|
||||
/// Book amount: revenue for income, expense for COGS and deductions.
|
||||
pub book: Decimal,
|
||||
/// Deductible (or taxable) amount after the deductible percentage.
|
||||
pub tax: Decimal,
|
||||
}
|
||||
|
||||
/// An account with P&L activity and no tax category.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Uncategorized {
|
||||
/// `"CODE Name"`.
|
||||
pub account: String,
|
||||
/// Revenue or Expense.
|
||||
pub account_type: AccountType,
|
||||
/// Book amount (normal side).
|
||||
pub book: Decimal,
|
||||
}
|
||||
|
||||
/// The tax summary for a period: book amounts by tax category, deductible
|
||||
/// amounts, and every account still needing a category.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct TaxSummary {
|
||||
/// First date, inclusive.
|
||||
pub start: NaiveDate,
|
||||
/// Last date, inclusive.
|
||||
pub end: NaiveDate,
|
||||
/// The return, when known.
|
||||
pub form: Option<TaxForm>,
|
||||
/// Income categories.
|
||||
pub income: Vec<TaxSummaryLine>,
|
||||
/// Cost of goods sold.
|
||||
pub cost_of_goods: Vec<TaxSummaryLine>,
|
||||
/// Deductions.
|
||||
pub deductions: Vec<TaxSummaryLine>,
|
||||
/// Items a partnership or S corporation states separately to its owners
|
||||
/// (Schedule K) instead of netting them in ordinary income: portfolio
|
||||
/// interest and charitable contributions (docs/spec/sources/
|
||||
/// irs-tax-lines.md). Not in [`TaxSummary::book_net`] or `tax_net`.
|
||||
#[serde(default)]
|
||||
pub separately_stated: Vec<TaxSummaryLine>,
|
||||
/// P&L accounts with activity and no category.
|
||||
pub uncategorized: Vec<Uncategorized>,
|
||||
}
|
||||
|
||||
impl TaxSummary {
|
||||
/// Book net income of the categorized accounts.
|
||||
pub fn book_net(&self) -> Decimal {
|
||||
sum(&self.income, |l| l.book)
|
||||
- sum(&self.cost_of_goods, |l| l.book)
|
||||
- sum(&self.deductions, |l| l.book)
|
||||
}
|
||||
|
||||
/// Income less deductible amounts, before the CPA's other adjustments.
|
||||
pub fn tax_net(&self) -> Decimal {
|
||||
sum(&self.income, |l| l.tax)
|
||||
- sum(&self.cost_of_goods, |l| l.tax)
|
||||
- sum(&self.deductions, |l| l.tax)
|
||||
}
|
||||
}
|
||||
|
||||
fn sum(lines: &[TaxSummaryLine], f: fn(&TaxSummaryLine) -> Decimal) -> Decimal {
|
||||
lines.iter().map(f).sum()
|
||||
}
|
||||
|
||||
/// Build the tax summary for `[start, end]`, closing entries excluded (like
|
||||
/// the P&L). Deductible amounts round to the cent, half away from zero.
|
||||
pub fn tax_summary(
|
||||
ledger: &Ledger,
|
||||
start: NaiveDate,
|
||||
end: NaiveDate,
|
||||
form: Option<TaxForm>,
|
||||
) -> Result<TaxSummary, LedgerError> {
|
||||
let pl = crate::reports::profit_and_loss_for_period(ledger, start, end, None)?;
|
||||
let mut by_cat: BTreeMap<&'static str, TaxSummaryLine> = BTreeMap::new();
|
||||
let mut uncategorized = Vec::new();
|
||||
for line in pl.revenue.iter().chain(pl.expenses.iter()) {
|
||||
let account = ledger.chart().find_by_code(&line.code).ok_or_else(|| {
|
||||
LedgerError::InvalidTaxCategory(format!("unknown account {}", line.code))
|
||||
})?;
|
||||
let name = format!("{} {}", account.code, account.name);
|
||||
let Some(c) = account.tax_category.as_deref().and_then(category) else {
|
||||
uncategorized.push(Uncategorized {
|
||||
account: name,
|
||||
account_type: account.account_type,
|
||||
book: line.amount,
|
||||
});
|
||||
continue;
|
||||
};
|
||||
// Income from an expense-type account (e.g. a contra) counts with
|
||||
// its sign flipped, so returns reduce receipts.
|
||||
let book = if c.kind == TaxKind::Income && account.account_type == AccountType::Expense {
|
||||
-line.amount
|
||||
} else {
|
||||
line.amount
|
||||
};
|
||||
let pct = account
|
||||
.deductible_percent
|
||||
.unwrap_or_else(|| Decimal::from(c.default_deductible_percent));
|
||||
let tax = (book * pct / Decimal::from(100))
|
||||
.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero);
|
||||
let entry = by_cat.entry(c.key).or_insert_with(|| TaxSummaryLine {
|
||||
category: c.key.to_string(),
|
||||
label: c.label.to_string(),
|
||||
accounts: vec![],
|
||||
book: Decimal::ZERO,
|
||||
tax: Decimal::ZERO,
|
||||
});
|
||||
entry.accounts.push(name);
|
||||
entry.book += book;
|
||||
entry.tax += tax;
|
||||
}
|
||||
let mut out = TaxSummary {
|
||||
start,
|
||||
end,
|
||||
form,
|
||||
income: vec![],
|
||||
cost_of_goods: vec![],
|
||||
deductions: vec![],
|
||||
separately_stated: vec![],
|
||||
uncategorized,
|
||||
};
|
||||
// Catalog order, so the summary reads like the return.
|
||||
for c in CATEGORIES {
|
||||
if let Some(line) = by_cat.remove(c.key) {
|
||||
if separately_stated(c.key, form) {
|
||||
out.separately_stated.push(line);
|
||||
continue;
|
||||
}
|
||||
match c.kind {
|
||||
TaxKind::Income => out.income.push(line),
|
||||
TaxKind::CostOfGoods => out.cost_of_goods.push(line),
|
||||
TaxKind::Deduction => out.deductions.push(line),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Whether a partnership or S corporation states this category separately
|
||||
/// to its owners (Schedule K) rather than in ordinary income. Form 1065
|
||||
/// and 1120-S instructions: Schedule K takes "only taxable portfolio
|
||||
/// interest", and "charitable contributions" are among the "items ... must
|
||||
/// state separately".
|
||||
pub fn separately_stated(key: &str, form: Option<TaxForm>) -> bool {
|
||||
matches!(form, Some(TaxForm::Form1065 | TaxForm::Form1120S))
|
||||
&& matches!(key, "interest_income" | "charitable_contributions")
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//! Time series. Numbers computed by hand.
|
||||
//!
|
||||
//! Cash 1000 and card 2100. Jan 5: owner puts in 5,000. Jan 20: 300 of
|
||||
//! software on the card. Feb 3: 1,200 of sales into cash. Feb 10: pay the
|
||||
//! card 300. Mar 15: 800 rent from cash.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::analysis::{
|
||||
Interval, accounts_of_type, balance_history, balance_series, flows_by_interval, interval_start,
|
||||
pnl_by_interval,
|
||||
};
|
||||
use ledger_core::{AccountType, Currency, Ledger, Money, Side};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
fn d(m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, m, day).unwrap()
|
||||
}
|
||||
|
||||
fn dec(n: i64) -> Decimal {
|
||||
Decimal::from(n)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn balances_pnl_and_flows_over_time() {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let mut a = |c: &str, t| l.add_account(c, c, t, None, false).unwrap();
|
||||
let cash = a("1000", AccountType::Asset);
|
||||
let card = a("2100", AccountType::Liability);
|
||||
let equity = a("3000", AccountType::Equity);
|
||||
let sales = a("4000", AccountType::Revenue);
|
||||
let software = a("6300", AccountType::Expense);
|
||||
let rent = a("6600", AccountType::Expense);
|
||||
for (date, dr, cr, amt) in [
|
||||
(d(1, 5), cash, equity, 5000),
|
||||
(d(1, 20), software, card, 300),
|
||||
(d(2, 3), cash, sales, 1200),
|
||||
(d(2, 10), card, cash, 300),
|
||||
(d(3, 15), rent, cash, 800),
|
||||
] {
|
||||
let e = l.create_entry(date, "x");
|
||||
let m = Money::new(dec(amt), Currency::USD);
|
||||
l.add_line(e, dr, Side::Debit, m).unwrap();
|
||||
l.add_line(e, cr, Side::Credit, m).unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
balance_series(&l, &[cash], &[d(1, 1), d(1, 31), d(2, 28), d(3, 31)]),
|
||||
[dec(0), dec(5000), dec(5900), dec(5100)]
|
||||
);
|
||||
// The card is a liability: owed 300, then paid off.
|
||||
assert_eq!(
|
||||
balance_series(&l, &[card], &[d(1, 31), d(2, 28)]),
|
||||
[dec(300), dec(0)]
|
||||
);
|
||||
assert_eq!(
|
||||
balance_history(&l, &[cash]),
|
||||
[
|
||||
(d(1, 5), dec(5000)),
|
||||
(d(2, 3), dec(6200)),
|
||||
(d(2, 10), dec(5900)),
|
||||
(d(3, 15), dec(5100))
|
||||
]
|
||||
);
|
||||
let pnl = pnl_by_interval(&l, d(1, 1), d(3, 31), Interval::Month).unwrap();
|
||||
let nets: Vec<Decimal> = pnl.iter().map(|p| p.net()).collect();
|
||||
assert_eq!(nets, [dec(-300), dec(1200), dec(-800)]);
|
||||
let flows = flows_by_interval(&l, &[cash], d(1, 1), d(3, 31), Interval::Month);
|
||||
let io: Vec<(Decimal, Decimal)> = flows.iter().map(|f| (f.2, f.3)).collect();
|
||||
assert_eq!(
|
||||
io,
|
||||
[
|
||||
(dec(5000), dec(0)),
|
||||
(dec(1200), dec(300)),
|
||||
(dec(0), dec(800))
|
||||
]
|
||||
);
|
||||
// Cash and card together: a card charge is money out when it's made,
|
||||
// and paying the card moves nothing out of the pair.
|
||||
let both = flows_by_interval(&l, &[cash, card], d(1, 1), d(2, 28), Interval::Month);
|
||||
let io: Vec<(Decimal, Decimal)> = both.iter().map(|f| (f.2, f.3)).collect();
|
||||
assert_eq!(io, [(dec(5000), dec(300)), (dec(1200), dec(0))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intervals_start_on_calendar_boundaries() {
|
||||
for (day, want) in [
|
||||
(d(2, 14), d(1, 1)),
|
||||
(d(5, 31), d(4, 1)),
|
||||
(d(11, 30), d(10, 1)),
|
||||
] {
|
||||
assert_eq!(interval_start(day, Interval::Quarter), want, "{day}");
|
||||
assert_eq!(interval_start(day, Interval::Year), d(1, 1), "{day}");
|
||||
}
|
||||
assert_eq!(interval_start(d(11, 30), Interval::Month), d(11, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accounts_of_type_picks_from_the_chart_in_order() {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let mut a = |c: &str, t| l.add_account(c, c, t, None, false).unwrap();
|
||||
let cash = a("1000", AccountType::Asset);
|
||||
let card = a("2100", AccountType::Liability);
|
||||
let rent = a("6600", AccountType::Expense);
|
||||
let bank = a("1010", AccountType::Asset);
|
||||
let assets = accounts_of_type(&l, &[AccountType::Asset]);
|
||||
assert_eq!(assets.len(), 2);
|
||||
assert!(assets.contains(&cash) && assets.contains(&bank));
|
||||
let mut cash_like = accounts_of_type(&l, &[AccountType::Asset, AccountType::Liability]);
|
||||
cash_like.sort();
|
||||
let mut want = vec![cash, card, bank];
|
||||
want.sort();
|
||||
assert_eq!(cash_like, want);
|
||||
assert!(!cash_like.contains(&rent));
|
||||
assert!(accounts_of_type(&l, &[AccountType::Revenue]).is_empty());
|
||||
}
|
||||
@@ -231,7 +231,37 @@ fn registration_rejects_date_overflow() {
|
||||
expense_account: expense,
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ledger_core::LedgerError::AssetDateOverflow));
|
||||
assert!(matches!(
|
||||
err,
|
||||
ledger_core::LedgerError::DateOutOfRange(d) if d == NaiveDate::MAX
|
||||
));
|
||||
// Inside the calendar the books keep (ledger_core::calendar), but a
|
||||
// life that runs past its last day: refused too, whatever chrono
|
||||
// could still reach (P0 review round 3, S1).
|
||||
let new = |date: NaiveDate, months: u32| NewAsset {
|
||||
description: "Late machine".to_string(),
|
||||
purchase_date: date,
|
||||
cost: usd("1000"),
|
||||
salvage_value: usd("0"),
|
||||
useful_life_months: months,
|
||||
cost_account: cost,
|
||||
accum_depr_account: accum,
|
||||
expense_account: expense,
|
||||
};
|
||||
let d = |y, m, day| NaiveDate::from_ymd_opt(y, m, day).unwrap();
|
||||
for (date, months) in [
|
||||
(d(9999, 6, 1), 12),
|
||||
(d(9999, 12, 31), 1),
|
||||
(d(2026, 1, 1), u32::MAX),
|
||||
] {
|
||||
assert!(matches!(
|
||||
ledger.register_asset(new(date, months)).unwrap_err(),
|
||||
ledger_core::LedgerError::AssetDateOverflow
|
||||
));
|
||||
}
|
||||
// A life that ends on the calendar's last day is kept.
|
||||
ledger.register_asset(new(d(9999, 11, 30), 1)).unwrap();
|
||||
ledger.register_asset(new(d(1, 1, 1), 12)).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -261,18 +291,39 @@ fn schedule_through_purchase_date_covers_one_day() {
|
||||
}
|
||||
|
||||
#[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.
|
||||
fn van_schedule_is_the_hand_computed_one() {
|
||||
// Rank 1, computed by hand (rules.md B-15), replacing an unreviewed
|
||||
// golden file (2026-10-13; every row of the old file was checked
|
||||
// against this computation first, and matched).
|
||||
// $60,000 over 60 months is $1,000 a month. Bought 2026-01-15: January
|
||||
// holds 17 of 31 days = 548.39; February 2026 through December 2030 are
|
||||
// 59 full months; life ends 2031-01-15, so January 2031 holds 14 of 31
|
||||
// days = 451.61. 548.39 + 59,000 + 451.61 = 60,000.00 exactly.
|
||||
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);
|
||||
assert_eq!(sched.len(), 61);
|
||||
let mut accumulated = Decimal::ZERO;
|
||||
for (k, line) in sched.iter().enumerate() {
|
||||
let (year, month) = (2026 + (k as i32) / 12, (k as u32) % 12 + 1);
|
||||
let amount = match k {
|
||||
0 => Decimal::new(54839, 2),
|
||||
60 => Decimal::new(45161, 2),
|
||||
_ => Decimal::new(100000, 2),
|
||||
};
|
||||
accumulated += amount;
|
||||
assert_eq!(line.period, format!("{year}-{month:02}"));
|
||||
assert_eq!(line.amount, amount, "{}", line.period);
|
||||
assert_eq!(line.accumulated, accumulated, "{}", line.period);
|
||||
assert_eq!(
|
||||
line.book_value,
|
||||
Decimal::from(60_000) - accumulated,
|
||||
"{}",
|
||||
line.period
|
||||
);
|
||||
}
|
||||
assert_eq!(accumulated, Decimal::from(60_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -379,3 +430,84 @@ fn one_month_useful_life_is_valid() {
|
||||
let total: Decimal = sched.iter().map(|l| l.amount).sum();
|
||||
assert_eq!(total, Decimal::new(30000, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disposal_on_the_purchase_date_is_allowed() {
|
||||
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();
|
||||
// Held one day: 1,000/month × 1/31 = 32.26 of depreciation.
|
||||
let d = disposal_amounts(&asset, date(2026, 1, 15), usd("60000")).unwrap();
|
||||
assert_eq!(d.accumulated, Decimal::new(3226, 2));
|
||||
assert_eq!(d.book_value, Decimal::new(5_996_774, 2));
|
||||
assert_eq!(d.gain_loss, Decimal::new(3226, 2));
|
||||
// The day before purchase is not a disposal.
|
||||
assert!(matches!(
|
||||
disposal_amounts(&asset, date(2026, 1, 14), usd("60000")),
|
||||
Err(ledger_core::LedgerError::DisposalBeforePurchase(_))
|
||||
));
|
||||
}
|
||||
|
||||
/// An asset links to the posted entry that acquired it, if that entry
|
||||
/// debits its cost account and isn't a reversal or reversed; a correction
|
||||
/// (reverse, repost) links the replacement. Refusals change nothing.
|
||||
#[test]
|
||||
fn acquisition_links_only_to_a_live_entry_debiting_the_cost_account() {
|
||||
use ledger_core::{LedgerError, Side};
|
||||
let (mut ledger, cost, accum, expense) = test_ledger();
|
||||
let capital = ledger
|
||||
.add_account("3000", "Capital", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let id = van_asset(&mut ledger, 1650, cost, accum, expense);
|
||||
let post = |l: &mut Ledger, dr: u64, cr: u64| {
|
||||
let e = l.create_entry(date(2026, 1, 15), "contribution");
|
||||
l.add_line(e, dr, Side::Debit, usd("1650")).unwrap();
|
||||
l.add_line(e, cr, Side::Credit, usd("1650")).unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
e
|
||||
};
|
||||
let original = post(&mut ledger, cost, capital);
|
||||
let wrong = post(&mut ledger, expense, capital);
|
||||
let draft = ledger.create_entry(date(2026, 1, 15), "draft");
|
||||
let refused = |l: &mut Ledger, entry: u64| {
|
||||
let before = l.clone();
|
||||
let err = l.set_asset_acquisition(id, entry).unwrap_err();
|
||||
assert_eq!(*l, before);
|
||||
err
|
||||
};
|
||||
assert!(matches!(
|
||||
refused(&mut ledger, wrong),
|
||||
LedgerError::AcquisitionEntryMismatch { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
refused(&mut ledger, draft),
|
||||
LedgerError::AcquisitionEntryMismatch { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
refused(&mut ledger, 999),
|
||||
LedgerError::UnknownEntry(999)
|
||||
));
|
||||
assert!(matches!(
|
||||
ledger.set_asset_acquisition(99, original),
|
||||
Err(LedgerError::UnknownAsset(99))
|
||||
));
|
||||
ledger.set_asset_acquisition(id, original).unwrap();
|
||||
assert_eq!(ledger.get_asset(id).unwrap().acquired_by, Some(original));
|
||||
|
||||
// Corrected: reversed, reposted. Neither the original nor its
|
||||
// reversal can be the link; the replacement can.
|
||||
let reversal = ledger
|
||||
.reverse_entry(original, date(2026, 1, 16), "joint, not one member's")
|
||||
.unwrap();
|
||||
let replacement = post(&mut ledger, cost, capital);
|
||||
assert!(matches!(
|
||||
refused(&mut ledger, original),
|
||||
LedgerError::AcquisitionEntryMismatch { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
refused(&mut ledger, reversal),
|
||||
LedgerError::AcquisitionEntryMismatch { .. }
|
||||
));
|
||||
ledger.set_asset_acquisition(id, replacement).unwrap();
|
||||
assert_eq!(ledger.get_asset(id).unwrap().acquired_by, Some(replacement));
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
//! Conventions, approvals and templates in the engine, value by value:
|
||||
//! slots, summaries, template bounds, the approval fingerprint (journaled,
|
||||
//! so its meaning is fixed forever), which entries a rule covers, and
|
||||
//! when a convention is a revision at all.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: every bound and every rule says exactly what it means; no more,
|
||||
//! no less.
|
||||
//! SOURCE: plan/served.md P1; P1 mutation run (2026-10-17).
|
||||
//! ```
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::conventions::{self, Convention, ConventionKind, NewConvention};
|
||||
use ledger_core::settings::{Change, Source};
|
||||
use ledger_core::{AccountType, Currency, EntryId, Ledger, LedgerError, Money, Side};
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeMap;
|
||||
use std::str::FromStr;
|
||||
|
||||
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
||||
}
|
||||
|
||||
fn dec(s: &str) -> Decimal {
|
||||
Decimal::from_str(s).unwrap()
|
||||
}
|
||||
|
||||
fn src() -> Source {
|
||||
Source {
|
||||
decided_by: "A".into(),
|
||||
decided_on: d(2026, 10, 17),
|
||||
citation: None,
|
||||
note: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn convention(id: &str, kind: ConventionKind, structured: Option<Value>) -> NewConvention {
|
||||
NewConvention {
|
||||
id: id.into(),
|
||||
kind,
|
||||
title: "Title".into(),
|
||||
body_md: "Body.".into(),
|
||||
structured,
|
||||
source: src(),
|
||||
}
|
||||
}
|
||||
|
||||
fn template(lines: Vec<Value>) -> NewConvention {
|
||||
convention(
|
||||
"t",
|
||||
ConventionKind::PostingTemplate,
|
||||
Some(json!({ "lines": lines })),
|
||||
)
|
||||
}
|
||||
|
||||
fn line(account: &str, side: &str, share: &str) -> Value {
|
||||
json!({"account": account, "side": side, "share": share})
|
||||
}
|
||||
|
||||
fn refusal(c: &NewConvention) -> String {
|
||||
conventions::check_new(c).expect_err("refused")
|
||||
}
|
||||
|
||||
/// A slot is a name in braces: lowercase letters, digits and `_`, 1 to 32
|
||||
/// of them.
|
||||
#[test]
|
||||
fn a_slot_is_a_short_lowercase_name_in_braces() {
|
||||
for (account, want) in [
|
||||
("{expense}", Some("expense")),
|
||||
("{a_1}", Some("a_1")),
|
||||
("{_}", Some("_")),
|
||||
("{7}", Some("7")),
|
||||
(
|
||||
"{abcdefghijklmnopqrstuvwxyz012345}",
|
||||
Some("abcdefghijklmnopqrstuvwxyz012345"),
|
||||
),
|
||||
("{abcdefghijklmnopqrstuvwxyz0123456}", None),
|
||||
("{}", None),
|
||||
("{Expense}", None),
|
||||
("{a-b}", None),
|
||||
("{a b}", None),
|
||||
("expense", None),
|
||||
("{expense", None),
|
||||
("6000", None),
|
||||
] {
|
||||
assert_eq!(conventions::slot(account), want, "{account}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A summary is the first line with text, trimmed, at most 160
|
||||
/// characters, with an ellipsis only when it was cut.
|
||||
#[test]
|
||||
fn a_summary_is_the_first_line_with_text() {
|
||||
let summary = |body: &str| {
|
||||
Convention {
|
||||
id: "c".into(),
|
||||
kind: ConventionKind::Note,
|
||||
title: "T".into(),
|
||||
body_md: body.into(),
|
||||
structured: None,
|
||||
source: src(),
|
||||
seq: 1,
|
||||
revision: 1,
|
||||
retired: None,
|
||||
}
|
||||
.summary()
|
||||
};
|
||||
assert_eq!(summary("\n \n First line \nsecond"), "First line");
|
||||
assert_eq!(summary(""), "");
|
||||
assert_eq!(summary("\n \n"), "");
|
||||
let exactly = "é".repeat(160);
|
||||
assert_eq!(summary(&exactly), exactly, "160 characters: whole");
|
||||
let over = "é".repeat(161);
|
||||
assert_eq!(summary(&over), format!("{exactly}…"), "161: cut");
|
||||
assert_eq!(summary(&format!(" {exactly} ")), exactly, "trimmed first");
|
||||
}
|
||||
|
||||
/// A posting template has 2 to 50 lines, each share in (0, 1], each
|
||||
/// side summing to exactly 1; a refusal names the line.
|
||||
#[test]
|
||||
fn template_bounds_are_exact() {
|
||||
// 50 lines: one debit, 49 credits summing to 1.
|
||||
let mut fifty = vec![line("6000", "debit", "1"), line("1000", "credit", "0.04")];
|
||||
fifty.extend((0..48).map(|_| line("1000", "credit", "0.02")));
|
||||
assert_eq!(fifty.len(), 50);
|
||||
conventions::check_new(&template(fifty)).expect("50 lines");
|
||||
let mut fifty_one = vec![line("6000", "debit", "1")];
|
||||
fifty_one.extend((0..50).map(|_| line("1000", "credit", "0.02")));
|
||||
assert!(refusal(&template(fifty_one)).contains("2 to 50 lines"));
|
||||
assert!(refusal(&template(vec![])).contains("2 to 50 lines"));
|
||||
// One line can't balance: refused by its count, not its shares.
|
||||
assert!(refusal(&template(vec![line("6000", "debit", "1")])).contains("2 to 50 lines"));
|
||||
conventions::check_new(&template(vec![
|
||||
line("6000", "debit", "1"),
|
||||
line("1000", "credit", "1"),
|
||||
]))
|
||||
.expect("2 lines");
|
||||
// A zero share is no share.
|
||||
let e = refusal(&template(vec![
|
||||
line("6000", "debit", "1"),
|
||||
line("6000", "debit", "0"),
|
||||
line("1000", "credit", "1"),
|
||||
]));
|
||||
assert!(e.starts_with("template line 2: share \"0\""), "{e}");
|
||||
// Over 1, or below zero, even when the side still sums to 1.
|
||||
let e = refusal(&template(vec![
|
||||
line("6000", "debit", "1.5"),
|
||||
line("6000", "debit", "-0.5"),
|
||||
line("1000", "credit", "1"),
|
||||
]));
|
||||
assert!(e.starts_with("template line 1: share \"1.5\""), "{e}");
|
||||
let e = refusal(&template(vec![
|
||||
line("6000", "debit", "1"),
|
||||
line("1000", "credit", "1.5"),
|
||||
line("1000", "credit", "-0.5"),
|
||||
]));
|
||||
assert!(e.starts_with("template line 2: share \"1.5\""), "{e}");
|
||||
let e = refusal(&template(vec![
|
||||
line("6000", "debit", "1"),
|
||||
line("1000", "credit", "0.5"),
|
||||
]));
|
||||
assert!(e.contains("sum to exactly 1"), "{e}");
|
||||
}
|
||||
|
||||
/// FNV-1a, 64 bits, written out here independently of the engine.
|
||||
fn fnv1a(text: &str) -> String {
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for b in text.bytes() {
|
||||
h ^= u64::from(b);
|
||||
h = h.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
format!("{h:016x}")
|
||||
}
|
||||
|
||||
/// The approval fingerprint is journaled with every approval and
|
||||
/// recomputed at post: its text and hash are fixed forever.
|
||||
#[test]
|
||||
fn the_fingerprint_is_fixed_forever() {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let exp = l
|
||||
.add_account("6000", "Software", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let bank = l
|
||||
.add_account("1000", "Bank", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let e = l.create_entry(d(2026, 10, 2), "Laptop");
|
||||
let m = Money::new(dec("900.50"), Currency::USD);
|
||||
l.add_line(e, exp, Side::Debit, m).unwrap();
|
||||
l.add_line(e, bank, Side::Credit, m).unwrap();
|
||||
let entry = l.get_entry(e).unwrap();
|
||||
let text = format!(
|
||||
"2026-10-02\u{1f}Laptop\
|
||||
\u{1e}{exp}\u{1f}D\u{1f}900.5\u{1f}USD\u{1f}\u{1f}\u{1f}\
|
||||
\u{1e}{bank}\u{1f}C\u{1f}900.5\u{1f}USD\u{1f}\u{1f}\u{1f}"
|
||||
);
|
||||
let plain = conventions::fingerprint(entry, &[], None);
|
||||
assert_eq!(plain, fnv1a(&text));
|
||||
assert_eq!(fnv1a(""), "cbf29ce484222325", "FNV-1a's offset basis");
|
||||
assert_eq!(fnv1a("a"), "af63dc4c8601ec8c", "FNV-1a's published value");
|
||||
let none = conventions::fingerprint(entry, &[], Some("bank fee"));
|
||||
let with_none = format!(
|
||||
"2026-10-02\u{1f}Laptop\u{1d}none\u{1f}bank fee\
|
||||
\u{1e}{exp}\u{1f}D\u{1f}900.5\u{1f}USD\u{1f}\u{1f}\u{1f}\
|
||||
\u{1e}{bank}\u{1f}C\u{1f}900.5\u{1f}USD\u{1f}\u{1f}\u{1f}"
|
||||
);
|
||||
assert_eq!(none, fnv1a(&with_none));
|
||||
assert_ne!(none, plain);
|
||||
}
|
||||
|
||||
/// Books enforcing approval rules, with an expense and a bank account.
|
||||
fn enforcing() -> (Ledger, ledger_core::AccountId, ledger_core::AccountId) {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let exp = l
|
||||
.add_account("6000", "Software", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let bank = l
|
||||
.add_account("1000", "Bank", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let on = [Change {
|
||||
key: "policy.approval_rules".into(),
|
||||
value: Some(json!("enforce")),
|
||||
}];
|
||||
let made = l.check_settings(&on, &src()).unwrap();
|
||||
l.apply_settings(&made, &src(), 1).unwrap();
|
||||
(l, exp, bank)
|
||||
}
|
||||
|
||||
fn set_convention(l: &mut Ledger, c: &NewConvention, seq: u64) {
|
||||
let c = l.check_convention(c).unwrap();
|
||||
l.apply_convention(c, seq).unwrap();
|
||||
}
|
||||
|
||||
fn draft(l: &mut Ledger, lines: &[(ledger_core::AccountId, Side, &str)]) -> EntryId {
|
||||
let e = l.create_entry(d(2026, 10, 2), "Spend");
|
||||
for (a, side, amount) in lines {
|
||||
l.add_line(e, *a, *side, Money::new(dec(amount), Currency::USD))
|
||||
.unwrap();
|
||||
}
|
||||
e
|
||||
}
|
||||
|
||||
/// A rule's amount is "over": an entry whose debits are exactly it isn't
|
||||
/// covered; one a cent over is. It counts debits, not credits, even on a
|
||||
/// draft that doesn't balance yet.
|
||||
#[test]
|
||||
fn a_rule_covers_debits_strictly_over_its_amount() {
|
||||
let (mut l, exp, bank) = enforcing();
|
||||
set_convention(
|
||||
&mut l,
|
||||
&convention(
|
||||
"big",
|
||||
ConventionKind::ApprovalRule,
|
||||
Some(json!({"approver": "B", "amount_over": "500"})),
|
||||
),
|
||||
2,
|
||||
);
|
||||
let want = vec![("big".to_string(), "B".to_string())];
|
||||
let at = draft(
|
||||
&mut l,
|
||||
&[(exp, Side::Debit, "500"), (bank, Side::Credit, "500")],
|
||||
);
|
||||
assert!(l.approvals_missing(at).unwrap().is_empty(), "exactly 500");
|
||||
let over = draft(
|
||||
&mut l,
|
||||
&[(exp, Side::Debit, "500.01"), (bank, Side::Credit, "500.01")],
|
||||
);
|
||||
assert_eq!(l.approvals_missing(over).unwrap(), want);
|
||||
let debits = draft(&mut l, &[(exp, Side::Debit, "600")]);
|
||||
assert_eq!(l.approvals_missing(debits).unwrap(), want, "debits count");
|
||||
let credits = draft(&mut l, &[(bank, Side::Credit, "600")]);
|
||||
assert!(
|
||||
l.approvals_missing(credits).unwrap().is_empty(),
|
||||
"credits don't"
|
||||
);
|
||||
}
|
||||
|
||||
/// What check_settings returns is what will be journaled.
|
||||
#[test]
|
||||
fn check_settings_returns_the_changes_it_will_journal() {
|
||||
let l = Ledger::new(Currency::USD);
|
||||
let name = Change {
|
||||
key: "company.name".into(),
|
||||
value: Some(json!(" Acme LLC ")),
|
||||
};
|
||||
let made = l.check_settings(&[name], &src()).unwrap();
|
||||
assert_eq!(
|
||||
made,
|
||||
[Change {
|
||||
key: "company.name".into(),
|
||||
value: Some(json!("Acme LLC")),
|
||||
}]
|
||||
);
|
||||
assert!(!l.settings().is_journaled(), "checking changes nothing");
|
||||
}
|
||||
|
||||
/// A convention set again unchanged is refused; a change to its title,
|
||||
/// its body or its structure alone is a revision.
|
||||
#[test]
|
||||
fn any_one_change_is_a_revision() {
|
||||
let (mut l, _, _) = enforcing();
|
||||
let base = convention(
|
||||
"big",
|
||||
ConventionKind::ApprovalRule,
|
||||
Some(json!({"approver": "B", "amount_over": "500"})),
|
||||
);
|
||||
set_convention(&mut l, &base, 2);
|
||||
let e = l.check_convention(&base).unwrap_err();
|
||||
assert!(
|
||||
matches!(&e, LedgerError::InvalidConvention(m) if m.contains("already says exactly that")),
|
||||
"{e}"
|
||||
);
|
||||
let title = NewConvention {
|
||||
title: "New title".into(),
|
||||
..base.clone()
|
||||
};
|
||||
let body = NewConvention {
|
||||
body_md: "New body.".into(),
|
||||
..base.clone()
|
||||
};
|
||||
let structured = NewConvention {
|
||||
structured: Some(json!({"approver": "B", "amount_over": "600"})),
|
||||
..base.clone()
|
||||
};
|
||||
for c in [title, body, structured] {
|
||||
assert!(l.check_convention(&c).is_ok(), "{c:?}");
|
||||
}
|
||||
let revised = NewConvention {
|
||||
title: "New title".into(),
|
||||
..base.clone()
|
||||
};
|
||||
set_convention(&mut l, &revised, 3);
|
||||
assert_eq!(l.conventions().convention("big").unwrap().revision, 2);
|
||||
}
|
||||
|
||||
/// A template drafts any amount up to a line's most, and refuses above
|
||||
/// it as out of range.
|
||||
#[test]
|
||||
fn a_template_drafts_up_to_a_lines_most() {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
for (code, t) in [("6000", AccountType::Expense), ("1000", AccountType::Asset)] {
|
||||
l.add_account(code, code, t, None, false).unwrap();
|
||||
}
|
||||
set_convention(
|
||||
&mut l,
|
||||
&template(vec![
|
||||
line("6000", "debit", "1"),
|
||||
line("1000", "credit", "1"),
|
||||
]),
|
||||
1,
|
||||
);
|
||||
let max = ledger_core::kernel::MAX_LINE_AMOUNT;
|
||||
let (_, lines) = l.template_lines("t", max, &BTreeMap::new()).unwrap();
|
||||
assert!(lines.iter().all(|(_, _, m)| m.amount == max));
|
||||
assert!(matches!(
|
||||
l.template_lines("t", max + dec("0.01"), &BTreeMap::new()),
|
||||
Err(LedgerError::AmountOutOfRange)
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,663 @@
|
||||
//! 1099 tracking. Numbers computed by hand.
|
||||
//!
|
||||
//! 2026 payments:
|
||||
//! - Dana (1099-NEC, W-9): bill 1,500 paid by check + 700 direct = 2,200,
|
||||
//! over the 2,000 threshold. A 300 payment on 2025-12-30 counts in 2025.
|
||||
//! - Ace Rentals (1099-MISC): 1,200 twice, one reversed = 1,200, under.
|
||||
//! - Sam (1099-NEC): 5,000 by credit card = 0 cash: the card processor's
|
||||
//! 1099-K covers it.
|
||||
//! - Bigco (no 1099): 9,000, listed but never reportable.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::arap::DocumentLine;
|
||||
use ledger_core::form1099::default_threshold;
|
||||
use ledger_core::{
|
||||
AccountType, Currency, Form1099, Ledger, LedgerError, Money, Side, vendor_1099_report,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
use std::str::FromStr;
|
||||
|
||||
fn dec(s: &str) -> Decimal {
|
||||
Decimal::from_str(s).unwrap()
|
||||
}
|
||||
|
||||
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
||||
}
|
||||
|
||||
fn books() -> Ledger {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let bank = l
|
||||
.add_account("1000", "Bank", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let card = l
|
||||
.add_account("2100", "Credit Card", AccountType::Liability, None, false)
|
||||
.unwrap();
|
||||
let contract = l
|
||||
.add_account("6000", "Contract Labor", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let rent = l
|
||||
.add_account("6600", "Rent", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let dana = l.add_vendor("Dana").unwrap();
|
||||
let ace = l.add_vendor("Ace Rentals").unwrap();
|
||||
let sam = l.add_vendor("Sam").unwrap();
|
||||
let bigco = l.add_vendor("Bigco").unwrap();
|
||||
l.set_vendor_1099(dana, Some(Form1099::Nec), true).unwrap();
|
||||
l.set_vendor_1099(ace, Some(Form1099::Misc), false).unwrap();
|
||||
l.set_vendor_1099(sam, Some(Form1099::Nec), true).unwrap();
|
||||
let pay = |l: &mut Ledger, date, vendor, dr, cr, amt: &str| {
|
||||
let e = l.create_entry(date, "payment");
|
||||
let m = Money::new(dec(amt), Currency::USD);
|
||||
l.add_line(e, dr, Side::Debit, m).unwrap();
|
||||
l.add_line(e, cr, Side::Credit, m).unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
l.set_entry_vendor(e, Some(vendor)).unwrap();
|
||||
e
|
||||
};
|
||||
let bill = l
|
||||
.create_bill(
|
||||
dana,
|
||||
d(2026, 3, 1),
|
||||
d(2026, 3, 31),
|
||||
vec![DocumentLine {
|
||||
description: "Design work".into(),
|
||||
amount: dec("1500"),
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
l.pay_bill(bill, d(2026, 3, 20), dec("1500"), bank).unwrap();
|
||||
pay(&mut l, d(2026, 6, 1), dana, contract, bank, "700");
|
||||
pay(&mut l, d(2025, 12, 30), dana, contract, bank, "300");
|
||||
pay(&mut l, d(2026, 1, 1), ace, rent, bank, "1200");
|
||||
let wrong = pay(&mut l, d(2026, 2, 1), ace, rent, bank, "1200");
|
||||
l.reverse_entry(wrong, d(2026, 2, 2), "duplicate").unwrap();
|
||||
pay(&mut l, d(2026, 4, 1), sam, contract, card, "5000");
|
||||
pay(&mut l, d(2026, 5, 1), bigco, contract, bank, "9000");
|
||||
// Untagged spending is not anyone's 1099.
|
||||
let e = l.create_entry(d(2026, 7, 1), "untagged");
|
||||
let m = Money::new(dec("50"), Currency::USD);
|
||||
l.add_line(e, contract, Side::Debit, m).unwrap();
|
||||
l.add_line(e, bank, Side::Credit, m).unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
l
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cash_paid_per_vendor_and_who_is_reportable() {
|
||||
let l = books();
|
||||
let r = vendor_1099_report(&l, 2026, None).unwrap();
|
||||
assert_eq!(r.threshold, dec("2000"));
|
||||
let rows: Vec<(&str, Decimal, bool)> = r
|
||||
.vendors
|
||||
.iter()
|
||||
.map(|v| (v.name.as_str(), v.paid, v.reportable))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
rows,
|
||||
[
|
||||
("Ace Rentals", dec("1200"), false),
|
||||
("Bigco", dec("9000"), false),
|
||||
("Dana", dec("2200"), true),
|
||||
("Sam", dec("0"), false),
|
||||
]
|
||||
);
|
||||
let ace = &r.vendors[0];
|
||||
assert_eq!(ace.form, Some(Form1099::Misc));
|
||||
assert!(!ace.tin_on_file);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn years_and_thresholds() {
|
||||
let l = books();
|
||||
// SOURCE: rules.md B-19 (sources/irs-1099-thresholds.md).
|
||||
assert_eq!(default_threshold(2025), Ok(dec("600")));
|
||||
assert_eq!(default_threshold(2026), Ok(dec("2000")));
|
||||
let y2025 = vendor_1099_report(&l, 2025, None).unwrap();
|
||||
let dana = y2025.vendors.iter().find(|v| v.name == "Dana").unwrap();
|
||||
assert_eq!(dana.paid, dec("300"));
|
||||
assert!(!dana.reportable, "300 < 600");
|
||||
// An override: at 1,000, Ace's 1,200 is reportable.
|
||||
let r = vendor_1099_report(&l, 2026, Some(dec("1000"))).unwrap();
|
||||
assert!(
|
||||
r.vendors
|
||||
.iter()
|
||||
.any(|v| v.name == "Ace Rentals" && v.reportable)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_need_real_entries_and_vendors() {
|
||||
let mut l = books();
|
||||
assert!(matches!(
|
||||
l.set_entry_vendor(999, Some(1)),
|
||||
Err(LedgerError::UnknownEntry(999))
|
||||
));
|
||||
assert!(matches!(
|
||||
l.set_entry_vendor(1, Some(999)),
|
||||
Err(LedgerError::UnknownVendor(999))
|
||||
));
|
||||
// The bill and its payment were tagged automatically.
|
||||
assert_eq!(l.entry_vendor(1), Some(1));
|
||||
assert_eq!(l.entry_vendor(2), Some(1));
|
||||
l.set_entry_vendor(2, None).unwrap();
|
||||
assert_eq!(l.entry_vendor(2), None);
|
||||
assert_eq!(Form1099::parse("1099-NEC"), Some(Form1099::Nec));
|
||||
assert_eq!(Form1099::parse("misc"), Some(Form1099::Misc));
|
||||
assert_eq!(Form1099::parse("K"), None);
|
||||
}
|
||||
|
||||
/// Spec (Stephen, 2026-10-13): a rule set by law that wanderbooks doesn't
|
||||
/// know for a year is never guessed. 26 U.S.C. §6041(h) indexes the $2,000
|
||||
/// threshold for calendar years after 2026; until the indexed amount is in
|
||||
/// the rule table, asking for it is an error that says what to do.
|
||||
#[test]
|
||||
fn an_unknown_year_threshold_is_never_guessed() {
|
||||
let err = default_threshold(2027).unwrap_err();
|
||||
let LedgerError::RuleNotKnown {
|
||||
rule,
|
||||
year,
|
||||
known_through,
|
||||
authority,
|
||||
} = &err
|
||||
else {
|
||||
panic!("expected RuleNotKnown, got {err:?}");
|
||||
};
|
||||
assert_eq!(
|
||||
(rule.as_str(), *year, *known_through),
|
||||
("1099 reporting threshold", 2027, 2026)
|
||||
);
|
||||
assert!(authority.contains("https://www.irs.gov/"), "{authority}");
|
||||
let why = err.to_string();
|
||||
for needle in [
|
||||
"2027",
|
||||
"official source",
|
||||
"CPA",
|
||||
"irs.gov",
|
||||
"tell the human",
|
||||
] {
|
||||
assert!(why.contains(needle), "missing {needle:?} in: {why}");
|
||||
}
|
||||
// It reaches agents: it names the authority, never this repo.
|
||||
for never in ["github", "pull request", "docs/", ".md"] {
|
||||
assert!(!why.to_lowercase().contains(never), "{never:?} in: {why}");
|
||||
}
|
||||
let l = books();
|
||||
assert!(matches!(
|
||||
vendor_1099_report(&l, 2027, None),
|
||||
Err(LedgerError::RuleNotKnown { .. })
|
||||
));
|
||||
// An explicit threshold (from the IRS, or the owner or CPA) works.
|
||||
let r = vendor_1099_report(&l, 2027, Some(dec("2100"))).unwrap();
|
||||
assert_eq!(r.threshold, dec("2100"));
|
||||
}
|
||||
|
||||
/// Spec: a dated rule is due for review from its review date, and stays
|
||||
/// due; before it, nothing is stale. The engine has no clock: callers pass
|
||||
/// today.
|
||||
#[test]
|
||||
fn dated_rules_go_stale_on_their_review_date() {
|
||||
use ledger_core::rules::{THRESHOLD_1099, stale_rules};
|
||||
assert!(stale_rules(d(2026, 10, 13)).is_empty());
|
||||
let due = THRESHOLD_1099.review_after();
|
||||
assert!(stale_rules(due.pred_opt().unwrap()).is_empty());
|
||||
assert_eq!(stale_rules(due).len(), 1);
|
||||
assert_eq!(
|
||||
stale_rules(d(2027, 1, 1))[0].name,
|
||||
"1099 reporting threshold"
|
||||
);
|
||||
assert_eq!(stale_rules(d(2030, 6, 1)).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forms_have_their_irs_names() {
|
||||
assert_eq!(Form1099::Nec.label(), "1099-NEC");
|
||||
assert_eq!(Form1099::Misc.label(), "1099-MISC");
|
||||
// A label parses back to its form.
|
||||
for f in [Form1099::Nec, Form1099::Misc] {
|
||||
assert_eq!(Form1099::parse(f.label()), Some(f));
|
||||
}
|
||||
}
|
||||
|
||||
/// vendor_1099_report_with SCENARIOS 1–2, worked by hand. Payment sources:
|
||||
/// cash 1000 and the members' financing liability 2200. Kim (1099-MISC,
|
||||
/// landlord) in 2026:
|
||||
/// - 1,000 rent from the bank (Dr rent, Cr 1000): +1,000
|
||||
/// - 2,400 prepaid rent from the bank (Dr 1300, Cr 1000): +2,400 (paid
|
||||
/// when made; the prepaid asset is not a source, so it doesn't offset)
|
||||
/// - 800 rent paid by a member (Dr rent, Cr 2200): +800
|
||||
/// - 500 rent on the card (Dr rent, Cr 2100): 0 (1099-K)
|
||||
/// - 300 refunded to the bank (Dr 1000, Cr rent): −300
|
||||
///
|
||||
/// Total 3,900. The plain report (every asset a source, no financing)
|
||||
/// sees 1,000 + 0 + 0 + 0 − 300 = 700: the understatement this fixes.
|
||||
#[test]
|
||||
fn payments_come_from_cash_and_financing_only() {
|
||||
use ledger_core::{PaymentAccounts, vendor_1099_report_with};
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let acct = |l: &mut Ledger, code: &str, t| l.add_account(code, code, t, None, false).unwrap();
|
||||
let bank = acct(&mut l, "1000", AccountType::Asset);
|
||||
let prepaid = acct(&mut l, "1300", AccountType::Asset);
|
||||
let card = acct(&mut l, "2100", AccountType::Liability);
|
||||
let members = acct(&mut l, "2200", AccountType::Liability);
|
||||
let rent = acct(&mut l, "6600", AccountType::Expense);
|
||||
let kim = l.add_vendor("Kim").unwrap();
|
||||
l.set_vendor_1099(kim, Some(Form1099::Misc), true).unwrap();
|
||||
for (dr, cr, amt) in [
|
||||
(rent, bank, "1000"),
|
||||
(prepaid, bank, "2400"),
|
||||
(rent, members, "800"),
|
||||
(rent, card, "500"),
|
||||
(bank, rent, "300"),
|
||||
] {
|
||||
let e = l.create_entry(d(2026, 5, 1), "rent");
|
||||
let m = Money::new(dec(amt), Currency::USD);
|
||||
l.add_line(e, dr, Side::Debit, m).unwrap();
|
||||
l.add_line(e, cr, Side::Credit, m).unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
l.set_entry_vendor(e, Some(kim)).unwrap();
|
||||
}
|
||||
let sources = PaymentAccounts {
|
||||
cash: vec![bank],
|
||||
financing: vec![members],
|
||||
};
|
||||
let r = vendor_1099_report_with(&l, 2026, None, &sources).unwrap();
|
||||
assert_eq!(r.vendors[0].paid, dec("3900"));
|
||||
assert!(r.vendors[0].reportable, "3,900 is over 2,000");
|
||||
let plain = vendor_1099_report(&l, 2026, None).unwrap();
|
||||
assert_eq!(plain.vendors[0].paid, dec("700"));
|
||||
}
|
||||
|
||||
/// Books for the shared-payment tests: bank 1000, card 2050, contract
|
||||
/// labor 6000; Dana (1099-NEC) and member Stephen as parties.
|
||||
struct Shared {
|
||||
l: Ledger,
|
||||
bank: ledger_core::AccountId,
|
||||
card: ledger_core::AccountId,
|
||||
labor: ledger_core::AccountId,
|
||||
dana: ledger_core::VendorId,
|
||||
dana_p: ledger_core::PartyId,
|
||||
stephen: ledger_core::PartyId,
|
||||
}
|
||||
|
||||
fn shared_books() -> Shared {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let bank = l
|
||||
.add_account("1000", "Bank", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let card = l
|
||||
.add_account("2050", "Card", AccountType::Liability, None, false)
|
||||
.unwrap();
|
||||
let labor = l
|
||||
.add_account("6000", "Contract Labor", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let dana = l.add_vendor("Dana").unwrap();
|
||||
l.set_vendor_1099(dana, Some(Form1099::Nec), true).unwrap();
|
||||
let dana_p = l.party_of_vendor(dana).unwrap();
|
||||
let stephen = l
|
||||
.add_party("Stephen", &[ledger_core::Role::Member])
|
||||
.unwrap();
|
||||
Shared {
|
||||
l,
|
||||
bank,
|
||||
card,
|
||||
labor,
|
||||
dana,
|
||||
dana_p,
|
||||
stephen,
|
||||
}
|
||||
}
|
||||
|
||||
/// Post `lines` on `date` and name `shares` on it.
|
||||
fn post_shared(
|
||||
s: &mut Shared,
|
||||
date: NaiveDate,
|
||||
lines: &[(ledger_core::AccountId, Side, &str)],
|
||||
shares: &[(ledger_core::PartyId, ledger_core::Share)],
|
||||
) -> ledger_core::EntryId {
|
||||
let e = s.l.create_entry(date, "shared payment");
|
||||
for (a, side, amt) in lines {
|
||||
s.l.add_line(e, *a, *side, Money::new(dec(amt), Currency::USD))
|
||||
.unwrap();
|
||||
}
|
||||
s.l.post_entry(e).unwrap();
|
||||
let shares: Vec<ledger_core::PartyShare> = shares
|
||||
.iter()
|
||||
.map(|&(party, share)| ledger_core::PartyShare { party, share })
|
||||
.collect();
|
||||
s.l.set_entry_parties(e, &shares).unwrap();
|
||||
e
|
||||
}
|
||||
|
||||
fn dana_paid(s: &Shared) -> (Decimal, Vec<ledger_core::EntryId>) {
|
||||
let r = vendor_1099_report(&s.l, 2026, None).unwrap();
|
||||
let line = r.vendors.iter().find(|v| v.vendor_id == s.dana).unwrap();
|
||||
(line.paid, r.unresolved)
|
||||
}
|
||||
|
||||
/// vendor_1099_report_with_members SCENARIO 3, by hand: $1,000.00 from
|
||||
/// the bank (Dr 6000 / Cr 1000), Dana $600.00 and Stephen $400.00 by
|
||||
/// amount. Payment = target = 1,000.00, so Dana's 1099 counts her share
|
||||
/// unchanged: 600.00 (not the whole 1,000.00).
|
||||
#[test]
|
||||
fn a_shared_payment_counts_the_vendors_share() {
|
||||
use ledger_core::Share::Amount;
|
||||
let mut s = shared_books();
|
||||
let (bank, labor, dana_p, stephen) = (s.bank, s.labor, s.dana_p, s.stephen);
|
||||
let e = post_shared(
|
||||
&mut s,
|
||||
d(2026, 3, 1),
|
||||
&[
|
||||
(labor, Side::Debit, "1000.00"),
|
||||
(bank, Side::Credit, "1000.00"),
|
||||
],
|
||||
&[
|
||||
(dana_p, Amount(dec("600.00"))),
|
||||
(stephen, Amount(dec("400.00"))),
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
s.l.entry_vendor(e),
|
||||
Some(s.dana),
|
||||
"the vendor tag follows Dana"
|
||||
);
|
||||
assert_eq!(dana_paid(&s), (dec("600.00"), vec![]));
|
||||
// With sources and members named, the same.
|
||||
let r = ledger_core::vendor_1099_report_with_members(
|
||||
&s.l,
|
||||
2026,
|
||||
None,
|
||||
&ledger_core::PaymentAccounts {
|
||||
cash: vec![bank],
|
||||
financing: vec![],
|
||||
},
|
||||
&[],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r.vendors[0].paid, dec("600.00"));
|
||||
assert!(!r.vendors[0].reportable, "600.00 is under 2,000");
|
||||
}
|
||||
|
||||
/// SCENARIO 4, by hand: $100.01 from the bank split 50% / 50%. Each part
|
||||
/// is 100.01 × 50 / 100 = 50.005, rounded toward zero to 50.00; the cent
|
||||
/// left over goes to the party named first (shares INVARIANT 8). Dana
|
||||
/// first: 50.01. A second payment with Dana named second: 50.00. Total
|
||||
/// 100.01.
|
||||
#[test]
|
||||
fn a_percent_split_gives_the_leftover_cent_to_the_first() {
|
||||
use ledger_core::Share::Percent;
|
||||
let mut s = shared_books();
|
||||
let (bank, labor, dana_p, stephen) = (s.bank, s.labor, s.dana_p, s.stephen);
|
||||
let lines = [
|
||||
(labor, Side::Debit, "100.01"),
|
||||
(bank, Side::Credit, "100.01"),
|
||||
];
|
||||
post_shared(
|
||||
&mut s,
|
||||
d(2026, 4, 1),
|
||||
&lines,
|
||||
&[(dana_p, Percent(dec("50"))), (stephen, Percent(dec("50")))],
|
||||
);
|
||||
assert_eq!(dana_paid(&s).0, dec("50.01"));
|
||||
post_shared(
|
||||
&mut s,
|
||||
d(2026, 4, 2),
|
||||
&lines,
|
||||
&[(stephen, Percent(dec("50"))), (dana_p, Percent(dec("50")))],
|
||||
);
|
||||
assert_eq!(dana_paid(&s).0, dec("100.01"), "50.01 + 50.00");
|
||||
}
|
||||
|
||||
/// SCENARIO 6, by hand: SCENARIO 3's payment reversed the same year. The
|
||||
/// reversal carries the shares; its target is −1,000.00, Dana's amount
|
||||
/// resolves to −600.00, and the payment is −1,000.00 = target, so she
|
||||
/// nets 600.00 − 600.00 = 0.00 (still listed: she is marked for a 1099).
|
||||
#[test]
|
||||
fn a_reversed_shared_payment_nets_to_zero() {
|
||||
use ledger_core::Share::Amount;
|
||||
let mut s = shared_books();
|
||||
let (bank, labor, dana_p, stephen) = (s.bank, s.labor, s.dana_p, s.stephen);
|
||||
let e = post_shared(
|
||||
&mut s,
|
||||
d(2026, 3, 1),
|
||||
&[
|
||||
(labor, Side::Debit, "1000.00"),
|
||||
(bank, Side::Credit, "1000.00"),
|
||||
],
|
||||
&[
|
||||
(dana_p, Amount(dec("600.00"))),
|
||||
(stephen, Amount(dec("400.00"))),
|
||||
],
|
||||
);
|
||||
let rev = s.l.reverse_entry(e, d(2026, 3, 2), "wrong payee").unwrap();
|
||||
assert_eq!(s.l.entry_parties(rev), s.l.entry_parties(e));
|
||||
assert_eq!(dana_paid(&s), (dec("0.00"), vec![]));
|
||||
}
|
||||
|
||||
/// SCENARIO 5, by hand: $1,000.00 of contract labor, 600.00 from the
|
||||
/// bank and 400.00 on the card, Dana 60% / Stephen 40%. Target (the flow
|
||||
/// net, the card counted as money) 1,000.00, Dana's share 600.00, the
|
||||
/// 1099 payment 600.00 (the card is not a payment source): 600.00 ×
|
||||
/// 600.00 / 1,000.00 = 360.00. Then $100.00 with 33.33 from the bank and
|
||||
/// 66.67 on the card, 50/50: share 50.00, 33.33 × 50.00 / 100.00 =
|
||||
/// 16.665, rounded half away from zero to 16.67. Total 376.67.
|
||||
#[test]
|
||||
fn a_shared_payment_partly_on_a_card_scales_to_the_payment() {
|
||||
use ledger_core::Share::Percent;
|
||||
let mut s = shared_books();
|
||||
let (bank, card, labor, dana_p, stephen) = (s.bank, s.card, s.labor, s.dana_p, s.stephen);
|
||||
post_shared(
|
||||
&mut s,
|
||||
d(2026, 5, 1),
|
||||
&[
|
||||
(labor, Side::Debit, "1000.00"),
|
||||
(bank, Side::Credit, "600.00"),
|
||||
(card, Side::Credit, "400.00"),
|
||||
],
|
||||
&[(dana_p, Percent(dec("60"))), (stephen, Percent(dec("40")))],
|
||||
);
|
||||
assert_eq!(dana_paid(&s).0, dec("360.00"));
|
||||
post_shared(
|
||||
&mut s,
|
||||
d(2026, 5, 2),
|
||||
&[
|
||||
(labor, Side::Debit, "100.00"),
|
||||
(bank, Side::Credit, "33.33"),
|
||||
(card, Side::Credit, "66.67"),
|
||||
],
|
||||
&[(dana_p, Percent(dec("50"))), (stephen, Percent(dec("50")))],
|
||||
);
|
||||
assert_eq!(dana_paid(&s).0, dec("376.67"));
|
||||
}
|
||||
|
||||
/// INVARIANT 4, by hand: amounts of 600.00 and 300.00 on a 1,000.00
|
||||
/// payment don't resolve (shares SCENARIO 5), so the whole 1,000.00
|
||||
/// counts toward Dana and the entry is listed. A single-party entry
|
||||
/// (300.00, Dana alone) counts in full, as before shares: 1,300.00.
|
||||
#[test]
|
||||
fn unresolved_shares_count_in_full_and_are_listed() {
|
||||
use ledger_core::Share::{Amount, Derived};
|
||||
let mut s = shared_books();
|
||||
let (bank, labor, dana_p, stephen) = (s.bank, s.labor, s.dana_p, s.stephen);
|
||||
let e = post_shared(
|
||||
&mut s,
|
||||
d(2026, 6, 1),
|
||||
&[
|
||||
(labor, Side::Debit, "1000.00"),
|
||||
(bank, Side::Credit, "1000.00"),
|
||||
],
|
||||
&[
|
||||
(dana_p, Amount(dec("600.00"))),
|
||||
(stephen, Amount(dec("300.00"))),
|
||||
],
|
||||
);
|
||||
post_shared(
|
||||
&mut s,
|
||||
d(2026, 6, 2),
|
||||
&[
|
||||
(labor, Side::Debit, "300.00"),
|
||||
(bank, Side::Credit, "300.00"),
|
||||
],
|
||||
&[(dana_p, Derived)],
|
||||
);
|
||||
assert_eq!(dana_paid(&s), (dec("1300.00"), vec![e]));
|
||||
}
|
||||
|
||||
/// The SCENARIO 3 payment: $1,000.00 from the bank, Dana 600.00 and
|
||||
/// Stephen 400.00 by amount.
|
||||
fn dana_and_stephen(s: &mut Shared) -> ledger_core::EntryId {
|
||||
use ledger_core::Share::Amount;
|
||||
let (bank, labor, dana_p, stephen) = (s.bank, s.labor, s.dana_p, s.stephen);
|
||||
post_shared(
|
||||
s,
|
||||
d(2026, 3, 1),
|
||||
&[
|
||||
(labor, Side::Debit, "1000.00"),
|
||||
(bank, Side::Credit, "1000.00"),
|
||||
],
|
||||
&[
|
||||
(dana_p, Amount(dec("600.00"))),
|
||||
(stephen, Amount(dec("400.00"))),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// set_entry_vendor SCENARIO 4, by hand: re-tagging Dana's vendor on the
|
||||
/// shared payment keeps both shares, so her 1099 stays 600.00 (not the
|
||||
/// whole 1,000.00) and Stephen keeps his part. Tagging a vendor whose
|
||||
/// party isn't named is refused, changing nothing.
|
||||
#[test]
|
||||
fn tagging_the_vendor_keeps_a_shared_entrys_parties() {
|
||||
let mut s = shared_books();
|
||||
let e = dana_and_stephen(&mut s);
|
||||
let before = s.l.entry_parties(e).to_vec();
|
||||
s.l.set_entry_vendor(e, Some(s.dana)).unwrap();
|
||||
assert_eq!(s.l.entry_parties(e), before.as_slice());
|
||||
assert!(s.l.entry_names_party(e, s.stephen));
|
||||
assert_eq!(dana_paid(&s), (dec("600.00"), vec![]));
|
||||
let other = s.l.add_vendor("Other Co").unwrap();
|
||||
let err = s.l.set_entry_vendor(e, Some(other)).unwrap_err();
|
||||
assert!(matches!(err, LedgerError::InvalidParty(_)), "{err:?}");
|
||||
assert_eq!(s.l.entry_parties(e), before.as_slice());
|
||||
assert_eq!(s.l.entry_vendor(e), Some(s.dana));
|
||||
// One party or none: the vendor's party becomes the entry's one party.
|
||||
let (labor, bank, stephen) = (s.labor, s.bank, s.stephen);
|
||||
let lone = post_shared(
|
||||
&mut s,
|
||||
d(2026, 3, 5),
|
||||
&[(labor, Side::Debit, "50.00"), (bank, Side::Credit, "50.00")],
|
||||
&[(stephen, ledger_core::Share::Derived)],
|
||||
);
|
||||
s.l.set_entry_vendor(lone, Some(s.dana)).unwrap();
|
||||
assert_eq!(s.l.entry_party(lone), Some(s.dana_p));
|
||||
}
|
||||
|
||||
/// set_entry_vendor SCENARIO 5: clearing the tag on the shared payment,
|
||||
/// whose vendor's party (Dana) is named, is refused and changes nothing,
|
||||
/// so the 1099 (600.00) and Dana's party activity never disagree.
|
||||
#[test]
|
||||
fn clearing_the_vendor_on_a_shared_entry_is_refused() {
|
||||
let mut s = shared_books();
|
||||
let e = dana_and_stephen(&mut s);
|
||||
let err = s.l.set_entry_vendor(e, None).unwrap_err();
|
||||
assert!(matches!(err, LedgerError::InvalidParty(_)), "{err:?}");
|
||||
assert_eq!(s.l.entry_vendor(e), Some(s.dana));
|
||||
assert!(s.l.entry_names_party(e, s.dana_p));
|
||||
assert_eq!(dana_paid(&s), (dec("600.00"), vec![]));
|
||||
// Dropping Dana from the parties clears the tag with her.
|
||||
s.l.set_entry_parties(e, &[ledger_core::PartyShare::whole(s.stephen)])
|
||||
.unwrap();
|
||||
assert_eq!(s.l.entry_vendor(e), None);
|
||||
}
|
||||
|
||||
/// set_entry_parties SCENARIO 3, by hand: the shared payment reversed,
|
||||
/// then re-apportioned 700.00 / 300.00. The reversal is re-apportioned
|
||||
/// too, so Dana nets 700.00 − 700.00 = 0.00 (not 700.00 − 600.00 =
|
||||
/// 100.00). Re-apportioning the reversal itself does the same for the
|
||||
/// original.
|
||||
#[test]
|
||||
fn re_apportioning_a_reversed_entry_re_apportions_its_reversal() {
|
||||
use ledger_core::Share::Amount;
|
||||
let mut s = shared_books();
|
||||
let e = dana_and_stephen(&mut s);
|
||||
let rev = s.l.reverse_entry(e, d(2026, 3, 2), "wrong payee").unwrap();
|
||||
assert_eq!(s.l.reversal_chain(e), vec![e, rev]);
|
||||
assert_eq!(s.l.reversal_chain(rev), vec![e, rev]);
|
||||
let shares = |a: &str, b: &str| {
|
||||
vec![
|
||||
ledger_core::PartyShare {
|
||||
party: s.dana_p,
|
||||
share: Amount(dec(a)),
|
||||
},
|
||||
ledger_core::PartyShare {
|
||||
party: s.stephen,
|
||||
share: Amount(dec(b)),
|
||||
},
|
||||
]
|
||||
};
|
||||
let new = shares("700.00", "300.00");
|
||||
s.l.set_entry_parties(e, &new).unwrap();
|
||||
assert_eq!(s.l.entry_parties(rev), new.as_slice());
|
||||
assert_eq!(dana_paid(&s), (dec("0.00"), vec![]));
|
||||
let back = shares("550.00", "450.00");
|
||||
s.l.set_entry_parties(rev, &back).unwrap();
|
||||
assert_eq!(s.l.entry_parties(e), back.as_slice());
|
||||
assert_eq!(dana_paid(&s), (dec("0.00"), vec![]));
|
||||
// Re-tagging the vendor on a reversed one-party entry tags both.
|
||||
let (labor, bank, stephen) = (s.labor, s.bank, s.stephen);
|
||||
let lone = post_shared(
|
||||
&mut s,
|
||||
d(2026, 4, 1),
|
||||
&[(labor, Side::Debit, "80.00"), (bank, Side::Credit, "80.00")],
|
||||
&[(stephen, ledger_core::Share::Derived)],
|
||||
);
|
||||
let lone_rev = s.l.reverse_entry(lone, d(2026, 4, 2), "oops").unwrap();
|
||||
s.l.set_entry_vendor(lone, Some(s.dana)).unwrap();
|
||||
assert_eq!(s.l.entry_vendor(lone_rev), Some(s.dana));
|
||||
assert_eq!(dana_paid(&s), (dec("0.00"), vec![]));
|
||||
}
|
||||
|
||||
/// add_vendor INVARIANTS 2-3: a party named on a shared entry beside
|
||||
/// Dana (who has a vendor record) can't gain a vendor record, which
|
||||
/// would leave the entry naming two vendors (shares INVARIANT 5); a party
|
||||
/// named with no vendor party gains the tag with its new record.
|
||||
#[test]
|
||||
fn a_named_party_gaining_a_vendor_record_keeps_one_vendor_per_entry() {
|
||||
use ledger_core::Share::Percent;
|
||||
let mut s = shared_books();
|
||||
let beta = s.l.add_party("Beta", &[]).unwrap();
|
||||
let (bank, labor, dana_p) = (s.bank, s.labor, s.dana_p);
|
||||
let e = post_shared(
|
||||
&mut s,
|
||||
d(2026, 7, 1),
|
||||
&[
|
||||
(labor, Side::Debit, "100.00"),
|
||||
(bank, Side::Credit, "100.00"),
|
||||
],
|
||||
&[(dana_p, Percent(dec("60"))), (beta, Percent(dec("40")))],
|
||||
);
|
||||
let vendors_before = s.l.list_vendors().len();
|
||||
let err = s.l.add_vendor("Beta").unwrap_err();
|
||||
assert!(matches!(err, LedgerError::InvalidParty(_)), "{err:?}");
|
||||
assert_eq!(s.l.list_vendors().len(), vendors_before, "nothing changed");
|
||||
assert_eq!(s.l.get_party(beta).unwrap().vendor, None);
|
||||
// Re-issuing the same shares is still accepted.
|
||||
let same = s.l.entry_parties(e).to_vec();
|
||||
s.l.set_entry_parties(e, &same).unwrap();
|
||||
// Gamma, named only beside Stephen (a member, no vendor record).
|
||||
let gamma = s.l.add_party("Gamma", &[]).unwrap();
|
||||
let stephen = s.stephen;
|
||||
let g = post_shared(
|
||||
&mut s,
|
||||
d(2026, 7, 2),
|
||||
&[
|
||||
(labor, Side::Debit, "100.00"),
|
||||
(bank, Side::Credit, "100.00"),
|
||||
],
|
||||
&[(stephen, Percent(dec("50"))), (gamma, Percent(dec("50")))],
|
||||
);
|
||||
assert_eq!(s.l.entry_vendor(g), None);
|
||||
let gv = s.l.add_vendor("Gamma").unwrap();
|
||||
assert_eq!(s.l.get_party(gamma).unwrap().vendor, Some(gv));
|
||||
assert_eq!(s.l.entry_vendor(g), Some(gv), "the tag follows the party");
|
||||
}
|
||||
@@ -1,368 +0,0 @@
|
||||
[
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
@@ -10,9 +10,10 @@
|
||||
//! semantic oracle (see `docs/oracle.md`).
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::reports::{balance_sheet, profit_and_loss, snapshot, trial_balance};
|
||||
use ledger_core::reports::{balance_sheet, snapshot, trial_balance, unclosed_earnings};
|
||||
use ledger_core::{
|
||||
AccountId, AccountType, Currency, EntryStatus, Ledger, LedgerError, Money, Side,
|
||||
AccountId, AccountType, ChartOfAccounts, Currency, EntryStatus, JournalEntry, Ledger,
|
||||
LedgerError, Money, Side,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
@@ -181,14 +182,162 @@ fn zero_amount_line_is_rejected() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_amount_line_is_rejected() {
|
||||
let c = fixture_company();
|
||||
let mut ledger = c.ledger;
|
||||
let id = ledger.create_entry(date(21), "Negative line");
|
||||
let minus = Money::new(Decimal::from(-100), Currency::USD);
|
||||
ledger.add_line(id, c.cash, Side::Debit, minus).unwrap();
|
||||
ledger.add_line(id, c.equity, Side::Credit, minus).unwrap();
|
||||
assert_eq!(
|
||||
ledger.post_entry(id).unwrap_err(),
|
||||
LedgerError::NegativeAmountLine
|
||||
);
|
||||
}
|
||||
|
||||
fn kernel_max() -> Decimal {
|
||||
ledger_core::kernel::MAX_LINE_AMOUNT
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_range_amount_is_rejected() {
|
||||
let c = fixture_company();
|
||||
let mut ledger = c.ledger;
|
||||
// Big enough to overflow a sum: an error, never a panic, as soon as
|
||||
// the line is added (a draft is summed before it posts: its debits,
|
||||
// its parties' shares; P0 review round 3, S1). Replayed as an earlier
|
||||
// version wrote it (rules.md B-28): accepted into its draft, whose
|
||||
// post refuses it, never a panic.
|
||||
let id = ledger.create_entry(date(21), "Too big");
|
||||
let old = ledger.create_entry(date(21), "Too big, journaled before the bound");
|
||||
for amount in [
|
||||
Decimal::MAX,
|
||||
Decimal::MIN,
|
||||
kernel_max() + Decimal::new(1, 2),
|
||||
] {
|
||||
let big = Money::new(amount, Currency::USD);
|
||||
assert_eq!(
|
||||
ledger.add_line(id, c.cash, Side::Debit, big).unwrap_err(),
|
||||
LedgerError::AmountOutOfRange
|
||||
);
|
||||
ledger
|
||||
.replay_add_line(old, c.cash, Side::Debit, big)
|
||||
.unwrap();
|
||||
}
|
||||
let max_usd = Money::new(Decimal::MAX, Currency::USD);
|
||||
ledger
|
||||
.replay_add_line(old, c.equity, Side::Credit, max_usd)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ledger.post_entry(old).unwrap_err(),
|
||||
LedgerError::AmountOutOfRange
|
||||
);
|
||||
assert_eq!(
|
||||
ledger.replay_post_entry(old).unwrap_err(),
|
||||
LedgerError::AmountOutOfRange
|
||||
);
|
||||
// The largest line goes in, and posts.
|
||||
let max = Money::new(kernel_max(), Currency::USD);
|
||||
ledger.add_line(id, c.cash, Side::Debit, max).unwrap();
|
||||
ledger.add_line(id, c.equity, Side::Credit, max).unwrap();
|
||||
ledger.post_entry(id).unwrap();
|
||||
// Too precise: refused as soon as it is added (finer than a cent)...
|
||||
let id = ledger.create_entry(date(21), "Too precise");
|
||||
let tiny = Money::new("0.00000000001".parse().unwrap(), Currency::USD);
|
||||
assert!(matches!(
|
||||
ledger.add_line(id, c.cash, Side::Debit, tiny),
|
||||
Err(LedgerError::FinerThanMinorUnit { places: 2, .. })
|
||||
));
|
||||
// ...and out of range even for books replayed from before that rule.
|
||||
ledger
|
||||
.replay_add_line(id, c.cash, Side::Debit, tiny)
|
||||
.unwrap();
|
||||
ledger
|
||||
.replay_add_line(id, c.equity, Side::Credit, tiny)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ledger.replay_post_entry(id).unwrap_err(),
|
||||
LedgerError::AmountOutOfRange
|
||||
);
|
||||
}
|
||||
|
||||
/// B-3 (DECISION, Stephen 2026-10-13: a ledger in one money holds no
|
||||
/// fractions of its smallest unit). Regression: a payroll run posted
|
||||
/// $65.27794 (found by the store's refusal property in CI, 2026-10-13).
|
||||
#[test]
|
||||
fn no_entry_posts_a_fraction_of_the_minor_unit() {
|
||||
let c = fixture_company();
|
||||
let mut ledger = c.ledger;
|
||||
let usd = |s: &str| Money::new(s.parse().unwrap(), Currency::USD);
|
||||
// Whole cents, with or without trailing zeros, are fine.
|
||||
for ok in ["65.27", "65.270", "65"] {
|
||||
let id = ledger.create_entry(date(21), "cents");
|
||||
ledger.add_line(id, c.cash, Side::Debit, usd(ok)).unwrap();
|
||||
ledger
|
||||
.add_line(id, c.equity, Side::Credit, usd(ok))
|
||||
.unwrap();
|
||||
ledger.post_entry(id).unwrap();
|
||||
}
|
||||
// A fraction of a cent is refused when added...
|
||||
let id = ledger.create_entry(date(21), "sub-cent");
|
||||
assert!(matches!(
|
||||
ledger.add_line(id, c.cash, Side::Debit, usd("65.27794")),
|
||||
Err(LedgerError::FinerThanMinorUnit { places: 2, .. })
|
||||
));
|
||||
// ...and when posted, whatever path put the line on the draft.
|
||||
ledger
|
||||
.replay_add_line(id, c.cash, Side::Debit, usd("65.275"))
|
||||
.unwrap();
|
||||
ledger
|
||||
.replay_add_line(id, c.equity, Side::Credit, usd("65.275"))
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
ledger.post_entry(id),
|
||||
Err(LedgerError::FinerThanMinorUnit { .. })
|
||||
));
|
||||
assert_eq!(
|
||||
ledger.get_entry(id).unwrap().status(),
|
||||
ledger_core::EntryStatus::Draft
|
||||
);
|
||||
// Books from before the rule still replay as booked.
|
||||
ledger.replay_post_entry(id).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fx_overflow_is_an_error() {
|
||||
let c = fixture_company();
|
||||
let mut ledger = c.ledger;
|
||||
let id = ledger.create_entry(date(21), "Huge FX");
|
||||
assert_eq!(
|
||||
ledger
|
||||
.add_line_fx(
|
||||
id,
|
||||
c.cash,
|
||||
Side::Debit,
|
||||
Currency::EUR,
|
||||
Decimal::MAX,
|
||||
Decimal::from(2)
|
||||
)
|
||||
.unwrap_err(),
|
||||
LedgerError::AmountOutOfRange
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_account_is_rejected() {
|
||||
// Refused when the line is added (since 2026-10-13); a line that got
|
||||
// in anyway (replayed from an older journal) is still refused at post.
|
||||
let c = fixture_company();
|
||||
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();
|
||||
assert_eq!(
|
||||
ledger.add_line(id, 999_999, Side::Credit, usd(100)),
|
||||
Err(LedgerError::UnknownAccount(999_999))
|
||||
);
|
||||
ledger
|
||||
.add_line(id, 999_999, Side::Credit, usd(100))
|
||||
.replay_add_line(id, 999_999, Side::Credit, usd(100))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ledger.post_entry(id).unwrap_err(),
|
||||
@@ -204,7 +353,14 @@ fn placeholder_account_is_rejected() {
|
||||
.add_account("100", "Assets header", AccountType::Asset, None, true)
|
||||
.unwrap();
|
||||
let id = ledger.create_entry(date(21), "Post to header");
|
||||
ledger.add_line(id, header, Side::Debit, usd(100)).unwrap();
|
||||
assert_eq!(
|
||||
ledger.add_line(id, header, Side::Debit, usd(100)),
|
||||
Err(LedgerError::PlaceholderAccount("100".to_string()))
|
||||
);
|
||||
// Got in anyway (an older journal): still refused at post.
|
||||
ledger
|
||||
.replay_add_line(id, header, Side::Debit, usd(100))
|
||||
.unwrap();
|
||||
ledger
|
||||
.add_line(id, c.equity, Side::Credit, usd(100))
|
||||
.unwrap();
|
||||
@@ -230,6 +386,39 @@ fn foreign_currency_line_is_rejected() {
|
||||
);
|
||||
}
|
||||
|
||||
/// R-1 / B-3: one currency throughout an entry. Even when every line
|
||||
/// matches its own account's currency (a chart the ledger never builds
|
||||
/// today, R-5), an entry mixing currencies is refused.
|
||||
#[test]
|
||||
fn an_entry_mixing_currencies_is_refused_even_on_matching_accounts() {
|
||||
let c = fixture_company();
|
||||
let mut ledger = c.ledger;
|
||||
let id = ledger.create_entry(date(21), "two moneys");
|
||||
ledger.add_line(id, c.cash, Side::Debit, usd(100)).unwrap();
|
||||
ledger
|
||||
.add_line(id, c.revenue, Side::Credit, usd(100))
|
||||
.unwrap();
|
||||
let mut json = serde_json::to_value(ledger.get_entry(id).unwrap()).unwrap();
|
||||
json["lines"][1]["amount"]["currency"] = serde_json::to_value(Currency::EUR).unwrap();
|
||||
let mixed: JournalEntry = serde_json::from_value(json).unwrap();
|
||||
|
||||
let mut chart = ChartOfAccounts::default();
|
||||
chart
|
||||
.add(ledger.chart().get(c.cash).unwrap().clone())
|
||||
.unwrap();
|
||||
let mut revenue = ledger.chart().get(c.revenue).unwrap().clone();
|
||||
revenue.currency = Currency::EUR;
|
||||
chart.add(revenue).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
mixed.validate(&chart),
|
||||
Err(LedgerError::CurrencyMismatch {
|
||||
expected: "USD".to_string(),
|
||||
found: "EUR".to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_account_code_is_rejected() {
|
||||
let c = fixture_company();
|
||||
@@ -295,6 +484,88 @@ fn reversal_nets_to_zero_and_links_back() {
|
||||
assert!(balance_sheet(&ledger).unwrap().balanced());
|
||||
}
|
||||
|
||||
/// A posted mistake, for the reversal rules below.
|
||||
fn posted_mistake(ledger: &mut Ledger, c: &Company) -> ledger_core::EntryId {
|
||||
let id = ledger.create_entry(date(21), "Mistake");
|
||||
ledger.add_line(id, c.rent, Side::Debit, usd(500)).unwrap();
|
||||
ledger.add_line(id, c.cash, Side::Credit, usd(500)).unwrap();
|
||||
ledger.post_entry(id).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
/// Spec (Stephen, 2026-10-13): an entry is reversed at most once. A second
|
||||
/// reversal would undo the original twice.
|
||||
#[test]
|
||||
fn an_entry_is_reversed_at_most_once() {
|
||||
let c = fixture_company();
|
||||
let mut ledger = c.ledger.clone();
|
||||
let id = posted_mistake(&mut ledger, &c);
|
||||
let rev = ledger.reverse_entry(id, date(22), "Reverse").unwrap();
|
||||
let before = ledger.next_entry_id();
|
||||
assert_eq!(
|
||||
ledger.reverse_entry(id, date(23), "Again").unwrap_err(),
|
||||
LedgerError::EntryAlreadyReversed(id, rev)
|
||||
);
|
||||
// Refused means untouched: no entry allocated, no draft left behind.
|
||||
assert_eq!(ledger.next_entry_id(), before);
|
||||
assert_eq!(ledger.account_movement(c.rent), Decimal::from(800));
|
||||
}
|
||||
|
||||
/// Spec: reversing the reversal re-instates the original, and is allowed
|
||||
/// (once, like any other entry).
|
||||
#[test]
|
||||
fn a_reversal_can_itself_be_reversed_once() {
|
||||
let c = fixture_company();
|
||||
let mut ledger = c.ledger.clone();
|
||||
let id = posted_mistake(&mut ledger, &c);
|
||||
let rev = ledger.reverse_entry(id, date(22), "Reverse").unwrap();
|
||||
let back = ledger.reverse_entry(rev, date(23), "Re-instate").unwrap();
|
||||
assert_eq!(ledger.account_movement(c.rent), Decimal::from(1300));
|
||||
assert_eq!(
|
||||
ledger.reverse_entry(rev, date(24), "Again").unwrap_err(),
|
||||
LedgerError::EntryAlreadyReversed(rev, back)
|
||||
);
|
||||
}
|
||||
|
||||
/// Spec (Stephen, 2026-10-13): a closing entry is never reversed. Undoing
|
||||
/// it would leave the period marked closed with its earnings unswept.
|
||||
#[test]
|
||||
fn a_closing_entry_is_never_reversed() {
|
||||
let c = fixture_company();
|
||||
let mut ledger = c.ledger.clone();
|
||||
let jan = ledger.create_period("2026-01", date(1), date(31)).unwrap();
|
||||
let closing = ledger.close_period(jan).unwrap().unwrap();
|
||||
let feb = NaiveDate::from_ymd_opt(2026, 2, 1).unwrap();
|
||||
let before = ledger.next_entry_id();
|
||||
assert_eq!(
|
||||
ledger
|
||||
.reverse_entry(closing, feb, "Undo close")
|
||||
.unwrap_err(),
|
||||
LedgerError::ClosingEntryIrreversible(closing)
|
||||
);
|
||||
assert_eq!(ledger.next_entry_id(), before);
|
||||
}
|
||||
|
||||
/// Regression (max-testing pass, 2026-10-13): a reversal dated in a closed
|
||||
/// period was refused only at post time, after allocating an id and
|
||||
/// leaving a draft behind. Refused means untouched.
|
||||
#[test]
|
||||
fn a_reversal_into_a_closed_period_leaves_no_trace() {
|
||||
let c = fixture_company();
|
||||
let mut ledger = c.ledger.clone();
|
||||
let id = posted_mistake(&mut ledger, &c);
|
||||
let jan = ledger.create_period("2026-01", date(1), date(31)).unwrap();
|
||||
// Nothing is open in January now except our posted mistake; close it.
|
||||
ledger.close_period(jan).unwrap();
|
||||
let before = ledger.next_entry_id();
|
||||
assert!(matches!(
|
||||
ledger.reverse_entry(id, date(25), "Into the closed month"),
|
||||
Err(LedgerError::ClosedPeriod(..))
|
||||
));
|
||||
assert_eq!(ledger.next_entry_id(), before);
|
||||
assert!(ledger.check_reversal(id, date(25)).is_err());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reports: the accounting laws hold.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -326,7 +597,7 @@ fn accounting_equation_holds() {
|
||||
#[test]
|
||||
fn profit_and_loss_is_correct() {
|
||||
let c = fixture_company();
|
||||
let pl = profit_and_loss(&c.ledger).unwrap();
|
||||
let pl = unclosed_earnings(&c.ledger).unwrap();
|
||||
assert_eq!(pl.revenue, Decimal::from(4_000));
|
||||
assert_eq!(pl.expenses, Decimal::from(800));
|
||||
assert_eq!(pl.net_income(), Decimal::from(3_200));
|
||||
@@ -485,6 +756,7 @@ fn cash_flow_params(cash: AccountId) -> CashFlowParams {
|
||||
end: date(31),
|
||||
cash_accounts: vec![cash],
|
||||
financing_accounts: vec![],
|
||||
investing_accounts: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -633,3 +905,585 @@ fn cash_flow_with_registered_asset_and_disposal() {
|
||||
assert_eq!(cf.cash_from_financing, Decimal::new(10000000, 2));
|
||||
// Sum: 0 - 400 + 100,000 = 99,600. Matches cash.
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Control accounts (Stephen, 2026-10-13): the engine posts invoices, bills,
|
||||
// payroll, and closes to accounts it finds by code. When an account with
|
||||
// that code exists but is the wrong kind, it refuses, says why, and changes
|
||||
// nothing, rather than posting to it or forcing the chart into its
|
||||
// numbering. SOURCE: rules.md B-4 (the elements), DECISION.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn books_with(code: &str, name: &str, t: AccountType, placeholder: bool) -> Ledger {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
l.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
l.add_account(code, name, t, None, placeholder).unwrap();
|
||||
l
|
||||
}
|
||||
|
||||
fn doc(amount: u64) -> Vec<ledger_core::DocumentLine> {
|
||||
vec![ledger_core::DocumentLine {
|
||||
description: "work".into(),
|
||||
amount: Decimal::from(amount),
|
||||
}]
|
||||
}
|
||||
|
||||
/// Refused means untouched: same chart, same ids, nothing posted.
|
||||
fn assert_untouched(before: &Ledger, after: &Ledger) {
|
||||
assert_eq!(after.chart().len(), before.chart().len());
|
||||
assert_eq!(after.next_entry_id(), before.next_entry_id());
|
||||
assert_eq!(after.next_invoice_id(), before.next_invoice_id());
|
||||
assert_eq!(after.next_bill_id(), before.next_bill_id());
|
||||
assert_eq!(
|
||||
after.posted_entries().count(),
|
||||
before.posted_entries().count()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_invoice_refuses_a_1200_that_is_not_receivables() {
|
||||
let mut l = books_with("1200", "Customer Deposits", AccountType::Liability, false);
|
||||
let c = l.add_customer("Acme").unwrap();
|
||||
let before = l.clone();
|
||||
let err = l
|
||||
.create_invoice(c, date(5), date(30), doc(100))
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
LedgerError::ControlAccountMismatch {
|
||||
code: "1200".into(),
|
||||
name: "Customer Deposits".into(),
|
||||
role: "accounts receivable".into(),
|
||||
expected: AccountType::Asset,
|
||||
found: AccountType::Liability,
|
||||
placeholder: false,
|
||||
}
|
||||
);
|
||||
// The reason names the account, what it is, and what was needed.
|
||||
let why = err.to_string();
|
||||
assert!(
|
||||
why.contains("1200") && why.contains("Customer Deposits"),
|
||||
"{why}"
|
||||
);
|
||||
assert!(why.contains("Liability") && why.contains("Asset"), "{why}");
|
||||
assert_untouched(&before, &l);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_invoice_refuses_a_placeholder_1200() {
|
||||
let mut l = books_with("1200", "Receivables", AccountType::Asset, true);
|
||||
let c = l.add_customer("Acme").unwrap();
|
||||
let before = l.clone();
|
||||
assert!(matches!(
|
||||
l.create_invoice(c, date(5), date(30), doc(100)),
|
||||
Err(LedgerError::ControlAccountMismatch {
|
||||
placeholder: true,
|
||||
..
|
||||
})
|
||||
));
|
||||
assert_untouched(&before, &l);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_invoice_creates_nothing_when_its_second_account_is_wrong() {
|
||||
// 1200 is missing (would be created) but 4000 is the wrong type: the
|
||||
// refusal must come before 1200 is added.
|
||||
let mut l = books_with("4000", "Owner Loans", AccountType::Liability, false);
|
||||
let c = l.add_customer("Acme").unwrap();
|
||||
let before = l.clone();
|
||||
assert!(matches!(
|
||||
l.create_invoice(c, date(5), date(30), doc(100)),
|
||||
Err(LedgerError::ControlAccountMismatch { .. })
|
||||
));
|
||||
assert!(l.chart().find_by_code("1200").is_none());
|
||||
assert_untouched(&before, &l);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bill_refuses_a_2000_that_is_not_payables() {
|
||||
let mut l = books_with("2000", "Savings", AccountType::Asset, false);
|
||||
let v = l.add_vendor("Vend").unwrap();
|
||||
let before = l.clone();
|
||||
assert!(matches!(
|
||||
l.create_bill(v, date(5), date(30), doc(100)),
|
||||
Err(LedgerError::ControlAccountMismatch {
|
||||
expected: AccountType::Liability,
|
||||
..
|
||||
})
|
||||
));
|
||||
assert_untouched(&before, &l);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_close_refuses_a_3999_that_is_not_equity() {
|
||||
let mut l = books_with("3999", "Suspense", AccountType::Asset, false);
|
||||
let sales = l
|
||||
.add_account("4000", "Sales", AccountType::Revenue, None, false)
|
||||
.unwrap();
|
||||
let e = l.create_entry(date(5), "sale");
|
||||
l.add_line(e, 1, Side::Debit, usd(100)).unwrap();
|
||||
l.add_line(e, sales, Side::Credit, usd(100)).unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
let jan = l.create_period("2026-01", date(1), date(31)).unwrap();
|
||||
let before = l.clone();
|
||||
assert!(matches!(
|
||||
l.close_period(jan),
|
||||
Err(LedgerError::ControlAccountMismatch {
|
||||
expected: AccountType::Equity,
|
||||
..
|
||||
})
|
||||
));
|
||||
assert_untouched(&before, &l);
|
||||
assert!(l.closed_period_containing(date(5)).is_none());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chart, periods, and lines: rules confirmed by Stephen, 2026-10-13.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A child account subdivides its parent, so it is the same kind of
|
||||
/// account. SOURCE: rules.md B-4; DECISION (Stephen, 2026-10-13).
|
||||
#[test]
|
||||
fn a_child_account_has_its_parents_type() {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let cash = l
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, true)
|
||||
.unwrap();
|
||||
l.add_account("1010", "Checking", AccountType::Asset, Some(cash), false)
|
||||
.unwrap();
|
||||
let before = l.chart().len();
|
||||
assert_eq!(
|
||||
l.add_account("1020", "Rent", AccountType::Expense, Some(cash), false),
|
||||
Err(LedgerError::ParentTypeMismatch {
|
||||
code: "1020".into(),
|
||||
parent_code: "1000".into(),
|
||||
expected: AccountType::Asset,
|
||||
found: AccountType::Expense,
|
||||
})
|
||||
);
|
||||
assert_eq!(l.chart().len(), before);
|
||||
assert!(l.chart().find_by_code("1020").is_none());
|
||||
}
|
||||
|
||||
/// Periods nest (a month in a quarter in a year) but never partially
|
||||
/// overlap. DECISION (Stephen, 2026-10-13).
|
||||
#[test]
|
||||
fn periods_nest_but_never_partially_overlap() {
|
||||
let d = |m: u32, day: u32| NaiveDate::from_ymd_opt(2026, m, day).unwrap();
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
|
||||
l.create_period("2026-02", d(2, 1), d(2, 28)).unwrap();
|
||||
// A quarter around both months, and a year around everything.
|
||||
l.create_period("Q1 2026", d(1, 1), d(3, 31)).unwrap();
|
||||
l.create_period("2026", d(1, 1), d(12, 31)).unwrap();
|
||||
// A week inside January nests too.
|
||||
l.create_period("week", d(1, 5), d(1, 11)).unwrap();
|
||||
let next = l.next_period_id();
|
||||
// Mid-January to mid-February straddles two months: refused.
|
||||
assert!(matches!(
|
||||
l.create_period("straddle", d(1, 15), d(2, 15)),
|
||||
Err(LedgerError::PeriodsOverlap { .. })
|
||||
));
|
||||
// So is a quarter that starts inside Q1.
|
||||
assert!(matches!(
|
||||
l.create_period("odd quarter", d(2, 1), d(4, 30)),
|
||||
Err(LedgerError::PeriodsOverlap { .. })
|
||||
));
|
||||
assert_eq!(l.next_period_id(), next, "refused means no id burned");
|
||||
// Disjoint is fine.
|
||||
l.create_period(
|
||||
"2027-01",
|
||||
NaiveDate::from_ymd_opt(2027, 1, 1).unwrap(),
|
||||
NaiveDate::from_ymd_opt(2027, 1, 31).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// A line names a real, postable account when it is added, not only when
|
||||
/// the entry posts. DECISION (Stephen, 2026-10-13); rules.md B-1.
|
||||
#[test]
|
||||
fn a_line_on_an_unknown_or_placeholder_account_is_refused_when_added() {
|
||||
let c = fixture_company();
|
||||
let mut l = c.ledger.clone();
|
||||
let header = l
|
||||
.add_account("1900", "Other assets", AccountType::Asset, None, true)
|
||||
.unwrap();
|
||||
let e = l.create_entry(date(21), "Lines");
|
||||
assert_eq!(
|
||||
l.add_line(e, 999_999, Side::Debit, usd(10)),
|
||||
Err(LedgerError::UnknownAccount(999_999))
|
||||
);
|
||||
assert_eq!(
|
||||
l.add_line(e, header, Side::Debit, usd(10)),
|
||||
Err(LedgerError::PlaceholderAccount("1900".into()))
|
||||
);
|
||||
assert!(matches!(
|
||||
l.add_line_fx(
|
||||
e,
|
||||
header,
|
||||
Side::Debit,
|
||||
Currency::EUR,
|
||||
Decimal::from(10),
|
||||
Decimal::from(2)
|
||||
),
|
||||
Err(LedgerError::PlaceholderAccount(_))
|
||||
));
|
||||
assert!(
|
||||
l.get_entry(e).unwrap().lines().is_empty(),
|
||||
"refused lines aren't added"
|
||||
);
|
||||
}
|
||||
|
||||
/// Spec (rules.md B-3; DECISION, Stephen 2026-10-13, after the GnuCash
|
||||
/// cross-check): a foreign line is booked in the base currency's smallest
|
||||
/// unit, half away from zero; the foreign original is kept.
|
||||
#[test]
|
||||
fn a_foreign_line_is_booked_in_whole_base_units() {
|
||||
let book = |base: Currency, foreign: Currency, amount: &str, rate: &str| {
|
||||
let mut l = Ledger::new(base);
|
||||
let a = l
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let e = l.create_entry(date(5), "fx");
|
||||
let r = l.add_line_fx(
|
||||
e,
|
||||
a,
|
||||
Side::Debit,
|
||||
foreign,
|
||||
amount.parse().unwrap(),
|
||||
rate.parse().unwrap(),
|
||||
);
|
||||
r.map(|()| {
|
||||
let line = l.get_entry(e).unwrap().lines()[0].clone();
|
||||
(line.amount.amount, line.foreign_amount)
|
||||
})
|
||||
};
|
||||
let dec = |s: &str| s.parse::<Decimal>().unwrap();
|
||||
// 100.00 EUR x 1.0834567 = 108.34567 USD -> 108.35.
|
||||
assert_eq!(
|
||||
book(Currency::USD, Currency::EUR, "100.00", "1.0834567").unwrap(),
|
||||
(dec("108.35"), Some(dec("100.00")))
|
||||
);
|
||||
// 10.00 USD x 151.237 = 1512.37 JPY -> 1512 (yen have no minor unit).
|
||||
assert_eq!(
|
||||
book(Currency::JPY, Currency::USD, "10.00", "151.237")
|
||||
.unwrap()
|
||||
.0,
|
||||
dec("1512")
|
||||
);
|
||||
// Exactly half a cent rounds away from zero.
|
||||
assert_eq!(
|
||||
book(Currency::USD, Currency::EUR, "1", "0.125").unwrap().0,
|
||||
dec("0.13")
|
||||
);
|
||||
// Rounds to nothing: refused.
|
||||
assert!(book(Currency::USD, Currency::EUR, "0.001", "1").is_err());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ledger bookkeeping surfaces: id allocators, FX table, tax percent,
|
||||
// reconciliation, and closing variants (mutation-testing gaps, 2026-10-13).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn cents(c: i64) -> Decimal {
|
||||
Decimal::new(c, 2)
|
||||
}
|
||||
|
||||
/// `next_*_id` predicts the id the next record receives, and each record
|
||||
/// gets its own.
|
||||
#[test]
|
||||
fn id_allocators_predict_the_next_id_and_never_repeat() {
|
||||
use ledger_core::{Frequency, NewRecurring, NewSchedule, ScheduleKind};
|
||||
let c = fixture_company();
|
||||
let mut l = c.ledger;
|
||||
let schedule = |n: &str| NewSchedule {
|
||||
description: n.into(),
|
||||
kind: ScheduleKind::PrepaidExpense,
|
||||
total: usd(1_200),
|
||||
start: date(1),
|
||||
months: 12,
|
||||
balance_account: c.equipment,
|
||||
pl_account: c.rent,
|
||||
};
|
||||
let mut seen = Vec::new();
|
||||
for n in ["a", "b"] {
|
||||
let predicted = l.next_schedule_id();
|
||||
let id = l.register_schedule(schedule(n)).unwrap();
|
||||
assert_eq!(id, predicted);
|
||||
assert!(!seen.contains(&id));
|
||||
seen.push(id);
|
||||
}
|
||||
let mut listed: Vec<_> = l.schedules().map(|s| s.id).collect();
|
||||
listed.sort();
|
||||
assert_eq!(listed, seen);
|
||||
|
||||
let recurring = || NewRecurring {
|
||||
description: "rent".into(),
|
||||
lines: vec![
|
||||
(c.rent, Side::Debit, usd(800)),
|
||||
(c.cash, Side::Credit, usd(800)),
|
||||
],
|
||||
frequency: Frequency::Monthly,
|
||||
start: date(1),
|
||||
end: None,
|
||||
};
|
||||
let mut seen = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let predicted = l.next_recurring_id();
|
||||
let id = l.register_recurring(recurring()).unwrap();
|
||||
assert_eq!(id, predicted);
|
||||
assert!(!seen.contains(&id));
|
||||
seen.push(id);
|
||||
}
|
||||
|
||||
let v = l.add_vendor("Vend").unwrap();
|
||||
for _ in 0..2 {
|
||||
let predicted = l.next_bill_id();
|
||||
let id = l.create_bill(v, date(5), date(30), doc(100)).unwrap();
|
||||
assert_eq!(id, predicted);
|
||||
assert_ne!(l.next_bill_id(), id);
|
||||
}
|
||||
}
|
||||
|
||||
/// The FX table records positive rates for foreign currencies only, and
|
||||
/// gives back exactly what was recorded.
|
||||
#[test]
|
||||
fn fx_rates_are_positive_foreign_and_read_back_exactly() {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let rate: Decimal = "1.0834".parse().unwrap();
|
||||
assert_eq!(l.get_fx_rate(Currency::EUR, date(5)), None);
|
||||
assert_eq!(
|
||||
l.set_fx_rate(Currency::EUR, date(5), Decimal::ZERO),
|
||||
Err(LedgerError::InvalidFxRate(Decimal::ZERO))
|
||||
);
|
||||
assert!(l.set_fx_rate(Currency::EUR, date(5), -rate).is_err());
|
||||
assert!(l.set_fx_rate(Currency::USD, date(5), rate).is_err());
|
||||
assert_eq!(l.get_fx_rate(Currency::USD, date(5)), None);
|
||||
l.set_fx_rate(Currency::EUR, date(5), rate).unwrap();
|
||||
assert_eq!(l.get_fx_rate(Currency::EUR, date(5)), Some(rate));
|
||||
assert_eq!(l.get_fx_rate(Currency::EUR, date(6)), None);
|
||||
}
|
||||
|
||||
/// A deductible percent is 0 to 100 inclusive (set_account_tax's spec);
|
||||
/// 0 is real (penalties).
|
||||
#[test]
|
||||
fn deductible_percent_bounds_are_inclusive() {
|
||||
let c = fixture_company();
|
||||
let mut l = c.ledger;
|
||||
let rent = c.rent;
|
||||
l.set_account_tax(rent, Some("meals"), Some(Decimal::ZERO))
|
||||
.unwrap();
|
||||
l.set_account_tax(rent, Some("meals"), Some(Decimal::from(100)))
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
l.set_account_tax(rent, Some("meals"), Some(cents(-1))),
|
||||
Err(LedgerError::InvalidTaxCategory(_))
|
||||
));
|
||||
}
|
||||
|
||||
/// Spec (pay_bill scenario 2): a payment may not predate its bill; the
|
||||
/// bill's own date is fine.
|
||||
#[test]
|
||||
fn a_bill_can_be_paid_on_its_own_date() {
|
||||
let c = fixture_company();
|
||||
let mut l = c.ledger;
|
||||
let v = l.add_vendor("Vend").unwrap();
|
||||
let bill = l.create_bill(v, date(21), date(31), doc(100)).unwrap();
|
||||
l.pay_bill(bill, date(21), cents(1_000), c.cash).unwrap();
|
||||
}
|
||||
|
||||
/// Reconciliation invariants 1 and 4: on a credit-normal account the book
|
||||
/// balance is the account's balance, and a line dated on the statement
|
||||
/// date is outstanding until cleared.
|
||||
#[test]
|
||||
fn reconciling_a_card_reads_its_normal_side_and_includes_the_statement_date() {
|
||||
let c = fixture_company();
|
||||
let mut l = c.ledger;
|
||||
let card = l
|
||||
.add_account("2100", "Card", AccountType::Liability, None, false)
|
||||
.unwrap();
|
||||
post(
|
||||
&mut l,
|
||||
date(21),
|
||||
"charge",
|
||||
&[(c.rent, Side::Debit, 500), (card, Side::Credit, 500)],
|
||||
);
|
||||
post(
|
||||
&mut l,
|
||||
date(25),
|
||||
"pay card",
|
||||
&[(card, Side::Debit, 200), (c.cash, Side::Credit, 200)],
|
||||
);
|
||||
let r = l
|
||||
.reconciliation_report(card, date(25), Decimal::from(300))
|
||||
.unwrap();
|
||||
assert_eq!(r.book_balance, Decimal::from(300));
|
||||
assert_eq!(r.book_balance, l.account_balance(card).unwrap());
|
||||
let outstanding: Vec<_> = r.uncleared_lines.iter().map(|u| u.date).collect();
|
||||
assert_eq!(outstanding, vec![date(21), date(25)]);
|
||||
}
|
||||
|
||||
/// B-11: closing to retained earnings with nothing to close but a swept
|
||||
/// account's balance still sweeps it.
|
||||
#[test]
|
||||
fn a_close_sweeps_dividends_even_without_income() {
|
||||
let mut l = books_with("3100", "Dividends", AccountType::Equity, false);
|
||||
let dividends = 2;
|
||||
post(
|
||||
&mut l,
|
||||
date(10),
|
||||
"dividend",
|
||||
&[(dividends, Side::Debit, 100), (1, Side::Credit, 100)],
|
||||
);
|
||||
let jan = l.create_period("2026-01", date(1), date(31)).unwrap();
|
||||
let to = ledger_core::CloseTo::RetainedEarnings {
|
||||
sweep: vec![dividends],
|
||||
};
|
||||
let predicted = l.preview_close_period_to(jan, &to).unwrap();
|
||||
assert_eq!(predicted, Some(l.next_entry_id()));
|
||||
assert_eq!(l.close_period_to(jan, &to).unwrap(), predicted);
|
||||
assert_eq!(l.account_balance(dividends).unwrap(), Decimal::ZERO);
|
||||
let re = l.chart().find_by_code("3999").unwrap().id;
|
||||
assert_eq!(l.account_balance(re).unwrap(), Decimal::from(-100));
|
||||
}
|
||||
|
||||
/// `preview_close_period` names the entry a close with income will post.
|
||||
#[test]
|
||||
fn preview_close_names_the_closing_entry() {
|
||||
let mut c = fixture_company();
|
||||
let jan = c
|
||||
.ledger
|
||||
.create_period("2026-01", date(1), date(31))
|
||||
.unwrap();
|
||||
let next = c.ledger.next_entry_id();
|
||||
assert_eq!(c.ledger.preview_close_period(jan).unwrap(), Some(next));
|
||||
assert_eq!(c.ledger.close_period(jan).unwrap(), Some(next));
|
||||
}
|
||||
|
||||
/// Closes journaled under the pre-2026-10-13 rule replay under it: each
|
||||
/// share rounded half away from zero, the last member absorbing the rest.
|
||||
/// Net 1.05 at 30/30/40 is 0.32, 0.32, 0.41 (largest remainder would give
|
||||
/// 0.32, 0.31, 0.42).
|
||||
#[test]
|
||||
fn a_last_absorbs_close_gives_the_rounding_to_the_last_member() {
|
||||
let mut l = books_with("4000", "Sales", AccountType::Revenue, false);
|
||||
let capital: Vec<AccountId> = ["3001", "3002", "3003"]
|
||||
.iter()
|
||||
.map(|code| {
|
||||
l.add_account(code, "Capital", AccountType::Equity, None, false)
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
let e = l.create_entry(date(5), "sale");
|
||||
let m = Money::new(cents(105), Currency::USD);
|
||||
l.add_line(e, 1, Side::Debit, m).unwrap();
|
||||
l.add_line(e, 2, Side::Credit, m).unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
let jan = l.create_period("2026-01", date(1), date(31)).unwrap();
|
||||
let members = capital
|
||||
.iter()
|
||||
.zip(["0.3", "0.3", "0.4"])
|
||||
.map(|(&capital, share)| ledger_core::MemberClose {
|
||||
capital,
|
||||
share: share.parse().unwrap(),
|
||||
distributions: None,
|
||||
})
|
||||
.collect();
|
||||
l.close_period_to(jan, &ledger_core::CloseTo::MembersLastAbsorbs(members))
|
||||
.unwrap();
|
||||
let got: Vec<Decimal> = capital
|
||||
.iter()
|
||||
.map(|&a| l.account_balance(a).unwrap())
|
||||
.collect();
|
||||
assert_eq!(got, vec![cents(32), cents(32), cents(41)]);
|
||||
}
|
||||
|
||||
/// B-12: a period whose closing date already lies in a closed period
|
||||
/// can't close; the refusal names that period.
|
||||
#[test]
|
||||
fn a_period_ending_inside_a_closed_period_cannot_close() {
|
||||
let mut c = fixture_company();
|
||||
let q1 = c
|
||||
.ledger
|
||||
.create_period(
|
||||
"2026-Q1",
|
||||
date(1),
|
||||
NaiveDate::from_ymd_opt(2026, 3, 31).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let jan = c
|
||||
.ledger
|
||||
.create_period("2026-01", date(1), date(31))
|
||||
.unwrap();
|
||||
c.ledger.close_period(q1).unwrap();
|
||||
assert_eq!(
|
||||
c.ledger.close_period(jan),
|
||||
Err(LedgerError::ClosedPeriod(q1, date(31).to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
/// Replay (for journaling stores) rebuilds invoice and bill records
|
||||
/// exactly: ids follow the allocator, payments accumulate, vendors tag
|
||||
/// the entries. A record replayed out of order is refused.
|
||||
#[test]
|
||||
fn replayed_documents_and_payments_rebuild_the_records() {
|
||||
use ledger_core::DocumentStatus;
|
||||
let mut l = books_with("2000", "Accounts Payable", AccountType::Liability, false);
|
||||
let c = l.add_customer("Acme").unwrap();
|
||||
let v = l.add_vendor("Vend").unwrap();
|
||||
|
||||
let inv = l.next_invoice_id();
|
||||
assert_eq!(
|
||||
l.replay_invoice(inv + 1, c, date(5), date(30), doc(100), 10),
|
||||
Err(LedgerError::ReplayIdMismatch(inv + 1, inv))
|
||||
);
|
||||
l.replay_invoice(inv, c, date(5), date(30), doc(100), 10)
|
||||
.unwrap();
|
||||
// Each later record replays at the id the allocator now predicts.
|
||||
let mut ids = vec![inv];
|
||||
for entry in [13, 14] {
|
||||
let next = l.next_invoice_id();
|
||||
l.replay_invoice(next, c, date(5), date(30), doc(1), entry)
|
||||
.unwrap();
|
||||
ids.push(next);
|
||||
}
|
||||
ids.sort();
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), 3);
|
||||
assert!(ids.iter().all(|&i| l.get_invoice(i).is_some()));
|
||||
l.replay_invoice_payment(inv, Decimal::from(30), 11)
|
||||
.unwrap();
|
||||
l.replay_invoice_payment(inv, Decimal::from(20), 12)
|
||||
.unwrap();
|
||||
let i = l.get_invoice(inv).unwrap();
|
||||
assert_eq!(i.amount_paid, Decimal::from(50));
|
||||
assert_eq!(i.balance().unwrap(), Decimal::from(50));
|
||||
assert_eq!(i.status, DocumentStatus::Partial);
|
||||
|
||||
let bill = l.next_bill_id();
|
||||
assert_eq!(
|
||||
l.replay_bill(bill + 1, v, date(5), date(30), doc(100), 20),
|
||||
Err(LedgerError::ReplayIdMismatch(bill + 1, bill))
|
||||
);
|
||||
assert_eq!(l.entry_vendor(20), None);
|
||||
l.replay_bill(bill, v, date(5), date(30), doc(100), 20)
|
||||
.unwrap();
|
||||
assert_eq!(l.entry_vendor(20), Some(v));
|
||||
let mut ids = vec![bill];
|
||||
for entry in [23, 24] {
|
||||
let next = l.next_bill_id();
|
||||
l.replay_bill(next, v, date(5), date(30), doc(1), entry)
|
||||
.unwrap();
|
||||
ids.push(next);
|
||||
}
|
||||
ids.sort();
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), 3);
|
||||
l.replay_bill_payment(bill, Decimal::from(30), 21).unwrap();
|
||||
l.replay_bill_payment(bill, Decimal::from(70), 22).unwrap();
|
||||
let b = l.get_bill(bill).unwrap();
|
||||
assert_eq!(b.amount_paid, Decimal::from(100));
|
||||
assert_eq!(b.status, DocumentStatus::Paid);
|
||||
assert_eq!(l.entry_vendor(22), Some(v));
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
//! The bridge between the proofs and the engine.
|
||||
//!
|
||||
//! The Kani harnesses (`src/proofs.rs`) prove the accounting kernel on
|
||||
//! `i64` money in the smallest unit; the engine runs the same kernel on
|
||||
//! `Decimal`. These properties check the two instantiations agree, value
|
||||
//! for value, on cents: whatever Kani proved of the `i64` kernel holds of
|
||||
//! the `Decimal` one for the same amounts.
|
||||
|
||||
use ledger_core::kernel::{self, Amount, LineError};
|
||||
use ledger_core::{AccountType, Side};
|
||||
use proptest::prelude::*;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
/// Cents within both instantiations' line range.
|
||||
const MAX_CENTS: i64 = 1 << 40;
|
||||
|
||||
fn dec(cents: i64) -> Decimal {
|
||||
Decimal::new(cents, 2)
|
||||
}
|
||||
|
||||
fn arb_side() -> impl Strategy<Value = Side> {
|
||||
prop_oneof![Just(Side::Debit), Just(Side::Credit)]
|
||||
}
|
||||
|
||||
fn arb_type() -> impl Strategy<Value = AccountType> {
|
||||
prop_oneof![
|
||||
Just(AccountType::Asset),
|
||||
Just(AccountType::Liability),
|
||||
Just(AccountType::Equity),
|
||||
Just(AccountType::Revenue),
|
||||
Just(AccountType::Expense),
|
||||
]
|
||||
}
|
||||
|
||||
fn arb_cents() -> impl Strategy<Value = i64> {
|
||||
prop_oneof![
|
||||
-MAX_CENTS..=MAX_CENTS,
|
||||
// Small amounts collide, so balanced entries and zeros show up.
|
||||
-3i64..=3,
|
||||
]
|
||||
}
|
||||
|
||||
/// Lines over four accounts. Balanced entries are rare at random, so half
|
||||
/// the time the last line plugs the difference.
|
||||
fn arb_lines() -> impl Strategy<Value = Vec<(u8, Side, i64)>> {
|
||||
(
|
||||
prop::collection::vec((0u8..4, arb_side(), arb_cents()), 0..7),
|
||||
any::<bool>(),
|
||||
)
|
||||
.prop_map(|(mut lines, plug)| {
|
||||
if plug && !lines.is_empty() {
|
||||
let net: i64 = lines[..lines.len() - 1]
|
||||
.iter()
|
||||
.map(|&(_, s, a)| if s == Side::Debit { a } else { -a })
|
||||
.sum();
|
||||
let last = lines.len() - 1;
|
||||
if net.abs() > MAX_CENTS {
|
||||
// Out of both ranges, where the two differ by design.
|
||||
return lines;
|
||||
}
|
||||
lines[last].1 = if net > 0 { Side::Credit } else { Side::Debit };
|
||||
lines[last].2 = net.abs();
|
||||
}
|
||||
lines
|
||||
})
|
||||
}
|
||||
|
||||
fn to_dec(lines: &[(u8, Side, i64)]) -> Vec<(u8, Side, Decimal)> {
|
||||
lines.iter().map(|&(k, s, a)| (k, s, dec(a))).collect()
|
||||
}
|
||||
|
||||
fn sides<T: Copy>(lines: &[(u8, Side, T)]) -> Vec<(Side, T)> {
|
||||
lines.iter().map(|&(_, s, a)| (s, a)).collect()
|
||||
}
|
||||
|
||||
fn map_err(e: LineError<i64>) -> LineError<Decimal> {
|
||||
match e {
|
||||
LineError::TooFewLines => LineError::TooFewLines,
|
||||
LineError::ZeroAmount => LineError::ZeroAmount,
|
||||
LineError::NegativeAmount => LineError::NegativeAmount,
|
||||
LineError::OutOfRange => LineError::OutOfRange,
|
||||
LineError::Unbalanced { debits, credits } => LineError::Unbalanced {
|
||||
debits: dec(debits),
|
||||
credits: dec(credits),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(2000))]
|
||||
|
||||
#[test]
|
||||
fn check_lines_agrees(lines in arb_lines()) {
|
||||
let i = kernel::check_lines(&sides(&lines)).map_err(map_err);
|
||||
let d = kernel::check_lines(&sides(&to_dec(&lines)));
|
||||
prop_assert_eq!(i, d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sums_agree(lines in arb_lines()) {
|
||||
let (i, d) = (sides(&lines), sides(&to_dec(&lines)));
|
||||
let ti = kernel::totals(i.iter().copied()).unwrap();
|
||||
let td = kernel::totals(d.iter().copied()).unwrap();
|
||||
prop_assert_eq!((dec(ti.0), dec(ti.1)), td);
|
||||
prop_assert_eq!(
|
||||
kernel::net(i.iter().copied()).map(dec),
|
||||
kernel::net(d.iter().copied())
|
||||
);
|
||||
let mi: Vec<_> = kernel::movements(lines.iter().copied())
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|(k, m)| (k, dec(m)))
|
||||
.collect();
|
||||
prop_assert_eq!(Some(mi), kernel::movements(to_dec(&lines)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reversal_agrees(lines in arb_lines()) {
|
||||
let ri: Vec<_> = kernel::reversal(&lines)
|
||||
.into_iter()
|
||||
.map(|(k, s, a)| (k, s, dec(a)))
|
||||
.collect();
|
||||
prop_assert_eq!(ri, kernel::reversal(&to_dec(&lines)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn balances_agree(t in arb_type(), m in arb_cents()) {
|
||||
prop_assert_eq!(kernel::normal_balance(t, m).map(dec), kernel::normal_balance(t, dec(m)));
|
||||
let ci = kernel::columns(m).unwrap();
|
||||
prop_assert_eq!(Some((dec(ci.0), dec(ci.1))), kernel::columns(dec(m)));
|
||||
prop_assert_eq!(
|
||||
kernel::closing_line(m).map(|(s, a)| (s, dec(a))),
|
||||
kernel::closing_line(dec(m))
|
||||
);
|
||||
prop_assert_eq!(
|
||||
kernel::credit_line(m).map(|(s, a)| (s, dec(a))),
|
||||
kernel::credit_line(dec(m))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plug_last_agrees(whole in arb_cents(), parts in prop::collection::vec(arb_cents(), 0..5)) {
|
||||
let mut pi = parts.clone();
|
||||
let mut pd: Vec<Decimal> = parts.iter().copied().map(dec).collect();
|
||||
kernel::plug_last(whole, &mut pi).unwrap();
|
||||
kernel::plug_last(dec(whole), &mut pd).unwrap();
|
||||
prop_assert_eq!(pi.into_iter().map(dec).collect::<Vec<_>>(), pd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apportion_agrees(
|
||||
parts in prop::collection::vec(arb_cents(), 0..5),
|
||||
rems in prop::collection::vec(0i64..100, 5),
|
||||
short in 0i64..500,
|
||||
) {
|
||||
let n = parts.len();
|
||||
let whole: i64 = parts.iter().sum::<i64>() + short;
|
||||
let mut pi = parts.clone();
|
||||
let mut pd: Vec<Decimal> = parts.iter().copied().map(dec).collect();
|
||||
let ri = &rems[..n];
|
||||
let rd: Vec<Decimal> = ri.iter().copied().map(dec).collect();
|
||||
let oi = kernel::apportion(whole, &mut pi, ri, 100);
|
||||
let od = kernel::apportion(dec(whole), &mut pd, &rd, dec(100));
|
||||
prop_assert_eq!(oi.is_some(), od.is_some());
|
||||
if oi.is_some() {
|
||||
prop_assert_eq!(pi.into_iter().map(dec).collect::<Vec<_>>(), pd);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_range_agrees(cents in arb_cents()) {
|
||||
prop_assert_eq!(cents.in_line_range(), dec(cents).in_line_range());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
//! Model-based stateful test (rank 3: a peer).
|
||||
//!
|
||||
//! The peer is the Lean state machine (verify/lean/Wanderbooks/Ledger.lean)
|
||||
//! transcribed into the simplest Rust that has the same observable
|
||||
//! behavior: a list of accounts, a list of drafts, an append-only list of
|
||||
//! posted entries, a list of periods, and loops. It is written from the
|
||||
//! spec (rules.md, the Ledger doc comments), not from ledger.rs.
|
||||
//!
|
||||
//! Random command sequences, weighted toward commands that interact (post
|
||||
//! a draft, reverse a posted entry, close a period with drafts in it,
|
||||
//! reverse a closing entry, reverse twice), run against both. After every
|
||||
//! command: the same accept/refuse decision, and the same posted log,
|
||||
//! chart, drafts, periods, and every account's balance.
|
||||
//!
|
||||
//! `MODEL_STATS=1 cargo test --test model -- --nocapture` prints every
|
||||
//! accepted command: check that posts, reversals, and closes really
|
||||
//! happen. (The first version never posted anything, so it agreed with the
|
||||
//! model vacuously; the balanced `Pair` command fixed that.) Shown to have
|
||||
//! teeth: letting the model reverse an entry twice fails in 0.03s.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::reports::trial_balance;
|
||||
use ledger_core::{AccountType, Currency, EntryStatus, Ledger, Money, Side};
|
||||
use proptest::prelude::*;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct MLine {
|
||||
account: u64,
|
||||
side: Side,
|
||||
amount: Decimal,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct MEntry {
|
||||
id: u64,
|
||||
date: NaiveDate,
|
||||
lines: Vec<MLine>,
|
||||
reversal_of: Option<u64>,
|
||||
closes: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct MPeriod {
|
||||
start: NaiveDate,
|
||||
end: NaiveDate,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct Model {
|
||||
/// (id, code, type, placeholder)
|
||||
accounts: Vec<(u64, String, AccountType, bool)>,
|
||||
drafts: Vec<MEntry>,
|
||||
posted: Vec<MEntry>,
|
||||
periods: Vec<MPeriod>,
|
||||
next_account: u64,
|
||||
next_entry: u64,
|
||||
}
|
||||
|
||||
fn signed(l: &MLine) -> Decimal {
|
||||
if l.side == Side::Debit {
|
||||
l.amount
|
||||
} else {
|
||||
-l.amount
|
||||
}
|
||||
}
|
||||
|
||||
impl Model {
|
||||
fn new() -> Self {
|
||||
Model {
|
||||
next_account: 1,
|
||||
next_entry: 1,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn account(&self, id: u64) -> Option<&(u64, String, AccountType, bool)> {
|
||||
self.accounts.iter().find(|a| a.0 == id)
|
||||
}
|
||||
|
||||
fn closed_on(&self, d: NaiveDate) -> bool {
|
||||
self.periods
|
||||
.iter()
|
||||
.any(|p| p.closed && p.start <= d && d <= p.end)
|
||||
}
|
||||
|
||||
/// Debits minus credits on an account, over posted entries (through
|
||||
/// `end` if given).
|
||||
fn movement(&self, account: u64, end: Option<NaiveDate>) -> Decimal {
|
||||
self.posted
|
||||
.iter()
|
||||
.filter(|e| end.is_none_or(|end| e.date <= end))
|
||||
.flat_map(|e| &e.lines)
|
||||
.filter(|l| l.account == account)
|
||||
.map(signed)
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn add_account(&mut self, code: &str, t: AccountType, placeholder: bool) -> bool {
|
||||
if code.is_empty() || self.accounts.iter().any(|a| a.1 == code) {
|
||||
return false;
|
||||
}
|
||||
self.accounts
|
||||
.push((self.next_account, code.into(), t, placeholder));
|
||||
self.next_account += 1;
|
||||
true
|
||||
}
|
||||
|
||||
fn draft(&mut self, date: NaiveDate) -> Option<u64> {
|
||||
if self.closed_on(date) {
|
||||
return None;
|
||||
}
|
||||
let id = self.next_entry;
|
||||
self.next_entry += 1;
|
||||
self.drafts.push(MEntry {
|
||||
id,
|
||||
date,
|
||||
lines: vec![],
|
||||
reversal_of: None,
|
||||
closes: false,
|
||||
});
|
||||
Some(id)
|
||||
}
|
||||
|
||||
fn add_line(&mut self, entry: u64, l: MLine) -> bool {
|
||||
let postable = self.account(l.account).is_some_and(|a| !a.3);
|
||||
let Some(d) = self.drafts.iter_mut().find(|d| d.id == entry) else {
|
||||
return false;
|
||||
};
|
||||
if !postable {
|
||||
return false;
|
||||
}
|
||||
d.lines.push(l);
|
||||
true
|
||||
}
|
||||
|
||||
fn acceptable(&self, lines: &[MLine]) -> bool {
|
||||
let debits: Decimal = lines
|
||||
.iter()
|
||||
.filter(|l| l.side == Side::Debit)
|
||||
.map(|l| l.amount)
|
||||
.sum();
|
||||
let credits: Decimal = lines
|
||||
.iter()
|
||||
.filter(|l| l.side == Side::Credit)
|
||||
.map(|l| l.amount)
|
||||
.sum();
|
||||
lines.len() >= 2
|
||||
&& lines.iter().all(|l| l.amount > Decimal::ZERO)
|
||||
&& lines
|
||||
.iter()
|
||||
.all(|l| self.account(l.account).is_some_and(|a| !a.3))
|
||||
&& debits == credits
|
||||
}
|
||||
|
||||
fn post(&mut self, entry: u64) -> bool {
|
||||
let Some(i) = self.drafts.iter().position(|d| d.id == entry) else {
|
||||
return false;
|
||||
};
|
||||
let d = &self.drafts[i];
|
||||
if self.closed_on(d.date) || !self.acceptable(&d.lines) {
|
||||
return false;
|
||||
}
|
||||
let d = self.drafts.remove(i);
|
||||
self.posted.push(d);
|
||||
true
|
||||
}
|
||||
|
||||
fn reverse(&mut self, entry: u64, date: NaiveDate) -> bool {
|
||||
let Some(orig) = self.posted.iter().find(|e| e.id == entry).cloned() else {
|
||||
return false;
|
||||
};
|
||||
let reversed_already = self.posted.iter().any(|e| e.reversal_of == Some(entry));
|
||||
if reversed_already || orig.closes || self.closed_on(date) {
|
||||
return false;
|
||||
}
|
||||
let lines = orig
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| MLine {
|
||||
side: if l.side == Side::Debit {
|
||||
Side::Credit
|
||||
} else {
|
||||
Side::Debit
|
||||
},
|
||||
..l.clone()
|
||||
})
|
||||
.collect();
|
||||
self.posted.push(MEntry {
|
||||
id: self.next_entry,
|
||||
date,
|
||||
lines,
|
||||
reversal_of: Some(entry),
|
||||
closes: false,
|
||||
});
|
||||
self.next_entry += 1;
|
||||
true
|
||||
}
|
||||
|
||||
fn create_period(&mut self, start: NaiveDate, end: NaiveDate) -> bool {
|
||||
if end < start {
|
||||
return false;
|
||||
}
|
||||
let nests =
|
||||
|p: &MPeriod| (start >= p.start && end <= p.end) || (p.start >= start && p.end <= end);
|
||||
if self
|
||||
.periods
|
||||
.iter()
|
||||
.any(|p| start <= p.end && p.start <= end && !nests(p))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.periods.push(MPeriod {
|
||||
start,
|
||||
end,
|
||||
closed: false,
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// Close into retained earnings (3999).
|
||||
fn close(&mut self, index: usize) -> bool {
|
||||
let Some(p) = self.periods.get(index).cloned() else {
|
||||
return false;
|
||||
};
|
||||
if p.closed
|
||||
|| self
|
||||
.drafts
|
||||
.iter()
|
||||
.any(|d| p.start <= d.date && d.date <= p.end)
|
||||
|| self.periods.iter().any(|q| q.closed && q.end > p.end)
|
||||
|| self
|
||||
.periods
|
||||
.iter()
|
||||
.any(|q| q.closed && q.start <= p.end && p.end <= q.end)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// Retained earnings must be a postable equity account if it exists.
|
||||
if let Some(re) = self.accounts.iter().find(|a| a.1 == "3999")
|
||||
&& (re.2 != AccountType::Equity || re.3)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut lines = Vec::new();
|
||||
let nominal: Vec<u64> = {
|
||||
let mut v: Vec<_> = self
|
||||
.accounts
|
||||
.iter()
|
||||
.filter(|a| matches!(a.2, AccountType::Revenue | AccountType::Expense))
|
||||
.collect();
|
||||
v.sort_by(|a, b| a.1.cmp(&b.1));
|
||||
v.iter().map(|a| a.0).collect()
|
||||
};
|
||||
for a in nominal {
|
||||
let m = self.movement(a, Some(p.end));
|
||||
if m > Decimal::ZERO {
|
||||
lines.push(MLine {
|
||||
account: a,
|
||||
side: Side::Credit,
|
||||
amount: m,
|
||||
});
|
||||
} else if m < Decimal::ZERO {
|
||||
lines.push(MLine {
|
||||
account: a,
|
||||
side: Side::Debit,
|
||||
amount: -m,
|
||||
});
|
||||
}
|
||||
}
|
||||
if !lines.is_empty() {
|
||||
let net: Decimal = lines.iter().map(signed).sum();
|
||||
let re = match self.accounts.iter().find(|a| a.1 == "3999") {
|
||||
Some(a) => a.0,
|
||||
None => {
|
||||
self.add_account("3999", AccountType::Equity, false);
|
||||
self.next_account - 1
|
||||
}
|
||||
};
|
||||
if net > Decimal::ZERO {
|
||||
lines.push(MLine {
|
||||
account: re,
|
||||
side: Side::Credit,
|
||||
amount: net,
|
||||
});
|
||||
} else if net < Decimal::ZERO {
|
||||
lines.push(MLine {
|
||||
account: re,
|
||||
side: Side::Debit,
|
||||
amount: -net,
|
||||
});
|
||||
}
|
||||
self.posted.push(MEntry {
|
||||
id: self.next_entry,
|
||||
date: p.end,
|
||||
lines,
|
||||
reversal_of: None,
|
||||
closes: true,
|
||||
});
|
||||
self.next_entry += 1;
|
||||
}
|
||||
self.periods[index].closed = true;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CODES: [&str; 7] = ["1000", "2000", "3000", "3999", "4000", "6000", "1900"];
|
||||
const TYPES: [AccountType; 5] = [
|
||||
AccountType::Asset,
|
||||
AccountType::Liability,
|
||||
AccountType::Equity,
|
||||
AccountType::Revenue,
|
||||
AccountType::Expense,
|
||||
];
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Cmd {
|
||||
AddAccount(usize, usize, bool),
|
||||
Draft(u32),
|
||||
AddLine(usize, usize, bool, i64),
|
||||
/// A balanced pair: debit one account, credit another, same amount.
|
||||
Pair(usize, usize, usize, i64),
|
||||
Post(usize),
|
||||
Reverse(usize, u32),
|
||||
CreatePeriod(u32, u32),
|
||||
Close(usize),
|
||||
}
|
||||
|
||||
fn day(n: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, 1, 1).unwrap() + chrono::Days::new(u64::from(n % 180))
|
||||
}
|
||||
|
||||
fn cmd() -> impl Strategy<Value = Cmd> {
|
||||
prop_oneof![
|
||||
2 => (0..CODES.len(), 0..TYPES.len(), prop::bool::weighted(0.1))
|
||||
.prop_map(|(c, t, h)| Cmd::AddAccount(c, t, h)),
|
||||
3 => (0u32..180).prop_map(Cmd::Draft),
|
||||
6 => (0usize..8, 0usize..8, any::<bool>(),
|
||||
prop_oneof![8 => 1i64..100_000, 1 => Just(0i64), 1 => -500i64..0])
|
||||
.prop_map(|(e, a, s, c)| Cmd::AddLine(e, a, s, c)),
|
||||
6 => (0usize..8, 0usize..8, 0usize..8, 1i64..100_000)
|
||||
.prop_map(|(e, a, b, c)| Cmd::Pair(e, a, b, c)),
|
||||
5 => (0usize..8).prop_map(Cmd::Post),
|
||||
2 => (0usize..8, 0u32..180).prop_map(|(e, d)| Cmd::Reverse(e, d)),
|
||||
2 => (0u32..180, 0u32..60).prop_map(|(s, len)| Cmd::CreatePeriod(s, s + len)),
|
||||
2 => (0usize..4).prop_map(Cmd::Close),
|
||||
]
|
||||
}
|
||||
|
||||
/// Apply one command to both and return (engine accepted, model accepted).
|
||||
/// Indices pick among existing drafts / posted entries / accounts / periods,
|
||||
/// so most commands hit something real.
|
||||
fn apply(l: &mut Ledger, m: &mut Model, c: &Cmd) -> (bool, bool) {
|
||||
let pick = |v: &[u64], i: usize| v.get(i % v.len().max(1)).copied().unwrap_or(999);
|
||||
let accounts: Vec<u64> = m.accounts.iter().map(|a| a.0).collect();
|
||||
let drafts: Vec<u64> = m.drafts.iter().map(|d| d.id).collect();
|
||||
let posted: Vec<u64> = m.posted.iter().map(|e| e.id).collect();
|
||||
match *c {
|
||||
Cmd::AddAccount(code, t, h) => (
|
||||
l.add_account(CODES[code], CODES[code], TYPES[t], None, h)
|
||||
.is_ok(),
|
||||
m.add_account(CODES[code], TYPES[t], h),
|
||||
),
|
||||
Cmd::Draft(d) => {
|
||||
let got = l.try_create_entry(day(d), "x").ok();
|
||||
let want = m.draft(day(d));
|
||||
assert_eq!(got, want, "draft ids");
|
||||
(got.is_some(), want.is_some())
|
||||
}
|
||||
Cmd::AddLine(e, a, debit, cents) => {
|
||||
let (e, a) = (pick(&drafts, e), pick(&accounts, a));
|
||||
let side = if debit { Side::Debit } else { Side::Credit };
|
||||
let amount = Decimal::new(cents, 2);
|
||||
(
|
||||
l.add_line(e, a, side, Money::new(amount, Currency::USD))
|
||||
.is_ok(),
|
||||
m.add_line(
|
||||
e,
|
||||
MLine {
|
||||
account: a,
|
||||
side,
|
||||
amount,
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
Cmd::Pair(e, a, b, cents) => {
|
||||
let (e, a, b) = (pick(&drafts, e), pick(&accounts, a), pick(&accounts, b));
|
||||
let amount = Decimal::new(cents, 2);
|
||||
let money = Money::new(amount, Currency::USD);
|
||||
let got = l.add_line(e, a, Side::Debit, money).is_ok()
|
||||
&& l.add_line(e, b, Side::Credit, money).is_ok();
|
||||
let want = m.add_line(
|
||||
e,
|
||||
MLine {
|
||||
account: a,
|
||||
side: Side::Debit,
|
||||
amount,
|
||||
},
|
||||
) && m.add_line(
|
||||
e,
|
||||
MLine {
|
||||
account: b,
|
||||
side: Side::Credit,
|
||||
amount,
|
||||
},
|
||||
);
|
||||
(got, want)
|
||||
}
|
||||
Cmd::Post(e) => {
|
||||
let e = pick(&drafts, e);
|
||||
(l.post_entry(e).is_ok(), m.post(e))
|
||||
}
|
||||
Cmd::Reverse(e, d) => {
|
||||
let e = pick(&posted, e);
|
||||
(
|
||||
l.reverse_entry(e, day(d), "undo").is_ok(),
|
||||
m.reverse(e, day(d)),
|
||||
)
|
||||
}
|
||||
Cmd::CreatePeriod(s, e) => (
|
||||
l.create_period("p", day(s), day(e)).is_ok(),
|
||||
m.create_period(day(s), day(e)),
|
||||
),
|
||||
Cmd::Close(i) => {
|
||||
let ids: Vec<u64> = l.list_periods().iter().map(|p| p.id).collect();
|
||||
let i = if ids.is_empty() { 0 } else { i % ids.len() };
|
||||
let id = ids.get(i).copied().unwrap_or(999);
|
||||
(l.close_period(id).is_ok(), m.close(i))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every observable agrees.
|
||||
fn agree(l: &Ledger, m: &Model) -> Result<(), TestCaseError> {
|
||||
// Chart.
|
||||
prop_assert_eq!(l.chart().len(), m.accounts.len());
|
||||
for (id, code, t, h) in &m.accounts {
|
||||
let a = l.chart().get(*id).expect("account");
|
||||
prop_assert_eq!((&a.code, a.account_type, a.placeholder), (code, *t, *h));
|
||||
}
|
||||
// The posted log, in order, line for line.
|
||||
let log: Vec<_> = l.posted_entries().collect();
|
||||
prop_assert_eq!(log.len(), m.posted.len());
|
||||
for (e, me) in log.iter().zip(&m.posted) {
|
||||
prop_assert_eq!(
|
||||
(e.id(), e.date(), e.reversal_of()),
|
||||
(me.id, me.date, me.reversal_of)
|
||||
);
|
||||
let lines: Vec<MLine> = e
|
||||
.lines()
|
||||
.iter()
|
||||
.map(|l| MLine {
|
||||
account: l.account,
|
||||
side: l.side,
|
||||
amount: l.amount.amount,
|
||||
})
|
||||
.collect();
|
||||
prop_assert_eq!(&lines, &me.lines, "entry {}", e.id());
|
||||
}
|
||||
// Drafts, with their lines.
|
||||
for d in &m.drafts {
|
||||
let e = l.get_entry(d.id).expect("draft exists");
|
||||
prop_assert_eq!(e.status(), EntryStatus::Draft);
|
||||
prop_assert_eq!(e.lines().len(), d.lines.len());
|
||||
}
|
||||
prop_assert_eq!(l.next_entry_id(), m.next_entry);
|
||||
prop_assert_eq!(l.next_account_id(), m.next_account);
|
||||
// Periods.
|
||||
let ps = l.list_periods();
|
||||
prop_assert_eq!(ps.len(), m.periods.len());
|
||||
for (p, mp) in ps.iter().zip(&m.periods) {
|
||||
prop_assert_eq!(
|
||||
(
|
||||
p.start,
|
||||
p.end,
|
||||
p.status == ledger_core::PeriodStatus::Closed
|
||||
),
|
||||
(mp.start, mp.end, mp.closed)
|
||||
);
|
||||
}
|
||||
// Every account's balance, and the trial balance balances.
|
||||
for (id, ..) in &m.accounts {
|
||||
prop_assert_eq!(
|
||||
l.account_movement(*id),
|
||||
m.movement(*id, None),
|
||||
"account {}",
|
||||
id
|
||||
);
|
||||
}
|
||||
let tb = trial_balance(l);
|
||||
prop_assert_eq!(tb.total_debits, tb.total_credits);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(2048))]
|
||||
|
||||
/// Oracle: the model (the Lean state machine, transcribed). Same
|
||||
/// decision on every command, same books after every command.
|
||||
#[test]
|
||||
fn the_ledger_matches_its_model(cmds in prop::collection::vec(cmd(), 1..60)) {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let mut m = Model::new();
|
||||
for c in &cmds {
|
||||
let (got, want) = apply(&mut l, &mut m, c);
|
||||
if std::env::var("MODEL_STATS").is_ok() && got {
|
||||
eprintln!("ACCEPTED {}", format!("{c:?}").split('(').next().unwrap());
|
||||
}
|
||||
prop_assert_eq!(got, want, "{:?}: engine accepted {}, model {}", c, got, want);
|
||||
agree(&l, &m)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
//! Cross-check against GnuCash (rank 3: a differential oracle).
|
||||
//!
|
||||
//! GnuCash is *an* independent implementation of double entry, not the
|
||||
//! spec (docs/oracle.md, "Who is right"). This test builds random books
|
||||
//! through wanderbooks' engine (entries, foreign-currency lines, reversals,
|
||||
//! period closes), hands their posted entries to GnuCash 5.10 running in a
|
||||
//! pinned container (oracle/gnucash/), and compares every account's
|
||||
//! balance at month ends and random dates, exactly. A difference is a
|
||||
//! finding to adjudicate from principles (rules.md), never a reason to
|
||||
//! change wanderbooks to match.
|
||||
//!
|
||||
//! Needs Docker: `just oracle` builds the image and runs this.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::reports::{profit_and_loss_for_period, trial_balance_as_of};
|
||||
use ledger_core::{AccountId, AccountType, Currency, Ledger, Money, Side};
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
/// Books per run, and the seed of the first.
|
||||
const BOOKS: u64 = 200;
|
||||
const SEED: u64 = 0x005e_ed0f_b00c;
|
||||
|
||||
/// A small deterministic generator (xorshift64*): the run is reproducible
|
||||
/// from its seed, with no new dependency.
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 >> 12;
|
||||
self.0 ^= self.0 << 25;
|
||||
self.0 ^= self.0 >> 27;
|
||||
self.0.wrapping_mul(0x2545_f491_4f6c_dd1d)
|
||||
}
|
||||
fn below(&mut self, n: u64) -> u64 {
|
||||
self.next() % n
|
||||
}
|
||||
}
|
||||
|
||||
fn d(m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, m, day).unwrap()
|
||||
}
|
||||
|
||||
fn month_end(m: u32) -> NaiveDate {
|
||||
if m == 12 {
|
||||
d(12, 31)
|
||||
} else {
|
||||
d(m + 1, 1).pred_opt().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
/// Random books through the engine's public commands. Closing months
|
||||
/// (when `closes`) happens in order, after the month's entries.
|
||||
fn generate(seed: u64, closes: bool, fx: bool) -> (Ledger, Vec<AccountId>) {
|
||||
let mut r = Rng(seed | 1);
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let chart = [
|
||||
("1000", "Cash", AccountType::Asset),
|
||||
("1500", "Equipment", AccountType::Asset),
|
||||
("2000", "Payables", AccountType::Liability),
|
||||
("3000", "Capital", AccountType::Equity),
|
||||
("4000", "Sales", AccountType::Revenue),
|
||||
("4100", "Interest", AccountType::Revenue),
|
||||
("6000", "Rent", AccountType::Expense),
|
||||
("6100", "Software", AccountType::Expense),
|
||||
];
|
||||
let ids: Vec<AccountId> = chart
|
||||
.iter()
|
||||
.map(|(c, n, t)| l.add_account(c, n, *t, None, false).unwrap())
|
||||
.collect();
|
||||
let mut posted = Vec::new();
|
||||
for month in 1..=6u32 {
|
||||
let entries = 1 + r.below(6);
|
||||
for _ in 0..entries {
|
||||
let day = 1 + r.below(28) as u32;
|
||||
let e = l.create_entry(d(month, day), "generated");
|
||||
let lines = 1 + r.below(3);
|
||||
let mut net = Decimal::ZERO;
|
||||
for _ in 0..lines {
|
||||
let account = ids[r.below(ids.len() as u64) as usize];
|
||||
let side = if r.below(2) == 0 {
|
||||
Side::Debit
|
||||
} else {
|
||||
Side::Credit
|
||||
};
|
||||
if fx && r.below(8) == 0 {
|
||||
// A foreign line: odd decimal places in the base amount.
|
||||
let foreign = Decimal::new(1 + r.below(500_000) as i64, 2);
|
||||
let rate = Decimal::new(5_000 + r.below(20_000) as i64, 4);
|
||||
l.add_line_fx(e, account, side, Currency::EUR, foreign, rate)
|
||||
.unwrap();
|
||||
// Balance against what was booked (rounded to the cent).
|
||||
let base = l
|
||||
.get_entry(e)
|
||||
.unwrap()
|
||||
.lines()
|
||||
.last()
|
||||
.unwrap()
|
||||
.amount
|
||||
.amount;
|
||||
net += if side == Side::Debit { base } else { -base };
|
||||
} else {
|
||||
let amount = Decimal::new(1 + r.below(1_000_000) as i64, 2);
|
||||
l.add_line(e, account, side, Money::new(amount, Currency::USD))
|
||||
.unwrap();
|
||||
net += if side == Side::Debit { amount } else { -amount };
|
||||
}
|
||||
}
|
||||
// A plug line on cash balances the entry.
|
||||
if !net.is_zero() {
|
||||
let side = if net > Decimal::ZERO {
|
||||
Side::Credit
|
||||
} else {
|
||||
Side::Debit
|
||||
};
|
||||
l.add_line(e, ids[0], side, Money::new(net.abs(), Currency::USD))
|
||||
.unwrap();
|
||||
} else {
|
||||
let amount = Money::new(Decimal::ONE, Currency::USD);
|
||||
l.add_line(e, ids[0], Side::Debit, amount).unwrap();
|
||||
l.add_line(e, ids[3], Side::Credit, amount).unwrap();
|
||||
}
|
||||
if l.post_entry(e).is_ok() {
|
||||
posted.push(e);
|
||||
}
|
||||
}
|
||||
// Now and then, reverse an earlier entry of this month.
|
||||
if r.below(3) == 0
|
||||
&& let Some(&e) = posted.last()
|
||||
{
|
||||
let date = l.get_entry(e).unwrap().date();
|
||||
let _ = l.reverse_entry(e, date, "reversal");
|
||||
}
|
||||
if closes {
|
||||
let p = l
|
||||
.create_period(&format!("2026-{month:02}"), d(month, 1), month_end(month))
|
||||
.unwrap();
|
||||
l.close_period(p).unwrap();
|
||||
}
|
||||
}
|
||||
(l, ids)
|
||||
}
|
||||
|
||||
fn book_json(l: &Ledger, as_of: &[NaiveDate]) -> Value {
|
||||
let accounts: Vec<Value> = l
|
||||
.chart()
|
||||
.all()
|
||||
.map(|a| {
|
||||
json!({"id": a.id, "code": a.code, "name": a.name,
|
||||
"type": format!("{:?}", a.account_type)})
|
||||
})
|
||||
.collect();
|
||||
let entries: Vec<Value> = l
|
||||
.posted_entries()
|
||||
.map(|e| {
|
||||
json!({
|
||||
"date": e.date().to_string(),
|
||||
"description": e.description(),
|
||||
"lines": e.lines().iter().map(|ln| json!({
|
||||
"account": ln.account,
|
||||
"side": if ln.side == Side::Debit { "debit" } else { "credit" },
|
||||
"amount": ln.amount.amount.to_string(),
|
||||
})).collect::<Vec<_>>(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"accounts": accounts,
|
||||
"entries": entries,
|
||||
"as_of": as_of.iter().map(|d| d.to_string()).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
/// wanderbooks' trial balance as of a date, as GnuCash reports balances:
|
||||
/// debits positive, by account id, zero for accounts not listed.
|
||||
fn ours(l: &Ledger, as_of: NaiveDate) -> BTreeMap<AccountId, Decimal> {
|
||||
let tb = trial_balance_as_of(l, as_of);
|
||||
let mut out: BTreeMap<AccountId, Decimal> =
|
||||
l.chart().all().map(|a| (a.id, Decimal::ZERO)).collect();
|
||||
for line in tb.lines {
|
||||
let id = l.chart().find_by_code(&line.code).unwrap().id;
|
||||
out.insert(id, line.debit - line.credit);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn gnucash(books: &[Value]) -> Vec<Value> {
|
||||
let mut child = Command::new("docker")
|
||||
.args(["run", "--rm", "-i", "wanderbooks-gnucash-oracle"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("docker is needed: run `just oracle`");
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.unwrap()
|
||||
.write_all(serde_json::to_string(books).unwrap().as_bytes())
|
||||
.unwrap();
|
||||
let out = child.wait_with_output().unwrap();
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"the GnuCash oracle failed; build it with `just oracle`"
|
||||
);
|
||||
serde_json::from_slice(&out.stdout).expect("oracle output is JSON")
|
||||
}
|
||||
|
||||
fn dec(v: &Value) -> Decimal {
|
||||
v.as_str().unwrap().parse().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs Docker and the GnuCash image: `just oracle`"]
|
||||
fn balances_match_gnucash_on_generated_books() {
|
||||
let mut cases = Vec::new();
|
||||
for i in 0..BOOKS {
|
||||
let seed = SEED.wrapping_add(i.wrapping_mul(0x9e37_79b9_7f4a_7c15));
|
||||
let closes = i % 2 == 1;
|
||||
let (l, _) = generate(seed, closes, true);
|
||||
let mut r = Rng(seed ^ 0xa5a5);
|
||||
let mut as_of: Vec<NaiveDate> = (1..=6).map(month_end).collect();
|
||||
for _ in 0..3 {
|
||||
as_of.push(d(1 + r.below(6) as u32, 1 + r.below(28) as u32));
|
||||
}
|
||||
cases.push((seed, closes, l, as_of));
|
||||
}
|
||||
let books: Vec<Value> = cases
|
||||
.iter()
|
||||
.map(|(_, _, l, as_of)| book_json(l, as_of))
|
||||
.collect();
|
||||
let results = gnucash(&books);
|
||||
assert_eq!(results.len(), cases.len());
|
||||
|
||||
let mut diffs = Vec::new();
|
||||
let mut compared = 0usize;
|
||||
for ((seed, closes, l, as_of), result) in cases.iter().zip(&results) {
|
||||
for date in as_of {
|
||||
let theirs = &result["balances"][date.to_string()];
|
||||
for (id, mine) in ours(l, *date) {
|
||||
let g = dec(&theirs[id.to_string()]);
|
||||
compared += 1;
|
||||
if g != mine {
|
||||
let code = &l.chart().get(id).unwrap().code;
|
||||
diffs.push(format!(
|
||||
"seed {seed:#x} (closes: {closes}) account {code} as of {date}: wanderbooks {mine}, GnuCash {g}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
// A month's P&L is the change in its income and expense balances,
|
||||
// on books with no closing entries (GnuCash books a close as an
|
||||
// ordinary transaction; wanderbooks' period P&L leaves it out).
|
||||
if !closes {
|
||||
for m in 2..=6u32 {
|
||||
let pnl = profit_and_loss_for_period(l, d(m, 1), month_end(m), None).unwrap();
|
||||
let before = &result["balances"][month_end(m - 1).to_string()];
|
||||
let after = &result["balances"][month_end(m).to_string()];
|
||||
let mut gnucash_net = Decimal::ZERO;
|
||||
for a in l.chart().all() {
|
||||
if matches!(a.account_type, AccountType::Revenue | AccountType::Expense) {
|
||||
let k = a.id.to_string();
|
||||
// Income is credit-normal: net income = -(change).
|
||||
gnucash_net -= dec(&after[&k]) - dec(&before[&k]);
|
||||
}
|
||||
}
|
||||
compared += 1;
|
||||
if gnucash_net != pnl.net_income() {
|
||||
diffs.push(format!(
|
||||
"seed {seed:#x} P&L 2026-{m:02}: wanderbooks {}, GnuCash {gnucash_net}",
|
||||
pnl.net_income()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!(
|
||||
"compared {compared} balances and P&Ls across {} books",
|
||||
cases.len()
|
||||
);
|
||||
assert!(
|
||||
diffs.is_empty(),
|
||||
"{} difference(s) with GnuCash; adjudicate each from rules.md:\n{}",
|
||||
diffs.len(),
|
||||
diffs.join("\n")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
//! Payout gross-up. Numbers computed by hand.
|
||||
//!
|
||||
//! An app-store month: App A sold 1,000.00, App B 500.00; the store kept
|
||||
//! 15% (225.00) and clawed back a 9.99 refund. Deposit: 1,500.00 − 225.00
|
||||
//! − 9.99 = 1,265.01.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::reports::profit_and_loss_for_period;
|
||||
use ledger_core::{
|
||||
AccountType, Currency, Ledger, LedgerError, Money, Payout, PayoutAdjustment, PayoutLine, Side,
|
||||
payout_plan,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
use std::str::FromStr;
|
||||
|
||||
fn dec(s: &str) -> Decimal {
|
||||
Decimal::from_str(s).unwrap()
|
||||
}
|
||||
|
||||
fn d(day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, 11, day).unwrap()
|
||||
}
|
||||
|
||||
struct Books {
|
||||
l: Ledger,
|
||||
bank: u64,
|
||||
sales: u64,
|
||||
fees: u64,
|
||||
refunds: u64,
|
||||
}
|
||||
|
||||
fn books() -> Books {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let mut a = |code: &str, name: &str, t| l.add_account(code, name, t, None, false).unwrap();
|
||||
let bank = a("1000", "Bank", AccountType::Asset);
|
||||
let sales = a("4000", "App Sales", AccountType::Revenue);
|
||||
let fees = a("6000", "Platform Fees", AccountType::Expense);
|
||||
let refunds = a("4100", "Refunds", AccountType::Expense);
|
||||
Books {
|
||||
l,
|
||||
bank,
|
||||
sales,
|
||||
fees,
|
||||
refunds,
|
||||
}
|
||||
}
|
||||
|
||||
fn line(account: u64, amount: &str) -> PayoutLine {
|
||||
PayoutLine {
|
||||
account,
|
||||
amount: dec(amount),
|
||||
class: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn month(b: &Books, deposit: &str) -> Payout {
|
||||
Payout {
|
||||
deposit_account: b.bank,
|
||||
deposit: dec(deposit),
|
||||
revenue: vec![line(b.sales, "1000.00"), line(b.sales, "500.00")],
|
||||
fees: vec![line(b.fees, "225.00")],
|
||||
adjustments: vec![PayoutAdjustment {
|
||||
account: b.refunds,
|
||||
side: Side::Debit,
|
||||
amount: dec("9.99"),
|
||||
class: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_payout_is_grossed_up_to_revenue_and_fees() {
|
||||
let mut b = books();
|
||||
let (plan, totals) = payout_plan(&month(&b, "1265.01"), b.l.chart()).unwrap();
|
||||
assert_eq!(totals.gross, dec("1500.00"));
|
||||
assert_eq!(totals.fees, dec("225.00"));
|
||||
assert_eq!(totals.adjustments, dec("-9.99"));
|
||||
assert_eq!(
|
||||
plan.iter()
|
||||
.map(|(a, s, m, _)| (*a, *s, *m))
|
||||
.collect::<Vec<_>>(),
|
||||
[
|
||||
(b.bank, Side::Debit, dec("1265.01")),
|
||||
(b.fees, Side::Debit, dec("225.00")),
|
||||
(b.refunds, Side::Debit, dec("9.99")),
|
||||
(b.sales, Side::Credit, dec("1000.00")),
|
||||
(b.sales, Side::Credit, dec("500.00")),
|
||||
]
|
||||
);
|
||||
let e = b.l.create_entry(d(30), "App store payout");
|
||||
for (a, s, m, _) in plan {
|
||||
b.l.add_line(e, a, s, Money::new(m, Currency::USD)).unwrap();
|
||||
}
|
||||
b.l.post_entry(e).unwrap();
|
||||
let pl = profit_and_loss_for_period(&b.l, d(1), d(30), None).unwrap();
|
||||
assert_eq!(pl.total_revenue, dec("1500.00"));
|
||||
assert_eq!(pl.total_expenses, dec("234.99"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_payout_must_reconcile_to_the_cent() {
|
||||
let b = books();
|
||||
let err = payout_plan(&month(&b, "1265.00"), b.l.chart()).unwrap_err();
|
||||
let LedgerError::InvalidPayout(msg) = err else {
|
||||
panic!("{err:?}")
|
||||
};
|
||||
assert!(msg.contains("= 1265.01"), "{msg}");
|
||||
assert!(msg.contains("off by -0.01"), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_negative_payout_credits_the_bank() {
|
||||
let b = books();
|
||||
// 10.00 of sales, 3.00 of fees, a 50.00 refund: the store debits 43.00.
|
||||
let p = Payout {
|
||||
deposit_account: b.bank,
|
||||
deposit: dec("-43.00"),
|
||||
revenue: vec![line(b.sales, "10.00")],
|
||||
fees: vec![line(b.fees, "3.00")],
|
||||
adjustments: vec![PayoutAdjustment {
|
||||
account: b.refunds,
|
||||
side: Side::Debit,
|
||||
amount: dec("50.00"),
|
||||
class: None,
|
||||
}],
|
||||
};
|
||||
let (plan, _) = payout_plan(&p, b.l.chart()).unwrap();
|
||||
assert_eq!(plan[0], (b.bank, Side::Credit, dec("43.00"), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accounts_must_fit_their_roles() {
|
||||
let b = books();
|
||||
let invalid = |p: Payout| {
|
||||
matches!(
|
||||
payout_plan(&p, b.l.chart()),
|
||||
Err(LedgerError::InvalidPayout(_))
|
||||
)
|
||||
};
|
||||
let mut p = month(&b, "1265.01");
|
||||
p.fees[0].account = b.sales;
|
||||
assert!(invalid(p), "fees must be expenses");
|
||||
let mut p = month(&b, "1265.01");
|
||||
p.revenue[0].account = b.fees;
|
||||
assert!(invalid(p), "revenue must be revenue or a liability");
|
||||
let mut p = month(&b, "1265.01");
|
||||
p.deposit_account = b.sales;
|
||||
assert!(invalid(p), "deposits land in an asset");
|
||||
let mut p = month(&b, "1265.01");
|
||||
p.fees[0].amount = dec("-225.00");
|
||||
assert!(invalid(p), "amounts are positive");
|
||||
let mut p = month(&b, "1265.01");
|
||||
p.revenue.clear();
|
||||
assert!(invalid(p), "needs revenue");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_payout_has_no_bank_line() {
|
||||
let b = books();
|
||||
// 10.00 of sales, 3.00 of fees, a 7.00 refund: nothing deposited.
|
||||
let p = Payout {
|
||||
deposit_account: b.bank,
|
||||
deposit: dec("0.00"),
|
||||
revenue: vec![line(b.sales, "10.00")],
|
||||
fees: vec![line(b.fees, "3.00")],
|
||||
adjustments: vec![PayoutAdjustment {
|
||||
account: b.refunds,
|
||||
side: Side::Debit,
|
||||
amount: dec("7.00"),
|
||||
class: None,
|
||||
}],
|
||||
};
|
||||
let (plan, totals) = payout_plan(&p, b.l.chart()).unwrap();
|
||||
assert_eq!(totals.deposit, Decimal::ZERO);
|
||||
assert_eq!(
|
||||
plan.iter()
|
||||
.map(|(a, s, m, _)| (*a, *s, *m))
|
||||
.collect::<Vec<_>>(),
|
||||
[
|
||||
(b.fees, Side::Debit, dec("3.00")),
|
||||
(b.refunds, Side::Debit, dec("7.00")),
|
||||
(b.sales, Side::Credit, dec("10.00")),
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
# Seeds for failure cases proptest has generated in the past. It is
|
||||
# automatically read and these particular cases re-run before any
|
||||
# novel cases are generated.
|
||||
#
|
||||
# It is recommended to check this file in to source control so that
|
||||
# everyone who runs the test benefits from these saved cases.
|
||||
cc 973b72cb286deab3cabcf8a48a797a1aa76e660f1a6f3a7046d02f655ad467a6 # shrinks to cents = 8074248648, shares = [0.0877914951989026063100137174, 0.327846364883401920438957476, 0.2235939643347050754458161866, 0.3607681755829903978052126200]
|
||||
cc 181075cd6abb81d059781749aadb13e990e59e6658279089e173fe655bd6bcf2 # shrinks to cents = 4209937262, shares = [0.3215789473684210526315789474, 0.2426315789473684210526315789, 0.1663157894736842105263157895, 0.2694736842105263157894736842]
|
||||
@@ -10,8 +10,8 @@ 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,
|
||||
balance_sheet, balance_sheet_as_of, profit_and_loss_for_period, trial_balance,
|
||||
unclosed_earnings,
|
||||
},
|
||||
};
|
||||
use proptest::prelude::*;
|
||||
@@ -157,7 +157,7 @@ proptest! {
|
||||
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();
|
||||
let all = unclosed_earnings(&ledger).unwrap();
|
||||
prop_assert_eq!((whole.total_revenue, whole.total_expenses), (all.revenue, all.expenses));
|
||||
}
|
||||
|
||||
@@ -319,3 +319,69 @@ proptest! {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Member splits (periods::allocate): conservation, a cent of each exact
|
||||
// share, fairness, and sign symmetry. Rank 2: laws, not recomputation.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 1 to 5 positive shares summing to exactly 1 (the last takes the rest).
|
||||
fn arb_shares() -> impl Strategy<Value = Vec<Decimal>> {
|
||||
prop::collection::vec(1u32..1000, 1..=5).prop_map(|weights| {
|
||||
let total: u32 = weights.iter().sum();
|
||||
let mut shares: Vec<Decimal> = weights
|
||||
.iter()
|
||||
.map(|w| Decimal::from(*w) / Decimal::from(total))
|
||||
.collect();
|
||||
let rest: Decimal = shares[..shares.len() - 1].iter().sum();
|
||||
*shares.last_mut().unwrap() = Decimal::ONE - rest;
|
||||
shares
|
||||
})
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(2000))]
|
||||
|
||||
#[test]
|
||||
fn prop_split_parts_sum_to_the_whole_within_a_cent_of_each_share(
|
||||
cents in -10_000_000_000i64..10_000_000_000, shares in arb_shares(),
|
||||
) {
|
||||
let amount = Decimal::new(cents, 2);
|
||||
let parts = ledger_core::periods::allocate(amount, &shares);
|
||||
prop_assert_eq!(parts.len(), shares.len());
|
||||
prop_assert_eq!(parts.iter().copied().sum::<Decimal>(), amount);
|
||||
for (p, s) in parts.iter().zip(&shares) {
|
||||
prop_assert!((*p - amount * s).abs() < Decimal::new(1, 2), "{p} vs share {s}");
|
||||
prop_assert!(p.scale() <= 2 || p.normalize().scale() <= 2, "{p} is not whole cents");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prop_split_never_rounds_up_a_smaller_remainder_over_a_larger(
|
||||
cents in 0i64..10_000_000_000, shares in arb_shares(),
|
||||
) {
|
||||
let amount = Decimal::new(cents, 2);
|
||||
let parts = ledger_core::periods::allocate(amount, &shares);
|
||||
let exact: Vec<Decimal> = shares.iter().map(|s| amount * s).collect();
|
||||
let floor = |e: &Decimal| e.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::ToZero);
|
||||
for i in 0..parts.len() {
|
||||
for j in 0..parts.len() {
|
||||
let (ri, rj) = (exact[i] - floor(&exact[i]), exact[j] - floor(&exact[j]));
|
||||
let (up_i, up_j) = (parts[i] > floor(&exact[i]), parts[j] > floor(&exact[j]));
|
||||
if ri > rj {
|
||||
prop_assert!(up_i || !up_j, "member {j} rounded up over member {i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prop_split_of_a_loss_is_the_negated_split_of_the_gain(
|
||||
cents in 0i64..10_000_000_000, shares in arb_shares(),
|
||||
) {
|
||||
let amount = Decimal::new(cents, 2);
|
||||
let gain = ledger_core::periods::allocate(amount, &shares);
|
||||
let loss = ledger_core::periods::allocate(-amount, &shares);
|
||||
prop_assert_eq!(loss, gain.iter().map(|p| -*p).collect::<Vec<_>>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
//! Amortization schedules and recurring entries. Numbers computed by hand.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::reports::{balance_sheet_as_of, profit_and_loss_for_period};
|
||||
use ledger_core::{
|
||||
AccountType, Currency, EntryStatus, Frequency, Ledger, LedgerError, Money, NewRecurring,
|
||||
NewSchedule, ScheduleKind, Side,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
use std::str::FromStr;
|
||||
|
||||
fn d(m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, m, day).unwrap()
|
||||
}
|
||||
|
||||
fn dec(s: &str) -> Decimal {
|
||||
Decimal::from_str(s).unwrap()
|
||||
}
|
||||
|
||||
fn usd(s: &str) -> Money {
|
||||
Money::new(dec(s), Currency::USD)
|
||||
}
|
||||
|
||||
struct Books {
|
||||
l: Ledger,
|
||||
cash: u64,
|
||||
prepaid: u64,
|
||||
deferred: u64,
|
||||
insurance: u64,
|
||||
revenue: u64,
|
||||
rent: u64,
|
||||
}
|
||||
|
||||
fn books() -> Books {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let mut a = |code: &str, name: &str, t| l.add_account(code, name, t, None, false).unwrap();
|
||||
let cash = a("1000", "Cash", AccountType::Asset);
|
||||
let prepaid = a("1300", "Prepaid Expenses", AccountType::Asset);
|
||||
let deferred = a("2400", "Deferred Revenue", AccountType::Liability);
|
||||
let insurance = a("6300", "Insurance", AccountType::Expense);
|
||||
let revenue = a("4000", "Subscription Revenue", AccountType::Revenue);
|
||||
let rent = a("6600", "Rent", AccountType::Expense);
|
||||
Books {
|
||||
l,
|
||||
cash,
|
||||
prepaid,
|
||||
deferred,
|
||||
insurance,
|
||||
revenue,
|
||||
rent,
|
||||
}
|
||||
}
|
||||
|
||||
fn post(l: &mut Ledger, date: NaiveDate, dr: u64, cr: u64, amt: &str) {
|
||||
let e = l.create_entry(date, "x");
|
||||
l.add_line(e, dr, Side::Debit, usd(amt)).unwrap();
|
||||
l.add_line(e, cr, Side::Credit, usd(amt)).unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_prepaid_expense_is_expensed_month_by_month() {
|
||||
let mut b = books();
|
||||
// A year of insurance, paid March 15: an asset, not an expense yet.
|
||||
post(&mut b.l, d(3, 15), b.prepaid, b.cash, "1200");
|
||||
let id =
|
||||
b.l.register_schedule(NewSchedule {
|
||||
description: "Liability insurance".into(),
|
||||
kind: ScheduleKind::PrepaidExpense,
|
||||
total: usd("1200"),
|
||||
start: d(3, 15),
|
||||
months: 12,
|
||||
balance_account: b.prepaid,
|
||||
pl_account: b.insurance,
|
||||
})
|
||||
.unwrap();
|
||||
// Through May: March, April, May, at month end, 100 each.
|
||||
let due: Vec<_> = b.l.get_schedule(id).unwrap().due(d(5, 31)).unwrap();
|
||||
assert_eq!(
|
||||
due,
|
||||
[
|
||||
(d(3, 31), dec("100")),
|
||||
(d(4, 30), dec("100")),
|
||||
(d(5, 31), dec("100"))
|
||||
]
|
||||
);
|
||||
for (month, _) in due {
|
||||
b.l.post_schedule_month(id, month).unwrap();
|
||||
}
|
||||
assert!(
|
||||
b.l.get_schedule(id)
|
||||
.unwrap()
|
||||
.due(d(5, 31))
|
||||
.unwrap()
|
||||
.is_empty(),
|
||||
"idempotent"
|
||||
);
|
||||
assert!(matches!(
|
||||
b.l.post_schedule_month(id, d(4, 30)),
|
||||
Err(LedgerError::InvalidSchedule(_))
|
||||
));
|
||||
assert_eq!(
|
||||
b.l.get_schedule(id).unwrap().remaining().unwrap(),
|
||||
dec("900")
|
||||
);
|
||||
let bs = balance_sheet_as_of(&b.l, d(5, 31)).unwrap();
|
||||
let prepaid = bs.assets.iter().find(|a| a.code == "1300").unwrap();
|
||||
assert_eq!(prepaid.amount, dec("900"));
|
||||
let pl = profit_and_loss_for_period(&b.l, d(3, 1), d(5, 31), None).unwrap();
|
||||
assert_eq!(pl.total_expenses, dec("300"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deferred_revenue_is_earned_to_the_cent() {
|
||||
let mut b = books();
|
||||
// Three months of a subscription collected up front.
|
||||
post(&mut b.l, d(1, 5), b.cash, b.deferred, "1000");
|
||||
let id =
|
||||
b.l.register_schedule(NewSchedule {
|
||||
description: "Quarterly plan, Acme".into(),
|
||||
kind: ScheduleKind::DeferredRevenue,
|
||||
total: usd("1000"),
|
||||
start: d(1, 5),
|
||||
months: 3,
|
||||
balance_account: b.deferred,
|
||||
pl_account: b.revenue,
|
||||
})
|
||||
.unwrap();
|
||||
let plan: Vec<Decimal> =
|
||||
b.l.get_schedule(id)
|
||||
.unwrap()
|
||||
.due(d(12, 31))
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|(_, a)| a)
|
||||
.collect();
|
||||
assert_eq!(plan, [dec("333.33"), dec("333.33"), dec("333.34")]);
|
||||
for (m, _) in b.l.get_schedule(id).unwrap().due(d(12, 31)).unwrap() {
|
||||
b.l.post_schedule_month(id, m).unwrap();
|
||||
}
|
||||
let pl = profit_and_loss_for_period(&b.l, d(1, 1), d(3, 31), None).unwrap();
|
||||
assert_eq!(pl.total_revenue, dec("1000"));
|
||||
let bs = balance_sheet_as_of(&b.l, d(3, 31)).unwrap();
|
||||
assert!(bs.liabilities.is_empty(), "fully earned");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedules_respect_closed_periods_and_account_types() {
|
||||
let mut b = books();
|
||||
let jan = b.l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
|
||||
b.l.close_period(jan).unwrap();
|
||||
let id =
|
||||
b.l.register_schedule(NewSchedule {
|
||||
description: "Insurance".into(),
|
||||
kind: ScheduleKind::PrepaidExpense,
|
||||
total: usd("120"),
|
||||
start: d(1, 1),
|
||||
months: 12,
|
||||
balance_account: b.prepaid,
|
||||
pl_account: b.insurance,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
b.l.post_schedule_month(id, d(1, 31)),
|
||||
Err(LedgerError::ClosedPeriod(..))
|
||||
));
|
||||
b.l.post_schedule_month(id, d(2, 28)).unwrap();
|
||||
// A prepaid schedule needs an asset and an expense, not a liability.
|
||||
let bad = b.l.register_schedule(NewSchedule {
|
||||
description: "x".into(),
|
||||
kind: ScheduleKind::PrepaidExpense,
|
||||
total: usd("120"),
|
||||
start: d(1, 1),
|
||||
months: 12,
|
||||
balance_account: b.deferred,
|
||||
pl_account: b.insurance,
|
||||
});
|
||||
assert!(matches!(bad, Err(LedgerError::InvalidSchedule(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recurring_entries_generate_drafts_never_posts() {
|
||||
let mut b = books();
|
||||
let id =
|
||||
b.l.register_recurring(NewRecurring {
|
||||
description: "Office rent".into(),
|
||||
lines: vec![
|
||||
(b.rent, Side::Debit, usd("2000")),
|
||||
(b.cash, Side::Credit, usd("2000")),
|
||||
],
|
||||
frequency: Frequency::Monthly,
|
||||
start: d(1, 31),
|
||||
end: Some(d(4, 30)),
|
||||
})
|
||||
.unwrap();
|
||||
// Through March: Jan 31, Feb 28, Mar 31 (month ends, no drift).
|
||||
let due = b.l.recurring_entries().next().unwrap().due(d(3, 31));
|
||||
assert_eq!(due, [d(1, 31), d(2, 28), d(3, 31)]);
|
||||
let drafts: Vec<_> = due
|
||||
.iter()
|
||||
.map(|date| b.l.generate_recurring(id, *date).unwrap())
|
||||
.collect();
|
||||
for e in &drafts {
|
||||
assert_eq!(b.l.get_entry(*e).unwrap().status(), EntryStatus::Draft);
|
||||
}
|
||||
// Drafts don't touch the books until posted.
|
||||
let q1 = profit_and_loss_for_period(&b.l, d(1, 1), d(3, 31), None).unwrap();
|
||||
assert_eq!(q1.total_expenses, Decimal::ZERO);
|
||||
b.l.post_entry(drafts[1]).unwrap();
|
||||
let feb = profit_and_loss_for_period(&b.l, d(2, 1), d(2, 28), None).unwrap();
|
||||
assert_eq!(feb.total_expenses, dec("2000"));
|
||||
// Never twice; nothing after the end date.
|
||||
assert!(matches!(
|
||||
b.l.generate_recurring(id, d(2, 28)),
|
||||
Err(LedgerError::InvalidSchedule(_))
|
||||
));
|
||||
assert_eq!(
|
||||
b.l.recurring_entries().next().unwrap().due(d(12, 31)),
|
||||
[d(4, 30)]
|
||||
);
|
||||
// Templates must balance.
|
||||
let bad = b.l.register_recurring(NewRecurring {
|
||||
description: "x".into(),
|
||||
lines: vec![
|
||||
(b.rent, Side::Debit, usd("2000")),
|
||||
(b.cash, Side::Credit, usd("1999")),
|
||||
],
|
||||
frequency: Frequency::Monthly,
|
||||
start: d(1, 1),
|
||||
end: None,
|
||||
});
|
||||
assert!(matches!(bad, Err(LedgerError::InvalidSchedule(_))));
|
||||
let _ = b.revenue;
|
||||
}
|
||||
|
||||
/// Regression (B-12, found by the store's refusal property on
|
||||
/// 2026-10-13): a recurring occurrence dated in a closed period is
|
||||
/// refused, leaves nothing behind, and is reported as needing a human.
|
||||
#[test]
|
||||
fn a_recurring_occurrence_in_a_closed_period_is_refused() {
|
||||
let mut b = books();
|
||||
let id =
|
||||
b.l.register_recurring(NewRecurring {
|
||||
description: "Office rent".into(),
|
||||
lines: vec![
|
||||
(b.rent, Side::Debit, usd("2000")),
|
||||
(b.cash, Side::Credit, usd("2000")),
|
||||
],
|
||||
frequency: Frequency::Monthly,
|
||||
start: d(1, 31),
|
||||
end: None,
|
||||
})
|
||||
.unwrap();
|
||||
let jan = b.l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
|
||||
b.l.close_period(jan).unwrap();
|
||||
let next = b.l.next_entry_id();
|
||||
assert!(matches!(
|
||||
b.l.generate_recurring(id, d(1, 31)),
|
||||
Err(LedgerError::ClosedPeriod(p, _)) if p == jan
|
||||
));
|
||||
assert_eq!(b.l.next_entry_id(), next, "no id spent");
|
||||
assert!(b.l.get_entry(next).is_none(), "no draft left behind");
|
||||
assert_eq!(b.l.recurring_in_closed_periods(d(2, 28)), [(id, d(1, 31))]);
|
||||
// Later occurrences still generate.
|
||||
assert_eq!(b.l.generate_recurring(id, d(2, 28)).unwrap(), next);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedule_length_is_one_to_six_hundred_months() {
|
||||
let b = books();
|
||||
let sched = |months| NewSchedule {
|
||||
description: "Insurance".into(),
|
||||
kind: ScheduleKind::PrepaidExpense,
|
||||
total: usd("600"),
|
||||
start: d(1, 1),
|
||||
months,
|
||||
balance_account: b.prepaid,
|
||||
pl_account: b.insurance,
|
||||
};
|
||||
let chart = b.l.chart();
|
||||
for ok in [1, 600] {
|
||||
assert!(
|
||||
ledger_core::schedules::validate_schedule(&sched(ok), chart).is_ok(),
|
||||
"{ok} months"
|
||||
);
|
||||
}
|
||||
for bad in [0, 601] {
|
||||
assert!(
|
||||
matches!(
|
||||
ledger_core::schedules::validate_schedule(&sched(bad), chart),
|
||||
Err(LedgerError::InvalidSchedule(_))
|
||||
),
|
||||
"{bad} months"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recurring_templates_take_any_number_of_lines_and_may_end_on_their_start() {
|
||||
let mut b = books();
|
||||
// Three balanced lines are a valid template.
|
||||
b.l.register_recurring(NewRecurring {
|
||||
description: "Rent and insurance".into(),
|
||||
lines: vec![
|
||||
(b.rent, Side::Debit, usd("2000")),
|
||||
(b.insurance, Side::Debit, usd("100")),
|
||||
(b.cash, Side::Credit, usd("2100")),
|
||||
],
|
||||
frequency: Frequency::Monthly,
|
||||
start: d(1, 1),
|
||||
end: None,
|
||||
})
|
||||
.unwrap();
|
||||
// An end on the start date is one occurrence, not an error.
|
||||
let once =
|
||||
b.l.register_recurring(NewRecurring {
|
||||
description: "One-off".into(),
|
||||
lines: vec![
|
||||
(b.rent, Side::Debit, usd("2000")),
|
||||
(b.cash, Side::Credit, usd("2000")),
|
||||
],
|
||||
frequency: Frequency::Monthly,
|
||||
start: d(5, 1),
|
||||
end: Some(d(5, 1)),
|
||||
})
|
||||
.unwrap();
|
||||
let r = b.l.recurring_entries().find(|r| r.id == once).unwrap();
|
||||
assert_eq!(r.due(d(12, 31)), [d(5, 1)]);
|
||||
// An end before the start is.
|
||||
let bad = b.l.register_recurring(NewRecurring {
|
||||
description: "Backwards".into(),
|
||||
lines: vec![
|
||||
(b.rent, Side::Debit, usd("2000")),
|
||||
(b.cash, Side::Credit, usd("2000")),
|
||||
],
|
||||
frequency: Frequency::Monthly,
|
||||
start: d(5, 2),
|
||||
end: Some(d(5, 1)),
|
||||
});
|
||||
assert!(matches!(bad, Err(LedgerError::InvalidSchedule(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frequencies_parse_and_step_from_the_start() {
|
||||
assert_eq!(Frequency::parse("weekly"), Some(Frequency::Weekly));
|
||||
assert_eq!(Frequency::parse(" Monthly "), Some(Frequency::Monthly));
|
||||
assert_eq!(Frequency::parse("QUARTERLY"), Some(Frequency::Quarterly));
|
||||
assert_eq!(Frequency::parse("annually"), Some(Frequency::Yearly));
|
||||
assert_eq!(Frequency::parse("fortnightly"), None);
|
||||
// Weekly: the kth occurrence is 7k days on.
|
||||
let start = d(1, 1);
|
||||
for (k, want) in [(0, d(1, 1)), (1, d(1, 8)), (2, d(1, 15)), (3, d(1, 22))] {
|
||||
assert_eq!(Frequency::Weekly.nth(start, k), Some(want), "week {k}");
|
||||
}
|
||||
assert_eq!(Frequency::Quarterly.nth(start, 2), Some(d(7, 1)));
|
||||
for (k, year) in [(1, 2027), (2, 2028), (3, 2029)] {
|
||||
assert_eq!(
|
||||
Frequency::Yearly.nth(start, k),
|
||||
NaiveDate::from_ymd_opt(year, 1, 1),
|
||||
"year {k}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression (found by the store's refusal property, 2026-10-13): a
|
||||
/// schedule whose total no line could carry, or finer than a cent, is
|
||||
/// refused when registered, not at a month end.
|
||||
#[test]
|
||||
fn a_schedule_total_no_month_could_post_is_refused() {
|
||||
let mut b = books();
|
||||
let new = |total: &str| NewSchedule {
|
||||
description: "Insurance".into(),
|
||||
kind: ScheduleKind::PrepaidExpense,
|
||||
total: usd(total),
|
||||
start: d(1, 1),
|
||||
months: 6,
|
||||
balance_account: b.prepaid,
|
||||
pl_account: b.insurance,
|
||||
};
|
||||
let huge = new("46116860184273879.03");
|
||||
let sub_cent = new("120.005");
|
||||
assert!(matches!(
|
||||
b.l.register_schedule(huge),
|
||||
Err(LedgerError::AmountOutOfRange)
|
||||
));
|
||||
assert!(matches!(
|
||||
b.l.register_schedule(sub_cent),
|
||||
Err(LedgerError::FinerThanMinorUnit { .. })
|
||||
));
|
||||
}
|
||||
|
||||
/// Regression: a schedule month refused after its entry was begun leaves
|
||||
/// the ledger exactly as it was: no half-built draft, no id spent
|
||||
/// (Ledger::atomically). The doomed schedule can only come from an older
|
||||
/// journal now, so it is registered through the replay path.
|
||||
#[test]
|
||||
fn a_refused_schedule_month_leaves_nothing_behind() {
|
||||
let mut b = books();
|
||||
let id =
|
||||
b.l.replay_register_schedule(NewSchedule {
|
||||
description: "Insurance".into(),
|
||||
kind: ScheduleKind::PrepaidExpense,
|
||||
total: usd("46116860184273879.03"),
|
||||
start: d(1, 1),
|
||||
months: 6,
|
||||
balance_account: b.prepaid,
|
||||
pl_account: b.insurance,
|
||||
})
|
||||
.unwrap();
|
||||
let before = b.l.clone();
|
||||
assert!(b.l.post_schedule_month(id, d(1, 31)).is_err());
|
||||
assert!(
|
||||
b.l.differs_from(&before).is_empty(),
|
||||
"{:?}",
|
||||
b.l.differs_from(&before)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
//! The settings registry's small parts, value by value: each accessor, each
|
||||
//! check boundary, each tax classification, each masked or joined display.
|
||||
//! `settings.rs`'s own tests cover the invariants; these pin the exact
|
||||
//! outputs the server and viewer rely on (P1 mutation testing,
|
||||
//! 2026-10-17).
|
||||
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use ledger_core::settings::{
|
||||
Change, Company, Member, PartyPolicy, Settings, Source, check, display,
|
||||
form_for_classification, holds_printed_tin, key, line_text, long_text, parse_month_day,
|
||||
parse_share, shape, today_in,
|
||||
};
|
||||
use ledger_core::{AccountType, Currency, Ledger, PartyField, Role, TaxForm};
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::{Value, json};
|
||||
use std::str::FromStr;
|
||||
|
||||
fn date(y: i32, m: u32, d: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, d).unwrap()
|
||||
}
|
||||
|
||||
fn dec(s: &str) -> Decimal {
|
||||
Decimal::from_str(s).unwrap()
|
||||
}
|
||||
|
||||
fn src() -> Source {
|
||||
Source {
|
||||
decided_by: "Owner".into(),
|
||||
decided_on: date(2026, 10, 17),
|
||||
citation: None,
|
||||
note: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings holding `pairs`, checked then applied as one change.
|
||||
fn settings(pairs: &[(&str, Value)]) -> Settings {
|
||||
let changes: Vec<Change> = pairs
|
||||
.iter()
|
||||
.map(|(k, v)| Change {
|
||||
key: (*k).into(),
|
||||
value: Some(v.clone()),
|
||||
})
|
||||
.collect();
|
||||
let mut s = Settings::default();
|
||||
let checked = s.check_changes(&changes, &src()).unwrap();
|
||||
s.apply(&checked, &src(), 1).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn checked(name: &str, v: Value) -> Result<Value, String> {
|
||||
check(key(name).unwrap(), &v)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_return_what_was_set() {
|
||||
let s = settings(&[
|
||||
("policy.evidence.threshold", json!("0.00")),
|
||||
("policy.evidence.required_accounts", json!(["1000", "2050"])),
|
||||
("policy.evidence.exempt_accounts", json!(["6900"])),
|
||||
("books.cash_accounts", json!(["1000", "1010"])),
|
||||
("books.financing_accounts", json!(["2500"])),
|
||||
("books.investing_accounts", json!(["1500", "1510"])),
|
||||
("books.asset_tag_prefix", json!("WL")),
|
||||
("policy.parties", json!("off")),
|
||||
("books.close_sweep", json!(["3100"])),
|
||||
]);
|
||||
assert_eq!(s.receipt_threshold(), Some("0.00"));
|
||||
assert_eq!(s.receipt_required_accounts(), ["1000", "2050"]);
|
||||
assert_eq!(s.receipt_exempt_accounts(), ["6900"]);
|
||||
assert_eq!(s.cash_accounts(), ["1000", "1010"]);
|
||||
assert_eq!(s.financing_accounts(), ["2500"]);
|
||||
assert_eq!(s.investing_accounts(), ["1500", "1510"]);
|
||||
assert_eq!(s.asset_tag_prefix(), Some("WL"));
|
||||
assert_eq!(s.parties(), PartyPolicy::Off);
|
||||
assert_eq!(s.company("USD").close_sweep, ["3100"]);
|
||||
let s = settings(&[("policy.parties", json!("require"))]);
|
||||
assert_eq!(s.parties(), PartyPolicy::Require);
|
||||
// Unset: nothing, and the default policy.
|
||||
let s = Settings::default();
|
||||
assert_eq!(s.receipt_threshold(), None);
|
||||
assert!(s.cash_accounts().is_empty());
|
||||
assert_eq!(s.asset_tag_prefix(), None);
|
||||
assert_eq!(s.parties(), PartyPolicy::Suggest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_company_profile_is_its_company_keys_name_first() {
|
||||
let s = settings(&[
|
||||
("company.ein", json!("12-3456789")),
|
||||
("company.name", json!("Acme LLC")),
|
||||
("company.duns", json!("149976972")),
|
||||
(
|
||||
"company.members",
|
||||
json!([
|
||||
{"name": "Kat", "share": "50%", "capital_account": "3001"},
|
||||
{"name": "Stephen", "share": "50%", "capital_account": "3000"},
|
||||
]),
|
||||
),
|
||||
("books.accounting_basis", json!("cash")),
|
||||
("books.asset_tag_prefix", json!("WL")),
|
||||
]);
|
||||
let c = s.company("USD");
|
||||
let profile: Vec<(&str, &str)> = c
|
||||
.profile
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str(), v.as_str()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
profile,
|
||||
[
|
||||
("name", "Acme LLC"),
|
||||
("base_currency", "USD"),
|
||||
("ein", "12-3456789"),
|
||||
("duns", "149976972"),
|
||||
]
|
||||
);
|
||||
assert_eq!(c.name, "Acme LLC");
|
||||
assert_eq!(c.basis, "Cash");
|
||||
assert_eq!(c.members.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn party_policy_spellings() {
|
||||
assert_eq!(PartyPolicy::Off.key(), "off");
|
||||
assert_eq!(PartyPolicy::Suggest.key(), "suggest");
|
||||
assert_eq!(PartyPolicy::Require.key(), "require");
|
||||
assert_eq!(PartyPolicy::parse("off"), Some(PartyPolicy::Off));
|
||||
assert_eq!(PartyPolicy::parse("suggest"), Some(PartyPolicy::Suggest));
|
||||
assert_eq!(PartyPolicy::parse("require"), Some(PartyPolicy::Require));
|
||||
assert_eq!(PartyPolicy::parse("required"), Some(PartyPolicy::Require));
|
||||
assert_eq!(PartyPolicy::parse("Require"), None);
|
||||
assert_eq!(PartyPolicy::parse(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fiscal_year_bounds() {
|
||||
let c = Company {
|
||||
fiscal_year_start: (7, 1),
|
||||
..Company::default()
|
||||
};
|
||||
assert_eq!(
|
||||
c.fiscal_year_containing(date(2026, 3, 15)),
|
||||
(date(2025, 7, 1), date(2026, 6, 30))
|
||||
);
|
||||
assert_eq!(
|
||||
c.fiscal_year_containing(date(2026, 6, 30)),
|
||||
(date(2025, 7, 1), date(2026, 6, 30))
|
||||
);
|
||||
assert_eq!(
|
||||
c.fiscal_year_containing(date(2026, 7, 1)),
|
||||
(date(2026, 7, 1), date(2027, 6, 30))
|
||||
);
|
||||
let calendar = Company::default();
|
||||
assert_eq!(
|
||||
calendar.fiscal_year_containing(date(2024, 2, 29)),
|
||||
(date(2024, 1, 1), date(2024, 12, 31))
|
||||
);
|
||||
}
|
||||
|
||||
/// A member's party is the live one of their name, else the most recent
|
||||
/// archived one of their name: never another archived member's.
|
||||
#[test]
|
||||
fn party_members_find_each_members_own_party() {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let capital = l
|
||||
.add_account("3001", "Capital — Kat", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
l.add_account(
|
||||
"3000",
|
||||
"Capital — Stephen",
|
||||
AccountType::Equity,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let kat = l.add_party("Kat", &[Role::Member]).unwrap();
|
||||
let other = l.add_party("Pat", &[Role::Member]).unwrap();
|
||||
l.update_party(kat, PartyField::Archived(true)).unwrap();
|
||||
l.update_party(other, PartyField::Archived(true)).unwrap();
|
||||
let c = Company {
|
||||
members: vec![Member {
|
||||
name: "kat ".into(),
|
||||
share: Decimal::ONE,
|
||||
capital_account: "3001".into(),
|
||||
distribution_account: None,
|
||||
files_jointly_with: None,
|
||||
}],
|
||||
..Company::default()
|
||||
};
|
||||
let got = c.party_members(&l);
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].party, kat);
|
||||
assert_eq!(got[0].capital, capital);
|
||||
assert_eq!(got[0].share, Decimal::ONE);
|
||||
assert_eq!(got[0].distributions, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printed_tin_joiners() {
|
||||
// Three spaces is not a joiner; a 3-6 split is no TIN's shape; two
|
||||
// dashes is not one joiner.
|
||||
assert!(!holds_printed_tin("123 45 6789"));
|
||||
assert!(!holds_printed_tin("123 456789"));
|
||||
assert!(!holds_printed_tin("123--45--6789"));
|
||||
// Two spaces, and a dash with a space each side, are.
|
||||
assert!(holds_printed_tin("123 45 6789"));
|
||||
assert!(holds_printed_tin("123 - 45 - 6789"));
|
||||
assert!(holds_printed_tin("12 3456789"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_length_boundaries() {
|
||||
assert_eq!(line_text("x", "aaaaa", 5).unwrap(), "aaaaa");
|
||||
assert_eq!(
|
||||
line_text("x", "aaaaaa", 5).unwrap_err(),
|
||||
"x is longer than 5 characters"
|
||||
);
|
||||
assert_eq!(long_text("x", "aaaaa", 5).unwrap(), "aaaaa");
|
||||
assert_eq!(
|
||||
long_text("x", "aaaaaa", 5).unwrap_err(),
|
||||
"x is longer than 5 characters"
|
||||
);
|
||||
assert_eq!(long_text("x", "a\nb\tc", 10).unwrap(), "a\nb\tc");
|
||||
assert_eq!(
|
||||
long_text("x", "a\u{7}b", 10).unwrap_err(),
|
||||
"x holds a control character"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_boundaries() {
|
||||
assert_eq!(parse_share("K", "1").unwrap(), Decimal::ONE);
|
||||
assert_eq!(parse_share("K", "100%").unwrap(), Decimal::ONE);
|
||||
assert_eq!(parse_share("K", "0.5").unwrap(), dec("0.5"));
|
||||
assert_eq!(parse_share("K", "50%").unwrap(), dec("0.5"));
|
||||
assert_eq!(parse_share("K", "0.0001").unwrap(), dec("0.0001"));
|
||||
for bad in ["0", "0%", "-0.5", "1.0001", "101%", "2"] {
|
||||
assert_eq!(
|
||||
parse_share("K", bad).unwrap_err(),
|
||||
format!("member K: share {bad:?} must be like \"50%\" or \"0.5\""),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn month_day_digits_only() {
|
||||
assert_eq!(parse_month_day("07-01").unwrap(), (7, 1));
|
||||
for bad in ["+1-01", "01-+1", "-01", "01-"] {
|
||||
assert_eq!(
|
||||
parse_month_day(bad).unwrap_err(),
|
||||
format!("fiscal_year_start {bad:?}: expected \"MM-DD\", e.g. \"01-01\""),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_classification_names_its_return() {
|
||||
use TaxForm::*;
|
||||
for (c, want) in [
|
||||
("partnership", Some(Form1065)),
|
||||
("General Partnership", Some(Form1065)),
|
||||
("S corporation", Some(Form1120S)),
|
||||
("S-corp", Some(Form1120S)),
|
||||
("s corp", Some(Form1120S)),
|
||||
("scorp", Some(Form1120S)),
|
||||
("C corporation", Some(Form1120)),
|
||||
("ccorp", Some(Form1120)),
|
||||
("corporation", Some(Form1120)),
|
||||
("corp", Some(Form1120)),
|
||||
("association", Some(Form1120)),
|
||||
("disregarded entity", Some(ScheduleC)),
|
||||
("sole proprietor", Some(ScheduleC)),
|
||||
("single-member LLC", Some(ScheduleC)),
|
||||
("member single", None),
|
||||
("corporation or S corporation", None),
|
||||
("partnership or disregarded", None),
|
||||
("LLC", None),
|
||||
("", None),
|
||||
] {
|
||||
assert_eq!(form_for_classification(c), want, "{c:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codes_and_choices_keep_their_spelling() {
|
||||
assert_eq!(
|
||||
checked("books.close_into", json!(" 3000 ")),
|
||||
Ok(json!("3000"))
|
||||
);
|
||||
assert_eq!(
|
||||
checked("books.cash_accounts", json!([" 1000", "1010 "])),
|
||||
Ok(json!(["1000", "1010"]))
|
||||
);
|
||||
for (k, v) in [
|
||||
("books.accounting_basis", "accrual"),
|
||||
("books.accounting_basis", "cash"),
|
||||
("policy.parties", "off"),
|
||||
("policy.parties", "suggest"),
|
||||
("policy.parties", "require"),
|
||||
("policy.approval_rules", "off"),
|
||||
("policy.approval_rules", "enforce"),
|
||||
] {
|
||||
assert_eq!(checked(k, json!(v)), Ok(json!(v)), "{k} {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn amount_bound_is_inclusive() {
|
||||
let max = ledger_core::kernel::MAX_LINE_AMOUNT.to_string();
|
||||
assert_eq!(
|
||||
checked("policy.evidence.threshold", json!(max)),
|
||||
Ok(json!(max))
|
||||
);
|
||||
let over = format!("{max}.01");
|
||||
assert_eq!(
|
||||
checked("policy.evidence.threshold", json!(over)),
|
||||
Err(format!(
|
||||
"policy.evidence.threshold {over:?} is out of range"
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ein_shapes() {
|
||||
let ein = |s: &str| checked("company.ein", json!(s));
|
||||
assert_eq!(ein("12-3456789"), Ok(json!("12-3456789")));
|
||||
assert_eq!(ein("123456789"), Ok(json!("12-3456789")));
|
||||
let refused = Err("company.ein: expected an EIN, NN-NNNNNNN".to_string());
|
||||
for bad in [
|
||||
"12345678",
|
||||
"12-34-567",
|
||||
"12345678x",
|
||||
"123-456789",
|
||||
"1-23456789",
|
||||
] {
|
||||
assert_eq!(ein(bad), refused, "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_control_and_tag_prefix_bounds() {
|
||||
let nc = |s: &str| checked("company.irs_name_control", json!(s));
|
||||
for ok in ["WAND", "A&B-", "A&", "A-B", "w"] {
|
||||
assert_eq!(nc(ok), Ok(json!(ok.to_uppercase())), "{ok:?}");
|
||||
}
|
||||
assert_eq!(
|
||||
nc("WANDE"),
|
||||
Err("company.irs_name_control \"WANDE\": one to four letters, digits, & or -".into())
|
||||
);
|
||||
assert!(nc("W.A").is_err());
|
||||
let tag = |s: &str| checked("books.asset_tag_prefix", json!(s));
|
||||
assert_eq!(tag("WL"), Ok(json!("WL")));
|
||||
assert_eq!(tag("ABCDEFGH"), Ok(json!("ABCDEFGH")));
|
||||
assert_eq!(
|
||||
tag("ABCDEFGHI"),
|
||||
Err("books.asset_tag_prefix \"ABCDEFGHI\": 1 to 8 letters or digits".into())
|
||||
);
|
||||
assert!(tag("").is_err());
|
||||
assert!(tag("W-L").is_err());
|
||||
}
|
||||
|
||||
/// Replay checks an amount and a fiscal year start by their shape.
|
||||
#[test]
|
||||
fn replay_shapes_amounts_and_month_starts() {
|
||||
let threshold = key("policy.evidence.threshold").unwrap();
|
||||
assert!(shape(threshold, &json!("25.00")).is_ok());
|
||||
assert_eq!(
|
||||
shape(threshold, &json!("abc")),
|
||||
Err("policy.evidence.threshold \"abc\" is not a decimal".into())
|
||||
);
|
||||
let fy = key("company.fiscal_year_start").unwrap();
|
||||
assert!(shape(fy, &json!("07-15")).is_ok());
|
||||
for bad in ["13-01", "02-30", "07"] {
|
||||
assert_eq!(
|
||||
shape(fy, &json!(bad)),
|
||||
Err(format!("fiscal_year_start {bad:?}: expected \"MM-DD\"")),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn today_is_the_companys_today() {
|
||||
let utc = chrono::Utc::now().date_naive();
|
||||
let near = |d: NaiveDate| (d - utc).num_days().abs() <= 1;
|
||||
let s = settings(&[("company.timezone", json!("America/Denver"))]);
|
||||
assert!(near(s.today()), "{}", s.today());
|
||||
assert!(near(today_in(None)));
|
||||
// UTC+14 and UTC-11 are 25 hours apart: never the same day.
|
||||
let east = today_in(Some("Pacific/Kiritimati"));
|
||||
let west = today_in(Some("Pacific/Pago_Pago"));
|
||||
assert!(near(east) && near(west));
|
||||
assert!(east > west, "{east} {west}");
|
||||
assert!(east.year() >= 2026);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_joins_members_and_lists() {
|
||||
let members = key("company.members").unwrap();
|
||||
let v = json!([
|
||||
{"name": "Kat", "share": "0.5", "capital_account": "3001"},
|
||||
{"name": "Stephen", "share": "0.5", "capital_account": "3000"},
|
||||
]);
|
||||
assert_eq!(display(members, &v, false), "Kat 0.5, Stephen 0.5");
|
||||
let cash = key("books.cash_accounts").unwrap();
|
||||
assert_eq!(display(cash, &json!(["1000", "1010"]), false), "1000, 1010");
|
||||
let ein = key("company.ein").unwrap();
|
||||
assert_eq!(display(ein, &json!("12-3456789"), false), "**-***6789");
|
||||
assert_eq!(display(ein, &json!("12-3456789"), true), "12-3456789");
|
||||
}
|
||||
|
||||
/// A date is taken only as written YYYY-MM-DD, one spelling per day.
|
||||
#[test]
|
||||
fn dates_take_one_spelling() {
|
||||
let formed = |s: &str| checked("company.formed", json!(s));
|
||||
assert_eq!(formed("2026-10-04"), Ok(json!("2026-10-04")));
|
||||
assert_eq!(formed(" 2026-10-04 "), Ok(json!("2026-10-04")));
|
||||
for bad in ["2026-1-4", "2026-10-4", "+2026-10-04"] {
|
||||
assert_eq!(
|
||||
formed(bad),
|
||||
Err(format!(
|
||||
"company.formed {bad:?}: expected a date, YYYY-MM-DD"
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
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,792 @@
|
||||
//! No command reaches an overflow or a date chrono can't hold: every amount
|
||||
//! a new command takes is at most a journal line's
|
||||
//! (kernel::MAX_LINE_AMOUNT), every date is in the calendar the books keep
|
||||
//! (ledger_core::calendar), and what can't be computed exactly is refused,
|
||||
//! never wrapped or panicked on (P0 review round 3, finding S1: a panic
|
||||
//! stops the books server). The bounds are the commands' only: replay
|
||||
//! accepts every line an earlier version accepted (rules.md B-28, posted
|
||||
//! is forever; P0 review round 4), so each bound here has its pair, a
|
||||
//! replay of an old line past it, accepted.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: a command the books can't hold is refused with a reason, the
|
||||
//! ledger unchanged; a replayed line an earlier version wrote past
|
||||
//! the same bound is accepted exactly as written.
|
||||
//! SOURCE: DECISION (P0 review round 3, 2026-10-16, S1; round 4,
|
||||
//! 2026-10-17); kernel.rs MAX_LINE_AMOUNT (rules.md: a line over a
|
||||
//! quadrillion is a mistake); calendar.rs (four-digit years).
|
||||
//! ```
|
||||
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use ledger_core::kernel::MAX_LINE_AMOUNT;
|
||||
use ledger_core::{
|
||||
AccountId, AccountType, Currency, DocumentLine, Frequency, Ledger, LedgerError, Money,
|
||||
NewRecurring, NewSchedule, PayrollLine, Side,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
||||
}
|
||||
|
||||
fn usd(a: Decimal) -> Money {
|
||||
Money::new(a, Currency::USD)
|
||||
}
|
||||
|
||||
/// One past the largest a line may be.
|
||||
fn too_big() -> Decimal {
|
||||
MAX_LINE_AMOUNT + Decimal::new(1, 2)
|
||||
}
|
||||
|
||||
struct Books {
|
||||
ledger: Ledger,
|
||||
cash: AccountId,
|
||||
prepaid: AccountId,
|
||||
revenue: AccountId,
|
||||
expense: AccountId,
|
||||
}
|
||||
|
||||
fn books() -> Books {
|
||||
let mut ledger = Ledger::new(Currency::USD);
|
||||
let mut add = |code: &str, ty| ledger.add_account(code, code, ty, None, false).unwrap();
|
||||
let cash = add("1000", AccountType::Asset);
|
||||
let prepaid = add("1300", AccountType::Asset);
|
||||
let revenue = add("4000", AccountType::Revenue);
|
||||
let expense = add("6000", AccountType::Expense);
|
||||
add("3000", AccountType::Equity);
|
||||
Books {
|
||||
ledger,
|
||||
cash,
|
||||
prepaid,
|
||||
revenue,
|
||||
expense,
|
||||
}
|
||||
}
|
||||
|
||||
/// A date outside the calendar is refused wherever a command takes one:
|
||||
/// a draft, a period, a schedule's months, a recurring entry, a document.
|
||||
#[test]
|
||||
fn dates_outside_the_calendar_are_refused_everywhere() {
|
||||
let mut b = books();
|
||||
for far in [NaiveDate::MAX, NaiveDate::MIN, d(10000, 1, 1), d(0, 12, 31)] {
|
||||
assert_eq!(
|
||||
b.ledger.try_create_entry(far, "far").unwrap_err(),
|
||||
LedgerError::DateOutOfRange(far)
|
||||
);
|
||||
assert_eq!(
|
||||
b.ledger.create_period("far", far, far).unwrap_err(),
|
||||
LedgerError::DateOutOfRange(far)
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
b.ledger
|
||||
.create_period("far", d(2026, 1, 1), d(10000, 1, 1))
|
||||
.unwrap_err(),
|
||||
LedgerError::DateOutOfRange(d(10000, 1, 1))
|
||||
);
|
||||
b.ledger.try_create_entry(d(1, 1, 1), "first day").unwrap();
|
||||
b.ledger
|
||||
.try_create_entry(d(9999, 12, 31), "last day")
|
||||
.unwrap();
|
||||
b.ledger
|
||||
.create_period("last", d(9999, 12, 1), d(9999, 12, 31))
|
||||
.unwrap();
|
||||
// A schedule whose months run past the calendar's last day.
|
||||
let schedule = |start: NaiveDate, months: u32| NewSchedule {
|
||||
description: "Insurance".into(),
|
||||
kind: ledger_core::schedules::ScheduleKind::PrepaidExpense,
|
||||
total: usd(Decimal::new(30000, 2)),
|
||||
start,
|
||||
months,
|
||||
balance_account: b.prepaid,
|
||||
pl_account: b.expense,
|
||||
};
|
||||
for (start, months) in [
|
||||
(d(9999, 12, 15), 2),
|
||||
(d(9999, 1, 1), 13),
|
||||
(NaiveDate::MAX, 1),
|
||||
(d(0, 6, 1), 1),
|
||||
] {
|
||||
assert!(
|
||||
b.ledger.register_schedule(schedule(start, months)).is_err(),
|
||||
"{start} + {months}"
|
||||
);
|
||||
}
|
||||
b.ledger
|
||||
.register_schedule(schedule(d(9999, 1, 1), 12))
|
||||
.unwrap();
|
||||
// A recurring entry starting or ending outside it.
|
||||
let recurring = |start: NaiveDate, end: Option<NaiveDate>| NewRecurring {
|
||||
description: "Rent".into(),
|
||||
lines: vec![
|
||||
(b.expense, Side::Debit, usd(Decimal::ONE)),
|
||||
(b.cash, Side::Credit, usd(Decimal::ONE)),
|
||||
],
|
||||
frequency: Frequency::Monthly,
|
||||
start,
|
||||
end,
|
||||
};
|
||||
for (start, end) in [
|
||||
(NaiveDate::MIN, None),
|
||||
(d(2026, 1, 1), Some(NaiveDate::MAX)),
|
||||
] {
|
||||
assert!(matches!(
|
||||
b.ledger.register_recurring(recurring(start, end)),
|
||||
Err(LedgerError::DateOutOfRange(_))
|
||||
));
|
||||
}
|
||||
// An invoice due outside it.
|
||||
let customer = b.ledger.add_customer("Client").unwrap();
|
||||
let line = vec![DocumentLine {
|
||||
description: "work".into(),
|
||||
amount: Decimal::ONE,
|
||||
}];
|
||||
assert_eq!(
|
||||
b.ledger
|
||||
.create_invoice(customer, d(2026, 1, 1), NaiveDate::MAX, line.clone())
|
||||
.unwrap_err(),
|
||||
LedgerError::DateOutOfRange(NaiveDate::MAX)
|
||||
);
|
||||
let _ = b.revenue;
|
||||
}
|
||||
|
||||
/// The pairs of dates_outside_the_calendar_are_refused_everywhere: each
|
||||
/// replay of an old line dated past the calendar is accepted as written.
|
||||
#[test]
|
||||
fn replayed_dates_outside_the_calendar_are_accepted() {
|
||||
let mut b = books();
|
||||
for far in [NaiveDate::MAX, NaiveDate::MIN, d(10000, 1, 1), d(0, 12, 31)] {
|
||||
let e = b.ledger.create_entry(far, "far");
|
||||
assert_eq!(b.ledger.get_entry(e).unwrap().date(), far);
|
||||
let p = b.ledger.replay_create_period("far", far, far).unwrap();
|
||||
assert_eq!(b.ledger.get_period(p).unwrap().start, far);
|
||||
}
|
||||
// Only the order is checked on replay.
|
||||
assert!(matches!(
|
||||
b.ledger
|
||||
.replay_create_period("backwards", d(2026, 2, 1), d(2026, 1, 1)),
|
||||
Err(LedgerError::InvalidPeriodRange { .. })
|
||||
));
|
||||
let schedule = |start: NaiveDate, months: u32| NewSchedule {
|
||||
description: "Insurance".into(),
|
||||
kind: ledger_core::schedules::ScheduleKind::PrepaidExpense,
|
||||
total: usd(Decimal::new(30000, 2)),
|
||||
start,
|
||||
months,
|
||||
balance_account: b.prepaid,
|
||||
pl_account: b.expense,
|
||||
};
|
||||
for (start, months) in [
|
||||
(d(9999, 12, 15), 2),
|
||||
(d(9999, 6, 1), 12),
|
||||
(NaiveDate::MAX, 1),
|
||||
(d(0, 6, 1), 1),
|
||||
] {
|
||||
let id = b
|
||||
.ledger
|
||||
.replay_register_schedule(schedule(start, months))
|
||||
.unwrap_or_else(|e| panic!("{start} + {months}: {e}"));
|
||||
assert_eq!(b.ledger.get_schedule(id).unwrap().start, start);
|
||||
}
|
||||
// The month bounds are main's: 1 to 600.
|
||||
assert!(
|
||||
b.ledger
|
||||
.replay_register_schedule(schedule(d(2026, 1, 1), 601))
|
||||
.is_err()
|
||||
);
|
||||
let recurring = |start: NaiveDate, end: Option<NaiveDate>| NewRecurring {
|
||||
description: "Rent".into(),
|
||||
lines: vec![
|
||||
(b.expense, Side::Debit, usd(Decimal::ONE)),
|
||||
(b.cash, Side::Credit, usd(Decimal::ONE)),
|
||||
],
|
||||
frequency: Frequency::Monthly,
|
||||
start,
|
||||
end,
|
||||
};
|
||||
for (start, end) in [
|
||||
(NaiveDate::MIN, None),
|
||||
(d(2026, 1, 1), Some(NaiveDate::MAX)),
|
||||
] {
|
||||
b.ledger
|
||||
.replay_register_recurring(recurring(start, end))
|
||||
.unwrap();
|
||||
}
|
||||
let customer = b.ledger.add_customer("Client").unwrap();
|
||||
let vendor = b.ledger.add_vendor("Supplier").unwrap();
|
||||
let line = vec![DocumentLine {
|
||||
description: "work".into(),
|
||||
amount: Decimal::ONE,
|
||||
}];
|
||||
b.ledger
|
||||
.replay_invoice(1, customer, NaiveDate::MIN, NaiveDate::MAX, line.clone(), 1)
|
||||
.unwrap();
|
||||
b.ledger
|
||||
.replay_bill(1, vendor, d(0, 1, 1), d(10000, 1, 1), line, 1)
|
||||
.unwrap();
|
||||
let mut add = |code: &str, ty| b.ledger.add_account(code, code, ty, None, false).unwrap();
|
||||
let equipment = add("1500", AccountType::Asset);
|
||||
let accum = add("1501", AccountType::Asset);
|
||||
let depreciation = add("6100", AccountType::Expense);
|
||||
let asset = |purchase_date: NaiveDate, useful_life_months: u32| ledger_core::NewAsset {
|
||||
description: "Late".into(),
|
||||
purchase_date,
|
||||
cost: usd(Decimal::new(120000, 2)),
|
||||
salvage_value: usd(Decimal::ZERO),
|
||||
useful_life_months,
|
||||
cost_account: equipment,
|
||||
accum_depr_account: accum,
|
||||
expense_account: depreciation,
|
||||
};
|
||||
for (date, life) in [
|
||||
(d(9999, 1, 1), 60),
|
||||
(d(2026, 1, 1), 120_000),
|
||||
(d(0, 1, 1), 1),
|
||||
] {
|
||||
assert_eq!(
|
||||
b.ledger.register_asset(asset(date, life)).unwrap_err(),
|
||||
if date.year() == 0 {
|
||||
LedgerError::DateOutOfRange(date)
|
||||
} else {
|
||||
LedgerError::AssetDateOverflow
|
||||
}
|
||||
);
|
||||
b.ledger.replay_register_asset(asset(date, life)).unwrap();
|
||||
}
|
||||
// Replay keeps main's one date rule: the life's end is a date chrono
|
||||
// holds.
|
||||
assert_eq!(
|
||||
b.ledger
|
||||
.replay_register_asset(asset(d(262_142, 1, 1), 12))
|
||||
.unwrap_err(),
|
||||
LedgerError::AssetDateOverflow
|
||||
);
|
||||
let id = b
|
||||
.ledger
|
||||
.replay_register_asset(asset(d(2026, 1, 1), 12))
|
||||
.unwrap();
|
||||
let fix = |purchase_date: NaiveDate| ledger_core::AssetCorrection {
|
||||
cost: usd(Decimal::new(120000, 2)),
|
||||
salvage_value: usd(Decimal::ZERO),
|
||||
useful_life_months: 60,
|
||||
purchase_date,
|
||||
};
|
||||
assert_eq!(
|
||||
b.ledger.correct_asset(id, fix(d(9999, 1, 1))).unwrap_err(),
|
||||
LedgerError::AssetDateOverflow
|
||||
);
|
||||
b.ledger
|
||||
.replay_correct_asset(id, fix(d(9999, 1, 1)))
|
||||
.unwrap();
|
||||
assert_eq!(b.ledger.get_asset(id).unwrap().purchase_date, d(9999, 1, 1));
|
||||
// A disposal dated past the calendar.
|
||||
let proceeds = usd(Decimal::ZERO);
|
||||
assert_eq!(
|
||||
b.ledger
|
||||
.dispose_asset(id, d(10000, 1, 1), proceeds)
|
||||
.unwrap_err(),
|
||||
LedgerError::DateOutOfRange(d(10000, 1, 1))
|
||||
);
|
||||
b.ledger
|
||||
.replay_dispose_asset(id, d(10000, 1, 1), proceeds)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// An amount larger than a line may be is refused wherever a command takes
|
||||
/// one, so every sum the books make of them is exact.
|
||||
#[test]
|
||||
fn amounts_larger_than_a_line_are_refused_everywhere() {
|
||||
let mut b = books();
|
||||
let big = too_big();
|
||||
// A draft line, as it is added; replayed as an older version wrote it.
|
||||
let e = b.ledger.try_create_entry(d(2026, 1, 1), "big").unwrap();
|
||||
assert_eq!(
|
||||
b.ledger
|
||||
.add_line(e, b.expense, Side::Debit, usd(big))
|
||||
.unwrap_err(),
|
||||
LedgerError::AmountOutOfRange
|
||||
);
|
||||
for a in [big, Decimal::MAX, -Decimal::MAX] {
|
||||
b.ledger
|
||||
.replay_add_line(e, b.expense, Side::Debit, usd(a))
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(b.ledger.get_entry(e).unwrap().lines().len(), 3);
|
||||
// Its post refuses it, never a panic.
|
||||
assert!(b.ledger.post_entry(e).is_err());
|
||||
// Document lines, and their replay.
|
||||
let customer = b.ledger.add_customer("Client").unwrap();
|
||||
let lines = |a: Decimal| {
|
||||
vec![
|
||||
DocumentLine {
|
||||
description: "a".into(),
|
||||
amount: a,
|
||||
},
|
||||
DocumentLine {
|
||||
description: "b".into(),
|
||||
amount: Decimal::ONE,
|
||||
},
|
||||
]
|
||||
};
|
||||
for a in [big, Decimal::MAX, Decimal::new(1, 11)] {
|
||||
assert!(matches!(
|
||||
b.ledger
|
||||
.create_invoice(customer, d(2026, 1, 1), d(2026, 2, 1), lines(a)),
|
||||
Err(LedgerError::ValueOutOfRange(_))
|
||||
));
|
||||
}
|
||||
// Replayed as journaled: whatever its lines, a document is restored;
|
||||
// a total past exact arithmetic is refused where it is computed.
|
||||
for (id, a) in [(1, big), (2, Decimal::new(1, 11)), (3, Decimal::MAX)] {
|
||||
b.ledger
|
||||
.replay_invoice(id, customer, d(2026, 1, 1), d(2026, 2, 1), lines(a), id)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
b.ledger.get_invoice(2).unwrap().total(),
|
||||
Ok(Decimal::new(100_000_000_001, 11))
|
||||
);
|
||||
assert!(matches!(
|
||||
b.ledger.get_invoice(3).unwrap().total(),
|
||||
Err(LedgerError::TooLargeToCompute(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
b.ledger.ar_aging(d(2026, 3, 1)),
|
||||
Err(LedgerError::TooLargeToCompute(_))
|
||||
));
|
||||
// A recurring template's lines.
|
||||
let recurring = NewRecurring {
|
||||
description: "x".into(),
|
||||
lines: vec![
|
||||
(b.expense, Side::Debit, usd(Decimal::MAX)),
|
||||
(b.expense, Side::Debit, usd(Decimal::ONE)),
|
||||
(b.cash, Side::Credit, usd(Decimal::ONE)),
|
||||
],
|
||||
frequency: Frequency::Monthly,
|
||||
start: d(2026, 1, 1),
|
||||
end: None,
|
||||
};
|
||||
assert_eq!(
|
||||
b.ledger.register_recurring(recurring.clone()).unwrap_err(),
|
||||
LedgerError::AmountOutOfRange
|
||||
);
|
||||
// On replay, one whose debits overflow Decimal is refused (no version
|
||||
// could have written it: main panicked on it), never a panic; one past
|
||||
// the line limit that balances is accepted.
|
||||
assert!(matches!(
|
||||
b.ledger.replay_register_recurring(recurring),
|
||||
Err(LedgerError::TooLargeToCompute(_))
|
||||
));
|
||||
let half = Decimal::MAX / Decimal::TWO;
|
||||
b.ledger
|
||||
.replay_register_recurring(NewRecurring {
|
||||
description: "x".into(),
|
||||
lines: vec![
|
||||
(b.expense, Side::Debit, usd(half)),
|
||||
(b.cash, Side::Credit, usd(half)),
|
||||
],
|
||||
frequency: Frequency::Monthly,
|
||||
start: d(2026, 1, 1),
|
||||
end: None,
|
||||
})
|
||||
.unwrap();
|
||||
// Payroll figures, run and replayed.
|
||||
let payroll = |fed: Decimal, ss: Decimal| PayrollLine {
|
||||
employee_name: "Dana".into(),
|
||||
employee: None,
|
||||
gross_pay: Decimal::ONE,
|
||||
federal_withholding: fed,
|
||||
state_withholding: fed,
|
||||
ss_employee: Decimal::ZERO,
|
||||
medicare_employee: Decimal::ZERO,
|
||||
ss_employer: ss,
|
||||
medicare_employer: Decimal::ONE,
|
||||
other_deductions: Decimal::ZERO,
|
||||
};
|
||||
for line in [
|
||||
payroll(Decimal::MAX, Decimal::ONE),
|
||||
payroll(Decimal::ZERO, big),
|
||||
] {
|
||||
assert!(matches!(
|
||||
b.ledger.run_payroll(d(2026, 1, 31), vec![line], b.cash),
|
||||
Err(LedgerError::ValueOutOfRange(_))
|
||||
));
|
||||
}
|
||||
// Replayed as journaled, sub-cent and past the limit alike; a net
|
||||
// past exact arithmetic is refused where it is computed.
|
||||
let fine = payroll(Decimal::new(1, 12), Decimal::ONE);
|
||||
let huge = payroll(Decimal::ZERO, big);
|
||||
b.ledger
|
||||
.replay_payroll_run(1, d(2026, 1, 31), vec![fine, huge], 1)
|
||||
.unwrap();
|
||||
let mut over = payroll(Decimal::ZERO, Decimal::ONE);
|
||||
over.gross_pay = -Decimal::MAX;
|
||||
over.federal_withholding = Decimal::MAX;
|
||||
assert!(matches!(
|
||||
over.net_pay(),
|
||||
Err(LedgerError::TooLargeToCompute(_))
|
||||
));
|
||||
assert!(
|
||||
ledger_core::payroll::check_payroll_ranges(&[payroll(MAX_LINE_AMOUNT, MAX_LINE_AMOUNT)])
|
||||
.is_ok()
|
||||
);
|
||||
// A statement balance in a reconciliation.
|
||||
for balance in [-Decimal::MAX, big, -big] {
|
||||
assert!(matches!(
|
||||
b.ledger
|
||||
.reconciliation_report(b.cash, d(2026, 1, 31), balance),
|
||||
Err(LedgerError::ValueOutOfRange(_))
|
||||
));
|
||||
}
|
||||
let report = b
|
||||
.ledger
|
||||
.reconciliation_report(b.cash, d(2026, 1, 31), -MAX_LINE_AMOUNT)
|
||||
.unwrap();
|
||||
assert_eq!(report.difference, MAX_LINE_AMOUNT);
|
||||
// A payout's lines and its deposit.
|
||||
let payout = |revenue: Decimal, deposit: Decimal| ledger_core::Payout {
|
||||
deposit_account: b.cash,
|
||||
deposit,
|
||||
revenue: vec![ledger_core::PayoutLine {
|
||||
account: b.revenue,
|
||||
amount: revenue,
|
||||
class: None,
|
||||
}],
|
||||
fees: vec![],
|
||||
adjustments: vec![],
|
||||
};
|
||||
for p in [payout(big, big), payout(Decimal::ONE, -Decimal::MAX)] {
|
||||
assert!(matches!(
|
||||
ledger_core::payout_plan(&p, b.ledger.chart()),
|
||||
Err(LedgerError::ValueOutOfRange(_))
|
||||
));
|
||||
}
|
||||
assert!(
|
||||
ledger_core::payout_plan(&payout(MAX_LINE_AMOUNT, MAX_LINE_AMOUNT), b.ledger.chart())
|
||||
.is_ok()
|
||||
);
|
||||
// A contributor's basis.
|
||||
let basis = |b: Decimal| ledger_core::TaxBasis {
|
||||
rule: ledger_core::TaxBasisRule::Carryover,
|
||||
contributor_basis: Some(b),
|
||||
};
|
||||
assert!(basis(big).check().is_err());
|
||||
assert!(basis(MAX_LINE_AMOUNT).check().is_ok());
|
||||
// On replay: every journaled basis passed check_journaled.
|
||||
let cents_max = Decimal::MAX.round_dp(2) / Decimal::ONE_HUNDRED;
|
||||
assert!(basis(big.round_dp(2)).check_journaled().is_ok());
|
||||
assert!(basis(cents_max.round_dp(2)).check_journaled().is_ok());
|
||||
assert!(basis(-Decimal::ONE).check_journaled().is_err());
|
||||
// A party's share of an entry.
|
||||
let share = |a: Decimal| {
|
||||
vec![ledger_core::PartyShare {
|
||||
party: 1,
|
||||
share: ledger_core::shares::Share::Amount(a),
|
||||
}]
|
||||
};
|
||||
let known = |_| true;
|
||||
assert!(ledger_core::shares::check_shares(&share(big.round_dp(2)), known).is_err());
|
||||
assert!(ledger_core::shares::check_shares(&share(MAX_LINE_AMOUNT), known).is_ok());
|
||||
// On replay: every journaled share passed check_shares_journaled.
|
||||
assert!(ledger_core::shares::check_shares_journaled(&share(big.round_dp(2)), known).is_ok());
|
||||
assert!(ledger_core::shares::check_shares_journaled(&share(Decimal::MAX), known).is_ok());
|
||||
assert!(ledger_core::shares::check_shares_journaled(&share(Decimal::ZERO), known).is_err());
|
||||
}
|
||||
|
||||
/// The ledger-level pairs for a basis and shares: the command refuses past
|
||||
/// the limit; replay of the old line is accepted, and what it feeds (the
|
||||
/// register, the shares' resolution) refuses what it can't compute.
|
||||
#[test]
|
||||
fn replayed_bases_and_shares_past_the_limit_are_accepted() {
|
||||
let mut b = books();
|
||||
let mut add = |code: &str, ty| b.ledger.add_account(code, code, ty, None, false).unwrap();
|
||||
let equipment = add("1500", AccountType::Asset);
|
||||
let accum = add("1501", AccountType::Asset);
|
||||
let depreciation = add("6100", AccountType::Expense);
|
||||
// Decimal's largest is a whole number of cents, as every version
|
||||
// required of a basis and a share.
|
||||
let cents_max = Decimal::MAX;
|
||||
let basis = ledger_core::TaxBasis {
|
||||
rule: ledger_core::TaxBasisRule::Carryover,
|
||||
contributor_basis: Some(cents_max),
|
||||
};
|
||||
for _ in 0..2 {
|
||||
let id = b
|
||||
.ledger
|
||||
.register_asset(ledger_core::NewAsset {
|
||||
description: "Laptop".into(),
|
||||
purchase_date: d(2026, 1, 1),
|
||||
cost: usd(Decimal::new(240000, 2)),
|
||||
salvage_value: usd(Decimal::ZERO),
|
||||
useful_life_months: 24,
|
||||
cost_account: equipment,
|
||||
accum_depr_account: accum,
|
||||
expense_account: depreciation,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
b.ledger.set_asset_tax_basis(id, basis),
|
||||
Err(LedgerError::InvalidTaxBasis(_))
|
||||
));
|
||||
b.ledger.replay_set_asset_tax_basis(id, basis).unwrap();
|
||||
assert_eq!(b.ledger.get_asset(id).unwrap().tax, Some(basis));
|
||||
}
|
||||
assert!(matches!(
|
||||
ledger_core::fixed_asset_register(&b.ledger, d(2026, 12, 31)),
|
||||
Err(LedgerError::TooLargeToCompute(_))
|
||||
));
|
||||
let a = b
|
||||
.ledger
|
||||
.add_party("A", &[ledger_core::Role::Member])
|
||||
.unwrap();
|
||||
let c = b
|
||||
.ledger
|
||||
.add_party("C", &[ledger_core::Role::Member])
|
||||
.unwrap();
|
||||
let e = b.ledger.try_create_entry(d(2026, 3, 1), "rent").unwrap();
|
||||
b.ledger
|
||||
.add_line(e, b.expense, Side::Debit, usd(Decimal::TEN))
|
||||
.unwrap();
|
||||
b.ledger
|
||||
.add_line(e, b.cash, Side::Credit, usd(Decimal::TEN))
|
||||
.unwrap();
|
||||
b.ledger.post_entry(e).unwrap();
|
||||
let shares = [a, c].map(|party| ledger_core::PartyShare {
|
||||
party,
|
||||
share: ledger_core::shares::Share::Amount(cents_max),
|
||||
});
|
||||
assert!(matches!(
|
||||
b.ledger.set_entry_parties(e, &shares),
|
||||
Err(LedgerError::InvalidParty(_))
|
||||
));
|
||||
b.ledger.replay_set_entry_parties(e, &shares).unwrap();
|
||||
assert_eq!(b.ledger.entry_parties(e), &shares);
|
||||
let money = ledger_core::parties::money_accounts(&b.ledger, &[b.cash]);
|
||||
let why = ledger_core::shares::resolve(b.ledger.get_entry(e).unwrap(), &shares, &money, &[])
|
||||
.unwrap_err();
|
||||
assert!(why.contains("too large to compute"), "{why}");
|
||||
// Replay still checks what every version checked.
|
||||
assert_eq!(
|
||||
b.ledger.replay_set_entry_parties(99, &shares).unwrap_err(),
|
||||
LedgerError::UnknownEntry(99)
|
||||
);
|
||||
}
|
||||
|
||||
/// A replayed payment is as journaled: no bound but exact arithmetic (a
|
||||
/// paid amount past it is refused, never wrapped). The command's bound is
|
||||
/// the balance (Overpayment).
|
||||
#[test]
|
||||
fn replayed_payments_are_as_journaled() {
|
||||
let mut b = books();
|
||||
let customer = b.ledger.add_customer("Client").unwrap();
|
||||
let invoice = b
|
||||
.ledger
|
||||
.create_invoice(
|
||||
customer,
|
||||
d(2026, 1, 1),
|
||||
d(2026, 2, 1),
|
||||
vec![DocumentLine {
|
||||
description: "work".into(),
|
||||
amount: Decimal::new(10000, 2),
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
b.ledger
|
||||
.receive_payment(invoice, d(2026, 1, 15), too_big(), b.cash),
|
||||
Err(LedgerError::Overpayment(..))
|
||||
));
|
||||
b.ledger
|
||||
.replay_invoice_payment(invoice, too_big(), 98)
|
||||
.unwrap();
|
||||
b.ledger
|
||||
.replay_invoice_payment(invoice, Decimal::MAX - too_big(), 99)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
b.ledger.get_invoice(invoice).unwrap().amount_paid,
|
||||
Decimal::MAX
|
||||
);
|
||||
assert!(matches!(
|
||||
b.ledger.replay_invoice_payment(invoice, Decimal::ONE, 100),
|
||||
Err(LedgerError::TooLargeToCompute(_))
|
||||
));
|
||||
// Refused before anything changed.
|
||||
assert_eq!(
|
||||
b.ledger.get_invoice(invoice).unwrap().amount_paid,
|
||||
Decimal::MAX
|
||||
);
|
||||
}
|
||||
|
||||
/// A disposal's proceeds are a line of its entry, and its date a day of
|
||||
/// the calendar, for a new disposal; a replayed one is as journaled, and
|
||||
/// its amounts (disposal_amounts, pure) are computed, never a panic.
|
||||
#[test]
|
||||
fn disposal_proceeds_and_dates_are_bounded() {
|
||||
let mut b = books();
|
||||
let mut add = |code: &str, ty| b.ledger.add_account(code, code, ty, None, false).unwrap();
|
||||
let equipment = add("1500", AccountType::Asset);
|
||||
let accum = add("1501", AccountType::Asset);
|
||||
let depreciation = add("6100", AccountType::Expense);
|
||||
let laptop = || ledger_core::NewAsset {
|
||||
description: "Laptop".into(),
|
||||
purchase_date: d(2026, 1, 1),
|
||||
cost: usd(Decimal::new(240000, 2)),
|
||||
salvage_value: usd(Decimal::ZERO),
|
||||
useful_life_months: 24,
|
||||
cost_account: equipment,
|
||||
accum_depr_account: accum,
|
||||
expense_account: depreciation,
|
||||
};
|
||||
let id = b.ledger.register_asset(laptop()).unwrap();
|
||||
for proceeds in [Decimal::MAX, too_big()] {
|
||||
assert_eq!(
|
||||
b.ledger
|
||||
.dispose_asset(id, d(2026, 6, 30), usd(proceeds))
|
||||
.unwrap_err(),
|
||||
LedgerError::AmountOutOfRange
|
||||
);
|
||||
let asset = b.ledger.get_asset(id).unwrap().clone();
|
||||
let amounts = ledger_core::disposal_amounts(&asset, d(2026, 6, 30), usd(proceeds)).unwrap();
|
||||
assert_eq!(amounts.gain_loss, proceeds - amounts.book_value);
|
||||
}
|
||||
assert_eq!(
|
||||
b.ledger
|
||||
.dispose_asset(id, NaiveDate::MAX, usd(Decimal::ONE))
|
||||
.unwrap_err(),
|
||||
LedgerError::DateOutOfRange(NaiveDate::MAX)
|
||||
);
|
||||
// Disposal amounts at chrono's last day: computed.
|
||||
let asset = b.ledger.get_asset(id).unwrap().clone();
|
||||
let amounts = ledger_core::disposal_amounts(&asset, NaiveDate::MAX, usd(Decimal::ONE)).unwrap();
|
||||
assert_eq!(amounts.book_value, Decimal::ZERO);
|
||||
b.ledger
|
||||
.dispose_asset(id, d(2026, 6, 30), usd(MAX_LINE_AMOUNT))
|
||||
.unwrap();
|
||||
// Replayed past both bounds: accepted as journaled.
|
||||
let old = b.ledger.register_asset(laptop()).unwrap();
|
||||
b.ledger
|
||||
.replay_dispose_asset(old, NaiveDate::MAX, usd(Decimal::MAX))
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
b.ledger.get_asset(old).unwrap().status,
|
||||
ledger_core::AssetStatus::Disposed { date, .. } if date == NaiveDate::MAX
|
||||
));
|
||||
// Replay keeps what every version checked: no disposal twice, none
|
||||
// before the purchase, no negative proceeds.
|
||||
assert_eq!(
|
||||
b.ledger
|
||||
.replay_dispose_asset(old, NaiveDate::MAX, usd(Decimal::ONE))
|
||||
.unwrap_err(),
|
||||
LedgerError::AssetAlreadyDisposed(old)
|
||||
);
|
||||
let early = b.ledger.register_asset(laptop()).unwrap();
|
||||
assert!(matches!(
|
||||
b.ledger
|
||||
.replay_dispose_asset(early, d(2025, 1, 1), usd(Decimal::ONE)),
|
||||
Err(LedgerError::DisposalBeforePurchase(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
b.ledger
|
||||
.replay_dispose_asset(early, d(2026, 2, 1), usd(-Decimal::ONE)),
|
||||
Err(LedgerError::NegativeProceeds(_))
|
||||
));
|
||||
}
|
||||
|
||||
/// The bounds are the line limit exactly: a new draft line (base or
|
||||
/// foreign) and a recurring template's line may be as large as a line, and
|
||||
/// no larger; a replayed one past it is accepted as journaled.
|
||||
#[test]
|
||||
fn the_bound_is_the_line_limit_itself() {
|
||||
let mut b = books();
|
||||
let e = b.ledger.try_create_entry(d(2026, 1, 1), "max").unwrap();
|
||||
for amount in [MAX_LINE_AMOUNT, -MAX_LINE_AMOUNT] {
|
||||
b.ledger
|
||||
.add_line(e, b.cash, Side::Debit, usd(amount))
|
||||
.unwrap();
|
||||
}
|
||||
for amount in [too_big().round_dp(2), -too_big().round_dp(2)] {
|
||||
assert_eq!(
|
||||
b.ledger
|
||||
.add_line(e, b.cash, Side::Debit, usd(amount))
|
||||
.unwrap_err(),
|
||||
LedgerError::AmountOutOfRange
|
||||
);
|
||||
}
|
||||
// A foreign line: its base amount, foreign x rate, is what's bounded.
|
||||
b.ledger
|
||||
.add_line_fx(
|
||||
e,
|
||||
b.cash,
|
||||
Side::Debit,
|
||||
Currency::EUR,
|
||||
MAX_LINE_AMOUNT,
|
||||
Decimal::ONE,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
b.ledger
|
||||
.add_line_fx(
|
||||
e,
|
||||
b.cash,
|
||||
Side::Debit,
|
||||
Currency::EUR,
|
||||
MAX_LINE_AMOUNT,
|
||||
Decimal::TWO
|
||||
)
|
||||
.unwrap_err(),
|
||||
LedgerError::AmountOutOfRange
|
||||
);
|
||||
// Replayed as an earlier version booked them: past the limit too.
|
||||
for amount in [too_big(), -too_big(), Decimal::MAX] {
|
||||
b.ledger
|
||||
.replay_add_line_fx(
|
||||
e,
|
||||
b.cash,
|
||||
Side::Debit,
|
||||
usd(amount),
|
||||
Currency::EUR,
|
||||
Decimal::ONE,
|
||||
)
|
||||
.unwrap();
|
||||
b.ledger
|
||||
.replay_add_line(e, b.cash, Side::Debit, usd(amount))
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(b.ledger.get_entry(e).unwrap().lines().len(), 9);
|
||||
let recurring = |a: Decimal| NewRecurring {
|
||||
description: "max".into(),
|
||||
lines: vec![
|
||||
(b.expense, Side::Debit, usd(a)),
|
||||
(b.cash, Side::Credit, usd(a)),
|
||||
],
|
||||
frequency: Frequency::Monthly,
|
||||
start: d(2026, 1, 1),
|
||||
end: None,
|
||||
};
|
||||
b.ledger
|
||||
.register_recurring(recurring(MAX_LINE_AMOUNT))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
b.ledger
|
||||
.register_recurring(recurring(too_big()))
|
||||
.unwrap_err(),
|
||||
LedgerError::AmountOutOfRange
|
||||
);
|
||||
let old = b
|
||||
.ledger
|
||||
.replay_register_recurring(recurring(too_big()))
|
||||
.unwrap();
|
||||
// Its occurrences generate drafts as journaled; their post refuses.
|
||||
let draft = b
|
||||
.ledger
|
||||
.replay_generate_recurring(old, d(2026, 1, 1))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
b.ledger.get_entry(draft).unwrap().lines()[0].amount.amount,
|
||||
too_big()
|
||||
);
|
||||
assert_eq!(
|
||||
b.ledger.post_entry(draft).unwrap_err(),
|
||||
LedgerError::AmountOutOfRange
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,412 @@
|
||||
//! Figures derived from what replay accepts: computed exactly where exact
|
||||
//! arithmetic and chrono's calendar reach, refused (TooLargeToCompute,
|
||||
//! DateOutOfRange) where they don't, never a panic (rules.md B-28; P0
|
||||
//! review round 4, 2026-10-17).
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: books an earlier version wrote (a draft line of Decimal's
|
||||
//! largest, shares and bases past the line limit, a document's lines
|
||||
//! or a payroll run as journaled, dates at chrono's last day) can be
|
||||
//! read every way the engine reads books, and the server stays up.
|
||||
//! SOURCE: DECISION (P0 review round 4: posted is forever; derived
|
||||
//! computations refuse rather than panic).
|
||||
//! ```
|
||||
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use ledger_core::{
|
||||
AccountType, Currency, DocumentLine, Ledger, LedgerError, Money, NewAsset, PartyShare,
|
||||
PayrollLine, Side,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
||||
}
|
||||
|
||||
fn usd(a: Decimal) -> Money {
|
||||
Money::new(a, Currency::USD)
|
||||
}
|
||||
|
||||
fn too_large(r: Result<impl std::fmt::Debug, LedgerError>) -> bool {
|
||||
matches!(r, Err(LedgerError::TooLargeToCompute(_)))
|
||||
}
|
||||
|
||||
/// An asset whose life ends in chrono's last month (an earlier version
|
||||
/// accepted any life whose end chrono holds): its schedule walks into
|
||||
/// December 262142, prorates that month by its 31 days, and sums to its
|
||||
/// cost exactly.
|
||||
#[test]
|
||||
fn depreciation_reaches_chronos_last_month() {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let mut add = |code: &str, ty| l.add_account(code, code, ty, None, false).unwrap();
|
||||
let cost_account = add("1500", AccountType::Asset);
|
||||
let accum = add("1510", AccountType::Asset);
|
||||
let expense = add("6100", AccountType::Expense);
|
||||
let y = NaiveDate::MAX.year();
|
||||
let id = l
|
||||
.replay_register_asset(NewAsset {
|
||||
description: "edge".into(),
|
||||
purchase_date: d(y, 1, 15),
|
||||
cost: usd(Decimal::new(120000, 2)),
|
||||
salvage_value: usd(Decimal::ZERO),
|
||||
useful_life_months: 11,
|
||||
cost_account,
|
||||
accum_depr_account: accum,
|
||||
expense_account: expense,
|
||||
})
|
||||
.unwrap();
|
||||
let a = l.get_asset(id).unwrap();
|
||||
let full = ledger_core::depreciation_schedule(a, NaiveDate::MAX);
|
||||
assert_eq!(full.len(), 12);
|
||||
assert_eq!(full[0].period, format!("{y}-01"));
|
||||
assert_eq!(full[11].period, format!("{y}-12"));
|
||||
assert_eq!(full[11].accumulated, Decimal::new(120000, 2));
|
||||
assert_eq!(
|
||||
full.iter().map(|l| l.amount).sum::<Decimal>(),
|
||||
Decimal::new(120000, 2)
|
||||
);
|
||||
// Through November: eleven months, December not yet.
|
||||
let nov = ledger_core::depreciation_schedule(a, d(y, 11, 30));
|
||||
assert_eq!(nov.len(), 11);
|
||||
assert!(nov.last().unwrap().accumulated < Decimal::new(120000, 2));
|
||||
// December is walked without stepping past chrono's last day.
|
||||
let dec = ledger_core::depreciation_schedule(a, d(y, 12, 10));
|
||||
assert_eq!(dec.last().unwrap().period, format!("{y}-12"));
|
||||
assert_eq!(
|
||||
ledger_core::accumulated_depreciation(a, NaiveDate::MAX),
|
||||
Decimal::new(120000, 2)
|
||||
);
|
||||
assert_eq!(ledger_core::book_value(a, NaiveDate::MAX), Decimal::ZERO);
|
||||
}
|
||||
|
||||
/// An asset whose life runs past 9999 (or starts before year 1) trues up
|
||||
/// in its final month like any other: its lifetime is exactly its cost.
|
||||
#[test]
|
||||
fn a_life_past_9999_trues_up_to_the_cent() {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let mut add = |code: &str, ty| l.add_account(code, code, ty, None, false).unwrap();
|
||||
let cost_account = add("1500", AccountType::Asset);
|
||||
let accum = add("1510", AccountType::Asset);
|
||||
let expense = add("6100", AccountType::Expense);
|
||||
// March 17th to April 17th: a 31-day first month and a 30-day last, so
|
||||
// the partial months drift and the true-up is needed.
|
||||
for (start, months) in [
|
||||
(d(9999, 3, 17), 61),
|
||||
(d(-10, 3, 17), 61),
|
||||
(d(9998, 3, 17), 25),
|
||||
] {
|
||||
let id = l
|
||||
.replay_register_asset(NewAsset {
|
||||
description: "far".into(),
|
||||
purchase_date: start,
|
||||
cost: usd(Decimal::new(100_000, 2)),
|
||||
salvage_value: usd(Decimal::new(1, 2)),
|
||||
useful_life_months: months,
|
||||
cost_account,
|
||||
accum_depr_account: accum,
|
||||
expense_account: expense,
|
||||
})
|
||||
.unwrap();
|
||||
let a = l.get_asset(id).unwrap();
|
||||
let s = ledger_core::depreciation_schedule(a, NaiveDate::MAX);
|
||||
assert_eq!(
|
||||
s.last().unwrap().accumulated,
|
||||
Decimal::new(99_999, 2),
|
||||
"{start} + {months}"
|
||||
);
|
||||
assert_eq!(
|
||||
s.iter().map(|l| l.amount).sum::<Decimal>(),
|
||||
Decimal::new(99_999, 2),
|
||||
"{start} + {months}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// February has 29 days exactly in the years chrono calls leap, from its
|
||||
/// first year to its last (year 0 and the negative years included), so a
|
||||
/// schedule in any of them prorates February rightly.
|
||||
#[test]
|
||||
fn february_is_prorated_by_chronos_calendar_in_every_year() {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let mut add = |code: &str, ty| l.add_account(code, code, ty, None, false).unwrap();
|
||||
let cost_account = add("1500", AccountType::Asset);
|
||||
let accum = add("1510", AccountType::Asset);
|
||||
let expense = add("6100", AccountType::Expense);
|
||||
let min = NaiveDate::MIN.year();
|
||||
let max = NaiveDate::MAX.year();
|
||||
for year in [
|
||||
min,
|
||||
min + 1,
|
||||
-400,
|
||||
-100,
|
||||
-4,
|
||||
-1,
|
||||
0,
|
||||
1,
|
||||
4,
|
||||
100,
|
||||
400,
|
||||
1900,
|
||||
2000,
|
||||
2024,
|
||||
2026,
|
||||
max - 1,
|
||||
max,
|
||||
] {
|
||||
let leap = NaiveDate::from_ymd_opt(year, 2, 29).is_some();
|
||||
// Bought February 1st, two months of life, so February is a whole
|
||||
// month of it.
|
||||
let id = l
|
||||
.replay_register_asset(NewAsset {
|
||||
description: format!("{year}"),
|
||||
purchase_date: d(year, 2, 1),
|
||||
cost: usd(Decimal::from(2900)),
|
||||
salvage_value: usd(Decimal::ZERO),
|
||||
useful_life_months: 2,
|
||||
cost_account,
|
||||
accum_depr_account: accum,
|
||||
expense_account: expense,
|
||||
})
|
||||
.unwrap();
|
||||
let a = l.get_asset(id).unwrap();
|
||||
// Through the 14th: 14 of February's days.
|
||||
let half = ledger_core::depreciation_schedule(a, d(year, 2, 14));
|
||||
let days = if leap { 29 } else { 28 };
|
||||
let want = (Decimal::from(1450) * Decimal::from(14) / Decimal::from(days))
|
||||
.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero);
|
||||
assert_eq!(half.last().unwrap().amount, want, "{year}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A draft an earlier version journaled with lines of Decimal's largest:
|
||||
/// its money flow and debits are refused, its shares don't resolve, its
|
||||
/// post refuses it.
|
||||
#[test]
|
||||
fn a_draft_of_decimals_largest_is_refused_where_summed() {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let cash = l
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let card = l
|
||||
.add_account("2050", "Card", AccountType::Liability, None, false)
|
||||
.unwrap();
|
||||
let rent = l
|
||||
.add_account("6000", "Rent", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let p = l
|
||||
.add_party("Acme", &[ledger_core::Role::Contractor])
|
||||
.unwrap();
|
||||
let q = l
|
||||
.add_party("Beta", &[ledger_core::Role::Contractor])
|
||||
.unwrap();
|
||||
let money = ledger_core::parties::money_accounts(&l, &[cash]);
|
||||
for (dr, cr) in [(rent, cash), (rent, card), (card, cash), (cash, card)] {
|
||||
let e = l.create_entry(d(2026, 3, 1), "huge");
|
||||
for side in [Side::Debit, Side::Debit] {
|
||||
l.replay_add_line(e, dr, side, usd(Decimal::MAX)).unwrap();
|
||||
}
|
||||
l.replay_add_line(e, cr, Side::Credit, usd(Decimal::MAX))
|
||||
.unwrap();
|
||||
l.replay_add_line(e, cr, Side::Credit, usd(Decimal::MAX))
|
||||
.unwrap();
|
||||
let entry = l.get_entry(e).unwrap();
|
||||
assert!(too_large(ledger_core::parties::entry_flow(entry, &money)));
|
||||
assert!(too_large(ledger_core::shares::share_target(entry, &money)));
|
||||
let why =
|
||||
ledger_core::shares::resolve(entry, &[PartyShare::whole(p)], &money, &[]).unwrap_err();
|
||||
assert!(why.contains("too large to compute"), "{why}");
|
||||
assert_eq!(l.post_entry(e).unwrap_err(), LedgerError::AmountOutOfRange);
|
||||
}
|
||||
// One line of Decimal's largest flows, and its share resolves whole.
|
||||
let e = l.create_entry(d(2026, 3, 1), "one huge");
|
||||
l.replay_add_line(e, rent, Side::Debit, usd(Decimal::MAX))
|
||||
.unwrap();
|
||||
l.replay_add_line(e, cash, Side::Credit, usd(Decimal::MAX))
|
||||
.unwrap();
|
||||
let entry = l.get_entry(e).unwrap();
|
||||
assert_eq!(
|
||||
ledger_core::parties::entry_flow(entry, &money).unwrap().net,
|
||||
Decimal::MAX
|
||||
);
|
||||
assert_eq!(
|
||||
ledger_core::shares::resolve(entry, &[PartyShare::whole(p)], &money, &[]).unwrap(),
|
||||
[(p, Decimal::MAX)]
|
||||
);
|
||||
// A percent of it: the product is past exact arithmetic.
|
||||
let halves = [p, q].map(|party| PartyShare {
|
||||
party,
|
||||
share: ledger_core::shares::Share::Percent(Decimal::from(50)),
|
||||
});
|
||||
let why = ledger_core::shares::resolve(entry, &halves, &money, &[]).unwrap_err();
|
||||
assert!(why.contains("too large to compute"), "{why}");
|
||||
}
|
||||
|
||||
/// A document is restored as journaled: its total and balance are exact,
|
||||
/// or refused past exact arithmetic; aging sums by party, checked.
|
||||
#[test]
|
||||
fn documents_and_aging_are_exact_or_refused() {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let customer = l.add_customer("Client").unwrap();
|
||||
let line = |a: Decimal| DocumentLine {
|
||||
description: "x".into(),
|
||||
amount: a,
|
||||
};
|
||||
let fine = vec![
|
||||
line(Decimal::new(1, 11)),
|
||||
line("9.99999999999".parse().unwrap()),
|
||||
];
|
||||
l.replay_invoice(1, customer, d(2026, 3, 1), d(2026, 3, 31), fine, 1)
|
||||
.unwrap();
|
||||
let inv = l.get_invoice(1).unwrap();
|
||||
assert_eq!(inv.total(), Ok(Decimal::TEN));
|
||||
assert_eq!(inv.balance(), Ok(Decimal::TEN));
|
||||
let rows = l.ar_aging(d(2026, 6, 1)).unwrap();
|
||||
// 62 days past due on June 1st: 61-90.
|
||||
assert_eq!(rows[0].days_90, Decimal::TEN);
|
||||
assert_eq!(rows[0].total(), Ok(Decimal::TEN));
|
||||
// Two documents of half Decimal's largest each: each is exact, the
|
||||
// party's aging bucket is not.
|
||||
let half = Decimal::MAX / Decimal::TWO;
|
||||
for id in [2, 3] {
|
||||
l.replay_invoice(
|
||||
id,
|
||||
customer,
|
||||
d(2026, 3, 1),
|
||||
d(2026, 3, 31),
|
||||
vec![line(half)],
|
||||
id,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(l.get_invoice(id).unwrap().total(), Ok(half));
|
||||
}
|
||||
assert!(too_large(l.ar_aging(d(2026, 6, 1))));
|
||||
// Paid more than its total, as journaled: a negative balance, exact.
|
||||
l.replay_invoice_payment(1, Decimal::from(11), 9).unwrap();
|
||||
assert_eq!(l.get_invoice(1).unwrap().balance(), Ok(-Decimal::ONE));
|
||||
// A total of lines that overflow, and its balance.
|
||||
l.replay_invoice(
|
||||
4,
|
||||
customer,
|
||||
d(2026, 3, 1),
|
||||
d(2026, 3, 31),
|
||||
vec![line(Decimal::MAX), line(Decimal::MAX)],
|
||||
4,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(too_large(l.get_invoice(4).unwrap().total()));
|
||||
assert!(too_large(l.get_invoice(4).unwrap().balance()));
|
||||
// A balance past exact arithmetic: a total of Decimal's largest paid
|
||||
// a negative amount.
|
||||
l.replay_invoice(
|
||||
5,
|
||||
customer,
|
||||
d(2026, 3, 1),
|
||||
d(2026, 3, 31),
|
||||
vec![line(Decimal::MAX)],
|
||||
5,
|
||||
)
|
||||
.unwrap();
|
||||
l.replay_invoice_payment(5, -Decimal::ONE, 10).unwrap();
|
||||
assert!(too_large(l.get_invoice(5).unwrap().balance()));
|
||||
// Bills alike.
|
||||
let vendor = l.add_vendor("Supplier").unwrap();
|
||||
l.replay_bill(
|
||||
1,
|
||||
vendor,
|
||||
d(2026, 3, 1),
|
||||
d(2026, 3, 31),
|
||||
vec![line(Decimal::MAX), line(Decimal::ONE)],
|
||||
6,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(too_large(l.get_bill(1).unwrap().total()));
|
||||
assert!(too_large(l.ap_aging(d(2026, 6, 1))));
|
||||
let row = ledger_core::AgingRow {
|
||||
name: "x".into(),
|
||||
current: Decimal::MAX,
|
||||
days_30: Decimal::ONE,
|
||||
days_60: Decimal::ZERO,
|
||||
days_90: Decimal::ZERO,
|
||||
days_90_plus: Decimal::ZERO,
|
||||
};
|
||||
assert!(too_large(row.total()));
|
||||
for (i, one) in [
|
||||
(0, Decimal::ONE),
|
||||
(1, Decimal::TWO),
|
||||
(2, Decimal::TEN),
|
||||
(3, Decimal::ONE_HUNDRED),
|
||||
(4, Decimal::ONE_THOUSAND),
|
||||
] {
|
||||
let mut r = ledger_core::AgingRow {
|
||||
current: Decimal::ZERO,
|
||||
..row.clone()
|
||||
};
|
||||
r.days_30 = Decimal::ZERO;
|
||||
match i {
|
||||
0 => r.current = one,
|
||||
1 => r.days_30 = one,
|
||||
2 => r.days_60 = one,
|
||||
3 => r.days_90 = one,
|
||||
_ => r.days_90_plus = one,
|
||||
}
|
||||
assert_eq!(r.total(), Ok(one), "bucket {i}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A payroll run restored as journaled: net pay is exact, or refused past
|
||||
/// exact arithmetic; a run's plan refuses totals it can't hold.
|
||||
#[test]
|
||||
fn payroll_figures_are_exact_or_refused() {
|
||||
let line = |gross: Decimal, fed: Decimal| PayrollLine {
|
||||
employee_name: "Dana".into(),
|
||||
employee: None,
|
||||
gross_pay: gross,
|
||||
federal_withholding: fed,
|
||||
state_withholding: Decimal::new(1, 2),
|
||||
ss_employee: Decimal::new(2, 2),
|
||||
medicare_employee: Decimal::new(3, 2),
|
||||
ss_employer: Decimal::ZERO,
|
||||
medicare_employer: Decimal::ZERO,
|
||||
other_deductions: Decimal::new(4, 2),
|
||||
};
|
||||
assert_eq!(
|
||||
line(Decimal::from(100), Decimal::new(1, 12)).net_pay(),
|
||||
Ok("99.899999999999".parse().unwrap())
|
||||
);
|
||||
assert!(too_large(line(-Decimal::MAX, Decimal::ONE).net_pay()));
|
||||
let accounts = ledger_core::payroll::PayrollAccounts {
|
||||
salary_expense: 1,
|
||||
payroll_tax_expense: 2,
|
||||
federal_payable: 3,
|
||||
state_payable: 4,
|
||||
ss_payable: 5,
|
||||
medicare_payable: 6,
|
||||
other_payable: 7,
|
||||
};
|
||||
let big = line(Decimal::MAX, Decimal::ZERO);
|
||||
assert!(too_large(ledger_core::payroll::payroll_entry_plan(
|
||||
&[big.clone(), big],
|
||||
accounts,
|
||||
8,
|
||||
Currency::USD
|
||||
)));
|
||||
let plan = ledger_core::payroll::payroll_entry_plan(
|
||||
&[line(Decimal::from(100), Decimal::ONE)],
|
||||
accounts,
|
||||
8,
|
||||
Currency::USD,
|
||||
)
|
||||
.unwrap();
|
||||
let debits: Decimal = plan
|
||||
.iter()
|
||||
.filter(|(_, s, _)| *s == Side::Debit)
|
||||
.map(|(_, _, m)| m.amount)
|
||||
.sum();
|
||||
let credits: Decimal = plan
|
||||
.iter()
|
||||
.filter(|(_, s, _)| *s == Side::Credit)
|
||||
.map(|(_, _, m)| m.amount)
|
||||
.sum();
|
||||
assert_eq!(debits, Decimal::from(100));
|
||||
assert_eq!(credits, Decimal::from(100));
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,693 @@
|
||||
//! Spec-derived tests for an asset's federal tax basis (rules.md B-25):
|
||||
//! `TaxBasisRule`, `Ledger::set_asset_tax_basis` / `check_asset_tax_basis`,
|
||||
//! `Asset::tax_basis`, `Asset::built_in_gain`, and the tax fields of
|
||||
//! `reports::fixed_asset_register`.
|
||||
//!
|
||||
//! Every expected value comes from the written specification: the doc
|
||||
//! comments and INVARIANTS/SCENARIOS blocks on those items, and the
|
||||
//! vendored sources (`docs/spec/sources/irs-partnership-basis.md`): cost
|
||||
//! basis (IRC §1012(a)), carryover of the contributor's adjusted basis
|
||||
//! (IRC §723), the lesser of fair market value and adjusted basis on a
|
||||
//! conversion from personal use (Treas. Reg. §1.168(i)-4(b)(1)), and
|
||||
//! built-in gain or loss as fair market value less the contributor's basis
|
||||
//! (IRC §704(c)). Amounts are worked by hand.
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::{
|
||||
AccountId, AccountType, AssetId, Currency, Ledger, LedgerError, Money, NewAsset, Side,
|
||||
TaxBasis, TaxBasisRule, fixed_asset_register,
|
||||
};
|
||||
use proptest::prelude::*;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
fn d(s: &str) -> Decimal {
|
||||
Decimal::from_str(s).expect("decimal literal")
|
||||
}
|
||||
|
||||
fn usd(s: &str) -> Money {
|
||||
Money::new(d(s), Currency::USD)
|
||||
}
|
||||
|
||||
fn date(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, day).expect("valid date")
|
||||
}
|
||||
|
||||
struct Accts {
|
||||
equipment: AccountId,
|
||||
accum: AccountId,
|
||||
depr_expense: AccountId,
|
||||
}
|
||||
|
||||
/// A USD ledger with a fixed-asset chart, funded with $50,000 cash.
|
||||
fn fixture() -> (Ledger, Accts) {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let mut acct = |code: &str, name: &str, ty: AccountType| {
|
||||
l.add_account(code, name, ty, None, false).unwrap()
|
||||
};
|
||||
let cash = acct("1000", "Cash", AccountType::Asset);
|
||||
let equipment = acct("1500", "Equipment", AccountType::Asset);
|
||||
let accum = acct("1510", "Accumulated Depreciation", AccountType::Asset);
|
||||
let capital = acct("3000", "Owner Capital", AccountType::Equity);
|
||||
let depr_expense = acct("6200", "Depreciation Expense", AccountType::Expense);
|
||||
let e = l.try_create_entry(date(2026, 1, 1), "funding").unwrap();
|
||||
l.add_line(e, cash, Side::Debit, usd("50000.00")).unwrap();
|
||||
l.add_line(e, capital, Side::Credit, usd("50000.00"))
|
||||
.unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
(
|
||||
l,
|
||||
Accts {
|
||||
equipment,
|
||||
accum,
|
||||
depr_expense,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn register(l: &mut Ledger, a: &Accts, description: &str, cost: &str) -> AssetId {
|
||||
l.register_asset(NewAsset {
|
||||
description: description.to_string(),
|
||||
purchase_date: date(2026, 1, 1),
|
||||
cost: usd(cost),
|
||||
salvage_value: usd("0.00"),
|
||||
useful_life_months: 36,
|
||||
cost_account: a.equipment,
|
||||
accum_depr_account: a.accum,
|
||||
expense_account: a.depr_expense,
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn tb(rule: TaxBasisRule, basis: Option<&str>) -> TaxBasis {
|
||||
TaxBasis {
|
||||
rule,
|
||||
contributor_basis: basis.map(d),
|
||||
}
|
||||
}
|
||||
|
||||
/// A ledger with one asset of book cost (fair market value) `cost`.
|
||||
fn one(cost: &str) -> (Ledger, AssetId) {
|
||||
let (mut l, a) = fixture();
|
||||
let id = register(&mut l, &a, "Laptop", cost);
|
||||
(l, id)
|
||||
}
|
||||
|
||||
fn set(l: &mut Ledger, id: AssetId, rule: TaxBasisRule, basis: Option<&str>) {
|
||||
l.set_asset_tax_basis(id, tb(rule, basis)).unwrap();
|
||||
}
|
||||
|
||||
const RULES: [TaxBasisRule; 3] = [
|
||||
TaxBasisRule::Cost,
|
||||
TaxBasisRule::Carryover,
|
||||
TaxBasisRule::PersonalUseConversion,
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------- TaxBasisRule
|
||||
|
||||
/// TaxBasisRule::key: the wire names `cost`, `carryover`,
|
||||
/// `personal_use_conversion`.
|
||||
#[test]
|
||||
fn rule_keys_are_the_documented_wire_names() {
|
||||
assert_eq!(TaxBasisRule::Cost.key(), "cost");
|
||||
assert_eq!(TaxBasisRule::Carryover.key(), "carryover");
|
||||
assert_eq!(
|
||||
TaxBasisRule::PersonalUseConversion.key(),
|
||||
"personal_use_conversion"
|
||||
);
|
||||
}
|
||||
|
||||
/// TaxBasisRule::parse: the rule named `key`; round-trips with key().
|
||||
#[test]
|
||||
fn rule_parse_round_trips_with_key() {
|
||||
for r in RULES {
|
||||
assert_eq!(TaxBasisRule::parse(r.key()), Some(r));
|
||||
}
|
||||
}
|
||||
|
||||
/// Anything other than a documented wire name is no rule.
|
||||
#[test]
|
||||
fn rule_parse_refuses_other_names() {
|
||||
for bad in [
|
||||
"",
|
||||
"Cost",
|
||||
"COST",
|
||||
" cost",
|
||||
"cost ",
|
||||
"carry_over",
|
||||
"personal-use-conversion",
|
||||
"PersonalUseConversion",
|
||||
"fmv",
|
||||
] {
|
||||
assert_eq!(TaxBasisRule::parse(bad), None, "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The serde names are snake_case, the same as the wire names.
|
||||
#[test]
|
||||
fn rule_serde_names_are_the_wire_names() {
|
||||
for r in RULES {
|
||||
let j = serde_json::to_value(r).unwrap();
|
||||
assert_eq!(j, serde_json::Value::String(r.key().to_string()));
|
||||
let back: TaxBasisRule = serde_json::from_value(j).unwrap();
|
||||
assert_eq!(back, r);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- set / check
|
||||
|
||||
/// SCENARIO 1: an unknown asset is refused with UnknownAsset.
|
||||
#[test]
|
||||
fn an_unknown_asset_is_refused() {
|
||||
let (mut l, id) = one("1000.00");
|
||||
let before = l.clone();
|
||||
let unknown = id + 99;
|
||||
for t in [
|
||||
tb(TaxBasisRule::Cost, None),
|
||||
tb(TaxBasisRule::Carryover, Some("500.00")),
|
||||
] {
|
||||
assert!(matches!(
|
||||
l.check_asset_tax_basis(unknown, t),
|
||||
Err(LedgerError::UnknownAsset(x)) if x == unknown
|
||||
));
|
||||
assert!(matches!(
|
||||
l.set_asset_tax_basis(unknown, t),
|
||||
Err(LedgerError::UnknownAsset(x)) if x == unknown
|
||||
));
|
||||
}
|
||||
assert_eq!(l, before, "INVARIANT 3");
|
||||
}
|
||||
|
||||
/// INVARIANT 1: Cost takes no contributor basis.
|
||||
#[test]
|
||||
fn cost_with_a_contributor_basis_is_refused() {
|
||||
let (mut l, id) = one("1000.00");
|
||||
let before = l.clone();
|
||||
for b in ["0.00", "500.00", "1000.00"] {
|
||||
let t = tb(TaxBasisRule::Cost, Some(b));
|
||||
assert!(matches!(
|
||||
l.check_asset_tax_basis(id, t),
|
||||
Err(LedgerError::InvalidTaxBasis(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
l.set_asset_tax_basis(id, t),
|
||||
Err(LedgerError::InvalidTaxBasis(_))
|
||||
));
|
||||
}
|
||||
assert_eq!(l, before, "INVARIANT 3");
|
||||
assert_eq!(l.get_asset(id).unwrap().tax, None);
|
||||
}
|
||||
|
||||
/// INVARIANT 1: Carryover and PersonalUseConversion require one.
|
||||
#[test]
|
||||
fn a_contribution_without_a_contributor_basis_is_refused() {
|
||||
let (mut l, id) = one("1000.00");
|
||||
let before = l.clone();
|
||||
for r in [TaxBasisRule::Carryover, TaxBasisRule::PersonalUseConversion] {
|
||||
let t = tb(r, None);
|
||||
assert!(matches!(
|
||||
l.check_asset_tax_basis(id, t),
|
||||
Err(LedgerError::InvalidTaxBasis(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
l.set_asset_tax_basis(id, t),
|
||||
Err(LedgerError::InvalidTaxBasis(_))
|
||||
));
|
||||
}
|
||||
assert_eq!(l, before, "INVARIANT 3");
|
||||
}
|
||||
|
||||
/// INVARIANT 2: a contributor basis is non-negative.
|
||||
#[test]
|
||||
fn a_negative_contributor_basis_is_refused() {
|
||||
let (mut l, id) = one("1000.00");
|
||||
let before = l.clone();
|
||||
for r in [TaxBasisRule::Carryover, TaxBasisRule::PersonalUseConversion] {
|
||||
for b in ["-0.01", "-1", "-500.00"] {
|
||||
let t = tb(r, Some(b));
|
||||
assert!(
|
||||
matches!(
|
||||
l.set_asset_tax_basis(id, t),
|
||||
Err(LedgerError::InvalidTaxBasis(_))
|
||||
),
|
||||
"{r:?} {b}"
|
||||
);
|
||||
assert!(l.check_asset_tax_basis(id, t).is_err());
|
||||
}
|
||||
}
|
||||
assert_eq!(l, before, "INVARIANT 3");
|
||||
}
|
||||
|
||||
/// INVARIANT 2: in cents at most.
|
||||
#[test]
|
||||
fn more_than_two_decimal_places_is_refused() {
|
||||
let (mut l, id) = one("1000.00");
|
||||
let before = l.clone();
|
||||
for r in [TaxBasisRule::Carryover, TaxBasisRule::PersonalUseConversion] {
|
||||
for b in ["500.001", "0.005", "1.2345"] {
|
||||
let t = tb(r, Some(b));
|
||||
assert!(
|
||||
matches!(
|
||||
l.set_asset_tax_basis(id, t),
|
||||
Err(LedgerError::InvalidTaxBasis(_))
|
||||
),
|
||||
"{r:?} {b}"
|
||||
);
|
||||
assert!(l.check_asset_tax_basis(id, t).is_err());
|
||||
}
|
||||
}
|
||||
assert_eq!(l, before, "INVARIANT 3");
|
||||
}
|
||||
|
||||
/// Valid records: zero is non-negative; whole dollars and one decimal
|
||||
/// place are "in cents at most". check agrees with set, and a valid set
|
||||
/// changes nothing but the asset's tax record (book figures don't change).
|
||||
#[test]
|
||||
fn valid_records_are_accepted_and_book_figures_stay() {
|
||||
for (r, b) in [
|
||||
(TaxBasisRule::Cost, None),
|
||||
(TaxBasisRule::Carryover, Some("0")),
|
||||
(TaxBasisRule::Carryover, Some("0.00")),
|
||||
(TaxBasisRule::Carryover, Some("750")),
|
||||
(TaxBasisRule::PersonalUseConversion, Some("750.5")),
|
||||
(TaxBasisRule::PersonalUseConversion, Some("750.50")),
|
||||
(TaxBasisRule::PersonalUseConversion, Some("2500.00")),
|
||||
] {
|
||||
let (mut l, id) = one("1000.00");
|
||||
let t = tb(r, b);
|
||||
l.check_asset_tax_basis(id, t).unwrap();
|
||||
let before = l.get_asset(id).unwrap().clone();
|
||||
l.set_asset_tax_basis(id, t).unwrap();
|
||||
let after = l.get_asset(id).unwrap();
|
||||
assert_eq!(after.tax, Some(t), "{r:?} {b:?}");
|
||||
// Book figures don't change.
|
||||
let mut same = after.clone();
|
||||
same.tax = before.tax;
|
||||
assert_eq!(same, before, "{r:?} {b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// SCENARIO 2: set again, the new record replaces the old.
|
||||
#[test]
|
||||
fn setting_again_replaces() {
|
||||
let (mut l, id) = one("1000.00");
|
||||
set(&mut l, id, TaxBasisRule::Cost, None);
|
||||
assert_eq!(l.get_asset(id).unwrap().tax_basis(), Some(d("1000.00")));
|
||||
set(&mut l, id, TaxBasisRule::Carryover, Some("400.00"));
|
||||
let a = l.get_asset(id).unwrap();
|
||||
assert_eq!(a.tax, Some(tb(TaxBasisRule::Carryover, Some("400.00"))));
|
||||
assert_eq!(a.tax_basis(), Some(d("400.00")));
|
||||
set(
|
||||
&mut l,
|
||||
id,
|
||||
TaxBasisRule::PersonalUseConversion,
|
||||
Some("1200.00"),
|
||||
);
|
||||
let a = l.get_asset(id).unwrap();
|
||||
assert_eq!(
|
||||
a.tax,
|
||||
Some(tb(TaxBasisRule::PersonalUseConversion, Some("1200.00")))
|
||||
);
|
||||
assert_eq!(a.tax_basis(), Some(d("1000.00")));
|
||||
set(&mut l, id, TaxBasisRule::Cost, None);
|
||||
let a = l.get_asset(id).unwrap();
|
||||
assert_eq!(a.tax, Some(tb(TaxBasisRule::Cost, None)));
|
||||
assert_eq!(a.built_in_gain(), None);
|
||||
}
|
||||
|
||||
/// INVARIANT 3: a refusal after a valid record leaves that record.
|
||||
#[test]
|
||||
fn a_refusal_keeps_the_earlier_record() {
|
||||
let (mut l, id) = one("1000.00");
|
||||
set(&mut l, id, TaxBasisRule::Carryover, Some("300.00"));
|
||||
let before = l.clone();
|
||||
assert!(
|
||||
l.set_asset_tax_basis(id, tb(TaxBasisRule::Cost, Some("1.00")))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
l.set_asset_tax_basis(id, tb(TaxBasisRule::Carryover, None))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
l.set_asset_tax_basis(id, tb(TaxBasisRule::Carryover, Some("-1.00")))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
l.set_asset_tax_basis(id, tb(TaxBasisRule::Carryover, Some("1.001")))
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(l, before);
|
||||
assert_eq!(l.get_asset(id).unwrap().tax_basis(), Some(d("300.00")));
|
||||
}
|
||||
|
||||
/// Setting one asset's record leaves the others alone.
|
||||
#[test]
|
||||
fn a_record_belongs_to_one_asset() {
|
||||
let (mut l, a) = fixture();
|
||||
let x = register(&mut l, &a, "Laptop", "1000.00");
|
||||
let y = register(&mut l, &a, "Desk", "400.00");
|
||||
set(&mut l, x, TaxBasisRule::Carryover, Some("800.00"));
|
||||
assert_eq!(l.get_asset(y).unwrap().tax, None);
|
||||
assert_eq!(l.get_asset(y).unwrap().tax_basis(), None);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- tax_basis
|
||||
|
||||
/// Not recorded: None (and no built-in gain).
|
||||
#[test]
|
||||
fn no_record_means_no_tax_basis() {
|
||||
let (l, id) = one("1000.00");
|
||||
let a = l.get_asset(id).unwrap();
|
||||
assert_eq!(a.tax, None);
|
||||
assert_eq!(a.tax_basis(), None);
|
||||
assert_eq!(a.built_in_gain(), None);
|
||||
}
|
||||
|
||||
/// INVARIANT 1: Cost: the book cost (IRC §1012(a)).
|
||||
#[test]
|
||||
fn cost_basis_is_the_book_cost() {
|
||||
let (mut l, id) = one("1234.56");
|
||||
set(&mut l, id, TaxBasisRule::Cost, None);
|
||||
let a = l.get_asset(id).unwrap();
|
||||
assert_eq!(a.tax_basis(), Some(d("1234.56")));
|
||||
// A purchase has no built-in gain.
|
||||
assert_eq!(a.built_in_gain(), None);
|
||||
}
|
||||
|
||||
/// INVARIANT 2: Carryover: the contributor's adjusted basis (IRC §723),
|
||||
/// whether above or below fair market value.
|
||||
#[test]
|
||||
fn carryover_basis_is_the_contributors_basis() {
|
||||
for (fmv, basis, gain) in [
|
||||
("1000.00", "600.00", "400.00"), // built-in gain
|
||||
("1000.00", "1500.00", "-500.00"), // built-in loss
|
||||
("1000.00", "1000.00", "0.00"), // boundary
|
||||
("1000.00", "0.00", "1000.00"), // fully depreciated
|
||||
] {
|
||||
let (mut l, id) = one(fmv);
|
||||
set(&mut l, id, TaxBasisRule::Carryover, Some(basis));
|
||||
let a = l.get_asset(id).unwrap();
|
||||
assert_eq!(a.tax_basis(), Some(d(basis)), "{fmv} {basis}");
|
||||
assert_eq!(a.built_in_gain(), Some(d(gain)), "{fmv} {basis}");
|
||||
}
|
||||
}
|
||||
|
||||
/// INVARIANT 3: PersonalUseConversion: min(book cost, contributor's basis)
|
||||
/// (Treas. Reg. §1.168(i)-4(b)(1): "the lesser of its fair market value or
|
||||
/// its adjusted depreciable basis").
|
||||
#[test]
|
||||
fn personal_use_conversion_is_the_lesser_of_value_and_basis() {
|
||||
for (fmv, basis, tax, gain) in [
|
||||
// Paid 2,000, worth 1,200 now: basis 1,200; built-in loss −800.
|
||||
("1200.00", "2000.00", "1200.00", "-800.00"),
|
||||
// Paid 300, worth 450: basis 300; built-in gain 150.
|
||||
("450.00", "300.00", "300.00", "150.00"),
|
||||
// Boundary: value equals basis.
|
||||
("999.99", "999.99", "999.99", "0.00"),
|
||||
// A cent either side of the boundary.
|
||||
("1000.00", "1000.01", "1000.00", "-0.01"),
|
||||
("1000.00", "999.99", "999.99", "0.01"),
|
||||
] {
|
||||
let (mut l, id) = one(fmv);
|
||||
set(&mut l, id, TaxBasisRule::PersonalUseConversion, Some(basis));
|
||||
let a = l.get_asset(id).unwrap();
|
||||
assert_eq!(a.tax_basis(), Some(d(tax)), "{fmv} {basis}");
|
||||
assert_eq!(a.built_in_gain(), Some(d(gain)), "{fmv} {basis}");
|
||||
}
|
||||
}
|
||||
|
||||
/// built_in_gain: FMV (book cost) less the contributor's basis; positive
|
||||
/// for a gain, negative for a loss, zero at FMV == basis, regardless of
|
||||
/// which contribution rule (the definition does not depend on the rule).
|
||||
#[test]
|
||||
fn built_in_gain_sign_and_boundary() {
|
||||
for r in [TaxBasisRule::Carryover, TaxBasisRule::PersonalUseConversion] {
|
||||
let (mut l, id) = one("800.00");
|
||||
set(&mut l, id, r, Some("500.00"));
|
||||
let g = l.get_asset(id).unwrap().built_in_gain().unwrap();
|
||||
assert_eq!(g, d("300.00"));
|
||||
assert!(g > Decimal::ZERO, "{r:?}: FMV > basis is a gain");
|
||||
|
||||
set(&mut l, id, r, Some("1100.00"));
|
||||
let g = l.get_asset(id).unwrap().built_in_gain().unwrap();
|
||||
assert_eq!(g, d("-300.00"));
|
||||
assert!(g < Decimal::ZERO, "{r:?}: basis > FMV is a loss");
|
||||
|
||||
set(&mut l, id, r, Some("800.00"));
|
||||
let g = l.get_asset(id).unwrap().built_in_gain().unwrap();
|
||||
assert!(g.is_zero(), "{r:?}: FMV == basis is neither");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- register
|
||||
|
||||
struct RegisterBooks {
|
||||
l: Ledger,
|
||||
bought: AssetId,
|
||||
carried: AssetId,
|
||||
converted: AssetId,
|
||||
unrecorded: AssetId,
|
||||
sold: AssetId,
|
||||
}
|
||||
|
||||
/// Five assets: a purchase at cost, a carryover contribution, a
|
||||
/// personal-use conversion, one with no tax basis, and one disposed (with
|
||||
/// a tax basis, which the totals must leave out).
|
||||
fn register_books() -> RegisterBooks {
|
||||
let (mut l, a) = fixture();
|
||||
let bought = register(&mut l, &a, "Server", "3000.00");
|
||||
let carried = register(&mut l, &a, "Printer", "500.00");
|
||||
let converted = register(&mut l, &a, "Laptop", "1200.00");
|
||||
let unrecorded = register(&mut l, &a, "Desk", "700.00");
|
||||
let sold = register(&mut l, &a, "Old monitor", "360.00");
|
||||
set(&mut l, bought, TaxBasisRule::Cost, None);
|
||||
set(&mut l, carried, TaxBasisRule::Carryover, Some("650.25"));
|
||||
set(
|
||||
&mut l,
|
||||
converted,
|
||||
TaxBasisRule::PersonalUseConversion,
|
||||
Some("2000.00"),
|
||||
);
|
||||
set(&mut l, sold, TaxBasisRule::Cost, None);
|
||||
l.dispose_asset(sold, date(2026, 3, 31), usd("100.00"))
|
||||
.unwrap();
|
||||
RegisterBooks {
|
||||
l,
|
||||
bought,
|
||||
carried,
|
||||
converted,
|
||||
unrecorded,
|
||||
sold,
|
||||
}
|
||||
}
|
||||
|
||||
/// INVARIANT 5: each row's tax fields are the asset's.
|
||||
#[test]
|
||||
fn register_rows_carry_the_assets_tax_fields() {
|
||||
let b = register_books();
|
||||
let r = fixed_asset_register(&b.l, date(2026, 6, 30)).unwrap();
|
||||
assert_eq!(r.rows.len(), 5);
|
||||
for row in &r.rows {
|
||||
let a = b.l.get_asset(row.asset_id).unwrap();
|
||||
assert_eq!(row.tax_rule, a.tax.map(|t| t.rule));
|
||||
assert_eq!(
|
||||
row.contributor_basis,
|
||||
a.tax.and_then(|t| t.contributor_basis)
|
||||
);
|
||||
assert_eq!(row.tax_basis, a.tax_basis());
|
||||
assert_eq!(row.built_in_gain, a.built_in_gain());
|
||||
}
|
||||
let row = |id| r.rows.iter().find(|x| x.asset_id == id).unwrap();
|
||||
// By hand.
|
||||
let x = row(b.bought);
|
||||
assert_eq!(x.tax_rule, Some(TaxBasisRule::Cost));
|
||||
assert_eq!(x.contributor_basis, None);
|
||||
assert_eq!(x.tax_basis, Some(d("3000.00")));
|
||||
assert_eq!(x.built_in_gain, None);
|
||||
let x = row(b.carried);
|
||||
assert_eq!(x.tax_rule, Some(TaxBasisRule::Carryover));
|
||||
assert_eq!(x.contributor_basis, Some(d("650.25")));
|
||||
assert_eq!(x.tax_basis, Some(d("650.25")));
|
||||
assert_eq!(x.built_in_gain, Some(d("-150.25")));
|
||||
let x = row(b.converted);
|
||||
assert_eq!(x.tax_rule, Some(TaxBasisRule::PersonalUseConversion));
|
||||
assert_eq!(x.contributor_basis, Some(d("2000.00")));
|
||||
assert_eq!(x.tax_basis, Some(d("1200.00")));
|
||||
assert_eq!(x.built_in_gain, Some(d("-800.00")));
|
||||
let x = row(b.unrecorded);
|
||||
assert_eq!(x.tax_rule, None);
|
||||
assert_eq!(x.contributor_basis, None);
|
||||
assert_eq!(x.tax_basis, None);
|
||||
assert_eq!(x.built_in_gain, None);
|
||||
// A disposed asset's row still shows its record.
|
||||
let x = row(b.sold);
|
||||
assert!(x.disposed.is_some());
|
||||
assert_eq!(x.tax_rule, Some(TaxBasisRule::Cost));
|
||||
assert_eq!(x.tax_basis, Some(d("360.00")));
|
||||
}
|
||||
|
||||
/// INVARIANT 3: total_tax_basis sums held assets with a basis, exactly;
|
||||
/// missing_tax_basis counts held ones without; disposed excluded.
|
||||
#[test]
|
||||
fn register_tax_totals_cover_held_assets_only() {
|
||||
let b = register_books();
|
||||
let r = fixed_asset_register(&b.l, date(2026, 6, 30)).unwrap();
|
||||
// 3000.00 + 650.25 + 1200.00 (the disposed 360.00 left out).
|
||||
assert_eq!(r.total_tax_basis, d("4850.25"));
|
||||
assert_eq!(r.missing_tax_basis, 1);
|
||||
// Before the disposal date the old monitor is still held.
|
||||
let r = fixed_asset_register(&b.l, date(2026, 2, 28)).unwrap();
|
||||
assert_eq!(r.total_tax_basis, d("5210.25"));
|
||||
assert_eq!(r.missing_tax_basis, 1);
|
||||
}
|
||||
|
||||
/// A disposed asset without a tax basis isn't counted as missing.
|
||||
#[test]
|
||||
fn a_disposed_asset_without_a_basis_is_not_missing() {
|
||||
let (mut l, a) = fixture();
|
||||
let x = register(&mut l, &a, "Laptop", "1000.00");
|
||||
let y = register(&mut l, &a, "Desk", "400.00");
|
||||
l.dispose_asset(y, date(2026, 3, 31), usd("0.00")).unwrap();
|
||||
let r = fixed_asset_register(&l, date(2026, 6, 30)).unwrap();
|
||||
assert_eq!(r.missing_tax_basis, 1);
|
||||
assert_eq!(r.total_tax_basis, Decimal::ZERO);
|
||||
set(&mut l, x, TaxBasisRule::Carryover, Some("900.00"));
|
||||
let r = fixed_asset_register(&l, date(2026, 6, 30)).unwrap();
|
||||
assert_eq!(r.missing_tax_basis, 0);
|
||||
assert_eq!(r.total_tax_basis, d("900.00"));
|
||||
}
|
||||
|
||||
/// No assets: zero total, none missing.
|
||||
#[test]
|
||||
fn an_empty_register_has_no_tax_basis() {
|
||||
let (l, _) = fixture();
|
||||
let r = fixed_asset_register(&l, date(2026, 6, 30)).unwrap();
|
||||
assert!(r.rows.is_empty());
|
||||
assert_eq!(r.total_tax_basis, Decimal::ZERO);
|
||||
assert_eq!(r.missing_tax_basis, 0);
|
||||
}
|
||||
|
||||
/// Recording tax basis leaves the book totals alone.
|
||||
#[test]
|
||||
fn tax_basis_does_not_move_book_totals() {
|
||||
let (mut l, a) = fixture();
|
||||
let x = register(&mut l, &a, "Laptop", "1200.00");
|
||||
let before = fixed_asset_register(&l, date(2026, 6, 30)).unwrap();
|
||||
set(
|
||||
&mut l,
|
||||
x,
|
||||
TaxBasisRule::PersonalUseConversion,
|
||||
Some("2000.00"),
|
||||
);
|
||||
let after = fixed_asset_register(&l, date(2026, 6, 30)).unwrap();
|
||||
assert_eq!(after.total_cost, before.total_cost);
|
||||
assert_eq!(after.total_accumulated, before.total_accumulated);
|
||||
assert_eq!(after.total_book_value, before.total_book_value);
|
||||
assert_eq!(after.ledger_cost, before.ledger_cost);
|
||||
assert_eq!(after.ledger_accumulated, before.ledger_accumulated);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- proptest
|
||||
|
||||
fn cents() -> impl Strategy<Value = Decimal> {
|
||||
(0i64..=100_000_000).prop_map(|c| Decimal::new(c, 2))
|
||||
}
|
||||
|
||||
fn positive_cents() -> impl Strategy<Value = Decimal> {
|
||||
(1i64..=100_000_000).prop_map(|c| Decimal::new(c, 2))
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(256))]
|
||||
|
||||
/// Asset::tax_basis INVARIANTS 1–3 and built_in_gain over random
|
||||
/// costs, bases, and rules; register totals agree.
|
||||
#[test]
|
||||
fn prop_tax_basis_follows_its_rule(
|
||||
assets in proptest::collection::vec((positive_cents(), cents(), 0usize..4), 1..8)
|
||||
) {
|
||||
let (mut l, a) = fixture();
|
||||
let mut expected_total = Decimal::ZERO;
|
||||
let mut expected_missing = 0usize;
|
||||
for (i, (cost, basis, rule)) in assets.iter().enumerate() {
|
||||
let id = register(&mut l, &a, &format!("Asset {i}"), &cost.to_string());
|
||||
let want = match rule {
|
||||
0 => {
|
||||
set(&mut l, id, TaxBasisRule::Cost, None);
|
||||
Some((*cost, None))
|
||||
}
|
||||
1 => {
|
||||
l.set_asset_tax_basis(id, TaxBasis {
|
||||
rule: TaxBasisRule::Carryover,
|
||||
contributor_basis: Some(*basis),
|
||||
}).unwrap();
|
||||
Some((*basis, Some(*cost - *basis)))
|
||||
}
|
||||
2 => {
|
||||
l.set_asset_tax_basis(id, TaxBasis {
|
||||
rule: TaxBasisRule::PersonalUseConversion,
|
||||
contributor_basis: Some(*basis),
|
||||
}).unwrap();
|
||||
Some((if cost < basis { *cost } else { *basis }, Some(*cost - *basis)))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let asset = l.get_asset(id).unwrap();
|
||||
match want {
|
||||
Some((tax, gain)) => {
|
||||
prop_assert_eq!(asset.tax_basis(), Some(tax));
|
||||
prop_assert_eq!(asset.built_in_gain(), gain);
|
||||
expected_total += tax;
|
||||
}
|
||||
None => {
|
||||
prop_assert_eq!(asset.tax_basis(), None);
|
||||
expected_missing += 1;
|
||||
}
|
||||
}
|
||||
if let Some(t) = asset.tax_basis() {
|
||||
// Personal-use conversion never exceeds fair market value.
|
||||
if *rule == 2 {
|
||||
prop_assert!(t <= *cost);
|
||||
prop_assert!(t <= *basis);
|
||||
}
|
||||
}
|
||||
}
|
||||
let r = fixed_asset_register(&l, date(2026, 6, 30)).unwrap();
|
||||
prop_assert_eq!(r.total_tax_basis, expected_total);
|
||||
prop_assert_eq!(r.missing_tax_basis, expected_missing);
|
||||
}
|
||||
|
||||
/// check agrees with set, and a refused record changes nothing.
|
||||
#[test]
|
||||
fn prop_check_agrees_with_set(
|
||||
cents in -1_000_000i64..1_000_000,
|
||||
scale in 0u32..5,
|
||||
rule in 0usize..3,
|
||||
some in any::<bool>(),
|
||||
) {
|
||||
let (mut l, id) = one("1000.00");
|
||||
let basis = some.then(|| Decimal::new(cents, scale));
|
||||
let t = TaxBasis { rule: RULES[rule], contributor_basis: basis };
|
||||
let before = l.clone();
|
||||
let checked = l.check_asset_tax_basis(id, t);
|
||||
let done = l.set_asset_tax_basis(id, t);
|
||||
prop_assert_eq!(checked.is_ok(), done.is_ok());
|
||||
// The spec, independently: INVARIANTS 1 and 2.
|
||||
let valid = match (RULES[rule], basis) {
|
||||
(TaxBasisRule::Cost, None) => true,
|
||||
(TaxBasisRule::Cost, Some(_)) => false,
|
||||
(_, None) => false,
|
||||
(_, Some(b)) => b >= Decimal::ZERO && b.normalize().scale() <= 2,
|
||||
};
|
||||
prop_assert_eq!(done.is_ok(), valid, "{:?} {:?}", RULES[rule], basis);
|
||||
if done.is_err() {
|
||||
prop_assert_eq!(&l, &before);
|
||||
} else {
|
||||
prop_assert_eq!(l.get_asset(id).unwrap().tax, Some(t));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# Seeds for failure cases proptest has generated in the past. It is
|
||||
# automatically read and these particular cases re-run before any
|
||||
# novel cases are generated.
|
||||
#
|
||||
# It is recommended to check this file in to source control so that
|
||||
# everyone who runs the test benefits from these saved cases.
|
||||
cc b177aed2b1d69b6a0abb37b170ec5b9efc73d7fae49bace065c15c819ecca711 # shrinks to months = [(0, 0, 0, 507, 37392), (0, 0, 0, 9054, 3952), (0, 0, 0, 34144, 7979)], closes = [false, true, false, false, false, false, false, false, false, false, false, false], split = 14
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,344 @@
|
||||
//! Tax categories and the tax summary. Expected numbers computed by hand.
|
||||
//!
|
||||
//! The fixture: a partnership's first quarter.
|
||||
//!
|
||||
//! | account | category | % | book | deductible |
|
||||
//! |-----------------------|-----------------------|---|---------|------------|
|
||||
//! | 4000 Sales | gross_receipts | | 10,000 | 10,000 |
|
||||
//! | 4100 Refunds (exp.) | returns_allowances | | 250 | (250) |
|
||||
//! | 6000 Platform fees | commissions_fees | | 1,500 | 1,500 |
|
||||
//! | 6100 Meals | meals |50%| 333.33 | 166.67 |
|
||||
//! | 6200 Penalties | nondeductible | 0%| 40 | 0 |
|
||||
//! | 6300 Software | other_deductions | | 600 | 600 |
|
||||
//! | 6400 Partner pay | guaranteed_payments | | 2,000 | 2,000 |
|
||||
//! | 6500 Misc | (none) | | 75 | uncategorized |
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::tax::{TaxForm, check_category, tax_summary};
|
||||
use ledger_core::{AccountType, Currency, Ledger, LedgerError, Money, Side};
|
||||
use rust_decimal::Decimal;
|
||||
use std::str::FromStr;
|
||||
|
||||
fn d(m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, m, day).unwrap()
|
||||
}
|
||||
|
||||
fn dec(s: &str) -> Decimal {
|
||||
Decimal::from_str(s).unwrap()
|
||||
}
|
||||
|
||||
fn books() -> Ledger {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let cash = l
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let mut acct =
|
||||
|code: &str, name: &str, t: AccountType, cat: Option<&str>, pct: Option<&str>| {
|
||||
let id = l.add_account(code, name, t, None, false).unwrap();
|
||||
if let Some(c) = cat {
|
||||
l.set_account_tax(id, Some(c), pct.map(dec)).unwrap();
|
||||
}
|
||||
id
|
||||
};
|
||||
let sales = acct(
|
||||
"4000",
|
||||
"Sales",
|
||||
AccountType::Revenue,
|
||||
Some("gross_receipts"),
|
||||
None,
|
||||
);
|
||||
let refunds = acct(
|
||||
"4100",
|
||||
"Refunds",
|
||||
AccountType::Expense,
|
||||
Some("returns_allowances"),
|
||||
None,
|
||||
);
|
||||
let fees = acct(
|
||||
"6000",
|
||||
"Platform fees",
|
||||
AccountType::Expense,
|
||||
Some("commissions_fees"),
|
||||
None,
|
||||
);
|
||||
let meals = acct("6100", "Meals", AccountType::Expense, Some("meals"), None);
|
||||
let pen = acct(
|
||||
"6200",
|
||||
"Penalties",
|
||||
AccountType::Expense,
|
||||
Some("nondeductible"),
|
||||
None,
|
||||
);
|
||||
let soft = acct(
|
||||
"6300",
|
||||
"Software",
|
||||
AccountType::Expense,
|
||||
Some("other_deductions"),
|
||||
None,
|
||||
);
|
||||
let gp = acct(
|
||||
"6400",
|
||||
"Partner pay",
|
||||
AccountType::Expense,
|
||||
Some("guaranteed_payments"),
|
||||
None,
|
||||
);
|
||||
let misc = acct("6500", "Misc", AccountType::Expense, None, None);
|
||||
let mut post = |dr, cr, amt: &str| {
|
||||
let e = l.create_entry(d(2, 1), "x");
|
||||
l.add_line(e, dr, Side::Debit, Money::new(dec(amt), Currency::USD))
|
||||
.unwrap();
|
||||
l.add_line(e, cr, Side::Credit, Money::new(dec(amt), Currency::USD))
|
||||
.unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
};
|
||||
post(cash, sales, "10000");
|
||||
for (a, amt) in [
|
||||
(refunds, "250"),
|
||||
(fees, "1500"),
|
||||
(meals, "333.33"),
|
||||
(pen, "40"),
|
||||
(soft, "600"),
|
||||
(gp, "2000"),
|
||||
(misc, "75"),
|
||||
] {
|
||||
post(a, cash, amt);
|
||||
}
|
||||
l
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_summary_groups_by_category_and_applies_deductible_percents() {
|
||||
let l = books();
|
||||
let ts = tax_summary(&l, d(1, 1), d(3, 31), Some(TaxForm::Form1065)).unwrap();
|
||||
let rows = |lines: &[ledger_core::TaxSummaryLine]| -> Vec<(String, Decimal, Decimal)> {
|
||||
lines
|
||||
.iter()
|
||||
.map(|l| (l.category.clone(), l.book, l.tax))
|
||||
.collect()
|
||||
};
|
||||
// Refunds (an expense account) reduce receipts as negative income.
|
||||
assert_eq!(
|
||||
rows(&ts.income),
|
||||
[
|
||||
("gross_receipts".into(), dec("10000"), dec("10000")),
|
||||
("returns_allowances".into(), dec("-250"), dec("-250")),
|
||||
]
|
||||
);
|
||||
// Catalog order; meals at 50% (333.33 → 166.665 → 166.67, half away
|
||||
// from zero); penalties at 0%.
|
||||
assert_eq!(
|
||||
rows(&ts.deductions),
|
||||
[
|
||||
("commissions_fees".into(), dec("1500"), dec("1500")),
|
||||
("guaranteed_payments".into(), dec("2000"), dec("2000")),
|
||||
("meals".into(), dec("333.33"), dec("166.67")),
|
||||
("other_deductions".into(), dec("600"), dec("600")),
|
||||
("nondeductible".into(), dec("40"), dec("0")),
|
||||
]
|
||||
);
|
||||
assert_eq!(ts.uncategorized.len(), 1);
|
||||
assert_eq!(ts.uncategorized[0].account, "6500 Misc");
|
||||
assert_eq!(ts.uncategorized[0].book, dec("75"));
|
||||
// Book: 9,750 − 4,473.33 = 5,276.67. Tax: 9,750 − 4,266.67 = 5,483.33.
|
||||
assert_eq!(ts.book_net(), dec("5276.67"));
|
||||
assert_eq!(ts.tax_net(), dec("5483.33"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_override_beats_the_category_default() {
|
||||
let mut l = books();
|
||||
let meals = l.chart().find_by_code("6100").unwrap().id;
|
||||
// A company party is 100% deductible: override the 50% default.
|
||||
l.set_account_tax(meals, Some("meals"), Some(dec("100")))
|
||||
.unwrap();
|
||||
let ts = tax_summary(&l, d(1, 1), d(3, 31), None).unwrap();
|
||||
let m = ts
|
||||
.deductions
|
||||
.iter()
|
||||
.find(|x| x.category == "meals")
|
||||
.unwrap();
|
||||
assert_eq!(m.tax, dec("333.33"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn categories_must_fit_the_account_and_the_return() {
|
||||
let mut l = books();
|
||||
let sales = l.chart().find_by_code("4000").unwrap().id;
|
||||
let meals = l.chart().find_by_code("6100").unwrap().id;
|
||||
let bad = |r: Result<(), LedgerError>| matches!(r, Err(LedgerError::InvalidTaxCategory(_)));
|
||||
assert!(
|
||||
bad(l.set_account_tax(sales, Some("meals"), None)),
|
||||
"deduction on revenue"
|
||||
);
|
||||
assert!(
|
||||
bad(l.set_account_tax(meals, Some("no_such"), None)),
|
||||
"unknown key"
|
||||
);
|
||||
assert!(
|
||||
bad(l.set_account_tax(meals, Some("meals"), Some(dec("101")))),
|
||||
"over 100%"
|
||||
);
|
||||
assert!(
|
||||
bad(l.set_account_tax(meals, None, Some(dec("50")))),
|
||||
"percent without category"
|
||||
);
|
||||
// Guaranteed payments exist only on a partnership return.
|
||||
assert!(
|
||||
check_category(
|
||||
"guaranteed_payments",
|
||||
AccountType::Expense,
|
||||
Some(TaxForm::Form1065)
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
check_category(
|
||||
"guaranteed_payments",
|
||||
AccountType::Expense,
|
||||
Some(TaxForm::ScheduleC)
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
check_category(
|
||||
"officer_compensation",
|
||||
AccountType::Expense,
|
||||
Some(TaxForm::Form1120S)
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
// Clearing works.
|
||||
l.set_account_tax(meals, None, None).unwrap();
|
||||
assert!(l.chart().get(meals).unwrap().tax_category.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forms_parse_from_common_spellings() {
|
||||
for (s, f) in [
|
||||
("schedule_c", TaxForm::ScheduleC),
|
||||
("Schedule C", TaxForm::ScheduleC),
|
||||
("1065", TaxForm::Form1065),
|
||||
("Form 1065", TaxForm::Form1065),
|
||||
("1120s", TaxForm::Form1120S),
|
||||
("1120-S", TaxForm::Form1120S),
|
||||
("1120", TaxForm::Form1120),
|
||||
] {
|
||||
assert_eq!(TaxForm::parse(s), Some(f), "{s}");
|
||||
}
|
||||
assert_eq!(TaxForm::parse("990"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_of_goods_reduces_net_like_a_deduction() {
|
||||
let mut l = books();
|
||||
let cash = l.chart().find_by_code("1000").unwrap().id;
|
||||
let cogs = l
|
||||
.add_account("5000", "Cost of sales", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
l.set_account_tax(cogs, Some("cost_of_goods_sold"), None)
|
||||
.unwrap();
|
||||
let e = l.create_entry(d(2, 1), "x");
|
||||
let m = Money::new(dec("1000"), Currency::USD);
|
||||
l.add_line(e, cogs, Side::Debit, m).unwrap();
|
||||
l.add_line(e, cash, Side::Credit, m).unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
let ts = tax_summary(&l, d(1, 1), d(3, 31), Some(TaxForm::Form1065)).unwrap();
|
||||
assert_eq!(ts.cost_of_goods.len(), 1);
|
||||
assert_eq!(ts.cost_of_goods[0].book, dec("1000"));
|
||||
assert_eq!(ts.cost_of_goods[0].tax, dec("1000"));
|
||||
// The fixture's nets, less 1,000 of cost of goods sold.
|
||||
assert_eq!(ts.book_net(), dec("4276.67"));
|
||||
assert_eq!(ts.tax_net(), dec("4483.33"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forms_have_their_irs_names() {
|
||||
let labels: Vec<&str> = TaxForm::ALL.iter().map(|f| f.label()).collect();
|
||||
assert_eq!(
|
||||
labels,
|
||||
["Schedule C", "Form 1065", "Form 1120-S", "Form 1120"]
|
||||
);
|
||||
// A label parses back to its form.
|
||||
for f in TaxForm::ALL {
|
||||
assert_eq!(TaxForm::parse(f.label()), Some(f));
|
||||
}
|
||||
}
|
||||
|
||||
/// A small book with portfolio interest (125) and a donation (300) besides
|
||||
/// sales (1,000), for where each lands by return.
|
||||
fn interest_and_gift() -> Ledger {
|
||||
let mut l = Ledger::new(Currency::USD);
|
||||
let cash = l
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let mut acct = |code: &str, t, cat: &str| {
|
||||
let id = l.add_account(code, code, t, None, false).unwrap();
|
||||
l.set_account_tax(id, Some(cat), None).unwrap();
|
||||
id
|
||||
};
|
||||
let sales = acct("4000", AccountType::Revenue, "gross_receipts");
|
||||
let interest = acct("4200", AccountType::Revenue, "interest_income");
|
||||
let gift = acct("6900", AccountType::Expense, "charitable_contributions");
|
||||
let usd = |s: &str| Money::new(dec(s), Currency::USD);
|
||||
for (dr, cr, amt) in [
|
||||
(cash, sales, "1000"),
|
||||
(cash, interest, "125"),
|
||||
(gift, cash, "300"),
|
||||
] {
|
||||
let e = l.create_entry(d(2, 1), "x");
|
||||
l.add_line(e, dr, Side::Debit, usd(amt)).unwrap();
|
||||
l.add_line(e, cr, Side::Credit, usd(amt)).unwrap();
|
||||
l.post_entry(e).unwrap();
|
||||
}
|
||||
l
|
||||
}
|
||||
|
||||
/// Oracle: docs/spec/sources/irs-tax-lines.md. On Forms 1065 and 1120-S,
|
||||
/// Schedule K takes "only taxable portfolio interest", and charitable
|
||||
/// contributions are among the "items ... must state separately": both
|
||||
/// leave the ordinary-income net (1,000 here, not 825) and are listed
|
||||
/// apart. On Form 1120 they stay in it (lines 5 and 19).
|
||||
#[test]
|
||||
fn pass_throughs_state_interest_and_gifts_separately() {
|
||||
let l = interest_and_gift();
|
||||
for form in [TaxForm::Form1065, TaxForm::Form1120S] {
|
||||
let ts = tax_summary(&l, d(1, 1), d(12, 31), Some(form)).unwrap();
|
||||
let keys: Vec<&str> = ts
|
||||
.separately_stated
|
||||
.iter()
|
||||
.map(|x| x.category.as_str())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
keys,
|
||||
["interest_income", "charitable_contributions"],
|
||||
"{form:?}"
|
||||
);
|
||||
assert_eq!(ts.book_net(), dec("1000"), "{form:?}");
|
||||
}
|
||||
let ts = tax_summary(&l, d(1, 1), d(12, 31), Some(TaxForm::Form1120)).unwrap();
|
||||
assert!(ts.separately_stated.is_empty());
|
||||
assert_eq!(ts.book_net(), dec("825"));
|
||||
}
|
||||
|
||||
/// Oracle: Schedule C instructions, Part V ("Do not include charitable
|
||||
/// contributions"): the category is refused for a Schedule C business.
|
||||
#[test]
|
||||
fn a_sole_proprietor_cannot_deduct_a_gift_on_schedule_c() {
|
||||
assert!(matches!(
|
||||
check_category(
|
||||
"charitable_contributions",
|
||||
AccountType::Expense,
|
||||
Some(TaxForm::ScheduleC)
|
||||
),
|
||||
Err(LedgerError::InvalidTaxCategory(_))
|
||||
));
|
||||
assert!(
|
||||
check_category(
|
||||
"charitable_contributions",
|
||||
AccountType::Expense,
|
||||
Some(TaxForm::Form1120)
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
[package]
|
||||
name = "ledger-gui"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Read-only, live desktop view of wanderbooks books."
|
||||
|
||||
[[bin]]
|
||||
name = "ledger-gui"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
ledger-core = { path = "../ledger-core" }
|
||||
ledger-store = { path = "../ledger-store" }
|
||||
ledger-config = { path = "../ledger-config" }
|
||||
ledger-import = { path = "../ledger-import" }
|
||||
ledger-vault = { path = "../ledger-vault" }
|
||||
eframe = { version = "0.36", default-features = false, features = ["default_fonts", "wgpu", "wayland", "x11", "persistence"] }
|
||||
egui = "0.36"
|
||||
egui_extras = { version = "0.36", features = ["image", "datepicker", "serde"] }
|
||||
egui-phosphor = "0.14"
|
||||
hayro = { version = "0.8", features = ["embed-fonts"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
|
||||
notify = "8"
|
||||
jiff = "0.2"
|
||||
chrono = "0.4"
|
||||
rust_decimal = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
chrono = "0.4"
|
||||
egui_kittest = { version = "0.36", features = ["wgpu", "snapshot", "eframe"] }
|
||||
proptest = "1"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,92 @@
|
||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION AND CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,836 @@
|
||||
//! The window: sidebar, top bar, and the current page.
|
||||
|
||||
use crate::books::Books;
|
||||
use crate::data::{Loader, Source, Update};
|
||||
use crate::nav::{Nav, Route, Section};
|
||||
use crate::pages;
|
||||
use crate::receipts::Receipts;
|
||||
use crate::theme::{self, pal};
|
||||
use egui::{
|
||||
Align, Color32, CornerRadius, Frame, Key, Layout, Margin, RichText, Sense, Stroke, Ui, vec2,
|
||||
};
|
||||
use egui_phosphor::regular as icon;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Window widths below this get the icon rail instead of the sidebar.
|
||||
pub const COMPACT_BELOW: f32 = 1000.0;
|
||||
|
||||
/// Per-page state that survives navigation.
|
||||
#[derive(Default)]
|
||||
pub struct PageState {
|
||||
pub transactions: pages::transactions::State,
|
||||
pub reports: pages::reports::State,
|
||||
pub accounts: pages::accounts::State,
|
||||
pub assets: pages::assets::State,
|
||||
pub parties: pages::parties::State,
|
||||
pub documents: pages::documents::State,
|
||||
pub journal: pages::journal::State,
|
||||
}
|
||||
|
||||
/// What a page can ask of the shell.
|
||||
pub struct Shell<'a> {
|
||||
pub receipts: &'a mut Receipts,
|
||||
pub state: &'a mut PageState,
|
||||
pub go: Option<Route>,
|
||||
/// Seconds since the last live update arrived (for "new" highlights).
|
||||
pub since_update: f64,
|
||||
}
|
||||
|
||||
impl Shell<'_> {
|
||||
pub fn go(&mut self, r: Route) {
|
||||
self.go = Some(r);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
loader: Option<Loader>,
|
||||
books: Option<Arc<Books>>,
|
||||
error: Option<String>,
|
||||
nav: Nav,
|
||||
receipts: Receipts,
|
||||
dark: bool,
|
||||
state: PageState,
|
||||
palette: crate::palette::Palette,
|
||||
updated_at: f64,
|
||||
live_label: bool,
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// The live app over a books directory.
|
||||
pub fn new(cc: &eframe::CreationContext<'_>, source: Source) -> Self {
|
||||
let dark = cc
|
||||
.storage
|
||||
.and_then(|s| s.get_string("dark"))
|
||||
.map(|v| v == "true")
|
||||
.unwrap_or(cc.egui_ctx.global_style().visuals.dark_mode);
|
||||
let mut app = Self::bare(&cc.egui_ctx, dark);
|
||||
app.loader = Some(Loader::spawn(source, cc.egui_ctx.clone()));
|
||||
app
|
||||
}
|
||||
|
||||
/// An app showing fixed books (tests and screenshots).
|
||||
pub fn with_books(ctx: &egui::Context, books: Books, dark: bool, start: Route) -> Self {
|
||||
let mut app = Self::bare(ctx, dark);
|
||||
app.books = Some(Arc::new(books));
|
||||
app.nav = Nav::new(start);
|
||||
app
|
||||
}
|
||||
|
||||
fn bare(ctx: &egui::Context, dark: bool) -> Self {
|
||||
theme::install_fonts(ctx);
|
||||
theme::apply(ctx, dark);
|
||||
egui_extras::install_image_loaders(ctx);
|
||||
Self {
|
||||
loader: None,
|
||||
books: None,
|
||||
error: None,
|
||||
nav: Nav::new(Route::Overview),
|
||||
receipts: Receipts::new(ctx),
|
||||
dark,
|
||||
state: PageState::default(),
|
||||
palette: Default::default(),
|
||||
updated_at: -1e9,
|
||||
live_label: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate (tests).
|
||||
pub fn goto(&mut self, r: Route) {
|
||||
self.nav.go(r);
|
||||
}
|
||||
|
||||
/// Block until every receipt on screen has rendered (screenshots).
|
||||
pub fn settle(&mut self, ctx: &egui::Context) {
|
||||
self.receipts.settle(ctx);
|
||||
}
|
||||
|
||||
/// Go where the agent asked (`books.show`).
|
||||
pub fn show(&mut self, target: ledger_store::viewer::ViewerTarget) {
|
||||
use crate::nav::ReportKind;
|
||||
use ledger_store::viewer::ViewerTarget as T;
|
||||
let Some(b) = self.books.clone() else {
|
||||
return;
|
||||
};
|
||||
let date = |s: &Option<String>| {
|
||||
s.as_deref()
|
||||
.and_then(|d| d.parse::<chrono::NaiveDate>().ok())
|
||||
};
|
||||
let route = match target {
|
||||
T::Page { page } => match page.as_str() {
|
||||
"overview" => Route::Overview,
|
||||
"cash" => Route::Cash,
|
||||
"profit" => Route::Profit,
|
||||
"transactions" => Route::Transactions,
|
||||
"reports" => Route::Reports(ReportKind::ProfitAndLoss),
|
||||
"accounts" => Route::Accounts,
|
||||
"assets" => Route::Assets,
|
||||
"parties" => Route::Parties,
|
||||
"documents" => Route::Documents,
|
||||
"company" => Route::Company,
|
||||
"journal" => Route::Journal,
|
||||
_ => return,
|
||||
},
|
||||
T::Entry { entry_id } => Route::Entry(entry_id),
|
||||
T::Account { code, start, end } => {
|
||||
let Some(a) = b.ledger.chart().find_by_code(&code) else {
|
||||
return;
|
||||
};
|
||||
self.state.accounts.range = match (date(&start), date(&end)) {
|
||||
(None, None) => None,
|
||||
(s, e) => Some((
|
||||
s.unwrap_or_else(|| b.kpis.first_entry.unwrap_or(b.today)),
|
||||
e.unwrap_or(b.today),
|
||||
)),
|
||||
};
|
||||
Route::Account(a.id)
|
||||
}
|
||||
T::Report {
|
||||
report,
|
||||
start,
|
||||
end,
|
||||
columns,
|
||||
compare,
|
||||
} => {
|
||||
use crate::pages::reports::{Columns, Compare};
|
||||
self.state.reports.columns = match columns.as_deref() {
|
||||
Some("month") => Columns::Month,
|
||||
Some("quarter") => Columns::Quarter,
|
||||
Some("year") => Columns::Year,
|
||||
_ => Columns::Total,
|
||||
};
|
||||
self.state.reports.compare = match compare.as_deref() {
|
||||
Some("prior_period") => Compare::PriorPeriod,
|
||||
Some("prior_year") => Compare::PriorYear,
|
||||
_ => Compare::None,
|
||||
};
|
||||
let kind = match report.as_str() {
|
||||
"profit_and_loss" => ReportKind::ProfitAndLoss,
|
||||
"balance_sheet" => ReportKind::BalanceSheet,
|
||||
"cash_flow" => ReportKind::CashFlow,
|
||||
"members_equity" => ReportKind::MembersEquity,
|
||||
"trial_balance" => ReportKind::TrialBalance,
|
||||
"general_ledger" => ReportKind::GeneralLedger,
|
||||
"tax_summary" => ReportKind::TaxSummary,
|
||||
"vendor_1099" => ReportKind::Vendor1099,
|
||||
"ar_aging" => ReportKind::ArAging,
|
||||
"ap_aging" => ReportKind::ApAging,
|
||||
"fixed_asset_register" => ReportKind::FixedAssetRegister,
|
||||
_ => return,
|
||||
};
|
||||
if let (s, e) = (date(&start), date(&end))
|
||||
&& (s.is_some() || e.is_some())
|
||||
{
|
||||
let (ds, de) = crate::pages::reports::Range::YearToDate.dates(&b);
|
||||
self.state.reports.range =
|
||||
crate::pages::reports::Range::Custom(s.unwrap_or(ds), e.unwrap_or(de));
|
||||
}
|
||||
Route::Reports(kind)
|
||||
}
|
||||
T::Document { attachment_id } => Route::Document(attachment_id),
|
||||
T::Asset { asset_id } => {
|
||||
if b.ledger.get_asset(asset_id).is_none() {
|
||||
return;
|
||||
}
|
||||
Route::Asset(asset_id)
|
||||
}
|
||||
T::Party { party_id } => {
|
||||
if b.ledger.get_party(party_id).is_none() {
|
||||
return;
|
||||
}
|
||||
Route::Party(party_id)
|
||||
}
|
||||
T::Reconciliation { id } => {
|
||||
// Its own page: statement, balance, the lines it cleared.
|
||||
if b.reconciliation(id).is_none() {
|
||||
return;
|
||||
}
|
||||
Route::Reconciliation(id)
|
||||
}
|
||||
T::Search { query } => {
|
||||
let st = &mut self.state.transactions;
|
||||
st.query = query;
|
||||
st.filter = Default::default();
|
||||
st.account = None;
|
||||
Route::Transactions
|
||||
}
|
||||
};
|
||||
self.nav.go(route);
|
||||
}
|
||||
|
||||
/// Where the app is (tests).
|
||||
pub fn route(&self) -> &Route {
|
||||
&self.nav.current
|
||||
}
|
||||
|
||||
/// Page state (tests).
|
||||
/// Label a fixed-books app "Live", as it reads when following a
|
||||
/// journal (screenshots of what a running app looks like).
|
||||
pub fn show_live_label(&mut self) {
|
||||
self.live_label = true;
|
||||
}
|
||||
|
||||
pub fn state_mut(&mut self) -> &mut PageState {
|
||||
&mut self.state
|
||||
}
|
||||
|
||||
fn handle_keys(&mut self, ui: &Ui) {
|
||||
let (back, fwd, palette) = ui.input(|i| {
|
||||
(
|
||||
(i.modifiers.alt && i.key_pressed(Key::ArrowLeft))
|
||||
|| i.pointer.button_pressed(egui::PointerButton::Extra1),
|
||||
(i.modifiers.alt && i.key_pressed(Key::ArrowRight))
|
||||
|| i.pointer.button_pressed(egui::PointerButton::Extra2),
|
||||
i.modifiers.command && i.key_pressed(Key::K),
|
||||
)
|
||||
});
|
||||
if back {
|
||||
self.nav.back();
|
||||
}
|
||||
if fwd {
|
||||
self.nav.forward();
|
||||
}
|
||||
if palette {
|
||||
self.palette.open();
|
||||
}
|
||||
}
|
||||
|
||||
/// The sidebar: full below [`COMPACT_BELOW`] window widths, an icon
|
||||
/// rail (names in tooltips) when the window is narrower.
|
||||
fn sidebar(&mut self, ui: &mut Ui, compact: bool) {
|
||||
if compact {
|
||||
return self.rail(ui);
|
||||
}
|
||||
let p = pal(ui.ctx());
|
||||
let books = self.books.clone();
|
||||
egui::Panel::left("sidebar")
|
||||
.exact_size(224.0)
|
||||
.resizable(false)
|
||||
.frame(
|
||||
Frame::new()
|
||||
.fill(p.sidebar)
|
||||
.inner_margin(Margin::symmetric(12, 16)),
|
||||
)
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(28.0, 28.0), Sense::hover());
|
||||
ui.painter().rect_filled(r, CornerRadius::same(8), p.accent);
|
||||
let initial = books
|
||||
.as_ref()
|
||||
.and_then(|b| b.company_name().chars().next())
|
||||
.unwrap_or('W');
|
||||
ui.painter().text(
|
||||
r.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
initial,
|
||||
theme::bold(15.0),
|
||||
Color32::WHITE,
|
||||
);
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
let name = books
|
||||
.as_ref()
|
||||
.map(|b| b.company_name().to_string())
|
||||
.unwrap_or_else(|| "wanderbooks".into());
|
||||
ui.add(
|
||||
egui::Label::new(RichText::new(name).font(theme::semibold(13.5)))
|
||||
.truncate(),
|
||||
);
|
||||
ui.label(
|
||||
RichText::new("Books")
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
});
|
||||
});
|
||||
ui.add_space(18.0);
|
||||
let current = self.nav.current.section();
|
||||
let attention = books.as_ref().map(|b| b.attention.len()).unwrap_or(0);
|
||||
// Short windows: the links scroll above the status box
|
||||
// instead of the box drawing over them.
|
||||
let links_h = (ui.available_height() - SIDEBAR_FOOTER).max(64.0);
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("sidebar_links")
|
||||
.max_height(links_h)
|
||||
.show(ui, |ui| {
|
||||
for s in Section::ALL {
|
||||
let on = s == current;
|
||||
let (rect, resp) = ui.allocate_exact_size(
|
||||
vec2(ui.available_width(), 32.0),
|
||||
Sense::click(),
|
||||
);
|
||||
if on {
|
||||
ui.painter()
|
||||
.rect_filled(rect, CornerRadius::same(8), p.surface);
|
||||
ui.painter().rect_stroke(
|
||||
rect,
|
||||
CornerRadius::same(8),
|
||||
Stroke::new(1.0, p.border),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
} else if resp.hovered() {
|
||||
ui.painter()
|
||||
.rect_filled(rect, CornerRadius::same(8), p.hover);
|
||||
}
|
||||
let color = if on { p.text } else { p.muted };
|
||||
ui.painter().text(
|
||||
rect.left_center() + vec2(10.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
s.icon(),
|
||||
egui::FontId::proportional(16.0),
|
||||
if on { p.accent } else { p.muted },
|
||||
);
|
||||
ui.painter().text(
|
||||
rect.left_center() + vec2(36.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
s.title(),
|
||||
if on {
|
||||
theme::semibold(13.5)
|
||||
} else {
|
||||
theme::regular(13.5)
|
||||
},
|
||||
color,
|
||||
);
|
||||
if s == Section::Overview && attention > 0 {
|
||||
let badge = egui::Rect::from_center_size(
|
||||
rect.right_center() - vec2(18.0, 0.0),
|
||||
vec2(22.0, 18.0),
|
||||
);
|
||||
ui.painter().rect_filled(
|
||||
badge,
|
||||
CornerRadius::same(9),
|
||||
p.warning_soft,
|
||||
);
|
||||
ui.painter().text(
|
||||
badge.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
attention.to_string(),
|
||||
theme::semibold(11.0),
|
||||
p.warning,
|
||||
);
|
||||
}
|
||||
if resp.clicked() {
|
||||
self.nav.go(s.home());
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
|
||||
let label = if self.dark {
|
||||
format!("{} Light mode", icon::SUN)
|
||||
} else {
|
||||
format!("{} Dark mode", icon::MOON)
|
||||
};
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(
|
||||
RichText::new(label)
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
)
|
||||
.frame(false),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
self.dark = !self.dark;
|
||||
theme::apply(ui.ctx(), self.dark);
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
if let Some(b) = &books {
|
||||
live_status(
|
||||
ui,
|
||||
b,
|
||||
ui.input(|i| i.time) - self.updated_at,
|
||||
self.loader.is_some() || self.live_label,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// The sidebar as a narrow rail of icons, for narrow windows.
|
||||
fn rail(&mut self, ui: &mut Ui) {
|
||||
let p = pal(ui.ctx());
|
||||
let books = self.books.clone();
|
||||
egui::Panel::left("rail")
|
||||
.exact_size(RAIL_W)
|
||||
.resizable(false)
|
||||
.frame(
|
||||
Frame::new()
|
||||
.fill(p.sidebar)
|
||||
.inner_margin(Margin::symmetric(10, 16)),
|
||||
)
|
||||
.show(ui, |ui| {
|
||||
let name = books
|
||||
.as_ref()
|
||||
.map(|b| b.company_name().to_string())
|
||||
.unwrap_or_else(|| "wanderbooks".into());
|
||||
let (r, _) =
|
||||
ui.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::hover());
|
||||
let logo = egui::Rect::from_center_size(r.center(), vec2(28.0, 28.0));
|
||||
ui.painter()
|
||||
.rect_filled(logo, CornerRadius::same(8), p.accent);
|
||||
ui.painter().text(
|
||||
logo.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
name.chars().next().unwrap_or('W'),
|
||||
theme::bold(15.0),
|
||||
Color32::WHITE,
|
||||
);
|
||||
ui.interact(r, ui.id().with("logo"), Sense::hover())
|
||||
.on_hover_text(format!("{name} · Books"));
|
||||
ui.add_space(18.0);
|
||||
let current = self.nav.current.section();
|
||||
let attention = books.as_ref().map(|b| b.attention.len()).unwrap_or(0);
|
||||
let links_h = (ui.available_height() - RAIL_FOOTER).max(72.0);
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("rail_links")
|
||||
.max_height(links_h)
|
||||
.show(ui, |ui| {
|
||||
for s in Section::ALL {
|
||||
let on = s == current;
|
||||
let (rect, resp) = ui.allocate_exact_size(
|
||||
vec2(ui.available_width(), 36.0),
|
||||
Sense::click(),
|
||||
);
|
||||
if on {
|
||||
ui.painter()
|
||||
.rect_filled(rect, CornerRadius::same(8), p.surface);
|
||||
ui.painter().rect_stroke(
|
||||
rect,
|
||||
CornerRadius::same(8),
|
||||
Stroke::new(1.0, p.border),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
} else if resp.hovered() {
|
||||
ui.painter()
|
||||
.rect_filled(rect, CornerRadius::same(8), p.hover);
|
||||
}
|
||||
ui.painter().text(
|
||||
rect.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
s.icon(),
|
||||
egui::FontId::proportional(18.0),
|
||||
if on { p.accent } else { p.muted },
|
||||
);
|
||||
let tip = if s == Section::Overview && attention > 0 {
|
||||
ui.painter().circle_filled(
|
||||
rect.center() + vec2(9.0, -8.0),
|
||||
4.0,
|
||||
p.warning,
|
||||
);
|
||||
format!("{} · {attention} need attention", s.title())
|
||||
} else {
|
||||
s.title().to_string()
|
||||
};
|
||||
if resp.on_hover_text(tip).clicked() {
|
||||
self.nav.go(s.home());
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.with_layout(Layout::bottom_up(Align::Center), |ui| {
|
||||
let (glyph, tip) = if self.dark {
|
||||
(icon::SUN, "Light mode")
|
||||
} else {
|
||||
(icon::MOON, "Dark mode")
|
||||
};
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(RichText::new(glyph).size(16.0).color(p.muted))
|
||||
.frame(false)
|
||||
.min_size(vec2(32.0, 32.0)),
|
||||
)
|
||||
.on_hover_text(tip)
|
||||
.clicked()
|
||||
{
|
||||
self.dark = !self.dark;
|
||||
theme::apply(ui.ctx(), self.dark);
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
if let Some(b) = &books {
|
||||
let live = self.loader.is_some() || self.live_label;
|
||||
let checks = checks(b);
|
||||
let ok = checks.iter().all(|c| c.0);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(32.0, 32.0), Sense::hover());
|
||||
if ok {
|
||||
let pulse = (ui.input(|i| i.time) - self.updated_at < 2.0) as u8 as f32;
|
||||
ui.painter().circle_filled(
|
||||
r.center(),
|
||||
4.0 + pulse,
|
||||
if live { p.positive } else { p.faint },
|
||||
);
|
||||
} else {
|
||||
ui.painter().text(
|
||||
r.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
icon::WARNING_CIRCLE,
|
||||
egui::FontId::proportional(18.0),
|
||||
p.negative,
|
||||
);
|
||||
}
|
||||
let mut tip = format!(
|
||||
"{} · event {}",
|
||||
if live { "Live" } else { "Snapshot" },
|
||||
b.seq()
|
||||
);
|
||||
for (ok, text, _) in &checks {
|
||||
tip.push_str(&format!(
|
||||
"\n{} {text}",
|
||||
if *ok {
|
||||
icon::SEAL_CHECK
|
||||
} else {
|
||||
icon::WARNING_CIRCLE
|
||||
}
|
||||
));
|
||||
}
|
||||
resp.on_hover_text(tip);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Back, forward, where you are, today, and search. Squeezed, the
|
||||
/// search shrinks to its icon, the date goes, and the crumb truncates:
|
||||
/// nothing ever overlaps.
|
||||
fn topbar(&mut self, ui: &mut Ui) {
|
||||
let p = pal(ui.ctx());
|
||||
egui::Panel::top("topbar")
|
||||
.exact_size(52.0)
|
||||
.frame(
|
||||
Frame::new()
|
||||
.fill(p.bg)
|
||||
.inner_margin(Margin::symmetric(16, 10))
|
||||
.stroke(Stroke::new(1.0, p.border)),
|
||||
)
|
||||
.show(ui, |ui| {
|
||||
let width = ui.available_width();
|
||||
ui.horizontal_centered(|ui| {
|
||||
let nav_btn = |ui: &mut Ui, glyph: &str, enabled: bool, tip: &str| {
|
||||
ui.add_enabled(
|
||||
enabled,
|
||||
egui::Button::new(RichText::new(glyph).size(16.0))
|
||||
.frame(false)
|
||||
.min_size(vec2(28.0, 28.0)),
|
||||
)
|
||||
.on_hover_text(tip)
|
||||
.clicked()
|
||||
};
|
||||
if nav_btn(ui, icon::ARROW_LEFT, self.nav.can_back(), "Back (Alt+←)") {
|
||||
self.nav.back();
|
||||
}
|
||||
if nav_btn(
|
||||
ui,
|
||||
icon::ARROW_RIGHT,
|
||||
self.nav.can_forward(),
|
||||
"Forward (Alt+→)",
|
||||
) {
|
||||
self.nav.forward();
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
// Right side first, so the crumb gets only what's left.
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
let (text, min_w) = if width >= 640.0 {
|
||||
(
|
||||
format!(
|
||||
"{} Search everything… Ctrl K",
|
||||
icon::MAGNIFYING_GLASS
|
||||
),
|
||||
260.0,
|
||||
)
|
||||
} else if width >= 420.0 {
|
||||
(format!("{} Search", icon::MAGNIFYING_GLASS), 0.0)
|
||||
} else {
|
||||
(icon::MAGNIFYING_GLASS.to_string(), 30.0)
|
||||
};
|
||||
let search = egui::Button::new(
|
||||
RichText::new(text)
|
||||
.font(theme::regular(13.0))
|
||||
.color(p.faint),
|
||||
)
|
||||
.fill(p.surface)
|
||||
.stroke(Stroke::new(1.0, p.border))
|
||||
.corner_radius(CornerRadius::same(8))
|
||||
.min_size(vec2(min_w, 30.0));
|
||||
if ui
|
||||
.add(search)
|
||||
.on_hover_text("Search everything (Ctrl K)")
|
||||
.clicked()
|
||||
{
|
||||
self.palette.open();
|
||||
}
|
||||
if let Some(b) = &self.books
|
||||
&& width >= 860.0
|
||||
{
|
||||
ui.label(
|
||||
RichText::new(format!("Today {}", crate::fmt::date(b.today)))
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
ui.with_layout(Layout::left_to_right(Align::Center), |ui| {
|
||||
let crumbs =
|
||||
crate::palette::crumbs(self.books.as_deref(), &self.nav.current);
|
||||
// Narrow, the last crumb alone says where you are.
|
||||
let skip = if ui.available_width() >= 260.0 {
|
||||
0
|
||||
} else {
|
||||
crumbs.len() - 1
|
||||
};
|
||||
let last = crumbs.len() - 1;
|
||||
let mut go = None;
|
||||
for (i, (text, to)) in crumbs.iter().enumerate().skip(skip) {
|
||||
if i > skip && ui.available_width() >= 260.0 - 80.0 {
|
||||
ui.label(RichText::new(icon::CARET_RIGHT).color(p.faint));
|
||||
}
|
||||
let rich = if i == last && i > 0 {
|
||||
RichText::new(text).font(theme::semibold(13.5))
|
||||
} else {
|
||||
RichText::new(text)
|
||||
.font(theme::regular(13.5))
|
||||
.color(p.muted)
|
||||
};
|
||||
let label = egui::Label::new(rich).truncate();
|
||||
match to {
|
||||
Some(to) => {
|
||||
let r = ui.add(label.sense(egui::Sense::click()));
|
||||
if r.hovered() {
|
||||
ui.ctx()
|
||||
.set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
}
|
||||
if r.on_hover_text(format!("Go to {text}")).clicked() {
|
||||
go = Some(to.clone());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
ui.add(label);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(to) = go {
|
||||
self.nav.go(to);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Width of the icon rail.
|
||||
const RAIL_W: f32 = 60.0;
|
||||
/// Height kept clear under the sidebar's links for the status box and the
|
||||
/// theme toggle; the rail's equivalent.
|
||||
const SIDEBAR_FOOTER: f32 = 172.0;
|
||||
const RAIL_FOOTER: f32 = 84.0;
|
||||
|
||||
fn live_status(ui: &mut Ui, b: &Books, since_update: f64, live: bool) {
|
||||
let p = pal(ui.ctx());
|
||||
Frame::new()
|
||||
.fill(p.surface)
|
||||
.stroke(Stroke::new(1.0, p.border))
|
||||
.corner_radius(CornerRadius::same(8))
|
||||
.inner_margin(Margin::same(10))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.horizontal(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(8.0, 8.0), Sense::hover());
|
||||
let pulse = (since_update < 2.0) as u8 as f32;
|
||||
let color = if live { p.positive } else { p.faint };
|
||||
ui.painter().circle_filled(r.center(), 4.0 + pulse, color);
|
||||
ui.label(
|
||||
RichText::new(if live { "Live" } else { "Snapshot" })
|
||||
.font(theme::semibold(12.0)),
|
||||
);
|
||||
ui.label(
|
||||
RichText::new(format!("· event {}", b.seq()))
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
});
|
||||
for (ok, text, tip) in checks(b) {
|
||||
let (glyph, color) = if ok {
|
||||
(icon::SEAL_CHECK, p.muted)
|
||||
} else {
|
||||
(icon::WARNING_CIRCLE, p.negative)
|
||||
};
|
||||
let r = ui
|
||||
.label(
|
||||
RichText::new(format!("{glyph} {text}"))
|
||||
.font(theme::regular(11.5))
|
||||
.color(color),
|
||||
)
|
||||
.on_hover_text(tip);
|
||||
if text == "Journal chain verified" {
|
||||
r.context_menu(|ui| {
|
||||
crate::widgets::copy_item(ui, "Copy journal head", &b.head);
|
||||
crate::widgets::copy_item(
|
||||
ui,
|
||||
"Copy data folder",
|
||||
&b.data_dir.display().to_string(),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The integrity checks the sidebar shows: (ok, label, explanation).
|
||||
fn checks(b: &Books) -> Vec<(bool, String, String)> {
|
||||
use ledger_store::DiskCheck;
|
||||
let mut out = vec![(
|
||||
true,
|
||||
"Journal chain verified".to_string(),
|
||||
format!(
|
||||
"Every journal event's hash chains to the one before it.\nHead: {}\nData: {}",
|
||||
b.head,
|
||||
b.data_dir.display()
|
||||
),
|
||||
)];
|
||||
out.push(match &b.integrity.disk {
|
||||
DiskCheck::Matches => (true, "Database matches journal".to_string(), "The server's SQLite database matches a fresh projection of the journal, every table, row for row.".to_string()),
|
||||
DiskCheck::Behind { applied, journal } => (true, "Database catching up".to_string(), format!("The database has applied {applied} of {journal} journal events; the server applies the rest right after writing them.")),
|
||||
DiskCheck::Ahead { .. } => (true, "Viewer catching up".to_string(), "The server has written journal events this view hasn't read yet; it reads them on its next refresh.".to_string()),
|
||||
DiskCheck::Missing => (true, "Read-only copy".to_string(), "No server database here: a copy of the books (a CPA's, say), or the server hasn't run yet. The journal and every file are verified either way.".to_string()),
|
||||
DiskCheck::Differs(why) => (false, "Database differs".to_string(), format!("{why}. The server rebuilds it from the journal (books.verify, or on its next start).")),
|
||||
DiskCheck::Unreadable(why) => (false, "Database unreadable".to_string(), why.clone()),
|
||||
});
|
||||
let bad = b.integrity.file_failures.len();
|
||||
out.push((
|
||||
bad == 0,
|
||||
if bad == 0 {
|
||||
format!("{} files verified", b.integrity.files_ok)
|
||||
} else {
|
||||
format!("{bad} files damaged")
|
||||
},
|
||||
"Every stored receipt and document re-hashed against its recorded SHA-256.".into(),
|
||||
));
|
||||
out
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) {
|
||||
let updates = self.loader.as_ref().map(|l| l.poll()).unwrap_or_default();
|
||||
for u in updates {
|
||||
match u {
|
||||
Update::Books(b) => {
|
||||
if self.books.is_some() {
|
||||
self.updated_at = ui.input(|i| i.time);
|
||||
}
|
||||
self.books = Some(b);
|
||||
self.error = None;
|
||||
}
|
||||
Update::Error(e) => self.error = Some(e),
|
||||
Update::Show(target) => self.show(target),
|
||||
}
|
||||
}
|
||||
self.receipts.pump(ui.ctx());
|
||||
self.handle_keys(ui);
|
||||
let compact = ui.available_width() < COMPACT_BELOW;
|
||||
self.sidebar(ui, compact);
|
||||
self.topbar(ui);
|
||||
let p = pal(ui.ctx());
|
||||
let since_update = ui.input(|i| i.time) - self.updated_at;
|
||||
if since_update < 6.0 {
|
||||
ui.ctx()
|
||||
.request_repaint_after(std::time::Duration::from_millis(250));
|
||||
}
|
||||
egui::CentralPanel::no_frame()
|
||||
.frame(Frame::new().fill(p.bg))
|
||||
.show(ui, |ui| {
|
||||
if let Some(err) = &self.error {
|
||||
Frame::new()
|
||||
.fill(p.negative_soft)
|
||||
.inner_margin(Margin::symmetric(20, 8))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.label(
|
||||
RichText::new(format!("{} {err}", icon::WARNING)).color(p.negative),
|
||||
);
|
||||
});
|
||||
}
|
||||
let Some(books) = self.books.clone() else {
|
||||
ui.centered_and_justified(|ui| {
|
||||
ui.label(RichText::new("Opening the books…").color(p.muted));
|
||||
});
|
||||
return;
|
||||
};
|
||||
let mut shell = Shell {
|
||||
receipts: &mut self.receipts,
|
||||
state: &mut self.state,
|
||||
go: None,
|
||||
since_update,
|
||||
};
|
||||
let route = self.nav.current.clone();
|
||||
pages::show(ui, &books, &route, &mut shell);
|
||||
if let Some(to) = shell.go.take() {
|
||||
self.nav.go(to);
|
||||
}
|
||||
if let Some(to) = self.palette.show(ui.ctx(), &books) {
|
||||
self.nav.go(to);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||
storage.set_string("dark", self.dark.to_string());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,739 @@
|
||||
//! What the books need: drafts left unposted, missing receipts, schedules
|
||||
//! and recurring entries due, open past periods, uncategorized tax lines,
|
||||
//! 1099 vendors without a W-9, overdue bills and invoices, money owed to
|
||||
//! owners, fixed asset depreciation not posted, assets without a tag or a
|
||||
//! tax basis, contractors and landlords paid without W-9 facts, bank and
|
||||
//! card accounts not reconciled through last month, and legal
|
||||
//! rules (like the 1099 threshold) due for an update. Each item says
|
||||
//! what's wrong, where to look, and the prompt that
|
||||
//! asks the agent to fix it (the GUI never changes the books itself).
|
||||
|
||||
use crate::books::{Attention, Books, Severity};
|
||||
use crate::fmt;
|
||||
use crate::nav::Route;
|
||||
use chrono::Datelike;
|
||||
use ledger_core::reports::profit_and_loss_for_period;
|
||||
use ledger_core::{AccountType, EntryStatus, PeriodStatus, vendor_1099_report};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
/// A figure the books can't compute (rules.md B-28: books an earlier
|
||||
/// version wrote may hold dates past chrono's calendar or amounts past
|
||||
/// exact arithmetic): a Problem saying so and why, never a guess.
|
||||
fn cant_compute(what: String, why: &ledger_core::LedgerError, target: Option<Route>) -> Attention {
|
||||
Attention {
|
||||
severity: Severity::Problem,
|
||||
title: format!("{what} can't be computed"),
|
||||
detail: format!("{why}."),
|
||||
target,
|
||||
prompt: format!(
|
||||
"In wanderbooks, {} can't be computed ({why}). Find the record behind it and tell me how to correct it.",
|
||||
what.to_lowercase()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the books need, as items to show.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: tell the humans what's unfinished or wrong, and hand them the
|
||||
/// prompt that gets the agent to fix it; never change the books.
|
||||
/// SOURCE: DECISION (Stephen: the viewer is read-only; its "Needs
|
||||
/// attention" list offers prompts, AGENTS.md); the conditions
|
||||
/// below; docs/spec/sources/irs-1099-thresholds.md (W-9s).
|
||||
/// INVARIANTS:
|
||||
/// 1. Pure: reads `b`, writes nothing.
|
||||
/// 2. One item per: draft entry (Todo); posted entry missing a receipt
|
||||
/// (Problem; four or more are one combined item); recurring entry or
|
||||
/// schedule due through today (Todo); period ended and still open
|
||||
/// (Todo); fiscal-year account with activity and no tax category
|
||||
/// (Info); legal rule known only through a past year (Todo); 1099
|
||||
/// vendor without a W-9 (Problem); overdue bills, and overdue
|
||||
/// invoices (Problem, one each, with the total); money owed to
|
||||
/// owners (Info, per account with a balance); the database differing
|
||||
/// from the journal or unreadable, and damaged or missing stored
|
||||
/// files (Problem); depreciation due through the last finished month
|
||||
/// and not posted (Books::depreciation_due amount > 0: Todo, one
|
||||
/// item); the asset register and the books' asset cost accounts
|
||||
/// disagreeing today (unregistered_cost != 0: Problem); an active
|
||||
/// asset in service with no tag (Todo, one per asset); an active
|
||||
/// asset in service with no tax basis (Asset::tax_basis is None:
|
||||
/// Todo, one per asset, its prompt naming books.set_asset_tax_basis);
|
||||
/// a party not archived whose W-9 is Missing (pages::parties::
|
||||
/// w9_status: it files a 1099, has no W-9 facts, and its vendor
|
||||
/// record doesn't say one is on file) and that was paid this year
|
||||
/// (PartyInfo::paid_ytd > 0),
|
||||
/// unless its vendor is already a 1099 vendor without a W-9 above
|
||||
/// (Todo, one per party, its prompt from
|
||||
/// pages::parties::collect_w9_prompt, naming books.set_w9); a cash
|
||||
/// or card account (Books::reconcilable_accounts) whose
|
||||
/// Books::reconciliation_due is Some(date) (Todo, one per account,
|
||||
/// titled "Reconcile <account> through <date>", its prompt from
|
||||
/// pages::reconciliation::reconcile_prompt, naming
|
||||
/// books.record_statement and books.reconcile); when `[books]
|
||||
/// parties` isn't off, the posted entries dated this calendar year
|
||||
/// (Jan 1 through today) that name no party and aren't marked as
|
||||
/// having none (Ledger::entry_no_party), leaving out the ones the
|
||||
/// engine makes from a schedule (period closes, depreciation,
|
||||
/// amortization: no counterparty by nature) (one item: Info under
|
||||
/// suggest, Todo under require; its prompt names
|
||||
/// books.set_entry_party).
|
||||
/// 3. Nothing else: books with none of those yield no items, except
|
||||
/// that a figure behind an item that can't be computed (an
|
||||
/// amortization plan past chrono's last day, an aging past exact
|
||||
/// arithmetic: books an earlier version wrote, rules.md B-28) is one
|
||||
/// Problem item saying so, never a panic or a guess.
|
||||
/// 4. Every item's prompt starts "In wanderbooks," or names
|
||||
/// wanderbooks, and its amounts are the books' exact amounts.
|
||||
/// ```
|
||||
pub fn find(b: &Books) -> Vec<Attention> {
|
||||
let mut out = Vec::new();
|
||||
let today = b.today;
|
||||
|
||||
for e in b.entries.iter().filter(|e| e.status == EntryStatus::Draft) {
|
||||
out.push(Attention {
|
||||
severity: Severity::Todo,
|
||||
title: format!("Draft not posted: {}", e.description),
|
||||
detail: format!(
|
||||
"#{} · {} · {}. Drafts appear in no report until posted.",
|
||||
e.id,
|
||||
fmt::date(e.date),
|
||||
fmt::dollars_or(e.amount)
|
||||
),
|
||||
target: Some(Route::Entry(e.id)),
|
||||
prompt: format!(
|
||||
"In wanderbooks, review draft entry {} (\"{}\", {}): attach its receipt if it needs one, preview it, and post it if it's right.",
|
||||
e.id,
|
||||
e.description,
|
||||
fmt::dollars_or(e.amount)
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let missing: Vec<_> = b
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| e.status == EntryStatus::Posted && e.missing_receipt)
|
||||
.collect();
|
||||
if missing.len() > 3 {
|
||||
let total = missing
|
||||
.iter()
|
||||
.try_fold(Decimal::ZERO, |t, e| t.checked_add(e.amount?));
|
||||
let list: Vec<String> = missing
|
||||
.iter()
|
||||
.map(|e| format!("{} ({}, {})", e.id, e.description, e.date))
|
||||
.collect();
|
||||
out.push(Attention {
|
||||
severity: Severity::Problem,
|
||||
title: format!("{} without receipts", fmt::count(missing.len(), "entry", "entries")),
|
||||
detail: format!(
|
||||
"{} in entries with no receipt attached ({}).",
|
||||
fmt::dollars_or(total),
|
||||
b.receipt_rule()
|
||||
),
|
||||
target: Some(Route::Transactions),
|
||||
prompt: format!(
|
||||
"In wanderbooks, these entries have no receipt: {}. Help me find each receipt and attach it with books.attach_receipt.",
|
||||
list.join("; ")
|
||||
),
|
||||
});
|
||||
}
|
||||
for e in missing.iter().filter(|_| missing.len() <= 3) {
|
||||
out.push(Attention {
|
||||
severity: Severity::Problem,
|
||||
title: format!("No receipt: {}", e.description),
|
||||
detail: format!(
|
||||
"#{} · {} · {}: {}.",
|
||||
e.id,
|
||||
fmt::date(e.date),
|
||||
fmt::dollars_or(e.amount),
|
||||
b.receipt_rule()
|
||||
),
|
||||
target: Some(Route::Entry(e.id)),
|
||||
prompt: format!(
|
||||
"In wanderbooks, entry {} (\"{}\", {} on {}) has no receipt. Find it and attach it with books.attach_receipt.",
|
||||
e.id,
|
||||
e.description,
|
||||
fmt::dollars_or(e.amount),
|
||||
e.date
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
for r in b.ledger.recurring_entries() {
|
||||
let due = r.due(today);
|
||||
if !due.is_empty() {
|
||||
out.push(Attention {
|
||||
severity: Severity::Todo,
|
||||
title: format!("Recurring \"{}\" is due", r.description),
|
||||
detail: format!(
|
||||
"{} since {}.",
|
||||
fmt::count(due.len(), "occurrence", "occurrences"),
|
||||
fmt::date(due[0])
|
||||
),
|
||||
target: Some(Route::Company),
|
||||
prompt: format!(
|
||||
"In wanderbooks, run books.run_recurring through {today}, then attach each draft's receipt and post them."
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for s in b.ledger.schedules() {
|
||||
// A schedule an earlier version journaled may not compute (its
|
||||
// months past chrono's last day): say so, never guess.
|
||||
let due = match s.due(today) {
|
||||
Ok(due) => due,
|
||||
Err(e) => {
|
||||
out.push(cant_compute(
|
||||
format!("Amortization schedule \"{}\"", s.description),
|
||||
&e,
|
||||
Some(Route::Company),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !due.is_empty() {
|
||||
let Some(total) = due
|
||||
.iter()
|
||||
.try_fold(Decimal::ZERO, |t, (_, a)| t.checked_add(*a))
|
||||
else {
|
||||
out.push(cant_compute(
|
||||
format!("Amortization schedule \"{}\"", s.description),
|
||||
&ledger_core::LedgerError::TooLargeToCompute("the months due".into()),
|
||||
Some(Route::Company),
|
||||
));
|
||||
continue;
|
||||
};
|
||||
out.push(Attention {
|
||||
severity: Severity::Todo,
|
||||
title: format!("Amortization due: {}", s.description),
|
||||
detail: format!(
|
||||
"{} totaling {} not yet posted.",
|
||||
fmt::count(due.len(), "month", "months"),
|
||||
fmt::dollars(total)
|
||||
),
|
||||
target: Some(Route::Company),
|
||||
prompt: format!(
|
||||
"In wanderbooks, preview books.run_schedules through {today}, then post it if it's right."
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for p in b.ledger.list_periods() {
|
||||
if p.status == PeriodStatus::Open && p.end < today {
|
||||
out.push(Attention {
|
||||
severity: Severity::Todo,
|
||||
title: format!("Period {} ended but is open", p.name),
|
||||
detail: format!(
|
||||
"{} – {}. Closing locks it and moves its earnings to equity.",
|
||||
fmt::date(p.start),
|
||||
fmt::date(p.end)
|
||||
),
|
||||
target: Some(Route::Company),
|
||||
prompt: format!(
|
||||
"In wanderbooks, review period {} and, if everything for it is recorded and reconciled, preview and close it.",
|
||||
p.name
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let (fy_start, _) = b.fiscal_ytd();
|
||||
if let Ok(pl) = profit_and_loss_for_period(&b.ledger, fy_start, today, None) {
|
||||
let uncategorized: Vec<String> = pl
|
||||
.revenue
|
||||
.iter()
|
||||
.chain(&pl.expenses)
|
||||
.filter(|l| {
|
||||
b.ledger
|
||||
.chart()
|
||||
.find_by_code(&l.code)
|
||||
.is_some_and(|a| a.tax_category.is_none())
|
||||
})
|
||||
.map(|l| format!("{} {}", l.code, l.name))
|
||||
.collect();
|
||||
if !uncategorized.is_empty() {
|
||||
out.push(Attention {
|
||||
severity: Severity::Info,
|
||||
title: format!(
|
||||
"{} without a tax category",
|
||||
fmt::count(uncategorized.len(), "account", "accounts")
|
||||
),
|
||||
detail: format!(
|
||||
"{}. The tax summary lists them as uncategorized.",
|
||||
uncategorized.join(", ")
|
||||
),
|
||||
target: Some(Route::Accounts),
|
||||
prompt: format!(
|
||||
"In wanderbooks, propose a tax category for each of these accounts ({}) using books.tax_categories, and set them with books.set_tax_category once I agree.",
|
||||
uncategorized.join(", ")
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Rules set by law that wanderbooks may no longer know (rules.md;
|
||||
// Stephen, 2026-10-13): the agent finds the figure from the authority
|
||||
// or asks, confirms it, and passes it explicitly (the handbook's
|
||||
// "Rules that change with the law"); the human reports it upstream.
|
||||
for rule in ledger_core::rules::stale_rules(today) {
|
||||
out.push(Attention {
|
||||
severity: Severity::Todo,
|
||||
title: format!("Check the {}", rule.name),
|
||||
detail: format!(
|
||||
"wanderbooks knows it through {}. {}",
|
||||
rule.known_through, rule.review_why
|
||||
),
|
||||
target: None,
|
||||
prompt: format!(
|
||||
"In wanderbooks, the {} is known only through {}. Find the current figure from the official authority, {}, or ask me or our CPA, and confirm it with me; then pass it explicitly wherever a tool asks for it. Tell me what you found and its official source, so I can report it upstream and the rule is updated for everyone.",
|
||||
rule.name, rule.known_through, rule.authority
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let mut years = vec![today.year()];
|
||||
if today.month() == 1 {
|
||||
years.insert(0, today.year() - 1);
|
||||
}
|
||||
// Vendors the "needs a W-9" item already names this year.
|
||||
let mut asked_w9 = std::collections::HashSet::new();
|
||||
for year in years {
|
||||
// A year whose threshold isn't known is flagged by the stale-rule
|
||||
// item above; never guess it here.
|
||||
let Ok(r) = vendor_1099_report(&b.ledger, year, None) else {
|
||||
continue;
|
||||
};
|
||||
for v in r.vendors.iter().filter(|v| v.reportable && !v.tin_on_file) {
|
||||
if year == today.year() {
|
||||
asked_w9.insert(v.vendor_id);
|
||||
}
|
||||
let party = b.ledger.party_of_vendor(v.vendor_id);
|
||||
out.push(Attention {
|
||||
severity: Severity::Problem,
|
||||
title: format!("{} needs a W-9", v.name),
|
||||
detail: format!(
|
||||
"Paid {} in {year}, over the {} 1099 threshold.",
|
||||
fmt::dollars(v.paid),
|
||||
fmt::dollars(r.threshold)
|
||||
),
|
||||
target: Some(party.map_or(Route::Company, Route::Party)),
|
||||
prompt: format!(
|
||||
"Remind me to request a W-9 from {}; once I have it, mark it on file in wanderbooks with books.set_vendor_1099.",
|
||||
v.name
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for p in b.ledger.parties().filter(|p| {
|
||||
!p.archived
|
||||
&& crate::pages::parties::w9_status(b, p) == crate::pages::parties::W9Status::Missing
|
||||
&& !p.vendor.is_some_and(|v| asked_w9.contains(&v))
|
||||
}) {
|
||||
let Some(info) = b.party(p.id).filter(|i| i.paid_ytd > Decimal::ZERO) else {
|
||||
continue;
|
||||
};
|
||||
out.push(Attention {
|
||||
severity: Severity::Todo,
|
||||
title: format!("Collect a W-9 from {}", p.label()),
|
||||
detail: format!(
|
||||
"Paid {} in {} as {}, with no W-9 facts recorded: collect a W-9 before the next payment.",
|
||||
fmt::dollars(info.paid_ytd),
|
||||
today.year(),
|
||||
if p.roles.contains(&ledger_core::Role::Landlord)
|
||||
&& !p.roles.contains(&ledger_core::Role::Contractor)
|
||||
{
|
||||
"a landlord"
|
||||
} else {
|
||||
"a contractor"
|
||||
}
|
||||
),
|
||||
target: Some(Route::Party(p.id)),
|
||||
prompt: crate::pages::parties::collect_w9_prompt(b, p),
|
||||
});
|
||||
}
|
||||
|
||||
// Overdue: (parties, total), or why it can't be computed (documents
|
||||
// are restored as journaled, of any size).
|
||||
let overdue = |rows: Result<Vec<ledger_core::AgingRow>, ledger_core::LedgerError>| {
|
||||
let too_large = || ledger_core::LedgerError::TooLargeToCompute("the overdue total".into());
|
||||
let late: Vec<Decimal> = rows?
|
||||
.iter()
|
||||
.map(|r| {
|
||||
[r.days_60, r.days_90, r.days_90_plus]
|
||||
.into_iter()
|
||||
.try_fold(r.days_30, Decimal::checked_add)
|
||||
.ok_or_else(too_large)
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
.into_iter()
|
||||
.filter(|d| !d.is_zero())
|
||||
.collect();
|
||||
let total = late
|
||||
.iter()
|
||||
.try_fold(Decimal::ZERO, |t, d| t.checked_add(*d))
|
||||
.ok_or_else(too_large)?;
|
||||
Ok::<_, ledger_core::LedgerError>((late.len(), total))
|
||||
};
|
||||
let (n, total) = match overdue(b.ledger.ap_aging(today)) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
out.push(cant_compute(
|
||||
"The payables aging".into(),
|
||||
&e,
|
||||
Some(Route::Reports(crate::nav::ReportKind::ApAging)),
|
||||
));
|
||||
(0, Decimal::ZERO)
|
||||
}
|
||||
};
|
||||
if n > 0 {
|
||||
out.push(Attention {
|
||||
severity: Severity::Problem,
|
||||
title: format!("{} overdue to pay", fmt::dollars(total)),
|
||||
detail: format!(
|
||||
"{} with bills past due.",
|
||||
fmt::count(n, "vendor", "vendors")
|
||||
),
|
||||
target: Some(Route::Reports(crate::nav::ReportKind::ApAging)),
|
||||
prompt: "In wanderbooks, show me the A/P aging and which bills are past due.".into(),
|
||||
});
|
||||
}
|
||||
let (n, total) = match overdue(b.ledger.ar_aging(today)) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
out.push(cant_compute(
|
||||
"The receivables aging".into(),
|
||||
&e,
|
||||
Some(Route::Reports(crate::nav::ReportKind::ArAging)),
|
||||
));
|
||||
(0, Decimal::ZERO)
|
||||
}
|
||||
};
|
||||
if n > 0 {
|
||||
out.push(Attention {
|
||||
severity: Severity::Problem,
|
||||
title: format!("{} overdue to collect", fmt::dollars(total)),
|
||||
detail: format!(
|
||||
"{} with invoices past due.",
|
||||
fmt::count(n, "customer", "customers")
|
||||
),
|
||||
target: Some(Route::Reports(crate::nav::ReportKind::ArAging)),
|
||||
prompt:
|
||||
"In wanderbooks, show me the A/R aging and draft reminders for past-due invoices."
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
|
||||
for a in b.ledger.chart().all().filter(|a| {
|
||||
a.account_type == AccountType::Liability && {
|
||||
let n = a.name.to_lowercase();
|
||||
n.contains("due to") || n.contains("owed to")
|
||||
}
|
||||
}) {
|
||||
let bal = b.balance_on(a.id, today);
|
||||
if bal > Decimal::ZERO {
|
||||
out.push(Attention {
|
||||
severity: Severity::Info,
|
||||
title: format!("{} owed on {}", fmt::dollars(bal), a.name),
|
||||
detail: "Expenses someone paid personally, not yet reimbursed.".into(),
|
||||
target: Some(Route::Account(a.id)),
|
||||
prompt: format!(
|
||||
"In wanderbooks, list what makes up the {} balance on {} {} and who is owed what.",
|
||||
fmt::dollars(bal),
|
||||
a.code,
|
||||
a.name
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for a in b.reconcilable_accounts() {
|
||||
let Some(through) = b.reconciliation_due(a) else {
|
||||
continue;
|
||||
};
|
||||
let name = b.account_name(a);
|
||||
out.push(Attention {
|
||||
severity: Severity::Todo,
|
||||
title: format!("Reconcile {name} through {}", fmt::date(through)),
|
||||
detail: match b.reconciled_through(a) {
|
||||
Some(d) => format!(
|
||||
"Reconciled through {}. The monthly close reconciles it to each statement since.",
|
||||
fmt::date(d)
|
||||
),
|
||||
None => "Never reconciled. The monthly close reconciles it to its statement.".into(),
|
||||
},
|
||||
target: Some(Route::Account(a)),
|
||||
prompt: crate::pages::reconciliation::reconcile_prompt(b, a, through),
|
||||
});
|
||||
}
|
||||
|
||||
let policy = b.settings.parties;
|
||||
if policy != ledger_config::PartyPolicy::Off {
|
||||
let year_start = chrono::NaiveDate::from_ymd_opt(today.year(), 1, 1).expect("valid");
|
||||
let unnamed: Vec<_> = b
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
e.status == EntryStatus::Posted
|
||||
&& e.date >= year_start
|
||||
&& e.date <= today
|
||||
&& e.parties.is_empty()
|
||||
&& b.ledger.entry_no_party(e.id).is_none()
|
||||
&& !matches!(e.origin, "Period close" | "Depreciation" | "Amortization")
|
||||
})
|
||||
.collect();
|
||||
if !unnamed.is_empty() {
|
||||
let list: Vec<String> = unnamed
|
||||
.iter()
|
||||
.map(|e| format!("{} ({}, {})", e.id, e.description, e.date))
|
||||
.collect();
|
||||
out.push(Attention {
|
||||
severity: if policy == ledger_config::PartyPolicy::Require {
|
||||
Severity::Todo
|
||||
} else {
|
||||
Severity::Info
|
||||
},
|
||||
title: format!(
|
||||
"{} no counterparty",
|
||||
fmt::count(unnamed.len(), "entry names", "entries name")
|
||||
),
|
||||
detail: format!(
|
||||
"Posted in {} with no party and not marked as having none. Tagging each entry with who it was with keeps the parties' totals and 1099s complete.",
|
||||
today.year()
|
||||
),
|
||||
target: Some(Route::Transactions),
|
||||
prompt: format!(
|
||||
"In wanderbooks, these posted entries from {} name no counterparty: {}. Propose a party for each, grouped by party (adding any that aren't in the directory yet), and once I agree, tag them with books.set_entry_party. For any with truly no counterparty (like a transfer between the company's own accounts), mark it with books.set_entry_party with party null and a no_party_reason.",
|
||||
today.year(),
|
||||
list.join("; ")
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// The register computes unless a contributor basis an earlier version
|
||||
// journaled is past exact arithmetic: then one item says so.
|
||||
let (due, register) = match (b.depreciation_due(today), b.asset_register(today)) {
|
||||
(Ok(due), Ok(register)) => (Some(due), Some(register)),
|
||||
(Err(e), _) | (_, Err(e)) => {
|
||||
out.push(cant_compute(
|
||||
"The fixed asset register".into(),
|
||||
&e,
|
||||
Some(Route::Reports(crate::nav::ReportKind::FixedAssetRegister)),
|
||||
));
|
||||
(None, None)
|
||||
}
|
||||
};
|
||||
if let Some(due) = due.filter(|d| d.amount > Decimal::ZERO) {
|
||||
let months = if due.periods.is_empty() {
|
||||
"every month not yet posted".to_string()
|
||||
} else {
|
||||
due.periods.join(", ")
|
||||
};
|
||||
out.push(Attention {
|
||||
severity: Severity::Todo,
|
||||
title: format!("Depreciation not posted through {}", fmt::date(due.through)),
|
||||
detail: format!(
|
||||
"{} scheduled and not yet in the books ({}).",
|
||||
fmt::dollars(due.amount),
|
||||
months
|
||||
),
|
||||
target: Some(Route::Assets),
|
||||
prompt: format!(
|
||||
"In wanderbooks, run books.run_depreciation for {months} (depreciation due through {} and not posted: {}), then show me the fixed asset register.",
|
||||
due.through,
|
||||
fmt::dollars(due.amount)
|
||||
),
|
||||
});
|
||||
}
|
||||
let unregistered = register.map_or(Decimal::ZERO, |r| r.unregistered_cost());
|
||||
if !unregistered.is_zero() {
|
||||
let (title, why) = if unregistered > Decimal::ZERO {
|
||||
(
|
||||
format!(
|
||||
"{} of equipment not in the asset register",
|
||||
fmt::dollars(unregistered)
|
||||
),
|
||||
"The asset cost accounts hold more than the registered assets cost: something bought was never registered.",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
format!(
|
||||
"{} of registered assets not in the books",
|
||||
fmt::dollars(-unregistered)
|
||||
),
|
||||
"Registered assets cost more than their cost accounts hold: a purchase entry may be missing.",
|
||||
)
|
||||
};
|
||||
out.push(Attention {
|
||||
severity: Severity::Problem,
|
||||
title,
|
||||
detail: why.into(),
|
||||
target: Some(Route::Assets),
|
||||
prompt: format!(
|
||||
"In wanderbooks, the fixed asset register and the asset cost accounts differ by {} today. Find what's behind it (books.list_assets and the cost accounts' registers) and tell me how to fix it.",
|
||||
fmt::dollars(unregistered)
|
||||
),
|
||||
});
|
||||
}
|
||||
for a in b.ledger.registered_assets().filter(|a| {
|
||||
a.tag.is_none() && a.status == ledger_core::AssetStatus::Active && a.purchase_date <= today
|
||||
}) {
|
||||
out.push(Attention {
|
||||
severity: Severity::Todo,
|
||||
title: format!("No tag: {}", a.description),
|
||||
detail: format!(
|
||||
"Asset {}, in service {}. A tag is the label on the thing itself.",
|
||||
a.id,
|
||||
fmt::date(a.purchase_date)
|
||||
),
|
||||
target: Some(Route::Asset(a.id)),
|
||||
prompt: format!(
|
||||
"In wanderbooks, label asset {} (\"{}\") with books.label_asset: give it the next generated tag unless I have my own, and its serial number if I can find it. Then print its label with books.asset_labels.",
|
||||
a.id, a.description
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
for a in b.ledger.registered_assets().filter(|a| {
|
||||
a.tax_basis().is_none()
|
||||
&& a.status == ledger_core::AssetStatus::Active
|
||||
&& a.purchase_date <= today
|
||||
}) {
|
||||
let name = a.tag.clone().unwrap_or_else(|| format!("asset {}", a.id));
|
||||
out.push(Attention {
|
||||
severity: Severity::Todo,
|
||||
title: format!("No tax basis: {}", b.asset_label(a.id)),
|
||||
detail: format!(
|
||||
"Book cost {}, in service {}. The CPA needs its federal tax basis beside its book value.",
|
||||
fmt::dollars(a.cost.amount),
|
||||
fmt::date(a.purchase_date)
|
||||
),
|
||||
target: Some(Route::Asset(a.id)),
|
||||
prompt: format!(
|
||||
"In wanderbooks, record the tax basis of {name} (\"{}\", book cost {}) with books.set_asset_tax_basis. Ask me how the company got it: bought (cost), contributed by a member from business use (carryover, IRC §723: the member's adjusted basis), or converted by a member from personal use (personal-use conversion, Treas. Reg. §1.168(i)-4(b): the member's adjusted basis; the tax basis is the lesser of that and its fair market value). Then show me the asset.",
|
||||
a.description,
|
||||
fmt::dollars(a.cost.amount)
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
use ledger_store::DiskCheck;
|
||||
let verify_prompt = "In wanderbooks, run books.verify and tell me what it found.".to_string();
|
||||
match &b.integrity.disk {
|
||||
DiskCheck::Differs(why) => out.push(Attention {
|
||||
severity: Severity::Problem,
|
||||
title: "The database doesn't match the journal".into(),
|
||||
detail: format!("{why}. The journal is the record; the server rebuilds the database from it (books.verify, or on its next start)."),
|
||||
target: Some(Route::Journal),
|
||||
prompt: verify_prompt.clone(),
|
||||
}),
|
||||
DiskCheck::Unreadable(why) => out.push(Attention {
|
||||
severity: Severity::Problem,
|
||||
title: "Couldn't check the database".into(),
|
||||
detail: format!("{why}."),
|
||||
target: Some(Route::Journal),
|
||||
prompt: verify_prompt.clone(),
|
||||
}),
|
||||
_ => {}
|
||||
}
|
||||
if !b.integrity.file_failures.is_empty() {
|
||||
let ids: Vec<String> = b
|
||||
.integrity
|
||||
.file_failures
|
||||
.iter()
|
||||
.map(|(id, _)| id.to_string())
|
||||
.collect();
|
||||
out.push(Attention {
|
||||
severity: Severity::Problem,
|
||||
title: format!("{} damaged or missing", fmt::count(ids.len(), "stored file is", "stored files are")),
|
||||
detail: format!(
|
||||
"Attachment {}: the bytes on disk don't match their recorded hash. Restore them from a backup (the books dir's git history).",
|
||||
ids.join(", ")
|
||||
),
|
||||
target: Some(Route::Documents),
|
||||
prompt: verify_prompt,
|
||||
});
|
||||
}
|
||||
out.sort_by_key(|a| a.severity);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Empty books opened as of `today`.
|
||||
fn books(tag: &str, today: chrono::NaiveDate) -> Books {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("ledger-gui-attention-{}-{tag}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
// Loaded books need no files: removed once loaded, pass or panic.
|
||||
let _cleanup = crate::test_support::TempDir(dir.clone());
|
||||
let data_dir = dir.join("books-data");
|
||||
ledger_store::Store::create(&data_dir, ledger_core::Currency::USD).unwrap();
|
||||
crate::data::load_once(&crate::data::Source {
|
||||
data_dir,
|
||||
books_toml: None,
|
||||
today: Some(today),
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// INVARIANTS 2 and 4: a dated rule past its review is one Todo item
|
||||
/// whose prompt asks the agent to find the figure from the official
|
||||
/// authority (named, never the repo's copy), confirm it with the human
|
||||
/// and pass it explicitly, and to tell the human so it can be reported
|
||||
/// upstream; before the review there is no such item. No prompt points
|
||||
/// the agent at files, git, GitHub or pull requests.
|
||||
#[test]
|
||||
fn a_stale_rule_asks_for_the_authority_and_the_humans_confirmation() {
|
||||
let rule = ledger_core::rules::THRESHOLD_1099;
|
||||
let review = rule.review_after();
|
||||
assert!(
|
||||
find(&books("fresh", review.pred_opt().unwrap()))
|
||||
.iter()
|
||||
.all(|a| !a.title.contains(rule.name)),
|
||||
"no item before the review date"
|
||||
);
|
||||
let items = find(&books("stale", review));
|
||||
let stale: Vec<_> = items
|
||||
.iter()
|
||||
.filter(|a| a.title == format!("Check the {}", rule.name))
|
||||
.collect();
|
||||
assert_eq!(stale.len(), 1, "{items:?}");
|
||||
let a = stale[0];
|
||||
assert_eq!(a.severity, Severity::Todo);
|
||||
assert!(
|
||||
a.detail.contains(&rule.known_through.to_string()),
|
||||
"{}",
|
||||
a.detail
|
||||
);
|
||||
for want in [
|
||||
"In wanderbooks,",
|
||||
rule.name,
|
||||
&rule.known_through.to_string(),
|
||||
rule.authority,
|
||||
"confirm it with me",
|
||||
"pass it explicitly",
|
||||
"report it upstream",
|
||||
] {
|
||||
assert!(a.prompt.contains(want), "{want:?} missing: {}", a.prompt);
|
||||
}
|
||||
for item in &items {
|
||||
let lower = item.prompt.to_lowercase();
|
||||
for never in ["pull request", ".md", "docs/", rule.source] {
|
||||
assert!(!lower.contains(never), "{never:?} in {}", item.prompt);
|
||||
}
|
||||
for word in lower.split(|c: char| !c.is_alphanumeric()) {
|
||||
assert!(
|
||||
!["git", "github", "repo", "repository", "issue"].contains(&word),
|
||||
"{word:?} in {}",
|
||||
item.prompt
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,494 @@
|
||||
//! Charts, painted directly: a balance line (step-shaped, because a
|
||||
//! balance holds until the next transaction), grouped bars, ranked
|
||||
//! horizontal bars, and sparklines. Every point answers hover with its
|
||||
//! exact value; bars report clicks so they can drill down.
|
||||
|
||||
use crate::fmt;
|
||||
use crate::theme::{self, pal};
|
||||
use chrono::NaiveDate;
|
||||
use egui::{
|
||||
Align2, Color32, CornerRadius, Painter, Pos2, Rect, Sense, Shape, Stroke, Ui, pos2, vec2,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
fn f(d: Decimal) -> f64 {
|
||||
d.try_into().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// ```text
|
||||
/// INVARIANTS (finite lo <= hi, n >= 1):
|
||||
/// 1. Evenly spaced by a step of 1, 2, 2.5, or 5 times a power of ten
|
||||
/// (each tick a whole multiple of the step), all finite.
|
||||
/// 2. The first tick is at or below lo and the last at or above hi, so
|
||||
/// no data falls outside the gridlines.
|
||||
/// 3. At most n + 2 ticks.
|
||||
/// When no such step exists in f64 (nice ticks past f64's range, within
|
||||
/// a factor of ten of it, or a span too small for the magnitude), the
|
||||
/// ticks are just [lo, hi] (or [lo]).
|
||||
/// ```
|
||||
/// "Nice" axis ticks covering [lo, hi].
|
||||
pub fn ticks(lo: f64, hi: f64, n: usize) -> Vec<f64> {
|
||||
let n = n.max(1);
|
||||
let fallback = || if lo == hi { vec![lo] } else { vec![lo, hi] };
|
||||
let span = (hi - lo).max(f64::MIN_POSITIVE);
|
||||
if !span.is_finite() {
|
||||
return fallback();
|
||||
}
|
||||
let raw = span / n as f64;
|
||||
let mut mag = 10f64.powf(raw.log10().floor());
|
||||
// Try the nice steps from the smallest that fits n, coarser as needed
|
||||
// (rounding at large magnitudes can cost a tick).
|
||||
for _ in 0..4 {
|
||||
for m in [1.0, 2.0, 2.5, 5.0] {
|
||||
let step = m * mag;
|
||||
if !step.is_finite() || step <= 0.0 || span / step > n as f64 {
|
||||
continue;
|
||||
}
|
||||
let (k0, k1) = ((lo / step).floor(), (hi / step).ceil());
|
||||
if !(k0.is_finite() && k1.is_finite()) || k1 - k0 + 1.0 > (n + 2) as f64 {
|
||||
continue;
|
||||
}
|
||||
let v: Vec<f64> = (0..=(k1 - k0) as i64)
|
||||
.map(|k| (k0 + k as f64) * step)
|
||||
.collect();
|
||||
let ok = v.iter().all(|x| x.is_finite())
|
||||
&& v.windows(2).all(|w| w[1] > w[0])
|
||||
&& v.first().is_some_and(|f| *f <= lo)
|
||||
&& v.last().is_some_and(|l| *l >= hi);
|
||||
if ok {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
mag *= 10.0;
|
||||
}
|
||||
fallback()
|
||||
}
|
||||
|
||||
fn money_label(v: f64) -> String {
|
||||
fmt::compact(Decimal::from_f64_retain(v).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// A balance over time as a step line with a soft fill. `points` are
|
||||
/// (date, balance at end of day), ascending; the line runs to `until`.
|
||||
pub fn balance_line(ui: &mut Ui, points: &[(NaiveDate, Decimal)], until: NaiveDate, height: f32) {
|
||||
let p = pal(ui.ctx());
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), height), Sense::hover());
|
||||
let painter = ui.painter_at(rect);
|
||||
if points.is_empty() {
|
||||
painter.text(
|
||||
rect.center(),
|
||||
Align2::CENTER_CENTER,
|
||||
"No cash activity yet",
|
||||
theme::regular(12.5),
|
||||
p.faint,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let first = points[0].0;
|
||||
let last = until.max(points[points.len() - 1].0);
|
||||
let days = ((last - first).num_days().max(1)) as f64;
|
||||
let vals: Vec<f64> = points.iter().map(|(_, v)| f(*v)).collect();
|
||||
let lo = vals.iter().cloned().fold(0.0f64, f64::min);
|
||||
let hi = vals.iter().cloned().fold(f64::MIN, f64::max).max(lo + 1.0);
|
||||
let ts = ticks(lo, hi, 4);
|
||||
let (lo, hi) = (ts[0].min(lo), ts[ts.len() - 1].max(hi));
|
||||
let plot = Rect::from_min_max(rect.min + vec2(52.0, 6.0), rect.max - vec2(8.0, 22.0));
|
||||
let x =
|
||||
|d: NaiveDate| plot.left() + ((d - first).num_days() as f64 / days) as f32 * plot.width();
|
||||
let y = |v: f64| plot.bottom() - ((v - lo) / (hi - lo)) as f32 * plot.height();
|
||||
for t in &ts {
|
||||
let yy = y(*t);
|
||||
painter.line_segment(
|
||||
[pos2(plot.left(), yy), pos2(plot.right(), yy)],
|
||||
Stroke::new(1.0, p.border),
|
||||
);
|
||||
painter.text(
|
||||
pos2(plot.left() - 8.0, yy),
|
||||
Align2::RIGHT_CENTER,
|
||||
money_label(*t),
|
||||
theme::regular(11.0),
|
||||
p.faint,
|
||||
);
|
||||
}
|
||||
// X labels: first, last, and a few between.
|
||||
let n_labels = ((plot.width() / 90.0) as usize).clamp(1, 4);
|
||||
for i in 0..=n_labels {
|
||||
// At most `last` (a one-day chart is one day wide, and its first day
|
||||
// may be chrono's last).
|
||||
let d = first
|
||||
.checked_add_days(chrono::Days::new(
|
||||
((days * i as f64) / n_labels as f64).round() as u64,
|
||||
))
|
||||
.map_or(last, |d| d.min(last));
|
||||
let align = match i {
|
||||
0 => Align2::LEFT_TOP,
|
||||
i if i == n_labels => Align2::RIGHT_TOP,
|
||||
_ => Align2::CENTER_TOP,
|
||||
};
|
||||
painter.text(
|
||||
pos2(x(d), plot.bottom() + 6.0),
|
||||
align,
|
||||
fmt::date_short(d),
|
||||
theme::regular(11.0),
|
||||
p.faint,
|
||||
);
|
||||
}
|
||||
// Step path.
|
||||
let mut line: Vec<Pos2> = Vec::new();
|
||||
for (i, (d, v)) in points.iter().enumerate() {
|
||||
let px = x(*d);
|
||||
if i > 0 {
|
||||
line.push(pos2(px, line.last().unwrap().y));
|
||||
}
|
||||
line.push(pos2(px, y(f(*v))));
|
||||
}
|
||||
line.push(pos2(x(last), line.last().unwrap().y));
|
||||
let base = y(0.0f64.clamp(lo, hi));
|
||||
let mut fill = line.clone();
|
||||
fill.push(pos2(x(last), base));
|
||||
fill.push(pos2(x(first), base));
|
||||
let soft = Color32::from_rgba_unmultiplied(p.accent.r(), p.accent.g(), p.accent.b(), 28);
|
||||
// Fill: one mesh of strips under each flat step (the outline may be
|
||||
// non-convex). A mesh has no anti-aliased edges, so strips meet without
|
||||
// seams.
|
||||
let mut mesh = egui::Mesh::default();
|
||||
for w in line.windows(2) {
|
||||
if (w[0].y - w[1].y).abs() < 0.01 && w[1].x > w[0].x {
|
||||
mesh.add_colored_rect(
|
||||
Rect::from_min_max(
|
||||
pos2(w[0].x, w[0].y.min(base)),
|
||||
pos2(w[1].x, w[0].y.max(base)),
|
||||
),
|
||||
soft,
|
||||
);
|
||||
}
|
||||
}
|
||||
painter.add(Shape::mesh(mesh));
|
||||
let _ = fill;
|
||||
painter.add(Shape::line(line.clone(), Stroke::new(2.0, p.accent)));
|
||||
// Hover: the balance on the hovered day.
|
||||
if let Some(hp) = resp.hover_pos()
|
||||
&& plot.x_range().contains(hp.x)
|
||||
{
|
||||
let frac = ((hp.x - plot.left()) / plot.width()) as f64;
|
||||
let day = first
|
||||
.checked_add_days(chrono::Days::new((frac * days).round() as u64))
|
||||
.map_or(last, |d| d.min(last));
|
||||
let bal = points
|
||||
.iter()
|
||||
.take_while(|(d, _)| *d <= day)
|
||||
.last()
|
||||
.map(|(_, v)| *v);
|
||||
if let Some(b) = bal {
|
||||
let hx = x(day);
|
||||
painter.line_segment(
|
||||
[pos2(hx, plot.top()), pos2(hx, plot.bottom())],
|
||||
Stroke::new(1.0, p.faint),
|
||||
);
|
||||
painter.circle_filled(pos2(hx, y(f(b))), 4.0, p.accent);
|
||||
tooltip(
|
||||
&painter,
|
||||
pos2(hx, plot.top()),
|
||||
&fmt::date(day),
|
||||
&fmt::dollars(b),
|
||||
p.surface,
|
||||
p.border,
|
||||
p.text,
|
||||
p.muted,
|
||||
rect,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn tooltip(
|
||||
painter: &Painter,
|
||||
at: Pos2,
|
||||
title: &str,
|
||||
value: &str,
|
||||
bg: Color32,
|
||||
border: Color32,
|
||||
fg: Color32,
|
||||
muted: Color32,
|
||||
bounds: Rect,
|
||||
) {
|
||||
let t1 = painter.layout_no_wrap(title.to_string(), theme::regular(11.5), muted);
|
||||
let t2 = painter.layout_no_wrap(value.to_string(), theme::semibold(13.0), fg);
|
||||
let w = t1.size().x.max(t2.size().x) + 16.0;
|
||||
let h = t1.size().y + t2.size().y + 12.0;
|
||||
let mut r = Rect::from_min_size(pos2(at.x + 8.0, at.y), vec2(w, h));
|
||||
if r.right() > bounds.right() {
|
||||
r = r.translate(vec2(-w - 16.0, 0.0));
|
||||
}
|
||||
painter.rect(
|
||||
r,
|
||||
CornerRadius::same(6),
|
||||
bg,
|
||||
Stroke::new(1.0, border),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
painter.galley(r.min + vec2(8.0, 6.0), t1.clone(), muted);
|
||||
painter.galley(r.min + vec2(8.0, 6.0 + t1.size().y), t2, fg);
|
||||
}
|
||||
|
||||
/// One group of bars on a [`bar_chart`].
|
||||
pub struct BarGroup {
|
||||
pub label: String,
|
||||
pub values: Vec<Decimal>,
|
||||
}
|
||||
|
||||
/// Grouped bars (e.g. income and spending per month), with a legend.
|
||||
/// Returns the clicked group, if any.
|
||||
pub fn bar_chart(
|
||||
ui: &mut Ui,
|
||||
groups: &[BarGroup],
|
||||
series: &[(&str, Color32)],
|
||||
height: f32,
|
||||
) -> Option<usize> {
|
||||
let p = pal(ui.ctx());
|
||||
ui.horizontal(|ui| {
|
||||
for (name, color) in series {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover());
|
||||
ui.painter().rect_filled(r, CornerRadius::same(3), *color);
|
||||
ui.label(
|
||||
egui::RichText::new(*name)
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
});
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), height), Sense::click());
|
||||
let painter = ui.painter_at(rect);
|
||||
if groups.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let all: Vec<f64> = groups
|
||||
.iter()
|
||||
.flat_map(|g| g.values.iter().map(|v| f(*v)))
|
||||
.collect();
|
||||
let lo = all.iter().cloned().fold(0.0f64, f64::min);
|
||||
let hi = all.iter().cloned().fold(0.0f64, f64::max).max(lo + 1.0);
|
||||
let ts = ticks(lo, hi, 4);
|
||||
let (lo, hi) = (ts[0].min(lo), ts[ts.len() - 1].max(hi));
|
||||
let plot = Rect::from_min_max(rect.min + vec2(52.0, 6.0), rect.max - vec2(4.0, 22.0));
|
||||
let y = |v: f64| plot.bottom() - ((v - lo) / (hi - lo)) as f32 * plot.height();
|
||||
for t in &ts {
|
||||
let yy = y(*t);
|
||||
painter.line_segment(
|
||||
[pos2(plot.left(), yy), pos2(plot.right(), yy)],
|
||||
Stroke::new(1.0, p.border),
|
||||
);
|
||||
painter.text(
|
||||
pos2(plot.left() - 8.0, yy),
|
||||
Align2::RIGHT_CENTER,
|
||||
money_label(*t),
|
||||
theme::regular(11.0),
|
||||
p.faint,
|
||||
);
|
||||
}
|
||||
let gw = plot.width() / groups.len() as f32;
|
||||
let n = series.len().max(1) as f32;
|
||||
let bw = ((gw * 0.7) / n).min(28.0);
|
||||
let mut clicked = None;
|
||||
let hover = resp.hover_pos();
|
||||
for (gi, g) in groups.iter().enumerate() {
|
||||
let gx = plot.left() + gw * gi as f32;
|
||||
let grect = Rect::from_min_max(pos2(gx, plot.top()), pos2(gx + gw, plot.bottom()));
|
||||
let hovered = hover.is_some_and(|h| grect.contains(h));
|
||||
if hovered {
|
||||
painter.rect_filled(grect, CornerRadius::same(6), p.hover);
|
||||
}
|
||||
let total_w = bw * n + 3.0 * (n - 1.0);
|
||||
let start = gx + (gw - total_w) / 2.0;
|
||||
for (si, v) in g.values.iter().enumerate() {
|
||||
let color = series.get(si).map(|s| s.1).unwrap_or(p.accent);
|
||||
let x0 = start + si as f32 * (bw + 3.0);
|
||||
let (y0, y1) = (y(0.0), y(f(*v)));
|
||||
let r = Rect::from_min_max(
|
||||
pos2(x0, y0.min(y1)),
|
||||
pos2(x0 + bw, y0.max(y1).max(y0.min(y1) + 1.0)),
|
||||
);
|
||||
painter.rect_filled(
|
||||
r,
|
||||
CornerRadius {
|
||||
nw: 3,
|
||||
ne: 3,
|
||||
sw: 0,
|
||||
se: 0,
|
||||
},
|
||||
color,
|
||||
);
|
||||
}
|
||||
// Crowded, label every other group (or fewer), counting back from
|
||||
// the latest so it's always labeled.
|
||||
let every = (34.0 / gw).ceil().max(1.0) as usize;
|
||||
if (groups.len() - 1 - gi).is_multiple_of(every) {
|
||||
painter.text(
|
||||
pos2(gx + gw / 2.0, plot.bottom() + 6.0),
|
||||
Align2::CENTER_TOP,
|
||||
&g.label,
|
||||
theme::regular(11.0),
|
||||
p.faint,
|
||||
);
|
||||
}
|
||||
if hovered {
|
||||
let lines: Vec<String> = series
|
||||
.iter()
|
||||
.zip(&g.values)
|
||||
.map(|((name, _), v)| format!("{name}: {}", fmt::dollars(*v)))
|
||||
.collect();
|
||||
tooltip(
|
||||
&painter,
|
||||
pos2(gx + gw / 2.0, plot.top()),
|
||||
&g.label,
|
||||
&lines.join("\n"),
|
||||
p.surface,
|
||||
p.border,
|
||||
p.text,
|
||||
p.muted,
|
||||
rect,
|
||||
);
|
||||
if resp.clicked() {
|
||||
clicked = Some(gi);
|
||||
}
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
}
|
||||
}
|
||||
clicked
|
||||
}
|
||||
|
||||
/// Ranked horizontal bars with labels and exact amounts. Returns the
|
||||
/// clicked row.
|
||||
pub fn hbars(ui: &mut Ui, rows: &[(String, Decimal)], color: Color32) -> Option<usize> {
|
||||
let p = pal(ui.ctx());
|
||||
let max = rows
|
||||
.iter()
|
||||
.map(|(_, v)| f(*v).abs())
|
||||
.fold(0.0, f64::max)
|
||||
.max(1e-9);
|
||||
let mut clicked = None;
|
||||
for (i, (label, v)) in rows.iter().enumerate() {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click());
|
||||
let painter = ui.painter_at(rect);
|
||||
if resp.hovered() {
|
||||
painter.rect_filled(rect, CornerRadius::same(6), p.hover);
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
}
|
||||
let amount = fmt::dollars(*v);
|
||||
let ag = painter.layout_no_wrap(amount, theme::regular(13.0), p.text);
|
||||
let label_w = (rect.width() * 0.38).min(220.0);
|
||||
let lg = painter.layout(label.clone(), theme::regular(13.0), p.text, label_w);
|
||||
painter.galley(
|
||||
pos2(rect.left() + 6.0, rect.center().y - lg.size().y / 2.0),
|
||||
lg,
|
||||
p.text,
|
||||
);
|
||||
let bar_left = rect.left() + label_w + 14.0;
|
||||
let bar_max = rect.right() - ag.size().x - 18.0 - bar_left;
|
||||
let w = (f(*v).abs() / max) as f32 * bar_max.max(0.0);
|
||||
let bar = Rect::from_min_size(
|
||||
pos2(bar_left, rect.center().y - 6.0),
|
||||
vec2(w.max(2.0), 12.0),
|
||||
);
|
||||
painter.rect_filled(bar, CornerRadius::same(3), color);
|
||||
painter.galley(
|
||||
pos2(
|
||||
rect.right() - ag.size().x - 6.0,
|
||||
rect.center().y - ag.size().y / 2.0,
|
||||
),
|
||||
ag,
|
||||
p.text,
|
||||
);
|
||||
if resp.clicked() {
|
||||
clicked = Some(i);
|
||||
}
|
||||
}
|
||||
clicked
|
||||
}
|
||||
|
||||
/// A tiny trend line.
|
||||
pub fn sparkline(painter: &Painter, rect: Rect, values: &[f64], color: Color32) {
|
||||
let lo = values.iter().cloned().fold(f64::MAX, f64::min);
|
||||
let hi = values.iter().cloned().fold(f64::MIN, f64::max);
|
||||
let span = (hi - lo).max(1e-9);
|
||||
let n = values.len().max(2) - 1;
|
||||
let pts: Vec<Pos2> = values
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| {
|
||||
pos2(
|
||||
rect.left() + rect.width() * i as f32 / n as f32,
|
||||
rect.bottom() - ((v - lo) / span) as f32 * rect.height(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
painter.add(Shape::line(pts, Stroke::new(1.5, color)));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use egui_kittest::Harness;
|
||||
|
||||
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
||||
}
|
||||
|
||||
/// Every text a frame painted, with where it was painted.
|
||||
fn painted(out: &egui::FullOutput) -> Vec<(String, egui::Pos2, f32)> {
|
||||
fn walk(s: &egui::Shape, out: &mut Vec<(String, egui::Pos2, f32)>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => {
|
||||
out.push((t.galley.text().to_string(), t.pos, t.galley.size().x))
|
||||
}
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut texts = Vec::new();
|
||||
for c in &out.shapes {
|
||||
walk(&c.shape, &mut texts);
|
||||
}
|
||||
texts
|
||||
}
|
||||
|
||||
/// charts::balance_line: the x axis is labeled with its first day, its
|
||||
/// last, and days evenly between (rounded); hovering shows the balance on
|
||||
/// the hovered day.
|
||||
#[test]
|
||||
fn the_balance_chart_labels_its_days_and_shows_the_hovered_one() {
|
||||
let points = vec![
|
||||
(d(2026, 1, 1), Decimal::new(10_000, 2)),
|
||||
(d(2026, 1, 11), Decimal::new(5_000, 2)),
|
||||
];
|
||||
let mut h = Harness::builder()
|
||||
.with_size(egui::vec2(800.0, 300.0))
|
||||
.build_ui(move |ui| balance_line(ui, &points, d(2026, 1, 11), 200.0));
|
||||
crate::theme::install_fonts(&h.ctx);
|
||||
crate::theme::apply(&h.ctx, false);
|
||||
h.run();
|
||||
let texts = painted(h.output());
|
||||
let labels: Vec<&str> = texts.iter().map(|(t, _, _)| t.as_str()).collect();
|
||||
// Ten days, four gaps (the plot is wide): 0, 2.5, 5, 7.5, 10 rounded.
|
||||
for want in ["Jan 1", "Jan 4", "Jan 6", "Jan 9", "Jan 11"] {
|
||||
assert!(labels.contains(&want), "{want} not in {labels:?}");
|
||||
}
|
||||
// The plot runs from the first label's left edge to the last's right.
|
||||
let (_, first, _) = texts.iter().find(|(t, _, _)| t == "Jan 1").unwrap();
|
||||
let (_, last, w) = texts.iter().find(|(t, _, _)| t == "Jan 11").unwrap();
|
||||
let (left, right) = (first.x, last.x + w);
|
||||
for (frac, day) in [(0.3, d(2026, 1, 4)), (0.5, d(2026, 1, 6))] {
|
||||
h.hover_at(egui::pos2(left + (right - left) * frac, first.y - 40.0));
|
||||
h.run();
|
||||
let texts = painted(h.output());
|
||||
let want = crate::fmt::date(day);
|
||||
assert!(
|
||||
texts.iter().any(|(t, _, _)| *t == want),
|
||||
"{want} not shown at {frac}: {:?}",
|
||||
texts.iter().map(|(t, _, _)| t).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
//! The live loader. A background thread owns the read-only [`Snapshot`],
|
||||
//! watches the data directory, and on every change applies the new journal
|
||||
//! lines and sends a fresh [`Books`] to the UI, then asks for a repaint.
|
||||
//! A change made by the agent in another window shows up within a frame or
|
||||
//! two. Filesystem events are the trigger; a slow poll backs them up in case
|
||||
//! an event is missed (network drives, odd editors).
|
||||
|
||||
use crate::books::{Books, Integrity};
|
||||
use chrono::NaiveDate;
|
||||
use ledger_config::BooksSettings;
|
||||
use ledger_store::{Snapshot, StoreError};
|
||||
use notify::{RecursiveMode, Watcher};
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
/// Where the books come from.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Source {
|
||||
/// The `books-data` directory (journal and blobs).
|
||||
pub data_dir: PathBuf,
|
||||
/// The company's `wanderbooks.toml`, if any.
|
||||
pub books_toml: Option<PathBuf>,
|
||||
/// Fix "today" (tests, screenshots); otherwise the local date.
|
||||
pub today: Option<NaiveDate>,
|
||||
}
|
||||
|
||||
pub enum Update {
|
||||
Books(Arc<Books>),
|
||||
Error(String),
|
||||
/// The agent asked the viewer to show something (`books.show`).
|
||||
Show(ledger_store::viewer::ViewerTarget),
|
||||
}
|
||||
|
||||
pub struct Loader {
|
||||
rx: Receiver<Update>,
|
||||
}
|
||||
|
||||
impl Loader {
|
||||
pub fn spawn(source: Source, ctx: egui::Context) -> Loader {
|
||||
let (tx, rx) = channel();
|
||||
std::thread::Builder::new()
|
||||
.name("books-loader".into())
|
||||
.spawn(move || run(source, ctx, tx))
|
||||
.expect("spawn loader");
|
||||
Loader { rx }
|
||||
}
|
||||
|
||||
/// Every update since the last call, in order (books before the
|
||||
/// navigation that refers to them).
|
||||
pub fn poll(&self) -> Vec<Update> {
|
||||
self.rx.try_iter().collect()
|
||||
}
|
||||
|
||||
/// Block until the first update (tests, headless).
|
||||
pub fn wait(&self, timeout: Duration) -> Option<Update> {
|
||||
self.rx.recv_timeout(timeout).ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// Load once, synchronously (tests and one-shot tools).
|
||||
pub fn load_once(source: &Source) -> Result<Books, String> {
|
||||
let snap = Snapshot::load(&source.data_dir).map_err(|e| {
|
||||
problem(&e, || {
|
||||
format!("Can't read the books in {}", source.data_dir.display())
|
||||
})
|
||||
})?;
|
||||
let integrity = Integrity::check(&snap, &mut HashSet::new());
|
||||
let settings = settings(source, &snap)?;
|
||||
let today = today(source, &settings);
|
||||
Books::build(&snap, settings, today, None, integrity)
|
||||
}
|
||||
|
||||
/// What the viewer says when the books can't be read. Books from a newer
|
||||
/// wanderbooks aren't broken, so they get the plain "update" message on its
|
||||
/// own, never a failure framing; anything else is `context: error`.
|
||||
pub fn problem(e: &StoreError, context: impl FnOnce() -> String) -> String {
|
||||
match e {
|
||||
StoreError::NewerJournal { .. } => {
|
||||
let text = e.to_string();
|
||||
let mut chars = text.chars();
|
||||
chars
|
||||
.next()
|
||||
.map(|c| c.to_uppercase().chain(chars).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
_ => format!("{}: {e}", context()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Today: as fixed for tests, else in the company's time zone, else the
|
||||
/// computer's.
|
||||
fn today(source: &Source, settings: &BooksSettings) -> NaiveDate {
|
||||
source
|
||||
.today
|
||||
.unwrap_or_else(|| ledger_core::settings::today_in(settings.timezone.as_deref()))
|
||||
}
|
||||
|
||||
/// The settings: the books' own when they keep them (the settings file is
|
||||
/// then never read, as the server never reads it), else the settings
|
||||
/// file's.
|
||||
fn settings(source: &Source, snap: &Snapshot) -> Result<BooksSettings, String> {
|
||||
let books = snap.ledger().settings();
|
||||
if books.is_journaled() {
|
||||
return Ok(ledger_config::codec::books_settings(
|
||||
books,
|
||||
snap.ledger().base_currency().code(),
|
||||
));
|
||||
}
|
||||
match &source.books_toml {
|
||||
Some(p) if p.exists() => ledger_config::load(p),
|
||||
_ => Ok(BooksSettings::default()),
|
||||
}
|
||||
}
|
||||
|
||||
fn mtime(p: &Option<PathBuf>) -> Option<SystemTime> {
|
||||
p.as_ref()
|
||||
.and_then(|p| std::fs::metadata(p).ok())
|
||||
.and_then(|m| m.modified().ok())
|
||||
}
|
||||
|
||||
fn run(source: Source, ctx: egui::Context, tx: Sender<Update>) {
|
||||
let send = |u: Update| {
|
||||
let _ = tx.send(u);
|
||||
ctx.request_repaint();
|
||||
};
|
||||
let mut snap = loop {
|
||||
match Snapshot::load(&source.data_dir) {
|
||||
Ok(s) => break s,
|
||||
Err(e) => {
|
||||
send(Update::Error(problem(&e, || {
|
||||
format!("Can't read the books in {}", source.data_dir.display())
|
||||
})));
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut verified: HashSet<String> = HashSet::new();
|
||||
// Build the picture; also return the database check it was built with.
|
||||
let mut build = |snap: &Snapshot, prev: Option<u64>| -> (Update, ledger_store::DiskCheck) {
|
||||
let integrity = Integrity::check(snap, &mut verified);
|
||||
let disk = integrity.disk.clone();
|
||||
let update = match settings(&source, snap).and_then(|s| {
|
||||
let today = today(&source, &s);
|
||||
Books::build(snap, s, today, prev, integrity)
|
||||
}) {
|
||||
Ok(b) => Update::Books(Arc::new(b)),
|
||||
Err(e) => Update::Error(e),
|
||||
};
|
||||
(update, disk)
|
||||
};
|
||||
let (first, disk) = build(&snap, None);
|
||||
let mut last_disk = Some(disk);
|
||||
send(first);
|
||||
|
||||
// Requests to show something: apply new ones, and on start one made in
|
||||
// the last two minutes (the agent said "open the viewer"); never an
|
||||
// old one.
|
||||
let started = chrono::Utc::now();
|
||||
let mut last_show = 0u64;
|
||||
let mut check_show = |send: &dyn Fn(Update)| {
|
||||
if let Some(cmd) = ledger_store::viewer::read(&source.data_dir)
|
||||
&& cmd.seq != last_show
|
||||
{
|
||||
let first_look = last_show == 0;
|
||||
last_show = cmd.seq;
|
||||
let recent = chrono::DateTime::parse_from_rfc3339(&cmd.at)
|
||||
.is_ok_and(|t| started.signed_duration_since(t) < chrono::Duration::minutes(2));
|
||||
if !first_look || recent {
|
||||
send(Update::Show(cmd.target));
|
||||
}
|
||||
}
|
||||
};
|
||||
check_show(&send);
|
||||
|
||||
let (ev_tx, ev_rx) = channel();
|
||||
let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
|
||||
if res.is_ok() {
|
||||
let _ = ev_tx.send(());
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
if let Some(w) = watcher.as_mut() {
|
||||
let _ = w.watch(&source.data_dir, RecursiveMode::NonRecursive);
|
||||
let cmd = ledger_store::viewer::command_path(&source.data_dir);
|
||||
if let Some(dir) = cmd.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
let _ = w.watch(dir, RecursiveMode::NonRecursive);
|
||||
}
|
||||
if let Some(t) = &source.books_toml
|
||||
&& let Some(dir) = t.parent()
|
||||
{
|
||||
let _ = w.watch(dir, RecursiveMode::NonRecursive);
|
||||
}
|
||||
}
|
||||
let mut toml_mtime = mtime(&source.books_toml);
|
||||
let day_now = |snap: &Snapshot| {
|
||||
settings(&source, snap)
|
||||
.map(|s| today(&source, &s))
|
||||
.unwrap_or_else(|_| today(&source, &BooksSettings::default()))
|
||||
};
|
||||
let mut day = day_now(&snap);
|
||||
loop {
|
||||
match ev_rx.recv_timeout(Duration::from_secs(3)) {
|
||||
Ok(()) => {
|
||||
// Let a burst of writes settle, then take them all at once.
|
||||
std::thread::sleep(Duration::from_millis(30));
|
||||
while ev_rx.try_recv().is_ok() {}
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout) => {}
|
||||
Err(RecvTimeoutError::Disconnected) => {
|
||||
std::thread::sleep(Duration::from_secs(3));
|
||||
}
|
||||
}
|
||||
let before = snap.journal_seq();
|
||||
let grew = match snap.refresh() {
|
||||
Ok(n) => n > 0,
|
||||
Err(e) => {
|
||||
send(Update::Error(problem(&e, || {
|
||||
"Reading the journal failed".into()
|
||||
})));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let toml_now = mtime(&source.books_toml);
|
||||
let new_day = day_now(&snap);
|
||||
// The writer journals first and updates its database right after:
|
||||
// a "behind" database usually catches up a moment later, so look
|
||||
// again rather than leave a stale warning up.
|
||||
let disk_changed = !grew
|
||||
&& last_disk
|
||||
.as_ref()
|
||||
.is_some_and(|d| *d != ledger_store::DiskCheck::Matches && snap.check_disk() != *d);
|
||||
if grew || disk_changed || toml_now != toml_mtime || new_day != day {
|
||||
toml_mtime = toml_now;
|
||||
day = new_day;
|
||||
let (update, disk) = build(&snap, grew.then_some(before));
|
||||
last_disk = Some(disk);
|
||||
send(update);
|
||||
}
|
||||
check_show(&send);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,135 @@
|
||||
//! Files the viewer makes for you (report PDFs). They go to the
|
||||
//! Downloads folder, never into the books: the viewer stays read-only.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Where saved files go: `$XDG_DOWNLOAD_DIR`, else `~/Downloads`, else
|
||||
/// the home directory.
|
||||
pub fn downloads_dir() -> PathBuf {
|
||||
if let Some(d) = std::env::var_os("XDG_DOWNLOAD_DIR").map(PathBuf::from)
|
||||
&& d.is_dir()
|
||||
{
|
||||
return d;
|
||||
}
|
||||
let home = std::env::var_os("HOME")
|
||||
.or_else(|| std::env::var_os("USERPROFILE"))
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
let d = home.join("Downloads");
|
||||
if d.is_dir() { d } else { home }
|
||||
}
|
||||
|
||||
/// `name` made safe as a file name on every platform.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: a report title (company data) can never name a file outside the
|
||||
/// downloads folder, or one a platform refuses.
|
||||
/// SOURCE: docs/spec/sources/file-names.md (Microsoft's naming rules;
|
||||
/// POSIX NAME_MAX).
|
||||
/// INVARIANTS:
|
||||
/// 1. The result is `<base>.<ext>`, `base` holding no reserved
|
||||
/// character (< > : " / \ | ? *) and no control character: each is
|
||||
/// replaced ('-' or a space).
|
||||
/// 2. `base` is never empty, never "." or "..", never ends with a space
|
||||
/// or a period, and is never a reserved device name (CON, NUL, COM1,
|
||||
/// LPT¹, ... in any case, before its first period): such a base gets
|
||||
/// a leading '_'.
|
||||
/// 3. The whole name is at most 255 bytes of UTF-8, cut at a character
|
||||
/// boundary.
|
||||
/// ```
|
||||
pub fn file_name(name: &str, ext: &str) -> String {
|
||||
let clean: String = name
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
|
||||
c if c.is_control() => ' ',
|
||||
c => c,
|
||||
})
|
||||
.collect();
|
||||
let mut base = clean.trim().trim_end_matches(['.', ' ']).to_string();
|
||||
if base.is_empty() {
|
||||
base = "report".to_string();
|
||||
}
|
||||
let stem = base
|
||||
.split('.')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim_end()
|
||||
.to_uppercase();
|
||||
let reserved = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|
||||
|| ["COM", "LPT"].iter().any(|p| {
|
||||
stem.strip_prefix(p).is_some_and(|n| {
|
||||
matches!(
|
||||
n,
|
||||
"1" | "2"
|
||||
| "3"
|
||||
| "4"
|
||||
| "5"
|
||||
| "6"
|
||||
| "7"
|
||||
| "8"
|
||||
| "9"
|
||||
| "\u{b9}"
|
||||
| "\u{b2}"
|
||||
| "\u{b3}"
|
||||
)
|
||||
})
|
||||
});
|
||||
if reserved {
|
||||
base.insert(0, '_');
|
||||
}
|
||||
let limit = 255usize.saturating_sub(ext.len() + 1);
|
||||
if base.len() > limit {
|
||||
let mut cut = limit;
|
||||
while !base.is_char_boundary(cut) {
|
||||
cut -= 1;
|
||||
}
|
||||
base.truncate(cut);
|
||||
// A cut can leave a trailing space or period: trim again.
|
||||
while base.ends_with(['.', ' ']) {
|
||||
base.pop();
|
||||
}
|
||||
if base.is_empty() {
|
||||
base = "report".to_string();
|
||||
}
|
||||
}
|
||||
format!("{base}.{ext}")
|
||||
}
|
||||
|
||||
/// Write `bytes` to the downloads folder as `name.ext` (replacing a file
|
||||
/// of that name: the same report renders the same bytes) and open it with
|
||||
/// the system's viewer.
|
||||
pub fn save_and_open(name: &str, ext: &str, bytes: &[u8]) -> Result<PathBuf, String> {
|
||||
let path = downloads_dir().join(file_name(name, ext));
|
||||
std::fs::write(&path, bytes).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
open(&path);
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Open a file with whatever the desktop uses for its type. Best effort:
|
||||
/// the file is saved either way.
|
||||
fn open(path: &Path) {
|
||||
#[cfg(target_os = "macos")]
|
||||
let cmd = std::process::Command::new("open").arg(path).spawn();
|
||||
#[cfg(target_os = "windows")]
|
||||
let cmd = std::process::Command::new("cmd")
|
||||
.args(["/C", "start", ""])
|
||||
.arg(path)
|
||||
.spawn();
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
let cmd = std::process::Command::new("xdg-open").arg(path).spawn();
|
||||
let _ = cmd;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn file_names_are_safe() {
|
||||
assert_eq!(
|
||||
file_name("Acme LLC — Profit and loss — Jan 1/2026: draft", "pdf"),
|
||||
"Acme LLC — Profit and loss — Jan 1-2026- draft.pdf"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
//! Formatting money, dates, and counts. Amounts are exact decimals in and
|
||||
//! exact strings out: nothing here rounds except `compact`, which says so.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: show every amount exactly as the books hold it.
|
||||
//! SOURCE: DECISION (the MCP contract: "money is exact or it is wrong");
|
||||
//! CONVENTION (US accounting display: thousands commas,
|
||||
//! negatives in parentheses or with a minus sign).
|
||||
//! INVARIANTS:
|
||||
//! 1. money, accounting, and dollars NEVER round: every decimal place
|
||||
//! the amount has (at least two) is shown, thousands grouped by
|
||||
//! commas in threes, so the text reads back to the same value.
|
||||
//! 2. Zero (and negative zero) shows without a sign; a negative shows
|
||||
//! U+2212 (money, dollars) or parentheses (accounting), never both
|
||||
//! and never "-0".
|
||||
//! 3. compact rounds (chart axes, tight tiles): $950, $1.2k, $25k,
|
||||
//! $3.4M; it never shows a sign on what rounds to zero.
|
||||
//! ```
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
/// Thousands separators, every decimal (at least two), a true minus sign:
|
||||
/// `−1,234.56`.
|
||||
pub fn money(d: Decimal) -> String {
|
||||
let s = group(d.abs());
|
||||
if is_negative(d) {
|
||||
format!("\u{2212}{s}")
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// What the viewer shows for a figure the books can't compute (rules.md
|
||||
/// B-28: an earlier version's draft lines, shares, or bases past exact
|
||||
/// arithmetic): said in words, never a number.
|
||||
pub const CANT_COMPUTE: &str = "too large to compute";
|
||||
|
||||
/// [`money`], or [`CANT_COMPUTE`] for a figure the books can't compute.
|
||||
pub fn money_or(d: Option<Decimal>) -> String {
|
||||
d.map_or_else(|| CANT_COMPUTE.to_string(), money)
|
||||
}
|
||||
|
||||
/// [`dollars`], or [`CANT_COMPUTE`] for a figure the books can't compute.
|
||||
pub fn dollars_or(d: Option<Decimal>) -> String {
|
||||
d.map_or_else(|| CANT_COMPUTE.to_string(), dollars)
|
||||
}
|
||||
|
||||
/// Accounting style: negatives in parentheses, `(1,234.56)`.
|
||||
pub fn accounting(d: Decimal) -> String {
|
||||
let s = group(d.abs());
|
||||
if is_negative(d) { format!("({s})") } else { s }
|
||||
}
|
||||
|
||||
/// With the currency sign: `$1,234.56`, `−$12.00`.
|
||||
pub fn dollars(d: Decimal) -> String {
|
||||
let s = format!("${}", group(d.abs()));
|
||||
if is_negative(d) {
|
||||
format!("\u{2212}{s}")
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// Rounded for chart axes and tight tiles: `$1.2k`, `$3.4M`, `$950`.
|
||||
pub fn compact(d: Decimal) -> String {
|
||||
let f: f64 = d.try_into().unwrap_or(0.0);
|
||||
let a = f.abs();
|
||||
let s = if a >= 1_000_000.0 {
|
||||
format!("${:.1}M", a / 1_000_000.0)
|
||||
} else if a >= 10_000.0 {
|
||||
format!("${:.0}k", a / 1_000.0)
|
||||
} else if a >= 1_000.0 {
|
||||
format!("${:.1}k", a / 1_000.0)
|
||||
} else {
|
||||
format!("${a:.0}")
|
||||
};
|
||||
// No sign on what rounds to zero ("$0", not "−$0").
|
||||
if f < 0.0 && s != "$0" {
|
||||
format!("\u{2212}{s}")
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
fn is_negative(d: Decimal) -> bool {
|
||||
d.is_sign_negative() && !d.is_zero()
|
||||
}
|
||||
|
||||
/// A non-negative amount, every decimal it has (at least two), grouped.
|
||||
fn group(d: Decimal) -> String {
|
||||
let places = d.normalize().scale().max(2) as usize;
|
||||
let s = format!("{:.*}", places, d);
|
||||
let (int, frac) = s.split_once('.').unwrap_or((&s, "00"));
|
||||
let mut out = String::new();
|
||||
for (i, c) in int.chars().enumerate() {
|
||||
if i > 0 && (int.len() - i) % 3 == 0 {
|
||||
out.push(',');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
format!("{out}.{frac}")
|
||||
}
|
||||
|
||||
/// The plain decimal behind a displayed amount, for the clipboard:
|
||||
/// `$1,234.56` → `1234.56`, `(207.68)` and `−12.00` → `-207.68`, `-12.00`.
|
||||
/// Exact (the display never rounds). None when the text isn't an amount.
|
||||
pub fn plain(display: &str) -> Option<String> {
|
||||
let t = display.trim();
|
||||
let (neg, t) = match t.strip_prefix('(').and_then(|x| x.strip_suffix(')')) {
|
||||
Some(inner) => (true, inner),
|
||||
None => (false, t),
|
||||
};
|
||||
let (neg, t) = match t.strip_prefix('\u{2212}').or_else(|| t.strip_prefix('-')) {
|
||||
Some(rest) => (!neg, rest),
|
||||
None => (neg, t.strip_prefix('+').unwrap_or(t)),
|
||||
};
|
||||
let digits: String = t.chars().filter(|c| !matches!(c, '$' | ',')).collect();
|
||||
let d: Decimal = digits.parse().ok()?;
|
||||
if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit() || c == '.') {
|
||||
return None;
|
||||
}
|
||||
Some(if neg && !d.is_zero() {
|
||||
format!("-{digits}")
|
||||
} else {
|
||||
digits
|
||||
})
|
||||
}
|
||||
|
||||
/// A percent change from `prev` to `now`, if `prev` isn't zero.
|
||||
pub fn pct_change(prev: Decimal, now: Decimal) -> Option<f64> {
|
||||
if prev.is_zero() {
|
||||
return None;
|
||||
}
|
||||
let p: f64 = prev.try_into().ok()?;
|
||||
let n: f64 = now.try_into().ok()?;
|
||||
Some((n - p) / p.abs() * 100.0)
|
||||
}
|
||||
|
||||
/// `Oct 12, 2026`.
|
||||
pub fn date(d: NaiveDate) -> String {
|
||||
d.format("%b %-d, %Y").to_string()
|
||||
}
|
||||
|
||||
/// `Oct 12`.
|
||||
pub fn date_short(d: NaiveDate) -> String {
|
||||
d.format("%b %-d").to_string()
|
||||
}
|
||||
|
||||
/// `Oct 2026`.
|
||||
pub fn month(d: NaiveDate) -> String {
|
||||
d.format("%b %Y").to_string()
|
||||
}
|
||||
|
||||
/// `1 receipt`, `3 receipts`.
|
||||
pub fn count(n: usize, one: &str, many: &str) -> String {
|
||||
format!("{n} {}", if n == 1 { one } else { many })
|
||||
}
|
||||
|
||||
/// An RFC 3339 timestamp from the journal, shown as local-ish UTC text:
|
||||
/// `Oct 11, 2026 18:04 UTC`.
|
||||
pub fn timestamp(ts: &str) -> String {
|
||||
chrono::DateTime::parse_from_rfc3339(ts)
|
||||
.map(|t| t.format("%b %-d, %Y %H:%M UTC").to_string())
|
||||
.unwrap_or_else(|_| ts.to_string())
|
||||
}
|
||||
|
||||
/// Bytes as `12.3 KB`.
|
||||
pub fn bytes(n: u64) -> String {
|
||||
let f = n as f64;
|
||||
if f >= 1_048_576.0 {
|
||||
format!("{:.1} MB", f / 1_048_576.0)
|
||||
} else if f >= 1024.0 {
|
||||
format!("{:.1} KB", f / 1024.0)
|
||||
} else {
|
||||
format!("{n} B")
|
||||
}
|
||||
}
|
||||
|
||||
/// `snake_case_key` as `Snake case key`, for settings shown as labels,
|
||||
/// keeping acronyms upper case (`ein` → `EIN`).
|
||||
pub fn label(key: &str) -> String {
|
||||
const ACRONYMS: [&str; 9] = [
|
||||
"ein", "duns", "irs", "cpa", "llc", "tin", "ssn", "id", "url",
|
||||
];
|
||||
let words: Vec<String> = key
|
||||
.split(['_', ' '])
|
||||
.filter(|w| !w.is_empty())
|
||||
.enumerate()
|
||||
.map(|(i, w)| {
|
||||
if ACRONYMS.contains(&w.to_lowercase().as_str()) {
|
||||
w.to_uppercase()
|
||||
} else if i == 0 {
|
||||
let mut c = w.chars();
|
||||
c.next()
|
||||
.map(|f| f.to_uppercase().collect::<String>() + c.as_str())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
w.to_string()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
words.join(" ")
|
||||
}
|
||||
|
||||
/// A `CamelCase` enum name as words: `PrepaidExpense` → `Prepaid expense`.
|
||||
pub fn words(debug: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for (i, c) in debug.chars().enumerate() {
|
||||
if c.is_uppercase() && i > 0 {
|
||||
out.push(' ');
|
||||
out.extend(c.to_lowercase());
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The tax return a form enum stands for.
|
||||
pub fn tax_form(f: ledger_core::TaxForm) -> &'static str {
|
||||
match f {
|
||||
ledger_core::TaxForm::ScheduleC => "Schedule C (Form 1040)",
|
||||
ledger_core::TaxForm::Form1065 => "Form 1065 (partnership)",
|
||||
ledger_core::TaxForm::Form1120S => "Form 1120-S (S corporation)",
|
||||
ledger_core::TaxForm::Form1120 => "Form 1120 (C corporation)",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::str::FromStr;
|
||||
|
||||
/// money_or / dollars_or: a figure as money_ or dollars does, and one
|
||||
/// the books can't compute in words, never a number (rules.md B-28).
|
||||
#[test]
|
||||
fn a_figure_or_why_not() {
|
||||
let v = Decimal::new(-123_456, 2);
|
||||
assert_eq!(money_or(Some(v)), money(v));
|
||||
assert_eq!(dollars_or(Some(v)), dollars(v));
|
||||
assert_eq!(money_or(None), CANT_COMPUTE);
|
||||
assert_eq!(dollars_or(None), CANT_COMPUTE);
|
||||
assert_eq!(CANT_COMPUTE, "too large to compute");
|
||||
}
|
||||
|
||||
fn dec(s: &str) -> Decimal {
|
||||
Decimal::from_str(s).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn money_formats() {
|
||||
assert_eq!(money(dec("1234567.5")), "1,234,567.50");
|
||||
assert_eq!(money(dec("-12")), "\u{2212}12.00");
|
||||
assert_eq!(money(dec("0")), "0.00");
|
||||
assert_eq!(money(dec("-0.00")), "0.00");
|
||||
assert_eq!(accounting(dec("-207.68")), "(207.68)");
|
||||
assert_eq!(dollars(dec("999.999")), "$999.999");
|
||||
assert_eq!(money(dec("0.125")), "0.125");
|
||||
assert_eq!(compact(dec("-0.4")), "$0");
|
||||
assert_eq!(compact(dec("1234")), "$1.2k");
|
||||
assert_eq!(compact(dec("-25000")), "\u{2212}$25k");
|
||||
assert_eq!(compact(dec("950.4")), "$950");
|
||||
assert_eq!(label("state_entity_number"), "State entity number");
|
||||
assert_eq!(label("ein"), "EIN");
|
||||
assert_eq!(label("irs_name_control"), "IRS name control");
|
||||
assert_eq!(words("PrepaidExpense"), "Prepaid expense");
|
||||
assert_eq!(plain("$1,234.56").as_deref(), Some("1234.56"));
|
||||
assert_eq!(plain("(207.68)").as_deref(), Some("-207.68"));
|
||||
assert_eq!(plain("\u{2212}$12.00").as_deref(), Some("-12.00"));
|
||||
assert_eq!(plain("+1,000.005").as_deref(), Some("1000.005"));
|
||||
assert_eq!(plain("0.00").as_deref(), Some("0.00"));
|
||||
assert_eq!(plain("Debit"), None);
|
||||
// Only digits and a point: the decimal parser alone takes these.
|
||||
assert_eq!(plain("1_000.00"), None);
|
||||
assert_eq!(plain("1e3"), None);
|
||||
assert_eq!(plain(""), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//! `ledger-gui`: a read-only, live view of wanderbooks books for the people
|
||||
//! who own them and the accountant who checks them. See `plan/gui.md`.
|
||||
|
||||
#![allow(missing_docs)]
|
||||
|
||||
pub mod app;
|
||||
pub mod attention;
|
||||
pub mod books;
|
||||
pub mod charts;
|
||||
pub mod data;
|
||||
pub mod demo;
|
||||
pub mod export;
|
||||
pub mod fmt;
|
||||
pub mod nav;
|
||||
pub mod pages;
|
||||
pub mod palette;
|
||||
mod receipts;
|
||||
#[cfg(test)]
|
||||
mod test_support;
|
||||
mod theme;
|
||||
mod widgets;
|
||||
|
||||
pub use app::App;
|
||||
pub use data::Source;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The vault key file named on the command line (`--vault-key-file`),
|
||||
/// which wins over a settings file's `[vault] key_file`, as it does for
|
||||
/// the server: books that keep their own settings never name it.
|
||||
static VAULT_KEY_FILE: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
|
||||
|
||||
/// Name the vault key file (once, at start).
|
||||
pub fn set_vault_key_file(path: std::path::PathBuf) {
|
||||
let _ = VAULT_KEY_FILE.set(path);
|
||||
}
|
||||
|
||||
/// The vault key file named on the command line, if one was.
|
||||
pub fn vault_key_file() -> Option<std::path::PathBuf> {
|
||||
VAULT_KEY_FILE.get().cloned()
|
||||
}
|
||||
|
||||
/// How [`run`] stops when it can't go on: the reason is already printed
|
||||
/// on stderr, and the number is the process's exit code (2: a bad argument; 1: the demo books couldn't be built). A
|
||||
/// library never ends the process itself, so the books it opened are
|
||||
/// closed properly on the way out; the binaries turn this into the exit.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Exit(pub i32);
|
||||
|
||||
impl std::fmt::Display for Exit {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "exit code {}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Exit {}
|
||||
|
||||
/// Run the viewer with `args` (the command line after the program name);
|
||||
/// `prog` names the program in the usage text (`ledger-gui`, or
|
||||
/// `wanderbooks gui`). It opens the books read-only and returns when the
|
||||
/// window closes. An unknown argument returns [`Exit`] (code 2).
|
||||
///
|
||||
/// `ledger-gui [--data-dir PATH] [--books-toml PATH] [--vault-key-file PATH] [--today YYYY-MM-DD] [--demo]`:
|
||||
/// with no flags it looks for `books-data/` and `wanderbooks.toml` in the
|
||||
/// current directory; `--demo` builds a demo company in a temp directory
|
||||
/// and opens that.
|
||||
pub fn run(
|
||||
prog: &str,
|
||||
args: impl IntoIterator<Item = String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut data_dir: Option<PathBuf> = None;
|
||||
let mut books_toml: Option<PathBuf> = None;
|
||||
let mut today = None;
|
||||
let mut demo = false;
|
||||
let mut args = args.into_iter();
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--data-dir" => data_dir = args.next().map(PathBuf::from),
|
||||
"--books-toml" => books_toml = args.next().map(PathBuf::from),
|
||||
"--vault-key-file" => {
|
||||
if let Some(k) = args.next() {
|
||||
crate::set_vault_key_file(PathBuf::from(k));
|
||||
}
|
||||
}
|
||||
"--today" => today = args.next().and_then(|d| d.parse().ok()),
|
||||
"--demo" => demo = true,
|
||||
"-h" | "--help" => {
|
||||
println!(
|
||||
"usage: {prog} [--data-dir PATH] [--books-toml PATH] [--vault-key-file PATH] [--today YYYY-MM-DD] [--demo]"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
other => {
|
||||
eprintln!("unknown argument {other}");
|
||||
return Err(Box::new(Exit(2)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if demo {
|
||||
let d = today.unwrap_or_else(|| chrono::Local::now().date_naive());
|
||||
let dir = std::env::temp_dir().join(format!("wanderbooks-demo-{d}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
if let Err(e) = crate::demo::build(&dir, d) {
|
||||
eprintln!("building the demo books failed: {e}");
|
||||
return Err(Box::new(Exit(1)));
|
||||
}
|
||||
eprintln!("demo books: {}", dir.display());
|
||||
data_dir = Some(dir.join("books-data"));
|
||||
books_toml = Some(dir.join("wanderbooks.toml"));
|
||||
}
|
||||
let data_dir = data_dir.unwrap_or_else(|| PathBuf::from("books-data"));
|
||||
let books_toml = books_toml.or_else(|| {
|
||||
let guess = data_dir
|
||||
.parent()
|
||||
.unwrap_or(&data_dir)
|
||||
.join("wanderbooks.toml");
|
||||
guess.exists().then_some(guess)
|
||||
});
|
||||
let source = Source {
|
||||
data_dir,
|
||||
books_toml,
|
||||
today,
|
||||
};
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("wanderbooks")
|
||||
.with_app_id("wanderbooks")
|
||||
.with_inner_size([1440.0, 920.0])
|
||||
// Small on purpose: tiling window managers hand out small slots
|
||||
// regardless, and every screen lays itself out to fit.
|
||||
.with_min_inner_size([360.0, 400.0]),
|
||||
..Default::default()
|
||||
};
|
||||
Ok(eframe::run_native(
|
||||
"wanderbooks",
|
||||
options,
|
||||
Box::new(move |cc| Ok(Box::new(App::new(cc, source)))),
|
||||
)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod exit_tests {
|
||||
use super::Exit;
|
||||
|
||||
#[test]
|
||||
fn an_exit_says_its_code() {
|
||||
assert_eq!(Exit(2).to_string(), "exit code 2");
|
||||
assert_eq!(Exit(1).to_string(), "exit code 1");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//! `ledger-gui`: the read-only, live viewer. The same viewer is
|
||||
//! `wanderbooks gui`; both call [`ledger_gui::run`].
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
match ledger_gui::run("ledger-gui", std::env::args().skip(1)) {
|
||||
Err(e) => match e.downcast_ref::<ledger_gui::Exit>() {
|
||||
Some(exit) => std::process::exit(exit.0),
|
||||
None => Err(e),
|
||||
},
|
||||
done => done,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
//! Where you are and how you got there: a browser-style history.
|
||||
|
||||
use ledger_core::{AccountId, AssetId, EntryId, PartyId};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum ReportKind {
|
||||
ProfitAndLoss,
|
||||
BalanceSheet,
|
||||
CashFlow,
|
||||
TrialBalance,
|
||||
GeneralLedger,
|
||||
MembersEquity,
|
||||
TaxSummary,
|
||||
ArAging,
|
||||
ApAging,
|
||||
Vendor1099,
|
||||
FixedAssetRegister,
|
||||
}
|
||||
|
||||
impl ReportKind {
|
||||
pub const ALL: [ReportKind; 11] = [
|
||||
ReportKind::ProfitAndLoss,
|
||||
ReportKind::BalanceSheet,
|
||||
ReportKind::CashFlow,
|
||||
ReportKind::MembersEquity,
|
||||
ReportKind::TrialBalance,
|
||||
ReportKind::GeneralLedger,
|
||||
ReportKind::FixedAssetRegister,
|
||||
ReportKind::TaxSummary,
|
||||
ReportKind::Vendor1099,
|
||||
ReportKind::ArAging,
|
||||
ReportKind::ApAging,
|
||||
];
|
||||
|
||||
pub fn title(self) -> &'static str {
|
||||
match self {
|
||||
ReportKind::ProfitAndLoss => "Profit and loss",
|
||||
ReportKind::BalanceSheet => "Balance sheet",
|
||||
ReportKind::CashFlow => "Cash flow",
|
||||
ReportKind::TrialBalance => "Trial balance",
|
||||
ReportKind::GeneralLedger => "General ledger",
|
||||
ReportKind::MembersEquity => "Owners' equity",
|
||||
ReportKind::TaxSummary => "Tax summary",
|
||||
ReportKind::ArAging => "Receivables aging",
|
||||
ReportKind::ApAging => "Payables aging",
|
||||
ReportKind::Vendor1099 => "1099 worksheet",
|
||||
ReportKind::FixedAssetRegister => "Fixed asset register",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Route {
|
||||
Overview,
|
||||
Cash,
|
||||
Profit,
|
||||
Transactions,
|
||||
Entry(EntryId),
|
||||
Reports(ReportKind),
|
||||
Accounts,
|
||||
Account(AccountId),
|
||||
/// One reconciliation: its statement, balance, and the lines it
|
||||
/// cleared.
|
||||
Reconciliation(u64),
|
||||
/// The fixed asset register.
|
||||
Assets,
|
||||
/// One fixed asset.
|
||||
Asset(AssetId),
|
||||
/// The party directory.
|
||||
Parties,
|
||||
/// One party.
|
||||
Party(PartyId),
|
||||
Documents,
|
||||
Document(u64),
|
||||
Company,
|
||||
Journal,
|
||||
JournalEvent(u64),
|
||||
}
|
||||
|
||||
impl Route {
|
||||
/// The sidebar section this route belongs to.
|
||||
pub fn section(&self) -> Section {
|
||||
match self {
|
||||
Route::Overview => Section::Overview,
|
||||
Route::Cash => Section::Cash,
|
||||
Route::Profit => Section::Profit,
|
||||
Route::Transactions | Route::Entry(_) => Section::Transactions,
|
||||
Route::Reports(_) => Section::Reports,
|
||||
Route::Accounts | Route::Account(_) | Route::Reconciliation(_) => Section::Accounts,
|
||||
Route::Assets | Route::Asset(_) => Section::Assets,
|
||||
Route::Parties | Route::Party(_) => Section::Parties,
|
||||
Route::Documents | Route::Document(_) => Section::Documents,
|
||||
Route::Company => Section::Company,
|
||||
Route::Journal | Route::JournalEvent(_) => Section::Journal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Section {
|
||||
Overview,
|
||||
Cash,
|
||||
Profit,
|
||||
Transactions,
|
||||
Reports,
|
||||
Accounts,
|
||||
Assets,
|
||||
Parties,
|
||||
Documents,
|
||||
Company,
|
||||
Journal,
|
||||
}
|
||||
|
||||
impl Section {
|
||||
pub const ALL: [Section; 11] = [
|
||||
Section::Overview,
|
||||
Section::Cash,
|
||||
Section::Profit,
|
||||
Section::Transactions,
|
||||
Section::Reports,
|
||||
Section::Accounts,
|
||||
Section::Assets,
|
||||
Section::Parties,
|
||||
Section::Documents,
|
||||
Section::Company,
|
||||
Section::Journal,
|
||||
];
|
||||
|
||||
pub fn title(self) -> &'static str {
|
||||
match self {
|
||||
Section::Overview => "Overview",
|
||||
Section::Cash => "Cash",
|
||||
Section::Profit => "Profit",
|
||||
Section::Transactions => "Transactions",
|
||||
Section::Reports => "Reports",
|
||||
Section::Accounts => "Accounts",
|
||||
Section::Assets => "Assets",
|
||||
Section::Parties => "Parties",
|
||||
Section::Documents => "Documents",
|
||||
Section::Company => "Company",
|
||||
Section::Journal => "Journal",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon(self) -> &'static str {
|
||||
use egui_phosphor::regular as i;
|
||||
match self {
|
||||
Section::Overview => i::SQUARES_FOUR,
|
||||
Section::Cash => i::BANK,
|
||||
Section::Profit => i::TREND_UP,
|
||||
Section::Transactions => i::LIST_BULLETS,
|
||||
Section::Reports => i::FILE_TEXT,
|
||||
Section::Accounts => i::TREE_STRUCTURE,
|
||||
Section::Assets => i::PACKAGE,
|
||||
Section::Parties => i::ADDRESS_BOOK,
|
||||
Section::Documents => i::FILES,
|
||||
Section::Company => i::BUILDINGS,
|
||||
Section::Journal => i::SCROLL,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn home(self) -> Route {
|
||||
match self {
|
||||
Section::Overview => Route::Overview,
|
||||
Section::Cash => Route::Cash,
|
||||
Section::Profit => Route::Profit,
|
||||
Section::Transactions => Route::Transactions,
|
||||
Section::Reports => Route::Reports(ReportKind::ProfitAndLoss),
|
||||
Section::Accounts => Route::Accounts,
|
||||
Section::Assets => Route::Assets,
|
||||
Section::Parties => Route::Parties,
|
||||
Section::Documents => Route::Documents,
|
||||
Section::Company => Route::Company,
|
||||
Section::Journal => Route::Journal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ```text
|
||||
/// GOAL: back and forward behave like a web browser's.
|
||||
/// SOURCE: CONVENTION (browser session history: going somewhere new
|
||||
/// drops the forward list).
|
||||
/// INVARIANTS:
|
||||
/// 1. go(r) to a different route makes it current, pushes the old one
|
||||
/// on back, and clears forward; go to the current route does nothing.
|
||||
/// 2. back() then forward() returns to the same route (and vice versa);
|
||||
/// either does nothing when its list is empty.
|
||||
/// 3. can_back/can_forward say whether back/forward would move.
|
||||
/// ```
|
||||
/// Back/forward history.
|
||||
pub struct Nav {
|
||||
back: Vec<Route>,
|
||||
forward: Vec<Route>,
|
||||
pub current: Route,
|
||||
}
|
||||
|
||||
impl Nav {
|
||||
pub fn new(start: Route) -> Self {
|
||||
Self {
|
||||
back: Vec::new(),
|
||||
forward: Vec::new(),
|
||||
current: start,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn go(&mut self, to: Route) {
|
||||
if to != self.current {
|
||||
self.back.push(std::mem::replace(&mut self.current, to));
|
||||
self.forward.clear();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn can_back(&self) -> bool {
|
||||
!self.back.is_empty()
|
||||
}
|
||||
|
||||
pub fn can_forward(&self) -> bool {
|
||||
!self.forward.is_empty()
|
||||
}
|
||||
|
||||
pub fn back(&mut self) {
|
||||
if let Some(r) = self.back.pop() {
|
||||
self.forward.push(std::mem::replace(&mut self.current, r));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn forward(&mut self) {
|
||||
if let Some(r) = self.forward.pop() {
|
||||
self.back.push(std::mem::replace(&mut self.current, r));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
//! The chart of accounts with balances, and one account's register: every
|
||||
//! posting with a running balance and the balance over time, its bank or
|
||||
//! card statements, its reconciliation history, and its documents.
|
||||
|
||||
use super::assets::row;
|
||||
use super::page;
|
||||
use crate::app::Shell;
|
||||
use crate::books::Books;
|
||||
use crate::fmt;
|
||||
use crate::nav::Route;
|
||||
use crate::theme::{self, pal};
|
||||
use crate::widgets;
|
||||
use chrono::NaiveDate;
|
||||
use egui::{CornerRadius, RichText, Sense, Ui, vec2};
|
||||
use egui_phosphor::regular as icon;
|
||||
use ledger_core::AccountId;
|
||||
use ledger_core::AccountType;
|
||||
use ledger_core::analysis::balance_history;
|
||||
use ledger_core::reports::general_ledger;
|
||||
use ledger_store::Subject;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
/// Limit the register to these dates (set by drill-downs).
|
||||
pub range: Option<(NaiveDate, NaiveDate)>,
|
||||
pub show_empty: bool,
|
||||
}
|
||||
|
||||
pub fn show_chart(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
page(ui, "accounts", |ui| {
|
||||
widgets::page_header(
|
||||
ui,
|
||||
"Accounts",
|
||||
"Every account in the chart with today's balance. Click one for its register.",
|
||||
);
|
||||
ui.checkbox(
|
||||
&mut shell.state.accounts.show_empty,
|
||||
"Show accounts with no activity",
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
let used: std::collections::HashSet<u64> = b
|
||||
.entries
|
||||
.iter()
|
||||
.flat_map(|e| e.accounts.iter().copied())
|
||||
.collect();
|
||||
for (t, title) in [
|
||||
(AccountType::Asset, "Assets"),
|
||||
(AccountType::Liability, "Liabilities"),
|
||||
(AccountType::Equity, "Equity"),
|
||||
(AccountType::Revenue, "Income"),
|
||||
(AccountType::Expense, "Expenses"),
|
||||
] {
|
||||
let accounts: Vec<_> = b
|
||||
.ledger
|
||||
.chart()
|
||||
.all()
|
||||
.filter(|a| {
|
||||
a.account_type == t && (shell.state.accounts.show_empty || used.contains(&a.id))
|
||||
})
|
||||
.collect();
|
||||
if accounts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut go = None;
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
title,
|
||||
|_| {},
|
||||
|ui| {
|
||||
for a in &accounts {
|
||||
let bal = b.balance_on(a.id, b.today);
|
||||
let (r, resp) = ui
|
||||
.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(6), p.hover);
|
||||
}
|
||||
let painter = ui.painter_at(r);
|
||||
painter.text(
|
||||
r.left_center() + vec2(8.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
&a.code,
|
||||
theme::regular(13.0),
|
||||
p.muted,
|
||||
);
|
||||
let bal_text = fmt::accounting(bal);
|
||||
let bal_w = painter
|
||||
.layout_no_wrap(bal_text.clone(), theme::semibold(13.5), p.text)
|
||||
.size()
|
||||
.x;
|
||||
// Tags sit in their own column when there's room for
|
||||
// it; narrower, they move to the row's tooltip.
|
||||
let tags_x = 340.0;
|
||||
let tags_room = r.width() - tags_x - bal_w - 24.0;
|
||||
let name_room = if tags_room >= 120.0 {
|
||||
tags_x - 64.0 - 12.0
|
||||
} else {
|
||||
r.width() - 64.0 - bal_w - 24.0
|
||||
};
|
||||
widgets::clipped_text(
|
||||
&painter,
|
||||
r.left_center() + vec2(64.0, 0.0),
|
||||
&a.name,
|
||||
theme::regular(13.5),
|
||||
p.text,
|
||||
name_room,
|
||||
);
|
||||
let mut tags = Vec::new();
|
||||
if b.cash_accounts.contains(&a.id) {
|
||||
tags.push("cash".to_string());
|
||||
}
|
||||
if let Some(c) = &a.tax_category {
|
||||
let pct = a
|
||||
.deductible_percent
|
||||
.map(|p| format!(" {}%", p.normalize()))
|
||||
.unwrap_or_default();
|
||||
tags.push(format!("tax: {}{pct}", c.replace('_', " ")));
|
||||
} else if matches!(t, AccountType::Revenue | AccountType::Expense) {
|
||||
tags.push("no tax category".into());
|
||||
}
|
||||
if a.placeholder {
|
||||
tags.push("header".into());
|
||||
}
|
||||
let tags = tags.join(" · ");
|
||||
if tags_room >= 120.0 {
|
||||
widgets::clipped_text(
|
||||
&painter,
|
||||
r.left_center() + vec2(tags_x, 0.0),
|
||||
&tags,
|
||||
theme::regular(12.0),
|
||||
p.faint,
|
||||
tags_room,
|
||||
);
|
||||
}
|
||||
painter.text(
|
||||
r.right_center() - vec2(8.0, 0.0),
|
||||
egui::Align2::RIGHT_CENTER,
|
||||
bal_text,
|
||||
theme::semibold(13.5),
|
||||
p.text,
|
||||
);
|
||||
let resp = if tags_room < 120.0 && !tags.is_empty() {
|
||||
resp.on_hover_text(format!("{} {} · {tags}", a.code, a.name))
|
||||
} else {
|
||||
resp
|
||||
};
|
||||
if resp.clicked() {
|
||||
go = Some(a.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
if let Some(id) = go {
|
||||
shell.state.accounts.range = None;
|
||||
shell.go(Route::Account(id));
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn show_account(ui: &mut Ui, b: &Books, id: u64, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
let Some(a) = b.ledger.chart().get(id) else {
|
||||
return;
|
||||
};
|
||||
let first = b.kpis.first_entry.unwrap_or(b.today);
|
||||
let (s, e) = shell
|
||||
.state
|
||||
.accounts
|
||||
.range
|
||||
.unwrap_or((first.min(b.today), b.today));
|
||||
page(ui, "account", |ui| {
|
||||
widgets::copyable(
|
||||
ui,
|
||||
RichText::new(format!("{:?} account · {}", a.account_type, a.code))
|
||||
.font(theme::regular(13.0))
|
||||
.color(p.muted),
|
||||
&a.code,
|
||||
);
|
||||
ui.label(RichText::new(&a.name).font(theme::bold(26.0)));
|
||||
ui.add_space(4.0);
|
||||
let bal = b.balance_on(id, b.today);
|
||||
widgets::copyable(
|
||||
ui,
|
||||
RichText::new(format!("Balance today {}", fmt::dollars(bal)))
|
||||
.font(theme::regular(15.0))
|
||||
.color(p.muted),
|
||||
&fmt::plain(&fmt::dollars(bal)).unwrap_or_default(),
|
||||
);
|
||||
ui.add_space(6.0);
|
||||
let mut go_to: Option<Route> = None;
|
||||
reconciled_badge(ui, b, id);
|
||||
ui.add_space(8.0);
|
||||
let hist = balance_history(&b.ledger, &[id]);
|
||||
if hist.len() > 1 && a.account_type.is_balance_sheet() {
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Balance over time",
|
||||
|_| {},
|
||||
|ui| crate::charts::balance_line(ui, &hist, b.today, 180.0),
|
||||
);
|
||||
ui.add_space(12.0);
|
||||
}
|
||||
statements_and_reconciliations(ui, b, id, &mut go_to);
|
||||
super::documents::documents_card(
|
||||
ui,
|
||||
b,
|
||||
"Documents",
|
||||
&[Subject::Account(id)],
|
||||
"No documents linked to this account (statements, the agreement that opened it).",
|
||||
&mut go_to,
|
||||
);
|
||||
ui.add_space(14.0);
|
||||
if let Some(r) = go_to.take() {
|
||||
shell.go(r);
|
||||
}
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"{} {} – {}",
|
||||
icon::CALENDAR_BLANK,
|
||||
fmt::date(s),
|
||||
fmt::date(e)
|
||||
))
|
||||
.font(theme::semibold(13.5)),
|
||||
);
|
||||
if shell.state.accounts.range.is_some() && ui.button("Show all dates").clicked() {
|
||||
shell.state.accounts.range = None;
|
||||
}
|
||||
if ui
|
||||
.button(format!("{} Find in transactions", icon::LIST_BULLETS))
|
||||
.clicked()
|
||||
{
|
||||
shell.state.transactions.account = Some(id);
|
||||
shell.go(Route::Transactions);
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
let Ok(gl) = general_ledger(&b.ledger, s, e) else {
|
||||
return;
|
||||
};
|
||||
let Some(acct) = gl.accounts.iter().find(|x| x.code == a.code) else {
|
||||
widgets::card(ui, |ui| {
|
||||
widgets::empty(
|
||||
ui,
|
||||
icon::EMPTY,
|
||||
"No activity",
|
||||
"Nothing posted to this account in this range.",
|
||||
)
|
||||
});
|
||||
return;
|
||||
};
|
||||
let mut go = None;
|
||||
widgets::card(ui, |ui| {
|
||||
let amt = widgets::amount_col_w(ui.available_width(), 3, 90.0 + 120.0, 120.0);
|
||||
let w = ui.available_width().max(90.0 + 120.0 + amt * 3.0);
|
||||
widgets::min_width(ui, "register", w, |ui| {
|
||||
let header = |ui: &mut Ui, vals: [&str; 5]| {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(w, 26.0), Sense::hover());
|
||||
ui.painter().text(
|
||||
r.left_center() + vec2(4.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
vals[0],
|
||||
theme::semibold(12.0),
|
||||
p.muted,
|
||||
);
|
||||
ui.painter().text(
|
||||
r.left_center() + vec2(90.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
vals[1],
|
||||
theme::semibold(12.0),
|
||||
p.muted,
|
||||
);
|
||||
for (i, v) in vals[2..].iter().enumerate() {
|
||||
ui.painter().text(
|
||||
egui::pos2(r.right() - amt * (2 - i) as f32 - 4.0, r.center().y),
|
||||
egui::Align2::RIGHT_CENTER,
|
||||
*v,
|
||||
theme::semibold(12.0),
|
||||
p.muted,
|
||||
);
|
||||
}
|
||||
};
|
||||
header(ui, ["Date", "Description", "Debit", "Credit", "Balance"]);
|
||||
widgets::rule(ui);
|
||||
let line = |ui: &mut Ui,
|
||||
date: String,
|
||||
desc: String,
|
||||
sub: String,
|
||||
d: Decimal,
|
||||
c: Decimal,
|
||||
bal: Decimal,
|
||||
strong: bool|
|
||||
-> egui::Response {
|
||||
let (r, resp) = ui.allocate_exact_size(
|
||||
vec2(w, if sub.is_empty() { 30.0 } else { 42.0 }),
|
||||
Sense::click(),
|
||||
);
|
||||
resp.context_menu(|ui| {
|
||||
for (label, v) in [("Copy debit", d), ("Copy credit", c)] {
|
||||
if !v.is_zero() {
|
||||
widgets::copy_item(
|
||||
ui,
|
||||
label,
|
||||
&fmt::plain(&fmt::money(v)).unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
}
|
||||
widgets::copy_item(
|
||||
ui,
|
||||
"Copy balance",
|
||||
&fmt::plain(&fmt::money(bal)).unwrap_or_default(),
|
||||
);
|
||||
if let Some(id) = sub.strip_prefix('#').and_then(|t| t.split(' ').next()) {
|
||||
widgets::copy_item(ui, "Copy entry number", &format!("#{id}"));
|
||||
}
|
||||
});
|
||||
if resp.hovered() && !strong {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(6), p.hover);
|
||||
}
|
||||
let painter = ui.painter_at(r);
|
||||
let font = if strong {
|
||||
theme::semibold(13.0)
|
||||
} else {
|
||||
theme::regular(13.0)
|
||||
};
|
||||
let y = if sub.is_empty() {
|
||||
r.center().y
|
||||
} else {
|
||||
r.center().y - 7.0
|
||||
};
|
||||
painter.text(
|
||||
egui::pos2(r.left() + 4.0, y),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
date,
|
||||
theme::regular(12.5),
|
||||
p.muted,
|
||||
);
|
||||
// Leave room for the debit, credit, and balance columns.
|
||||
let text_w = r.width() - 90.0 - amt * 3.0 - 12.0;
|
||||
widgets::clipped_text(
|
||||
&painter,
|
||||
egui::pos2(r.left() + 90.0, y),
|
||||
&desc,
|
||||
font.clone(),
|
||||
p.text,
|
||||
text_w,
|
||||
);
|
||||
if !sub.is_empty() {
|
||||
widgets::clipped_text(
|
||||
&painter,
|
||||
egui::pos2(r.left() + 90.0, r.center().y + 9.0),
|
||||
&sub,
|
||||
theme::regular(11.5),
|
||||
p.faint,
|
||||
text_w,
|
||||
);
|
||||
}
|
||||
for (i, v) in [d, c].iter().enumerate() {
|
||||
if !v.is_zero() {
|
||||
painter.text(
|
||||
egui::pos2(r.right() - amt * (2 - i) as f32 - 4.0, y),
|
||||
egui::Align2::RIGHT_CENTER,
|
||||
fmt::money(*v),
|
||||
font.clone(),
|
||||
p.text,
|
||||
);
|
||||
}
|
||||
}
|
||||
painter.text(
|
||||
egui::pos2(r.right() - 4.0, y),
|
||||
egui::Align2::RIGHT_CENTER,
|
||||
fmt::accounting(bal),
|
||||
font,
|
||||
p.text,
|
||||
);
|
||||
resp
|
||||
};
|
||||
line(
|
||||
ui,
|
||||
fmt::date_short(s),
|
||||
"Beginning balance".into(),
|
||||
String::new(),
|
||||
Decimal::ZERO,
|
||||
Decimal::ZERO,
|
||||
acct.beginning_balance,
|
||||
true,
|
||||
);
|
||||
for post in acct.postings.iter().rev() {
|
||||
let r = line(
|
||||
ui,
|
||||
fmt::date_short(post.date),
|
||||
post.description.clone(),
|
||||
format!("#{} · {}", post.entry_id, post.split),
|
||||
post.debit,
|
||||
post.credit,
|
||||
post.balance,
|
||||
false,
|
||||
);
|
||||
if r.clicked() {
|
||||
go = Some(post.entry_id);
|
||||
}
|
||||
}
|
||||
widgets::rule(ui);
|
||||
line(
|
||||
ui,
|
||||
fmt::date_short(e),
|
||||
"Ending balance".into(),
|
||||
String::new(),
|
||||
acct.total_debits,
|
||||
acct.total_credits,
|
||||
acct.ending_balance,
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
if let Some(entry) = go {
|
||||
shell.go(Route::Entry(entry));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Where the account stands on reconciliation: "Reconciled through
|
||||
/// <date>" (in green when that covers last month, else with a notice and
|
||||
/// the agent prompt), or "Never reconciled" for an account the books
|
||||
/// expect to reconcile or one with statements. Nothing for other
|
||||
/// accounts.
|
||||
fn reconciled_badge(ui: &mut Ui, b: &Books, id: AccountId) {
|
||||
let p = pal(ui.ctx());
|
||||
let expected = b.reconcilable_accounts().contains(&id);
|
||||
let through = b.reconciled_through(id);
|
||||
if !expected && through.is_none() && b.statements_for(id).is_empty() {
|
||||
return;
|
||||
}
|
||||
let due = b.reconciliation_due(id);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
match through {
|
||||
Some(d) => {
|
||||
let (fg, bg) = if due.is_some() {
|
||||
(p.warning, p.warning_soft)
|
||||
} else {
|
||||
(p.positive, p.positive_soft)
|
||||
};
|
||||
widgets::pill(
|
||||
ui,
|
||||
&format!("{} Reconciled through {}", icon::CHECK_SQUARE, fmt::date(d)),
|
||||
fg,
|
||||
bg,
|
||||
);
|
||||
}
|
||||
None => {
|
||||
widgets::pill(ui, "Never reconciled", p.warning, p.warning_soft);
|
||||
}
|
||||
}
|
||||
if let Some(d) = due {
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"Due through {}: the monthly close reconciles every bank and card account to its statement.",
|
||||
fmt::date(d)
|
||||
))
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
widgets::copy_prompt(ui, &super::reconciliation::reconcile_prompt(b, id, d));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The account's statements (period, opening, closing, the file) and its
|
||||
/// reconciliations (date, statement balance, cleared lines, standing),
|
||||
/// side by side when there's room. Nothing when it has neither.
|
||||
fn statements_and_reconciliations(ui: &mut Ui, b: &Books, id: AccountId, go: &mut Option<Route>) {
|
||||
let p = pal(ui.ctx());
|
||||
let statements = b.statements_for(id);
|
||||
let recs = b.reconciliations_for(id);
|
||||
if statements.is_empty() && recs.is_empty() {
|
||||
return;
|
||||
}
|
||||
super::columns(ui, 1100.0, &[1.0, 1.0], |col, ui| {
|
||||
if col == 0 {
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Statements",
|
||||
|_| {},
|
||||
|ui| {
|
||||
if statements.is_empty() {
|
||||
ui.label(RichText::new("No statements recorded.").color(p.muted));
|
||||
return;
|
||||
}
|
||||
let amt = widgets::AMOUNT_MIN + 8.0;
|
||||
let w = ui.available_width().max(180.0 + amt * 2.0 + 120.0);
|
||||
widgets::min_width(ui, "statements", w, |ui| {
|
||||
let head =
|
||||
|t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted);
|
||||
row(
|
||||
ui,
|
||||
w,
|
||||
&[
|
||||
head("Period", 0.0, false),
|
||||
head("Opening", amt, true),
|
||||
head("Closing", amt, true),
|
||||
],
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
widgets::rule(ui);
|
||||
for st in statements.iter().rev() {
|
||||
let file = b
|
||||
.attachment(st.attachment_id)
|
||||
.map_or_else(String::new, |a| a.file_name.clone());
|
||||
let resp = row(
|
||||
ui,
|
||||
w,
|
||||
&[
|
||||
(
|
||||
format!(
|
||||
"{} – {}",
|
||||
fmt::date_short(st.period_start),
|
||||
fmt::date(st.period_end)
|
||||
),
|
||||
0.0,
|
||||
false,
|
||||
p.text,
|
||||
),
|
||||
(fmt::money(st.opening_balance), amt, true, p.muted),
|
||||
(fmt::money(st.closing_balance), amt, true, p.text),
|
||||
],
|
||||
Some((&format!("{} {file}", icon::FILE_PDF), p.faint)),
|
||||
false,
|
||||
true,
|
||||
);
|
||||
if resp.on_hover_text("Open the statement").clicked() {
|
||||
*go = Some(Route::Document(st.attachment_id));
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
} else {
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Reconciliations",
|
||||
|_| {},
|
||||
|ui| {
|
||||
if recs.is_empty() {
|
||||
ui.label(RichText::new("Never reconciled.").color(p.muted));
|
||||
return;
|
||||
}
|
||||
let amt = widgets::AMOUNT_MIN + 8.0;
|
||||
let w = ui.available_width().max(130.0 + amt + 70.0 + 110.0);
|
||||
widgets::min_width(ui, "reconciliations", w, |ui| {
|
||||
let head =
|
||||
|t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted);
|
||||
row(
|
||||
ui,
|
||||
w,
|
||||
&[
|
||||
head("Through", 0.0, false),
|
||||
head("Balance", amt, true),
|
||||
head("Lines", 70.0, true),
|
||||
],
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
widgets::rule(ui);
|
||||
for r in recs.iter().rev() {
|
||||
use super::reconciliation::Standing;
|
||||
let (sub, color) = match super::reconciliation::standing(b, r) {
|
||||
Standing::Current => ("Current".to_string(), p.positive),
|
||||
Standing::Superseded => ("Superseded".to_string(), p.faint),
|
||||
Standing::Voided(why) => (format!("Voided: {why}"), p.warning),
|
||||
};
|
||||
let text = if r.voided.is_some() { p.muted } else { p.text };
|
||||
let resp = row(
|
||||
ui,
|
||||
w,
|
||||
&[
|
||||
(fmt::date(r.statement_date), 0.0, false, text),
|
||||
(fmt::money(r.statement_balance), amt, true, text),
|
||||
(r.cleared.len().to_string(), 70.0, true, p.muted),
|
||||
],
|
||||
Some((&sub, color)),
|
||||
false,
|
||||
true,
|
||||
);
|
||||
if resp.on_hover_text("Open this reconciliation").clicked() {
|
||||
*go = Some(Route::Reconciliation(r.id));
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,293 @@
|
||||
//! A member's capital account statement, on their party page: the partner
|
||||
//! capital statement (opening balance, contributions of cash and of
|
||||
//! property, share of net income, distributions, closing balance), their
|
||||
//! share, and their part of what the company owes the members jointly.
|
||||
//! Every number is [`crate::books::CapitalStatement`]'s (which reads
|
||||
//! ledger_core::k1, the CPA package's K-1 worksheet); this module only
|
||||
//! lays it out.
|
||||
|
||||
use crate::books::{Books, CapitalKind, CapitalStatement};
|
||||
use crate::fmt;
|
||||
use crate::nav::Route;
|
||||
use crate::theme::{self, pal};
|
||||
use crate::widgets;
|
||||
use egui::{Align, Layout, RichText, Sense, Ui, vec2};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
/// What a capital line is, in words.
|
||||
pub fn kind_label(k: CapitalKind) -> &'static str {
|
||||
match k {
|
||||
CapitalKind::CashContribution => "Contribution — cash",
|
||||
CapitalKind::PropertyContribution => "Contribution — property",
|
||||
CapitalKind::IncomeShare => "Share of net income",
|
||||
CapitalKind::Distribution => "Distribution",
|
||||
}
|
||||
}
|
||||
|
||||
/// A share as a percent: 0.5 → "50%", 0.3333 → "33.33%".
|
||||
pub fn percent(share: Decimal) -> String {
|
||||
format!("{}%", (share * Decimal::ONE_HUNDRED).normalize())
|
||||
}
|
||||
|
||||
/// One statement row: an indented label, an amount column, and an
|
||||
/// optional total column; `strong` for the balances.
|
||||
fn line(
|
||||
ui: &mut Ui,
|
||||
label: &str,
|
||||
indent: f32,
|
||||
amount: Option<String>,
|
||||
total: Option<String>,
|
||||
strong: bool,
|
||||
) {
|
||||
let p = pal(ui.ctx());
|
||||
let col = 130.0;
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(indent);
|
||||
let font = if strong {
|
||||
theme::semibold(14.0)
|
||||
} else {
|
||||
theme::regular(14.0)
|
||||
};
|
||||
ui.label(RichText::new(label).font(font));
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
let t = total.unwrap_or_default();
|
||||
widgets::amount_cell(ui, &t, col, p.text, strong, false);
|
||||
let a = amount.unwrap_or_default();
|
||||
widgets::amount_cell(ui, &a, col, p.text, false, false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// A short rule over the amount column (`double`: two, under a total).
|
||||
fn underline(ui: &mut Ui, total_col: bool, double: bool) {
|
||||
let p = pal(ui.ctx());
|
||||
let col = 130.0;
|
||||
let w = ui.available_width();
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(w, if double { 5.0 } else { 3.0 }), Sense::hover());
|
||||
let right = if total_col {
|
||||
rect.right()
|
||||
} else {
|
||||
rect.right() - col - ui.spacing().item_spacing.x
|
||||
};
|
||||
let left = right - col + 30.0;
|
||||
let stroke = egui::Stroke::new(1.0, p.text);
|
||||
let y = rect.top() + 1.0;
|
||||
ui.painter()
|
||||
.line_segment([egui::pos2(left, y), egui::pos2(right, y)], stroke);
|
||||
if double {
|
||||
ui.painter().line_segment(
|
||||
[egui::pos2(left, y + 3.0), egui::pos2(right, y + 3.0)],
|
||||
stroke,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The member's capital account card.
|
||||
pub fn capital_card(
|
||||
ui: &mut Ui,
|
||||
b: &Books,
|
||||
st: &CapitalStatement,
|
||||
name: &str,
|
||||
go: &mut Option<Route>,
|
||||
) {
|
||||
let p = pal(ui.ctx());
|
||||
let account = b.account_name(st.capital);
|
||||
let (open_account, ()) = widgets::titled_card(
|
||||
ui,
|
||||
"Capital account",
|
||||
|ui| {
|
||||
widgets::link(
|
||||
ui,
|
||||
RichText::new(&account)
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.accent),
|
||||
)
|
||||
.on_hover_text("Open the capital account's register")
|
||||
.clicked()
|
||||
},
|
||||
|ui| {
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.label(RichText::new(name).font(theme::semibold(15.0)));
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"Statement of partner's capital · {} – {}",
|
||||
fmt::date(st.span.0),
|
||||
fmt::date(st.span.1)
|
||||
))
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
if let Some(j) = &st.files_jointly_with {
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"Files a joint return with {j}: a K-1 each, both on one Form 1040"
|
||||
))
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
}
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
let amt = |d: Decimal| Some(fmt::accounting(d));
|
||||
line(
|
||||
ui,
|
||||
&format!("Capital, {}", fmt::date(st.span.0)),
|
||||
0.0,
|
||||
None,
|
||||
Some(format!("${}", fmt::accounting(st.opening))),
|
||||
true,
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
RichText::new("Add")
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
line(
|
||||
ui,
|
||||
"Contributions of cash",
|
||||
16.0,
|
||||
amt(st.contributions_cash),
|
||||
None,
|
||||
false,
|
||||
);
|
||||
line(
|
||||
ui,
|
||||
"Contributions of property (at fair market value)",
|
||||
16.0,
|
||||
amt(st.contributions_property),
|
||||
None,
|
||||
false,
|
||||
);
|
||||
line(
|
||||
ui,
|
||||
&format!("Share of net income ({})", percent(st.share)),
|
||||
16.0,
|
||||
amt(st.income_share),
|
||||
None,
|
||||
false,
|
||||
);
|
||||
ui.label(
|
||||
RichText::new("Less")
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
line(
|
||||
ui,
|
||||
"Distributions",
|
||||
16.0,
|
||||
Some(fmt::accounting(-st.distributions_taken)),
|
||||
None,
|
||||
false,
|
||||
);
|
||||
if !st.other.is_zero() {
|
||||
line(ui, "Other changes", 16.0, amt(st.other), None, false);
|
||||
}
|
||||
underline(ui, false, false);
|
||||
let change = st.contributions_cash + st.contributions_property + st.income_share
|
||||
- st.distributions_taken
|
||||
+ st.other;
|
||||
line(
|
||||
ui,
|
||||
"Net change in capital",
|
||||
0.0,
|
||||
None,
|
||||
Some(fmt::accounting(change)),
|
||||
false,
|
||||
);
|
||||
underline(ui, true, false);
|
||||
line(
|
||||
ui,
|
||||
&format!("Capital, {}", fmt::date(st.span.1)),
|
||||
0.0,
|
||||
None,
|
||||
Some(format!("${}", fmt::accounting(st.closing))),
|
||||
true,
|
||||
);
|
||||
underline(ui, true, true);
|
||||
if !st.income_share.is_zero()
|
||||
&& !st.lines.iter().any(|l| l.kind == CapitalKind::IncomeShare)
|
||||
{
|
||||
ui.label(
|
||||
RichText::new(
|
||||
"No period closed in this span: the share of net income is earned but not yet closed into the capital account.",
|
||||
)
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.faint),
|
||||
);
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
widgets::rule(ui);
|
||||
ui.add_space(6.0);
|
||||
line(
|
||||
ui,
|
||||
"Share of profit, loss, and capital",
|
||||
0.0,
|
||||
None,
|
||||
Some(percent(st.share)),
|
||||
false,
|
||||
);
|
||||
let owed = format!(
|
||||
"Owed to them on Due to Shareholders ({} of {})",
|
||||
percent(st.share),
|
||||
fmt::dollars(st.due_to_members)
|
||||
);
|
||||
line(
|
||||
ui,
|
||||
&owed,
|
||||
0.0,
|
||||
None,
|
||||
Some(fmt::dollars(st.owed_to_member)),
|
||||
false,
|
||||
);
|
||||
if !st.lines.is_empty() {
|
||||
ui.add_space(10.0);
|
||||
ui.collapsing(
|
||||
RichText::new(format!(
|
||||
"{} in this span",
|
||||
fmt::count(st.lines.len(), "capital entry", "capital entries")
|
||||
))
|
||||
.font(theme::regular(12.5)),
|
||||
|ui| {
|
||||
for l in &st.lines {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
RichText::new(fmt::date(l.date))
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
if widgets::link(
|
||||
ui,
|
||||
RichText::new(format!("#{} {}", l.entry, l.description))
|
||||
.font(theme::regular(12.5)),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
*go = Some(Route::Entry(l.entry));
|
||||
}
|
||||
ui.label(
|
||||
RichText::new(kind_label(l.kind))
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.faint),
|
||||
);
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
widgets::amount_cell(
|
||||
ui,
|
||||
&fmt::accounting(l.amount),
|
||||
130.0,
|
||||
p.text,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
if open_account {
|
||||
*go = Some(Route::Account(st.capital));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,727 @@
|
||||
//! Cash: what's in the bank, where it's been going, how long it lasts, and
|
||||
//! what's coming in and out next.
|
||||
|
||||
use super::{columns, page};
|
||||
use crate::app::Shell;
|
||||
use crate::books::Books;
|
||||
use crate::charts::{self, BarGroup};
|
||||
use crate::fmt;
|
||||
use crate::nav::Route;
|
||||
use crate::theme::{self, pal};
|
||||
use crate::widgets;
|
||||
use chrono::{Datelike, Days, NaiveDate};
|
||||
use egui::{CornerRadius, RichText, Sense, Ui, vec2};
|
||||
use egui_phosphor::regular as icon;
|
||||
use ledger_core::analysis::{Interval, balance_history, flows_by_interval, interval_start};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
page(ui, "cash", |ui| {
|
||||
let k = &b.kpis;
|
||||
let since = b.today - Days::new(30);
|
||||
let flows = flows_by_interval(
|
||||
&b.ledger,
|
||||
&b.cash_accounts,
|
||||
since + Days::new(1),
|
||||
b.today,
|
||||
Interval::Year,
|
||||
);
|
||||
let (cin, cout): (Decimal, Decimal) =
|
||||
flows.iter().fold((Decimal::ZERO, Decimal::ZERO), |a, f| {
|
||||
(a.0 + f.2, a.1 + f.3)
|
||||
});
|
||||
widgets::page_header(
|
||||
ui,
|
||||
"Cash",
|
||||
&format!(
|
||||
"{} across {}. In the last 30 days {} came in and {} went out.",
|
||||
fmt::dollars(k.cash),
|
||||
fmt::count(b.cash_accounts.len(), "account", "accounts"),
|
||||
fmt::dollars(cin),
|
||||
fmt::dollars(cout)
|
||||
),
|
||||
);
|
||||
if b.cash_accounts.is_empty() {
|
||||
widgets::card(ui, |ui| {
|
||||
widgets::empty(
|
||||
ui,
|
||||
icon::BANK,
|
||||
"No cash accounts found",
|
||||
"Name an asset account \"checking\", \"savings\", or \"cash\", or ask the agent to set the books' cash accounts.",
|
||||
)
|
||||
});
|
||||
return;
|
||||
}
|
||||
// One tile per cash and card account.
|
||||
let mut tiles: Vec<(u64, bool)> = b.cash_accounts.iter().map(|a| (*a, false)).collect();
|
||||
tiles.extend(b.card_accounts.iter().map(|a| (*a, true)));
|
||||
// Up to four across, fewer when the window is narrow.
|
||||
let n = tiles.len().clamp(1, 4);
|
||||
let n = widgets::fit_columns(ui.available_width(), 200.0, 16.0, n);
|
||||
for row in tiles.chunks(n) {
|
||||
columns(ui, 0.0, &vec![1.0; n], |i, ui| {
|
||||
let Some((id, is_card)) = row.get(i) else {
|
||||
return;
|
||||
};
|
||||
let hist = balance_history(&b.ledger, &[*id]);
|
||||
let spark: Vec<f64> = hist
|
||||
.iter()
|
||||
.rev()
|
||||
.take(40)
|
||||
.rev()
|
||||
.map(|(_, v)| (*v).try_into().unwrap_or(0.0))
|
||||
.collect();
|
||||
let bal = b.balance_on(*id, b.today);
|
||||
let ago = b.balance_on(*id, since);
|
||||
let delta = bal - ago;
|
||||
let r = widgets::kpi(
|
||||
ui,
|
||||
widgets::Kpi {
|
||||
label: &b.account_label(*id),
|
||||
value: fmt::dollars(bal),
|
||||
sub: Some((
|
||||
if *is_card {
|
||||
"owed on the card".to_string()
|
||||
} else if delta.is_zero() {
|
||||
"no change in 30 days".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"{}{} in 30 days",
|
||||
if delta > Decimal::ZERO { "+" } else { "" },
|
||||
fmt::dollars(delta)
|
||||
)
|
||||
},
|
||||
if *is_card || delta.is_zero() {
|
||||
p.muted
|
||||
} else if delta > Decimal::ZERO {
|
||||
p.positive
|
||||
} else {
|
||||
p.negative
|
||||
},
|
||||
)),
|
||||
spark: (spark.len() > 1).then_some(&spark[..]),
|
||||
how: "",
|
||||
},
|
||||
);
|
||||
if r.interact(Sense::click()).clicked() {
|
||||
shell.state.accounts.range = None;
|
||||
shell.go(Route::Account(*id));
|
||||
}
|
||||
});
|
||||
}
|
||||
let first = k.first_entry.unwrap_or(b.today);
|
||||
let (start, end) = b.trailing_months(12);
|
||||
let start = start.max(interval_start(first, Interval::Month));
|
||||
columns(ui, 980.0, &[3.0, 2.0], |i, ui| match i {
|
||||
0 => {
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Cash balance",
|
||||
|_| {},
|
||||
|ui| charts::balance_line(ui, &b.cash_history(), b.today, 240.0),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
let months =
|
||||
flows_by_interval(&b.ledger, &b.cash_accounts, start, end, Interval::Month);
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Money in and out by month",
|
||||
|_| {},
|
||||
|ui| {
|
||||
let groups: Vec<BarGroup> = months
|
||||
.iter()
|
||||
.map(|(s, _, i, o)| BarGroup {
|
||||
label: s.format("%b").to_string(),
|
||||
values: vec![*i, *o],
|
||||
})
|
||||
.collect();
|
||||
charts::bar_chart(
|
||||
ui,
|
||||
&groups,
|
||||
&[("In", p.series[1]), ("Out", p.series[2])],
|
||||
210.0,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i {
|
||||
0 => runway(ui, b),
|
||||
_ => coming_up(ui, b, shell),
|
||||
});
|
||||
biggest_outflows(ui, b, shell);
|
||||
});
|
||||
}
|
||||
|
||||
fn runway(ui: &mut Ui, b: &Books) {
|
||||
let p = pal(ui.ctx());
|
||||
let ms = b.month_start();
|
||||
let three = ms - chrono::Months::new(3);
|
||||
let start = three.max(
|
||||
b.kpis
|
||||
.first_entry
|
||||
.map_or(three, |f| interval_start(f, Interval::Month)),
|
||||
);
|
||||
let months = flows_by_interval(
|
||||
&b.ledger,
|
||||
&b.cash_accounts,
|
||||
start,
|
||||
ms - Days::new(1),
|
||||
Interval::Month,
|
||||
);
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Burn and runway",
|
||||
|_| {},
|
||||
|ui| {
|
||||
if months.is_empty() {
|
||||
ui.label(RichText::new("Needs at least one full month of books.").color(p.muted));
|
||||
return;
|
||||
}
|
||||
let net: Decimal = months.iter().map(|(_, _, i, o)| i - o).sum::<Decimal>()
|
||||
/ Decimal::from(months.len() as u64);
|
||||
let net = net.round_dp(2);
|
||||
let big = |ui: &mut Ui, label: &str, value: String, color| {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(
|
||||
RichText::new(label)
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
ui.label(
|
||||
RichText::new(value)
|
||||
.font(theme::semibold(22.0))
|
||||
.color(color),
|
||||
);
|
||||
});
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
big(
|
||||
ui,
|
||||
"Average monthly net cash flow",
|
||||
format!(
|
||||
"{}{}",
|
||||
if net > Decimal::ZERO { "+" } else { "" },
|
||||
fmt::dollars(net)
|
||||
),
|
||||
if net >= Decimal::ZERO {
|
||||
p.positive
|
||||
} else {
|
||||
p.negative
|
||||
},
|
||||
);
|
||||
ui.add_space(32.0);
|
||||
if net < Decimal::ZERO {
|
||||
// Checked: a sub-cent average (lines an earlier version
|
||||
// booked) makes a runway past any calendar.
|
||||
let months_left = b.kpis.cash.checked_div(-net).map(|m| m.round_dp(1));
|
||||
let out = months_left
|
||||
.and_then(|m| m.checked_mul(Decimal::from(30)))
|
||||
.and_then(|d| u64::try_from(d).ok())
|
||||
.and_then(|d| b.today.checked_add_days(Days::new(d)));
|
||||
big(
|
||||
ui,
|
||||
"Runway",
|
||||
months_left
|
||||
.map_or_else(|| fmt::CANT_COMPUTE.into(), |m| format!("{m} months")),
|
||||
p.text,
|
||||
);
|
||||
ui.add_space(32.0);
|
||||
big(
|
||||
ui,
|
||||
"Cash runs out around",
|
||||
out.map_or_else(|| "beyond any calendar date".into(), fmt::month),
|
||||
p.warning,
|
||||
);
|
||||
} else {
|
||||
big(ui, "Runway", "Cash is growing".into(), p.positive);
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"Cash in minus cash out of {}, averaged over the last {} (owner contributions and draws included: this is what the bank sees).",
|
||||
b.cash_accounts.iter().map(|a| b.account_name(*a)).collect::<Vec<_>>().join(" and "),
|
||||
fmt::count(months.len(), "full month", "full months")
|
||||
))
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.faint),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// A recurring entry's ungenerated occurrences dated `from..=to` (and on
|
||||
/// or before its end), found from the occurrence just before `from`
|
||||
/// rather than walking from its start: a weekly entry an earlier version
|
||||
/// started at chrono's first day has millions of occurrences before today,
|
||||
/// and this runs every frame.
|
||||
fn occurrences_from(
|
||||
r: &ledger_core::RecurringEntry,
|
||||
from: NaiveDate,
|
||||
to: NaiveDate,
|
||||
) -> Vec<NaiveDate> {
|
||||
use ledger_core::Frequency;
|
||||
let last = r.end.map_or(to, |e| e.min(to));
|
||||
// How many periods from the start to `from`, rounded down, less one
|
||||
// for a month-end clamp: never past the first occurrence on or after
|
||||
// `from`.
|
||||
let months = |d: NaiveDate| {
|
||||
(i64::from(d.year()) - i64::from(r.start.year())) * 12 + i64::from(d.month())
|
||||
- i64::from(r.start.month())
|
||||
};
|
||||
let k0 = match r.frequency {
|
||||
Frequency::Weekly => (from - r.start).num_days() / 7,
|
||||
Frequency::Monthly => months(from),
|
||||
Frequency::Quarterly => months(from) / 3,
|
||||
Frequency::Yearly => months(from) / 12,
|
||||
} - 1;
|
||||
let Ok(k0) = u32::try_from(k0.max(0)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
(k0..)
|
||||
.map_while(|k| r.frequency.nth(r.start, k).filter(|d| *d <= last))
|
||||
.filter(|d| *d >= from && !r.generated.contains(d))
|
||||
.collect()
|
||||
}
|
||||
|
||||
struct Upcoming {
|
||||
date: NaiveDate,
|
||||
label: String,
|
||||
/// None: it can't be computed exactly (a recurring template or a
|
||||
/// document an earlier version journaled, past exact arithmetic).
|
||||
amount: Option<Decimal>,
|
||||
route: Option<Route>,
|
||||
}
|
||||
|
||||
fn coming_up(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
let horizon = b.today + Days::new(45);
|
||||
let mut items: Vec<Upcoming> = Vec::new();
|
||||
for r in b.ledger.recurring_entries() {
|
||||
let cash = r
|
||||
.lines
|
||||
.iter()
|
||||
.filter(|(a, _, _)| b.cash_accounts.contains(a))
|
||||
.try_fold(Decimal::ZERO, |sum, (_, s, m)| {
|
||||
if *s == ledger_core::Side::Debit {
|
||||
sum.checked_add(m.amount)
|
||||
} else {
|
||||
sum.checked_sub(m.amount)
|
||||
}
|
||||
});
|
||||
for d in occurrences_from(r, b.today, horizon) {
|
||||
items.push(Upcoming {
|
||||
date: d,
|
||||
label: format!("{} (recurring)", r.description),
|
||||
amount: cash,
|
||||
route: Some(Route::Company),
|
||||
});
|
||||
}
|
||||
}
|
||||
// An open document: its balance, or None when it can't be computed
|
||||
// (shown as such, never left out).
|
||||
for id in 1..b.ledger.next_invoice_id() {
|
||||
let Some(inv) = b.ledger.get_invoice(id) else {
|
||||
continue;
|
||||
};
|
||||
let balance = inv.balance().ok();
|
||||
if balance.is_some_and(|x| x.is_zero()) {
|
||||
continue;
|
||||
}
|
||||
let who = b
|
||||
.ledger
|
||||
.get_customer(inv.customer_id)
|
||||
.map(|c| c.name.clone())
|
||||
.unwrap_or_default();
|
||||
let late = inv.due_date < b.today;
|
||||
items.push(Upcoming {
|
||||
date: inv.due_date,
|
||||
label: format!(
|
||||
"Invoice {id} from {who}{}",
|
||||
if late { " — overdue" } else { "" }
|
||||
),
|
||||
amount: balance,
|
||||
route: Some(Route::Entry(inv.entry_id)),
|
||||
});
|
||||
}
|
||||
for id in 1..b.ledger.next_bill_id() {
|
||||
let Some(bill) = b.ledger.get_bill(id) else {
|
||||
continue;
|
||||
};
|
||||
let balance = bill.balance().ok();
|
||||
if balance.is_some_and(|x| x.is_zero()) {
|
||||
continue;
|
||||
}
|
||||
let who = b
|
||||
.ledger
|
||||
.get_vendor(bill.vendor_id)
|
||||
.map(|v| v.name.clone())
|
||||
.unwrap_or_default();
|
||||
items.push(Upcoming {
|
||||
date: bill.due_date,
|
||||
label: format!("Bill {id} to {who}"),
|
||||
amount: balance.map(|x| -x),
|
||||
route: Some(Route::Entry(bill.entry_id)),
|
||||
});
|
||||
}
|
||||
items.sort_by_key(|i| i.date);
|
||||
let mut go = None;
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Coming up",
|
||||
|ui| {
|
||||
ui.label(
|
||||
RichText::new("open invoices, bills, recurring")
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
},
|
||||
|ui| {
|
||||
if items.is_empty() {
|
||||
widgets::empty(
|
||||
ui,
|
||||
icon::CALENDAR_CHECK,
|
||||
"Nothing scheduled",
|
||||
"No open invoices, bills, or recurring entries due soon.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
// None: an item, or the sum, can't be computed exactly.
|
||||
let (inflow, outflow) = items.iter().fold(
|
||||
(Some(Decimal::ZERO), Some(Decimal::ZERO)),
|
||||
|(inflow, outflow), i| match i.amount {
|
||||
Some(a) if a > Decimal::ZERO => {
|
||||
(inflow.and_then(|x| x.checked_add(a)), outflow)
|
||||
}
|
||||
Some(a) => (inflow, outflow.and_then(|x| x.checked_sub(a))),
|
||||
None => (None, None),
|
||||
},
|
||||
);
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"Expected in {} · expected out {}",
|
||||
fmt::dollars_or(inflow),
|
||||
fmt::dollars_or(outflow)
|
||||
))
|
||||
.font(theme::semibold(13.0)),
|
||||
);
|
||||
ui.add_space(6.0);
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("upcoming")
|
||||
.max_height(220.0)
|
||||
.show(ui, |ui| {
|
||||
for it in &items {
|
||||
let (r, resp) = ui
|
||||
.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(6), p.hover);
|
||||
}
|
||||
let late = it.date < b.today;
|
||||
ui.painter().text(
|
||||
r.left_center() + vec2(6.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
fmt::date_short(it.date),
|
||||
theme::regular(12.5),
|
||||
if late { p.negative } else { p.muted },
|
||||
);
|
||||
widgets::clipped_text(
|
||||
ui.painter(),
|
||||
r.left_center() + vec2(70.0, 0.0),
|
||||
&it.label,
|
||||
theme::regular(13.0),
|
||||
p.text,
|
||||
r.width() - 70.0 - 130.0,
|
||||
);
|
||||
let (txt, c) = match it.amount {
|
||||
Some(a) if a > Decimal::ZERO => {
|
||||
(format!("+{}", fmt::money(a)), p.positive)
|
||||
}
|
||||
a => (fmt::money_or(a), p.text),
|
||||
};
|
||||
ui.painter().text(
|
||||
r.right_center() - vec2(6.0, 0.0),
|
||||
egui::Align2::RIGHT_CENTER,
|
||||
txt,
|
||||
theme::semibold(13.0),
|
||||
c,
|
||||
);
|
||||
if resp.clicked() {
|
||||
go = it.route.clone();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
if let Some(r) = go {
|
||||
shell.go(r);
|
||||
}
|
||||
}
|
||||
|
||||
fn biggest_outflows(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
let since = b.today - Days::new(30);
|
||||
let mut out: Vec<&crate::books::EntryInfo> = b
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
e.status == ledger_core::EntryStatus::Posted
|
||||
&& e.date > since
|
||||
&& e.cash_effect.is_some_and(|c| c < Decimal::ZERO)
|
||||
})
|
||||
.collect();
|
||||
out.sort_by_key(|e| e.cash_effect);
|
||||
out.truncate(8);
|
||||
let mut go = None;
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Biggest payments, last 30 days",
|
||||
|_| {},
|
||||
|ui| {
|
||||
if out.is_empty() {
|
||||
widgets::empty(ui, icon::ARROW_UP_RIGHT, "No payments", "");
|
||||
}
|
||||
for e in &out {
|
||||
let (r, resp) =
|
||||
ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(6), p.hover);
|
||||
}
|
||||
ui.painter().text(
|
||||
r.left_center() + vec2(6.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
fmt::date_short(e.date),
|
||||
theme::regular(12.5),
|
||||
p.muted,
|
||||
);
|
||||
widgets::clipped_text(
|
||||
ui.painter(),
|
||||
r.left_center() + vec2(70.0, 0.0),
|
||||
&e.description,
|
||||
theme::regular(13.0),
|
||||
p.text,
|
||||
r.width() - 70.0 - 130.0,
|
||||
);
|
||||
ui.painter().text(
|
||||
r.right_center() - vec2(6.0, 0.0),
|
||||
egui::Align2::RIGHT_CENTER,
|
||||
fmt::money_or(e.cash_effect),
|
||||
theme::semibold(13.0),
|
||||
p.text,
|
||||
);
|
||||
if resp.clicked() {
|
||||
go = Some(e.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
if let Some(id) = go {
|
||||
shell.go(Route::Entry(id));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ledger_core::{Frequency, RecurringEntry};
|
||||
|
||||
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
||||
}
|
||||
|
||||
/// occurrences_from is the recurring entry's due dates in the window,
|
||||
/// exactly, without walking from the start: month ends clamped, ends
|
||||
/// and generated dates honored, a start after the window, and a start
|
||||
/// at chrono's first day.
|
||||
#[test]
|
||||
fn occurrences_in_a_window_are_the_due_dates_in_it() {
|
||||
for frequency in [
|
||||
Frequency::Weekly,
|
||||
Frequency::Monthly,
|
||||
Frequency::Quarterly,
|
||||
Frequency::Yearly,
|
||||
] {
|
||||
for start in [
|
||||
d(2020, 1, 31),
|
||||
d(2024, 2, 29),
|
||||
d(2026, 1, 10),
|
||||
d(2026, 10, 20),
|
||||
d(2027, 1, 1),
|
||||
d(2027, 1, 15),
|
||||
] {
|
||||
for (end, (from, to)) in [None, Some(d(2026, 11, 2))].into_iter().flat_map(|end| {
|
||||
// Windows a few weeks long, years long, and one
|
||||
// opening before the entry starts.
|
||||
[
|
||||
(d(2026, 10, 17), d(2026, 12, 1)),
|
||||
(d(2026, 6, 1), d(2028, 12, 31)),
|
||||
(d(2025, 12, 20), d(2027, 12, 31)),
|
||||
]
|
||||
.map(|w| (end, w))
|
||||
}) {
|
||||
let mut r = RecurringEntry {
|
||||
id: 1,
|
||||
description: "x".into(),
|
||||
lines: vec![],
|
||||
frequency,
|
||||
start,
|
||||
end,
|
||||
generated: Default::default(),
|
||||
};
|
||||
if let Some(first) = frequency.nth(start, 3) {
|
||||
r.generated.insert(first);
|
||||
}
|
||||
let want: Vec<NaiveDate> =
|
||||
r.due(to).into_iter().filter(|x| *x >= from).collect();
|
||||
assert_eq!(
|
||||
occurrences_from(&r, from, to),
|
||||
want,
|
||||
"{frequency:?} {start} {end:?} {from}..{to}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let far = RecurringEntry {
|
||||
id: 1,
|
||||
description: "weekly since chrono's first day".into(),
|
||||
lines: vec![],
|
||||
frequency: Frequency::Weekly,
|
||||
start: NaiveDate::MIN,
|
||||
end: None,
|
||||
generated: Default::default(),
|
||||
};
|
||||
let got = occurrences_from(&far, d(2026, 10, 17), d(2026, 11, 30));
|
||||
assert!((6..=7).contains(&got.len()), "{got:?}");
|
||||
assert!(got.iter().all(|x| x.weekday() == NaiveDate::MIN.weekday()));
|
||||
}
|
||||
|
||||
/// The cash page's burn and runway, from the last three full months:
|
||||
/// burning $1,000.00 a month on $7,000.00 is a 7-month runway, out
|
||||
/// around May 2027 (210 days from Oct 15); an average of zero is cash
|
||||
/// growing, not a runway past any date.
|
||||
#[test]
|
||||
fn runway_is_cash_over_the_average_burn() {
|
||||
use crate::test_support::{Build, find, render};
|
||||
let mut b = Build::new("runway");
|
||||
let (cash, capital, rent) = (b.cash, b.capital, b.rent);
|
||||
b.post(d(2026, 1, 2), cash, capital, 1_000_000);
|
||||
for m in [7, 8, 9] {
|
||||
b.post(d(2026, m, 10), rent, cash, 100_000);
|
||||
}
|
||||
let (_dir, books) = b.books();
|
||||
let (texts, p) = render(books, Route::Cash, |_| {});
|
||||
let net = find(&texts, &fmt::dollars(Decimal::new(-100_000, 2)));
|
||||
assert_eq!(net.color, p.negative);
|
||||
find(&texts, "Runway");
|
||||
find(&texts, "7 months");
|
||||
find(&texts, "May 2027");
|
||||
// Zero on average: growing (July out, August in, September quiet).
|
||||
let mut b = Build::new("runway-flat");
|
||||
let (cash, capital, rent) = (b.cash, b.capital, b.rent);
|
||||
b.post(d(2026, 1, 2), cash, capital, 1_000_000);
|
||||
b.post(d(2026, 7, 10), rent, cash, 100_000);
|
||||
b.post(d(2026, 8, 10), cash, capital, 100_000);
|
||||
let (_dir, books) = b.books();
|
||||
let (texts, p) = render(books, Route::Cash, |_| {});
|
||||
assert_eq!(find(&texts, "Cash is growing").color, p.positive);
|
||||
assert!(!crate::test_support::has(
|
||||
&texts,
|
||||
"beyond any calendar date"
|
||||
));
|
||||
}
|
||||
|
||||
/// "Coming up" lists recurring cash (its cash lines' sign: rent paid
|
||||
/// out is negative, a recurring entry moving no cash zero, never
|
||||
/// "+0.00"), open invoices (overdue only when due before today) and
|
||||
/// open bills (negative), sorted by date, with the expected in and out.
|
||||
#[test]
|
||||
fn coming_up_is_what_cash_expects() {
|
||||
use crate::test_support::{Build, cents, find, has, render};
|
||||
use ledger_core::arap::DocumentLine;
|
||||
use ledger_core::{AccountType, Currency, Frequency, Money, NewRecurring, Side};
|
||||
let mut b = Build::new("coming-up");
|
||||
let (cash, capital, rent) = (b.cash, b.capital, b.rent);
|
||||
let accrued =
|
||||
b.s.add_account("2000", "Accrued", AccountType::Liability, None, false)
|
||||
.unwrap();
|
||||
b.post(d(2026, 1, 2), cash, capital, 1_000_000);
|
||||
let usd = |c: i64| Money::new(cents(c), Currency::USD);
|
||||
for (what, cr, c, day) in [("Rent", cash, 50_000, 20), ("Accrual", accrued, 5_000, 25)] {
|
||||
b.s.register_recurring(NewRecurring {
|
||||
description: what.into(),
|
||||
lines: vec![(rent, Side::Debit, usd(c)), (cr, Side::Credit, usd(c))],
|
||||
frequency: Frequency::Monthly,
|
||||
start: d(2026, 1, day),
|
||||
end: None,
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
let line = |c: i64| {
|
||||
vec![DocumentLine {
|
||||
description: "work".into(),
|
||||
amount: cents(c),
|
||||
}]
|
||||
};
|
||||
let acme = b.s.add_customer("Acme").unwrap();
|
||||
let beta = b.s.add_customer("Beta").unwrap();
|
||||
b.s.create_invoice(acme, d(2026, 9, 1), d(2026, 10, 1), line(120_000))
|
||||
.unwrap();
|
||||
b.s.create_invoice(beta, d(2026, 10, 1), d(2026, 10, 15), line(20_000))
|
||||
.unwrap();
|
||||
let supplier = b.s.add_vendor("Supplier").unwrap();
|
||||
b.s.create_bill(supplier, d(2026, 10, 1), d(2026, 11, 5), line(30_000))
|
||||
.unwrap();
|
||||
let (_dir, books) = b.books();
|
||||
let (texts, p) = render(books, Route::Cash, |_| {});
|
||||
find(&texts, "Coming up");
|
||||
find(&texts, "Invoice 1 from Acme \u{2014} overdue");
|
||||
find(&texts, "Invoice 2 from Beta");
|
||||
find(&texts, "Bill 1 to Supplier");
|
||||
find(
|
||||
&texts,
|
||||
"Expected in $1,400.00 \u{b7} expected out $1,300.00",
|
||||
);
|
||||
let plus = |c: i64| format!("+{}", fmt::money(cents(c)));
|
||||
assert_eq!(find(&texts, &plus(120_000)).color, p.positive);
|
||||
assert_eq!(find(&texts, &plus(20_000)).color, p.positive);
|
||||
assert_eq!(find(&texts, &fmt::money(cents(-50_000))).color, p.text);
|
||||
assert_eq!(find(&texts, &fmt::money(cents(-30_000))).color, p.text);
|
||||
assert_eq!(find(&texts, &fmt::money(Decimal::ZERO)).color, p.text);
|
||||
assert!(!has(&texts, &plus(0)));
|
||||
assert!(!has(&texts, &plus(50_000)));
|
||||
assert!(!has(&texts, &plus(30_000)));
|
||||
}
|
||||
|
||||
/// "Biggest payments": posted entries of the last 30 days that took
|
||||
/// cash out, never older ones, deposits, or drafts.
|
||||
#[test]
|
||||
fn biggest_payments_are_recent_posted_outflows() {
|
||||
use crate::test_support::{Build, find, has, render};
|
||||
let mut b = Build::new("outflows");
|
||||
let (cash, capital, rent) = (b.cash, b.capital, b.rent);
|
||||
b.post_as("Opening", d(2026, 1, 2), cash, capital, 1_000_000);
|
||||
b.post_as("Old rent", d(2026, 9, 10), rent, cash, 25_000);
|
||||
b.post_as("October rent", d(2026, 10, 1), rent, cash, 25_000);
|
||||
b.post_as("Deposit", d(2026, 10, 5), cash, capital, 5_000);
|
||||
b.draft("Draft pay", d(2026, 10, 10), rent, cash, 7_000);
|
||||
// Thirty days before today exactly: not in the last 30 days.
|
||||
b.post_as("Edge payment", d(2026, 9, 15), rent, cash, 9_000);
|
||||
// An expense the owner paid: no cash out.
|
||||
b.post_as("Owner paid", d(2026, 10, 12), rent, capital, 4_000);
|
||||
let (_dir, books) = b.books();
|
||||
let (texts, _) = render(books, Route::Cash, |_| {});
|
||||
find(&texts, "Biggest payments, last 30 days");
|
||||
find(&texts, "October rent");
|
||||
for gone in [
|
||||
"Old rent",
|
||||
"Deposit",
|
||||
"Draft pay",
|
||||
"Edge payment",
|
||||
"Owner paid",
|
||||
] {
|
||||
assert!(!has(&texts, gone), "{gone}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,777 @@
|
||||
//! The company: who it is, who owns it, and the standing machinery of the
|
||||
//! books — periods, fixed assets, amortization schedules, recurring
|
||||
//! entries, vendors and customers.
|
||||
|
||||
use super::{columns, page};
|
||||
use crate::app::Shell;
|
||||
use crate::books::Books;
|
||||
use crate::fmt;
|
||||
use crate::nav::Route;
|
||||
use crate::theme::{self, pal};
|
||||
use crate::widgets;
|
||||
use egui::{CornerRadius, RichText, Sense, Ui, vec2};
|
||||
use egui_phosphor::regular as icon;
|
||||
use ledger_core::assets::{accumulated_depreciation, book_value};
|
||||
use ledger_core::{PeriodStatus, vendor_1099_report};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
fn kv(ui: &mut Ui, k: &str, v: &str) {
|
||||
let p = pal(ui.ctx());
|
||||
// Narrow, the label goes above its value.
|
||||
if ui.available_width() < 360.0 {
|
||||
ui.label(RichText::new(k).font(theme::regular(12.0)).color(p.muted));
|
||||
widgets::copyable(ui, RichText::new(v).font(theme::regular(13.0)), v);
|
||||
ui.add_space(4.0);
|
||||
return;
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
let kw = (ui.available_width() * 0.4).min(180.0);
|
||||
let (r, _) = ui.allocate_exact_size(vec2(kw, 20.0), Sense::hover());
|
||||
ui.painter().text(
|
||||
r.left_center(),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
k,
|
||||
theme::regular(13.0),
|
||||
p.muted,
|
||||
);
|
||||
widgets::copyable(ui, RichText::new(v).font(theme::regular(13.0)), v);
|
||||
});
|
||||
}
|
||||
|
||||
/// Who decided a setting and when, under its value.
|
||||
fn provenance(ui: &mut Ui, by: &str, on: chrono::NaiveDate) {
|
||||
let p = pal(ui.ctx());
|
||||
ui.label(
|
||||
RichText::new(format!("{by} · {}", fmt::date(on)))
|
||||
.font(theme::regular(11.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
}
|
||||
|
||||
/// How the books are kept, as the owners decided: conventions, decisions,
|
||||
/// and what is open (each only when there is something to show).
|
||||
fn kept_cards(ui: &mut Ui, b: &Books) {
|
||||
let p = pal(ui.ctx());
|
||||
let kept = b.ledger.conventions();
|
||||
let conventions: Vec<_> = kept.conventions().filter(|c| c.retired.is_none()).collect();
|
||||
if !conventions.is_empty() {
|
||||
ui.add_space(12.0);
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"How the books are kept",
|
||||
|_| {},
|
||||
|ui| {
|
||||
for c in &conventions {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(RichText::new(&c.title).font(theme::semibold(13.0)));
|
||||
widgets::pill(ui, &c.kind.key().replace('_', " "), p.accent, p.accent_soft);
|
||||
});
|
||||
widgets::copyable(
|
||||
ui,
|
||||
RichText::new(c.summary()).font(theme::regular(12.5)),
|
||||
&c.body_md,
|
||||
);
|
||||
provenance(ui, &c.source.decided_by, c.source.decided_on);
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
let decisions: Vec<_> = kept
|
||||
.decisions()
|
||||
.filter(|d| d.superseded_by.is_none())
|
||||
.collect();
|
||||
if !decisions.is_empty() {
|
||||
ui.add_space(12.0);
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Decisions",
|
||||
|_| {},
|
||||
|ui| {
|
||||
for d in decisions.iter().rev() {
|
||||
ui.label(RichText::new(&d.new.subject).font(theme::semibold(13.0)));
|
||||
widgets::copyable(
|
||||
ui,
|
||||
RichText::new(&d.new.decision_md).font(theme::regular(12.5)),
|
||||
&d.new.decision_md,
|
||||
);
|
||||
provenance(ui, &d.new.decided_by.join(" and "), d.new.decided_on);
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
let open: Vec<_> = kept.open_items().filter(|i| i.resolved.is_none()).collect();
|
||||
if !open.is_empty() {
|
||||
ui.add_space(12.0);
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Open items",
|
||||
|_| {},
|
||||
|ui| {
|
||||
for i in open {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(RichText::new(icon::FLAG).color(p.warning));
|
||||
ui.label(RichText::new(&i.new.title).font(theme::regular(13.0)));
|
||||
if let Some(due) = i.new.due {
|
||||
// Late says so in words, not only in colour.
|
||||
let late = due < b.today;
|
||||
let when = if late { "overdue since" } else { "due" };
|
||||
ui.label(
|
||||
RichText::new(format!("{when} {}", fmt::date(due)))
|
||||
.font(theme::regular(12.0))
|
||||
.color(if late { p.warning } else { p.muted }),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
page(ui, "company", |ui| {
|
||||
let c = &b.settings.company;
|
||||
widgets::page_header(
|
||||
ui,
|
||||
&c.name,
|
||||
"The company's profile and the standing parts of its books.",
|
||||
);
|
||||
columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i {
|
||||
0 => {
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Profile",
|
||||
|_| {},
|
||||
|ui| {
|
||||
let kept = b.ledger.settings();
|
||||
// The currency is always known: facts are the rest.
|
||||
if c.profile.iter().all(|(k, _)| k == "base_currency") {
|
||||
ui.label(
|
||||
RichText::new(if kept.is_journaled() {
|
||||
"No company facts set yet: the agent records them as you give them."
|
||||
} else {
|
||||
"No company facts in the settings file yet: the agent records them as you give them."
|
||||
})
|
||||
.color(p.muted),
|
||||
);
|
||||
}
|
||||
for (k, v) in &c.profile {
|
||||
kv(ui, &fmt::label(k), v);
|
||||
// Who set it, and when (settings kept in the
|
||||
// books carry their source).
|
||||
if let Some(r) = kept.get(&format!("company.{k}")) {
|
||||
provenance(ui, &r.source.decided_by, r.source.decided_on);
|
||||
}
|
||||
}
|
||||
kv(ui, "Accounting basis", &c.basis);
|
||||
kv(
|
||||
ui,
|
||||
"Tax return",
|
||||
c.tax_form.map(fmt::tax_form).unwrap_or("not set"),
|
||||
);
|
||||
kv(ui, "Receipt threshold", &fmt::dollars(b.receipt_threshold));
|
||||
if let Some(z) = &b.settings.timezone {
|
||||
kv(ui, "Time zone", z);
|
||||
}
|
||||
ui.label(
|
||||
RichText::new(if kept.is_journaled() {
|
||||
"Kept in the books: the agent changes settings, with who decided and when."
|
||||
} else {
|
||||
"Read from the settings file: the agent can move it into the books."
|
||||
})
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
},
|
||||
);
|
||||
kept_cards(ui, b);
|
||||
}
|
||||
_ => {
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Owners",
|
||||
|_| {},
|
||||
|ui| {
|
||||
if c.members.is_empty() {
|
||||
// A single owner or a corporation: equity by account.
|
||||
for a in b
|
||||
.ledger
|
||||
.chart()
|
||||
.all()
|
||||
.filter(|a| a.account_type == ledger_core::AccountType::Equity)
|
||||
{
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(
|
||||
RichText::new(format!("{} {}", a.code, a.name))
|
||||
.font(theme::regular(13.0)),
|
||||
);
|
||||
ui.with_layout(
|
||||
egui::Layout::right_to_left(egui::Align::Center),
|
||||
|ui| {
|
||||
ui.label(
|
||||
RichText::new(fmt::accounting(
|
||||
b.balance_on(a.id, b.today),
|
||||
))
|
||||
.font(theme::semibold(13.0)),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
for m in &c.members {
|
||||
let cap = b.ledger.chart().find_by_code(&m.capital_account);
|
||||
let bal = cap.map(|a| b.balance_on(a.id, b.today)).unwrap_or_default();
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(RichText::new(&m.name).font(theme::semibold(13.5)));
|
||||
widgets::pill(
|
||||
ui,
|
||||
&format!("{}%", (m.share * Decimal::from(100)).normalize()),
|
||||
p.accent,
|
||||
p.accent_soft,
|
||||
);
|
||||
ui.with_layout(
|
||||
egui::Layout::right_to_left(egui::Align::Center),
|
||||
|ui| {
|
||||
ui.label(
|
||||
RichText::new(fmt::dollars(bal))
|
||||
.font(theme::semibold(13.5)),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
if let Some(a) = cap {
|
||||
ui.label(
|
||||
RichText::new(format!("Capital account {} {}", a.code, a.name))
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
},
|
||||
);
|
||||
ui.add_space(12.0);
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Periods",
|
||||
|_| {},
|
||||
|ui| {
|
||||
let periods = b.ledger.list_periods();
|
||||
if periods.is_empty() {
|
||||
ui.label(RichText::new("No periods yet. Closing a period locks it and moves its earnings into equity.").color(p.muted));
|
||||
}
|
||||
for per in periods {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let (g, color) = match per.status {
|
||||
PeriodStatus::Closed => (icon::LOCK, p.muted),
|
||||
PeriodStatus::Open => (icon::LOCK_OPEN, p.warning),
|
||||
};
|
||||
ui.label(RichText::new(g).color(color));
|
||||
ui.label(RichText::new(&per.name).font(theme::semibold(13.0)));
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"{} – {}",
|
||||
fmt::date(per.start),
|
||||
fmt::date(per.end)
|
||||
))
|
||||
.color(p.muted),
|
||||
);
|
||||
if let Some(e) = per.closing_entry
|
||||
&& widgets::link(
|
||||
ui,
|
||||
RichText::new(format!("closing entry #{e}"))
|
||||
.color(p.accent),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
shell.go(Route::Entry(e));
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
let mut subjects = vec![ledger_store::Subject::Company];
|
||||
subjects.extend(
|
||||
b.ledger
|
||||
.list_periods()
|
||||
.iter()
|
||||
.map(|x| ledger_store::Subject::Period(x.id)),
|
||||
);
|
||||
subjects.extend(
|
||||
b.ledger
|
||||
.schedules()
|
||||
.map(|x| ledger_store::Subject::Schedule(x.id)),
|
||||
);
|
||||
let mut go = None;
|
||||
super::documents::documents_card_with(
|
||||
ui,
|
||||
b,
|
||||
"Company documents",
|
||||
&subjects,
|
||||
&[ledger_store::Subject::Company],
|
||||
"No documents linked to the company, its periods, or its schedules yet (formation papers, the operating agreement, tax filings).",
|
||||
&mut go,
|
||||
);
|
||||
ui.add_space(16.0);
|
||||
if let Some(r) = go {
|
||||
shell.go(r);
|
||||
}
|
||||
assets(ui, b, shell);
|
||||
schedules(ui, b);
|
||||
recurring(ui, b);
|
||||
parties(ui, b, shell);
|
||||
});
|
||||
}
|
||||
|
||||
fn assets(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
let assets: Vec<_> = b.ledger.registered_assets().collect();
|
||||
if assets.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut go = None;
|
||||
let (register, _) = widgets::titled_card(
|
||||
ui,
|
||||
"Fixed assets",
|
||||
|ui| {
|
||||
widgets::link(
|
||||
ui,
|
||||
RichText::new(format!("Asset register {}", icon::ARROW_RIGHT))
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.accent),
|
||||
)
|
||||
.clicked()
|
||||
},
|
||||
|ui| {
|
||||
for a in &assets {
|
||||
// What has actually been posted, so this ties to the balance
|
||||
// sheet; the schedule may run ahead of the books.
|
||||
let acc: Decimal = b
|
||||
.journal
|
||||
.iter()
|
||||
.filter(|l| {
|
||||
l.line_type == "asset.depreciation_posted"
|
||||
&& l.payload.get("asset_id").and_then(|v| v.as_u64()) == Some(a.id)
|
||||
})
|
||||
.filter_map(|l| {
|
||||
l.payload
|
||||
.get("amount")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<Decimal>().ok())
|
||||
})
|
||||
.sum();
|
||||
let bv = a.cost.amount - acc;
|
||||
let scheduled = accumulated_depreciation(a, b.today);
|
||||
let _ = book_value;
|
||||
let dep = a.cost.amount - a.salvage_value.amount;
|
||||
let frac: f32 = if dep.is_zero() {
|
||||
1.0
|
||||
} else {
|
||||
(acc / dep).try_into().unwrap_or(0.0)
|
||||
};
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if widgets::link(
|
||||
ui,
|
||||
RichText::new(b.asset_label(a.id)).font(theme::semibold(13.5)),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
go = Some(a.id);
|
||||
}
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"bought {} · {} months · {}",
|
||||
fmt::date(a.purchase_date),
|
||||
a.useful_life_months,
|
||||
fmt::words(&format!("{:?}", a.status)).to_lowercase()
|
||||
))
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label(
|
||||
RichText::new(format!("book value {}", fmt::dollars(bv)))
|
||||
.font(theme::semibold(13.0)),
|
||||
);
|
||||
});
|
||||
});
|
||||
let behind = scheduled - acc;
|
||||
let note = if behind > Decimal::ZERO {
|
||||
format!(" · {} more scheduled through today", fmt::dollars(behind))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
progress(
|
||||
ui,
|
||||
frac,
|
||||
&format!(
|
||||
"{} of {} depreciated{note}",
|
||||
fmt::dollars(acc),
|
||||
fmt::dollars(dep)
|
||||
),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
},
|
||||
);
|
||||
if let Some(id) = go {
|
||||
shell.go(Route::Asset(id));
|
||||
}
|
||||
if register {
|
||||
shell.go(Route::Assets);
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
}
|
||||
|
||||
fn progress(ui: &mut Ui, frac: f32, label: &str) {
|
||||
let p = pal(ui.ctx());
|
||||
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 8.0), Sense::hover());
|
||||
ui.painter().rect_filled(r, CornerRadius::same(4), p.hover);
|
||||
let mut done = r;
|
||||
done.set_width(r.width() * frac.clamp(0.0, 1.0));
|
||||
ui.painter()
|
||||
.rect_filled(done, CornerRadius::same(4), p.accent);
|
||||
ui.label(
|
||||
RichText::new(label)
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.faint),
|
||||
);
|
||||
}
|
||||
|
||||
fn schedules(ui: &mut Ui, b: &Books) {
|
||||
let p = pal(ui.ctx());
|
||||
let list: Vec<_> = b.ledger.schedules().collect();
|
||||
if list.is_empty() {
|
||||
return;
|
||||
}
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Amortization schedules",
|
||||
|_| {},
|
||||
|ui| {
|
||||
for s in list {
|
||||
let total = s.total.amount;
|
||||
// A schedule an earlier version journaled may not compute
|
||||
// (months past chrono's last day): said, never guessed.
|
||||
let (remaining, due) = match (s.remaining(), s.due(b.today)) {
|
||||
(Ok(r), Ok(d)) => (r, d),
|
||||
(Err(e), _) | (_, Err(e)) => {
|
||||
ui.label(RichText::new(&s.description).font(theme::semibold(13.5)));
|
||||
ui.label(
|
||||
RichText::new(format!("Its plan can't be computed: {e}."))
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.negative),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let recognized = total.checked_sub(remaining);
|
||||
let frac: f32 = match recognized {
|
||||
_ if total.is_zero() => 1.0,
|
||||
Some(r) => r
|
||||
.checked_div(total)
|
||||
.and_then(|x| x.try_into().ok())
|
||||
.unwrap_or(0.0),
|
||||
None => 0.0,
|
||||
};
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(RichText::new(&s.description).font(theme::semibold(13.5)));
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"{} · {} months from {}",
|
||||
fmt::words(&format!("{:?}", s.kind)),
|
||||
s.months,
|
||||
fmt::month(s.start)
|
||||
))
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
if !due.is_empty() {
|
||||
widgets::pill(ui, &format!("{} due", due.len()), p.warning, p.warning_soft);
|
||||
}
|
||||
});
|
||||
progress(
|
||||
ui,
|
||||
frac,
|
||||
&format!(
|
||||
"{} of {} recognized · {} left",
|
||||
fmt::dollars_or(recognized),
|
||||
fmt::dollars(total),
|
||||
fmt::dollars(remaining)
|
||||
),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
},
|
||||
);
|
||||
ui.add_space(16.0);
|
||||
}
|
||||
|
||||
fn recurring(ui: &mut Ui, b: &Books) {
|
||||
let p = pal(ui.ctx());
|
||||
let list: Vec<_> = b.ledger.recurring_entries().collect();
|
||||
if list.is_empty() {
|
||||
return;
|
||||
}
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Recurring entries",
|
||||
|_| {},
|
||||
|ui| {
|
||||
for r in list {
|
||||
let amount = r
|
||||
.lines
|
||||
.iter()
|
||||
.filter(|(_, s, _)| *s == ledger_core::Side::Debit)
|
||||
.try_fold(Decimal::ZERO, |t, (_, _, m)| t.checked_add(m.amount));
|
||||
let next = (0..)
|
||||
.map_while(|k| {
|
||||
r.frequency
|
||||
.nth(r.start, k)
|
||||
.filter(|d| r.end.is_none_or(|e| *d <= e))
|
||||
})
|
||||
.find(|d| !r.generated.contains(d));
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(RichText::new(&r.description).font(theme::semibold(13.5)));
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"{} · {}",
|
||||
fmt::words(&format!("{:?}", r.frequency)),
|
||||
fmt::dollars_or(amount)
|
||||
))
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let text = match next {
|
||||
Some(d) if d <= b.today => format!("due {}", fmt::date(d)),
|
||||
Some(d) => format!("next {}", fmt::date(d)),
|
||||
None => "ended".into(),
|
||||
};
|
||||
ui.label(RichText::new(text).font(theme::regular(12.5)).color(
|
||||
if next.is_some_and(|d| d <= b.today) {
|
||||
p.warning
|
||||
} else {
|
||||
p.muted
|
||||
},
|
||||
));
|
||||
});
|
||||
});
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"{} generated so far · drafts only; each one is posted with its receipt",
|
||||
r.generated.len()
|
||||
))
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.faint),
|
||||
);
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
},
|
||||
);
|
||||
ui.add_space(16.0);
|
||||
}
|
||||
|
||||
fn parties(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
let mut go: Option<crate::nav::Route> = None;
|
||||
// A record's name opens its party.
|
||||
let mut name = |ui: &mut Ui, text: &str, party: Option<u64>| {
|
||||
let r = widgets::link(ui, RichText::new(text).font(theme::semibold(13.0)));
|
||||
if let Some(id) = party
|
||||
&& r.on_hover_text("Open this party").clicked()
|
||||
{
|
||||
go = Some(crate::nav::Route::Party(id));
|
||||
}
|
||||
};
|
||||
let year = chrono::Datelike::year(&b.today);
|
||||
let r1099 = vendor_1099_report(&b.ledger, year, None).ok();
|
||||
columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i {
|
||||
0 => {
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Vendors",
|
||||
|_| {},
|
||||
|ui| {
|
||||
let vendors = b.ledger.list_vendors();
|
||||
if vendors.is_empty() {
|
||||
ui.label(RichText::new("No vendors yet.").color(p.muted));
|
||||
}
|
||||
for v in vendors {
|
||||
let paid = r1099
|
||||
.iter()
|
||||
.flat_map(|r| &r.vendors)
|
||||
.find(|x| x.vendor_id == v.id)
|
||||
.map(|x| x.paid)
|
||||
.unwrap_or_default();
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
name(ui, &v.name, b.ledger.party_of_vendor(v.id));
|
||||
if let Some(f) = v.form_1099 {
|
||||
widgets::pill(ui, f.label(), p.accent, p.accent_soft);
|
||||
if v.tin_on_file {
|
||||
widgets::pill(ui, "W-9", p.positive, p.positive_soft);
|
||||
} else {
|
||||
widgets::pill(ui, "no W-9", p.negative, p.negative_soft);
|
||||
}
|
||||
}
|
||||
ui.with_layout(
|
||||
egui::Layout::right_to_left(egui::Align::Center),
|
||||
|ui| {
|
||||
if !paid.is_zero() {
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"{} paid in {year}",
|
||||
fmt::dollars(paid)
|
||||
))
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Customers",
|
||||
|_| {},
|
||||
|ui| {
|
||||
let customers = b.ledger.list_customers();
|
||||
if customers.is_empty() {
|
||||
ui.label(RichText::new("No customers yet.").color(p.muted));
|
||||
}
|
||||
for c in customers {
|
||||
// None: a balance, or their sum, can't be computed.
|
||||
let owed = (1..b.ledger.next_invoice_id())
|
||||
.filter_map(|i| b.ledger.get_invoice(i))
|
||||
.filter(|i| i.customer_id == c.id)
|
||||
.try_fold(Decimal::ZERO, |t, i| t.checked_add(i.balance().ok()?));
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
name(ui, &c.name, b.ledger.party_of_customer(c.id));
|
||||
ui.with_layout(
|
||||
egui::Layout::right_to_left(egui::Align::Center),
|
||||
|ui| {
|
||||
if !owed.is_some_and(|o| o.is_zero()) {
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"owes {}",
|
||||
fmt::dollars_or(owed)
|
||||
))
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
if let Some(r) = go {
|
||||
shell.go(r);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_support::{Build, beside, cents, d, find, has, render_all};
|
||||
use ledger_core::arap::DocumentLine;
|
||||
use ledger_core::{AccountType, Currency, Frequency, Money, NewRecurring, NewSchedule, Side};
|
||||
|
||||
/// The company page: each amortization schedule's recognized share as
|
||||
/// a bar (three of twelve months posted: a quarter), each recurring
|
||||
/// entry, and each customer with what they owe, theirs alone.
|
||||
#[test]
|
||||
fn the_company_page_says_what_each_record_holds() {
|
||||
let mut b = Build::new("company");
|
||||
let (cash, capital, rent) = (b.cash, b.capital, b.rent);
|
||||
let prepaid =
|
||||
b.s.add_account("1300", "Prepaid", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let insurance =
|
||||
b.s.add_account("6300", "Insurance", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
b.post(d(2026, 1, 2), cash, capital, 1_000_000);
|
||||
b.post(d(2026, 1, 2), prepaid, cash, 120_000);
|
||||
let usd = |c: i64| Money::new(cents(c), Currency::USD);
|
||||
b.s.register_schedule(NewSchedule {
|
||||
description: "Insurance plan".into(),
|
||||
kind: ledger_core::ScheduleKind::PrepaidExpense,
|
||||
total: usd(120_000),
|
||||
start: d(2026, 1, 1),
|
||||
months: 12,
|
||||
balance_account: prepaid,
|
||||
pl_account: insurance,
|
||||
})
|
||||
.unwrap();
|
||||
b.s.run_schedules(d(2026, 3, 31)).unwrap();
|
||||
b.s.register_recurring(NewRecurring {
|
||||
description: "Office rent".into(),
|
||||
lines: vec![
|
||||
(rent, Side::Debit, usd(50_000)),
|
||||
(cash, Side::Credit, usd(50_000)),
|
||||
],
|
||||
frequency: Frequency::Monthly,
|
||||
start: d(2026, 1, 20),
|
||||
end: None,
|
||||
})
|
||||
.unwrap();
|
||||
let line = |c: i64| {
|
||||
vec![DocumentLine {
|
||||
description: "work".into(),
|
||||
amount: cents(c),
|
||||
}]
|
||||
};
|
||||
let acme = b.s.add_customer("Acme").unwrap();
|
||||
let beta = b.s.add_customer("Beta").unwrap();
|
||||
b.s.add_customer("Gamma").unwrap();
|
||||
b.s.create_invoice(acme, d(2026, 9, 1), d(2026, 10, 1), line(120_000))
|
||||
.unwrap();
|
||||
b.s.create_invoice(beta, d(2026, 9, 1), d(2026, 10, 1), line(20_000))
|
||||
.unwrap();
|
||||
let (_dir, books) = b.books();
|
||||
let (texts, rects, p) = render_all(books, Route::Company, |_| {});
|
||||
find(&texts, "Amortization schedules");
|
||||
let label = find(
|
||||
&texts,
|
||||
"$300.00 of $1,200.00 recognized \u{b7} $900.00 left",
|
||||
);
|
||||
// The bar just above it: the track, and the part done.
|
||||
let near = |fill: egui::Color32| {
|
||||
rects
|
||||
.iter()
|
||||
.filter(|(r, f)| {
|
||||
*f == fill
|
||||
&& r.height() <= 8.5
|
||||
&& (label.pos.y - r.max.y) >= 0.0
|
||||
&& (label.pos.y - r.max.y) < 12.0
|
||||
})
|
||||
.map(|(r, _)| r.width())
|
||||
.fold(0.0f32, f32::max)
|
||||
};
|
||||
let (track, done) = (near(p.hover), near(p.accent));
|
||||
assert!(track > 100.0, "track {track}");
|
||||
assert!((done / track - 0.25).abs() < 0.01, "{done} of {track}");
|
||||
find(&texts, "Recurring entries");
|
||||
find(&texts, "Office rent");
|
||||
find(&texts, "Vendors");
|
||||
find(&texts, "Customers");
|
||||
assert_eq!(beside(&texts, "Acme").text, "owes $1,200.00");
|
||||
assert_eq!(beside(&texts, "Beta").text, "owes $200.00");
|
||||
assert!(!has(&texts, "owes $0.00"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
//! Every receipt and document the books hold, rendered: what it is,
|
||||
//! everything it is linked to, and whether it was withdrawn (and why).
|
||||
//! Also the compact "Documents" card every subject's page shows
|
||||
//! ([`documents_card`]).
|
||||
|
||||
use super::page;
|
||||
use crate::app::Shell;
|
||||
use crate::books::Books;
|
||||
use crate::fmt;
|
||||
use crate::nav::Route;
|
||||
use crate::receipts::State as Render;
|
||||
use crate::theme::{self, pal};
|
||||
use crate::widgets;
|
||||
use egui::{CornerRadius, RichText, Sense, Stroke, Ui, vec2};
|
||||
use egui_phosphor::regular as icon;
|
||||
use ledger_store::{Attachment, Subject};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
pub category: Option<String>,
|
||||
pub show_withdrawn: bool,
|
||||
pub query: String,
|
||||
}
|
||||
|
||||
pub fn show(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
|
||||
match selected.and_then(|id| b.attachments.iter().find(|a| a.id == id)) {
|
||||
Some(a) => super::split(
|
||||
ui,
|
||||
"documents",
|
||||
(ui.available_width() * 0.5).max(460.0),
|
||||
shell,
|
||||
|ui, shell| grid(ui, b, Some(a.id), shell),
|
||||
|ui, shell| detail(ui, b, a, shell),
|
||||
),
|
||||
None => grid(ui, b, None, shell),
|
||||
}
|
||||
}
|
||||
|
||||
fn grid(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
|
||||
page(ui, "documents", |ui| {
|
||||
let current = b
|
||||
.attachments
|
||||
.iter()
|
||||
.filter(|a| a.withdrawn_at.is_none())
|
||||
.count();
|
||||
widgets::page_header(
|
||||
ui,
|
||||
"Documents",
|
||||
&format!(
|
||||
"{} on file: receipts that prove entries, and the company's records. Bytes are content-addressed and verified on every read.",
|
||||
fmt::count(current, "document", "documents")
|
||||
),
|
||||
);
|
||||
let mut cats: Vec<String> = b.attachments.iter().map(|a| a.category.clone()).collect();
|
||||
cats.sort();
|
||||
cats.dedup();
|
||||
let st = &mut shell.state.documents;
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut st.query)
|
||||
.hint_text(format!("{} Search file names", icon::MAGNIFYING_GLASS))
|
||||
.desired_width(ui.available_width().min(260.0)),
|
||||
);
|
||||
if ui.selectable_label(st.category.is_none(), "All").clicked() {
|
||||
st.category = None;
|
||||
}
|
||||
for c in &cats {
|
||||
if ui
|
||||
.selectable_label(st.category.as_deref() == Some(c.as_str()), fmt::label(c))
|
||||
.clicked()
|
||||
{
|
||||
st.category = Some(c.clone());
|
||||
}
|
||||
}
|
||||
ui.checkbox(&mut st.show_withdrawn, "Show withdrawn");
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
let q = st.query.to_lowercase();
|
||||
let docs: Vec<&Attachment> = b
|
||||
.attachments
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|a| st.show_withdrawn || a.withdrawn_at.is_none())
|
||||
.filter(|a| st.category.as_ref().is_none_or(|c| &a.category == c))
|
||||
.filter(|a| {
|
||||
q.is_empty()
|
||||
|| a.file_name.to_lowercase().contains(&q)
|
||||
|| a.description
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.to_lowercase()
|
||||
.contains(&q)
|
||||
})
|
||||
.collect();
|
||||
if docs.is_empty() {
|
||||
widgets::card(ui, |ui| widgets::empty(ui, icon::FILES, "No documents", ""));
|
||||
return;
|
||||
}
|
||||
// As many 200-point tiles as fit, stretched to fill the row.
|
||||
let per_row = widgets::fit_columns(ui.available_width(), 200.0, 16.0, usize::MAX);
|
||||
let tile_w =
|
||||
((ui.available_width() - 16.0 * (per_row as f32 - 1.0)) / per_row as f32).min(280.0);
|
||||
let mut go = None;
|
||||
for row in docs.chunks(per_row) {
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 16.0;
|
||||
for a in row {
|
||||
if tile(ui, b, a, tile_w, selected == Some(a.id), shell).clicked() {
|
||||
go = Some(a.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(16.0);
|
||||
}
|
||||
if let Some(id) = go {
|
||||
shell.go(Route::Document(id));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn tile(
|
||||
ui: &mut Ui,
|
||||
b: &Books,
|
||||
a: &Attachment,
|
||||
w: f32,
|
||||
on: bool,
|
||||
shell: &mut Shell<'_>,
|
||||
) -> egui::Response {
|
||||
let p = pal(ui.ctx());
|
||||
let h = 250.0;
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(w, h), Sense::click());
|
||||
let fill = if on { p.selected } else { p.surface };
|
||||
ui.painter().rect(
|
||||
rect,
|
||||
CornerRadius::same(10),
|
||||
fill,
|
||||
Stroke::new(1.0, if resp.hovered() { p.accent } else { p.border }),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
let thumb = egui::Rect::from_min_size(rect.min + vec2(10.0, 10.0), vec2(w - 20.0, 170.0));
|
||||
ui.painter()
|
||||
.rect_filled(thumb, CornerRadius::same(6), p.hover);
|
||||
match shell.receipts.get(&a.blob_sha, &a.mime_type, b.blob_root()) {
|
||||
Render::Ready(pages) => {
|
||||
if let Some(tex) = pages.first() {
|
||||
// Top of the first page, filling the width.
|
||||
let size = tex.size_vec2();
|
||||
let scale = thumb.width() / size.x;
|
||||
let visible_h = (thumb.height() / scale).min(size.y);
|
||||
let uv = egui::Rect::from_min_max(
|
||||
egui::pos2(0.0, 0.0),
|
||||
egui::pos2(1.0, visible_h / size.y),
|
||||
);
|
||||
let img_rect =
|
||||
egui::Rect::from_min_size(thumb.min, vec2(thumb.width(), visible_h * scale));
|
||||
ui.painter()
|
||||
.image(tex.id(), img_rect, uv, egui::Color32::WHITE);
|
||||
}
|
||||
}
|
||||
Render::Loading => {
|
||||
ui.painter().text(
|
||||
thumb.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
"…",
|
||||
theme::regular(16.0),
|
||||
p.faint,
|
||||
);
|
||||
}
|
||||
Render::Failed(_) => {
|
||||
ui.painter().text(
|
||||
thumb.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
icon::FILE,
|
||||
egui::FontId::proportional(28.0),
|
||||
p.faint,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Title: what it proves, else what it is.
|
||||
let title = a
|
||||
.entry_id
|
||||
.and_then(|e| b.entry(e))
|
||||
.map(|e| e.description.clone())
|
||||
.or_else(|| a.description.clone())
|
||||
.unwrap_or_else(|| a.file_name.clone());
|
||||
let max_chars = ((w - 20.0) / 7.2) as usize;
|
||||
let title = if title.chars().count() > max_chars {
|
||||
title.chars().take(max_chars - 1).collect::<String>() + "…"
|
||||
} else {
|
||||
title
|
||||
};
|
||||
ui.painter().text(
|
||||
egui::pos2(rect.left() + 10.0, thumb.bottom() + 18.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
title,
|
||||
theme::semibold(12.5),
|
||||
p.text,
|
||||
);
|
||||
let sub = match (a.entry_id, &a.withdrawn_at) {
|
||||
(_, Some(_)) => format!("Withdrawn · {}", a.file_name),
|
||||
(Some(e), None) => match b.entry(e) {
|
||||
Some(x) => format!(
|
||||
"#{e} · {} · {}",
|
||||
fmt::date_short(x.date),
|
||||
fmt::dollars_or(x.amount)
|
||||
),
|
||||
None => format!("#{e}"),
|
||||
},
|
||||
(None, None) => format!("{} · {}", fmt::label(&a.category), a.file_name),
|
||||
};
|
||||
let sub = if sub.chars().count() > max_chars + 4 {
|
||||
sub.chars().take(max_chars + 3).collect::<String>() + "…"
|
||||
} else {
|
||||
sub
|
||||
};
|
||||
ui.painter().text(
|
||||
egui::pos2(rect.left() + 10.0, thumb.bottom() + 40.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
sub,
|
||||
theme::regular(11.5),
|
||||
if a.withdrawn_at.is_some() {
|
||||
p.warning
|
||||
} else {
|
||||
p.muted
|
||||
},
|
||||
);
|
||||
resp
|
||||
}
|
||||
|
||||
fn detail(ui: &mut Ui, b: &Books, a: &Attachment, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt(("doc", a.id))
|
||||
.auto_shrink(false)
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
RichText::new(format!("Document {}", a.id))
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui
|
||||
.add(egui::Button::new(RichText::new(icon::X).size(15.0)).frame(false))
|
||||
.clicked()
|
||||
{
|
||||
shell.go(Route::Documents);
|
||||
}
|
||||
});
|
||||
});
|
||||
widgets::copyable(
|
||||
ui,
|
||||
RichText::new(&a.file_name).font(theme::bold(20.0)),
|
||||
&a.file_name,
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
egui::Grid::new(("docfacts", a.id))
|
||||
.num_columns(2)
|
||||
.spacing(vec2(20.0, 6.0))
|
||||
.show(ui, |ui| {
|
||||
let k = |ui: &mut Ui, s: &str| {
|
||||
ui.label(RichText::new(s).font(theme::regular(12.5)).color(p.muted))
|
||||
};
|
||||
k(ui, "Category");
|
||||
ui.label(fmt::label(&a.category));
|
||||
ui.end_row();
|
||||
k(ui, "Type");
|
||||
ui.label(format!("{} · {}", a.mime_type, fmt::bytes(a.size_bytes)));
|
||||
ui.end_row();
|
||||
k(ui, "Filed");
|
||||
ui.label(fmt::timestamp(&a.created_at));
|
||||
ui.end_row();
|
||||
k(ui, "SHA-256");
|
||||
widgets::copyable(
|
||||
ui,
|
||||
RichText::new(&a.blob_sha).monospace().size(11.5),
|
||||
&a.blob_sha,
|
||||
);
|
||||
ui.end_row();
|
||||
if let Some(st) = b.statement(a.id) {
|
||||
k(ui, "Statement");
|
||||
ui.add(
|
||||
egui::Label::new(format!(
|
||||
"{}, {} – {}",
|
||||
b.account_label(st.account_id),
|
||||
fmt::date(st.period_start),
|
||||
fmt::date(st.period_end)
|
||||
))
|
||||
.wrap(),
|
||||
);
|
||||
ui.end_row();
|
||||
k(ui, "Opening");
|
||||
ui.label(fmt::dollars(st.opening_balance));
|
||||
ui.end_row();
|
||||
k(ui, "Closing");
|
||||
ui.label(
|
||||
RichText::new(fmt::dollars(st.closing_balance))
|
||||
.font(theme::semibold(13.5)),
|
||||
);
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
let mut go = None;
|
||||
links_section(ui, b, a, &mut go);
|
||||
if let Some(r) = go {
|
||||
shell.go(r);
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
super::entry::render(ui, b, a, shell, ui.available_width());
|
||||
if let Some(d) = &a.description {
|
||||
ui.add_space(6.0);
|
||||
ui.label(RichText::new(d).color(p.muted));
|
||||
}
|
||||
if a.withdrawn_at.is_some() {
|
||||
ui.add_space(6.0);
|
||||
widgets::pill(
|
||||
ui,
|
||||
&format!("Withdrawn: {}", a.withdrawn_reason.as_deref().unwrap_or("")),
|
||||
p.warning,
|
||||
p.warning_soft,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The icon for a subject's kind.
|
||||
pub fn subject_icon(s: Subject) -> &'static str {
|
||||
match s {
|
||||
Subject::Entry(_) => icon::RECEIPT,
|
||||
Subject::Account(_) => icon::TREE_STRUCTURE,
|
||||
Subject::Asset(_) => icon::PACKAGE,
|
||||
Subject::Party(_) => icon::ADDRESS_BOOK,
|
||||
Subject::Invoice(_) | Subject::Bill(_) => icon::FILE_TEXT,
|
||||
Subject::Period(_) => icon::CALENDAR_BLANK,
|
||||
Subject::PayrollRun(_) => icon::USERS,
|
||||
Subject::Schedule(_) => icon::CALENDAR_CHECK,
|
||||
Subject::Reconciliation(_) => icon::CHECK_SQUARE,
|
||||
Subject::Company => icon::BUILDINGS,
|
||||
}
|
||||
}
|
||||
|
||||
/// A small chip naming a subject, which opens it (see
|
||||
/// [`Books::subject_route`]) when it has somewhere to go.
|
||||
pub fn subject_chip(ui: &mut Ui, b: &Books, s: Subject, go: &mut Option<Route>) {
|
||||
let p = pal(ui.ctx());
|
||||
let route = b.subject_route(s);
|
||||
// A reconciliation's chip names its date before anything is cut.
|
||||
let mut label = match s {
|
||||
Subject::Reconciliation(r) => b.reconciliation(r).map_or_else(
|
||||
|| b.subject_label(s),
|
||||
|r| {
|
||||
format!(
|
||||
"Reconciliation, {} through {}",
|
||||
b.account_name(r.account_id),
|
||||
fmt::date(r.statement_date)
|
||||
)
|
||||
},
|
||||
),
|
||||
_ => b.subject_label(s),
|
||||
};
|
||||
if label.chars().count() > 60 {
|
||||
label = label.chars().take(59).collect::<String>() + "…";
|
||||
}
|
||||
let r = ui.add(
|
||||
egui::Button::new(
|
||||
RichText::new(format!("{} {label}", subject_icon(s)))
|
||||
.font(theme::regular(11.5))
|
||||
.color(if route.is_some() { p.accent } else { p.muted }),
|
||||
)
|
||||
.fill(if route.is_some() {
|
||||
p.accent_soft
|
||||
} else {
|
||||
p.hover
|
||||
})
|
||||
.stroke(Stroke::NONE)
|
||||
.corner_radius(CornerRadius::same(255))
|
||||
.min_size(vec2(0.0, 22.0)),
|
||||
);
|
||||
if let Some(to) = route
|
||||
&& r.on_hover_text(format!("Open {}", b.subject_label(s)))
|
||||
.clicked()
|
||||
{
|
||||
*go = Some(to);
|
||||
}
|
||||
}
|
||||
|
||||
/// The compact list of the documents linked to `subjects`: each file's
|
||||
/// name (opening its document page), category and description, and the
|
||||
/// other places it is linked as chips that open them.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: every subject's page shows the files that are evidence of it,
|
||||
/// and where else each one is evidence.
|
||||
/// SOURCE: docs/parties-and-attachments.md (Attachments, Viewer).
|
||||
/// INVARIANTS:
|
||||
/// 1. Lists exactly Books::documents_for(subjects), in that order.
|
||||
/// 2. A file's chips are links_of(file) less `here` (the page's own
|
||||
/// subject; [`documents_card`] passes `subjects`).
|
||||
/// 3. Empty: `empty` in muted text (nothing else).
|
||||
/// ```
|
||||
pub fn documents_card_with(
|
||||
ui: &mut Ui,
|
||||
b: &Books,
|
||||
title: &str,
|
||||
subjects: &[Subject],
|
||||
here: &[Subject],
|
||||
empty: &str,
|
||||
go: &mut Option<Route>,
|
||||
) {
|
||||
let p = pal(ui.ctx());
|
||||
let docs = b.documents_for(subjects);
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
title,
|
||||
|ui| {
|
||||
if !docs.is_empty() {
|
||||
widgets::pill(ui, &docs.len().to_string(), p.muted, p.hover);
|
||||
}
|
||||
},
|
||||
|ui| {
|
||||
if docs.is_empty() {
|
||||
ui.add(egui::Label::new(RichText::new(empty).color(p.muted)).wrap());
|
||||
return;
|
||||
}
|
||||
for (i, d) in docs.iter().enumerate() {
|
||||
if i > 0 {
|
||||
ui.add_space(4.0);
|
||||
widgets::rule(ui);
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
document_row(ui, b, d, here, go);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// [`documents_card_with`], the chips leaving out `subjects` themselves.
|
||||
pub fn documents_card(
|
||||
ui: &mut Ui,
|
||||
b: &Books,
|
||||
title: &str,
|
||||
subjects: &[Subject],
|
||||
empty: &str,
|
||||
go: &mut Option<Route>,
|
||||
) {
|
||||
documents_card_with(ui, b, title, subjects, subjects, empty, go);
|
||||
}
|
||||
|
||||
/// One file in a [`documents_card_with`].
|
||||
fn document_row(ui: &mut Ui, b: &Books, d: &Attachment, here: &[Subject], go: &mut Option<Route>) {
|
||||
let p = pal(ui.ctx());
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.label(RichText::new(file_icon(d)).size(18.0).color(p.muted));
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 2.0;
|
||||
if widgets::link_truncated(ui, RichText::new(&d.file_name).font(theme::semibold(13.0)))
|
||||
.on_hover_text("Open this document")
|
||||
.clicked()
|
||||
{
|
||||
*go = Some(Route::Document(d.id));
|
||||
}
|
||||
let mut sub = vec![];
|
||||
match b.statement(d.id) {
|
||||
None => sub.push(fmt::label(&d.category)),
|
||||
Some(st) => sub.push(format!(
|
||||
"Statement {} – {}, closing {}",
|
||||
fmt::date_short(st.period_start),
|
||||
fmt::date(st.period_end),
|
||||
fmt::dollars(st.closing_balance)
|
||||
)),
|
||||
}
|
||||
if let Some(desc) = &d.description {
|
||||
sub.push(desc.clone());
|
||||
}
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
RichText::new(sub.join(" · "))
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.muted),
|
||||
)
|
||||
.wrap(),
|
||||
);
|
||||
let elsewhere: Vec<Subject> = b
|
||||
.links_of(d.id)
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|s| !here.contains(s))
|
||||
.collect();
|
||||
if !elsewhere.is_empty() {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(4.0, 4.0);
|
||||
ui.label(
|
||||
RichText::new("Also on")
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.faint),
|
||||
);
|
||||
for s in elsewhere {
|
||||
subject_chip(ui, b, s, go);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// A PDF or an image.
|
||||
pub fn file_icon(a: &Attachment) -> &'static str {
|
||||
if a.mime_type == "application/pdf" {
|
||||
icon::FILE_PDF
|
||||
} else if a.mime_type.starts_with("image/") {
|
||||
icon::FILE_IMAGE
|
||||
} else {
|
||||
icon::FILE
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything a document is linked to: a chip per subject (an entry's
|
||||
/// with its date and amount in the label), opening it; and, for a
|
||||
/// statement, the reconciliations that used it.
|
||||
fn links_section(ui: &mut Ui, b: &Books, a: &Attachment, go: &mut Option<Route>) {
|
||||
let p = pal(ui.ctx());
|
||||
let links = b.links_of(a.id);
|
||||
ui.label(
|
||||
RichText::new("LINKED TO")
|
||||
.font(theme::semibold(11.5))
|
||||
.color(p.faint),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
if links.is_empty() {
|
||||
ui.label(
|
||||
RichText::new("Nothing yet: filed on its own.")
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
}
|
||||
for s in links {
|
||||
ui.horizontal(|ui| {
|
||||
subject_chip(ui, b, *s, go);
|
||||
if let Subject::Entry(e) = s
|
||||
&& let Some(x) = b.entry(*e)
|
||||
{
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"{} · {}",
|
||||
fmt::date(x.date),
|
||||
fmt::dollars_or(x.amount)
|
||||
))
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
}
|
||||
if let Subject::Reconciliation(r) = s
|
||||
&& let Some(r) = b.reconciliation(*r)
|
||||
&& let Some(why) = &r.voided
|
||||
{
|
||||
ui.label(
|
||||
RichText::new(format!("voided: {why}"))
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.warning),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_support::{Build, d, find, render};
|
||||
|
||||
/// A document's page lists what it is linked to: an entry with its date
|
||||
/// and amount.
|
||||
#[test]
|
||||
fn a_document_lists_the_entry_it_is_linked_to() {
|
||||
let mut b = Build::new("document-links");
|
||||
let (cash, capital) = (b.cash, b.capital);
|
||||
let e = b.post(d(2026, 1, 2), cash, capital, 100_000);
|
||||
let (att, _, _) =
|
||||
b.s.file_document("other", "note.pdf", "application/pdf", b"%PDF note", None)
|
||||
.unwrap();
|
||||
b.s.link_attachment(att, ledger_store::Subject::Entry(e))
|
||||
.unwrap();
|
||||
let (_dir, books) = b.books();
|
||||
let (texts, _) = render(books, Route::Document(att), |_| {});
|
||||
find(&texts, "LINKED TO");
|
||||
find(&texts, "Jan 2, 2026 \u{b7} $1,000.00");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
//! One entry, completely: what it is, every line, the receipt itself, and
|
||||
//! every journal event that touched it.
|
||||
|
||||
use crate::app::Shell;
|
||||
use crate::books::{Books, EntryInfo};
|
||||
use crate::fmt;
|
||||
use crate::nav::Route;
|
||||
use crate::receipts::State as Render;
|
||||
use crate::theme::{self, pal};
|
||||
use crate::widgets;
|
||||
use egui::{CornerRadius, Margin, RichText, Stroke, Ui, vec2};
|
||||
use egui_phosphor::regular as icon;
|
||||
use ledger_core::{EntryStatus, Side};
|
||||
use ledger_store::Attachment;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
let Some(entry) = b.ledger.get_entry(e.id) else {
|
||||
return;
|
||||
};
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt(("entry", e.id))
|
||||
.auto_shrink(false)
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
widgets::copyable(
|
||||
ui,
|
||||
RichText::new(format!("Entry #{}", e.id))
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
&format!("#{}", e.id),
|
||||
);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui
|
||||
.add(egui::Button::new(RichText::new(icon::X).size(15.0)).frame(false))
|
||||
.on_hover_text("Close")
|
||||
.clicked()
|
||||
{
|
||||
shell.go(Route::Transactions);
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.label(RichText::new(&e.description).font(theme::bold(21.0)));
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
match e.status {
|
||||
EntryStatus::Posted => widgets::pill(ui, "Posted", p.positive, p.positive_soft),
|
||||
EntryStatus::Draft => {
|
||||
widgets::pill(ui, "Draft — not in any report", p.warning, p.warning_soft)
|
||||
}
|
||||
};
|
||||
widgets::pill(ui, e.origin, p.muted, p.hover);
|
||||
if let Some(v) = &e.vendor
|
||||
&& e.parties.is_empty()
|
||||
{
|
||||
widgets::pill(ui, &format!("{} {v}", icon::STOREFRONT), p.muted, p.hover);
|
||||
}
|
||||
if e.missing_receipt {
|
||||
widgets::pill(ui, "Missing receipt", p.negative, p.negative_soft);
|
||||
}
|
||||
});
|
||||
if let Some(o) = e.reversal_of {
|
||||
ui.add_space(4.0);
|
||||
if widgets::link(
|
||||
ui,
|
||||
RichText::new(format!("{} Reverses #{o}", icon::ARROW_COUNTER_CLOCKWISE))
|
||||
.color(p.accent),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
shell.go(Route::Entry(o));
|
||||
}
|
||||
}
|
||||
if let Some(r) = e.reversed_by {
|
||||
ui.add_space(4.0);
|
||||
if widgets::link(
|
||||
ui,
|
||||
RichText::new(format!(
|
||||
"{} Reversed by #{r}",
|
||||
icon::ARROW_COUNTER_CLOCKWISE
|
||||
))
|
||||
.color(p.accent),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
shell.go(Route::Entry(r));
|
||||
}
|
||||
}
|
||||
let several = e.parties.len() > 1;
|
||||
for share in &e.parties {
|
||||
let Some(party) = b.ledger.get_party(share.id) else {
|
||||
continue;
|
||||
};
|
||||
ui.add_space(4.0);
|
||||
let mut text = format!("{} {}", super::parties::party_icon(party), party.label());
|
||||
if several {
|
||||
text.push_str(&format!(" · {}", share_text(share)));
|
||||
}
|
||||
if widgets::link(ui, RichText::new(text).color(p.accent))
|
||||
.on_hover_text(format!(
|
||||
"{} this entry is with ({}): open them",
|
||||
if several {
|
||||
"One of the parties"
|
||||
} else {
|
||||
"The party"
|
||||
},
|
||||
super::parties::roles_text(party).to_lowercase()
|
||||
))
|
||||
.clicked()
|
||||
{
|
||||
shell.go(Route::Party(party.id));
|
||||
}
|
||||
}
|
||||
if several && e.parties.iter().any(|x| x.resolved.is_none()) {
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"{} The parties' shares don't add up to what this entry moved",
|
||||
icon::WARNING
|
||||
))
|
||||
.color(p.negative),
|
||||
);
|
||||
}
|
||||
if e.parties.is_empty()
|
||||
&& let Some(why) = b.ledger.entry_no_party(e.id)
|
||||
{
|
||||
ui.add_space(4.0);
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
RichText::new(format!("{} No counterparty: {why}", icon::USER_MINUS))
|
||||
.color(p.muted),
|
||||
)
|
||||
.wrap(),
|
||||
)
|
||||
.on_hover_text("Marked as having no counterparty (entry.no_party)");
|
||||
}
|
||||
if let Some(&asset) = b.entry_assets.get(&e.id) {
|
||||
ui.add_space(4.0);
|
||||
if widgets::link(
|
||||
ui,
|
||||
RichText::new(format!("{} {}", icon::PACKAGE, b.asset_label(asset)))
|
||||
.color(p.accent),
|
||||
)
|
||||
.on_hover_text("Open this fixed asset")
|
||||
.clicked()
|
||||
{
|
||||
shell.go(Route::Asset(asset));
|
||||
}
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
|
||||
// Facts.
|
||||
let created = e.events.first().map(|&i| fmt::timestamp(&b.journal[i].ts));
|
||||
// Posted by entry.posted, or in one step by whatever created it
|
||||
// (a schedule, a close, a bill).
|
||||
let posted = e
|
||||
.events
|
||||
.iter()
|
||||
.find(|&&i| b.journal[i].line_type == "entry.posted")
|
||||
.or_else(|| e.events.first())
|
||||
.map(|&i| fmt::timestamp(&b.journal[i].ts));
|
||||
egui::Grid::new(("facts", e.id))
|
||||
.num_columns(2)
|
||||
.spacing(vec2(24.0, 6.0))
|
||||
.show(ui, |ui| {
|
||||
let k = |ui: &mut Ui, s: &str| {
|
||||
ui.label(RichText::new(s).font(theme::regular(12.5)).color(p.muted))
|
||||
};
|
||||
k(ui, "Date");
|
||||
ui.label(fmt::date(e.date));
|
||||
ui.end_row();
|
||||
k(ui, "Amount");
|
||||
widgets::copyable(
|
||||
ui,
|
||||
RichText::new(fmt::dollars_or(e.amount)).font(theme::semibold(14.0)),
|
||||
&fmt::plain(&fmt::dollars_or(e.amount)).unwrap_or_default(),
|
||||
);
|
||||
ui.end_row();
|
||||
if let Some(c) = &created {
|
||||
k(ui, "Recorded");
|
||||
ui.add(egui::Label::new(c).wrap());
|
||||
ui.end_row();
|
||||
}
|
||||
if e.status == EntryStatus::Posted
|
||||
&& let Some(t) = &posted
|
||||
{
|
||||
k(ui, "Posted");
|
||||
ui.add(egui::Label::new(t).wrap());
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
ui.add_space(16.0);
|
||||
|
||||
// Lines.
|
||||
section(ui, "Lines");
|
||||
let total_w = ui.available_width();
|
||||
let amt_w = ((total_w - 26.0) * 0.25).clamp(64.0, 104.0);
|
||||
let class_names: std::collections::HashMap<u32, String> = b
|
||||
.ledger
|
||||
.list_classes()
|
||||
.into_iter()
|
||||
.map(|c| (c.id, c.name.clone()))
|
||||
.collect();
|
||||
egui::Frame::new()
|
||||
.stroke(Stroke::new(1.0, p.border))
|
||||
.corner_radius(CornerRadius::same(10))
|
||||
.inner_margin(Margin::symmetric(12, 8))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(total_w - 26.0);
|
||||
ui.horizontal(|ui| {
|
||||
let w = ui.available_width() - amt_w * 2.0 - 16.0;
|
||||
let (r, _) = ui.allocate_exact_size(vec2(w, 18.0), egui::Sense::hover());
|
||||
ui.painter().text(
|
||||
r.left_center(),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
"Account",
|
||||
theme::regular(12.0),
|
||||
p.muted,
|
||||
);
|
||||
widgets::amount_cell(ui, "Debit", amt_w, p.muted, false, false);
|
||||
widgets::amount_cell(ui, "Credit", amt_w, p.muted, false, false);
|
||||
});
|
||||
// None: past exact arithmetic (a draft line an earlier
|
||||
// version journaled, of any size: rules.md B-28).
|
||||
let (mut dr, mut cr) = (Some(Decimal::ZERO), Some(Decimal::ZERO));
|
||||
for line in entry.lines() {
|
||||
ui.horizontal(|ui| {
|
||||
let w = ui.available_width() - amt_w * 2.0 - 16.0;
|
||||
ui.allocate_ui(vec2(w, 22.0), |ui| {
|
||||
ui.set_width(w);
|
||||
ui.horizontal(|ui| {
|
||||
let r = widgets::link_truncated(
|
||||
ui,
|
||||
RichText::new(b.account_label(line.account))
|
||||
.font(theme::regular(13.5)),
|
||||
);
|
||||
if r.clicked() {
|
||||
shell.go(Route::Account(line.account));
|
||||
}
|
||||
if let Some(c) = line.class_id.and_then(|c| class_names.get(&c))
|
||||
{
|
||||
ui.label(
|
||||
RichText::new(format!("{} {c}", icon::TAG))
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
}
|
||||
if let (Some(cur), Some(fa)) =
|
||||
(&line.foreign_currency, line.foreign_amount)
|
||||
{
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"{} {}",
|
||||
fmt::money(fa),
|
||||
cur.code()
|
||||
))
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
let a = fmt::money(line.amount.amount);
|
||||
let (d, c) = match line.side {
|
||||
Side::Debit => {
|
||||
dr = dr.and_then(|t| t.checked_add(line.amount.amount));
|
||||
(a, String::new())
|
||||
}
|
||||
Side::Credit => {
|
||||
cr = cr.and_then(|t| t.checked_add(line.amount.amount));
|
||||
(String::new(), a)
|
||||
}
|
||||
};
|
||||
widgets::amount_cell(ui, &d, amt_w, p.text, false, false);
|
||||
widgets::amount_cell(ui, &c, amt_w, p.text, false, false);
|
||||
});
|
||||
}
|
||||
widgets::rule(ui);
|
||||
ui.horizontal(|ui| {
|
||||
let w = ui.available_width() - amt_w * 2.0 - 16.0;
|
||||
let totalled = dr.is_some() && cr.is_some();
|
||||
let balanced = totalled && dr == cr;
|
||||
let (r, _) = ui.allocate_exact_size(vec2(w, 22.0), egui::Sense::hover());
|
||||
ui.painter().text(
|
||||
r.left_center(),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
if balanced {
|
||||
format!("{} Balanced", icon::CHECK)
|
||||
} else if totalled {
|
||||
"Out of balance".into()
|
||||
} else {
|
||||
"Too large to total".into()
|
||||
},
|
||||
theme::regular(12.0),
|
||||
if balanced { p.positive } else { p.negative },
|
||||
);
|
||||
widgets::amount_cell(ui, &fmt::money_or(dr), amt_w, p.text, true, false);
|
||||
widgets::amount_cell(ui, &fmt::money_or(cr), amt_w, p.text, true, false);
|
||||
});
|
||||
});
|
||||
ui.add_space(18.0);
|
||||
|
||||
// Receipts and documents: filed on it, linked to it, or linked
|
||||
// to the invoice, bill, or payroll run it records; a withdrawn
|
||||
// receipt filed on it stays, marked.
|
||||
let subjects = b.entry_subjects(e.id);
|
||||
let all_for_entry: Vec<&Attachment> = b
|
||||
.attachments
|
||||
.iter()
|
||||
.filter(|a| {
|
||||
a.entry_id == Some(e.id)
|
||||
|| (a.withdrawn_at.is_none()
|
||||
&& b.links_of(a.id).iter().any(|s| subjects.contains(s)))
|
||||
})
|
||||
.collect();
|
||||
section(
|
||||
ui,
|
||||
if all_for_entry.len() == 1 {
|
||||
"Receipt"
|
||||
} else {
|
||||
"Receipts and documents"
|
||||
},
|
||||
);
|
||||
if all_for_entry.is_empty() {
|
||||
let msg = if e.missing_receipt {
|
||||
format!("None attached, and {}.", b.receipt_rule())
|
||||
} else {
|
||||
"None attached.".to_string()
|
||||
};
|
||||
ui.label(RichText::new(msg).color(if e.missing_receipt {
|
||||
p.negative
|
||||
} else {
|
||||
p.muted
|
||||
}));
|
||||
}
|
||||
for a in all_for_entry {
|
||||
let elsewhere: Vec<ledger_store::Subject> = b
|
||||
.links_of(a.id)
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|s| *s != ledger_store::Subject::Entry(e.id))
|
||||
.collect();
|
||||
if !elsewhere.is_empty() {
|
||||
let mut go = None;
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(4.0, 4.0);
|
||||
ui.label(
|
||||
RichText::new("Linked to")
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.faint),
|
||||
);
|
||||
for s in elsewhere {
|
||||
super::documents::subject_chip(ui, b, s, &mut go);
|
||||
}
|
||||
});
|
||||
if let Some(r) = go {
|
||||
shell.go(r);
|
||||
}
|
||||
}
|
||||
attachment_view(ui, b, a, shell, true);
|
||||
ui.add_space(12.0);
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
|
||||
// History.
|
||||
section(ui, "History");
|
||||
for &i in &e.events {
|
||||
let line = &b.journal[i];
|
||||
let ev = super::journal::describe_any(b, line);
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.label(RichText::new(ev.icon).color(p.muted));
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
let r = ui.add(
|
||||
egui::Label::new(RichText::new(&ev.text).font(theme::regular(12.5)))
|
||||
.wrap()
|
||||
.sense(egui::Sense::click()),
|
||||
);
|
||||
if r.hovered() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
}
|
||||
if r.clicked() {
|
||||
shell.go(Route::JournalEvent(line.seq));
|
||||
}
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"event {} · {}",
|
||||
line.seq,
|
||||
fmt::timestamp(&line.ts)
|
||||
))
|
||||
.font(theme::regular(11.0))
|
||||
.color(p.faint),
|
||||
);
|
||||
});
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn section(ui: &mut Ui, title: &str) {
|
||||
let p = pal(ui.ctx());
|
||||
ui.label(
|
||||
RichText::new(title.to_uppercase())
|
||||
.font(theme::semibold(11.5))
|
||||
.color(p.faint),
|
||||
);
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
|
||||
/// A receipt or document: its name and facts, then the file itself.
|
||||
pub fn attachment_view(
|
||||
ui: &mut Ui,
|
||||
b: &Books,
|
||||
a: &Attachment,
|
||||
shell: &mut Shell<'_>,
|
||||
link_to_doc: bool,
|
||||
) {
|
||||
let p = pal(ui.ctx());
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
RichText::new(if a.mime_type == "application/pdf" {
|
||||
icon::FILE_PDF
|
||||
} else {
|
||||
icon::FILE_IMAGE
|
||||
})
|
||||
.size(18.0)
|
||||
.color(p.muted),
|
||||
);
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 1.0;
|
||||
let name = RichText::new(&a.file_name).font(theme::semibold(13.0));
|
||||
if link_to_doc {
|
||||
if widgets::link(ui, name).clicked() {
|
||||
shell.go(Route::Document(a.id));
|
||||
}
|
||||
} else {
|
||||
ui.label(name);
|
||||
}
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"{} · {} · filed {}",
|
||||
fmt::label(&a.category),
|
||||
fmt::bytes(a.size_bytes),
|
||||
fmt::timestamp(&a.created_at)
|
||||
))
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
});
|
||||
});
|
||||
if let Some(when) = &a.withdrawn_at {
|
||||
widgets::pill(
|
||||
ui,
|
||||
&format!(
|
||||
"Withdrawn {}: {}",
|
||||
fmt::timestamp(when),
|
||||
a.withdrawn_reason.as_deref().unwrap_or("")
|
||||
),
|
||||
p.warning,
|
||||
p.warning_soft,
|
||||
);
|
||||
}
|
||||
if let Some(d) = &a.description {
|
||||
ui.label(RichText::new(d).font(theme::regular(12.5)).color(p.muted));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
render(ui, b, a, shell, ui.available_width());
|
||||
}
|
||||
|
||||
/// The file itself, pages stacked, scaled to `width`.
|
||||
pub fn render(ui: &mut Ui, b: &Books, a: &Attachment, shell: &mut Shell<'_>, width: f32) {
|
||||
let p = pal(ui.ctx());
|
||||
match shell.receipts.get(&a.blob_sha, &a.mime_type, b.blob_root()) {
|
||||
Render::Loading => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spinner();
|
||||
ui.label(RichText::new("Rendering…").color(p.muted));
|
||||
});
|
||||
}
|
||||
Render::Failed(err) => {
|
||||
ui.label(
|
||||
RichText::new(format!("{} Can't show this file: {err}", icon::WARNING))
|
||||
.color(p.negative),
|
||||
);
|
||||
}
|
||||
Render::Ready(pages) => {
|
||||
for (i, tex) in pages.iter().enumerate() {
|
||||
let size = tex.size_vec2();
|
||||
let scale = (width / size.x).min(1.0);
|
||||
let img = egui::Image::new(tex)
|
||||
.fit_to_exact_size(size * scale)
|
||||
.corner_radius(CornerRadius::same(4));
|
||||
egui::Frame::new()
|
||||
.stroke(Stroke::new(1.0, p.border))
|
||||
.corner_radius(CornerRadius::same(6))
|
||||
.show(ui, |ui| {
|
||||
ui.add(img);
|
||||
});
|
||||
if pages.len() > 1 {
|
||||
ui.label(
|
||||
RichText::new(format!("Page {} of {}", i + 1, pages.len()))
|
||||
.font(theme::regular(11.0))
|
||||
.color(p.faint),
|
||||
);
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One party's share of an entry, in words: "$50.00 (derived)", "$70.00",
|
||||
/// "$33.34 (33.333%)", or the share alone when it doesn't resolve.
|
||||
pub fn share_text(s: &crate::books::EntryParty) -> String {
|
||||
let how = match s.share {
|
||||
ledger_core::Share::Amount(_) => None,
|
||||
ledger_core::Share::Percent(p) => Some(format!("{}%", p.normalize())),
|
||||
ledger_core::Share::Derived => Some("derived".to_string()),
|
||||
};
|
||||
match (s.resolved, how) {
|
||||
(Some(v), Some(h)) => format!("{} ({h})", fmt::dollars(v.abs())),
|
||||
(Some(v), None) => fmt::dollars(v.abs()),
|
||||
(None, Some(h)) => h,
|
||||
(None, None) => match s.share {
|
||||
ledger_core::Share::Amount(a) => fmt::dollars(a),
|
||||
_ => String::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_support::{Build, d, find, old_books, render};
|
||||
|
||||
/// An entry's totals line: "Balanced" when its debits equal its
|
||||
/// credits, "Out of balance" when they don't, "Too large to total"
|
||||
/// when a side is past exact arithmetic (a draft an earlier version
|
||||
/// journaled); its totals right-aligned under the lines' columns.
|
||||
#[test]
|
||||
fn an_entrys_totals_say_whether_it_balances() {
|
||||
let mut b = Build::new("entry-totals");
|
||||
let (cash, capital, rent) = (b.cash, b.capital, b.rent);
|
||||
let _ = rent;
|
||||
let posted = b.post(d(2026, 1, 2), cash, capital, 100_000);
|
||||
let usd = |c: i64| ledger_core::Money::new(Decimal::new(c, 2), ledger_core::Currency::USD);
|
||||
let (_dir, books) = b.books();
|
||||
let (texts, p) = render(books, Route::Entry(posted), |_| {});
|
||||
assert_eq!(
|
||||
find(&texts, &format!("{} Balanced", icon::CHECK)).color,
|
||||
p.positive
|
||||
);
|
||||
// Each column's amounts end at one edge: the lines' and the total's.
|
||||
let mut edges: Vec<i32> = texts
|
||||
.iter()
|
||||
.filter(|t| t.text == fmt::money(Decimal::new(100_000, 2)))
|
||||
.map(|t| (t.pos.x + t.width).round() as i32)
|
||||
.collect();
|
||||
assert_eq!(edges.len(), 4, "two lines and two totals");
|
||||
edges.sort_unstable();
|
||||
edges.dedup();
|
||||
assert_eq!(edges.len(), 2, "two columns: {edges:?}");
|
||||
let mut b = Build::new("entry-unbalanced");
|
||||
let (cash, rent) = (b.cash, b.rent);
|
||||
let draft = b.s.create_entry(d(2026, 3, 1), "half").unwrap();
|
||||
b.s.add_line(draft, rent, Side::Debit, usd(1_000)).unwrap();
|
||||
b.s.add_line(draft, cash, Side::Credit, usd(500)).unwrap();
|
||||
let (_dir, books) = b.books();
|
||||
let (texts, p) = render(books, Route::Entry(draft), |_| {});
|
||||
assert_eq!(find(&texts, "Out of balance").color, p.negative);
|
||||
// Its debits past exact arithmetic, its credits $1.00.
|
||||
let (_dir, books) = old_books("x_draft_max_lines");
|
||||
let (texts, p) = render(books, Route::Entry(1), |_| {});
|
||||
assert_eq!(find(&texts, "Too large to total").color, p.negative);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 380 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user