---
module: transactions.browser
title: Transactions Browser
tab: Architect
order: 30
audience: dev
---

How the transaction browser is built end to end. Three services participate; **only journal owns data**. See [platform.service-ownership].

## Components

| Piece | Repo / image | Role |
|---|---|---|
| SPA | `hiveops-transactions` → `hiveiq-transactions-frontend` (Svelte + Vite, 5178) | UI only; `TransactionBrowser.svelte` (list) + `CustomerJourney.svelte` (drawer) |
| BFF proxy | `hiveops-transactions` backend (port 8098, stateless) | JWT auth, forwards `/api/journal/**`, `/api/atms`, `/api/atm-groups`, `/api/adoons/**` |
| Data owner | `hiveops-journal` → `hiveiq-journal` (upload role, 8087/prod 8010) | Owns `hiveiq_journal` DB, parsing, all `/api/journal/**` reads |
| ATM/group lookup | `hiveops-devices` | Serves `/api/atms`, `/api/atm-groups` for the filter sidebar |
| Adoons analysis | `hiveops-ai` (`IncidentAiController`) | Internal `POST /api/adoons/analyze-journey` (X-Internal-Secret), reached via the AI-URL proxy target |

The SPA's axios client (`lib/api.ts`) base URL comes from `window.__APP_CONFIG__.apiUrl` injected by `entrypoint.sh` from `VITE_API_URL` (default `http://localhost:8098/api`). `TransactionBrowser.svelte` also issues a **relative** `fetch('/api/journal/transactions?…')`, so in production nginx routes `/api/journal/**` straight to journal.

## Read data flow (the browser itself)

```
Svelte SPA ──GET /api/journal/transactions?filters──► journal (upload role)
              JournalApiController.listTransactions()
                → InstitutionContext.resolveScope()  (JWT → institution keys | null)
                → determineMode(from,to) → HOT_ONLY | ARCHIVE_ONLY | BOTH
                → JournalTransactionSpecification.withFilters(...)  (JPA Specification)
                → journal_transactions  (or _archive, or UNION via JournalArchiveQueryService)
              ◄── Page<JournalTransactionDTO>
```

**This read path is fully synchronous and touches no Kafka.** Kafka in journal is ingest/parse-side only (see below). Filters supported: `atmId` (repeatable), `from`, `to`, `type`, `result`, `pan`, `amountMin`, `amountMax` (dollars → ×100 cents), `journalType` (`JRN_ATM`/`NH_TCR`), `foreignOnly`, `page`, `size`.

### 90-day hot/cold split
`determineMode()` compares the range against `now - journal.archive.days (90)`:
- range entirely ≥ cutoff → `journal_transactions` (hot)
- range entirely < cutoff → `journal_transactions_archive` (cold)
- spanning → `JournalArchiveQueryService` runs a UNION over both tables.
The SPA guards this with an "Archived Data" confirm dialog (`ARCHIVE_CUTOFF_DAYS = 90`).

## Drawer (CustomerJourney) data flow

Opening a row loads on demand:
- `GET /api/journal/cassette-config?atmId=` — parses the latest `REPLENISHMENT` raw block to map cassette→denomination (withdrawals only).
- `GET /api/journal/cheques/by-sequence?atmId=&txnId=` then `GET /api/journal/cheques/{id}/preview` (PNG) — deposits only, from `cheque_image`.
- `GET /adoons/status` gates the button; **Ask Adoons** → `POST /adoons/analyze-journey` → result cached back via `PATCH /api/journal/transactions/{id}/adoons-analysis` (writes `adoons_analysis` JSON on the row, fire-and-forget).

The timeline itself is built **client-side** from `rawLines` — no server call.

## DB tables (`hiveiq_journal`)

| Table | Read | Write (by this feature) |
|---|---|---|
| `journal_transactions` | list/detail (hot) | `adoons_analysis` via PATCH |
| `journal_transactions_archive` | list/detail (>90d) | — |
| `journal_files` | admin file list | — |
| `journal_gaps` | (gap tooling) | — |
| `atm_institution_map` | ATM→institution sync | — |
| `cheque_image` | deposit images | — |

## Kafka (ingest/parse side — not the browser read path)

See [platform.kafka] and [platform.data-architecture]. Journal follows an executor → Kafka → record-store pattern for *ingest*:

- `journal.parse-requests` / `journal.parse-requests.{INSTITUTION}` — upload role produces parse requests; **worker-role** containers consume (`JournalParseConsumer`).
- `journal.file-parsed` (`TOPIC_FILE_PARSED`) — produced after a file parses; consumed by `CassetteInventoryService` and `JournalGapDetectionService`.
- `hiveops.journal.transactions.recorded` (`TOPIC_TRANSACTIONS`) — per-transaction event stream for downstream consumers.
- `journal.replenishments` (`TOPIC_REPLENISHMENTS`) — replenishment events.

Once parsed rows land in `journal_transactions`, the browser reads them directly over HTTP; it does not subscribe to any topic.

## Key endpoints

| Method | Path | Service | Auth |
|---|---|---|---|
| GET | `/api/journal/transactions` | journal | `isAuthenticated()` + institution scope |
| GET | `/api/journal/transactions/{id}` | journal | `isAuthenticated()` (+403 on scope mismatch) |
| PATCH | `/api/journal/transactions/{id}/adoons-analysis` | journal | `isAuthenticated()` |
| GET | `/api/journal/cassette-config?atmId=` | journal | `isAuthenticated()` |
| GET | `/api/journal/cheques/by-sequence?atmId=&txnId=` | journal | JWT (via `/api/**`) |
| GET | `/api/journal/cheques/{id}/preview` | journal | JWT (via `/api/**`) |
| GET | `/api/journal/admin/**` (rules, reparse, files, reconcile) | journal | `hasAnyRole('MSP_ADMIN','BCOS_ADMIN')` |
| GET | `/api/atms`, `/api/atm-groups` | devices (proxied) | JWT |
| GET/POST | `/adoons/status`, `/adoons/analyze-journey` | AI-URL target | see [platform.service-ownership] |

## Deployment note

Journal ships one image, two roles (`journal.role`). All browser-facing controllers are `@ConditionalOnProperty(journal.role=upload)`, so **only the upload container answers this SPA**; worker containers only run the parse listener.
