RC2026

Frontend Engineering

·
JAN–AUG 2026

Vestra Assets

Next.js review workspace that turns financial statements into checked, exportable transaction data.

Stack

Frontend EngineeringNext.js + FlaskOCR Review UIPerformanceFirebase Auth

Synopsis

Vestra Assets turns uploaded financial statements into structured, reviewable transaction data. The extraction pipeline, built by a teammate on Flask and Firebase Cloud Functions, classifies each document with an OpenAI model, extracts every page as a separate chunk with Reducto, checks the extraction against the page image with an LLM, and rolls up a confidence score, flagging documents for human review. Between January and August 2026, I owned the frontend: a Turborepo with two Next.js apps, an OCR review app and an admin app, covering upload, processing status, split-pane correction against the source PDF and export, plus Firebase sign-in with an admin-role gate. On the backend: the server half of password-protected PDF handling, a categories module, and validation for CPF statements (Singapore's state savings scheme).

The Product

Financial statements in, reviewable transaction data out: upload, unlock, check against the page, export.

Vestra Assets document list, recreated with sample data. Ten statements for a fictional family trust (bank, credit-card, mortgage, CPF and payslip PDFs) each show a status: Needs review with the reason (confidence under 98, or balances don't reconcile), Extracting with a progress bar, Needs password, or Ready, with confidence score, transaction accuracy, type, page count and last update. Filter tabs count all 48 documents by status, and a footer loads the next 20.
Every uploaded statement with its status, confidence score and accuracy; anything under 98, or whose balances don't reconcile, waits for review.
Product features
#1

Unlock

Password-protected statements stop and ask instead of dead-ending: one password for the batch or one per file, used once and never stored.

My partI built password-protected PDF handling across both codebases: pikepdf unlocking into a separate copy, the password never stored, unlock and delete each claiming the file in a Firestore transaction first, and the screens for one shared password or one per file.

Vestra Assets upload batch, recreated with sample data. Six files: two bank statements stopped at Needs password, one extracting page 3 of 4, one classifying, one queued and one ready to review. An Unlock 2 statements panel offers the same password for both or one per file, notes the password is used once and never stored, that a wrong password leaves the file locked, and that PDFs which only restrict printing or copying open without asking.
#2

Review

The source page and the extracted data side by side: each flagged value opens on its page, gets fixed in a typed editor, and the running balance re-checks.

My partI built this workspace: resizable panes beside the source PDF, one issue list for metadata, flagged and missing rows, editors generated per statement type, and the virtualized table (first render of a synthetic 10,000-row statement went from 2,017 ms to 39 ms in a lab benchmark).

Vestra Assets review workspace, recreated with sample data. On the left, page 3 of a fictional bank statement with the misread withdrawal of 1,284.50 boxed. On the right, issue 3 of 7 (tabs for flagged transactions, metadata and missing rows): a typed editor where the debit read as 1,234.50 is corrected to 1,284.50, with a balance-off-by-50.00 chip and a note that the running balance now reconciles, above the virtualized table of 412 transactions with the corrected row highlighted.
#3

Export

Every transaction lands in a main category, and a reviewed statement downloads as one CSV or a ZIP with one file per category.

My partI built categories end to end (the Flask API over Firestore, the step that maps every sub-category to a main category, the pickers in the table) and the CSV and ZIP exports.

Vestra Assets export for a reviewed statement, recreated with sample data. A categories table rolls 412 transactions into main categories (income, transfers, housing, utilities, groceries, transport, insurance, uncategorized), each listing its sub-categories, row count, debits and credits, with totals. An export panel offers one CSV or a ZIP by category, selected, listing metadata.csv and one CSV per category.
#4

Administer

A separate admin app on the same domain for invitations, roles and categories; anyone who isn't an admin is sent to the review app.

My partI set up the two-app Turborepo and built Firebase sign-in for both apps with the admin-role gate, plus invitations, user and role management and category management.

Vestra Assets admin app, recreated with sample data. A users table lists people with an Admin or User role picker, Active or Invited status with days left on the invitation, and last activity; a header note says the app is for admins only and others go to OCR review. A categories panel lists main categories with their sub-categories and buttons to add more.

Architecture

System Layers

OCR review app (Next.js 16)

App Router app served under /app/ocr: upload, the document list, document detail and the split-pane correction workspace. TanStack Query holds server state and polls files still in progress, TanStack Virtual renders the transaction table, and react-resizable-panels splits the correction view.

Iglobe

Monorepo (Turborepo + pnpm)

Two Next.js apps over shared packages: @repo/ui for shadcn/ui components and CSS-variable design tokens, @repo/shared for the API client, query hooks, types, Firebase config and a Zustand auth store. The admin app sits at the root and rewrites /app/ocr/* to the OCR app, so both apps share one domain.

IIlayers

API (Flask on Cloud Functions)

Flask blueprints registered through one registry and served from Firebase Cloud Functions; the client sends the signed-in user's Firebase ID token as a bearer token. Contributions here: the categories module, CPF result validation, and the unlock route and Firestore claims for password-protected PDFs.

IIIserver

Extraction pipeline

Built by a teammate; context for the review UI. A Storage trigger and Firestore triggers move each file through preprocessing, classification with an OpenAI model, per-page extraction with Reducto, an LLM check against the page image and a rollup. The rollup flags a document for review when confidence falls below 98, transaction accuracy below 98%, or the balances don't reconcile.

IVcpu

Data (Firestore + Cloud Storage)

Firestore holds file status, QA scores and categories; Cloud Storage holds uploads, page chunks, extraction results and the unlocked copy of a protected PDF. Browsers upload and view PDFs directly through expiring signed URLs.

Vdatabase

Execution Flow

5 steps
  1. Step 01

    Upload

    Files go straight to Cloud Storage through signed upload URLs with per-file progress, then sit in an uploaded state until the process step is triggered.

  2. Step 02

    Process

    The backend pipeline preprocesses each file, classifies the document, extracts every page as a separate chunk, checks the extraction against the page image and rolls up a confidence score. A PDF needing a password stops at preprocessing until unlocked; the UI polls only files still in progress.

  3. Step 03

    Triage

    Documents with a confidence score under 98 (of 100), transaction accuracy under 98%, or credits minus debits not matching the change in balance come back flagged for review. The status card shows the score, the accuracy and the flag, and the document list filters on the flag.

  4. Step 04

    Correct

    An issue opens from the issue list or from a row in the transaction table, with the source page loaded in the PDF pane. Fixes go into the typed editor and missing rows into a pre-filled form; saving sends the corrected document back, where the backend validates the edit and recalculates the score.

  5. Step 05

    Export

    A finished document downloads as a CSV, or as a ZIP with a metadata CSV and one transaction CSV per category.

  6. Done

Core Engineering

Field Notes
01

Split-pane correction workspace

Resizable panes put the correction panel beside the source PDF.

Metadata issues, flagged transactions and rows the pipeline found missing share one issue list with a navigator. Selecting an issue loads the issue's source page into the PDF pane, and an expired signed URL can be refreshed in place without losing the page. Missing rows open a form pre-filled from the detected row, and fixes save per issue or all at once.

03

Password-protected PDFs, end to end

Before this work, encrypted PDFs stopped at a requires-action status with no way forward, including PDFs carrying only permission restrictions.

Rebuilt the flow around pikepdf: preprocessing rewrites permission-only encryption as a plain copy without prompting, so only a real password stops a file. The unlock route decrypts into a separate processed copy, never stores the password and resumes processing; a wrong password gets a 422 and the file stays blocked. Unlock and delete each claim the file in a Firestore transaction first, so a delete can't race a decrypted upload. The UI takes passwords during upload or later from the document list, one shared password or one per file, with pytest and Vitest covering the failure paths.

05

Schema-driven editors per statement type

Transaction fields for bank, credit-card, mortgage and CPF statements are defined once in the shared package, and the correction panel renders the editor from those definitions, down to CPF rows carrying a separate amount per CPF account.

Other types, including payslips and income-tax statements, fall back to a generic view.

07

Monorepo and design tokens

Set up the Turborepo with pnpm, two Next.js 16 apps, shared packages and an app switcher between the apps.

Design tokens are CSS variables in one tokens file, documented as the single source of truth and used by the shared shadcn/ui components. The OCR app runs under a /app/ocr base path and the admin app at the root rewrites /app/ocr/* to the OCR app, putting the two apps on one domain. Each app has a Vercel config with a turbo-ignore check, skipping a build when nothing the app depends on has changed.

02

Virtualized table, paginated lists

The transaction table mounted every row and resolved each category label by scanning the category list.

Moved the table to TanStack Virtual (fixed row height, an overscan of 10 rows, a scroll to the edited row when editing starts off screen) and replaced the scans with memoized lookup maps. A later lab benchmark of that change against the previous version, on a synthetic 10,000-row statement: first render fell from 2,017 ms to 39 ms, the DOM from 265,030 to 596 nodes, JS heap from 328 MB to 12.5 MB, and long tasks during a full scroll from 1,387 ms to none. The gain is virtualization and memoization together. Document lists got server-side pagination over the backend's cursors, plus infinite scroll.

First render, 10,000 rows
Before2,017 msAfter39 ms
DOM nodes
Before265,030After596
JS heap
Before328 MBAfter12.5 MB
04

Upload to export

Files upload straight to Cloud Storage through signed URLs with per-file progress, then wait for an explicit process step.

Polling asks only about files still in progress, every 3 seconds, and merges the answers into the list. A finished document can be re-extracted, corrected, deleted singly or in bulk, and exported as a CSV or as a ZIP holding a metadata CSV plus one transaction CSV per category.

06

Firebase sign-in and an admin-role gate

A shared AuthProvider and Zustand store connect Firebase Authentication to both apps.

AuthGuard sends signed-out visitors to the login page with the original path attached; AdminGuard also sends non-admins from the admin app to the OCR app. API calls carry the signed-in user's Firebase ID token as a bearer token. The admin app adds invitations, user and role management, and category management. The guards are client-side; server-side role checks belong to the backend.

08

Backend: categories and CPF validation

Built categories end to end: a Flask blueprint with create, read, update and delete for categories and sub-categories in Firestore, registered through the backend's blueprint registry; a merger step mapping every extracted transaction's sub-category to a main category; the category pickers in the transaction table; and the admin screen for managing categories.

Also extended the validation run on saved corrections to accept CPF transactions, with amounts split across three CPF accounts instead of a single amount field.

Reflections
Outcomes
  • “Measure at the time, not afterwards. The table was virtualized in February 2026 and only benchmarked in October, against the previous version on a synthetic 10,000-row statement. The gains were large but are lab numbers from one machine, with virtualization and memoized lookups measured together.
  • “Virtualizing an editable table is more than a rendering change. The overscan of 10 rows was set for edit safety as well as smooth scrolling, and the table scrolls to a row when editing starts off screen. A fixed row height keeps the math simple but costs flexibility for wrapped descriptions.
  • “Claim before mutating. Unlock and delete act on the same stored PDF, so each first claims the file in a Firestore transaction. A delete can't remove a file mid-unlock, a failed unlock restores the password-required state without overwriting a newer status, and tests cover both orderings.
  • “Older records need the new path too. Files recorded as failed with a PROTECTED error code still had to reach the unlock action, so the UI folds those records and requires-action files into one status, with Vitest tests on the mapping and the document-list filter.
  • “Client-side guards are navigation, not security. AdminGuard only redirects non-admins; the real check is role enforcement in the API, and admin-only routes need server-side tests to match.
Next Steps
  • Benchmark the review workspace on large real statements; the current numbers come from synthetic data.
  • Add typed correction schemas for payslips and income-tax statements, two of the six types the backend extracts and the review UI shows only in a generic view.
  • Add server-side tests for admin-only routes to back the client-side admin guard.

One Last Thing

The core challenge: OCR on financial statements can't be fully automatic. Flagged values have to be found, checked against the source page and corrected quickly, even on statements with long transaction lists. Bank statements also often arrive password-protected, and without an unlock path such files dead-end the pipeline.

More from the Archive

View All