---
module: dashboard.cash-stats
title: Cash Stats
tab: Architect
order: 30
audience: dev
---

How the **Cash Stats** page is built end-to-end. It is a thin read view over data that another service derives — the interesting work happens in `hiveops-journal`, not in the dashboard. See [platform.service-ownership].

## Services involved

| Service | Role for this feature |
|---------|----------------------|
| `hiveops-dashboard` (SPA, 5181) | Renders `CashStatus.svelte`; no backend |
| `hiveops-incident` (8080) | Owns `journal_events` + `atms`; serves the read API |
| `hiveops-journal` (8087, **upload** role) | Parses ATM journals, extracts cassette counts, **writes into incident's DB** |

## Data flow

```
Agent → agent-proxy → journal (chunked upload)  ─┐
                                                 │  parse (per-institution worker)
journal.parse-requests.{INSTITUTION} ────────────┘
        │ worker parses file, persists JournalTransaction (STARTUP has rawLines)
        ▼
journal.file-parsed  ──►  CassetteInventoryService (upload role, groupId journal-cassette-inventory)
        │  extracts [DENOMINATION] / RCY USD / MIX / RETRACT slots from the newest STARTUP
        ▼
  incident DB: journal_events  (event_type='CASSETTE_INVENTORY', event_source='JOURNAL')
        ▲
        │  GET /api/journal-events/fleet/cassette-inventory   (+ GET /api/atms)
CashStatus.svelte  ── merges events with ATM list, colours pills, sorts empty→low→ok→config→nodata
```

Key point: this is the **executor→Kafka→record-store** pattern applied across a service boundary. `hiveops-journal` is the executor (parse), `journal.file-parsed` is the event, and the "record store" it writes to is **incident's `journal_events` table** via a second read/write JDBC datasource (`incidentJdbcTemplate`), not its own DB. See [platform.kafka], [platform.data-architecture].

## Kafka

- **Topic:** `journal.file-parsed` (constant `JournalKafkaConfig.TOPIC_FILE_PARSED`), produced by `FileParsedEventProducer`.
- **Consumer for this feature:** `CassetteInventoryService.onFileParsed` — `@KafkaListener(groupId="journal-cassette-inventory", auto.offset.reset=latest)`. Runs **only in the upload role** (`@ConditionalOnProperty journal.role=upload`).
- Offset reset = **latest**: on a fresh consumer it ignores backlog; use the admin backfill to seed history.

## Write path (journal → incident)

`CassetteInventoryService.processFile(fileId, atmId)`:
1. Load STARTUP transactions for the file; take the most recent; read `rawLines`.
2. `extractSlots()` — regex the STARTUP text:
   - `[DENOMINATION]` table (preferred) → per-slot denom + count, type `RECYCLER`.
   - else `RCY USD` lines (older firmware).
   - `MIX CASSETTE COUNT` → slot `E` (type MIX), `RETRACT CASSETTE COUNT` → slot `R` (type RETRACT).
   - Slots sorted by denomination, lettered `A, B, C...`.
3. `resolveNumericId(atmId)` → `SELECT id FROM atms WHERE atm_id=?` (incident). Null → skip + warn.
4. **Freshness guard:** if incident already has a newer `CASSETTE_INVENTORY.event_time` for that ATM, skip.
5. Otherwise `DELETE` existing `CASSETTE_INVENTORY` rows for the ATM, then `INSERT` fresh slots (`event_source='JOURNAL'`, `cassette_currency='USD'`).

`backfillAll()` does the same across every ATM with STARTUPs (admin sync endpoint).

## Read path (incident → dashboard)

`JournalEventController.getFleetCassetteInventory()` → `JournalEventService.getFleetCassetteInventory(scope)`:
- Scope from `InstitutionContext.resolveScope()` → resolved to institution *names* via `institutionKeyService`.
- Repo query returns the **latest event per (atm_id, cassettePosition)** using `MAX(id)` grouped by `(atm.id, COALESCE(cassettePosition,''))`, with `JOIN FETCH e.atm` (no N+1). Three variants: unrestricted / single-institution / `institution IN (...)`.
- For ATMs with **no** live inventory events, it synthesizes slots from `AtmProperties`:
  - Source 1: agent-synced `cassetteConfigs` map (`position → "currency:denomination"`).
  - Source 2: manually applied `CassetteConfiguration` template.
  - These are flagged `event_source='CASSETTE_CONFIG'` → the UI renders them as "(no data)" config-only pills.

The frontend (`CashStatus.svelte`) does the rest client-side: groups events by ATM, computes empty/low/ok (`LOW_BILL_THRESHOLD = 200` bills), sorts empty→low→ok→config→no-data, and merges in ATMs from `/api/atms` that returned no events at all as "No inventory reported."

## DB tables

| Table | Service / DB | Access |
|-------|--------------|--------|
| `journal_events` | incident (`hiveiq_incident`) | read by API; **written by journal** via `incidentJdbcTemplate` |
| `atms` | incident | read (both by API and by journal's `resolveNumericId`) |
| `journal_transactions` | journal (`hiveiq_journal`) | read by `CassetteInventoryService` (STARTUP rawLines) |

## API endpoints (this feature)

| Method | Path | Service | Notes |
|--------|------|---------|-------|
| GET | `/api/journal-events/fleet/cassette-inventory` | incident | institution-scoped fleet inventory |
| GET | `/api/atms` | incident | ATM list for merge/labels |
| POST | `/api/journal/admin/sync-cassette-inventory` | journal | admin backfill (`MSP_ADMIN`/`BCOS_ADMIN`) |

Cross-links: [platform.data-architecture], [platform.kafka], [platform.service-ownership].
