Target

NAME

App::Moneymoor::Service::Target - what an envelope's target asks for in the period you are looking at.

SYNOPSIS


use App::Moneymoor::Service::Target;

my $scheme = $ws.scheme;
my $view   = $ws.budget(through-period => '2026-12-01');
my $tax    = $ws.categories.find-by-name('Tax');

# The one number everything keys on: this period's gap. £50,000 by
# April, stamped in August, and December is the fifth of the nine
# periods — so the empty pot is five ninths of the way behind.
say target-ask($view, $scheme, $tax, '2026-12-01');      # 2777777

# The figure the grid shows in its Target column: for a by-period
# target that is the current milestone, not the end goal.
say target-milestone($view, $scheme, $tax, '2026-12-01'); # 2777777

# Where in the plan this period sits, for the rail's copy.
my %c = target-cycle($scheme, $tax, '2026-12-01');
say "period { %c<index> } of { %c<of> }";                # period 5 of 9

# The caption, for the rail and the editor.
say describe-target($tax, $scheme);
# £50,000.00 by April 2027

DESCRIPTION

A target says what an envelope wants. This module says what it wants now — in one budget period, given everything the derivation already worked out about that period.

It is pure. No database, no clock, no widgets, no state: four subroutines over a BudgetView, a period scheme and a Model::Category. The engine has never heard of targets and never will (see App::Moneymoor::Model::Category), so everything here is derived on the way to the screen, from figures Service::Budget produced for its own reasons.

THE ONE FUNCTION THAT MATTERS

target-ask is "how much would fully funding this envelope's plan for this period cost". Every target feature in the app is that number wearing a different hat: the assign dialog's bare = assigns it, the grid's f assigns it to every envelope at once, the rail prints it, and the grid colours a row by whether it is zero. Adding a target kind therefore means teaching this function a new case and nothing else — which is exactly what the three kinds below are.

THE THREE KINDS, AND WHAT EACH MEASURES AGAINST

Same amount, three different questions, and the difference is which of the derivation's figures the amount is compared to.

# refill: "available should be £400 each period"
#   ask = max(0, 40000 - available)
#
# set_aside: "put £100 in each period"
#   ask = max(0, 10000 - assigned)
#
# by_period: "reach £50,000 available by April"
#   ask = max(0, milestone-for-this-period - available - goal-outflow)

refill measures available

The v0.1 behaviour and the default. Carry-in counts, because that is the point: a spending envelope you did not empty last period needs topping up, not refilling. This is the right shape for groceries and the wrong shape for a Christmas fund, which is why the other two exist.

set_aside measures assigned

"Put £100 in each period, whatever is already in there." Deliberately assigned-based, and the consequences are all intended:

  • Carry-in never silences it. £900 saved so far does not mean this period's £100 is done. That is the whole failure refill has with an accumulating fund.

  • A refund is not saving. Money coming back into the envelope is activity, not an assignment, so the ask stands.

  • Pulling money back re-opens it. assigned is a signed sum for the period, so moving £60 of this period's £100 out to cover something else takes the assignment to £40 and the ask back to £60. Undoing a contribution undoes it.

An assignment can be negative outright (a period where you took more out than you put in), which makes the ask larger than the target. That is correct: the plan for the period is still £100 in, and you are now further from it than you started.

by_period measures a milestone

"Reach £50,000 available by April." The plan is a straight line from the period the target was stamped in to the period containing the goal date, and each period's ask is the distance from where the envelope is to where the line says it should be. The rest of this page is that sentence in detail.

THE MILESTONE SCHEDULE

Four things define a by_period plan:

  • N — target-pence, the goal amount.

  • S — the period containing target-start, the stamped plan start. Service::Workspace.set-target stamps it; see there for when it re-stamps.

  • E — the period containing target-period, the goal period. Both are stored as dates and read as the periods containing them, so a budget that changes its period scheme re-derives the plan under the new windows instead of holding keys that no longer name periods.

  • R — target-repeat. 0 is a one-shot goal; R >= 1 puts goal periods at E, E+R, E+2R, … R counts periods, so quarterly VAT is "every 3" under monthly/1 and something else under weekly/4.

