Five modules, one Postgres.
The plumbing behind a single platform replacing five HR tools. Data flow, OCR pipeline, kiosk PWA, and the consolidated payroll export.
If you only read one paragraph.
Every employee event — a clock-in, a leave request, a receipt photo, a kiosk punch — is a row in the same Postgres database. Four roles see four different slices of that data, enforced by row-level security. Receipt photos go through an OCR pipeline that returns strict JSON, validated by Zod, into the expense form. Approvals fire transactional email via Resend with an inline image and a one-tap approve link. End of month, one button produces a CSV that has already reconciled attendance, leave, and approved expenses. The kiosk runs as a PWA with a service worker so it keeps signing people in even if wi-fi drops.
A photo to an approval, in 30 seconds.
The whole flow is six steps that the user perceives as: take a photo, confirm, done. The OCR step is invisible. The approval round-trip happens entirely from email.
Each module on its own.
Auth and roles
Supabase Auth, magic-link by default. Four roles: staff, manager, director, admin. profiles stores the manager-of relationship for hierarchical access. RLS policies use auth.uid() joined with profiles. Service-role access is only used by trusted server-side cron handlers.
Attendance and timesheets
Two tables, one model. attendance_punches is for the retail floor; timesheet_entries is for back-office staff tracking hours against projects. Both feed the payroll snapshot. The clock-in UI is a single button with a lunch toggle. The timesheet UI is a weekly grid.
Leave
UK statutory leave defaults: 28 days including bank holidays. Bank holidays auto-deducted per region (England + Wales, Scotland, Northern Ireland). Leave types are configurable: annual, sick, parental, unpaid. Balances accrue monthly via a pg_cron job. Approval is the same manager-email-with-link pattern as expenses.
Expenses with OCR
The OCR pipeline is one prompt, one schema, one validation step. The schema is the contract:
If the LLM returns anything that fails this schema, we discard the result and present an empty form. There is no “maybe right” data path. Strict schemas keep the data quality predictable.
Tablet kiosk PWA
The kiosk is a separate route running fullscreen on a wall-mounted iPad. A service worker caches the bundle and queues punches in IndexedDB if the network drops. On reconnect, the queue replays. The kiosk uses a scoped device token, not user auth, with an RLS policy that allows only inserts into kiosk_punches. PIN sign-in (4 digits) maps to a profiles.kiosk_pin_hash column verified server-side.
Payroll export
One button. The handler joins attendance, leave, and approved expenses for the requested month, freezes the result into payroll_snapshots, and emits a CSV ready for the accountant’s pipeline.
Director weekly digest
A Vercel Cron at Monday 07:00 UTC runs anomaly queries: expense spend up more than 30 percent week-over-week, three or more direct reports off on the same day next week, expenses pending more than 5 days. The result becomes a React Email digest, sent to directors only.
Why this, not that.
Next.js 16 (App Router)
Server components for read paths keep JS small on phones; Server Actions handle every mutation with end-to-end types from form to DB. One codebase, one deploy.
A separate Express API + SPA would have doubled the surface and added a stale-data class of bugs. App Router was a clean fit.
Supabase (Postgres 15 + Auth + Storage)
Single vendor for the whole back end. RLS lives next to the data. Storage and Auth share the same project.
Hand-rolling Postgres on RDS + NextAuth + S3 is three vendors, three bills, three runbooks. Not worth it at this scale.
Postgres RLS as the auth layer
Four roles times seven tables is a 28-cell grid. Encoded as policies, it is auditable, version-controlled, and bug-resistant.
Auth in route handlers is a forest of if-statements. One missed check is an incident.
Anthropic SDK (Claude Sonnet) for OCR
95%+ field accuracy on real-world receipts including foreign currencies, faded thermal paper, and handwriting. Strict JSON output validated by Zod.
Tesseract and a local model both struggled on photographed receipts. Tuning would have taken weeks for worse results.
Resend + React Email
Templates as React components, deliverability handled, simple API, generous free tier. Approval emails inline the receipt image.
Postmark is fine but more expensive at this scale. Self-hosted SMTP is operationally hostile.
Vercel Cron
Three scheduled jobs (overdue, weekly digest, payroll snapshot). Cron is a manifest entry, the handler is just a route.
A separate scheduler service is overkill for three daily jobs.
PWA service worker for the kiosk
Offline-tolerant sign-in, queues punches when the wi-fi drops. Reinstalls on tablet reboots without a native app.
A React Native kiosk would be the same UX with 10x the maintenance.
What we watch.
Sentry catches client-side errors. Vercel Analytics catches server-side latency. Postgres pg_stat_statements catches slow queries. The OCR pipeline emits its own structured logs (request id, schema validation outcome, latency) into a ocr_events table for later analysis.
What is next.
Asset register
Track issued laptops, phones, keys. Same auth, same UI conventions. Estimate: ~5 days.
Internal directory
Searchable photo wall + role + manager. Already half-built into profiles.
Mileage tracking
Postcode-to-postcode miles for company travel, HMRC AMAP rate applied automatically. Joins the existing expenses pipeline.
Anomaly digest improvements
More signals: unusual expense categories, attendance pattern shifts, balance-at-risk warnings before year-end.
Want the full engineering record?
The whitepaper goes deeper into the data model, RLS policies, and the implementation timeline.