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; or

  • an existing by_period target'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; or

  • the 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-scheme asserts it at runtime, re-reading the per-category totals after the rewrite and refusing (rolling back, leaving period_scheme untouched) if a single one has drifted.

  • It is atomic. The read, the rewrite and the budget_meta write 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 — required App::Moneymoor::DB.

  • scheme — the App::Moneymoor::Util::Period this workspace buckets and validates by, read from the file at construction. Not a constructor argument; change-scheme is the only mutator.

  • accounts / categories / payees / transactions / assignments — the gateways, built at construction, the last two carrying scheme.

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. :$period is the by_period goal, a 'YYYY-MM-DD' string or a Date. Returns the gateway's True, or its Failure.

  • 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)> — what change-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.

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.