Rand Stats

App::Moneymoor

zef:apogee

App::Moneymoor

Envelope budgeting for Raku — the "give every pound a job" school (the model YNAB popularised), as a terminal app over an encrypted SQLite (SQLCipher) file.

Two halves, and either is usable on its own: a derivation engine that stores only what you authored, and a terminal UI built on Selkie that drives it.

moneymoor                             # the TUI
MONEYMOOR_HOME=/tmp/demo moneymoor    # ...against a throwaway data home

A budget created, funded and spent, live in the terminal

(The recording is scripted — xxt/demo/record.sh regenerates it with vhs against a throwaway data home.)

Installing

Two ways in, depending on whether the machine already speaks Raku.

The installer — no Raku required

From the first tagged release onwards, one command installs a self-contained bundle: a Rakudo runtime, every module precompiled, notcurses and SQLCipher carried along. Nothing is compiled on your machine, nothing needs root, and everything lands under your own home directory:

curl -fsSL https://raw.githubusercontent.com/m-doughty/App-Moneymoor/main/install.sh | sh

On Windows, PowerShell:

irm https://raw.githubusercontent.com/m-doughty/App-Moneymoor/main/install.ps1 | iex

The script works out which bundle your machine takes (macOS arm64, Linux x86_64 with glibc 2.28+, or Windows x86_64), downloads it from the releases page, verifies its sha256, and links moneymoor onto your PATH. Re-running it upgrades in place — a failed download can never break the version you already have — and uninstall.sh / uninstall.ps1 take it all off again, including the PATH entry.

zef — Raku already installed

zef install App::Moneymoor

The one thing zef cannot bring along is the sqlcipher shared library:

The Portability section below has the why; the pure engine — every budgeting rule and its tests — needs no native library at all.

The app

bin/moneymoor is the whole user interface. --version and --help are the only things it will do without a terminal.

Budgets live in the data home, ~/.moneymoor/ — one encrypted *.db file each, plus config.json and an error log. Point MONEYMOOR_HOME somewhere else and all three move together, which is what makes a throwaway run safe.

The first screen lists the budgets it found and asks for a passphrase, or — with none to list, or on Ctrl+N — offers to create one. That passphrase is the SQLCipher key. There is no reset, no recovery question, and no copy of it anywhere: lose it and the file is noise. A wrong passphrase is reported differently from a wrong file, because they are different mistakes.

Inside are three tabs — 1 / 2 / 3 (or Ctrl+1 / Ctrl+2 / Ctrl+3 on terminals speaking the kitty keyboard protocol):

The bottom line always shows the keys that apply to whatever has focus; Ctrl+H lists all of them. Ctrl+G opens diagnostics — the derivation's warnings, invariant errors, and a digest fingerprint safe to paste into a bug report. Ctrl+, opens settings: eleven palettes and two glyph tiers (plain Unicode, or Nerd Font), applied live and remembered.

The idea

You do not budget the money you are going to earn. You budget the money you have, by giving every pound of it a job: rent, groceries, the December car insurance bill. When a job costs more than you gave it, you take the money from another job and watch that trade-off happen. That is the whole method, and it only works if the arithmetic is trustworthy.

So Moneymoor stores only what you authored — accounts, categories, transactions with their splits, and per-month assignments — and derives everything else on demand with a pure function. Balances, activity, available, Ready to Assign, credit-card payment reserves: all recomputed, never stored. A stored derived number is a cache, and a cache that disagrees with the transactions that produced it is worse than no budget at all.

Targets, and the two ways to hit them

Give an envelope a monthly target — in its editor, e on the grid — and you have said "I want this much available in here each month". Rent £750, Groceries £400. Blank the field to take the target away again.

Three things follow:

And f does the lot: it lists every visible envelope that is short of its target, with what each would take, the total, and what Ready to Assign will be afterwards — then applies all of it in one write, so the budget is derived once and cannot end up half-funded. It only ever adds; an envelope already over its target is left alone. If the total would push Ready to Assign below zero the dialog says so in red, and still lets you do it — that is a real step on the way to a plan, and the pill above the grid will keep saying so for as long as it is true.

Targets are a view-layer idea, deliberately. Service::Budget has never heard of one: "underfunded" is max(0, target - available), computed where it is drawn. A target moves no money by itself. Only =, f and your own typing do.

The master invariant

Envelopes partition cash. Every pound in a cash account is either sitting in an envelope or sitting in Ready to Assign, and no pound is in two places at once:

Σ available + RTA == Σ cash-account balances

Credit cards are not on the right-hand side: their balance is debt. What is on the left is the card's payment envelope — the cash you have set aside to pay it, which the engine fills automatically every time you spend on the card from a funded category.

The general form of the invariant, which the engine checks for every month and the property suite asserts after every single operation, adds the two terms that future-month budgeting and credit-card overspending introduce:

Σ available(m) + RTA(m) + assigned-in-months-after(m)
                        + credit-overspend(m)
    == cash-balance(m)

App::Moneymoor::Service::Budget's Pod is the full specification — four rules, each with worked numbers.

When an envelope goes red

Three different things, three colours, and the difference is what happens at the period boundary:

