Budget

NAME

App::Moneymoor::Service::Budget - the pure envelope-budgeting derivation, and the specification of what every number means.

SYNOPSIS


use App::Moneymoor::Service::Budget;

my $view = compute(
    :@accounts, :@categories, :@transactions, :@splits, :@assignments,
    through-period => '2026-04-01',
);

say $view.periods;                      # ('2026-03-01', '2026-04-01')
say $view.rta('2026-03-01');            # Ready to Assign, in pence

my $groceries = $view.category('2026-03-01', $groceries-id);
say $groceries.assigned;                # 40000
say $groceries.activity;                # -37250
say $groceries.available;               # 2750
say $groceries.flags;                   # ()

# Why does the Visa payment envelope hold £372.50?
.say for $view.moves-for('2026-03-01', category-id => $visa-payment-id);
# 2026-03-01 card-coverage 12 -> 4 £120.00
# 2026-03-01 card-coverage 13 -> 4 £252.50

# The engine checks itself:
say $view.invariant-errors;             # ()

DESCRIPTION

compute is a pure function. It takes lists of facts — accounts, categories, transactions, splits, assignments — and returns a BudgetView. It touches no database, no clock and no filesystem, which is what lets the property suite hammer it with hundreds of randomly generated budgets in a couple of seconds.

Nothing derived is ever stored. Balances, activity, available, Ready to Assign and credit-card payment moves are all recomputed from the facts on every call. A stored derived value is a cache, and a cache that disagrees with the transactions that produced it is worse than no budget at all.

All money is Int pence.

THE PERIOD IS THE KEYING DIMENSION

Everything below is derived per period, and a period is named by its own start date as a 'YYYY-MM-DD' string: '2026-03-01', '2026-08-14'. App::Moneymoor::Util::Period owns what a period is — a calendar month, a month anchored on payday, or an every-N-weeks pay window — and compute takes one as :$scheme.

The algebra that follows does not care. Rollover, coverage, the carry chain and Ready to Assign depend on exactly two properties of the keying dimension:

  • Facts bucket into exactly one period each, which is $scheme.period-of($date).

  • Periods are totally ordered, and the order is the order of the keys as text. Zero-padded ISO dates sort as strings exactly as they sort as dates, so the derivation compares, sorts and hashes period keys without parsing them and without consulting the scheme.

Nothing here reads a period's length. That is what makes a four-weekly budget and a calendar-month budget the same code: the degenerate case of the general one. Under the default monthly/1 scheme every period start is 'YYYY-MM-01', which is the calendar month the engine has always derived — the pre-period 'YYYY-MM' keys migrate by appending '-01' and no number changes.

:$scheme defaults to Period.default-scheme (monthly/1), and in this release every caller takes that default; Service::Workspace is where a budget's real scheme will be threaded in from.

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:

Σ available + RTA == Σ cash-account balances

That is the headline form, and it holds exactly whenever the budget has no future-dated assignments and no uncovered credit-card spending. The general form, which BudgetView.invariant-errors checks for every period, adds the two terms those features introduce:

Ī£ available(p) + RTA(p) + assigned-in-periods-after(p)
                            + credit-overspend(p)
        == cash-balance(p)
  • assigned-in-periods-after(p) — money you have already promised to a future period. Rule 4 removes it from Ready to Assign in every period, so it has to be added back to see the cash again.

  • credit-overspend(p) — card spending in period p that no category could fund. It is debt, not cash, and rule 3 writes it off at the period boundary, so it only ever appears in its own period.

Credit card accounts are not on the right-hand side. Their balance is debt; the cash you have set aside to pay it lives in the card's payment envelope, which is on the left-hand side.

THE FOUR RULES

Periods are derived in order, oldest first, each one starting from the previous period's carry. Within a period the order is: rule 1 (what each envelope has), rule 2 (card coverage), rule 3 (what carries), rule 4 (Ready to Assign).

RULE 1 — PER-CATEGORY AVAILABLE

available = carry-in + assigned + activity + moved-in - moved-out

activity is the sum of every split against the category, dated in this period, on an on-budget account — cash and credit alike. It is the "Activity" column of a budget screen. Splits on tracking accounts are ignored: tracking accounts are off budget.

moved-in / moved-out are the derived credit-card moves of rule 2, and are zero for every ordinary category.

Worked example. Groceries carries £27.50 in from the period before. In this one you assign £400 and spend £372.50 (£300 on the debit card, £72.50 on the Visa):

