---
module: recon.cash-forecast
title: Cash Forecast
tab: Internal
order: 20
audience: internal
---

> **Internal — ops/support/admin.** Grounded in `hiveops-recon` source (`ForecastController`, `CashForecastService`, `ReconSyncScheduler`, `ForecastView.svelte`). For the how-it's-built view see the **Architect** tab.

Cash Forecast is served entirely by **`hiveops-recon`** (backend port **8091**, NGINX `/api/recon/`, prod external **8013**; frontend `recon.bcos.cloud`). The screen is `ForecastView.svelte` and hits three endpoints only.

## Roles / access

| Action | Endpoint | Auth |
|---|---|---|
| View fleet forecast | GET `/api/recon/fleet/forecast` | Any authenticated user; **institution-scoped** via `InstitutionContext.resolveScope()` (admin = null = all ATMs) |
| View one ATM's forecast | GET `/api/recon/atm/{atmId}/forecast` | Authenticated; `verifyAtmAccess` 403s if the ATM's institution isn't in the caller's scope |
| **Recompute now** | POST `/api/recon/admin/forecast/recompute` | **`@PreAuthorize("hasAnyRole('MSP_ADMIN','BCOS_ADMIN')")`** — admin only |

- The **Recompute** button in the header calls the admin POST. A non-admin will get 403 there even though they can read the table.
- Read GETs have **no method-level `@PreAuthorize`** — scoping is done by institution inside the service, not by role.

## How the numbers get there

Forecasts are **derived from replenishment history, not live journal data**. The pipeline is:

1. Device must be **enrolled** in recon (`recon_device_selection`) — `recomputeAll()` only iterates enrolled ATMs.
2. Each replenishment produces a `replenishment_sessions` row (+ per-slot `replenishment_cassette_entries`).
3. `CashForecastService` reads the last **6** `CONFIRMED`/`LOCKED` sessions per ATM, computes a flat burn rate per cassette slot, and writes `cash_forecasts`.
4. Nightly recompute runs at **03:30** (`ReconSyncScheduler.recomputeForecasts`, after the 03:00 replenishment ingest). Recompute deletes and re-writes all rows for each ATM atomically.

## First things to check when it misbehaves

**"No forecast data yet" / table empty**
- Is the device **enrolled**? No rows in `recon_device_selection` for that ATM → it's never processed. Enroll via `POST /api/recon/device-selection/enroll/{atmId}` (admin).
- Does the ATM have replenishment history? Forecasts need **≥2** `CONFIRMED`/`LOCKED` sessions. A brand-new ATM with 0–1 sessions produces nothing.
- Was the nightly job run? Click **Recompute** (admin) to force it; response `{ "atmsProcessed": N }`.

**Rows stuck at "Insufficient History"**
- Fewer than 2 valid session pairs, **or** total spanned history `< 7 days` (`recon.forecast.min-history-days`), **or** every session pair exceeded the **45-day** gap cap (`recon.forecast.max-gap-days`) and got skipped. Long ATM outages between replenishments dilute/skip pairs.
- Also shown when only 1 eligible session exists.

**Rows show "No Burn"**
- Computed burn rate ≤ 0 — cassette isn't being dispensed (or recycler deposits offset all withdrawals). Expected for idle/deposit-only slots; no runout date is projected.

**A cassette is missing from the list**
- `RETRACT`-type slots and any slot with `denominationCents == 0` are **excluded by design**.
- A slot present in older sessions but **absent from the latest session** is skipped (current-level baseline comes from the latest session only).

**Runout / Fill By blank but status OK**
- Current level resolved to null or 0. Level is derived per entry: `physicalCountCents` → `journalClosingCents` → computed `opening − used + added`. If none are present, no projection is made.

**Wrong institution / customer sees ATMs they shouldn't (or misses their own)**
- Forecast institution is copied from the replenishment session's institution. If the device's institution is wrong upstream, the forecast inherits it. This is the same stale-mirror class of issue tracked platform-wide — the fix is upstream in devices, not here.

## Colour / status legend (UI)

- **Amber row** — projected runout within 3 days.
- **Red row** — recommended Fill By date already passed.
- Status badges map: `OK`, `INSUFFICIENT_HISTORY` → "Insufficient History", `NO_BURN` → "No Burn".

## Tunables (Spring config, `recon.forecast.*`)

| Property | Default | Effect |
|---|---|---|
| `recon.forecast.lookback-sessions` | 6 | Sessions considered per ATM |
| `recon.forecast.safety-buffer-days` | 2 | Fill By = runout − buffer |
| `recon.forecast.max-gap-days` | 45 | Session pairs longer than this are dropped |
| `recon.forecast.min-history-days` | 7 | Below this total span → Insufficient History |
| `recon.forecast.cron` | `0 30 3 * * *` | Nightly recompute (03:30) |

## Logs

Backend logs on the services VM (`docker compose logs hiveiq-recon`, or **Loki**). Key lines: `Cash forecast: starting nightly recompute`, `Cash forecast: recomputed N ATMs`, and per-ATM `Cash forecast failed for ATM {}`. One ATM failing does **not** abort the run (logged as `warn`, loop continues).