The toggle is per envelope and off by default, so nothing changes until you ask for it. It is also retroactive, because the budget is derived rather than stored: turning it on re-derives every period under the new rule, including Ready to Assign in periods that have already been and gone. That is the honest answer to "this envelope was always allowed to run negative".

Using the engine directly

use MacOS::NativeLib <sqlcipher>;   # macOS only; see PORTABILITY
use App::Moneymoor::DB;
use App::Moneymoor::Service::Workspace;
use App::Moneymoor::Model::Account;
use App::Moneymoor::Model::Category;
use App::Moneymoor::Model::Transaction;
use App::Moneymoor::Model::Split;

my $db = App::Moneymoor::DB.new(:db-path("$*HOME/.moneymoor/budget.db"));
my $opened = $db.connect('correct horse battery staple');
die $opened.exception.message if $opened ~~ Failure;

my $ws = App::Moneymoor::Service::Workspace.new(:$db);

# Two accounts. The credit card gets a payment envelope automatically,
# in the same SQL transaction.
my $current = $ws.accounts.create(App::Moneymoor::Model::Account.new(
    name => 'Current Account', type => 'cash'));
my $visa = $ws.accounts.create(App::Moneymoor::Model::Account.new(
    name => 'Visa', type => 'credit'));

my $food = $ws.categories.create(App::Moneymoor::Model::Category.new(
    name => 'Groceries'));
my $rta = $ws.categories.rta-category;

# £1,800 of salary in. Inflows are categorized to Ready to Assign.
$ws.transactions.create(
    App::Moneymoor::Model::Transaction.new(
        account-id => $current.id, date => '2026-03-01', amount => 180000),
    splits => [App::Moneymoor::Model::Split.new(
        category-id => $rta.id, amount => 180000)],
);

# Give £400 of it a job. Budgets are keyed by period start; under the
# default calendar-month scheme that is the first of the month.
$ws.set-assigned('2026-03-01', $food.id, 40000);

# £72.50 of groceries, on the card.
$ws.transactions.create(
    App::Moneymoor::Model::Transaction.new(
        account-id => $visa.id, date => '2026-03-14', amount => -7250),
    splits => [App::Moneymoor::Model::Split.new(
        category-id => $food.id, amount => -7250)],
);

my $view = $ws.budget(through-period => '2026-03-01');

say $view.rta('2026-03-01');                            # 140000
say $view.category('2026-03-01', $food.id).available;   # 32750
say $view.category('2026-03-01',
        $ws.accounts.payment-category-for($visa.id).id).available;  # 7250

# Why does the payment envelope hold £72.50?
.say for $view.moves-for('2026-03-01');
# 2026-03-01 card-coverage 3 -> 2 £72.50
#   (out of Groceries, into the Visa payment envelope)

say $view.invariant-errors;                          # []

$db.disconnect;

All money is Int pence. There are no floats and no Rats anywhere in the engine, the schema or the gateways — App::Moneymoor::Util::Money is the only place pence meet human-readable text:

use App::Moneymoor::Util::Money;

say format-pence(-123456);   # -£1,234.56
say parse-pence('£12.34');   # 1234

Settings (ctrl+,) picks the currency symbol (£, $, — display only; nothing converts) and the number format (1,234.56 or 1.234,56). Both are saved to config.json and applied without a restart. The decimal mark drives parsing too, and thousands are grouped with whichever separator the decimal mark is not, in exact groups of three — so '1,50' in . mode is an error rather than a 100x-too-large assignment:

set-money-locale(symbol => '', decimal-mark => ',');

say format-pence(-123456);   # -€1.234,56
say parse-pence('1.234,56'); # 123456

What is in the box

And the UI, which never touches SQL:

Not in v0.1

Scheduled transactions, CSV and bank imports, multi-currency, incremental rollup caching, and net worth over time (which wants a per-month, per-account balance derivation the engine does not have yet).

Portability

The sqlcipher shared library has to be loadable by NativeCall. On macOS put use MacOS::NativeLib <sqlcipher>; before the first use App::Moneymoor::DB. On Linux the system loader manages by itself only when the library's soname is libsqlcipher.so.0 — the distros that ship soname 1 (Debian 13, Ubuntu 24.04+) need DBIISH_SQLCIPHER_LIB pointed at the library file, which is loaded verbatim. On Windows the DLL has to be on PATH. The pure engine — Service::Budget, Util::Money, every Model — needs no native library at all, which is why the budgeting tests run everywhere regardless.

Testing

prove6 -Ilib -I../Selkie/lib -I../Notcurses-Native/lib t/

Forty-four files. Sixteen are the engine's: the DB layer, every gateway, one per budgeting rule, an explain-the-number walkthrough, and a seeded-PRNG property suite that builds hundreds of random-but-legal budgets one operation at a time and asserts, after every single one, that the master invariant holds, that the move ledger is a consistent double entry, and that recomputing from the same facts in a different order gives a byte-identical result.

The rest are the UI's, and none of them needs a terminal: Selkie::App calls notcurses_init in its constructor, so every view function is pure and tested as one, and every tab is mounted and driven through a real store over a real SQLCipher budget with the app itself stubbed out.

AUTHOR

Matt Doughty matt@apogee.guru

COPYRIGHT AND LICENSE

Copyright 2026 Matt Doughty

This library is free software; you can redistribute it and/or modify it under the Artistic License 2.0.