=begin pod

=head1 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 B<engine>
that stores only what you authored, and a B<terminal UI> built on
L<Selkie|https://raku.land/zef:apogee/Selkie> that drives it.

=begin code :lang<shell>
moneymoor                             # the TUI
MONEYMOOR_HOME=/tmp/demo moneymoor    # ...against a throwaway data home
=end code

![A budget created, funded and spent, live in the terminal](docs/demo.gif)

(The recording is scripted — C<xxt/demo/record.sh> regenerates it with
L<vhs|https://github.com/charmbracelet/vhs> against a throwaway data
home.)

=head2 Installing

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

=head3 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:

=begin code :lang<shell>
curl -fsSL https://raw.githubusercontent.com/m-doughty/App-Moneymoor/HEAD/install.sh | sh
=end code

On Windows, PowerShell:

=begin code :lang<shell>
irm https://raw.githubusercontent.com/m-doughty/App-Moneymoor/HEAD/install.ps1 | iex
=end code

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 C<moneymoor> onto
your C<PATH>. Re-running it upgrades in place — a failed download can
never break the version you already have — and C<uninstall.sh> /
C<uninstall.ps1> take it all off again, including the C<PATH> entry.

=head3 zef — Raku already installed

=begin code :lang<shell>
zef install App::Moneymoor
=end code

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

=item B<macOS> — C<brew install sqlcipher>. C<bin/moneymoor> finds the
      keg by itself on first launch.
=item B<Debian / Ubuntu> — C<apt install libsqlcipher-dev>. On
      releases that package soname 1 (Debian 13, Ubuntu 24.04+), also
      C<export DBIISH_SQLCIPHER_LIB=/usr/lib/x86_64-linux-gnu/libsqlcipher.so.1>
      — DBIish's own lookup only knows soname 0.
=item B<Fedora / RHEL> — C<dnf install sqlcipher>, then point
      C<DBIISH_SQLCIPHER_LIB> at the library it installed (EPEL ships
      it under a versioned name like C<libsqlcipher-3.34.1.so.0>).
=item B<Windows> — build C<sqlcipher.dll> with
      C<vcpkg install sqlcipher:x64-windows> and put its directory on
      C<PATH>.

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

=head2 The app

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

Budgets live in the B<data home>, C<~/.moneymoor/> — one encrypted
C<*.db> file each, plus C<config.json> and an error log. Point
C<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 C<Ctrl+N> — offers to create one. That
passphrase B<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 — C<1> / C<2> / C<3> (or C<Ctrl+1> / C<Ctrl+2> /
C<Ctrl+3> on terminals speaking the kitty keyboard protocol):

=item B<Budget> — the envelope grid for one month, grouped, with
      Ready-to-Assign above it and a rail beside it that shows exactly
      how an envelope's available figure was arrived at. C<a> assigns,
      C<m> moves money between envelopes, C<f> funds every underfunded
      target at once, C<x> explains a derived number, C<[> and C<]>
      change month.
=item B<Accounts> — ledgers on the left, register on the right. C<n>
      adds a transaction (splits and all), C<t> a transfer, C<c>
      cycles uncleared → cleared → reconciled, C<Ctrl+R> reconciles
      against a statement balance.
=item B<Reports> — the month's cash flow, and where the money went.

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

=head2 The idea

You do not budget the money you are going to earn. You budget the
money you B<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 B<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.

=head2 Targets, and the two ways to hit them

Give an envelope a B<monthly target> — in its editor, C<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:

=item The grid grows a B<Target> column, blank for the envelopes you
      have not set one on, and each group header shows what its
      envelopes want between them.
=item The detail rail says how far off you are: C<To fund  +£72.50>
      in amber, or C<Target met>.
=item The assign field learns two shapes. C<=450> means "make this
      envelope's available £450" — not "assign £450", which is a
      different number the moment anything carried over or was spent.
      A bare C<=> means "make it the target". Both are ordinary
      assignments underneath, by exactly the difference.

And C<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 B<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 B<view-layer> idea, deliberately. C<Service::Budget> has
never heard of one: "underfunded" is C<max(0, target - available)>,
computed where it is drawn. A target moves no money by itself. Only
C<=>, C<f> and your own typing do.

=head2 The master invariant

Envelopes partition B<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 B<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)

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

=head2 When an envelope goes red

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

=item B<Red> — you spent cash you did not have. The envelope resets to
      zero and Ready to Assign is charged for the shortfall, in that
      period and every one after it. This is the default and it is the
      rule that makes the method work: you cannot spend your way out of
      a hole by ignoring it.
=item B<Amber> — you spent on a credit card from an envelope that could
      not cover it. That is debt on the card, so it is written off at
      the boundary and nothing else is charged for it.
=item B<Purple> — the negative carries forward intact and nothing is
      charged to Ready to Assign. A card's payment envelope always does
      this ("you owe more than you have set aside"), and so does any
      envelope you tick B<Carry overspending> on in its editor — the
      one you deliberately run negative, like an expense you will be
      reimbursed for.

The toggle is per envelope and off by default, so nothing changes until
you ask for it. It is also B<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".

=head2 Using the engine directly

=begin code :lang<raku>

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;

=end code

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

=begin code :lang<raku>
use App::Moneymoor::Util::Money;

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

Settings (C<ctrl+,>) picks the currency symbol (C<£>, C<$>, C<€> —
display only; nothing converts) and the number format (C<1,234.56> or
C<1.234,56>). Both are saved to C<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 C<'1,50'> in C<.> mode is an error rather than a
100x-too-large assignment:

=begin code :lang<raku>
set-money-locale(symbol => '€', decimal-mark => ',');

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

=head2 What is in the box

=item C<App::Moneymoor::DB> — SQLCipher connection, idempotent
      migrations re-run on every connect, and C<run-txn> for
      multi-statement atomicity.
=item C<App::Moneymoor::Model::*> — attribute-only row classes with
      C<new-from-row> mapping and predicate methods.
=item C<App::Moneymoor::Gateway::*> — the SQL, plus the invariants
      that must hold before a row is written: splits sum to their
      transaction, transfers come in pairs, every credit account owns
      exactly one payment envelope, system rows cannot be deleted.
=item C<App::Moneymoor::Service::Budget> — the pure derivation, and
      the specification of the semantics.
=item C<App::Moneymoor::Service::Workspace> — gateways in, derived
      budget out, plus C<set-assigned> and C<move-money>.
=item C<App::Moneymoor::Util::Money> — pence ↔ C<"£12.34">, in the
      display locale the settings chose.

And the UI, which never touches SQL:

=item C<App::Moneymoor::UI> — app construction, the login handshake,
      the hand-off to the main screen.
=item C<App::Moneymoor::Screen::*> — Login, the shell, one controller
      per tab.
=item C<App::Moneymoor::StoreHandlers> — the state shape, and the one
      effect every mutation in the app funnels through.
=item C<App::Moneymoor::View::*> — the row and span builders. Pure
      functions: no widget code decides what a number says.
=item C<App::Moneymoor::Theme> / C<Themes> / C<Service::Icons> — the
      eleven palettes and the two glyph tiers.

=head2 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).

=head2 Portability

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

=head2 Testing

=begin code :lang<shell>
prove6 -Ilib -I../Selkie/lib -I../Notcurses-Native/lib t/
=end code

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:
C<Selkie::App> calls C<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.

=head1 AUTHOR

Matt Doughty <matt@apogee.guru>

=head1 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.

=end pod
