---
module: recon.cash-forecast
title: Cash Forecast
tab: Architect
order: 30
audience: dev
---

> **Internal — architecture.** Grounded in `hiveops-recon` source. Cross-links: [platform.data-architecture], [platform.kafka], [platform.service-ownership].

## Ownership

Cash Forecast is **owned end-to-end by `hiveops-recon`** — no other service serves or computes it. Recon owns the recon domain exclusively ([platform.service-ownership]). Backend port **8091** (`/api/recon/`, prod external **8013**); frontend `recon.bcos.cloud` (`ForecastView.svelte`), port 5175. DB: `hiveiq_recon`.

There is **no executor→Kafka→record-store round-trip** for this feature — forecasting is an in-process batch computation over already-ingested replenishment data, persisted to a single table. The only Kafka involvement is **upstream**, feeding the replenishment sessions the forecast reads.

## Data flow

```
journal.replenishments (Kafka)
        │  ReplenishmentEventConsumer  (group recon-replenishment-group)
        ▼
replenishment_sessions  +  replenishment_cassette_entries   (hiveiq_recon)
        │  (nightly 03:00 backfill safety net also writes these)
        ▼
CashForecastService.recomputeAll()   ← nightly 03:30  OR  admin POST /admin/forecast/recompute
        │  reads last 6 CONFIRMED/LOCKED sessions per enrolled ATM
        │  flat burn-rate model per cassette slot
        ▼
cash_forecasts   (delete-by-atm + saveAll, atomic per ATM)
        │
        ▼
GET /api/recon/fleet/forecast  →  CashForecastResponse[]  →  ForecastView.svelte
```

### Who triggers a recompute

- **Scheduled:** `ReconSyncScheduler.recomputeForecasts()` — `@Scheduled(cron = "${recon.forecast.cron:0 30 3 * * *}")` → 03:30 nightly, after the 03:00 replenishment ingest (`recon.ingest.cron`) and the 6-hourly open-cycle sync.
- **On demand:** POST `/api/recon/admin/forecast/recompute` (admin) → `recomputeAll()`.
- Both call the same code path; the endpoint returns `{ atmsProcessed }`.

### Enrollment gate

`recomputeAll()` iterates only ATMs from `DeviceSelectionService.getAllEnrolledAtmIds()` (all rows in `recon_device_selection`). Unenrolled devices are never forecast, regardless of replenishment history.

## The algorithm (v1, flat burn rate — `CashForecastService`)

Per enrolled ATM:
1. Fetch last **6** (`lookback-sessions`) sessions with status `CONFIRMED` or `LOCKED`, newest first; reorder oldest→newest.
2. Load `replenishment_cassette_entries` per session, keyed slot→entry.
3. Candidate slots = union across sessions, **excluding** `RETRACT` cassette type and `denominationCents == 0`. Slot must also exist in the **latest** session (it is the current-level baseline).
4. For each consecutive session pair, `durationDays = seconds(prev.start → curr.start)/86400`; pairs with `duration ≤ 0` or `> 45` (`max-gap-days`) are dropped. Accumulate `netUsed` per slot.
   - `netUsed = usedCents`, minus `impliedDepositsCents` for `RECYCLER`/`MIX` cassettes (deposits refill the cassette); clamped ≥ 0.
5. `burnPerDay = round(Σ netUsed / Σ durationDays)`.
6. **Current level** priority: `physicalCountCents` → `journalClosingCents` → `max(0, opening − used + added)`.
7. `projectedRunoutAt = latestSession.start + (currentLevel / burnPerDay) days`; `recommendedFillByAt = projectedRunout − safetyBufferDays (2)`.

**Status resolution:**
- `< 2` sessions, or `< 1` valid pair, or `totalDays < 7` (`min-history-days`) → `INSUFFICIENT_HISTORY`.
- `burnPerDay ≤ 0` → `NO_BURN`.
- otherwise → `OK` (runout/Fill By populated only if current level > 0).

## Tables (all in `hiveiq_recon`)

| Table | Role in this feature | R/W |
|---|---|---|
| `cash_forecasts` | Output — one row per (atm_id, slot), unique `uq_forecast_atm_slot` | Write (delete+insert), Read |
| `replenishment_sessions` | Burn-rate + timing input (CONFIRMED/LOCKED) | Read |
| `replenishment_cassette_entries` | Per-slot denomination/used/opening/physical count | Read |
| `recon_device_selection` | Enrollment — which ATMs get forecast | Read |
| `atm_cash_positions` | `verifyAtmAccess` institution check on `/atm/{id}/forecast` | Read |
| `atm_cassette_configs` | Cassette-type semantics (`CassetteType` enum: RETRACT/RECYCLER/MIX) | Referenced via entries |

## Kafka ([platform.kafka])

Recon is **consumer-only** (no producers in source). Relevant to this feature (upstream ingestion of the data forecasts read):

| Topic | Consumer | Group | Feeds |
|---|---|---|---|
| `journal.replenishments` | `ReplenishmentEventConsumer` | `recon-replenishment-group` | `replenishment_sessions` (primary forecast input) |
| `hiveops.device.events` | `DeviceEventConsumer` | `hiveops-recon-device-sync` | `device_summary` mirror (device/institution scoping) |
| `hiveops.journal.transactions.recorded` | `JournalTransactionConsumer` | `recon-transaction-group` | cycle txn totals (not forecast-critical) |

The `device_summary` mirror has **no reconciler** — if `hiveops.device.events` stops, scoping data goes stale ([platform.data-architecture]).

## API surface (this feature)

| Method + path | Handler | Notes |
|---|---|---|
| GET `/api/recon/fleet/forecast` | `ForecastController.getFleetForecast` | Institution-scoped; sorted soonest-runout-first (SQL `ORDER BY projectedRunoutAt ASC NULLS LAST`) |
| GET `/api/recon/atm/{atmId}/forecast` | `getAtmForecast` | Ordered by slot; `verifyAtmAccess` |
| POST `/api/recon/admin/forecast/recompute` | `recompute` | `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')`; returns `{ atmsProcessed }` |

Frontend `apiBase()` defaults to `http://localhost:8091/api/recon`, overridden at runtime by `window.__APP_CONFIG__.apiUrl`.
