RBZ — Operations Console
Info
Project Documentation

Zimbabwe FX Operations Workbench

Enterprise treasury console for RBZ exchange-rate ingestion, settlement, and audit.

v1.0.0Jump to tech stack

1. Overview

The Zimbabwe FX Operations Workbench is a browser-first treasury console that automates the ingestion of the Reserve Bank of Zimbabwe (RBZ) daily exchange-rate PDF, performs multi-currency settlement calculations (IMTT, bank fees, effective rate), and keeps a tamper-evident audit trail of every action.

It is designed to run as a single-page application with a thin Cloudflare Worker backend used only to proxy the RBZ website (which does not expose CORS headers) and to host static assets.

2. Technology Stack

The project is built on a modern, edge-friendly TypeScript stack with zero server-side database — all rate history is persisted in the user's browser via IndexedDB.

  • Framework: TanStack Start v1 (React 19 + Vite 7) running on Cloudflare Workers
  • Routing: TanStack Router with file-based routes under src/routes/
  • State: Zustand store (src/store/useFxStore.ts) for rates, audit, and sync status
  • Data fetching: TanStack Query + native fetch for server functions and proxy routes
  • Storage: IndexedDB via a typed wrapper in src/lib/db.ts (rates + audit log)
  • PDF parsing: pdfjs-dist (browser worker) in src/lib/pdfParser.ts
  • PDF generation: @react-pdf/renderer for settlement reports and this README
  • UI: Tailwind CSS v4, shadcn/ui primitives, lucide-react icons, Framer Motion
  • Charts: Recharts for the 7-day trend visualisation
  • Numerics: decimal.js for IMTT and rate calculations (avoids float drift)
  • Build / runtime: Bun, Vite 7, Cloudflare Workers (nodejs_compat)

3. System Architecture

The app boots from src/routes/__root.tsx, which mounts the AppShell and the Zustand store. On first load, useFxStore.init() seeds IndexedDB if empty, computes the weekend-aware target date, and immediately fires a background sync against the RBZ website.

All network calls to RBZ are proxied through two server routes — /api/public/rbz/scrape and /api/public/rbz/pdf — because rbz.co.zw does not send CORS headers and the browser would otherwise be blocked.

  • src/lib/businessDay.ts — Weekend-aware date logic. If today is Saturday/Sunday, target falls back to the most recent Friday. Uses LOCAL date components (not ISO UTC) to prevent timezone drift.
  • src/routes/api/public/rbz.scrape.ts — Server route that fetches the RBZ monthly index page, parses anchors with cheerio, and returns a list of {date, url} entries. Uses ranged GET (Range: bytes=0-0) instead of HEAD to probe existence — RBZ's host does not return 200 on HEAD reliably.
  • src/routes/api/public/rbz.pdf.ts — Server route that streams a specific RBZ PDF back to the browser, bypassing CORS.
  • src/lib/rbzSync.ts — Orchestrates the sync: scrape → dedupe against IndexedDB → download new PDFs → parse → persist rows → write audit entries.
  • src/lib/pdfParser.ts — Extracts currency rows from RBZ PDFs using pdfjs-dist. Manual upload uses the same parser for fallback.
  • src/store/useFxStore.ts — Single source of truth for the UI. Exposes init, runSync, importPdf, refreshRates, refreshAudit.

4. Daily Sync Flow

On every page load the app performs the following sequence so the user always sees the freshest available rate without manual intervention:

  • Compute today's date in the user's local timezone.
  • If weekend, fall back to the most recent Friday (RBZ does not publish on Sat/Sun).
  • Call /api/public/rbz/scrape?year=YYYY&month=MM to list all PDFs published that month.
  • Diff against IndexedDB; download only PDFs whose date is not already cached.
  • Parse each new PDF and persist its rows tagged with source = 'RBZ Auto-Sync'.
  • Write a structured audit entry for every step (started, scraped, imported, failed).
  • Update the dashboard. USD/ZWG and ZAR are pinned to the top of the rate grid.
  • If RBZ has not yet published for the target date, the UI shows the latest available rate with a 'no PDF yet' badge.

5. Data Model

All persistent data lives in two IndexedDB object stores. There is no server-side database — the app is fully client-owned.

  • RateRecord: { id, date, currency, bid, ask, mid, source, publishedAt }
  • AuditEntry: { id, ts, action, event, status (info|success|warning|error), payload? }
  • Seed data ships in src/lib/seed.ts for offline-first first-run experience.

6. Functional Modules

The application is organised into three top-level routes, each backed by its own page component:

  • / — Dashboard: live rate cards (USD, ZAR pinned), 7-day trend chart, sync controls, manual PDF dropzone.
  • /workbench — Transaction Workbench: multi-currency settlement calculator with IMTT, bank fees, and downloadable PDF settlement report.
  • /data-integrity — Data Integrity Center: full history table, audit log viewer, and rate reconciliation tooling.
  • /about — This documentation page with downloadable README PDF.

7. Operational Notes

A few non-obvious decisions worth knowing if you maintain or extend this project:

  • Timezone: toIsoDate() uses getFullYear/getMonth/getDate (NOT toISOString) so date keys reflect the user's local day, never UTC.
  • Probe: scrape uses Range: bytes=0-0 instead of HEAD because RBZ returns inconsistent status codes for HEAD.
  • SSR hydration: targetDate is initialised to '' on the server and populated in init() on the client; the date span uses suppressHydrationWarning to avoid mismatch warnings when SSR and client cross a midnight boundary.
  • Currency priority: the dashboard sort places USD then ZAR first, then alphabetises the rest.
  • Audit: every sync, import, and failure is recorded — the Data Integrity Center exposes the full timeline.

8. Security & Data Handling

The workbench is deliberately minimal in surface area. There are no user accounts, no telemetry, and no server-side database — every rate record and audit entry lives in the visitor's own browser (IndexedDB). Two thin server routes exist only to sidestep the RBZ website's lack of CORS headers.

  • Data locality: all rates and audit events are stored in IndexedDB under the origin serving the app; clearing site data wipes everything. Nothing is transmitted anywhere except to www.rbz.co.zw via the proxy.
  • PDF proxy hardening: /api/public/rbz/pdf validates protocol, host, and path (www.rbz.co.zw + /documents/Exchange_Rates/*.pdf), enforces a 15 s timeout, caps the response at 10 MB, and rejects non-application/pdf upstream content-types.
  • Scrape hardening: /api/public/rbz/scrape validates year/month with Zod, applies a per-IP rate limit (30 requests/minute), and gives each probe an 8 s abort timeout.
  • MCP tools: list_rbz_publications and get_latest_rbz_publication reuse the same probe timeouts. The MCP server is public and read-only — it only reveals URLs that RBZ has already published on its own website.
  • External links: every out-of-app link (PDF viewer, README) uses rel="noopener noreferrer" and target="_blank".
  • No secrets: the app requires no API keys, tokens, or auth. There is nothing sensitive to leak.

9. Extending the System

Common modifications and where to make them:

  • Add a currency: extend the CURRENCIES array in src/routes/workbench.tsx and ensure src/lib/pdfParser.ts recognises the symbol.
  • Change fallback rules: edit describeFallback() in src/lib/businessDay.ts.
  • Add a new audit action: call addAudit({ ts, action, event, status, payload }) from anywhere — the Data Integrity Center will display it automatically.
  • Swap PDF source: replace the scrape and pdf proxy routes; the rest of the pipeline is source-agnostic.
Documentation generated from src/lib/projectDocs.ts — the PDF and this page share the same source of truth.