carry-in    27.50
    assigned   400.00
    activity  -372.50
    ---------------------
    available   55.00

RULE 2 — CREDIT-CARD COVERAGE

Spending on a credit card does not move cash, but it does commit cash: the money has to stay put until the statement is paid. So when you spend on a card from a funded category, the engine moves that money from the category into the card's payment envelope. This is derived on every recompute and never stored.

For each category, in each period:

S       = total card spending charged to the category this period
    base    = carry-in + assigned + activity + S - moved-out
              (i.e. everything the category has, before its card
               spending is taken out)
    covered = min(S, max(0, base))

covered is then distributed across that period's card-spending splits in (date, transaction id, split id) order — greedily, each split taking as much of the remaining coverage as it can — so every move points at a specific transaction and a specific card. Each produces a Move from the category to that card's payment envelope.

Taking the coverage from the period's totals rather than from the running balance at the instant of each purchase is deliberate: it is what makes the derivation independent of the order facts arrive in, and it is the only definition under which the master invariant survives rule 3 (a mid-period refund that fills an overspent category must retroactively fund the card spending it just paid for). The distribution is still walked in date order, so the Move log reads chronologically.

Worked example — full coverage. Groceries has Ā£400 assigned, Ā£0 carried in, and one Ā£72.50 Visa shop:

S       =  72.50        base    = 0 + 400 + (-72.50) + 72.50 = 400
    covered = min(72.50, 400) = 72.50

Groceries available £327.50; the Visa payment envelope gains £72.50. Cash never moved, and £400 is still £400.

Worked example — partial coverage. Same category with only Ā£50 assigned:

S       =  72.50        base    = 50
    covered = min(72.50, 50) = 50

Groceries available is 50 - 72.50 = -£22.50 (overspent, in red); the Visa payment envelope gains £50. The remaining £22.50 is credit overspending: real debt on the card that no envelope is funding. See rule 3.

Three more card cases, all derived, all logged as moves:

  • Refund on a card (a positive split): the category gets the money back as activity, and the payment envelope gives up the cash it had reserved — a card-refund move from the payment envelope to the category. It is not capped: refunding more than you have reserved pushes the payment envelope negative, which is flagged (payment-negative) rather than rejected, because it is a true statement about your budget.

  • Paying the card (a transfer from a cash account to the card): the payment envelope's activity, straight up. Cash goes down, the envelope goes down, the debt goes down. Symmetrically, a cash advance (card to cash account) raises both.

  • An inflow to Ready to Assign recorded on a card (a positive split against the rta category): the money lands on the card rather than in the bank, so it reduces debt instead of raising cash. Ready to Assign rises and the payment envelope falls by the same amount — a card-inflow-to-rta move releasing the cash that was reserved for the debt it just cancelled. This is the only treatment consistent with the master invariant: adding it to the payment envelope instead would create envelope money that no cash account backs.

RULE 3 — ROLLOVER

At the period boundary each envelope's available becomes the next period's carry-in, with negative balances split into two very different kinds of overspending. What that split is depends on one property of the envelope — whether it carries a negative, which Model::Category.carries-negative answers:

credit-overspend = S - covered          (uncovered card spending)
    cash-overspend   = carries ?? 0 !! max(0, -base)
    carry-out        = available + credit-overspend + cash-overspend
  • Credit overspending is written off at the boundary, whatever kind of envelope it happened in. The debt stays on the card — that is what makes it debt — and no envelope and no future Ready to Assign is charged for it. The category is flagged credit-overspend for the period it happened in.

  • Cash overspending is money you spent that you did not have, and it is the half this rule has two answers for.

The forcing rule is the default and covers every envelope unless it has been told otherwise: the category resets to zero and the next period's Ready to Assign is reduced by exactly that amount, in every subsequent period (rule 4 subtracts the running total). You cannot spend your way out of a hole by ignoring it. The row is flagged cash-overspend.

Carrying is the other: the negative available becomes the next period's carry-in intact, and nothing is charged to Ready to Assign. There is no cash overspending on such an envelope by definition — the hole was not written off, so it does not need paying for a second time — which is why cash-overspend is zero and the row is flagged carried-negative instead. Two kinds of envelope carry:

  • Payment envelopes, always and by kind. A negative payment envelope means "you owe more on this card than you have set aside", which is precisely the thing you want to keep seeing until you fix it. Its flag is payment-negative rather than carried-negative, because the two states read differently to a user even though the arithmetic is identical: one is a card you are behind on, the other is a choice you made about an envelope.

  • Standard envelopes with carry-overspend set, which is that choice — for the envelope you deliberately run negative, like a reimbursable expense. It is a per-envelope flag with no default but False, so a budget file that has never heard of it behaves exactly as it did. See App::Moneymoor::Model::Category.

