Personal Ledger
Personal Ledger
Track your personal expense, investments and assets to help you understand what you have and to make informed decisions..
Explore the Docs
·
Report a Bug
·
Request a Feature
Table of Contents
About The Project
Personal Ledger aims to provide its users with an intuitive and user-friendly interface that offers insights into their spending habits and investment position. By presenting information clearly and concisely, users can effectively monitor their overall financial health, make informed decisions, and ultimately achieve their personal financial goals.
Motivation for Personal Ledger:
- Applications in general are opinionated, and finance applications are no different. Looking at the finance applications available, none ticked my boxes or were monthly subscription models with high fees.
- I niavely thought I could do it better.
- Funsy project to hone my programming skills.
Includes
The Personal Ledger has a number of standalone applications and services that can be combined to suit your preference. They include the following:
- Backend (Underdevement) : A backend server that can be accessed by front ends through an rpc interface. The backend can be a standalone binary service or Docker deployable.
- Web (Under Development) : A web frontend app that uses the backend for data quering. The web frontend can be a standalone binary service or Docker deployable
- Desktop (Planned) : A desktop app that wraps the web front end that can access the bakend or be standalone.
- TUI (Planned) : A text interface frontend that can access the backend or be standalone.
Built With
I have tried to keep the tech stack with Rust, because that is what I would like to learn and improve with. The Rust stack being used includes the following:
- Sqlite:
- Tonic:
- Tuari:
- Ratui:
Getting Started
At the moment Personal Ledger is under development, so there is no end-user installation yet — the backend server only wires up a Ping utility RPC. If you want to build, run or contribute to the code, see Developing With This Repo below.
Developing With This Repo
Personal Ledger is a Rust Cargo workspace. This section covers what you need to build, test and run it locally.
Prerequisites
Toolchain and dev-tool versions (Rust, protoc, mdBook, sqlx-cli, cargo-watch, cargo-audit) are pinned in mise.toml at the workspace root and managed by mise, which also defines the project’s dev tasks (mise tasks to list them). Install mise, then install the pinned tools for this repo:
curl https://mise.run | sh
cd personal-ledger
mise trust
mise install
mise’s shell/dir activation will also install the pinned tools automatically when you cd into the repo. Without this step, tools like protoc or sqlx-cli won’t be on your PATH.
Available Tasks (mise)
Beyond pinning tool versions, mise.toml also defines this project’s dev tasks — a replacement for the cargo-make/Makefile.toml setup this repo used previously. Run mise tasks at any time to list them with their descriptions straight from the config, or mise run <task> to run one:
| Task | What it does |
|---|---|
watch-tui | Rebuild and rerun the TUI on file changes |
docs-rustdoc | Build Rust docs (rustdoc) |
docs-mdbook | Build mdBook documentation |
docs-build | Build all documentation (docs-rustdoc + docs-mdbook) |
docs-serve | Serve mdBook documentation on port 8001 |
db-drop | Drop the dev database |
db-create | Create the dev database |
db-migrate-client | Apply the Client-Ledger migrations |
db-migrate-sync-server | Apply the Sync Server migrations |
db-migrate | Apply both the Client-Ledger and Sync-Server migration sets |
db-prepare-generate | Regenerate the sqlx query cache for lib-database |
db-prepare-check | Check the sqlx query cache is up to date |
db | Reset the dev database from scratch: drop, create, migrate, then refresh the query cache |
mise run watch-tui # rebuild + rerun the TUI on every save
mise run db # drop, recreate, migrate and refresh the local dev database
Building and Running
Most day-to-day work is plain cargo run against the workspace or a specific package:
# Build the whole workspace
cargo build
# Build just the server binary
cargo build --package server --bin server
# Run the server (reads config, initialises telemetry — currently does nothing else)
cargo run --package server
# Run the TUI, rebuilding and rerunning it on every file change
mise run watch-tui
Building lib-rpc requires a system protoc (protobuf compiler), provided via mise.toml. tonic_prost_build regenerates crates/libs/lib-rpc/src/generated/*.rs from the .proto files on every build; the generated files are checked in but should be treated as build output, not hand-edited.
Note:
crates/libs/lib-databaseexists on disk but is not currently a workspace member — it’s mid-development and not yet wired intoserver. Build/test it directly withcargo build --package lib_database, or add it back to[workspace].membersif you’re integrating it.
Testing, Linting and Formatting
# Test the whole workspace
cargo test
# Test a single crate
cargo test --package lib_config
# Lint and format
cargo clippy
cargo fmt
Documentation
Docs are built with mdBook and rustdoc via mise tasks:
mise run docs-build # docs-rustdoc + docs-mdbook
mise run docs-serve # serves mdBook on :8001
Agent-Assisted Development
This repo is set up to work with AI coding agents (like Claude Code). Repository conventions for agents live in CLAUDE.md, and project-specific skills live under .claude/skills/ (rustdoc conventions, tracing/log-level conventions, and unit-test patterns).
The agent development workflow (grilling out an idea, writing a spec, splitting it into tickets, implementing with TDD and code review) follows Matt Pocock’s Claude Code skills — worth a read as a reference for how contributions here are expected to move from idea to shipped code.
Usage
This space will be used to show useful examples of how a project can be used. Additional screenshots, code examples and demos work.
Include any links to more resources.
For more examples, please refer to the Documentation
Roadmap
- Map out the development workflow
- Develop REST API server (backend) minimum viable product
- Develop web server (frontend) minimum viable product
- Develop deployment integration
- Work through feature set:
- Feature 1
- Feature 2
See the Personal Ledger - Roadmap for a full list of proposed features.
Contributing
Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are greatly appreciated.
If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag “enhancement”. Don’t forget to give the project a star! Thanks again!
- Fork the Project
- Create your Feature Branch (
git checkout -b feature/AmazingFeature) - Commit your Changes (
git commit -m 'Add some AmazingFeature') - Push to the Branch (
git push origin feature/AmazingFeature) - Open a Pull Request
License
Distributed under the GPL3.0 License. See LICENSE.txt for more information.
Contact
- Ian Teda - ian@teda.id.au
- Project Link: https://github.com/IanTeda/personal-ledger
References
The following is a list of resources you find helpful and would like to give credit to:
Similar Applications
Below is a list of similar applications that you might prefer to use:
- GNU Cash
- KmyMoney
- Money Manager EX
- Firefly III
- You Need A Budget (YNAB)
- Mint
- Beancount
- Plain Text Accounting
- BudgE
- Budget Zero
- Buckets
Accounts
An account is a place where you hold money or track what you owe. Personal Ledger tracks four kinds: everyday transaction accounts (bank and cash), credit cards, loans, and investments. Each kind has its own fields and workflow because each does something different.
Heads up: investment and loan accounts are in the design phase. For now, this page covers transaction and credit card accounts, which are built. Dedicated screens for loans and investments are still to come.
Terminology
- Account Kind: which type of account it is (Transaction, Credit Card, Loan, or Investment) — decided when you create it and can’t be changed later.
- Balance: the current total in a transaction or credit card account, computed from all its transactions.
- Unit: the currency or security an account is denominated in (AUD, USD, AAPL shares, etc.) — fixed at creation.
Account kinds
Personal Ledger splits accounts into four kinds, each with its own shape and workflow.
Transaction Account
A place to hold everyday spending money — bank accounts and cash. Your Balance is computed from all transactions posted against it. You can record transactions, move money between accounts of the same currency, and track your spending against this account. Cash accounts link to a system placeholder for the institution; bank accounts link to a real bank.
Credit Card Account
Revolving debt. Like a Transaction Account, you can record spending against it and see your Balance. But it also tracks your credit limit and interest rate. Paying down a credit card is a plain transaction from a bank account into it — the same mechanism you’d use for any same-currency transfer.
Loan Account
An amount owed to a lender — a home loan, a car loan, or a personal loan. Unlike a Transaction Account, you don’t record individual transactions against a Loan Account. Instead, you record Repayments (payments toward the loan), which split the amount you paid into principal (reducing what you owe) and interest (the lender’s cut). A Loan Account tracks the original principal, an interest rate history (rates change), and your next due date.
Investment Account
A single holding or position — “10 shares of AAPL” or “0.5 bitcoin”. You don’t record transactions against it. Instead, you record Trades (buys and sells), which move cash from a transaction account and change your quantity held. Each investment account is one position at one broker; if you hold several securities, that’s several investment accounts.
Approach
The typical workflow depends on which account kind you’re working with:
Transaction and Credit Card Accounts:
- Create the account and link it to your financial institution.
- Set a starting balance (or import one from a bank statement).
- Record transactions as they happen, or import them.
- Review and mark them as cleared (seen on a statement) or reconciled (formally checked off).
Loan Accounts (not yet built):
- Create the account and enter the original principal, term, and interest rate history.
- Record Repayments as you pay them down, splitting them into principal and interest.
- Review loan progress and any interest-rate changes.
Investment Accounts (not yet built):
- Create the account for each holding (one per security, one per broker if you hold the same security in multiple places).
- Record Trades as you buy or sell.
- Track your quantity and cost basis.
Related
- Transactions: move money within transaction and credit card accounts.
- Categories: used to classify each part of a transaction.
- Payees: optional on each transaction, tracking who the money went to or came from.
- Tags: extra labels for cross-cutting themes like trips or tax deductions.
- Balance Checks: formal assertions of what an account should total, checked against your recorded balance.
Getting around
Press g then a to jump to Accounts from anywhere. For the full set of keys, see Getting around.
Feature set and requirements
Intended features for Accounts. Ticked means built.
- ACC-001: Create a transaction account (bank or cash) with name, unit, and starting balance
- ACC-002: Create a credit card account with name, unit, credit limit, and interest rate
- ACC-003: List accounts, filter by kind and active status, sort
- ACC-004: Edit an account’s name, type, or active status
- ACC-005: Delete an account
- ACC-006: Move transactions between accounts (same unit only)
- ACC-007: View account balance computed from transactions
- ACC-008: Create a loan account with principal, term, rate history, and due date
- ACC-009: Create an investment account for a single holding
- ACC-010: Record loan repayments (principal and interest splits)
- ACC-011: Record investment trades (buys and sells)
- ACC-012: Dedicated loan account screens and views
- ACC-013: Dedicated investment account screens and views
For developers
Curious how accounts are structured in the codebase, or planning to change them? See the Accounts development documentation.
Bills
Personal Ledger tracks recurring and one-off payment obligations, such as rent, subscriptions, and utilities, as Bills. This allows you to see what’s coming up before it’s due, what’s overdue, and what’s already been paid. This page explains the Bill workflow in Personal Ledger, the list of Bills, Bill Schedule entries, and the Anticipated Bill preview fit together, and how Bills show up inside Budget.
See CONTEXT.md for the one-paragraph canonical definition of each term, and ADR-0019 for why Bill Schedule entries are real, persisted rows linked to a Transaction rather than a lightweight, ledger-independent checklist. This document is where the fuller shape and workflow live, the same way docs/accounts.md covers Account Kinds.
Bills Workflow
Personal Ledger Bills start with adding Bills to the Bill Planner list. The planned list is then used to populate the Bill Schedule over the plan period. Bill Schedule line items are then mirrored and displayed in the associated Account transactions and the budgets known expenses. Individual Bill Schedule line items are marked as paid and updated with the actual amount. The paid Bill is stored historically for future reference and verifying Bill Plan amounts.
Why Bill Plan and Bill Schedule are Separate
A Bill on its own — “Telstra Internet, ~$89, due the 3rd of every month” — is a definition, not an individual planned Bill or record of anything that’s happened. What you actually pay, and when, is a series of separate dated events: January’s payment, February’s, March’s, each potentially a different amount, each with its own paid/unpaid status. As such, Personal Ledger keeps these as two things:
- Bill Plan — the recurring definition: what it’s for, roughly how much, which Account pays it, how often it recurs.
- Bill Schedule — one row per due date, generated ahead of time for the plan period from the Bill’s recurrence. A Monthly Bill has a new Bill Schedule entry every month. A One-shot Bill has exactly one schedule line item.
Similarly, this mirrors how a Loan Account’s interest rate is tracked as a history of dated entries rather than a single field rewritten in place, or how an Account is the umbrella and Account Kind is which specific shape it takes — one definition, many dated instances, each able to carry its own status and history.
Bill Plan
A Bill is added to the Plan once and edited occasionally. It’s what you fill in when you set up “I pay this.”
| – Column 1 – | – Column 2 – |
|---|---|
| Field | Meaning |
| —–– | ——— |
| Name | A human-readable label (e.g. “Telstra Internet”). |
| Category | Exactly one, Expense-type only — the same restriction Budget already enforces, since a Bill is always an outgoing cost. |
| Unit | Fixed at creation, the currency the Bill is paid in. |
| Account | Mandatory — which Transaction/Credit Card Account this Bill is expected to be paid from. This is what lets the Anticipated Bill preview (see below) know which Account’s list to appear in. |
| Payee | Optional — who the money goes to. |
| Planned Amount | An estimate of what’s owed each time — see Fixed/Estimated below for how strictly it’s treated. |
| Fixed or Estimated | Fixed for a Bill that’s always the same (a Netflix subscription); Estimated for one that varies (an electricity bill) — the Amount is then a planning figure, not a promise. |
| Recurrence | Weekly, Fortnightly, Monthly (on a fixed day of the month), Quarterly, Annually, or One-shoot for a single, non-repeating due date. |
| Ends On | Optional, and only meaningful for a recurring Bill. Leave unset for a Bill that recurs indefinitely; set it when you know a Bill has a natural end (e.g. a 12-month contract). |
| Attention Lead | Optional day count, unset by default (meaning Overdue-only). When set, a Bill Schedule entry that’s Due (not yet Overdue) but within this many days of its due date also shows up in Needs Attention — see docs/needs-attention.md. Set per Bill, since how much early notice is useful varies (rent versus a small subscription). |
| Active | Soft-delete, the same pattern as Account/Category/Payee/Tag — deactivating a Bill stops new Bill Schedule entries from being generated but leaves its history untouched. |
Bill Schedule
Each Bill Schedule entry is given a unique UUIDv7 and number prefixed with the bill plan period name. It moves through a status as its due date approaches and passes:
| Status | Meaning |
|---|---|
| Upcoming | The due date falls in a future calendar month. |
| Due | The due date falls within the current calendar month, from the 1st up to and including the due day itself. |
| Overdue | The due day has passed this month and it’s still unpaid. |
| Paid | Linked to the Transaction that settled it. |
| Skipped | Deliberately not paid — a recurring cycle you chose to skip, or a One-shoot Bill you cancelled. |
An Overdue entry doesn’t reset when the month rolls over — it just stays Overdue until you either pay or Skip it. Next month’s due date is a wholly separate Bill Schedule entry, starting its own Upcoming/Due cycle independently.
Marking one as Paid
A Bill Schedule entry only ever becomes Paid by linking to a real Transaction — never a plain “mark as paid” toggle with nothing behind it. There are two ways to get there:
- Pay it directly — Personal Ledger creates a new Transaction for you, seeded from the Bill’s Category, Payee, Account, and Amount, and links it to this Bill Schedule entry.
- Merge or Link an existing Transaction — if you’d already entered the payment separately (or it came in through a CSV/Balance Check import), you point that Transaction at this Bill Schedule entry instead of creating a duplicate.
Either way, once linked, the Transaction’s own date and amount are what count — the Bill’s Amount was only ever an estimate to plan against.
Wherever that Transaction shows up afterwards (its Account’s list, reports), it’s visually marked as carrying a Bill, so you can tell at a glance it’s a recurring obligation rather than a one-off purchase. This is unrelated to the ordinary Flagged marker you can put on any Transaction for your own follow-up — a Transaction can carry a Bill, be Flagged, or both, independently.
Bill History
For each Bill, Personal Ledger shows the last amount paid, an average over time, and the same period last year — computed from Paid entries’ actual Transaction amounts only. A Skipped entry doesn’t count as a $0 payment; it’s left out of the average entirely, since skipping a bill one cycle isn’t the same as it costing nothing.
No Bill Schedule entry is ever deleted, regardless of what happens to its Bill afterwards — deactivating a Bill, or it reaching its Ends On date, only stops future entries from being generated; every entry already generated stays exactly as it was, whether Paid, Skipped, or left Overdue. This makes Bill Schedule itself the historical record: a full, permanent list of what was due, when, and what happened to it.
That list can be filtered when reviewing history by any combination of:
- Status — e.g. only Paid, or only Skipped, to see what you’ve actually let lapse over time.
- Bill — every past entry for one specific Bill, the raw data behind its “last paid / average / same time last year” figures (see above).
- Category, Payee, or Account — the same filters already available elsewhere in Personal Ledger (e.g. Budget’s own Category/active-status filtering), so Bill history reviews the same way the rest of the Ledger does.
- Date range — e.g. everything due last financial year.
Anticipated Bill — seeing what’s coming, inline
Before a Bill Schedule entry is paid, its next due date shows up as a shaded preview row directly inside its target Account’s transaction list — sitting where the real payment will eventually land, so you can see it coming while reviewing your Account like any other line, without needing to check a separate screen. It isn’t a real Transaction: it disappears the moment the Bill Schedule entry resolves to Paid, replaced by the one real, linked Transaction — the two never sit in the list side by side.
Known Costs (Bills) in Budget
Bill and Budget stay independent entities — a Bill isn’t attached to any particular Budget — but a Budget’s progress view adds a Known Costs (Bills) figure alongside its actual spend, summing every unpaid (Due or Overdue) Bill Schedule entry that matches the Budget’s Category and Unit within its current period.
Example — a “$300/month for Utilities” Budget, with a Telstra Internet Bill ($89, due the 3rd) and an Electricity Bill ($150 estimated, due the 20th), viewed on the 10th of the month:
| – Column 1 – | – Column 2 – | – Column 3 – |
|---|---|---|
| Description | Amount | Note |
| Spent so far | $0.00 | No Utilities Transactions yet this period |
| Known Costs (Bills) | $239.00 | $89.00 Overdue (Telstra, due the 3rd, unpaid) + $150.00 Due (Electricity, due the 20th) |
| Remaining after known costs | $61.00 | $300 limit − $239 known costs |
An Overdue entry stays inside the Known Costs total — it’s still unpaid, committed spending — but is visually distinguished from a merely-Due one, so you can tell “coming up” apart from “already late.” Once a Bill Schedule entry is Paid, its linked Transaction’s actual amount moves into the Budget’s ordinary spend figure instead, so it’s never counted in both places at once.
Reminders
Overdue Bill Schedule entries — and Due ones within a Bill’s own Attention Lead, see above — feed into Needs Attention, the one cross-Ledger list of everything wanting action, surfaced on each Client’s own Dashboard (TUI and Desktop) whenever you have it open. See docs/needs-attention.md for the fuller picture, including how Bills sit alongside Flagged and unreconciled Transactions there. A merely Upcoming or plain-Due (outside its Attention Lead) Bill Schedule entry isn’t part of Needs Attention — it’s still visible in Bill Planner and Budget’s Known Costs (Bills) as usual, just not flagged as needing action yet.
What’s deliberately out of scope here
This document captures the shape agreed so far — it is not a functional spec, and nothing here has been built yet (no migrations, tables, or screens exist for Bill or Bill Schedule). Left for later, once that work is actually scoped:
- OS-level push notifications or a background reminder service — a Future Consideration beyond the in-app Dashboard surfacing described above.
- Relative-to-last-payment recurrence (e.g. “30 days after it was last paid”) — V1 supports fixed calendar recurrence only.
- Any dedicated Bill Planner screen layout for the TUI or Desktop app.
- The exact cadence/trigger for the process that keeps future Bill Schedule entries populated — ADR-0019 records why entries must be materialised ahead of time, not when that process runs.
A planned Bill can be merged with an actual transaction, linking and marking the bill as paid
Categories
Categories are how you answer the question “where does my money actually go?” Every transaction gets a category, like Groceries, Rent or Salary, and Personal Ledger uses them to add things up for you. This page explains how they work and how to look after them.
Heads up: the Categories screen is still being built, so a few things here describe where it’s headed rather than what’s in your copy of the app today.
What a category is
A category is just a label for a kind of spending or a kind of income. You make up the names, so use whatever makes sense to you. “Groceries”, “Dining out” and “Pets” are all fine.
Each part of a transaction gets one category. If you split a shopping trip between groceries and household bits, each part gets its own category. Payees (who you paid) and tags (extra labels like “Japan trip”) are separate things, and you can use them alongside categories.
Expense or income
Every category is one of two types:
- Expense: money going out, like Rent, Groceries or Electricity.
- Income: money coming in, like Salary or Interest.
You pick the type when you create a category. It’s worth getting right at the start, because changing it later, once you have transactions, can muddy your history.
Categories inside categories
Categories can sit inside other categories, up to three levels deep. That lets you keep the big picture tidy while still being specific when you want to be. For example:
- Housing
- Rent
- Utilities
- Electricity
- Water
- Food
- Groceries
- Dining
A few things to know about how this works:
- The big ones add up the small ones. Housing shows everything spent on Rent, Electricity and Water combined, so you can see the total without doing any sums. These are marked with a small “rollup” tag.
- You only file transactions under the bottom level. You’d put your electricity bill under Electricity, not under Housing. Housing then picks it up automatically.
- A subcategory follows its parent’s type. Put something under Housing and it becomes an expense too.
Looking at your categories
The Categories screen lists everything as a tree, with expenses in one section and income in another. Next to each category you’ll see:
- Budget: the monthly limit you’ve set, or “no budget” if you haven’t set one.
- Spent this month (or Received this month for income): how much has gone through it so far.
If you’ve set a budget, a little bar fills up as you spend. If you go over, the bar and the number turn red, so it’s easy to spot which category got away from you.
Adding a category
Press n (or choose Add category) and fill in:
- Name: what you want to call it.
- Type: expense or income.
- Parent category (optional): pick one if this should sit inside another category. Leave it as “none, top level” if not.
- Monthly budget (optional): a limit for the month. Most people leave this blank and just watch what they spend, and that’s fine.
Changing a category
Press e on a category to edit it. You can rename it, move it under a different parent, or change its budget.
Renaming is safe, and your existing transactions just show the new name. Moving a category around or changing its type after you’ve recorded transactions is allowed, but think twice first, because it changes how your past spending adds up. If transactions already use the category, the dialog will tell you how many.
Removing a category
Press d to delete a category. Because this can’t be undone, you’ll be asked to type the category’s name to confirm.
Your transactions are never deleted along with a category. The confirmation tells you how many transactions use it and whether any budgets refer to it, so you know what you’re affecting before you go ahead.
If you just want a category out of your way but want to keep its history, look for an option to hide (archive) it instead, if your version has one. That keeps every transaction and total exactly as they were.
Tips for a category list you’ll actually keep using
- Start small. A dozen categories is plenty. You can always split one into two later.
- Prefer broad over fussy. If you’d never look at the difference between “Coffee” and “Cafes”, make them one category.
- Use tags for the odd ones out. A holiday or a home renovation cuts across lots of categories, and a tag lets you total it up without messing with your category list.
- Set a budget where it matters. You don’t need one on everything, and a few on the big spenders is a great start.
Getting around
If you’d like to jump straight to your categories from anywhere, press g then c. For the full set of keys, see Getting around.
Getting around
Personal Ledger is built so you can do nearly everything without touching the mouse. This page shows you the few keys worth learning. There are only a handful, and they work the same way in the terminal app and the desktop app, so if you use both, you won’t have to relearn anything.
The big idea
Every single thing you can do in Personal Ledger has a name, and you can always reach it by typing that name. Keyboard shortcuts are just faster ways to get there. So if you forget a shortcut, you’re never stuck: open the command box (below), type what you want, and go.
Three kinds of keys
Almost everything you’ll do comes down to three ideas.
1. Jump somewhere with g
Press g, let go, then press a letter to leap straight to a screen. Think of it as “go to…”. So g then a takes you to Accounts, and g then d takes you to the Dashboard.
If you press g and then a letter that doesn’t go anywhere, nothing happens and you’re back where you started, so it’s safe to poke around. In the desktop app, the g also gives up on its own if you wait about a second.
Here are the jump letters for each app:
| Go to | Terminal app | Desktop app |
|---|---|---|
| Accounts | g a | g a |
| Budgets | g b | g b |
| Categories | g c | g c |
| Dashboard | g d | g d |
| Payees | g p | g p |
| Reports | g r | g r |
| Settings | g s | g s |
| Tags | g g | g t |
| Transactions | g t | g l |
| Units | g u | not in the desktop app yet |
| Balance checks | g k | not in the desktop app yet |
| Bills | not in the terminal app yet | g w |
The two apps don’t share every screen yet, and Tags and Transactions use different letters in each one. We’d like them to match eventually.
2. Do anything by name with :
Press : (a colon) and a command box opens. Start typing what you want to do, like accounts new, and it suggests matches as you go. Press Enter to run it.
This is your safety net. If you can’t remember a shortcut, or you’re not sure where something lives, : will find it.
Some commands are listed but aren’t finished yet. If you run one, Personal Ledger tells you it’s “not yet built” rather than quietly doing nothing.
3. Find things with /
Press / to start searching or filtering. In the terminal app this works on the Accounts, Payees and Tags screens. In the desktop app, the search box on the Transactions screen is the place to look, and more of it is on the way.
Always know where you are
Personal Ledger is modal, which is a fancy way of saying the same key can mean different things depending on what you’re doing. That sounds scarier than it is, because the status line at the bottom always tells you what’s going on. There are four situations:
- Normal: the resting state, where keys are shortcuts. You won’t see a label for this one.
- Insert: you’re typing into a form, so your keys become text.
- Command: the
:box is open. - Search: you’re typing a search or filter.
Ever unsure what a key will do? Glance at the bottom of the screen first.
A few keys worth remembering
| Key | What it does |
|---|---|
? | Shows help for the screen you’re on |
Esc | Goes back, or closes whatever’s open |
: | Opens the command box |
/ | Starts a search or filter |
g then a letter | Jumps to a screen |
Each screen also has its own keys for things like adding, editing or deleting an item, and the help screen (?) lists them for wherever you are.
Quitting
In the terminal app there are three ways out, and they all work:
qquits politely.:quitdoes the same, by name.Ctrl+Cquits straight away, which is handy if something ever seems stuck.
In the desktop app, you close the window like any other app.
Changing the keys
If you use the terminal app, you can change the keys for going back, opening help and opening the command box in your config file. See Settings for where that file lives.
The desktop app doesn’t have this yet. The other keys, such as g for jumping and q for quitting, are fixed for now.
For developers
Curious how the keys are built, or planning to change them? See Navigation and keyboard grammar: design.
Localisation
Personal Ledger can show its screens, and format your numbers, dates and money, the way people in your part of the world expect. This page explains what you get, how it picks your region, and how to change it.
Heads up: this is still being built. This page describes how localisation will work once it lands, so a few things here won’t be in your copy of the app yet.
What you get
Your locale is your language plus your region, for example en-AU for English as spoken in Australia. Personal Ledger starts with three:
en-US: American English (color, ZIP code).en-GB: British English (colour, postcode).en-AU: Australian English.
Your locale changes two things, and they always match each other:
- The wording and spelling on the screens, in both the desktop app and the terminal app.
- How numbers, dates and money are written. Dates come out day-first in Australia and the UK and month-first in the US. Amounts use your region’s separators and put the currency symbol where you’d expect it. An Australian dollar account shown to an American reader might read
A$1,234.56, so it can’t be mistaken for US dollars.
Typing dates
When you type a date, use whichever order is normal where you live, so 3/9/26 means the 3rd of September in Australia and the 9th of March in the US. Two things always work, whatever your locale:
- ISO dates, written year first:
2026-09-03. They can’t be misread, so they’re a good habit if you move between regions. - Plain words:
today,yesterdayandtomorrow.
Which locale do I get?
Personal Ledger looks at your computer’s own language and region settings and uses those. If your computer is set to something Personal Ledger doesn’t have yet (say, French or New Zealand English), you’ll get American English, which is the fallback for everything.
Changing it
You change your locale in the config file, then restart Personal Ledger. There’s no switch inside the app on purpose: your locale belongs to the computer you’re using, not to your ledger, so a laptop and a desktop can use different ones without fighting each other over a synced setting.
The simplest way is to add a locale line to your config file (see Settings for where that lives):
[Personal-Ledger]
locale = en-GB
You can also set it for a single run, which is handy for trying things out:
personal_ledger --locale en-GB
Or with an environment variable:
PERSONAL_LEDGER_PERSONAL_LEDGER__LOCALE=en-GB
If you set it more than one way, the most specific wins: the command-line flag beats the environment variable, which beats the config file, which beats your computer’s settings. If you make a typo (en_GB!!), Personal Ledger stops at startup and tells you, rather than quietly picking something else.
The Display page in Settings shows which locale is in use and where it came from (your system, or the config), so you can see at a glance why you’re getting what you’re getting. It just shows it: you change it with the steps above.
Your date style
Separately, Settings → Display lets you pick how dates look: short, medium, long, or ISO. Leave it alone and it follows your locale. Pick ISO and dates always show as 2026-09-03, whatever your locale says. This one changes straight away, and it syncs across your devices like your other display choices.
Good to know
- Your own words stay as you typed them. Account, payee, category and tag names are your data, so they’re never translated or changed.
- Commands stay in English. The palette commands you type (like
accounts new) are the same in every locale, though the descriptions next to them follow your locale. - Only English for now. The three locales above are the ones planned. More languages aren’t on the list yet.
- Spotted something that looks wrong or hasn’t been translated? Please open an issue and say which screen and which locale.
For the curious
There’s also a hidden fourth locale, en-XA, that isn’t offered to anyone in Settings. It’s a “pseudo” locale that squiggles every letter with accents and stretches the text, which makes it easy to spot any wording that hasn’t been converted yet and any screen where longer text won’t fit. You can turn it on with --locale en-XA if you want to see what it does.
For developers
Want to know how it all works underneath, or how to add a screen’s text? See the Localisation design.
Payees
A payee is the person or business your money went to, or came from. Woolworths, your landlord, your employer: they’re all payees. Personal Ledger keeps track of them so you can see who your money is going to.
Heads up: this is still being built. The dedicated Payees screen isn’t designed yet, so for now this page covers where payees show up in the Transactions screen, which is where you’ll meet them day to day.
You don’t have to set them up
You almost never create a payee yourself. The first time you type a new name on a transaction, Personal Ledger creates the payee for you. After that, it recognises the name, so you don’t end up with five spellings of the same shop. Matching ignores capital letters, so “woolworths” and “Woolworths” are the same payee.
A payee is optional. Plenty of things, like a cash withdrawal, don’t really have one.
Seeing payees on your transactions
Open Transactions and you’ll find the payee in its own column, right in the middle of each row. The row you’ve selected shows its payee in bold, so it’s easy to spot.
If you split a transaction across several categories, each part can have its own payee. In that case the row shows the first payee, with a “+2” style note when other, different payees are involved.
Finding transactions by payee
There are two quick ways to narrow your transactions down to a payee:
- Search: press
/and start typing. The list narrows as you type, matching payee names and descriptions. - Filter: press
fto open the filter box, then type into the Payee field. A “payee” chip appears at the top of the screen to show the filter is on. Click its✕to clear just that one, or use clear filters to clear them all.
The totals at the bottom of the screen update to match what you’re looking at, so filtering by a payee is also a quick way to see how much you’ve spent with them. Just keep in mind that it only covers the date range you’ve chosen, which starts as “this year”.
Payees, categories and tags
These three do different jobs, and they work best together:
- Payee: who the money went to or came from.
- Category: what kind of spending or income it was.
- Tag: an extra label for anything that cuts across the other two, like a holiday.
See Categories and Tags for more.
Getting around
Press g then p to jump to Payees from anywhere. For the full set of keys, see Getting around.
Setting up your ledger
This page walks you through the first thing you do with Personal Ledger: turning an empty ledger into one that is ready for real transactions. It explains what to expect at each step, what you can change later and what you can’t, and how to know when you’re done. Plan on 20 to 30 minutes if you have your bank details to hand.
Heads up: Personal Ledger is still being built, and there is no guided setup wizard yet. This page describes the setup order the app is designed around, using the screens that exist today. Where a step describes something still on its way, it says so.
What to expect
Setting up comes down to seven steps, in this order:
- Name your ledger.
- Choose your units.
- Add your institutions.
- Add your accounts, each with an opening balance.
- Set up your categories.
- Bring in your recent history.
- Check your balances against your statements.
The order matters because each step feeds the next. An account needs a unit and an institution before you can create it, and a transaction needs an account and a category before you can enter it. If you follow the list top to bottom, the app never asks you for something you haven’t set up yet.
You can stop after any step and pick up later. Nothing is lost, and you can start entering transactions as soon as you have one account and a few categories.
If you’re coming from another program
If you’ve used KMyMoney, GnuCash or Microsoft Money, most of this will feel familiar. Those programs all open with a short setup: pick a currency, add your accounts with an opening balance, and choose a starting set of categories. Personal Ledger asks for the same things, with a few differences worth knowing:
- No accounting jargon. GnuCash starts you with a tree of assets, liabilities, equity, income and expenses, and gives your opening balances an “Opening Balances” equity account to balance against. Personal Ledger uses single-entry bookkeeping, so an account simply starts with a balance and you never deal with a balancing account.
- Institutions are their own step. Like Microsoft Money, Personal Ledger knows which bank or broker each account lives with. Unlike Money, it does not link to your bank or download transactions, so your data stays on your own machines.
- Units instead of a single currency. KMyMoney and Money ask for one base currency up front. Personal Ledger asks for a default unit, which can be a currency, but also a share ticker or a cryptocurrency, and each account has exactly one.
- Categories start nearly empty. Instead of choosing a large template (GnuCash’s “Common Accounts”, Money’s Home or Business lists), you start with just Income and Expenses and grow the list as you go.
Step 1: Name your ledger
Your ledger is everything you track in one place: your accounts, institutions, categories and transactions. Open Settings (press g then s) and choose General. Fill in:
- Ledger name: anything that helps you recognise it, such as “Household”.
- Owner: your name.
- Financial year starts: the month your financial year begins. In Australia this is normally July. Reports use it to group your figures.
- Base unit: the unit most of your money is held in, such as AUD.
All of these can be changed later.
Step 2: Choose your units
A unit is what an account’s amount is measured in: dollars, shares, bitcoin. Every account has exactly one unit, and it can’t be changed once the account exists, so it pays to get this right.
Open Settings, then Units. Check that your everyday currency is in the list and add anything else you need, such as a share ticker for a brokerage holding. Then mark your everyday currency as the default, so new accounts start with it already chosen.
If all your money is in one currency, you’ll only ever need this step once.
Step 3: Add your institutions
An institution is the bank, lender or broker an account is held with, such as “Commonwealth Bank” or “Vanguard”. Every account needs one.
Open Settings, then Institutions, and add the ones you use. You don’t need to add them all now. You can always add another when you create an account.
Physical cash has no bank, so you don’t need to add anything for it. Personal Ledger includes a built-in “No institution” entry that cash accounts use.
Step 4: Add your accounts
Open Accounts (press g then a) and choose Add account. Each account asks for:
- Name: what you’ll call it, such as “Everyday Account”.
- Kind: what sort of account it is (see below).
- Institution and unit: picked from the lists you built in steps 2 and 3.
- Opening balance: how much the account held the moment before your first transaction.
- Account number: optional.
The four kinds of account
- Transaction account: where everyday money sits, either a bank account or cash. You enter transactions directly against it.
- Credit card account: revolving debt with a credit limit. You enter spending against it like any other account, and pay it off by moving money in from a transaction account.
- Loan account: money you owe a lender, such as a mortgage. You don’t enter transactions against it. You record repayments, and the amount owing goes down.
- Investment account: a single holding, such as 10 shares of a fund. Its quantity changes only when you record a trade.
See Account Kinds for the full picture.
Choosing an opening balance
Pick one start date for your ledger and use it for every account. The first day of a month or of your financial year works well, because statements line up with it. Then, for each account, take the balance from your statement on that date. Money owed on a credit card or loan counts as a negative amount.
Two things are fixed once the account exists: its unit and its opening balance. You can rename an account at any time, but if you get an opening balance wrong, the fix is to delete the account and add it again. It’s much easier to check it before you save.
Step 5: Set up your categories
Categories answer “where does my money go?”. Open Categories (press g then c). You’ll find two starting points already there, Income and Expenses, which can’t be removed. Everything else you add sits under one of them.
Don’t try to design the perfect list. A dozen categories is enough to begin with, for example Salary and Interest under Income, and Groceries, Rent, Utilities, Transport, Dining out and Health under Expenses. You can add more the first time a transaction doesn’t fit, and you can nest them, so Utilities can later hold Electricity and Water.
See Categories for how nesting and types work.
Step 6: Bring in your recent history
Decide how much history you want, then enter it.
- Start fresh (recommended). Begin at the opening balance date from step 4 and enter transactions from there. This is the quickest way to a ledger you trust.
- Back-fill a few months. Set your opening balance date earlier, then enter the transactions since. Only do this if you want the older figures in your reports.
Open Transactions and add each one with its date, amount, account and category. You can add a payee (who you paid, or who paid you) and tags too. Neither needs setting up beforehand: a payee you haven’t used before is created the first time you type it. See Payees and Tags.
Heads up: importing transactions from a bank file, such as CSV, QIF or OFX, is not available yet. When CSV import arrives, it will bring in only balance checks (a date and a balance). Transactions are entered by hand for now.
Step 7: Check your balances
Once your transactions are in, compare each account with its latest bank or card statement. If the balance in Personal Ledger matches, the account is set up correctly.
If it doesn’t, the difference is usually one of these:
- A wrong opening balance.
- A transaction that’s missing, entered twice or dated on the wrong side of the cut-off.
- A transaction that hasn’t cleared the bank yet.
Once an account matches, you can record a balance check: a note of what the balance should be on a given date, taken from your statement. Personal Ledger compares it with the balance it works out from your transactions and tells you if they disagree. It’s a good habit to repeat each time a statement arrives.
Optional: sync and back up
If you use Personal Ledger on more than one computer, you can connect them through your own sync server. See Deployment for how to run one, and Settings, then Sync server, to connect. Setting up sync is best done after your ledger is in good shape, so you copy a tidy ledger rather than a half-finished one.
Settings, then Data & backup is where your backup options live. Take a backup once your opening balances are in, so you always have a clean starting point to return to.
Setup checklist
You’re ready for everyday use when:
- Your ledger has a name, owner, financial year and base unit.
- Your units are set, with a default chosen.
- Your institutions are added.
- Every account you use is added, with the right kind, unit and opening balance.
- You have a starting set of categories.
- Your transactions are entered from your opening balance date.
- Each account matches its latest statement.
From here, see Getting around for the keyboard shortcuts that make daily entry quick.
Settings
Personal Ledger has two types of configurations: static and dynamic. Static configurations are needed to bootstrap or start the application, are not intended to change often, or are system-space or user-space configurations applied across different ledger files. Static configurations live in configuration files. Dynamic configurations aren’t needed to bootstrap the application, change often, and can be set and updated within the application itself.
Static Configuration Files
Personal Ledger looks for static configuration files in multiple locations. It uses a layered configuration approach with a defined precedence hierarchy to support different deployment scenarios. The next sections describe that hierarchy in detail.
Configuration Hierarchy
Personal Ledger looks for and loads configuration files in an order of precedence. If it finds a configuration file in a location, it parses it and uses the settings it finds. If it finds a subsequent file with higher precedence and a conflicting setting, it will supersede that setting with the one from the higher-precedence configuration file.
Personal Ledger uses the following order of precedence, from lowest to highest.
- Built-in Defaults (lowest precedence).
- System Configuration
- Platform-specific system-wide config directory
- Linux: /etc/personal-ledger/personal-ledger.conf
- macOS: /Library/Preferences/personal-ledger/personal-ledger.conf
- Windows: %ALLUSERSPROFILE%\personal-ledger\personal-ledger.conf
- Executable Directory
- Configuration file in the same directory as the binary
- Useful for portable applications
- Current Working Directory
- File: config/personal-ledger.conf
- Allows project-specific overrides when running from a directory
- User Configuration
- Platform-specific user config directory
- Linux/macOS: ~/.config/personal-ledger/personal-ledger.conf
- Windows: %APPDATA%\personal-ledger\personal-ledger.conf
- Explicit Configuration File
- The file pointed to by –config/-c, if given (still layered in below Environment Variables, same as every other file source above)
- Environment Variables
- Prefix: PERSONAL_LEDGER_
- Example: PERSONAL_LEDGER_PERSONAL_LEDGER__LOG=debug
- Example: PERSONAL_LEDGER_SYNC_SERVER__DATABASE_URI=sqlite:/tmp/test.db
- Use double underscores (__) to separate nested keys
- Explicit Command Line Flags (highest precedence)
- –data/-d, –file/-f, –log/-l, –locale – override the
[Personal-Ledger]section’s corresponding setting directly, applied after every source above (including Environment Variables)
INI Configuration Format
Personal Ledger uses the INI (Initialisation) file format for static configuration files. INI is a simple, human-readable format consisting of sections, keys, and values.
Basic Syntax
- Sections: Enclosed in square brackets [], e.g., [Personal-Ledger]
- Keys and Values: key = value, e.g., Tracing_Level = “debug”
- Comments: Lines starting with # or ; are comments.
- Case Sensitivity: Section names are case-insensitive (e.g., [Personal-Ledger] and [personal-ledger] are equivalent)
Example Structure
This is a comment
[SectionName] key1 = “string value” key2 = 42 key3 = true
Rules
- Section names should be descriptive and contain only alphanumeric characters, underscores, and hyphens.
- Keys should use lowercase with underscores (snake_case)
- String values should be quoted when they contain spaces or special characters.
- Boolean values: true or false
- Numeric values: integers or floats as appropriate.
Section Names
Configuration sections group related settings together. The application currently supports:
- [Personal-Ledger]: Bootstrap and startup configurations.
- [Keybindings]: Navigation and action keybindings.
- [Sync-Server]: Sync Server-specific settings (bind address). Only bin-sync-server uses the configuration settings. Both bin-tui and bin-desktop Sync Server settings are dynamic and thus use settings pulled from the database.
Personal Ledger Section
The [Personal-Ledger] section includes settings applied across Personal Ledger files and the client on a given system, but they are not intended to sync across computer systems.
config:
Points to a configuration directory not in the predefined hierarchy discussed above
- Type: String (Optional)
- CLI Flag: –config/-c
- Default: Refer to above ‘Configuration Hierarchy’
Example:
[Personal-Ledger]
config = "~/.config/personal_ledger.conf"
personal_ledger --config/-c '~/.config/personal_ledger.conf'
data:
Use a specific directory as the default location for Personal Ledger database files.
- Type: String
- CLI Flags: –data/-d
- Default:
- Defaults to the users
documentfolder
- Defaults to the users
Example:
[Personal-Ledger]
data = "~/Documents/Personal-Ledger"
personal_ledger --data/-d '~/Documents/Personal-Ledger/'
file:
Open to a specific Personal Ledger database file if no file is provided.
- Type: String (Optional)
- CLI Flags: –file/-f
- Default:
/My-Personal-Ledger.pldb
Example:
[Personal-Ledger]
file = "~/Documents/My-Personal-Ledger.pldb"
personal_ledger --file/-f '~/Documents/My-Personal-Ledger.pldb'
log:
Controls the verbosity of logging output.
- Type: String
- CLI Flags: –log/-l
- Valid Values:
- “trace”: Most verbose, includes all internal debugging information
- “debug”: Detailed debugging information
- “info”: General information messages (default)
- “warn”: Warning messages only
- “error”: Error messages only
- “off”: No logging output
- Default: “info”
Example:
[Personal-Ledger]
log = "debug"
personal_ledger --log/-l 'debug'
locale:
The Locale the Client uses for its text, numbers, dates and currency amounts, as a BCP-47 language tag. Per Client and never synced; changing it needs a restart. lib-config only resolves the request and reports where it came from – lib-locale decides which Locale is actually used, so a well-formed tag it doesn’t support (for example fr-FR) is passed through unchanged and negotiated there.
- Type: String (Optional)
- CLI Flag: –locale (long form only)
- Environment Variable: PERSONAL_LEDGER_PERSONAL_LEDGER__LOCALE
- Valid Values: any well-formed BCP-47 tag, stored in canonical casing (
en-gbbecomesen-GB). A malformed value is a startup error. There is nosystemkeyword: leave the key unset to follow the operating system. - Default, in order:
- The value from the configuration files, environment variable or
--locale - The operating system’s Locale, when it is a usable tag (
CandPOSIXare ignored) - “en-US”
- The value from the configuration files, environment variable or
Example:
[Personal-Ledger]
locale = "en-GB"
personal_ledger --locale 'en-GB'
The Sync Server ignores this setting.
log_file_path:
Optional path to a file that log output should also be written to, in addition to the console. Parent directories are created if they don’t already exist.
- Type: String (Optional)
- Default: unset (console output only)
Example:
[Personal-Ledger]
log_file_path = "/var/log/personal-ledger/personal-ledger.log"
Keybindings Section
The [Keybindings] section includes settings that define the keyboard navigation keys and key combinations. Refer to [Navigation#keyboard] for Personal Ledgers’ philosophy and approach to navigation and keyboard navigation specifically.
- goto_dashboard = g d
- goto_transactions = g x
- goto_account = g a
- goto_categories = g c
- goto_payees = g p
- goto_tags = g t
- goto_bills = g w
- goto_budget = g b
- goto_reports = g r
- goto_settings = g s
Example:
[Keybindings]
goto_leader = "g'"
goto_dashboard = "d"
goto_transactions = "x"
goto_account = "a"
goto_categories = "c"
goto_payees = "p"
goto_tags = "t"
goto_bills = "w"
goto_budget = "b"
goto_reports = "r"
goto_settings = "s"
Sync Server Section
TODO: Update when starting to work on sync server
bin-sync-server uses a reduced precedence chain instead (ADR-0014): Environment Variables → Explicit Configuration File (–config/-c) → Built-in Defaults. The Current Working Directory/Executable Directory/User/System tiers don’t apply – they don’t correspond to anything meaningful inside a Docker container, the Sync Server’s only deployment target.
The [Sync-Server] section currently has two settings:
bind_address
The socket address the Sync Server’s gRPC/HTTP listener binds to.
- Type: String
- Default: “0.0.0.0:50051”
database_uri
The URI of the Sync Server’s own database (its durable Change Set log, per ADR-0009) — not a Client’s local [Personal-Ledger] file; the Sync Server’s database is server-side storage, unrelated to any Client’s local Ledger file. Connection-pool settings (max/min connections, timeouts) are fixed in code, not configurable.
- Type: String
- Default: “sqlite:./sync-server.sqlite”
Example:
[Sync-Server]
bind_address = "0.0.0.0:50051"
database_uri = "sqlite:./sync-server.sqlite"
- Hardcoded default values in the application code
Higher precedence sources override lower precedence ones. For example, an environment variable will override any configuration file setting.
Example Configuration File
# Personal Ledger Configuration File
#
# This file contains configuration settings for the Personal Ledger.
# It uses INI format with sections and key-value pairs.
#
# Section names are case-insensitive (e.g., [Personal-Ledger] or [personal-ledger] both work).
# Values should be quoted strings where appropriate.
#
# For more information, see the documentation at docs/settings.md
# Settings applied across Personal Ledger files and the client on a given system, but
# they are not intended to sync across computer systems.
[Personal-Ledger]
# Static configuration file (this file)
config = "~/.config/personal_ledger.conf"
# Client data directory
data = "~/Documents/Personal-Ledger"
# Personal Ledger database file to open with
file = "~/Documents/My-Personal-Ledger.pldb"
# Client tracing log level
log = "debug"
# Optional: also write log output to this file, in addition to the console
# log_file_path = "/var/log/personal-ledger/personal-ledger.log"
# Keyboard navigation keys and key combinations.
[Keybindings]
goto_leader = "g'
goto_dashboard = "d"
goto_transactions = "x"
goto_account = "a"
goto_categories = "c"
goto_payees = "p"
goto_tags = "t"
goto_bills = "w"
goto_budget = "b"
goto_reports = "r"
goto_settings = "s"
# Only read by bin-sync-server -- bin-tui/bin-desktop ignore this section.
[Sync-Server]
bind_address = "0.0.0.0:50051"
database_uri = "sqlite:./sync-server.sqlite"
References
Per ADR-0014, Configuration covers only settings needed before the app (or its database) can run: the database location (a Client’s [Personal-Ledger] file, or the Sync Server’s own [Sync-Server] database_uri) and the telemetry level; connection-pool tuning (max/min connections, timeouts) is fixed in code rather than configurable. The gRPC bind address is Sync-Server-only. Everything else the user might tweak from inside a running Client — colour theme, date format, decimal/thousands separator, default Unit — is a Preference instead, stored in the Ledger’s own database, not here.
lib-config is shared by all three consumers (bin-tui, bin-desktop, bin-sync-server), but they don’t all see the same sections or search the same locations — see Configuration Hierarchy and Sync Server Section below.
Tags
Tags are little labels you stick on transactions, for the things that don’t fit neatly into a category. Think “Japan trip”, “tax deductible” or “shared”. They let you find and add up spending across your whole ledger for a particular theme.
Heads up: this is still being built. The dedicated Tags screen isn’t designed yet, so for now this page covers where tags show up in the Transactions screen, which is where you’ll meet them day to day.
Why tags, when you have categories?
Every transaction has exactly one category, and that’s what keeps your books tidy. But real life is messier. A trip to Japan touches flights, food, hotels and gifts, which are all different categories. And you can’t put “tax deductible” on a category, because deductible purchases turn up in all sorts of them.
That’s where tags come in. A tag ignores the category structure and just marks the transactions you care about, wherever they are.
How tags work
- A transaction can have as many tags as you like, or none at all.
- Tags are flat. There are no tags inside tags. A tag is just a name.
- Capital letters don’t matter. “Japan-trip” and “japan-trip” are the same tag.
- Your own words stay as typed. Tags are yours, so they’re never translated or changed.
Seeing tags on your transactions
Open Transactions and look at the Tags column. If a transaction has no tags, you’ll see a dash. If it has some, the first tag shows as a small chip, with a “+2” style note for any others.
Finding transactions by tag
- Filter: press
fto open the filter box, then type into the Tag field. A “tag” chip appears at the top of the screen to show the filter is on. Click its✕to clear just that one, or use clear filters to clear them all. - Combine it with other filters. The filter box lets you set everything at once, so you can ask for something like “everything tagged japan-trip, in this account, since January” in one go.
The totals at the bottom of the screen update to match what you’re looking at, so filtering by a tag is also how you add up a theme. Keep in mind that it only covers the date range you’ve chosen, which starts as “this year”. If you’re totalling a trip that started last year, widen the dates first.
One currency at a time
The running total is only shown when everything on screen is in the same currency (or unit). If your filtered list mixes currencies, the total says “mixed units” instead. Personal Ledger won’t quietly add dollars and yen together, because that would give you a number that looks right and isn’t.
Tags, categories and payees
These three do different jobs, and they work best together:
- Category: what kind of spending or income it was. Every transaction has one.
- Payee: who the money went to or came from.
- Tag: an optional extra label that cuts across both.
See Categories and Payees for more.
Tips
- Keep the list short. A handful of tags you actually use beats dozens you don’t.
- Name them the way you’d search for them. “japan-trip-2026” is easier to find later than “trip”.
- Use a category when it’s one thing, and a tag when it cuts across. Groceries is a category, and “christmas” is a tag.
Getting around
Press g then t to jump to Tags from anywhere. For the full set of keys, see Getting around.
Tracing (Logging)
Because we are using asynchronous functions, we use telemetry to group together log outputs in a meaningful way.
Overview
The Personal Ledger application uses a structured logging system based on the
tracing crate. This provides
hierarchical, contextual logging that makes it easier to understand application
flow and debug issues in asynchronous code.
The telemetry library will use WARN level if no level is set.
Telemetry Levels
The Personal Ledger uses the following telemetry levels, ordered from most verbose to least verbose:
TRACE
- Purpose: Maximum verbosity for detailed debugging
- Use Case: Development and troubleshooting
- Performance Impact: High (may impact performance)
- Example Output: Function entry/exit points, detailed state changes
DEBUG
- Purpose: Debug information for troubleshooting
- Use Case: Development and staging environments
- Performance Impact: Moderate
- Example Output: Variable values, API call details, intermediate results
INFO
- Purpose: Informational messages about application flow
- Use Case: Production monitoring
- Performance Impact: Low
- Example Output: Application startup, successful operations, user actions
WARN
- Purpose: Warning messages for potential issues
- Use Case: Production monitoring (default level)
- Performance Impact: Low
- Example Output: Deprecated API usage, recoverable errors, configuration issues
ERROR
- Purpose: Error conditions that may require attention
- Use Case: Production monitoring and alerting
- Performance Impact: Low
- Example Output: Failed operations, invalid inputs, system errors
OFF
- Purpose: Completely disable telemetry output
- Use Case: Performance-critical environments, testing
- Performance Impact: None
- Example Output: No telemetry output
Configuration
This is empty for now, as config is a work in progress after telemetry.
Transactions
A transaction is a record of money moving in or out of an account on a date. Personal Ledger uses them to track spending, income, and transfers. Every transaction can be split across several categories, so a shopping trip that mixed groceries with household goods stays split between those categories, just like real life.
Heads up: reconciliation (matching transactions against statements) is still being built. For now, you can record transactions and mark their status as Open, Cleared, or Reconciled, but the formal reconciliation screen is coming later.
Terminology
- Split: one line of a transaction. A transaction can have one or many splits, each with its own category, payee, and tags.
- Status: where a transaction sits in the reconciliation workflow — Open (just recorded), Cleared (seen on a statement), or Reconciled (formally checked off).
- Flagged: a marker you set on a transaction to flag it for follow-up or review, independent of its status.
Transaction concept
A transaction moves money on a date against exactly one account. It holds one or more splits — if you split a $100 shopping trip between $60 groceries and $40 household goods, that’s one transaction with two splits. Date and the account live on the transaction itself, shared by all its splits.
The split — category, payee, tags
Every split has a category and an amount. It can also have an optional payee (who the money went to or came from) and any number of tags (extra labels like “tax deductible” or “japan trip”).
Status — Open, Cleared, Reconciled
A transaction starts as Open when you first record it. As you check it against bank statements or feeds, you can move it to Cleared (you’ve seen it on a statement but haven’t formally reconciled), then Reconciled (checked off during a formal reconciliation — the most confirmed status). A Reconciled transaction’s fields are locked until you move it back to Open or Cleared.
Flagged — a marker for anything
The Flagged marker is independent of status. You can flag a transaction at any point in the reconciliation workflow to mark it for follow-up — an amount that looks wrong, a duplicate, anything that needs a second look. Flagging a transaction doesn’t change its status or lock it.
Approach
The typical workflow is:
- Record transactions as they happen, or import them from a bank feed or CSV.
- Spot-check them against statements as they arrive, marking them Cleared.
- Reconcile formally during a review cycle — mark the transactions you’ve checked off as Reconciled.
- Flag anything that needs follow-up.
Related
- Accounts: transactions are posted against exactly one account, which they update.
- Categories: each split has one category.
- Payees: optionally on each split — who the money went to or came from.
- Tags: optional labels on each split — cross-cutting themes like trips or tax deductions.
- Bills: a recurring bill schedule entry can link to a transaction when paid.
Getting around
Press g then t to jump to Transactions from anywhere. For the full set of keys, see Getting around.
Feature set and requirements
Intended features for Transactions. Ticked means built.
- TXN-001: Record a single-entry transaction with date, amount, category, account, and status
- TXN-002: Split a transaction across multiple categories
- TXN-003: Assign an optional payee to each split
- TXN-004: Assign multiple tags to a transaction
- TXN-005: Move a transaction between accounts (same currency only)
- TXN-006: Filter and search transactions by date, account, category, payee, status, or tags
- TXN-007: Set a Flagged marker, independent of status
- TXN-008: Move a transaction through Open/Cleared/Reconciled workflow
- TXN-009: Reconciliation screen for formal reconciliation against statements
- TXN-010: Import transactions from CSV or bank feeds
For developers
Curious how transactions are structured in the codebase, or planning to change them? See the Transactions development documentation.
Units
In Personal Ledger, a Unit represents the denomination used to measure value. Every Account balance and every Transaction line (or split line) is recorded in a Unit.
A Unit may represent:
- A fiat currency (AUD, USD, EUR)
- A cryptocurrency or digital asset (BTC, ETH)
- A listed equity or stock (AAPL, BHP)
- An exchange-traded fund (VAS, IVV)
- A commodity or precious metal (XAU, XAG)
- Or, any other user-defined instrument
The system treats all Units uniformly. From the application’s perspective, a Unit is simply an identifier with associated metadata that defines how values are displayed, stored, and priced.
Relationship to Accounts
Every Account is assigned exactly one Unit when it is created, and that assignment remains immutable.
The Account’s Unit:
- Defines the denomination of the Account balance.
- Determines how transaction amounts are interpreted.
- Cannot be changed after Account creation.
For example:
Account Unit Everyday Banking AUD Brokerage Account AUD Bitcoin Wallet BTC Apple Shares AAPL
An Account holding Bitcoin records balances in BTC. An Account holding Apple shares records balances in AAPL units.
Relationship to Transactions
All Transaction amounts are denominated in the Unit of the destination (incoming) Account. Transfers between Accounts with different Units are supported through an exchange rate recorded in the transactions.
Examples:
- Transfer AUD → AUD: no conversion required.
- Transfer AUD → USD: exchange rate required.
- Transfer AUD → BTC: exchange rate required.
- Transfer BTC → ETH: exchange rate required.
The exchange rate defines the relationship between the source Unit and destination Unit at the time of the transaction.
Unit Types
Personal Ledger supports Fiat Currencies, Cryptocurrencies or Digital Assets, Stocks or Equities, Exchange Traded Funds (ETFs), Managed or Mutual Funds, Commodities, and free-form or custom types.
Currencies
Government-issued fiat currencies identified by standard currency codes.
Examples:
- AUD
- USD
- EUR
- GBP
- JPY
Cryptocurrency or Digital Asset
Blockchain-based digital assets.
Examples:
- BTC
- ETH
- SOL
- ADA
Equity or Stock
Shares traded on public or private exchanges.
Examples:
- AAPL
- MSFT
- BHP
- CBA
Exchange Traded Fund (ETF)
Exchange-traded funds and similar pooled investment securities.
Examples:
- VAS
- IVV
- VGS
- NDQ
Commodity or Precious Metal
Tradable physical commodities or precious metals.
Examples:
- XAU (Gold)
- XAG (Silver)
- WTI (Crude Oil)
Custom
User-defined Units for assets that do not fit predefined categories.
Examples:
- Employee Share Scheme Units
- Private Company Shares
- Loyalty Points
- Store Credit
- Reward Tokens
Unit Metadata
An uppercase Code uniquely identifies each Unit.
Examples:
- AUD
- BTC
- AAPL
- XAU
The Code acts as the primary identifier throughout the system and must be unique.
Each Unit also stores the following metadata.
Code
A unique uppercase identifier for the Unit.
Examples:
- AUD
- BTC
- AAPL
Rules:
- Must be unique.
- Stored in uppercase.
- Cannot be changed once created.
Name
The human-readable name of the Unit.
Examples:
- Australian Dollar
- Bitcoin
- Apple Inc.
- Vanguard Australian Shares ETF
Type
The category of Unit.
Allowed values:
- Currency
- Crypto
- Equity
- ETF
- Commodity
- Custom
Symbol
The symbol used for market pricing or external integrations.
Examples:
- AUD
- BTC
- AAPL
- VAS.AX
This value may differ from the Unit Code where required by pricing providers.
Pricing Pair
Defines the Unit used to value or price this Unit.
Examples:
Unit Pricing Pair BTC AUD ETH AUD AAPL USD VAS AUD
Pricing services and portfolio valuation calculations use this information.
Precision
The number of decimal places supported by the Unit.
Examples:
Unit Precision AUD 2 USD 2 BTC 8 ETH 18 AAPL 6
Precision controls:
- Data validation
- Value entry
- Display formatting
- Rounding behaviour
Status
Indicates whether the Unit is available for use.
Values:
- Active - Available for new Accounts and Transactions.
- Inactive - Retained for historical records but unavailable for new use.
Inactive Units are never deleted if they have historical transactions associated with them.
Valuation and Pricing
Units may optionally be associated with market pricing data.
Where pricing data is available, Personal Ledger can calculate:
- Current market value
- Portfolio value
- Unrealised gains and losses
- Asset allocation
Pricing is always performed relative to the Unit’s configured Pricing Pair.
Examples:
- BTC priced in AUD
- AAPL priced in USD
- Gold priced in AUD
Pricing data does not affect a Unit’s stored quantity. It is used only for valuation and reporting.
Design Principles
The Unit model is intentionally generic.
Personal Ledger does not distinguish between currencies, cryptocurrencies, shares, commodities, or other assets when storing balances and transactions. All assets are represented using the same core Unit abstraction.
This provides several benefits:
- Consistent balance calculations.
- Simplified transaction processing.
- Support for both financial and non-financial assets.
- Extensibility without database schema changes.
- Uniform reporting and valuation workflows.
In practice, a Unit is:
A unique identifier that defines how value is measured, displayed, and optionally priced within Personal Ledger.
Examples
Code Name Type Pricing Pair Precision AUD Australian Dollar Currency AUD 2 USD United States Dollar Currency AUD 2 BTC Bitcoin Crypto AUD 8 ETH Ethereum Crypto AUD 18 AAPL Apple Inc. Equity USD 6 VAS Vanguard Australian Shares ETF ETF AUD 6 XAU Gold Commodity AUD 4
This structure lets Personal Ledger represent cash, investments, digital assets, commodities, and custom instruments with a single, consistent model.
Product Requirements Document Product Name: Personal Ledger Version: v0.0.1 (Draft — End-State Requirements Pool; active cycle scope defined in §2.1) Date: 3 September 2026 Author: Ian Teda
This document owns the product vision, scope, and requirements. Domain vocabulary (precise definitions of terms like “ledger”, “category”, “account”) is defined in CONTEXT.md. This PRD references domain terms without formally defining them.
1. Introduction
1.1 Product Vision
Personal Ledger aims to provide users with an intuitive, user-friendly interface that offers insights into their spending habits and their investment and asset position. By presenting information clearly and concisely, users can effectively track and monitor their overall financial health, make informed decisions, and ultimately achieve their personal financial goals without handing their financial data to a third party.
1.2 Problem Statement
Creating a market for paid personal finance tools is challenging because most individuals interested in tracking their finances are unlikely to pay a $100 annual subscription fee. As a result, personal finance tools often pivot toward business finance tools, since businesses are generally willing to spend significantly more than individuals on monitoring and tracking their finances.
This means personal finance tools often have to be free, which limits active development and features. I have tried many personal finance tools and found them outdated, unappealing, or based on a tracking model I do not necessarily agree with or want to use because it lacks a particular feature or aspect.
Personal Ledger aims to bridge the gap by providing a self-hosted, local-first ledger that lets the owner fully control and own their data.
1.3 Target Audience
Personal Ledger v0.0.1 targets a single usage persona, but future development cycles will expand this to couples, families, and groups. Individuals: Individuals who are looking to track and understand their personal financial position Family, Couples & Groups: People with shared finances looking to understand together and set goals and targets. This is out of scope for v0.0.1 (see Future Considerations).
1.4 Goals & Objectives
The overarching goals and objectives describe the product’s ultimate end state. These will be targeted in various degrees across the development cycle or phase. These goals and objectives include the following:
- Desktop App: A desktop native app that works across Linux, macOS, and Windows.
- TUI App: A text-based TUI for a keyboard-focused native app that works across Linux, macOS and Windows.
- Sync Server App: A headless service, deployable via Docker on a homelab, that acts as the always-on sync hub for the native (Desktop/TUI) apps.
- Local Offline First: Provide a self-hosted, offline, local-first ledger for tracking expenses, investments, and assets without a cloud dependency or subscription.
- Multiple Operating Systems and Devices: Support multiple operating systems and devices, each holding its own local-first data store, synced via a sync server when devices come online.
- Transactions: Model transactions against accounts, payees and categories using the five standard accounting categories (asset, liability, equity, income, expense) to classify transactions, without requiring double-entry bookkeeping.
- Understand Spending: Users interested in gaining insight and understanding their spending habits now and over time to make informed decisions.
- Track Against Budget: Users interested in tracking spending against a target budget to achieve financial goals.
- Reconcile Accounts: Users can verify their accounts and transactions against reality through reconciliation of account ledgers.
- Personal Investors: Users who need to track personal investments, understand buy-in costs, capital gains, returns, and tax implications.
- Personal Loan: Users who want to track the progress of paying down a loan.
- Personal Inventory: Users who wish to track their assets and inventory, such as household goods.
2. Scope
Four development cycles are planned for this product, each with its own Git branch within the repository; a later cycle’s branch supersedes the requirements captured in an earlier one as work is folded forward. Each cycle has overarching aims, limitations and objectives that are described below:
- Feasibility (v0.0.1): This phase will focus on demonstrating the underlying approach and technologies and their suitability in achieving the desired outcomes.
- Concept (v0.1.0): This phase will aim to develop a minimum viable product that demonstrates use cases and functionality.
- Development (v1.0.0): This phase will expand functionality and improve the user experience. At the same time, it will refine the code and security aspects.
- Fixes & Features (v1.0.1): This phase will address any lessons learnt and pain points in using the tool. It will also add features and functionality to enhance the tool’s usefulness.
Sections 3–6 hold the product’s ultimate end-state requirements — the full functional and non-functional scope across all four cycles, not what any single cycle delivers. As each cycle is scoped, the Functional Requirements (§3) relevant to it are moved out of that pool and into that cycle’s own subsection below (e.g. §2.2), keeping their original FR ID for traceability. Non-Functional Requirements, Dependencies, and Assumptions & Constraints (§4–6) remain global across all cycles rather than being moved. Items still listed in §3 have not yet been assigned to a cycle. Each cycle subsection can therefore hold two kinds of items: FR.* requirements moved in from §3, and items native to that cycle’s own investigation/delivery work (e.g. FC-* in §2.1) — the two ID namespaces coexist rather than one replacing the other.
2.1. Feasibility Cycle (v0.0.1)
The feasibility cycle demonstrates the underlying technologies and approach, specifically:
TUI App:
- FC-TUI-001: Investigate and research Rust TUI libraries, and lock the choice in an ADR.
- FC-TUI-002: Demonstrate that line, doughnut, candle stick and divergent graphs each work in the TUI app across platforms, using dummy data — one demonstration per chart type.
- FC-TUI-003: Demonstrate that tables work in the TUI app across platforms.
- FC-TUI-004: Demonstrate compiling, installing and running as non-root across Windows, macOS and Linux — a portable single executable on Windows (no installer, no elevation), and a real user-scope installer/package on macOS (
.dmg/.app) and Linux (.deband/or AppImage). Research and lock the packaging tool choice (e.g.cargo-dist,cargo-packager) in an ADR before building the per-OS packages. - FC-TUI-005: Demonstrate the TUI app operating end-to-end against the embedded SQLite persistence layer (
lib-database/lib-core, see FC-DATA-001) with real (not dummy) data, proving the local-first architecture works through a real client.
Desktop App:
- FC-DESKTOP-001: Investigate and research Rust desktop GUI libraries.
- FC-DESKTOP-002: Demonstrate that line, doughnut, candle stick and divergent graphs work in the desktop app across platforms, as they are a key requirement for visually representing spending, etc.
- FC-DESKTOP-003: Demonstrate that tables work in the desktop app across platforms.
- FC-DESKTOP-004: Demonstrate compiling, installing and running as non-root across Windows, macOS and Linux
Sync Server App:
- FC-SYNC-001: Investigate and research approaches for syncing independent local SQLite copies across devices (e.g. last-write-wins vs. CRDT vs. manual merge).
- FC-SYNC-002: Investigate and research authentication mechanisms (e.g. OAuth2, JWT, etc.) and implement a secure auth flow for the sync server and client.
- FC-SYNC-003: Demonstrate basic push/pull sync of ledger changes between two local SQLite instances via the sync server, including a Client that was offline catching up on changes queued while it was down.
- FC-SYNC-004: Demonstrate the Sync Server instance acting as the always-on sync hub that Desktop/TUI clients sync through.
- FC-SYNC-005: Demonstrate the Sync Server’s Dockerfile builds a multi-arch image (linux/amd64, linux/arm64) that runs correctly on each architecture.
- FC-SYNC-006: Demonstrate docker Compose deployment with a persistent volume for the Sync Server’s own durable store, surviving a container restart, and confirm the container runs as a non-root user.
- FC-SYNC-007: Demonstrate auth functionality.
Local Data:
- FC-DATA-001: Demonstrate an embedded SQLite persistence layer (
lib-database,lib-core) that each client (Desktop, TUI) can hold and operate against independently, without requiring a network connection. - FC-DATA-002: Research the best and most efficient way to store and calculate running Balances.
2.2. Concept Cycle (v0.1.0)
2.2.1 All
Implement these requirements to bring the TUI, Desktop and Sync up to functionality
- CC-ALL-001: Ensure all dependencies are up-to-date and cross-compilation is enabled and compiles on all platforms.
- CC-ALL-002:
Refactor lib_domain into lib_core— resolved: mechanical rename done (crates/libs/lib-domain→crates/libs/lib-core, packagelib_domain→lib_core, every dependent crate updated). No new shared responsibilities were folded in beyond the originallib_domainscope (RowID,CategoryTypes,UrlSlug,HexColor,HybridLogicalClock); revisit if real shared logic emerges once CC-TUI-005+/CC-DESKTOP-005+ build real CRUD. - CC-ALL-003:
Research depreciated Chrono crate and replace it— resolved: chrono is not deprecated (0.4.45, actively maintained, one old advisory fixed years ago);jiffwas ruled out becausesqlx-sqlitehas nojifffeature. Keeping chrono, no ADR needed (status quo confirmed, not a real trade-off). Seedocs/research/chrono-alternatives.md(branchresearch/chrono-alternatives). - CC-ALL-004: Review, research and grill the workspace code architecture and structure for best practice, readability and maintainability.
- CC-ALL-006: Wire
lib_tracing::initintobin-tuiandbin-desktop(already done forbin-sync-server); confirm telemetry implementation is compatible across binaries and with cross-compilation. - CC-ALL-007:
Research and decide on SQLite encryption-at-rest— resolved: OS/filesystem-level encryption (LUKS/FileVault/BitLocker), not SQLCipher — see ADR-0011. Confirms and formalises the existing §6.1 assumption; no code required this cycle.
2.2.2 TUI App:
- CC-TUI-001: Research and decide on screens and user flows for the TUI app. How are we going to do applicatoin settings?
- CC-TUI-002: Build out a TUI app using the TUI framework.
- CC-TUI-003: Research and decide on keybinding and navigation/workflow for the TUI app.
- CC-TUI-004: Research and decide on the TUI app’s data model and persistence layer.
- CC-TUI-005: Build out the units functionality.
- CC-TUI-007: Build out the payee functionality.
- CC-TUI-006: Build out the categories functionality.
- CC-TUI-008: Build out the accounts functionality.
- CC-TUI-009: Build out the transaction functionality.
- CC-TUI-010: Build out the budgeting functionality using line-item budgeting only (matching
CONTEXT.md’s existing Budget definition — a cap on one Category, not a full allocation); envelope and reverse budgeting are deferred to Future Considerations (§7). - CC-TUI-011: Build out the reporting functionality.
- CC-TUI-012: Build out the CSV import functionality (FR.33 — Balance Check import only; no CSV export is specified anywhere in the FR pool).
- CC-TUI-013: Build out the Balance Check functionality (FR.28–32) — added when charting the “TUI App concept” Wayfinder map; Balance Checks were missing from this list despite having their own FR section.
2.2.3 Desktop App:
Mirrors §2.2.2, built in parallel with the TUI cycle rather than sequenced after it — both clients share lib-database/lib-core, and bin-desktop already has a feasibility-cycle scaffold (GPUI, ADR-0007/0008) as mature as bin-tui’s.
- CC-DESKTOP-001: Research and decide on screens and user flows for the Desktop app, including how the Preference model from CC-TUI-001 is shared or diverges for Desktop (see
CONTEXT.md— Preference). - CC-DESKTOP-002: Replace the feasibility cycle’s dummy-data chart/table demo (
bin-desktop/src/main.rs) with live screens backed bylib-database. - CC-DESKTOP-003: Research and decide on navigation/workflow (menus, keyboard shortcuts) for the Desktop app.
- CC-DESKTOP-004: Reuse the TUI’s data model and persistence layer decision (CC-TUI-004) — no separate research needed unless Desktop surfaces a real gap.
- CC-DESKTOP-005: Build out the units functionality.
- CC-DESKTOP-006: Build out the categories functionality.
- CC-DESKTOP-007: Build out the payee functionality.
- CC-DESKTOP-008: Build out the accounts functionality.
- CC-DESKTOP-009: Build out the transaction functionality.
- CC-DESKTOP-010: Build out the budgeting functionality (line-item only — see CC-TUI-010).
- CC-DESKTOP-011: Build out the reporting functionality.
- CC-DESKTOP-012: Build out the CSV import/export functionality.
2.3. Development Cycle (v1.0.0)
This will be defined before starting the development cycle.
- DC-ALL-001: Password string in the configuration file should be an encrypted string. On loading the app, if the string is plain text, it should be encrypted and saved to the configuration file before starting the app.
2.4. Fixes & Features Cycle (v1.0.1)
This will be defined before starting the concept development cycle.
3. Functional Requirements
The requirements below describe the product’s ultimate end state across all development cycles (§2). They are not all in scope for any single cycle; as each cycle (§2.2–2.4) is scoped, its relevant items are moved out of this section and into that cycle’s own requirements list above, keeping their original FR ID. Items still listed here have not yet been assigned to a cycle.
Units
- FR.1: The system shall allow for multiple different Units like currencies, crypto, stocks, equities, precious metals, etc.
FR.2: When a transaction is between different Units, it should include an exchange rate.— superseded: cross-Unit transactions don’t exist in V1 at all (seeCONTEXT.md’s Unit entry and this document’s Constraints/Future Considerations, “Cross-Unit conversion”). This FR predates that decision and was never reconciled with it; surfaced and struck while resolving CC-TUI-005 (“Build out Units functionality” Wayfinder ticket).FR.3: Unit prices will be stored locally as part of a transaction exchange and every week to avoid excessive granularity. If more granularity is needed, it can be queried from the internet dynamically.— superseded alongside FR.2, same reason: no cross-Unit exchange exists to price.
Categories
- FR.4: The system shall allow creating a category with a code, name, optional description, optional URL slug, one of the five accounting types, optional colour, and optional icon.
- FR.5: The system shall allow retrieving a category by ID, by code, or by slug.
- FR.6: The system shall allow listing categories with pagination, filtering by type and/or active status, and sorting.
- FR.7: The system shall allow partially updating a category via a field mask.
Accounts
- FR.8: The system shall allow deleting a category, individually or in batch.
- FR.9: The system shall allow activating and deactivating a category.
- FR.10: The system shall allow creating an account with a name, a type (Cash, Bank, Credit Card, Investment, or Loan), a Unit it’s denominated in (fixed at creation), and a starting balance in that Unit.
- FR.11: The system shall allow retrieving an account by id.
- FR.12: The system shall allow listing accounts with pagination and filtering by type and/or active status.
- FR.13: The system shall allow updating an account’s name, type, or active status.
- FR.14: The system shall allow deleting an account. On deleting an account, there will be an option to transfer all the transactions under an account to another account. (CC-TUI-008 implements a bare delete only — the transfer option operates on Transaction rows, which don’t exist until CC-TUI-009; deferred there.)
- FR.15: The system shall allow the merging of two accounts into one. (Deferred to CC-TUI-009 alongside FR.14’s transfer option, for the same reason — there’s nothing to merge until Transactions exist.)
- FR.16: The system shall allow creating a single-entry transaction with a UUIDv7, date, an amount, exactly one Category, exactly one account, an optional Payee, an optional description, optional ID, a Transaction Status (Open, Cleared, or Reconciled — defaulting to Open), and an independent Flagged marker (defaulting to unset), and shall update the linked account’s running Balance accordingly. A Payee not yet seen is auto-created from the entered name (see CONTEXT.md — Payee, ADR-0012).
- FR.17: The system shall allow retrieving a transaction by id.
- FR.18: The system shall allow listing transactions with pagination, filtering by account, Category, Payee, Transaction Status, Flagged state, and/or date range, and sorting.
- FR.19: The system shall allow updating a transaction — including its Transaction Status and Flagged marker, each settable independently of the other — and shall adjust the affected account balance(s) accordingly, including moving a transaction between accounts that share the same Unit (moving it to an account with a different Unit is rejected — see Constraints). Status and Flagged changes are always explicit user actions; the system never infers or auto-applies them. A Reconciled transaction’s other fields cannot be updated until its Transaction Status is first moved back to Open or Cleared (see Constraints).
- FR.20: The system shall allow deleting a transaction, and shall reverse its effect on the linked account’s Balance.
- FR.21: The UUIDv7 will be used to determine the transaction creation date.
- FR.21a: The system shall allow creating a Payee with a name; a Payee is also auto-created the first time its name is entered on a Transaction (FR.16), so explicit creation exists mainly for consistency with other entities.
- FR.21b: The system shall allow retrieving a Payee by id.
- FR.21c: The system shall allow listing Payees with pagination and filtering by active status.
- FR.21d: The system shall allow renaming a Payee. The prior name is preserved as a Payee Alias so entering it on a future Transaction still resolves to the renamed Payee (see CONTEXT.md — Payee Alias, ADR-0012); every Transaction referencing the Payee reflects the new name immediately, since the reference is by identity, not stored text.
- FR.21e: The system shall allow deactivating a Payee (active status); deleting a Payee is rejected while any Transaction still references it.
Budgets
- FR.22: The system shall support line item budgeting (see CC-TUI-010/CC-DESKTOP-010 for the concept cycle’s scoping of this to line-item only). Envelope and reverse budgeting are future considerations (§7).
- FR.23: The system shall allow creating a budget with exactly one Category, a limit amount, a Unit it’s denominated in, and a recurring period (weekly, monthly, quarterly, or yearly).
- FR.24: The system shall allow retrieving a budget by ID.
- FR.25: The system shall allow listing budgets with pagination and filtering by Category and/or active status.
- FR.26: The system shall allow updating a budget’s limit amount, period, or active status.
- FR.27: The system shall allow deleting a budget.
Balance Checks
- FR.28: The system shall allow creating a Balance Check with exactly one account, a date, and an asserted balance amount in that account’s Unit, entered manually.
- FR.29: The system shall allow retrieving a Balance Check by id.
- FR.30: The system shall allow listing Balance Checks with pagination and filtering by account and/or date range.
- FR.31: The system shall allow updating a Balance Check’s date or asserted balance amount.
- FR.32: The system shall allow deleting a Balance Check.
- FR.33: The system shall allow importing Balance Checks for one account from a CSV file containing a date column and a balance column, creating one Balance Check per row; a malformed row shall abort the entire import rather than partially applying it.
Reporting
- FR.34: The system shall report the current Balance of a given account, or of all accounts, each expressed in its own Unit (no cross-Unit aggregation).
- FR.35: The system shall report the sum of transaction amounts per Category over a given date range, scoped to a single Unit (or account) at a time.
- FR.36: The system shall report the sum of transaction amounts per Payee over a given date range, scoped to a single Unit (or account) at a time.
- FR.37: The system shall report, for a given budget, the limit amount, the actual spending in its Category and Unit for the current period, and the amount remaining (or the amount over, if exceeded).
- FR.38: The system shall report, for a given Balance Check, the difference between its asserted amount and its account’s computed Balance as of that date.
Platform
- FR.39: The system shall expose Category, Account, Transaction, Budget, and Balance Check operations (FR.4–FR.38) via a local embedded library API (
lib-database/lib-core), called in-process by each client (Desktop, TUI) against its own local SQLite store. - FR.39a: The sync server shall expose a versioned gRPC sync protocol for syncing a client’s local ledger data with other devices — pushing and pulling change sets rather than exposing full CRUD — and each client shall be able to operate entirely offline against its local store between syncs. The Sync Server persists a durable log of change sets so a Client that has been offline can catch up without both peers being online simultaneously.
- FR.40: The system shall support layered configuration (defaults, system, user, executable-directory, working-directory, explicit path, environment variables).
- FR.41: The system shall emit structured, level-configurable tracing output.
4. Non-Functional Requirements
- NFR.1 Reliability (priority): Transaction creation, update, and deletion must keep an account’s running Balance consistent with its recorded transactions, even on partial failure (no orphaned balance updates). A Balance Check CSV import is atomic: a malformed row aborts the whole import rather than partially applying it. Database migrations must be safe to re-run.
- NFR.2 Security & Privacy (priority): All data is stored locally in SQLite; no data leaves the device by default. Encryption-at-rest relies on OS/filesystem-level encryption, not application-level cryptography — see §6.1 Assumptions and ADR-0011. The app never requires root/administrator privileges to run. No
unsafecode anywhere in the workspace (lint-enforced). Any secrets are wrapped insecrecy::Secretso they cannot leak into logs or traces. - NFR.3 Performance: Category, account, transaction, budget, and Balance Check CRUD, and the balance, category-total, payee-total, budget-vs-actual, and balance-check-variance reports, should respond quickly for typical personal-ledger data volumes (thousands, not millions, of transactions).
- NFR.4 Usability (API-level): Since V1 has no UI, the local library API (
lib-database) is the primary usability surface: errors are structured (viathiserror) rather than raw SQL or library errors, so a future client can present them meaningfully. The sync server’s gRPC protocol (FR.39a) is a separate, narrower surface for device sync, not the general-purpose API. - NFR.5 Maintainability: Each entity’s persistence logic is split into separate
find/insert/update/delete/builder/modelfiles, following the existingcategories/convention inlib-database. SQL queries list explicit columns; noSELECT *. - NFR.6a Compatibility (Desktop/TUI): The Desktop and TUI apps build and run natively on Linux, macOS, and Windows — anywhere the pinned Rust toolchain,
protoc, and SQLite are available. - NFR.6b Compatibility (Sync Server): The Sync Server ships as a multi-arch (amd64/arm64) Docker image and runs on any Docker host — Linux, macOS, or Windows — without requiring a native build on the host OS.
- NFR.7 (placeholder): GUI-framework dependencies and any UI-specific NFRs (e.g. rendering performance, offline/sync UX consistency) are TBD, to be added when the Desktop and TUI cycles are scoped.
- NFR.8 (placeholder): Sync Server-specific NFRs (change-set log durability, container security posture, backup/restore) are TBD, to be added when the Sync feasibility cycle work (§2.1 FC-SYNC-*) concludes.
5. Dependencies
- Rust toolchain,
protoc, and other dev tools pinned inmise.toml. - Core crates:
tonic(gRPC),sqlx(SQLite persistence),serde,tracing,thiserror,uuid(v7),chrono,secrecy, and a CSV-parsing crate (e.g.csv) for Balance Check import. - SQLite as the embedded database engine for each Client (no external database server required for V1); the Sync Server’s own Change Set log also uses SQLite via
sqlx, reusinglib-database’s conventions (see ADR-0009). - A decision will need to be made on the best time crate, as chrono is deprecated.
cargo-make, mdBook, and rustdoc for documentation builds.- Docker (or another OCI-compatible container runtime) to build and run the Sync Server.
- GUI-framework dependencies (Desktop/TUI) and any additional sync-protocol client dependencies are TBD, to be added once their respective cycles are scoped.
6. Assumptions and Constraints
6.1 Assumptions
- The user trusts their own machine/network to hold their financial data; V1 relies on OS/filesystem-level protection rather than application-level encryption-at-rest — see ADR-0011.
- The Sync Server is assumed to run within the user’s own trusted network (e.g. a homelab), not exposed directly to the public internet; authentication (FC-SYNC-002/007) protects against other devices on that trusted network, not against a hostile network.
6.2 Constraints
- No
unsafecode anywhere in the workspace (lint-enforced, see rootCargo.toml). - Each Account’s Unit is fixed at creation; a Transaction’s amount is always measured in its Account’s Unit, and a Transaction may only be moved to another Account sharing that same Unit.
- A Balance Check’s CSV import reads exactly a date column and a balance column for one Account; it does not import Transactions, categories, or payees.
- Transaction Status (Open, Cleared, Reconciled) and Flagged are both set manually by the user; the system never automatically matches a Transaction to a Balance Check or infers a status change. Flagged is independent of Transaction Status — a Transaction may be Flagged in any status, including Reconciled.
- A Reconciled Transaction’s other fields (amount, Category, account, Payee, description, date) cannot be updated until its Transaction Status is first moved back to Open or Cleared; the Status and Flagged fields themselves remain independently settable at any time.
- A Payee cannot be hard-deleted while any Transaction references it; only deactivation (active status) is allowed once referenced. Renaming a Payee is not a bulk update of Transactions — it changes one row, reflected everywhere via the reference — and preserves the prior name as a Payee Alias (see CONTEXT.md — Payee, Payee Alias, ADR-0012).
- SQL queries must list explicit columns; no
SELECT *. - Secrets must be wrapped in
secrecy::Secret.
7. Future Considerations & Roadmap
- Shared ledgers: Support for Family, Couples & Groups sharing a single ledger (see §1.3) — out of scope for v0.0.1, which targets a single individual.
- Institution: Associating an Account with the financial Institution that holds it (e.g. a bank) is deferred past V1; no Institution entity, field, or CRUD exists in this cycle.
- Cross-Unit conversion: V1 keeps every Account and Transaction in one fixed Unit with no conversion between Units (see
CONTEXT.md— Unit); multi-Unit rollups/conversion are a future consideration. - Payee fuzzy matching: V1 resolves a renamed Payee via the Payee Alias an explicit rename leaves behind (see CONTEXT.md — Payee Alias, ADR-0012); free-form fuzzy/typo-tolerant matching with no explicit rename behind it is a future consideration.
- Double-entry accounting: Personal Ledger deliberately uses single-entry Transactions in V1 (see ADR-0001); revisiting this for audit-grade, structurally-balanced accounting is a future consideration should the need arise.
- Desktop & TUI UI: The concrete UI/UX for each client app (§1.4) is not yet detailed as Functional Requirements; deferred until each respective cycle is scoped.
- Multi-device sync protocol: End-state Functional Requirements for the sync server’s protocol beyond FR.39a’s placeholder are deferred until a sync-focused cycle is scoped (see FR.39a, §2.1 Sync).
- Change Set log retention: Every Change Set is kept indefinitely for this cycle — deliberately not decided, since a feasibility demo on one self-hoster’s own small device fleet doesn’t yet produce the usage/volume data a pruning policy would need to design against (see ADR-0009); revisit once a later cycle has that data.
- Envelope & reverse budgeting: Personal Ledger’s concept cycle (§2.2) scopes Budgets to line-item only (see CC-TUI-010, CC-DESKTOP-010, FR.22); envelope budgeting (allocating every dollar of income across categories) and reverse budgeting are deferred past V1.
- Personal Investors: Tracking buy-in costs, capital gains, returns, and tax implications for investments (see §1.4) is a future consideration with no Functional Requirements defined yet.
- Personal Loan: Tracking progress paying down a loan (see §1.4) is a future consideration with no Functional Requirements defined yet.
- Personal Inventory: Tracking assets and household inventory (see §1.4) is a future consideration with no Functional Requirements defined yet.
Account Kinds
Personal Ledger accounts represent money spent, money owed or money held as an investment. Every account is initially nominated as one of these four types:
- Transaction Account,
- Credit Card Account
- Loan Account
- Investment Account
Each account type has its own record shape, workflow, and view, even though the app still groups all four under one “Accounts” navigation area for browsing. This document explains why they’re separate, what each one is, and how the mechanisms that move value between them (Transaction, Split, Trade, Repayment) fit together.
See CONTEXT.md for the one-paragraph canonical definition of each term; this document covers the fuller shape and relationships.
Why four entities, not one
Before this design, Personal Ledger modelled Cash, Bank, Credit Card, Investment, and Loan as five variants of a single Account type, all sharing one table. That works as long as every variant needs the same data — a name, a Unit, a starting balance. It stops working once a variant needs data the others don’t:
- A Loan Account needs principal, an interest rate history, a term, and a next-due-date — none of which mean anything for a plain Bank Account.
- An Investment Account needs a quantity that only ever changes through a Trade, never a plain Transaction — a Bank Account’s balance changes through Transactions and nothing else.
- A Credit Card Account needs a credit limit and an interest rate history too — revolving debt, not a simple cash balance — even though, unlike a Loan, it still accepts Transactions directly the same way a plain Bank Account does.
Rather than bolting loan-, investment-, and credit-card-specific columns onto one shared table, each becomes its own entity, grouped under the umbrella term Account (see below). Loan Account and Investment Account leave the shared-Transactions machinery entirely; Credit Card Account keeps it (it still posts Transactions and computes a Balance the same way) but still gets its own entity because its extra data (credit limit, rate history, statement date) doesn’t belong on a plain Transaction Account either.
Institution
The financial institution — a bank, a broker, a lender — that an Account is held with. A first-class entity, not a free-text field.
Every Account, regardless of Account Kind, carries a mandatory link to exactly one Institution. This is deliberately strict, including for Cash: a Cash-type Transaction Account represents physical, on-hand cash, which has no real-world institution — so it links to a system-seeded placeholder Institution (e.g. representing “no institution”) rather than leaving the field empty. Two alternatives were considered and rejected:
- Making the link optional (nullable) — rejected because every real Bank, Credit Card, Loan, and Investment record always has a genuine Institution, and an optional field invites inconsistent data for no principled reason.
- Forcing the user to pick a real Institution even for Cash — rejected as meaningless data entry.
Examples: “Commonwealth Bank”, “Vanguard”, “IG Markets”, the system-seeded placeholder for Cash.
Account and Account Kind
Account is the umbrella term: a place where value is held or owed. It is not itself a table with a type column — it’s the shared concept behind four separate entities, each with its own shape. Account Kind is which of the four an Account is: Transaction Account, Credit Card Account, Loan Account, or Investment Account, decided when the Account is created.
Every Account, of any Kind, shares exactly two things: a Unit (fixed at creation) and a mandatory Institution link (see above). Everything else — whether it accepts Transactions directly, what “the current amount” is called, what extra fields it carries — is specific to its Kind.
There’s a second, narrower “type” concept that’s easy to confuse with Account Kind: Transaction Account Type (Cash or Bank) only classifies within Transaction Account — it says nothing about Credit Card, Loan, or Investment Accounts. The two are named differently (Account Kind vs. Transaction Account Type) specifically so a reader can tell which axis is meant without more context.
| Account Kind | Accepts Transactions directly? | Has a Balance? | “Current amount” concept |
|---|---|---|---|
| Transaction Account | Yes | Yes | Balance |
| Credit Card Account | Yes | Yes | Balance |
| Loan Account | No — Repayment only | No | Outstanding Principal |
| Investment Account | No — Trade only | No | Quantity |
Transaction Account
An everyday place where spending money is held. Denominated in exactly one Unit, fixed at creation. Its Balance is derived from the Transactions posted against it — not stored directly.
Fields:
- Name — a human-readable label (e.g. “Everyday Spending”).
- Transaction Account Type — Cash or Bank.
- Unit — fixed at creation.
- Institution — mandatory (see above).
- Starting Balance — in the Transaction Account’s Unit, combined with its Transactions to compute the current Balance.
- Active flag — soft-delete; an inactive Transaction Account isn’t offered for new Transactions but its history remains valid.
Transaction Account Type stays a two-value field (Cash, Bank) rather than being inferred from whether the Institution is the Cash placeholder or a real one — keeping classification and Institution-linking independent, so one can change without silently implying the other.
Credit Card Account
Revolving debt held with a lender. Unlike Loan Account, a Credit Card Account behaves like a Transaction Account for spending: it accepts Transactions directly (with Splits), and its Balance is computed from them the same way.
Fields:
- Name — a human-readable label (e.g. “Rewards Visa”).
- Unit — fixed at creation.
- Institution — mandatory (see above), the card issuer.
- Starting Balance — same mechanics as Transaction Account.
- Credit Limit — the maximum balance the card allows.
- Interest Rate History — a list of (rate, effective-from date) pairs, the same shape as Loan Account’s (see below) — card rates change too, so a single scalar rate field would be wrong from day one.
- Statement/Due Date — when the current statement is due.
- Active flag — soft-delete, same pattern as Transaction Account.
Paying down a Credit Card Account is an ordinary Transaction, not a Repayment: it’s a same-Unit Transaction moving cash from a Transaction Account into the Credit Card Account, using the same “a Transaction may move to another Account sharing that same Unit” rule that already covers any same-Unit transfer (see CONTEXT.md, Unit). Repayment (see below) stays specific to Loan Account, precisely because a Loan Account — unlike Credit Card Account — can’t accept a Transaction directly at all.
Loan Account
An amount owed to a lender. A Loan Account is not a Transaction Account: it carries data a Transaction can’t express, and its amount owing changes through a dedicated mechanism (Repayment), not through plain Transactions.
Fields:
- Name — a human-readable label (e.g. “Home Loan”).
- Unit — fixed at creation, the currency the Loan is denominated in.
- Institution — mandatory (see above), the lender.
- Principal — the original amount borrowed.
- Interest Rate History — a list of (rate, effective-from date) pairs, not a single fixed rate. Many Australian loans (mortgages in particular) are variable-rate. The Loan’s current rate is whichever entry has the latest effective-from date on or before today.
- Term — the Loan’s original duration.
- Next Due Date — when the next Repayment is expected.
- Active flag — soft-delete, same pattern as Transaction Account.
A Loan Account’s amount owing is its Outstanding Principal, not a Balance — Balance is specific to Transaction Account and Credit Card Account (computed from Transactions), and a Loan Account has neither Transactions posted against it nor a stored balance field. Outstanding Principal is derived from Principal minus the sum of all Repayments’ principal Splits against this Loan.
Reporting concepts like an amortisation schedule or a payoff projection are future design work, out of scope for this document — they read from the Loan’s Principal, Interest Rate History, and Repayment history, once that reporting is actually designed.
Investment Account
A single holding or position — e.g. “10 shares of AAPL” — not a container of several holdings. An Investment Account is not a Transaction Account: its quantity changes only through a Trade, never through a plain Transaction.
Fields:
- Name — a human-readable label (e.g. “Apple Inc. — CommSec”).
- Unit — fixed at creation, the security or instrument itself (e.g. AAPL, VAS, BTC), not the currency it’s priced in.
- Institution — mandatory (see above), the broker or exchange it’s held through.
- Quantity — derived from the sum of all Trades against this Investment Account (buys add, sells subtract), not stored directly.
- Active flag — soft-delete, same pattern as Transaction Account.
A “Holding” concept distinct from Investment Account — one Investment Account containing several tickers, the way a real brokerage account often works — was considered and rejected: an Investment Account is the holding, one entity per position. If a real brokerage account holding several securities needs modelling later, that’s several Investment Accounts sharing one Institution, not one entity containing several holdings.
Market value, unrealised/realised capital gains, cost basis, and dividends are deliberately not modelled here. docs/product-requirements.md already marks capital-gains tracking as a future consideration with no requirements defined, and a prior requirement to store Unit prices (FR.3) was struck out because “no cross-Unit exchange exists to price” — valuation needs a pricing mechanism Personal Ledger doesn’t have yet. Cost Basis and Dividend are less blocked (they don’t need live market pricing) but are still reporting-feature detail, parked for a future design pass rather than guessed at here.
Transaction and Split
A Transaction is a single-entry record of an amount moving against exactly one Transaction Account or Credit Card Account — the two Account Kinds that accept Transactions directly — on a date, carrying a Transaction Status and, independently, a Flagged marker. Personal Ledger is deliberately single-entry, not double-entry (see ADR-0001).
A Transaction is composed of one or more Splits. A Split is one line of a Transaction: an Amount and a Category, plus an optional Payee and any number of Tags. A Transaction’s Splits’ Amounts sum to its total. Date and the Transaction Account or Credit Card Account it’s posted against live on the Transaction itself, shared by every Split within it.
This means a plain, single-Category Transaction — the common case — is just the one-Split case of the same structure, not a separate type from a multi-Category one. Nothing in the app needs to handle “a plain Transaction” and “a split Transaction” as two different shapes.
Example — a $2,000 home Loan Account repayment recorded through the Repayment mechanism below still uses Splits for its principal/interest breakdown:
| Split | Category | Amount |
|---|---|---|
| 1 | Loan Interest | $800.00 |
| 2 | Loan Principal | $1,200.00 |
Trade
The mechanism for buying or selling an Investment Account. A Trade moves cash out of (or into) a Transaction Account, in its Unit, and changes an Investment Account’s quantity, in its Unit.
This is structurally different from a Transaction/Split for two reasons:
- It crosses Account Kinds. A Split moves value within one Account. A Trade moves value from a Transaction Account into an Investment Account (or back) — two different Account Kinds entirely.
- It’s quantity- and price-aware. A Split is just a Category and an Amount. A Trade needs a quantity of the security bought or sold and a price, which together determine the cash Amount on the Transaction Account side.
No exchange rate is involved, and this doesn’t reopen the “Personal Ledger doesn’t convert between Units in V1” rule (see CONTEXT.md, Unit): each side of a Trade is simply denominated in its own Unit already — the Transaction Account side in currency, the Investment Account side in the security itself. The Trade’s price is what ties the two amounts together, not a currency-conversion exchange rate.
Example — buying 10 shares of AAPL at $150.00 each from a cash Transaction Account:
| Side | Entity | Unit | Amount |
|---|---|---|---|
| Cash out | Transaction Account (cash holding) | AUD | -$1,500.00 |
| Quantity in | Investment Account (AAPL) | AAPL | +10 |
Further Trade detail — fees, dividend reinvestment, partial fills — is future design work once Trade is actually built; this document covers only the shape agreed so far: a Transaction-Account-to-Investment-Account mechanism, distinct from Transaction/Split, quantity- and price-aware.
Repayment
The mechanism for paying down a Loan Account — the symmetric counterpart to Trade, for Loan Account instead of Investment Account. A Repayment moves cash out of a Transaction Account and reduces the Loan Account’s Outstanding Principal.
Like Trade, a Repayment crosses Account Kinds — Transaction Account into Loan Account — which a same-Account Transaction structurally can’t do. Unlike Trade, a Repayment reuses the Split concept for its own breakdown: a Repayment carries Splits the same way a Transaction does, typically one Split categorised as principal and one as interest, summing to the repayment total (see the example table above).
Repayment is specific to Loan Account. Paying down a Credit Card Account does not use Repayment — it uses an ordinary Transaction instead, because Credit Card Account, unlike Loan Account, already accepts Transactions directly (see Credit Card Account, above).
What’s deliberately out of scope here
This document captures the entity and mechanism shape agreed so far — it is not a functional spec, and docs/product-requirements.md still owns actual requirements (Loan and Investment tracking remain “future consideration” there; nothing here moves them into an active cycle). Left for later, once that work is actually scoped:
- Amortisation schedules and payoff projections for Loan Account, and the equivalent for Credit Card Account.
- Cost basis, realised/unrealised capital gains, market value, and dividends for Investment Account — all blocked on a pricing mechanism Personal Ledger doesn’t have yet.
- Trade’s exact fields (fees, partial fills, dividend reinvestment).
- Any dedicated screens/views for Loan Account, Investment Account, or Credit Card Account in the TUI or desktop app.
Directories & Files
This file describes the directory and file structure for the Personal Ledger project. It utilies Cargo workspaces with multiple reusable crates (libraries) and multiple binaries that combine in different ways to compile and build the different binaries and functionality.
Libraries
The Personal Ledger project is made up of the following libraries:
- Configuration: This library crate holds the configuration defaults, file parsing and logic.
- Database: This library crate contains the database abstraction layer.
- Domain: This library crate contains the business logic and type definitions. Including application error and result. No I/O is managed here.
- RPC: This libary crate contains the proto definitions, protoc generated files and grpc servers.
- Telemetry: This library crate contains the telemtry shared code.
Binaries
Every binary is a thin main.rs that just glues crates together. The Personal Ledger project is made up of the following binaries and there shared libraries:
1. Backend
A stand alone grpc server that interfaces with the database. It accepts grpc requests, queries the database and provides a grpc response. It contains the following libraries:
- Configuration
- Database
- Grpc
- Telemetry
2. Desktop
A standalone desktop applicatoin built with egui/tauri
3. TUI
4. Leptos (Web)
my-app/
├── Cargo.toml # Workspace root
├── crates/
│ ├── lib-core/ # Pure business logic, no I/O
│ │ ├── Cargo.toml
│ │ └── src/lib.rs
│ │
│ ├── lib-database/ # Database models + repository traits/impls
│ │ ├── Cargo.toml
│ │ └── src/
│ │ ├── lib.rs
│ │ ├── models.rs
│ │ └── repository.rs
│ │
│ ├── lib-rpc/
│ │ ├── Cargo.toml
│ │ ├── proto/
│ │ │ ├── personal-ledger/
| | | | └──v001/
│ │ │ │ ├── common.proto
│ │ │ │ ├── utilities.proto
│ │ │ │ └── ...
│ │ ├── build.rs # Generate protobuf services from proto files
│ │ └── src/
| | ├── gennerated/ # proto files generated by proto buf
| | | ├── mod.rs
| | | └── ...
│ │ ├── lib.rs
│ │ └── utilities.rs
| | └── ...
| |
│ ├── lib-telemetry/ # Telemetry module for the project
│ │ ├── Cargo.toml
│ │ └── src/
│ │ ├── lib.rs
│ │ ├── error.rs
│ │
│ ├── tui/ # Ratatui / crossterm UI (library so it can be reused)
│ │ ├── Cargo.toml
│ │ └── src/lib.rs
│ │
│ ├── backend/ # Binary: gRPC + HTTP server + DB
│ │ ├── Cargo.toml
│ │ └── src/main.rs
│ │
│ ├── cli/ # Binary: Interactive TUI that uses local DB
│ │ ├── Cargo.toml
│ │ └── src/main.rs
│ │
│ └── desktop/ # Binary: Standalone desktop (TUI or egui/tauri) + embedded DB
│ ├── Cargo.toml
│ └── src/main.rs
|
└── rust-toolchain.toml # Optional: pin toolchain + components
Getting started: deploying the Sync Server
This page covers deploying the Sync Server — the only component of Personal Ledger that’s actually deployed anywhere; the Desktop and TUI apps are installed locally as native binaries (see their own packaging notes in docs/directories-files.md). It assumes some familiarity with the architecture below; if you’re only interested in docker compose up, skip to Quick start.
Architecture
Personal Ledger is not a client/server app in the traditional sense — there’s no central database every Client reads and writes against. Instead:
- Ledger: the complete set of Accounts, Categories, and Transactions owned by one self-hoster. It’s a logical whole, physically replicated in full on every Client.
- Client (Desktop or TUI): an app instance holding its own full local copy of the Ledger in an embedded SQLite database, capable of every read/write entirely offline. Neither app depends on the Sync Server to function day to day.
- Sync Server: a separate, headless component — not a Client, and it never holds a full Ledger copy — that lets Clients propagate their local edits to each other. It does this by exchanging Change Sets (field-level edits) with each Client, not by exposing Ledger CRUD directly, and it keeps its own durable log of every Change Set so a Client that’s been offline can catch up without any other Client being online at the same time.
┌─────────────┐ ┌─────────────┐
│ Desktop │ │ TUI │
│ (own full │◄──┐ ┌──►│ (own full │
│ SQLite copy)│ │ │ │ SQLite copy)│
└─────────────┘ │ │ └─────────────┘
│ push/pull Change │
│ Sets over gRPC │
│ (bearer JWT) │
▼ ▼
┌─────────────────────────────────┐
│ Sync Server │
│ (headless, Docker-deployed) │
│ durable Change Set log (SQLite) │
└─────────────────────────────────┘
This is why the Sync Server is deployed at all: it’s the one component meant to run continuously, unattended, on infrastructure you control — a homelab, a NAS, a small always-on server — rather than on the same device you’re actively using the Desktop or TUI app on. See CONTEXT.md’s glossary for the precise definitions and docs/adr/0009-lww-sqlite-change-set-log.md/docs/adr/0010-oauth2-pkce-native-app-auth.md for how conflict resolution and authentication actually work.
Trusted network assumption
The Sync Server is assumed to run within your own trusted network (a homelab, not exposed directly to the public internet). Its authentication (OAuth2 Authorization Code + PKCE, see ADR-0010) protects against other devices on that trusted network, not against a hostile network — there’s no TLS on the gRPC/HTTP listener itself in this cycle. Don’t port-forward it to the public internet without adding your own transport-layer protection (a reverse proxy with TLS, a VPN/tailnet, etc.).
Current limitations
This is feasibility-cycle work (see the Sync Server feasibility map), not a finished product. Concretely:
- Desktop and TUI don’t actually sync yet. Both apps demonstrate their own embedded-SQLite storage today, but neither has the Client-side wiring (the browser-login flow, OS-keychain token storage, the actual push/pull calls) to talk to a Sync Server — that’s real application integration deliberately left out of this feasibility cycle (see the auth ticket’s own scope notes). The architecture and the Sync Server side of the protocol are proven end to end (see the map’s integration tests); the Client apps themselves aren’t wired up to use it yet.
- One hardcoded account. The Sync Server bootstraps a single account (
admin/change-me) on first run if none exists — seecrates/bins/bin-sync-server/src/main.rs’sBOOTSTRAP_USERNAME/BOOTSTRAP_PASSWORD. There’s no config-driven way to set a real password yet, and multi-account support doesn’t exist (ADR-0010 fixes this cycle to a single self-hoster’s own account). Don’t rely on this for anything beyond local experimentation until credential provisioning is addressed. - Backup/restore isn’t specified yet (PRD NFR.8 placeholder) — the persistent volume (below) survives container restarts, but there’s no documented backup strategy for it beyond whatever you already do for the Docker host itself.
The Docker image
Dockerfile.sync-server (repo root) builds a multi-arch (linux/amd64, linux/arm64) image: a rust:1.98.0-bookworm build stage, and a debian:bookworm-slim runtime stage that never runs the Sync Server process as root. See .github/workflows/build-publish-sync-server.yaml for how it’s built, verified (a real container run plus a grpcurl call, on both architectures, on every change), and — on a GitHub Release — published to ghcr.io/ianteda/personal-ledger-sync-server.
Non-root and PUID/PGID
The container always runs the Sync Server process as a non-root user. By default that’s a fixed uid/gid (10001), but it’s runtime-configurable via the PUID/PGID environment variables (the same convention linuxserver.io images use) — set them to match whatever user already owns your host-side volume if you want to inspect the durable store’s files directly from the host:
docker run -e PUID=1000 -e PGID=1000 ...
docker-entrypoint.sync-server.sh does the actual work: it starts as root just long enough to reshape the baked-in account to the requested PUID/PGID and fix up ownership of the durable store, then drops privileges via setpriv before ever executing the Sync Server binary itself.
Ports and storage
50051— the gRPC (and, for the/authorize//tokenOAuth2 endpoints, HTTP) listener. One port serves both; see ADR-0010./data— where the durable Change Set log (and the account store) lives, as a single SQLite file. Mount a volume here if you want it to survive anything more than a container restart on its own filesystem layer.
Quick start
The example deployment lives at example/compose.sync-server.yaml — it’s an example, not the only way to run this, but it’s the recommended starting point. It’s standalone: it pulls the pre-built multi-arch image from GHCR, so you don’t need to clone the repo or run it from any particular directory — just download the one file and start it:
curl -O https://raw.githubusercontent.com/IanTeda/Personal-Ledger/feasibility/example/compose.sync-server.yaml
docker compose -f compose.sync-server.yaml up -d
This pulls ghcr.io/ianteda/personal-ledger-sync-server:latest, starts the Sync Server on port 50051, and creates a named volume (sync-server-data) for its durable store — seeded from the image’s own pre-created (empty) database file on first run, so there’s no separate init step.
Confirm it’s actually serving requests (matches how this repo’s own CI verifies it):
grpcurl -plaintext \
-import-path crates/libs/lib-rpc/proto \
-proto personal-ledger/v001/utilities.proto \
localhost:50051 \
personal_ledger.utilities.v001.UtilitiesService/Ping
To override the non-root PUID/PGID (see above), either export them before running Compose or drop them in a .env file next to the compose file:
PUID=1000 PGID=1000 docker compose -f compose.sync-server.yaml up -d
Stopping and restarting
docker compose -f compose.sync-server.yaml restart (or down followed by up -d again) keeps the named volume, so the durable store — including the bootstrapped account — survives. Only down -v removes it; that’s a deliberate, explicit action, not something that happens by accident.
Building the image yourself
If you’d rather not wait for a published ghcr.io tag, build directly from the Dockerfile:
docker buildx build --file Dockerfile.sync-server --tag personal-ledger-sync-server:local .
Build context is the repo root (see the Dockerfile’s own header comment) — Cargo needs every workspace member’s Cargo.toml present to resolve the workspace, even though only bin-sync-server actually gets compiled.
Localisation design
The developer-facing design for localisation. Looking for the user guide (how to change your Locale, what changes)? See Localisation.
How Personal Ledger presents its interface in a Locale: which Messages the desktop and TUI show, and how numbers, dates and currency amounts are formatted. This page records the decisions reached on the Localisation of the Desktop and TUI UX Wayfinder map; each decision’s detail lives on its ticket, and the hard-to-reverse parts are in ADR-0021. The terms Locale, Message and Catalogue are defined in CONTEXT.md.
Status
Planned, not yet built. The shared crate, lib-locale, does not exist yet, and both bins still use hardcoded literals (about 1,700 candidate Message literals across bin-desktop and bin-tui). All the design decisions are made and the build is specced as ready-for-agent tickets listed on the map. Until the migration tickets land, don’t add hardcoded UI strings expecting lib-locale to exist, and leave literals as they are.
Locales
en-USis the source Locale: every Message is first written in it, and it is the only complete Catalogue. A missing Message there is an error.en-GBanden-AUare sparse override layers holding only the Messages that differ from the Locale they fall back to. The fallback chain is set explicitly in the crate, not left to negotiation:en-AUfalls back toen-GB, which falls back toen-US. Australian spelling matchesen-GBfar more thanen-US, soen-AUholds only what differs fromen-GB.en-XAis a pseudo-Locale used to find untranslated text and layout overflow. It is generated fromen-USat load time (accents and roughly a third extra length, with bracket delimiters), so no fourth Catalogue file exists. It is present in every build, never listed in Settings, and reachable only through thelocaleconfiguration. Its formatting falls back toen-US, because ICU4X has noen-XAdata.- Locales beyond these four, right-to-left scripts and non-Latin fonts are out of scope. A future real translation (for example
fr) must be a complete Catalogue, and a missing Message there is a warning rather than an error.
How the Locale is chosen
The Locale is Configuration, not a Preference. It is per-Client, never synced, needs no database read, and changes only by editing the configuration and restarting the Client. There is no in-app switch and no runtime Locale change to propagate.
lib-config resolves the requested Locale, from lowest to highest precedence:
- A
DEFAULT_LOCALEconstant,en-US. - The operating system’s locale, detected with
sys-locale. - The
localekey in the[Personal-Ledger]section, which followslib-config’s existing layers: config files, then thePERSONAL_LEDGER_PERSONAL_LEDGER__LOCALEenvironment variable, then the--locale <TAG>(no short flag) command-line flag.
The layers are read into locale: Option<String> (absent means unset) and resolved in a step after the build: the config value if present (source Config), else the detected system locale (source System), else DEFAULT_LOCALE (source Default). There is no system keyword: an absent key means the detected system locale stands. The value is validated as a BCP-47 tag with unic-langid internally and stored as a plain canonical-cased string, and the LocaleSource lets the Settings page show the effective Locale read-only with where it came from. A malformed value from a file, environment variable or flag is a startup error (InvalidLocale). A valid but unsupported tag passes through, because lib-config does not know the supported set. An unreadable system value (C, POSIX) is ignored quietly. The Sync Server skips detection and never reads the field.
Note that the documented double-underscore environment overrides do not currently work (Environment::with_prefix is missing .separator("__")), so a small bug ticket fixes them before the locale key is added.
lib-locale then negotiates that string against the supported set: an exact match wins, anything else (en-NZ, fr-FR, C, POSIX) becomes en-US, and en-XA is chosen only by an exact request. Each bin’s main calls Config::parse and then lib_locale::init, which sets the process-wide loader once. Settings has no control to change the Locale.
Formatting
The Locale owns number, date and currency formatting, replacing the earlier NumberFormat and DateFormat Preferences (ADR-0021). The two definitions that had already drifted apart, in lib-core and in bin-desktop, collapse into lib-locale.
- Numbers: the number-separator Preference is deleted, because
en-US,en-GBanden-AUall use1,234.56. - Dates: the date Preference becomes a nullable, synced date style (short, medium, long or ISO), persisted as
DateStyleinlib-core. No value means the Locale’s default, and ISO overrides the Locale for both display and typed input. The date style still changes live, because it only changes formatting output. - Typed date input: follows the Locale’s short form (read from the resolved ICU4X pattern), always also accepts ISO
YYYY-MM-DD, and acceptstoday,yesterdayandtomorrow. The accepted words come fromdate-word-*Messages, with the English words always accepted. Two-digit years are accepted and year-less input is opt-in per call site. ICU4X does not parse, so this is a small parser inlib-localeoverNaiveDate::from_ymd_opt. - Currency: the Unit decides what is shown (its code and fraction digits) and the Locale decides how (separators, symbol placement, disambiguation such as
A$for anen-USuser). Fiat Units go through the ICU4X currency formatter, wrapped and pinned becauseicu_experimentalis not semver-stable. Non-fiat Units (tickers, bitcoin) format the quantity as a decimal and place the Unit code through a Message. - Exactness: amounts stay
Money(BigDecimal), converted withto_plain_string()and never throughf64. Fluent’s ownNUMBER()andDATETIME()are not used, because in the Rust implementation they do no locale formatting.
Messages
- What is a Message: labels, headings, prose, validation and status text, command descriptions, argument placeholder words and example prose, hints, the labels of enumerated domain values, and user-reachable error text.
- What is not: command names and syntax (
accounts new,[into <acct>]), which are stable English ids typed by the user and dispatched on an id, never on display text; mock and seed data; element ids, paths, config keys and URLs; tracing, panic andexpecttext;sync-serveroutput; CLI--help; and temporarynot yet builtplaceholders, which are deleted as features land. - Domain vocabulary: each variant of
AccountType,TransactionStatus,CategoryTypes,UnitKindandBudgetPeriod, plus Flagged, has one Message with one canonical translation per Locale, shared by both bins. Stored tokens (as_str()) stay stable and are never displayed. ALabeltrait inlib-localeprovides them, so a call site readsaccount_type.label(), and each implementation is an exhaustivematch, so a new variant fails to compile until its Message exists. - Errors: libraries keep returning typed
thiserrorerrors with EnglishDisplayfor developers and logs. The bin maps each user-reachable variant (about 10 to 15) to a Message. Developer-facing wrapper errors are not Messages and are never spliced into a localised sentence througherr.to_string(). - Plurals: every count-plus-noun is a Fluent plural selector, even where English needs only one/other, and stems plus suffixes (
categor{y,ies}) are banned. - Glyphs: the label is the Message and the glyph is a separate argument, because glyph style is a Display Preference. The join is one Message with two variables.
- Casing and abbreviations: a Message is written once in natural sentence case. Upper-casing is done by the renderer through a Locale-aware helper. Abbreviations are not the default: the source Message uses the full word, and a
.shortvariant exists only where layout needs it. - Sentences: one Message per complete sentence or paragraph, never split for styling. Hard-wrapped literals are rejoined, and wrapping is the renderer’s job.
- Seeded rows: system-seeded rows that ship into synced data (such as
No institution) store a stable key and render the Message at display time, so Clients in different Locales each show their own.
Looking up a Message
Call sites use generated typed accessor functions with typed arguments, checked against en-US, not string ids and not a macro. A plural count is a typed integer and key tokens are typed arguments.
#![allow(unused)]
fn main() {
// bin-desktop
div().child(msg::accounts_delete_title(count))
// bin-tui
Line::from(msg::accounts_delete_title(count))
}
- Module:
lib_locale::msg::…for the shared layer, and each bin’s owncrate::msg::…for its bin-only Messages. Bin-only ids carrydesktop-ortui-prefixes, so the two modules never share a function name. - Ids: Fluent ids are kebab-case,
<area>-<thing>-<role>, named for their role rather than their English text (accounts-add-submit). Related Messages such as a dialog’s title, body and buttons use Fluent attributes on one id (accounts-delete.title). The generated accessor is the same name in snake_case. Shared vocabulary uses fixed families (account-kind-credit-card,transaction-status-open). - Loader: one process-wide loader, set once at startup, so views need no
cxor handle plumbing and lookups work in bothrenderanddraw. Tests get a scoped, thread-local override so parallel tests can use different Locales. - Arguments: only counts go in as numbers, for plural selectors. Money, dates and other formatted values arrive as strings from the formatting layer. Unicode isolating marks around placeables are switched off globally.
- Misses: a missing Message never panics. It resolves through the fallback chain to
en-US, and an id missing everywhere (possible only for a dynamic id) returns the id wrapped as⟦id⟧in debug builds and plain in release, with onetracing::warn!. - Styled and clickable spans: key tokens and nouns are normal string arguments passed as sentinels (a private-use code point pair carrying an index), and the formatted text is split so the caller styles each token. A translated styled span (the clickable
openin a sentence) uses a lightweight tag convention,<open>open</open>, parsed into segments with an optional tag. Both return segments with no UI types, each bin maps tags to its own styling or click handler, arguments are escaped so a value cannot forge a tag, and a test requires each Locale to use the same tags asen-US.
Catalogues and delivery
- Embedded at compile time. The Windows TUI is a single portable
.exeand the AppImage is a single file, all four Locales are shipped with the app, andbin-desktopalready embeds its icons withinclude_bytes!. Loading user-supplied Catalogues from disk is not planned. - Layers: a shared layer in
lib-locale(navigation, domain vocabulary, column labels, common dialogs, formatting Messages) plus a layer each bin owns for its own screens. The loader adds the layers as resources to one bundle per Locale, and each bin’sbuild.rsgenerates its own accessors with the same checks. - Files:
crates/libs/lib-locale/i18n/<locale>/<area>.ftlfor the shared layer, andcrates/bins/bin-desktop/i18n/<locale>/*.ftlandcrates/bins/bin-tui/i18n/<locale>/*.ftlfor each bin’s own. - Checks, run as ordinary
cargo test: every id in every Locale exists inen-US, variables and tags match,en-UShas no missing Message a bin references, and anen-XAsmoke test renders every Message without a panic or a literal id. Unused Messages are a warning from the generator, which emits its own report because the workspace allowsdead_code. - Adding a screen: add its Messages to
en-USonly.en-GBanden-AUinherit until they need to differ, anden-XAis automatic.
Crate structure
crates/libs/lib-locale/
build.rs generates msg/ accessors
i18n/<locale>/*.ftl the shared layer
src/
locale.rs supported set, negotiation, fallback chain
catalogue/ embedded layers, bundles, pseudo transform, loader, test override
msg/ generated shared accessors
rich.rs sentinel splitter and tag parser, no UI types
format/ number, date, currency, input (typed dates), casing
vocabulary.rs the Label trait for lib-core enums
error.rs
tests/ cross-Locale check, en-XA smoke test
lib-localedepends onlib-core, never the reverse, solib-corestays pure with no I/O and no ICU.lib-configdepends on neitherlib-localenor ICU4X, andlib-localedoes not depend onlib-config.bin-sync-servernever depends onlib-locale.- The generator is a small separate crate,
lib-locale-build, depending only onfluent-syntaxand used bylib-locale’s and each bin’sbuild.rs, because abuild.rscannot use its own crate’s dependencies and a build-dependency onlib-localewould compile Fluent and ICU4X for the host. The spike (research note) chose our own generator overfluent-typed, so the crate is created. - No
fluent-bundle,icuorunic-langidtype appears in any public signature, so a dependency can change without touching the bins. - Public surface:
init(&str) -> Locale,Locale, the generatedmsg::…accessors, the rich segment types,format_money(&Money, &Unit),format_date(NaiveDate, DateStyle),format_number,parse_date(&str, today), a Locale-awareupper, theLabeltrait, and awith_locale(...)test helper. - Dependencies to add:
fluent-bundle,fluent-langneg,fluent-pseudo(andfluent-syntax, inlib-locale-buildonly),unic-langid,icu(icu_decimal,icu_datetime,icu_casemap, and a pinnedicu_experimentalfor currency), andsys-localeinlib-config.
The name
The crate is lib-locale (crate lib_locale), chosen over lib-localisation and lib-i18n. It under-describes a crate that also owns Catalogues and formatting, so its rustdoc must state the whole scope up front, and gpui_component::locale() must be imported explicitly rather than glob-imported. Identifiers and Message ids use Australian spelling (Catalogue, colour); Message text follows its Locale, so en-US writes “color” and en-GB and en-AU override it as “colour”.
Interaction with the UI stacks
gpui-componentships its ownrust-i18n(English, Simplified and Traditional Chinese, and a partial Italian). The Locale is a process-wide static, so the desktop callsgpui_component::set_localeonce at startup with the mapped tag (anyen-*maps toen). Its calendar month and weekday names, the input context menu and dock strings cannot be overridden from outside, so they stay English or are avoided. ItsDialogbutton labels and the placeholders onSelect,DatePicker,ListandInputare overridden at the call site fromlib-localeMessages.gpuihas no Locale concept. Because the Locale never changes at runtime, no change signal orrefresh_windowsis needed.ratatuimeasures width in terminal cells withunicode-width. Accented Latin is one cell wide, but a longer Message is clipped, not wrapped, unless the widget wraps. Any width arithmetic must useUnicodeWidthStr::width, neverstr::len()orchars().count(), and fixed-width columns and one-line hints need generous constraints or explicit truncation soen-XApasses cleanly.
Open items
- Settled by the generator spike (note):
- We write our own generator over
fluent-syntax, notfluent-typed, which cannot express sparse override Locales, reports cross-Locale problems as warnings, and generates instance methods rather than free functions over a process-wide loader. Accessors takei64for counts and&strotherwise, and the kebab-case to snake_case conversion works, with a generator check for accessor-name collisions (an attributeaccounts-delete.titlecollides with a messageaccounts-delete-title). - A bin generates its own layer from
bin-*/i18n/with the same generator, and the runtime composes every layer into one bundle per Locale withadd_resource_overridingin fallback-chain order.fl!fromi18n-embed-flis not a fallback, because it validates ids only against its own crate’s assets. The bin build cannot see the shared layer’s terms and ids, so it must not fail on an unresolved reference, and cross-layer id collisions are caught by thedesktop-/tui-prefixes, a check atinitand a test. en-XAusesfluent_pseudo::transform_domwith markers off, because plaintransformmangles<tag>names and its markers bracket each text fragment; the generated outer[ … ]is added around the whole Message. The private-use sentinels survive the transform.- The chain
en-AU -> en-GB -> en-USworks with sparse Locales.
- We write our own generator over
- Measured on a
ratatuistand-in, for a stripped release binary: Fluent adds about 2.1 MB (1.4 MB with a size-tuned profile) and 11 s of clean build, and ICU4X with its default data adds about 1.3 MB and 24 s. Anicu4x-datagentrim toen-US,en-GBanden-AUcuts the ICU4X share to about 0.3 MB and saves about 12 s. The trim is a separate, later ticket, and must be regenerated when a new ICU4X marker is used. - Not yet scheduled: the per-screen string migration, the Preference migration (the number-separator column goes and
date_formatbecomes the nullable date style), the TUI palette’s move from dispatch-on-display-text to dispatch-on-id, the seeded-row keys, translation workflow, per-Locale command aliases, user-supplied Catalogues, and mapping Commonwealth variants (en-NZ,en-IN) toen-GB. - The cost figures come from a stand-in binary, not a real
bin-tuiorbin-desktopbuild, so re-measure oncelib-localeexists.
Research
The generator spike’s findings are in Localisation Message generator spike. The other findings behind these decisions are Markdown notes on throwaway research/localisation-* branches (they are not merged): the Fluent binding, formatting, gpui and ratatui behaviour, the hardcoded-text inventory, crate naming precedents, date input parsing, and the confirmation of Fluent against alternative message systems (which found no blocker: MessageFormat 2’s Rust runtime is not yet usable, and the gaps in Fluent are ones the shared crate would add anyway).
Navigation and keyboard grammar: design
This is the developer-facing design. The plain-language guide for people using the apps is Getting around.
This is the shared keyboard/navigation grammar both Personal Ledger clients — bin-tui (terminal, ratatui) and bin-desktop (GUI, gpui) — follow. It’s the cross-client layer: docs/ux/tui/navigation.md and docs/ux/desktop/README.md are each client’s own living reference for everything client-specific (screen layout, per-view keys, exact component structure); this document is what the two are meant to have in common, so a user moving between them keeps their muscle memory, and so a contributor building out either client’s own grammar has one place that states the shared rules rather than two documents quietly drifting apart.
The full decision trail behind this document lives on the TUI Keybindings & Shared Navigation Grammar Wayfinder map and its children.
Philosophy
Both clients commit to the same three things, independently arrived at before this document existed and confirmed here rather than invented fresh:
- Every action must be reachable by name. A keybinding is a shortcut to a command, never the only path to it.
bin-tui’s own navigation doc states this outright;bin-desktop’s command palette exists for the same reason. - A command with no real behaviour yet says so explicitly when run, rather than doing nothing or opening something half-built. Both clients’ command surfaces show a
"not yet built"message rather than silently swallowing the attempt. - The interface always says what mode it’s in. Both clients are modal: a status line names the mode whenever it isn’t the resting one, so a keystroke’s meaning is never ambiguous.
Three key shapes, not one leader-key scheme
The grammar has three genuinely different key shapes, each solving a different problem. Research: leader-key conventions considered and rejected unifying them into one mechanism — vim and Helix both keep their equivalents separate, and collapsing this app’s own two into one buys nothing mechanically since the underlying state (a pending-chord flag, a mode flag) is still needed either way. Read together, in increasing order of what they open onto:
- A held-modifier chord for atomic, single-step global commands (
Ctrl+<key>-shaped, WM-style — mirrors Hyprland’s/i3’s$mainMod). No pending state, can never collide with typed text. Configurable per-command vialib-config’sKeyBindingConfig::super_key(crates/libs/lib-config/src/keybindings.rs) — see “What’s actually configurable” below for which commands currently use it (as of this document, none in the truly-global set; see #158). - A released-prefix leader,
gthen a letter, for jump navigation. A finite, enumerable, mnemonic namespace — one letter per domain noun — safely explorable because an unrecognised completion just aborts the chord rather than doing something unexpected. Both clients already implement this identically in shape: a pending-chord flag armed by a bareg, cleared unconditionally on the very next keypress regardless of outcome (bin-tui’spending_leaderincrates/bins/bin-tui/src/shell.rs;bin-desktop’spending_gincrates/bins/bin-desktop/src/shell.rs), plus a timeout inbin-desktop’s case (1000ms) thatbin-tuidoesn’t currently have. - A bare, unmodified key opening the command surface — typed, fuzzy-matched, open-ended, the escape hatch behind “every action must be reachable by name.” Not part of the
g-namespace and not modifier-gated, matching vim’s and k9s’s own ex-command-line convention::.
Where the two clients agree today, and where they don’t
Both clients already independently arrived at shape 2 (a g-then-letter jump leader) in the same form. They disagree on shape 3:
bin-desktopopens its command palette on a bare:already — this document’s own recommendation, already shipped.bin-tuinow agrees too: it opens its command popup on a bare:(theopen_command_popupbinding,KeyBindingConfig-configurable,Shell::is_open_command_popupincrates/bins/bin-tui/src/shell.rs) as of Wire KeyBindingConfig into bin-tui’s Shell/View global key handling. It previously opened onCtrl+;— a deviation from its own original handoff that was never a considered decision (flagged, and never revisited, in issue #85’s own closing comment) and carried a real fragility: on a terminal without the Kitty keyboard protocol’s disambiguation extension,Ctrl+;collapsed onto the same control code asEsc.
Full rationale: docs/research/leader-key-conventions.md (on the research/leader-key-conventions branch, not yet merged).
The jump namespace
Both clients bind the same idea — g then a letter jumps straight to a noun — to their own noun set, which currently differs between them (the clients don’t show the same set of domains yet):
bin-tui (crates/bins/bin-tui/src/shell.rs‘s pending_leader handling): g a Accounts, g b Budgets, g c Categories, g d Dashboard, g g Tags (doubled — t was already Transactions’), g k Balance Checks, g p Payees, g r Reports, g s Settings, g t Transactions, g u Units.
bin-desktop (crates/bins/bin-desktop/src/shell.rs’s jump_noun_for_key): g a Accounts, g b Budgets, g c Categories, g d Dashboard, g l Transactions, g p Payees, g r Reports, g s Settings, g t Tags, g w Bills.
The two disagree on more than just which nouns exist (bin-desktop has no Balance Checks or Units nouns yet; bin-tui has no Bills noun yet): where both have Tags and Transactions, bin-tui gives Tags the doubled g g and keeps Transactions on g t, while bin-desktop moved Transactions to g l to give Tags the single g t. Reconciling this fully means the two clients agreeing on one noun set and one letter-per-noun assignment — out of scope for this document today (it documents the grammar’s shape, not a forced noun-for-noun remap), and worth its own decision once both clients’ noun sets actually converge.
Modes
Both clients share the same four-mode model — Normal, Insert, Command, Search — but they carry it at different fidelity today:
bin-desktop: all four are real, Shell-level state (nav::InputModeincrates/bins/bin-desktop/src/nav.rs), named in the status line whenever the mode isn’tNormal.Searchis entered on/and pre-empts other key handling like the other three, though nothing real is wired to it yet — a state flag, not a built search feature.bin-tui:Normal(rest),Command(the popup is open), andInsert(inside a form) are named the same way, butbin-tuihas no unified mode enum in its liveShell/Viewarchitecture at all — these are informal names for ad-hoc state (Option<CommandPopup>, a popup’s own presence), not one shared type the waybin-desktop’sInputModeis.Searchexists too, and unlikebin-desktop’s, it’s not a placeholder:/already opens real, working filter-typing inAccountsView,PayeesView, andTagsView(each view’s ownfiltering/filterfields, e.g.crates/bins/bin-tui/src/view/accounts.rs). But it’s per-view state, not aShell-level mode — the status line doesn’t announceSEARCHthe way it announcesCOMMAND, and a view without its own/handling has no search at all.
So the four-mode shape is already shared; making bin-tui’s own four real and uniform at the Shell level (matching bin-desktop‘s architecture, or migrating the three views’ existing filter behaviour into it) is real follow-on work, not done as part of this document.
Quit
Quit is deliberately not a single keybinding in either client’s grammar. bin-tui today has three separate, coexisting mechanisms, none of them configurable, and this document doesn’t collapse them into one:
Ctrl+C— a hard-quit safety net, recognised before any view sees the key, with no graceful variant (is_hard_quitincrates/bins/bin-tui/src/shell.rs).Q/q— graceful quit, routed through its ownAction::GracefulQuitso a future confirm-before-quit check has one place to slot in (is_quit/is_graceful_quit, same file).:quit— the named path, already real in the command popup’s registry (crates/bins/bin-tui/src/popup/command/commands/quit.rs).
bin-desktop doesn’t have an equivalent hard-quit safety net today (no analogue to Ctrl+C) — quitting a GUI app conventionally happens via window chrome, not a keyboard panic-key. Its own path is a close-button affordance, filling in the “3 window controls” slot its original design handoff specified but never built (Add a close-button window control to bin-desktop’s TopBar).
See #158’s own resolution for the full reasoning: quit was deliberately kept out of KeyBindingConfig’s remappable set rather than forced into a super_key-composition rule that only one command would ever use.
What’s actually configurable
lib-config’s KeyBindingConfig (crates/libs/lib-config/src/keybindings.rs, [keybindings] in personal-ledger.conf, PERSONAL_LEDGER_KEYBINDINGS__* env vars) is bin-tui-only today — bin-desktop doesn’t consume it, and bringing it in is explicitly out of scope for this map (see “Out of scope” on the map). Within bin-tui, only the truly-global set is in scope for real remapping, per the map’s own boundary — no per-view key (n/e/d/a/… inside any one domain’s own handle_key) is configurable:
back(Esc),help(?), andopen_command_popup(:) — bare keys, no modifier by default; all three are read fromKeyBindingConfigatShellstartup (Shell::with_keybindings, wired frommain.rs) as of #160.quitis not in this set — see “Quit” above.
super_key (the modifier held for globally-gated commands, mirroring a window manager’s $mainMod) remains defined in KeyBindingConfig for any future command that needs it, but gates nothing in the current truly-global set — quit, the one command it was shaped around, dropped out of the remappable set entirely rather than being forced through it.
Not yet specified
- A single, converged jump-noun set across both clients (see “The jump namespace” above) — waits on the clients’ own noun sets converging first, not a grammar question this document can settle alone.
bin-tui’s command popup already has ranking, Tab-complete, andCtrl+rhistory (crates/bins/bin-tui/src/popup/command/);bin-desktop’s palette doesn’t yet. Whether that’s worth converging is a future call, out of scope here —bin-desktopcode changes are out of scope for this map entirely (see the map’s own “Out of scope”).- Giving
bin-tui’s liveShella real, unified mode concept (its first —bin-tuihas none today, unlikebin-desktop‘snav::InputMode), withSearchas a genuineShell-level mode rather than per-view filter state — the three existing views’ own working/filtering would need to either stay as-is under a new umbrella or actually migrate into it. Confirmed with the user as documentation-only for now (see “Modes” above); real enough to ticket once someone picks it up.
Transactions — Development
This document covers how transactions are structured in the codebase. Start with the end-user documentation if you’re unfamiliar with the domain.
Overview
Personal Ledger transactions are single-entry records, not double-entry — a Transaction is one row linking to exactly one Account or Credit Card Account, on a date, with one or more Splits representing category breakdowns. This design is recorded in ADR-0001.
Each Transaction carries:
- A stable
RowID(UUIDv7-based) - A date
- A link to exactly one Account or Credit Card Account
- A Transaction Status (Open, Cleared, or Reconciled)
- A Flagged marker (independent of status)
- One or more Splits
Each Split carries:
- An amount
- A Category
- An optional Payee
- Zero or more Tags
- Shared transaction date and account from its Transaction
Database schema
Transactions live in lib-database under a (placeholder names until schema is finalised):
transactionstable: id (RowID), account_id, date, status, flagged, created_at, updated_attransaction_splitstable: id (RowID), transaction_id, category_id, amount, payee_id (nullable), created_at, updated_atsplit_tagstable: id (RowID), split_id, tag_id, created_at, updated_at
The Balance of a Transaction Account or Credit Card Account is derived from all its Transactions’ Splits’ amounts summing to the account total, not stored directly — see lib-database’s find.rs / balance-calculation modules for how that’s computed.
CRUD structure
Following the workspace convention, transaction CRUD is split per-file in lib-database/src/transactions/:
model.rs— Transaction and Split domain typesfind.rs— queries to retrieve transactionsinsert.rs— queries to create transactions and splitsupdate.rs— queries to modify transactions (status, flagged, move to another account)delete.rs— queries to remove transactionsbuilder.rs— helper for constructing transactions programmatically in tests
Splits are treated as part of the Transaction’s internal shape, not exposed as a separate CRUD surface — a Transaction and its Splits are a logical unit, and updates to one Imply updates to the related Splits.
Status workflow
Transaction Status (Open → Cleared → Reconciled) is a simple field, not a state machine enforced at the database level. The key constraint is in update.rs: a Reconciled transaction’s other fields (amount, category, payee, date, account) cannot be modified until its Status is moved back to Open or Cleared first. This is enforced at the query level, not at the application boundary.
The Status change is always an explicit user action — the system never infers or auto-applies status changes (e.g. no automatic Cleared just because a Balance Check matched it).
Flagged marker
Flagged is an independent boolean field on Transaction, separate from Status. A Transaction can be Flagged at any point in the reconciliation workflow. This is distinct from a Transaction “carrying a Bill” (a derived fact of a Bill Schedule link), which is a separate concept recorded in Bill Schedule, not on Transaction itself.
Payee auto-creation
When a Split is created with a payee name that doesn’t exist, the Payee is auto-created from the name (see ADR-0012). The insert flow handles this transparently — the name is resolved to or creates a Payee, and the Split links to its id. The TUI/Desktop app typically handles prompting the user for confirmation or offering a merge with an existing Payee before the insert happens.
Splitting across accounts (same-Unit transfers)
A Transaction cannot move value between two accounts directly — a Split is always against the Transaction’s Account. Transferring between two Accounts of the same Unit is an ordinary pair of Transactions: one posting a negative amount to the source account and a positive amount to the destination, with a pair of tags or a shared description so the user can see they’re related.
Cross-Unit transfers are not possible in V1 (see CONTEXT.md — Unit, and product-requirements.md — Constraints).
Moving a transaction to another account
The update.rs module includes logic to move a Transaction from one Account to another, but only if both Accounts share the same Unit. The move reverses the transaction’s effect on the old Account’s balance and reapplies it to the new one — both Balance calculations flow through the account’s aggregate find.rs function, so no explicit balance-update step is needed.
What’s not yet built
- Reconciliation screen: the workflow for formally matching transactions against a statement and marking them Reconciled is future design work (TXN-009).
- CSV import: parsing and inserting transactions from a CSV feed (TXN-010).
- Bank-feed sync: pulling transactions directly from a bank (future consideration, out of scope for V1).
Related codebase areas
lib-core—RowID, domain types likeCategoryTypeslib-database— Transaction and Split CRUD, Balance calculationbin-tui— TUI screens for recording and reviewing transactionsbin-desktop— desktop app screens (under development)
