---
module: transactions.customer-journey
title: Customer Journey
tab: Architect
order: 30
audience: dev
---

How the Customer Journey drawer is built. It is a **read-mostly composition across four services**, orchestrated entirely by the Svelte component `CustomerJourney.svelte` — there is no single "journey" backend endpoint. See [platform.service-ownership].

## Services involved

| Service | Role in this feature |
|---------|----------------------|
| **hiveops-transactions** (port 8098) | Stateless BFF proxy. Owns nothing; authenticates the JWT, forwards `/api/**` to journal / devices / incident. Only first-party route is `/api/users/me`. |
| **hiveops-journal** (port 8087, DB `hiveiq_journal`) | Source of truth for the transaction, its raw journal block, cheque images, cassette config, and the persisted Adoons verdict. |
| **hiveops-devices** | Supplies the ATM list (`/atms`) used only to resolve `atmVendor` (make) for the Adoons prompt. |
| **hiveops-incident** (port 8080) | Adoons gateway: validates the user JWT, then proxies to hiveops-ai with an `X-Internal-Secret`. Owns no journey data. |
| **hiveops-ai** ("Adoons") | Computes the verdict/narrative/steps from the raw journal block. |

## Data flow

**Everything is synchronous request/response. No Kafka is on the Customer Journey read or write path.** (hiveops-journal *does* run Kafka producers — file-parsed, transaction, replenishment events — during ingest/parsing, but the drawer reads already-persisted rows. See [platform.kafka].)

Open drawer:
1. `CustomerJourney.svelte` receives a `JournalTransaction` (already fetched by the transaction list) — includes `rawLines`, `amountCents`, `hostResult`, `sequenceNumber`, `sessionStartedAt/EndedAt`, `adoonsAnalysis`.
2. On mount → `GET /adoons/status` (button gating) and, conditionally, cheque/cassette lookups.
3. **Timeline is parsed in the browser** from `rawLines` (`buildSemanticTimeline`, `parseSessions` — Nautilus `********` vs Hyosung `[TRANSACTION RECORD]`/`++++` JPR delimiters). No backend involvement.

Ask Adoons (compute + persist):
```
Browser POST /adoons/analyze-journey {transactionId,rawLines,atmId,atmVendor}
  → transactions-proxy (services.ai.url → incident)
  → incident AdoonsController  (validates JWT, builds request)
  → hiveops-ai POST /api/adoons/analyze-journey  (header X-Internal-Secret, 270s read timeout)
  → returns {verdict, narrative, steps}
Browser then PATCH /journal/transactions/{id}/adoons-analysis  (fire-and-forget save)
  → journal writes adoonsAnalysis (JSON string) onto journal_transactions row
```
This mirrors the platform's proxy→compute→record-store pattern: incident is the JWT-boundary proxy, hiveops-ai is the executor, journal is the record store. See [platform.data-architecture].

## DB tables (hiveiq_journal)

| Table | Read | Written | By |
|-------|------|---------|----|
| `journal_transactions` | transaction row + `rawLines` | `adoonsAnalysis` column (JSON) | journal `JournalApiController` |
| `journal_transactions_archive` | 90-day cold copy (same shape) | — | archival job |
| `cheque_image` | deposit cheque metadata + PNG file path | — | journal `JournalImageQueryController` / `ChequeImageService` |

Cassette config is **not a table** — `GET /api/journal/cassette-config` reads the ATM's most recent `REPLENISHMENT` transaction and regex-parses its last `# CUR DENO` block on the fly.

## Key API endpoints

Frontend paths are relative to the transactions proxy base (`…/api`); the proxy forwards path + query verbatim (only `Authorization` + `Content-Type` headers survive).

- `GET  /adoons/status` → incident `GET /api/adoons/status` (probes ai `/actuator/health`)
- `POST /adoons/analyze-journey` → incident `/api/adoons/analyze-journey` → ai `/api/adoons/analyze-journey`
- `PATCH /journal/transactions/{id}/adoons-analysis` → journal (institution-scoped; 403 if out of scope)
- `GET  /journal/cheques/by-sequence?atmId&txnId` → journal (`ChequeImageService.listByTxnId`, institution-scoped)
- `GET  /journal/cheques/{id}/preview` → journal (PNG bytes; `verifyImageAccess` enforces scope)
- `GET  /journal/cassette-config?atmId` → journal (parses last REPLENISHMENT raw block)
- `GET  /atms` → devices (vendor/make lookup)

## Scoping / security notes

- All journal endpoints here are `@PreAuthorize("isAuthenticated()")`; institution isolation is enforced in the **service layer** via `InstitutionContext.resolveScope()`, not by role.
- The Adoons-save PATCH validates the transaction's `institution` is in the caller's scope before writing.
- The incident→ai hop is authenticated by `X-Internal-Secret`, not the user JWT — incident is the JWT trust boundary.

## Denomination inference (client-side, `parseDenominationsFromRaw`)

Precedence: (1) `DISPENSED NOTES` / `Dispense Count` cassette counts → (2) per-cassette `JB` sub-record amounts → (3) `cassette-config` denomination map → (4) single-denomination inference from `amountCents / notes`. Falls back to raw note counts when none resolve. All of this runs in the browser; the backend only supplies the raw block and the cassette map.