The viewed period v falls in a cycle: a run of k periods ending on a goal period. Cycle 0 runs S…E inclusive (so k is the number of periods in it, and i is v's 1-based place in it); every later cycle runs the R periods from just after one goal period through the next. Then, with base the envelope's carry-in at the cycle's first period:

milestone(i) = base + floor((N - base) * i / k)      # milestone(k) = N
ask          = max(0, milestone(i) - available - goal-outflow)

Integer pence throughout, and milestone(k) is forced to exactly N rather than trusted to the division: nine steps to £50,000 gives £5,555.55 a period, and nine of those is £49,999.95. The plan has to end on the number the user typed.

Worked example: £50,000 by April

Self-employment tax, stamped in the August period, goal 5 April, calendar months. S is 2026-08-01, E is 2027-04-01, so k is 9 and, from an empty envelope, base is 0:

i   milestone     ask with available = milestone (i.e. on plan)
1     555555      555555      # £5,555.55 — 50000/9, floored
2    1111111      555556
3    1666666      555555
…
8    4444444      555555
9    5000000      555556      # forced to exactly N

The pennies wobble by one and the total is exact, which is the right way round.

Getting ahead reduces later asks. Assign £10,000 in the first period and available is 1,000,000 against a milestone of 555,555: the ask is 0, and the second period's ask is 1111111 - 1000000 = 111111 rather than 555,556. The schedule is a line to a destination, not a subscription.

Raiding it makes the gap reappear at once. In the fifth period the milestone is 2,777,777. Take £5,000 out to cover a boiler and available falls to 2,300,000 — the ask is 477,777 in that same period, not spread over the four that are left. This is the settled ruling and it is deliberate: a big raid late in a plan produces one big ask, because the schedule is the schedule. It fires whether the envelope was raided by a move or by a spend.

A pre-funded pot ramps only the gap. Start the same plan with £20,000 already carried in and base is 2,000,000: milestones are 2000000 + 333333·i, so the first ask is 333,333 and not 555,555. Nothing asks the user to re-save money they have already saved.

The base is derived, never snapshotted. It is read out of the view at the cycle's first period every time the question is asked, so correcting a transaction dated before the plan started re-derives the whole schedule on the next repaint — the same recompute-from-facts rule the engine follows everywhere. And when base is above N (a pot that was already over its goal when the plan began), the milestone is clamped to N and the plan asks for nothing, rather than proposing a negative contribution.

Repeating goals, and the goal period's outflow

VAT, quarterly, £3,000, first due in the November period, under calendar months: R is 3 and E is 2026-11-01. Stamped in September, cycle 0 is Sep–Nov with k = 3, so the milestones are 100,000 / 200,000 / 300,000. Cycle 1 is Dec–Feb, cycle 2 is Mar–May, and there is no terminal state: every period after E belongs to some cycle.

Paying the bill in November is where the last ruling comes in. The payment is £3,000 of outflow, so available drops to zero — and without help, a £3,000 pot that has just done its job would read as a £3,000 raid for the rest of the month, with f offering to refill it immediately. So in the goal period, the envelope's outflow counts toward the milestone:

goal-outflow = max(0, -activity)      # in the goal period only
ask = max(0, 300000 - 0 - 300000)     # = 0 for the rest of November

In every other period spending still reads as a raid: the same £3,000 spent in October leaves an ask of 200,000, because October is not when the bill is due and the pot is now empty.

Two consequences worth knowing:

  • It is net activity. A refund landing in the goal period reduces the measured outflow, because activity is a signed sum. That is the deliberate reading of "outflow": what left the envelope this period, net — a £3,000 payment refunded in the same period did not leave.

  • Pick the period the bill is paid in as the goal period. VAT due on 7 November is a November goal. A payment that lags into the next cycle is the one shape this cannot model.

The next cycle's base is whatever is left after the bill, read as the carry-in of the cycle's first period — so an underpaid quarter starts the next ramp from below zero and catches up, and an overpaid one starts it ahead.

The edges

  • Before S — no ask, no milestone, no cycle. A plan that has not started asks for nothing.

  • One-shot, past E — the milestone is N for ever after, so the target degrades into "refill to N". The money is meant to stay there until it is spent, and if it is spent the envelope asks for it back.

  • Repeating, past E — there is no terminal state; v is in cycle ceil(steps-past-E / R).

  • E before S — a goal that was already in the past when the plan was stamped. There is no ramp to compute, so the milestone is N from S onwards, exactly as a one-shot past its goal. Re-stamping (which changing the amount or the goal period does) is how a user gets a real schedule back.

  • A missing or malformed date — a by_period row with no goal or no stamp cannot be a plan. Gateway::Category refuses to write one, so this is the hand-edited-file case, and the answer is 0 rather than an exception.

IT NEVER THROWS

Every sub here answers with a number, a Hash or a string, whatever it is handed. That is the same ruling as Util::Period.label's and for the same reason: these are called from inside Selkie::Store selectors and from render paths, where an exception takes out the whole subscription walk rather than one figure. A malformed date, a category with no id, a viewed period outside the view, a kind the CHECK constraint should have made impossible — each has a defined answer above, and none of them is a throw.

The one thing it does not do is guess. "No target", "the plan has not started" and "there is nothing to derive" all answer 0, because 0 is true of all of them: nothing to fund.

SUBROUTINES

  • target-ask(BudgetView:D $view, Period:D $scheme, Category:D $category, Str:D $period -- Int)> — the kind-aware underfunded amount for the viewed period, in pence. Never negative.

  • target-milestone(BudgetView:D $view, Period:D $scheme, Category:D $category, Str:D $period -- Int)> — the at-a-glance figure for the viewed period: the current milestone for by_period, and the target amount itself for the other two kinds. 0 when there is no target, or before a plan starts. The caller decides how to present it; this answers what the number is.

  • target-cycle(Period:D $scheme, Category:D $category, Str:D $period -- Hash)> — where the viewed period sits in a by_period plan: start and goal (period keys), index (1-based) and of. An empty Hash for every other kind, for a period before the plan starts, and for the two terminal shapes (a one-shot past its goal, a goal that was already past when the plan was stamped) — none of which is a cycle. Takes no view: cycles are scheme arithmetic.

  • describe-target(Category:D $category, Period:D $scheme -- Str)> — the caption for the rail and the editor, or '' when there is no target. Money through Util::Money.format-pence, so it follows the display locale.

SEE ALSO

  • %() — not a by-period target, a malformed tuple, or a period before the plan started. Nothing to derive.

  • < %( terminal => True ) > — the milestone is N and there is no ramp: a one-shot goal that is behind us, or a goal that was already behind us when the plan was stamped.

  • the full cycle — start, goal, index, of.

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.