---
module: technical.recon
title: Recon — ATM cash reconciliation service
tab: Overview
order: 10
audience: dev
---

> **DRAFT · internal.** Written 2026-07-01 from hiveops-recon source. Verify against code before relying on specifics.

## Responsibility

`hiveops-recon` is the ATM cash reconciliation microservice. Custodians record replenishments; the service aggregates cash movements from `hiveops-journal` per **reconciliation cycle** (period between two replenishments) and computes expected vs. actual closing balances, flagging variances. It also tracks per-cassette configuration, cash positions, and short-term cash forecasts, and maintains a local `device_summary` mirror (via Kafka) so it can scope data by device/institution without calling `hiveops-devices`. Owns the recon domain exclusively — see [platform.service-ownership].

Backend port **8091** (NGINX `/api/recon/`, prod external `8013`); frontend `recon.bcos.cloud`.

## Data

- **Database:** `hiveiq_recon` (Postgres, prod profile). Prod builds the JDBC URL from `DB_HOST` / `DB_PORT` / `DB_NAME` (default `hiveiq_recon`) — **not** a single `DB_URL` var. Dev profile uses in-memory H2 (`ddl-auto: create-drop`, Flyway disabled). See [platform.data-stores].
- **Key tables/entities** (Flyway `db/migration` + `@Table`):

| Table | Purpose |
|---|---|
| `replenishment_sessions` | Custodian-recorded replenishment events |
| `replenishment_cassette_entries` | Per-cassette/denomination detail for a session |
| `reconciliation_cycles` | Open/closed cycles with opening/closing balances + variance |
| `atm_cash_positions` | Current cash position per ATM |
| `atm_cassette_configs` | Per-ATM cassette layout/denomination config |
| `cash_forecasts` | Projected cash levels / forecast output |
| `recon_device_rules` | Rules driving auto device selection into recon |
| `recon_device_selection` | Which devices are enrolled in recon |
| `device_summary` | Local mirror of device data, fed by Kafka |

## Kafka

**Consumer-only — no producers found** (no `KafkaTemplate` / `StreamBridge` sends in source).

| Direction | Topic | Listener class | Group |
|---|---|---|---|
| Consumed | `hiveops.device.events` | `DeviceEventConsumer` | `hiveops-recon-device-sync` |
| Consumed | `journal.replenishments` | `ReplenishmentEventConsumer` | `recon-replenishment-group` |
| Consumed | `hiveops.journal.transactions.recorded` | `JournalTransactionConsumer` | `recon-transaction-group` |

Topic constants: `hiveops.device.events` in `config/DeviceEventKafkaConfig`; the two journal topics in `kafka/ReconKafkaConfig`. The `device_summary` mirror is populated from `hiveops.device.events`. See [platform.kafka].

## API surface

Base path `/api/recon` (admin sub-paths under `/api/recon/admin`, `/api/recon/device-rules`, `/api/recon/device-selection`). Not exhaustive:

| Method + path | Purpose |
|---|---|
| GET `/atm/{atmId}/summary` | Recon summary for one ATM |
| GET `/fleet/summary` | Fleet-wide recon summary |
| GET `/fleet/pending` | ATMs pending reconciliation |
| GET `/groups` | Grouped fleet summary |
| GET `/atm/{atmId}/cycles` · `/cycles/current` | List cycles / current open cycle |
| GET `/cycles/{id}` · `/cycles/{id}/startup-snapshots` | Cycle detail / startup snapshots |
| POST `/atm/{atmId}/sync` | Pull journal txns, recalc open cycle |
| GET `/atm/{atmId}/replenishments` · POST same | List / create replenishment session |
| GET `/atm/{atmId}/replenishments/prefill` | Auto-parse journal into a prefilled form |
| POST `/replenishments/{id}/lock` · PUT `/replenishments/{id}` | Lock / edit a session |
| GET/PUT `/atm/{atmId}/cassettes` · POST `/atm/{atmId}/cassettes/sync` | Cassette config read/update/sync |
| GET `/fleet/forecast` · GET `/atm/{atmId}/forecast` | Cash forecasts |
| GET/PUT `/device-selection` · POST `/device-selection/enroll/{atmId}` | Manage enrolled devices |
| GET/POST/PUT/DELETE `/device-rules[/{id}]` | Recon device rules CRUD |
| GET/PUT `/admin/rules` (text/plain) · POST `/admin/reload-rules` | recon-rules.yml view/edit/reload |
| POST `/admin/backfill[/{atmId}]` · `/admin/reparse/{atmId}` · `/admin/ingest-replenishments` | Admin backfill/reparse tooling |

## Gotchas

- **Write/admin endpoints are role-gated** with `@PreAuthorize("hasAnyRole('MSP_ADMIN','BCOS_ADMIN')")` (POST/PUT/DELETE across replenishments, cycles sync, cassettes, device-rules, device-selection, forecast recompute, all `/admin/*`). Read GETs (summaries, cycles, forecasts, prefill) are not annotated at method level.
- **Prod DB config uses `DB_HOST`/`DB_PORT`/`DB_NAME`**, unlike `hiveops-journal` which uses a single `DB_URL`.
- **Journal dependency:** prefill/sync require `hiveops-journal` to define the `REPLENISHMENT` transaction type (OPCode `AA`, `jpr_contains: "STANDARD REPLENISHMENT"`) in journal-rules; recon parses the `STANDARD REPLENISHMENT` block (BEG/USE/CUR per denomination).
- **Rules editing:** `/admin/rules` GET/PUT are `text/plain` (raw recon-rules.yml); changes need `/admin/reload-rules` to take effect. Rules path overridable via `RECON_RULES_PATH`.
- **Variance math** (from CLAUDE.md): `expected = opening + added - removed - withdrawals + deposits - reversals`; `variance = actual − expected`.
- **Consumer-only Kafka**: recon reacts to device + journal events but publishes nothing; the `device_summary` mirror has no reconciler, so it goes stale if upstream stops emitting `hiveops.device.events`.
- Swagger disabled by default (`SWAGGER_ENABLED=false`); actuator exposes only `health,info`.