compute is a pure derivation over the facts, so the flag is retroactive by construction: turning it on re-derives every period under the carrying rule, past Ready to Assign figures included. That is the honest reading of "this envelope was always allowed to run negative", and there is no other one available to a function that stores nothing.

Worked example. The partial-coverage Groceries above ends the period at -Ā£22.50, all of it uncovered card spending:

credit-overspend = 72.50 - 50 = 22.50
    cash-overspend   = max(0, -50) = 0
    carry-out        = -22.50 + 22.50 + 0 = 0

The next period's Groceries starts at zero and Ready to Assign is untouched. Had the same Ā£72.50 been spent on the debit card, S would be 0, base would be -22.50, and the whole Ā£22.50 would be cash overspending — the next period's Ready to Assign Ā£22.50 lighter.

Worked example — the same hole, carried. Give that Groceries row carry-overspend and spend the Ā£72.50 on the debit card instead:

credit-overspend = 0
    cash-overspend   = 0                    (it carries)
    carry-out        = -22.50 + 0 + 0 = -22.50

Next period's Groceries starts £22.50 down and Ready to Assign is untouched in every period. Assigning £22.50 into it fills the hole and ends the chain; ignoring it leaves the row in the red until you do.

RULE 4 — READY TO ASSIGN

RTA(p) = Σ inflow-to-RTA through p
           - Σ assigned in ALL periods, including future ones
           - Σ cash-overspend through the period before p

The middle term is the interesting one. Assignments to future periods are deducted immediately, from every period's Ready to Assign, not just from the period they land in. Funding December's council tax in August works exactly as you would hope: December's envelope has the money, and August stops offering it to you.

Ready to Assign can go negative. That is not an error — it is the statement "you have assigned money you do not have", and it is flagged rta-negative on the period.

Worked example. £1,800 salary arrives in the August period. You assign £600 there and £300 to December's council tax:

RTA(2026-08-01) = 1800 - (600 + 300) - 0 = 900
    RTA(2026-12-01) = 1800 - (600 + 300) - 0 = 900   (same money,
                                                      still only spent
                                                      once)

If August's Dining Out then overspends by £40 in cash:

RTA(2026-09-01) = 1800 - 900 - 40 = 860

WARNINGS, NOT EXCEPTIONS

compute is total: no fact combination makes it throw. Facts that cannot be interpreted are skipped and recorded in BudgetView.warnings — an unknown account or category, a malformed date, an assignment whose key is not a period start under the scheme, a transaction whose splits do not sum to its amount, an uncategorized transaction on an on-budget account, a credit account with no payment envelope, splits attached to an on-budget transfer.

The gateways make all of these impossible to store; the warnings exist because compute is also called on hand-built facts (tests, imports, a future undo buffer) and silently producing a wrong number is the one outcome a budget cannot tolerate. The assignment case is the sharpest: a key that is well-formed but is not a start under this scheme — a row written under a different scheme, or an unmigrated 'YYYY-MM' — would otherwise open a phantom bucket that the totals happily sum alongside the real ones.

SUBROUTINES

  • compute(:@accounts, :@categories, :@transactions, :@splits, :@assignments, Str :$through-period, Period :$scheme -- BudgetView)> — the derivation. The five fact lists hold App::Moneymoor::Model::* objects (Account, Category, Transaction, Split, Assignment); any may be empty. :$scheme is an App::Moneymoor::Util::Period and defaults to monthly/1, the calendar month. :$through-period extends the derived range in either direction, so a caller can ask for "the period we are in" even when the newest fact is older (or newer); it must itself be a period start under :$scheme, and is ignored with a warning when it is not. Note that compute never reads the clock — deciding which period is "now" is Service::Workspace's job.

  • valid-date(Str $d -- Bool)> — shape and calendar validity ('2026-02-30' is False).

  • valid-period(Str $p -- Bool)> — the same check under the name that says why the engine wants it: a period key is a date, because a period is named by its start. Format only — start-ness needs a scheme, and callers that hold one ask it with $scheme.period-of($p) eq $p.

  • period-range(Period:D $scheme, Str $from, Str $to -- Array)> — the inclusive run of starts the derivation walks, capped at MAX-PERIODS (1200). Empty when $to lt $from; a Failure when either endpoint is not a start under $scheme. Util::Period.periods-through is the uncapped form — the cap is an engine-level typo guard, not a fact about schemes.

