NZ Payslip
Checker
How I built a privacy-first AI tool that reads NZ payslips, recalculates every deduction from scratch, and tells workers whether their employer paid them correctly — without storing a single byte of their data.
1 The Problem
New Zealand shift workers — nurses, hospitality staff, factory workers, retail — are paid under rules that most employees don't fully understand. PAYE tax is calculated using annualised income brackets, not a flat rate. ACC earner levy applies to all liable earnings. KiwiSaver contributions must match the elected rate. Student loan repayments have per-period thresholds. And public holiday Mondayisation rules can change the gross amount entirely.
Most workers receive a payslip, glance at the net figure, and trust it. The tools that exist to verify pay are either general-purpose calculators requiring manual entry, or IRD's own portal — which tells you what tax should have been paid annually but not whether this specific payslip is correct.
The gap I wanted to fill: upload a payslip, get a line-by-line audit in seconds. No account. No data stored. No friction.
2 Architecture Overview
The system has three layers: a static frontend, a pair of Netlify serverless functions, and the Anthropic API. There is no database, no user accounts, and no persistent storage of any user data.
3 The AI Layer
The core challenge with using an LLM for payroll verification isn't getting it to read a payslip — modern vision models can do that easily. The challenge is getting it to output a consistent, machine-readable structure every single time, regardless of how different payslip formats look across hundreds of NZ employers.
Tool Use for Guaranteed Structure
I used Anthropic's tool use feature with tool_choice: { type: "tool", name: "analyse_payslip" }. This forces Claude to respond exclusively by calling a predefined tool — it cannot reply in free text. The tool's input schema defines exactly what fields are required, their types, and their allowed values.
This means the frontend can trust the shape of the response completely. No parsing of natural language. No regex on output. No retries when the model decides to be conversational. If the tool call doesn't appear in the response, the function throws — it's treated as a hard failure.
The Schema
The output schema covers every component of an NZ payslip as a typed object tree:
{
payPeriod: { type, startDate, endDate, payDate },
employee: { name, employeeId, taxCode, kiwiSaverRate, hasStudentLoan },
employer: { name, irNumber },
earnings: [ { description, hours, rate, amount } ],
totals: { totalHours, grossPay },
deductions: {
paye: { found, expected, pass, taxableIncome, annualisedIncome, marginalRates, note },
acc: { found, expected, pass, note },
kiwiSaver: { found, expected, pass, rate, applicable, note },
studentLoan: { found, expected, pass, applicable, weeklyThreshold, note }
},
otherDeductions: [ { description, amount } ],
netPay: { found, expected, pass, note },
overallPass: boolean,
confidenceLevel: "high" | "medium" | "low",
confidenceNote: string,
summary: string,
warnings: [ string ]
}
Every deduction has found, expected, and pass fields — not just a flag but the actual numbers that were compared. The working behind each calculation appears in the note field so users understand exactly how figures were verified.
System Prompt Design
The system prompt encodes NZ-specific payroll rules directly. Rather than relying on the model's training knowledge (which may be outdated), the prompt explicitly provides:
- → The 2024–2025 PAYE tax brackets for every common tax code (M, M SL, ME, SB, S, SH, ST, SA)
- → ACC earner levy rate ($1.60 per $100, capped at ~$142,283 annual liable earnings)
- → KiwiSaver contribution rates (3%, 4%, 6%, 8%, 10%) — detected from the payslip
- → Student loan threshold (~$24,128/year, 12% of gross above the per-period equivalent)
- → Explicit instruction to convert all annual thresholds to the detected pay period before comparing
That last point matters more than it sounds. A model that compares a weekly PAYE deduction against annual tax tables gets wrong answers. The prompt requires period conversion before any comparison is made.
Why Sonnet Over Haiku
The tool was initially built with Claude Haiku 4.5 for speed and cost. Haiku worked — it could read payslips and produce valid JSON. But on complex payslips with multiple earnings lines, overlapping deductions, and unusual tax codes, it would occasionally misread figures or skip the period conversion step. These aren't acceptable failure modes for a financial verification tool.
Claude Sonnet 4.6 produces markedly more accurate extraction and calculation, particularly on image-format payslips where layout varies significantly. The tradeoff is response time — Sonnet takes 15–30 seconds on a complex payslip versus 5–8 for Haiku. That response time is exactly what drove the background function architecture.
4 The Engineering Journey
This wasn't built right first time. The architecture went through three meaningful iterations, each driven by a real constraint hitting production.
Synchronous function + Haiku
The first version was a single synchronous Netlify Function. Browser uploads file, function calls Claude Haiku, Claude responds, function returns JSON, browser renders. Simple, fast, effective.
Haiku's speed meant the round-trip completed in 5–8 seconds — well within Netlify's 10-second function timeout. This worked.
Upgrade to Sonnet — timeout wall
Switching to Claude Sonnet 4.6 exposed the fundamental problem: Sonnet takes 15–30 seconds on a payslip. Netlify's free tier hard-caps synchronous functions at 10 seconds. The function was timing out before Claude could respond.
The obvious fix — upgrade the Netlify plan for a 26-second limit — was tested and found insufficient. Complex PDFs with multiple earnings lines could still exceed 26 seconds, meaning the plan upgrade just delayed the problem.
Alternatives considered and rejected
Supabase is already in the stack for ShiftPay. But this tool's core value proposition is zero data retention. Payslips contain employee names, IRD tax codes, employer IR numbers, and salary figures. Writing that to a PostgreSQL table — even temporarily — directly contradicts the privacy architecture and the NZ Privacy Act 2020 compliance story.
Redis with a short TTL would work technically — results auto-expire after 60 seconds. Rejected because it introduces a new third-party service with its own data processing terms, privacy policy, and failure modes. Every new dependency is a new risk surface.
Netlify Blobs is the platform's own edge key-value store, built into the same infrastructure the functions run on. No new accounts. No new service terms. The result is stored only for the seconds it takes the browser to retrieve it — the polling function deletes the blob the moment it reads it.
Background function + polling
Netlify Background Functions run for up to 15 minutes on all plans, including free. By naming the function file check-payslip-background.js, Netlify automatically routes it as a background job — returning a 202 immediately and running the function asynchronously.
The frontend generates a crypto.randomUUID() job ID before sending the request, then polls a second endpoint every 3 seconds until a result appears under that ID in the blob store.
From the user's perspective: upload, see a spinner with rotating status messages, get results. No visible difference — except it now works reliably regardless of how long Claude takes.
5 Privacy Architecture
Privacy wasn't added at the end — it was a constraint that shaped every architectural decision. The tool handles some of the most sensitive information a person has: their name, employer, tax code, salary, and deduction breakdown. Getting this wrong isn't a UX problem, it's a legal one under the NZ Privacy Act 2020.
The payslip is converted to a base64 string in the browser using the FileReader API before anything is sent to the server. The original file object never leaves the device. What travels over the network is a JSON payload — the same format as any API call.
The background function receives the base64 payload in memory, passes it directly to the Anthropic API, and discards it. Nothing from the payslip itself — not the base64, not any extracted field — is written to Netlify Blobs. Only the analysis result JSON goes to storage.
The polling function's operation is: read blob → delete blob → return result. It doesn't return success until after deletion. The result exists in Netlify Blobs only for the seconds between the background function writing it and the next poll cycle retrieving it.
The function logs timing and error metadata only — never payslip content, employee names, dollar amounts, or tax codes. Netlify's function logs would typically capture all console output; the logging strategy was designed knowing that.
API inputs sent to Anthropic are not used for model training and are not retained beyond the duration of the request, under their enterprise privacy commitments. This is independently verifiable at anthropic.com/privacy.
Under the NZ Privacy Act 2020, organisations must not collect personal information unless necessary for a lawful purpose. The architecture sidesteps this entirely: there is nothing to collect, no system to collect it into, and no identifier that could link a payslip to a person. Privacy compliance isn't enforced by a policy document — it's enforced by the code.
6 NZ Payroll Domain Complexity
NZ payroll is genuinely complex. Encoding it correctly into a system prompt required significant research into IRD rules, ACC legislation, and KiwiSaver employer obligations. The following is what the AI is instructed to verify on every payslip.
PAYE — Pay As You Earn
PAYE is calculated on annualised income — not the pay period amount. Claude is instructed to multiply the period's gross by the number of periods per year to get the annualised equivalent, apply the marginal tax brackets to that figure, then divide back to the period. This is the correct IRD method.
Secondary tax codes (S, SH, ST, etc.) apply a flat rate rather than the marginal bracket method, and the tool handles each code separately.
ACC Earner Levy
The ACC earner levy is $1.60 per $100 of liable earnings (1.60%), capped at an annual maximum (~$142,283 for 2024–2025). The cap means high earners stop paying ACC partway through the year — their payslip shows a zero ACC deduction. The tool is instructed to flag this as correct, not as a failure.
KiwiSaver
Employees elect a contribution rate of 3%, 4%, 6%, 8%, or 10% of gross earnings. The tool detects which rate was used from the payslip (either from an explicit field or inferred from the deduction amount) and validates the deduction matches. Employees who opt out are marked not applicable rather than failed.
Student Loan
Student loan repayments are 12% of gross earnings above a repayment threshold (~$24,128/year annualised). The per-period threshold must be calculated before comparing — a weekly payslip has a threshold of approximately $464/week, not $24,128. This period conversion is explicitly required in the system prompt because it's the most common calculation error.
7 What This Demonstrates
This project wasn't built to showcase technology for its own sake. It was built to solve a real problem for real people. But it reflects a set of engineering principles I apply consistently across everything I build.
Architectural tradeoffs
The background function pattern wasn't the first choice — it was the result of hitting real constraints and rejecting solutions that didn't meet the privacy bar. Knowing when to redesign versus when to tune is a core engineering judgment.
LLM integration in production
Structured tool use, system prompt design for domain accuracy, model selection tradeoffs, and handling non-deterministic outputs in a deterministic product. This is what real AI integration looks like beyond a demo.
Privacy as architecture
Not privacy as a checkbox — privacy as a constraint that shapes what the system is allowed to do at an architecture level. Compliance by design rather than policy.
Domain depth
NZ payroll has real regulatory complexity — tax brackets, levy caps, period conversion, secondary codes. Encoding that correctly into an AI system requires understanding the domain, not just the technology.
Shipped and live
This isn't a repo on GitHub — it's a production tool at nump.co.nz/PayslipCheck.html, deployed on Netlify, integrated with Anthropic's API, and being used by real NZ workers.
Solo end-to-end
Architecture, frontend, backend, AI integration, privacy design, deployment, and iteration — all by one person. No team, no agency, no boilerplate starter kit.
Try It
The tool is live and free to use. Upload any NZ payslip and see the full audit in under 30 seconds.
fact_check Open Payslip Checker