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 periodpthat 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-refundmove 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
rtacategory): 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 ā acard-inflow-to-rtamove 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-overspendfor 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-negativerather thancarried-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-overspendset, 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 holdApp::Moneymoor::Model::*objects (Account,Category,Transaction,Split,Assignment); any may be empty.:$schemeis anApp::Moneymoor::Util::Periodand defaults tomonthly/1, the calendar month.:$through-periodextends 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 thatcomputenever reads the clock ā deciding which period is "now" isService::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 atMAX-PERIODS(1200). Empty when$to lt $from; aFailurewhen either endpoint is not a start under$scheme.Util::Period.periods-throughis 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)andhas-category($id).category-periodsholds 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::Util::Period ā what a period is, and the only module that knows.
App::Moneymoor::Service::Workspace ā loads the facts, owns the scheme, and is the only layer that reads the clock.