The Projection Model
How the income statement forecasts a period it has no records for, how it measures whether those forecasts were any good, and every formula behind both.
This is the reference for the model itself. For how to use the planner and the goal features built on top of it, see Income Statement Planning.
The short version
Every projected figure in the report comes from one line:
forecast = level × seasonal factor × growth factorThree quantities, each answering a different question:
| Quantity | Question it answers | Where it comes from |
|---|---|---|
| Level | How much does this line do in a normal period? | The last three closed periods |
| Seasonal factor | How strong is this particular period compared to a normal one? | The whole available history |
| Growth factor | How far above the previous cycle does the business intend to close? | Declared by the user, 1 by default |
Everything below is detail about how each is computed, what happens at the edges, and how the result is checked against reality.
Scope: what gets projected
The forecast runs per line, per period. A line is one row of the report — a service under revenue, a general ledger account under expenses — and each is forecast independently, then summed into its section. Profit rows are never forecast directly; they are recomputed from the projected sections, so the statement always reconciles.
The projection only runs when the selected range reaches at least today:
eligible = endDate >= todayA closed range entirely in the past has nothing to project, and the report shows records only.
Spending the model is not allowed to see
Not every amount in the statement says something about how the business runs. Some vendors are paid for tax reasons, and their pattern is a decision rather than a cost of operating.
A vendor can be marked as fiscal strategy in the provider screen. Its spending is then reported exactly as before — same account, same line, same amount, in every closed period — and withheld from everything that forecasts: the level, the seasonal factor, the backtest and the planner.
The opposite question — what the model would say if it did count — can still be asked, by adding includeFiscalStrategy=true to the address. There is no control for it in the filter bar on purpose. Counting a tax decision as though it described the operation is a diagnostic, not a way to read the statement, and a switch sitting beside the others invites it to be flipped by anyone looking for a bigger number. A notice appears under the filter bar whenever the parameter is on, so whoever receives such a link knows what they are looking at.
The case this was built for: one vendor invoices between 0.01M and 0.11M a month and then settles 65M in December. The model has no way to know that is a tax decision, so it reads a very strong December and forecasts another one. The distortion spreads beyond that month, because a December factor of 8.46 depresses every other month's factor, which inflates the deseasonalized level from a run rate of 2.5M to 9.7M.
Neither a cap nor an outlier filter is the right instrument here. A carrier that genuinely does bill four times more in December would be suppressed by exactly the rule meant to catch the settlement, and silently. Which vendors are fiscal strategy is a judgement finance makes; nothing in the data can make it.
Marking a vendor changes nothing about the past on its own. The seasonal factor is built from closed periods, so it keeps its old shape until the expense history is rebuilt.
Which periods are closed
Nearly everything depends on this test, because only closed periods are allowed to teach the model anything:
| Scale | A period is closed when |
|---|---|
day | period < today |
month | period < today truncated to YYYY-MM |
year | Number(period) < current year |
The period containing today is open. It holds part of its activity and would misinform any average it entered, so it is excluded from both the level and the seasonal factors. It is handled separately — see The period in progress.
The level
The level is the average of the last three closed periods of the line (RUN_RATE_CLOSED_PERIODS = 3):
level = (p₋₁ + p₋₂ + p₋₃) / 3Three is a deliberate compromise: long enough that one unusual period cannot dominate, short enough that the figure still describes the business as it is now rather than as it was a year ago.
When some of those periods are empty
A line that only recently started trading, or one that bills irregularly, will have zeros among its last three periods. Averaging those zeros in would halve or third the level for no real reason, so the model reports which mode it used:
| Mode | When | Level |
|---|---|---|
full | All three periods have activity | Mean of all three |
non_zero_only | Some have activity, some do not | Mean of the non-zero ones only |
empty | None have activity | 0 |
This is why a brand-new line does not forecast at zero forever, and also why very sparse lines produce percentage errors large enough to be meaningless — there is nothing stable to average.
Deseasonalizing the level
Under the current model the three basis periods are divided by their own seasonal factors before being averaged:
level = mean( pᵢ / factor(pᵢ) ) for the last three closed periodsWithout this step, the season is counted twice on one side and not at all on the other. Suppose nine months of the year run at 200 and October through December run at 60. Projecting January from the basis October–December:
Raw basis: level = 60, forecast = 60 × 1.21 = 73
Deseasonalized basis: level = 60 / 0.36 = 165, forecast = 165 × 1.21 = 200The raw version takes a number already depressed by low season and treats it as the normal level. The deseasonalized version recovers what a normal month looks like, which is what the seasonal factor is meant to be multiplied by.
The seasonal index is therefore computed before the level, since the level is read through it.
The seasonal factor
A seasonal factor says how strong a given period is relative to a normal one. 1.3 means the period typically runs 30% above normal, 0.8 means 20% below.
Periods are grouped into buckets, and every period in the same bucket shares a factor:
| Scale | Bucket | Count |
|---|---|---|
month | Month number, 1–12 | 12 |
day | Day of week, 0–6 | 7 |
year | None — factor is always 1 | — |
The year scale has no seasonality by definition: a year is a full turn of the season, so there is nothing left to adjust for.
A bucket with no closed history falls back to a factor of 1, which reduces the forecast to the plain level.
Two ways to compute it
There are two methods, and the default adds one rule on top of the better of them. Which applies is decided per line, by how much history stands behind it.
centred — how the default computes the shape
Each observation is divided by the level of the cycle centred on it, and the resulting ratios are averaged per bucket:
ratio(period) = amount(period) / centred mean around period
factor(bucket) = mean of ratios in the bucket, rescaled so all factors average 1The centred mean spans exactly one cycle. Since 12 is even and has no single middle period, the two ends each count half:
centred mean at t = ( ½·x₍t₋₆₎ + x₍t₋₅₎ + … + x₍t₊₅₎ + ½·x₍t₊₆₎ ) / 12For the day scale the cycle is 7, which is odd, so it is a plain mean of the seven periods.
Near the beginning and end of the history the full window does not exist. Rather than discarding those observations — expensive when history is short — the window is shrunk to what is available. Those edge observations carry some residual trend bias as a result, which is the known cost of the approach.
Finally, every factor is floored at 0.1. Because the level divides by the factor, a factor near zero would send the level to infinity, and a sparse line is the usual way to produce one.
global — the fallback
The older model. Each bucket is divided by the average of the entire history:
factor(bucket) = mean of the bucket / mean of all periodsThis is simpler but conflates growth with season: in a growing business the later months look strong merely for being later, and the model reads that as a busy season.
Why the default is not the simpler one
Consider a business with no season at all that grows steadily:
| January | … | December | |
|---|---|---|---|
| Year 1 | 100 | 210 | |
| Year 2 | 220 | 330 |
Every month here is identical in character; only the level moves. Yet global computes a January factor of 0.74 and a December factor of 1.26, concluding that December is a strong month. It is not strong, it is merely later. Measured on a synthetic flat season, global spreads its factors by 0.51 — all of it misread growth — while centred spreads them by 0.16.
The history requirement
centred only applies to a line with at least one full cycle of closed history: 12 periods on the month scale, 7 on the day scale. Below that it falls back to global.
The reason is structural. A centred moving average of length L cannot centre a single observation until L of them exist; below that every window is one-sided, which puts back the very trend bias the method exists to remove. This was measured, not assumed: replayed over a window holding less than a cycle, centred raised the error on eight of ten locales, by as much as 25 points. With the requirement in place, those same windows reproduce global exactly.
The test reads history depth and nothing else. It never looks at the section, so revenue and expenses are always forecast by the same method.
The opening period is not evidence
A line's earliest closed period is discarded before the index is built. This is what separates centred_trimmed, the default, from plain centred.
The reason is the same one that excludes the period in progress. A period holding a few days of activity describes the calendar, not the season. The period in progress is visibly partial, so it was always excluded; a line's first period is partial in exactly the same way and nothing marks it as such. When a category is introduced part way through a period, that period records only what happened after it arrived.
Left in, the error does not stay small. It gets multiplied, because the level is read through the factors. A real case from production: cost of sales was introduced on 30 June 2025 and recorded 1,217 pesos that day, against months of 65M to 95M. That single day halved the June factor to 0.568. June 2026 then really cost 95.9M, but divided by 0.568 the model concluded the underlying level was 168.7M, which dragged the base from 83.7M to 109.6M and inflated every projected month by a third. Cost of sales came out at 94% to 102% of revenue against a history of 74% to 77%, which erased the projected profit entirely.
Discarding the opening period costs one observation and is measured to be worth it: replayed across ten locales it lowered the error 7.7 points on the largest, moved five others by less than a quarter point, and cost at most 1.4 points anywhere.
Two details keep the rule from doing harm elsewhere:
- A line with a single period keeps it. With nothing to compare it against the index is flat, which is what one observation honestly supports.
- History depth is still counted on the untrimmed set. A line does not lose the model its history entitles it to merely because one of its periods could not be trusted. Counting the trimmed set instead was measured and was worse: it flipped borderline lines to
globaland cost up to 1.8 points.
Which model was used
projection_details[lineId].model_variant reports it per line, and the projection popover in the report shows it. Two lines in the same column can differ, because the choice follows the history each one has.
The growth factor
A percentage the user declares in the planner, defaulting to none. It says how far above the previous cycle this one should close, measured on revenue:
target = revenue of the same window one cycle earlier × (1 + declared / 100)The multiplier the forecast actually carries follows from that target, and it is not the declared percentage. Part of the cycle is already recorded and cannot move, so the whole of the declared growth has to be absorbed by the periods still open:
multiplier = ( target − revenue already recorded ) / revenue the model projects at zero growthThat multiplier is then applied equally to every projected period of every line, the completion of the period in progress included, which is what leaves the seasonal shape untouched.
Why the two numbers differ
Half way through a fiscal year, aiming the year 10% above the last one asks roughly 20% of each remaining month, because six months of the target have already been fixed by what was recorded. The closer the cycle is to its end, the more it asks of what is left, and near the end the demand grows without bound. The planner reports both figures: the target, which is what was typed, and the lift on the open periods, which is what the model was told to do.
Two things follow that are easy to be surprised by:
- A modest target can lower the forecast. A business already heading for +24% that is asked for +10% will see its projection fall, because +10% is a smaller year than the one it was on course for. The planner shows where the cycle closes with nothing declared, so the comparison is on screen before a number is typed.
- The cycle lands within a fraction of a percent of the target rather than exactly on it. What a period still expects is floored at zero, so a line whose recorded amount already passed its forecast does not scale with the rest. Measured on production this is under 1%.
Anchoring on revenue
The target is measured on operating_income alone, and the resulting multiplier is applied to every section. Holding each section to its own target against last year would be more consistent on paper and unusable in practice: a category whose previous cycle is only partly recorded — cost of goods sold, which only begins in July 2025 — would be handed a target below what the current cycle has already spent, and the only way to meet it would be to forecast nothing.
When there is no cycle to aim at
The target needs the same window one cycle earlier to be fully loaded and fully recorded. Where it is not, the percentage falls back to its older reading — a flat lift on each projected period — and the planner says so. The reasons are reported in projection.growth.fallback_reason:
| Reason | Meaning |
|---|---|
scale_without_cycle | The day scale closes no cycle a counterpart can be found in |
prior_cycle_incomplete | A counterpart period is outside the loaded history, or records nothing |
nothing_left_to_project | The cycle has no open periods for the growth to act on |
Warnings
Resolved targets are reported with a warning when what they demand is worth questioning:
| Warning | Meaning |
|---|---|
large_uplift | The open periods would have to move more than 30% — beyond the range in which the cost structure behind the forecast still holds |
target_below_recorded | What is already recorded exceeds the target, so no forecast can bring the cycle down to it |
Accepted range for the declared percentage is −90% to +300%. Values outside it are refused rather than clamped, so a misplaced digit becomes an error instead of a forecast.
Because the growth factor changes what the model produces, it is a model parameter rather than a display setting: changing it refetches the statement and participates in the cache key. The backtest replays with the resolved multiplier, not the declared percentage, so the accuracy reported is that of the model on screen.
The period in progress
The period containing today has partial activity: some days recorded, some not. It is neither a closed period nor a purely future one.
It is completed by forecasting it like any other period and subtracting what it already holds:
completion = max(0, forecast for the whole period − recorded so far)Why not simply extrapolate from elapsed days
The obvious approach — take what the month has recorded in 6 of 31 days and scale it up by 31/6 — is wrong here, and produces a specific, predictable distortion.
Revenue is recorded the day it happens. Costs post later. So a few days into a period, the recorded margin is not the period's margin: it is revenue that has arrived without the costs that belong to it. Scaling that snapshot to full-period size reproduces the accounting lag at full scale and manufactures profit. This is not hypothetical; it is what produced a projected close of 242M where the corrected figure is 161M.
Taking the difference against a forecast built from closed periods — where the costs have posted — lands the period where the model says it should, regardless of how much of it has been booked.
The floor matters at the other end. A period that has already outrun its forecast lands on what it truly recorded rather than being dragged back down to the model.
What the report shows
The completion does not add a column. It is added to the in-progress period's existing column, which is marked as partial. Hovering it shows the split: the forecast for the whole period, the amount recorded so far, and the completion between them.
The horizon: how many periods to project
| Scale | Options |
|---|---|
day | 3, 6, 12 periods, or rest of cycle |
month | 3, 6, 12 periods, or rest of cycle |
year | 1, 2, 3 periods |
Rest of cycle resolves to whatever remains to close the cycle the scale belongs to:
- Month scale: months left until December, so
12 − month - Day scale: days left in the current month
- Year scale: not offered, since a span of years closes nothing — it falls back to the default
In December, or on the last day of a month, the rest-of-cycle horizon resolves to zero and the projection is reported as disabled rather than producing an empty column.
How much history the model reads
The query window widens beyond the displayed range when a projection or a comparison is active:
| Scale | Lookback |
|---|---|
day | 90 days |
month | 24 months |
year | 5 years |
Independently of this, the resolved range is capped at 31 periods. Past that the table stops being readable, and the day scale would otherwise return 365 columns.
Measuring the model: the backtest
Turning the backtest on replays the forecast over periods that have already closed and compares it against what actually happened. For each evaluated period the model is rewound to what it could have known at the time — a cutoff excludes every later period from both the level and the seasonal index — which is what makes the measurement honest rather than a model grading its own memory.
The replay is capped at the 24 most recent closed periods. Without the cap, a day-scale range took 12.7 seconds of blocking CPU.
Which periods can be measured
A period needs three closed periods behind it to feed a level, and those may sit outside the range on screen. The basis is drawn from everything the query loaded, so January stands on the previous October through December and the opening columns of a fiscal year are measurable. Requesting the backtest widens the lookback on its own, so this holds even with the projection off.
Loaded periods that precede the reported range feed a basis but never appear as results: a variance has nowhere to show without a column.
Variance: one gap, two readings
The same subtraction answers two different questions, so each entry carries both.
The model against reality — how wrong the forecast was. This is what the accuracy metrics average:
variance = projected − actual
variance_pct = variance / |actual| × 100Reality against the model — whether the period came in above or below what was asked of it. This is what the report shows in the cell:
vs_forecast = actual − projected
vs_forecast_pct = vs_forecast / |projected| × 100They differ in sign and in denominator, so neither is the other negated. A forecast of 100 against an actual of 125 is a model that fell 20% short of reality and a period that beat its forecast by 25%. Dividing by the magnitude keeps the sign meaningful on expense lines, where amounts are stored positive but an overshoot still means the same thing.
Each percentage is null where its own denominator is zero: variance_pct when the period recorded nothing, vs_forecast_pct when the model expected nothing.
In the table, vs_forecast_pct is coloured by direction rather than by size. A period above its forecast reads well on revenue and result rows and badly on cost rows, following the same rule as the year-over-year indicator: outspending a forecast is not the news that outselling one is.
The two accuracy figures
Both read the same population — periods with a non-zero actual — so they describe one set of periods rather than two.
Error (MAPE) — how far the forecast landed, in either direction:
MAPE = mean( |variance_pct| )Bias — which side of reality it landed on:
bias = mean( variance_pct )Positive means the model forecasts high; negative means it forecasts low.
The distinction matters because the absolute error cannot separate a model that is consistently 8% low from one that alternates by 8% either way, and those call for opposite corrections. The first wants a growth factor. The second wants nothing — a uniform factor cannot fix noise.
Both are null rather than zero when nothing was measurable, because an unmeasured model is not a centred one.
The suggested growth factor
The planner suggests the factor that would have centred the model. It is derived from the bias, but it is not simply the bias negated, because the bias was measured with the multiplier already applied:
centring lift = ( (1 + current lift/100) / (1 + bias/100) − 1 ) × 100With the model forecasting 8% low and nothing declared, that lift is +8.7%, not +8%.
That figure is a lift on each period, and the input asks for a closing total, so it is restated before being offered:
suggested = ( (recorded + (1 + lift/100) × projected at zero growth) / previous cycle − 1 ) × 100Skipping that step would overshoot by the size of the closed months: a lift of 8.7% typed in as a cycle target would move the open periods by far more than 8.7%.
Period-over-period comparison
Reads every displayed period against the same period one cycle earlier:
| Scale | Counterpart | Available |
|---|---|---|
month | Same month, previous year | Yes |
year | Previous year | Yes |
day | — | No |
change = current − previous
change_pct = change / |previous| × 100Dividing by the magnitude means a profit that recovered from a loss reads as a rise: −50 to +50 is +200%, not −200%.
Projected periods are compared too — whether the forecast implies growth or decline is precisely the point of asking — and are flagged as resting on a forecast.
A column is comparable or it is not, and that is a property of the column rather than of each cell. It is reported as unavailable when the counterpart falls outside the queried window, or when the period holds only part of its days and the projection is off, which would measure six days against a whole month.
The result rows carry a comparison as well. Since they hold no lines of their own, each side is carried down to a profit independently and only then read against the other, rather than summing the changes of the sections beneath.
What the model does not do
Stated explicitly, since each is a reasonable thing to expect:
- No trend within the forecast. Every projected period rests on the same level; the model does not extrapolate a rising line. Direction is the growth factor's job, and it is declared rather than estimated.
- No growth aimed at anything but the whole window. The target moves every open period by the same multiplier. Asking a particular month to carry more of the growth than its neighbours is a distribution question, and it belongs to the planner rather than to the model.
- No growth rate estimated for the user. The suggestion is offered next to the input, but nothing is applied until it is accepted.
- No cross-line relationships. Each line is forecast alone. Costs do not follow revenue in the forecast; that relationship lives in the planner, where it is declared.
- No confidence interval. A single figure is produced, not a range.
- No per-section model. Every category is forecast the same way, deliberately. A per-section choice was measured and available, and was refused: a forecast whose method changes by category cannot be explained to whoever reads the report.
Reference
Constants
| Constant | Value | Meaning |
|---|---|---|
RUN_RATE_CLOSED_PERIODS | 3 | Closed periods averaged into the level |
SEASONAL_CYCLE_LENGTH | 12 month, 7 day | One turn of the season |
SEASONAL_FACTOR_FLOOR | 0.1 | Lowest a factor may fall |
MAX_BACKTEST_PERIODS | 24 | Cap on replayed periods |
MAX_INCOME_STATEMENT_PERIODS | 31 | Cap on displayed columns |
GROWTH_FACTOR_MIN_PCT / MAX | −90 / +300 | Accepted growth range |
DEFAULT_MODEL_VARIANT | centred_trimmed | Seasonal model in production |
Where it lives
| Concern | File |
|---|---|
| The whole model | backend/libraries/financesIncomeStatementProjection.util.js |
| Statement assembly | backend/libraries/financesIncomeStatement.util.js |
| Variant measurement script | backend/scripts/finance-forecast-variant.script.js |
| Reading it in the frontend | frontend/client/src/composables/useIncomeStatement.js |
Measuring a change to the model
Any change to the seasonal model is measured against the one in production before being adopted, never adopted and observed afterwards. The script replays the backtest under each named variant and reports the error and bias per section:
node backend/scripts/finance-forecast-variant.script.js --orgId=1 --scale=month
node backend/scripts/finance-forecast-variant.script.js --orgId=1 --fiscalYear=2025It writes nothing. A variant is adopted when it lowers the error; a lower bias with a higher error means it moved the forecast onto the right side of reality for the wrong reason.
Measure across every receiver rather than one. The current model was nearly rejected on a four-receiver sample that pointed the opposite way from the full ten.
