One platform, five tools retired.
Engineering record for a bespoke internal staff platform delivered to an anonymised UK fashion retail client. Attendance, leave, expenses, timesheets, and a tablet kiosk — consolidated onto one Postgres, one auth boundary, and one deploy.
Executive summary
An anonymised UK fashion retail client of roughly 50 staff was running their workforce across five disjoint systems: an attendance spreadsheet, a leave SaaS, a paper expense process, a timesheet tool, and a clipboard at the door. We replaced the lot with a single Next.js 16 application backed by Supabase Postgres, with row-level security as the primary authorisation layer, an OCR-driven expense pipeline, a tablet PWA kiosk, and a one-click monthly payroll export. Payroll reconciliation went from ~16 hours to ~90 minutes. Four paid SaaS subscriptions were cancelled.
01Background
The client is a UK-based independent retailer with a head office buying team plus a retail floor. Across both, around 50 staff were tracked across five tools that had accreted over the years: a OneDrive attendance workbook, a SaaS leave tracker on the cheapest tier, a shared inbox of phone-photographed receipts, a separate timesheet web app for the buyers, and a paper sign-in book at reception that the team forgot to update.
The presenting symptom was payroll. Each month a manager spent two working days reconciling the four written sources against one another to produce a payroll input. The deeper symptom was that nobody could answer simple questions: who is in today, how many days off has somebody got left, what did the team spend on lunches this quarter. The data existed; it was scattered and stale.
02The problem in detail
| Symptom | Pre-platform measurement |
|---|---|
| Time spent reconciling monthly payroll | ~16 hours per month, by a senior manager |
| Average expense submission to approval | ~9 days |
| Receipts lost between phone and approval | ~12 percent of submitted spend |
| Kiosk sign-in adherence | ~70 percent on a typical week |
| SaaS subscriptions in scope | 4 paid + 1 spreadsheet |
The combined SaaS spend was modest in isolation but compounding. More importantly, none of the tools talked to one another. Manual reconciliation was the integration layer.
03Goals and non-goals
In scope
- Five integrated modules: attendance, leave, expenses, timesheets, kiosk sign-in
- Phone-camera receipt capture with OCR-extracted merchant, date, total, currency, and category guess
- Manager approval flows with email + in-app notification
- Tablet kiosk (PWA) for the front door, with PIN sign-in and offline tolerance
- One-click monthly payroll export (CSV) reconciled across all modules
- Director weekly digest email summarising anomalies
- Role-based access for staff, managers, directors, admins, enforced at the database layer
Explicitly out of scope
- Recruiting and ATS. The team is small enough that hiring is bespoke. Out.
- Performance reviews. Sensitive, slow-moving, and the client did not want to digitise it yet.
- Direct payroll filing. The export feeds the accountant’s pipeline. We do not touch HMRC submission.
- Native mobile apps. A responsive PWA was sufficient. Native would have 5x’d the cost.
04Architecture
┌──────────────────────────────────────────────────────────────┐ │ Phone (employee) Tablet (kiosk) Desktop (manager/admin) │ │ │ │ │ │ │ └─── Supabase Auth cookie / kiosk service-token ────────│ │ ↓ │ │ Vercel Edge → Next.js 16 (App Router) │ │ ↓ │ │ Server Actions (typed, end-to-end) │ │ │ │ │ ├─→ Postgres 15 (Supabase) — RLS enforced │ │ │ attendance, leave, expenses, timesheets, kiosk │ │ │ │ │ ├─→ Supabase Storage (private bucket) — receipt images │ │ │ │ │ ├─→ OCR worker → Anthropic SDK → claude-sonnet │ │ │ extracts merchant, date, total, currency, category│ │ │ │ │ └─→ Resend (transactional email) │ │ │ │ Vercel Cron │ │ • 06:00 daily → overdue approval reminders │ │ • 07:00 Monday → director weekly digest │ │ • 23:55 last-of-month → payroll snapshot │ └──────────────────────────────────────────────────────────────┘
Module data model
users,profiles,roles— auth + role assignmentattendance_punches— clock-in/out events; one row per punchleave_requests,leave_balances,leave_types— UK statutory leave model with bank holidays per regionexpenses,expense_attachments— one expense, one or more receipt imagestimesheet_entries— buyer / back-office tracking against projectskiosk_punches— separate table from attendance for retail floor sign-inpayroll_snapshots— frozen end-of-month aggregates for audit
05Key technical decisions
Single database over five integrations
Every module writes into the same Postgres. Cross-module features (payroll export, weekly digest, anomaly detection) are SQL joins, not API integrations. This is the single biggest architectural lever the platform has. Five SaaS tools talking via webhook hub is at least 5x more code, 5x more failure modes, and pays a vendor tax forever.
RLS as the authorisation layer
Four roles (staff, manager, director, admin) and seven tables means a 28-cell policy grid. Encoding it as RLS keeps the truth next to the data:
CREATE POLICY "manager_sees_direct_reports_expenses"
ON expenses FOR SELECT
USING (
EXISTS (
SELECT 1 FROM profiles p
WHERE p.user_id = expenses.user_id
AND p.manager_id = auth.uid()
)
);The application code does not check permissions; it queries and trusts that Postgres returns only the rows this user is entitled to see. Service-role access is used only by trusted server-side cron handlers.
Receipt OCR via Anthropic SDK
Receipts are messy: faded thermal paper, multiple currencies, foreign characters, hand-written totals, photos taken at angles. We tried two open-source OCR pipelines first; both struggled on real-world receipts. Claude Sonnet via the Anthropic SDK consistently extracted merchant, ISO date, total, currency, and a category guess at 95 percent+ field accuracy on a 200-receipt evaluation set. Latency under 2 seconds. The cost per receipt is fractions of a penny at the client’s volume.
The OCR call returns strict JSON validated by a Zod schema. If the schema fails, the user sees an empty form and types it themselves. There is no “maybe right” data path.
PWA kiosk over native app
The tablet at reception runs a single browser tab in fullscreen, installed as a PWA. A service worker caches the kiosk bundle so a flaky wi-fi connection does not break sign-in. Punches are queued locally and replayed on reconnect. The kiosk uses a separate scoped service token, not user auth, with a tightly bounded RLS policy that allows only kiosk_punches inserts.
Vercel + Supabase over self-hosted
The client wanted no server administration. Vercel handles deploy, TLS, scaling, cron, logs. Supabase handles Postgres, auth, storage, realtime. The combined operational surface is roughly two dashboards and a status page subscription. Self-hosting would have been cheaper in raw infra cost and dramatically more expensive in attention.
Monolith over microservices
One Next.js app, one repo, one deployment. There is no scenario at this scale where splitting attendance from expenses into separate services pays back. The team is one engineer; the database is shared; the operations are coupled. A monolith with strong module boundaries is the right tool.
06Implementation milestones
| Phase | Weeks | Deliverable |
|---|---|---|
| Discovery | 1 | Workflow shadowing, role matrix, module boundaries, data model draft |
| Foundations | 2-3 | Auth, profiles, roles, RLS policies, deployment pipeline, design system |
| Attendance + leave | 4-5 | Both modules end-to-end, manager approvals, balance accrual, statutory leave defaults |
| Expenses | 6-7 | Receipt upload, OCR pipeline, approval flow, manager email + approve link |
| Timesheets + kiosk | 8-9 | Buyer timesheets, kiosk PWA, offline queue, PIN sign-in |
| Payroll + analytics | 10 | Monthly snapshot, CSV export, weekly director digest cron |
| UAT + cut-over | 11-12 | Parallel run with old tools for one cycle, fix list, sunset of old SaaS |
07Results
Adoption was the unusual win. The platform was never made mandatory. Staff used it because it was faster than the alternatives. The kiosk became the default sign-in within a week. The phone-camera expense flow had near-100 percent adoption inside a fortnight because typing receipt details into a spreadsheet was just worse.
- Payroll reconciliation: 16 hours/month → 90 minutes/month (32x improvement)
- Expense submission to approval: 9 days → ~1 day
- Lost receipts: ~12 percent → near zero
- Kiosk adherence: ~70 percent → ~98 percent
- Subscriptions cancelled: 4 paid SaaS
The platform paid back its build cost inside the first year on saved licences and saved admin time alone, before counting the second-order effects (faster decisions, fewer disputes, better visibility).
08Lessons learned
One database is the cheat code
Cross-module features are trivial when every module shares storage. Five SaaS integrations are five hard problems. One Postgres is one easy one.
RLS is documentation
A future maintainer can read the policies and immediately understand who can do what. That is far better than auditing thirty route handlers for permission checks.
Strict JSON schemas keep OCR honest
The OCR call returns JSON; if it fails Zod parsing, we discard it and ask the user. There is no “sort of right” path. That single rule eliminated an entire class of subtle data quality bugs.
Adoption is a UX problem
No formal training was delivered. The platform replaced the old tools because it was faster. If you have to teach people to use your software, your software is too hard.
Build for the boring path
The unsexy modules (attendance, payroll export) get used every day. The fancy AI bit is the demo. Get the boring path airtight first.
09Conclusion
A bounded, opinionated, monolithic stack delivered five integrated modules in roughly twelve weeks, replaced four paid SaaS tools, and recovered a senior manager’s monthly payroll day. The interesting engineering was in the boring decisions: one database, RLS-first auth, strict OCR schemas, a PWA kiosk. None of these are novel; that is the point. The leverage came from picking conventional tools with discipline and resisting the temptation to split, micro-service, or re-platform.