Workspace
NAME
App::Moneymoor::Service::Workspace - the seam between the database and the pure budget derivation.
SYNOPSIS
use MacOS::NativeLib <sqlcipher>;
use App::Moneymoor::DB;
use App::Moneymoor::Service::Workspace;
my $db = App::Moneymoor::DB.new(:db-path($path));
$db.connect($passphrase);
# The budget's period scheme comes out of the file it was opened
# from ā there is no :scheme argument, and passing one is an error.
my $ws = App::Moneymoor::Service::Workspace.new(:$db);
say $ws.scheme.gist; # Period(monthly/1) on a file
# that has never chosen one
# Gateways, already wired to the same connection:
my $current = $ws.accounts.create(
App::Moneymoor::Model::Account.new(name => 'Current', type => 'cash'));
my $food = $ws.categories.create(
App::Moneymoor::Model::Category.new(name => 'Groceries'));
# Mutations that are really budgeting operations. The keys are period
# starts; under the default calendar-month scheme that is the first of
# the month.
$ws.set-assigned('2026-03-01', $food.id, 40000);
$ws.move-money('2026-03-01', $food.id, $dining.id, 5000);
# Targets are a budgeting operation too, and this is their front door:
# it stamps the plan start and normalises the fields the kind does not
# use.
$ws.set-target($food.id, kind => 'refill', pence => 40000);
$ws.set-target($tax.id, kind => 'by_period', pence => 5_000_000,
period => '2027-04-05'); # £50,000 by April
$ws.set-target($vat.id, kind => 'by_period', pence => 300000,
period => '2026-11-01', repeat => 3); # quarterly
# The whole derived budget, recomputed from facts:
my $view = $ws.budget(through-period => '2026-04-01');
say $view.rta('2026-03-01');
say $view.category('2026-03-01', $food.id).available;
# Or just the numbers you asked about:
say $ws.ready-to-assign('2026-03-01');
say $ws.available('2026-03-01', $food.id);
.say for $ws.explain('2026-03-01', $visa-payment-id);
# Which period are we in right now?
say $ws.current-period; # e.g. 2026-08-01
# Moving the whole budget onto payday windows. Ask first ā this is
# what a confirm dialog is made of:
my $payday = App::Moneymoor::Util::Period.monthly(anchor-day => 14);
my %plan = $ws.re-bucket-preview($payday);
say "{ %plan<rows> } assignments in { %plan<periods-before> } periods "
~ "become { %plan<rows-after> } in { %plan<periods-after> } "
~ "({ %plan<merged> } merged)";
# ...then do it. One transaction, totals preserved or nothing happens.
my %done = $ws.change-scheme($payday);
say $ws.scheme.label($ws.current-period); # 14 Aug ā 13 Sep 2026
DESCRIPTION
Service::Budget.compute is deliberately ignorant of storage: it
takes facts and returns a view. Workspace is the thin piece that
loads the facts through the gateways, calls it, and offers the
mutations that are budgeting operations rather than CRUD
(set-assigned, move-money and set-target).
It owns one gateway of each kind, all sharing the DB handle it was
constructed with, and exposes them as accounts, categories,
payees, transactions and assignments. Anything that is plain
CRUD goes through those directly ā wrapping every gateway method in a
pass-through would only add a second place for the signatures to
drift.
RECOMPUTATION IS THE WHOLE STRATEGY
budget reads every account, category, transaction, split and
assignment and derives the entire budget from scratch. There is no
incremental update path and no cached rollup in v0.1, on purpose: an
incremental rollup is a second implementation of the semantics that
has to agree with the first one forever, and the first thing it does
when it disagrees is quietly lose money.
For the size of budget a person actually has (a few thousand
transactions over a few years) a full recompute is milliseconds.
Callers that need it more often than that should hold the returned
BudgetView rather than call budget in a loop.
THE CLOCK LIVES HERE
compute never looks at the clock ā that is what makes it testable.
Someone still has to decide that "the budget runs through the period
we are in", and this is the only layer that may: budget defaults
:$through-period to current-period(), which reads the local date
and asks the scheme which period contains it. Pass :through-period
explicitly to pin it (every test in this distribution does).
SETTING A TARGET IS A BUDGETING OPERATION
set-target is to targets what set-assigned is to assignments:
the one call the app makes, sitting in front of a gateway that
deliberately knows less than it does. It is here rather than on
Gateway::Category for one reason ā it needs the clock, and
current-period is the only clock read in the distribution.
The stamp
A by_period target ramps from a plan start, and the plan start
is stamped, not inferred: "Ā£50,000 by April" means something
different begun in August than begun in February, and the difference
is the whole schedule. set-target stamps current-period when
the target becomes
by_periodā a new goal, or a kind change onto one; oran existing
by_periodtarget's amount or goal period changes ā the plan is a different plan, so it re-ramps from today rather than pretending it was always this one; orthe row somehow holds no start at all (a hand-edited file), because a plan without a start has no schedule to derive.
and keeps the existing stamp otherwise. The one change that
deliberately does not re-stamp is repeat: turning a one-shot goal
into a quarterly one, or changing "every 3" to "every 4", leaves the
cycle that is already under way exactly where it was.
Nothing else in the app may write target_start. Re-stamping is
what makes the ramp re-derive from where the envelope is now, and
a caller that could set it freely could put an envelope permanently
behind.
The normalising
Gateway::Category refuses a refill target that carries a goal
date, because an explicit caller with two ideas about what it is
storing should hear about it. This front door is the caller that
resolves them instead: a non-by_period kind arrives here with
whatever the form had in its unused fields and leaves with a NULL
goal, a NULL start and a repeat of 0.
The goal date itself is stored exactly as given ā it is not rounded to a period start. A date is read as "the period containing it", so a budget that changes its period scheme re-derives its plan under the new windows; a key normalised to the old scheme's start would name a period that no longer exists.
THE SCHEME LIVES HERE TOO
A budget period is a calendar month, a month anchored on payday, or an
every-N-weeks pay window, and which is a property of the budget
file, not of the engine. This layer is where that choice is held: the
scheme attribute is handed to the two gateways that validate period
keys (assignments and transactions) and to every compute call,
so a single object decides what a legal key is at the boundary and what
a bucket is inside the derivation. Two different answers to that
question in one process is precisely the failure that produces a budget
summed two ways.
It is loaded from the file, not passed in
The scheme belongs to the budget file ā two budgets on one machine
legitimately differ ā so it lives in budget_meta under the key
period_scheme, holding exactly what Period.to-hash produces, as
JSON:
{"anchor_day":14,"type":"monthly"}
{"anchor":"2026-08-14","type":"weekly","weeks":4}
TWEAK reads it before it builds a single gateway, so the two that
validate period keys are born holding the file's scheme rather than the
default. Absent is not an error: a file that has never chosen means
monthly/1, the calendar month, which is what every budget written
before periods existed was already doing.
A key that is present and unreadable ā malformed JSON, or a hash
Period.from-hash refuses ā throws, naming the key and the value
it found. Opening the file anyway would mean bucketing a budget under a
scheme its owner never chose: every derived figure would be summed by
the wrong windows, and the gateways' start-ness validation would then
reject every write the user attempted. A budget that will not open is
a problem with an obvious cause; one that opens under the wrong scheme
is not.
Because the file is the authority, there is no :scheme constructor
argument. Passing one throws rather than being quietly ignored ā a
caller that believed it had set the scheme and had not is the same
failure by a different route.
change-scheme is the only mutator
scheme is public-read and never assigned from outside this class.
The one path that changes it is change-scheme, which does not merely
record the new choice: it re-buckets the assignments to match. Each
assignment row moves to whichever period of the new scheme contains its
old period's start date, and rows that land together sum. That is
the whole rule, and three properties follow from it:
Total assigned per category is preserved. Nothing is created, dropped or moved between categories, only re-labelled ā so Ready to Assign and the master invariant come out exactly as they went in, which is why this operation needs no reconciliation step afterwards.
change-schemeasserts it at runtime, re-reading the per-category totals after the rewrite and refusing (rolling back, leavingperiod_schemeuntouched) if a single one has drifted.It is atomic. The read, the rewrite and the
budget_metawrite share one transaction, so a crash mid-change reopens the file under the old scheme with the old buckets, never as a mixture.It is deterministic and repeatable. Rows are re-inserted in
(period start, category)order, so the same budget re-bucketed the same way twice produces the same table.
It is lossy in the buckets, by design: two periods that merge cannot be told apart afterwards, so changing back does not restore the original split. The totals are what is guaranteed, and the totals are what the budget's arithmetic is made of.
An empty budget re-buckets nothing and just writes the key ā which is how a newly created budget adopts a non-default scheme. Calling it with the scheme already in force is a legal no-op that still writes the key: an explicit record beats an absent one, and the caller decides whether offering it makes sense.
ATTRIBUTES
dbā requiredApp::Moneymoor::DB.schemeā theApp::Moneymoor::Util::Periodthis workspace buckets and validates by, read from the file at construction. Not a constructor argument;change-schemeis the only mutator.accounts/categories/payees/transactions/assignmentsā the gateways, built at construction, the last two carryingscheme.
METHODS
budget(Str :$through-period --BudgetView)> ā load facts, derive, return.set-assigned(Str:D $period, Int:D $category-id, Int:D $amount)move-money(Str:D $period, Int:D $from-id, Int:D $to-id, Int:D $amount)set-target(Int:D $category-id, Str:D :$kind!, Int:D :$pence!, :$period, Int :$repeat = 0)ā set an envelope's whole target, stamping the plan start and normalising the fields the kind does not use.:$periodis theby_periodgoal, a'YYYY-MM-DD'string or aDate. Returns the gateway'sTrue, or itsFailure.ready-to-assign(Str:D $period --Int)>available(Str:D $period, Int:D $category-id --Int)>explain(Str:D $period, Int:D $category-id --Array)> ā the derived moves that touch a category in a period.current-period(--Str)> ā the start of the period containing today, local time.re-bucket-preview(Period:D $new --Hash)> ā whatchange-scheme($new)would do, without doing any of it. No writes, no state change.change-scheme(Period:D $new --Hash)> ā re-bucket, persist, and adopt$new. Returns the same summary.re-bucket-cell(Period:D $new, Str:D $old-period, Int:D $category-id --List)> ā the mapping rule in one expression.
The summary both re-bucket methods return
re-bucket-preview and change-scheme return the same five counts,
which is exactly the material a confirm dialog needs:
rowsā assignment rows before.periods-beforeā distinct period keys before.rows-afterā assignment rows after.periods-afterā distinct period keys after.mergedārows - rows-after, the number of rows that landed on top of another and were summed into it. Zero means the change is a pure re-labelling.
change-scheme's copy is computed from what it actually did, not from
a prediction, so a caller may compare the two.