# Reports, business plans and variance

> How monthly forecast figures become quarterly and annual statements, how report rows are calculated, how business plan drafts are filled, and how plan and actual figures are compared. This page gives the formulas, what each symbol means, when they hold and a worked example.

How monthly forecast figures become quarterly and annual statements, how report rows are calculated, how business
plan drafts are filled, and how plan and actual figures are compared. This page gives the formulas, what each
symbol means, when they hold and a worked example.

**How to read this page.** Each formula is shown on its own line, followed by its symbols, the conditions under
which it holds, what it says in plain words and a short example. Implementation details for developers close the
page.

## Cadence aggregation

Monthly figures are grouped into `monthly`, `quarterly` (fiscal quarters, key `FY-Qn`) or `annual` (fiscal years)
periods using `fiscalYearStartMonth`. How a line is combined depends on what kind of figure it is (its catalogue
`kind` in `calc/lines.py`):

$$\begin{gathered} \text{flow: } X_Q = \sum_{t \in Q} X_t \\[8pt] \text{opening: } X_Q = X_{\text{first}(Q)} \\[8pt] \text{stock: } X_Q = X_{\text{last}(Q)} \\[8pt] \text{ratio: } X_Q = \frac{\sum_{t \in Q} \text{num}_t}{\sum_{t \in Q} \text{den}_t} \end{gathered}$$

| Symbol | Meaning |
| --- | --- |
| $Q$ | A period: a fiscal quarter or year |
| flow | Something that happens during a month, such as revenue or costs |
| opening | A balance at the start of a month, such as opening cash |
| stock | A balance at the end of a month, such as ending cash or customers |
| ratio | A ratio of two other lines, such as gross margin |

**In plain words.** Revenue of a quarter is the revenue of its three months added up; cash at the end of a quarter
is the cash at the end of its last month. A ratio is never averaged: it is recalculated from the quarter's totals.

**Valid when.** Always. Months without a value are skipped in sums. `info` lines take the last month. Ratios are
rounded to 6 decimals and are null when the denominator is 0. Aggregation works on the rounded monthly series stored
in the run.

**Example.** Revenue of 10,000, 12,000 and 14,000 with gross profit of 7,000, 9,000 and 11,000 gives quarterly
revenue of 36,000 and a quarterly gross margin of $27{,}000 / 36{,}000 = 75\,\%$, not the average of the three
monthly margins (74.5 %).

## Statement and KPI templates

Starter templates live in `packages/contracts/templates/starter/*.json` and are installed as system templates (`tenant_id` null) by `ReportService.installStarterTemplates`. A tenant's own template with the same key takes precedence (`ReportService.resolve`).

| templateKey | kind | lang | default cadence | validations |
|---|---|---|---|---|
| `management-pnl` | statement | en | monthly | has_revenue, negative_cash, required_inputs_present |
| `cash-flow-runway` | statement | en | monthly | has_revenue, negative_cash |
| `balance-sheet-simplified` | statement | en | quarterly | balance_check |
| `de-operational-planning` | statement | de | annual | has_revenue, negative_cash |
| `startup-investor-financial-plan` | statement | en | annual (quarterly/annual only) | has_revenue, negative_cash |
| `kpi-controlling-dashboard` | kpi | en | monthly | has_revenue, negative_cash |
| `saas-operating-model` | kpi | en | monthly | has_revenue, negative_cash |
| `investor-business-plan-en` / `-es` | business_plan | en / es | annual only | none |
| `startup-business-plan-universal` | business_plan | en | annual only | profile_fields |

Rendering (`render.render_report`):

- Cadence must be in the template's `cadences`, else error.
- Each row is a header/text row (no values), a `ref` to a canonical line key, or a `formula` over earlier row ids
  or line keys: $\operatorname{sum}(a, b, \dots) = a + b + \dots$, $\operatorname{sub}(a, b, \dots) = a - b - \dots$
  (the first minus the rest), $\operatorname{neg}(a) = -a$ and $\operatorname{ratio}(a, b) = a / b$ (6 decimals,
  null when $b$ is 0 or null). Missing operands count as 0. Referencing a row defined later raises an error; an
  unknown `ref` yields nulls and a warning.
- `sign: -1` negates displayed values; `hideIfZero` drops rows that are all 0/null.
- Validations: `balance_check` warns if any `abs(bs.check) > 0.05`; `negative_cash` if any `cash.ending < 0`; `has_revenue` if no month has revenue > 0; `required_inputs_present` if listed line keys are absent. `profile_fields` is accepted by the schema but not evaluated yet; missing profile facts surface as placeholders instead (below). Forecast warnings are appended.
- Output includes lineage: run id, scenario, model version, engine version, input hash, calculated-at.
- The template's `currency` only changes the displayed currency code; no FX conversion is applied.

The control plane records each JSON render or export in `report_runs` (template version, forecast run, cadence, format).

## Business-plan drafts

`business_plan.render_business_plan` fills section `body` Markdown with a small token language (no code execution):