CLASSES

All are plain value objects with no behaviour beyond accessors and the lookups documented here.

  • Move — period, from-category-id, to-category-id, amount (always positive), cause (card-coverage / card-refund / card-inflow-to-rta), account-id, transaction-id.

  • CategoryPeriod — period, category-id, carry-in, assigned, activity, moved-in, moved-out, available, credit-overspend, cash-overspend, carry-out, flags.

  • BudgetPeriod — period, rta, cash-balance, assigned-total, assigned-future, credit-overspend-total, cash-overspend-total, category-periods, moves, flags, category($id) and has-category($id). category-periods holds only the categories that had something to say that period; category($id) answers for any category, with a zero-filled row when there is no stored one.

  • AccountBalance — account-id, cleared, uncleared, working (cleared + uncleared).

  • BudgetView — periods, period($p), category($p, $id), rta($p), moves, moves-for($p, :$category-id), account-balances, account-balance($id), warnings, invariant-errors, canonical-digest.

SEE ALSO

App::Moneymoor v0.4.2

YNAB-style envelope budgeting: a derivation engine

Authors

  • Matt Doughty

License

Artistic-2.0

Dependencies

DBIish:ver<0.6.7>:auth<zef:raku-community-modules>Notcurses::Native:ver<0.6.5+>:auth<zef:apogee>Selkie:ver<0.16.0+>:auth<zef:apogee>JSON::Fast:ver<0.19>:auth<cpan:TIMOTIMO>MacOS::NativeLib:ver<0.0.6>:auth<zef:lizmat>

Test Dependencies

Provides

  • App::Moneymoor
  • App::Moneymoor::Config
  • App::Moneymoor::DB
  • App::Moneymoor::Gateway::Account
  • App::Moneymoor::Gateway::Assignment
  • App::Moneymoor::Gateway::Category
  • App::Moneymoor::Gateway::Payee
  • App::Moneymoor::Gateway::Transaction
  • App::Moneymoor::Handlers::Boot
  • App::Moneymoor::Model::Account
  • App::Moneymoor::Model::Assignment
  • App::Moneymoor::Model::Category
  • App::Moneymoor::Model::CategoryGroup
  • App::Moneymoor::Model::Payee
  • App::Moneymoor::Model::Split
  • App::Moneymoor::Model::Transaction
  • App::Moneymoor::Screen::Accounts
  • App::Moneymoor::Screen::Budget
  • App::Moneymoor::Screen::Login
  • App::Moneymoor::Screen::Main
  • App::Moneymoor::Screen::Main::Keybinds
  • App::Moneymoor::Screen::Main::Modals
  • App::Moneymoor::Screen::Main::Subscriptions
  • App::Moneymoor::Screen::Reports
  • App::Moneymoor::Service::Budget
  • App::Moneymoor::Service::Icons
  • App::Moneymoor::Service::Target
  • App::Moneymoor::Service::Workspace
  • App::Moneymoor::StoreHandlers
  • App::Moneymoor::Theme
  • App::Moneymoor::Theme::Catppuccin
  • App::Moneymoor::Theme::Dracula
  • App::Moneymoor::Theme::Everforest
  • App::Moneymoor::Theme::Gruvbox
  • App::Moneymoor::Theme::Kanagawa
  • App::Moneymoor::Theme::Monokai
  • App::Moneymoor::Theme::Nord
  • App::Moneymoor::Theme::OneDark
  • App::Moneymoor::Theme::RosePine
  • App::Moneymoor::Theme::Solarized
  • App::Moneymoor::Theme::TokyoNight
  • App::Moneymoor::Themes
  • App::Moneymoor::UI
  • App::Moneymoor::Util::Money
  • App::Moneymoor::Util::Period
  • App::Moneymoor::View::BudgetRow
  • App::Moneymoor::View::EmptyState
  • App::Moneymoor::View::HintBar
  • App::Moneymoor::View::InspectorPane
  • App::Moneymoor::View::ModalChrome
  • App::Moneymoor::View::RegisterRow
  • App::Moneymoor::View::ReportRow
  • App::Moneymoor::Widget::BannerBar
  • App::Moneymoor::Widget::BootProgressModal

The Camelia image is copyright 2009 by Larry Wall. "Raku" is a trademark of the Yet Another Society. All rights reserved.

Built with Podlite — the markup and publishing tools behind this site.