- `{{ path }}` or `{{ path | filter }}`; filters: `currency`, `currency_k`, `percent`, `months`, `number`, `number1`, `month`, `date`, `text`. Currency is formatted with 0 decimals; `de`/`es` swap separators.
- `{{ table:name }}` for `annual_pnl`, `pricing`, `hiring`, `funding`, `costs` (first fiscal year, ranked), `unit_economics`, `scenario_assumptions`, `key_assumptions`.
- Paths resolve against: `company` (primary legal entity), `profile` (`business_profiles.data`), `scenario`, `run`, `summary` and `unit` (snake_case field names of the run summary), `products`, `plans`, `hires`, `funding` (upcoming non-cancelled included events from the as-of month), `year1..year5`, `lastYear`.
- Missing value or empty table: a visible placeholder `**[PLACEHOLDER: <name> — not provided yet]**` (localised) is written and listed in `placeholders`. Exceptions: a null `summary.cash_out_month*` renders "beyond the N-month horizon" and a null `summary.break_even_month` renders "not within the forecast horizon".
- Every figure from `summary`, `unit`, `funding`, `hires`, `year*`, `lastYear` and every table is listed in `grounding` with source `forecast_run:<runId or input hash prefix>`.

The control plane stores the draft in `business_plan_drafts` with `generated_markdown` (engine output) and `markdown` (user-editable; `edited` = they differ). Only drafts can be edited; approval requires the `approve` permission and writes an `approvals` row.

## Plan-vs-actual variance

`ControllingService.variance` chooses the basis and calls engine `POST /v1/variance` (`compare.variance`).

Basis (`ControllingService.basis`):

| Input | Default |
|---|---|
| Plan run | explicit id, else the plan-baseline run, else the latest run of the primary scenario |
| Forecast run | explicit id, else the latest run of the primary scenario |
| Through month | explicit, else the latest month with an actual record, else the previous calendar month |

Actuals (`RecordService.actuals`):

- `finance_records` with `classification = 'actual'`, not archived, status not `void`/`draft`, in base currency (`amount_base`).
- Line mapping (`actualLine`): invoice to `revenue`; expense category `payroll` to `opex.headcount`; COGS categories to `cogs.<category>`; everything else to `opex.<category>`.
- Invoices with a multi-month service period are spread evenly over its months (2 dp, remainder in the last month); everything else lands in the month of `record_date`.
- Payments (not archived) on the same set of records give `cash.collections` (incoming) and `cash.outflows` (outgoing), converted with the record's `fx_rate_to_base`.

Calculation (`compare.variance`):

- Only plan periods up to the through month are used. `cogs` and `opex` actuals are the sum of their detail lines; `gross_profit = revenue - cogs`, `operating_result = gross_profit - opex`.
- A month without any actual record has no actuals (null, not zero), and a note lists those months. A month with records but none for a cost line counts that line as 0.
- Default rows: `revenue`, `cogs`, `gross_profit`, `opex`, `operating_result`, any detail cost lines present in actuals, and the cash lines if present.
- There are no materiality thresholds; every non-zero variance is flagged favorable or unfavorable.

### Variance per cell

$$\begin{gathered} \Delta = A - B \\[8pt] \Delta\% = \frac{\Delta}{\lvert B \rvert} \\[8pt] \text{favorable} = \begin{cases} \Delta > 0 & \text{revenue and profit lines} \\ \Delta < 0 & \text{cost lines} \end{cases} \end{gathered}$$

| Symbol | Meaning | Unit |
| --- | --- | --- |
| $A$ | Actual amount for the line and month | currency |
| $B$ | The base it is compared with: the plan (or the forecast) | currency |
| $\Delta$ | Variance: how far the actual is from the base (`variance`) | currency |
| $\Delta\%$ | Variance as a share of the base (`variance_pct`) | share |

**In plain words.** The variance is actual minus plan. Above plan is good for revenue and profit and bad for costs,
so the favorable flag (`favorableVsPlan`) looks at the kind of line. Dividing by the absolute value of the plan
keeps the sign of the percentage the same as the sign of the variance, even when the plan is a loss.

**Valid when.** Both values exist. The percentage is null when the plan is 0 or missing (there is no meaningful
percentage of nothing); the flag is null when the variance is 0 or missing. Cost lines are `cogs*`, `opex*` and
`cash.outflows`. Amounts are rounded to 2 decimals, percentages to 6. The same is calculated against the forecast.

**Example.** Planned revenue €50,000, actual €45,000: $\Delta = -5{,}000$ and $\Delta\% = -10\,\%$, unfavorable.
Planned operating result −€20,000, actual −€15,000: $\Delta = +5{,}000$ and $\Delta\% = 5{,}000 / 20{,}000 = +25\,\%$,
favorable. Planned marketing €8,000, actual €9,000: $\Delta = +1{,}000$, unfavorable because it is a cost.

Drill-down (`ControllingService.drilldown`) returns the matching records, plan values from the plan run, related input entities and overrides from the run's input snapshot, and the run's lineage. Records cannot be created or changed in a locked fiscal period (`RecordService.assertPeriodOpen`).

## For developers

Sources: `apps/finance-engine/src/finance_engine/calc/aggregate.py`, `reports/render.py`, `reports/business_plan.py`, `models/templates.py`, `calc/compare.py` (`variance`), and in the control plane `reports/Reports.kt`, `controlling/Controlling.kt`, `records/Records.kt` (`actuals`, `actualLine`). Keep this page in sync with any change there.
