NUMP Logo
NUMP Limited
terminal Technical Case Study

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.

Claude Sonnet 4.6 Netlify Background Functions Netlify Blobs Vanilla JS NZ Privacy Act 2020

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.

Request Flow
Browser Converts file to base64 locally, generates UUID job ID
Browser POST /.netlify/functions/check-payslip-background
Netlify Returns 202 Accepted immediately, runs function in background
Function Sends file + system prompt to Anthropic API (Claude Sonnet 4.6)
Claude Returns structured JSON via tool use — full audit result
Function Writes result to Netlify Blobs keyed by job ID
Browser Polls /.netlify/functions/get-payslip-result?jobId=… every 3s
Poller Reads blob → deletes it immediately → returns result
Browser Renders full audit UI — result never touches a database
Frontend
Vanilla JS + Tailwind
No framework. File reading, drag-and-drop, polling loop, and full results rendering in plain JavaScript.
Functions
Netlify (Node.js)
Two serverless functions: a background worker that calls the API, and a polling endpoint that reads and deletes the result.
AI
Claude Sonnet 4.6
Vision + document understanding with forced tool use to guarantee structured JSON output on every request.

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.

v1

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.

check_circle Simple. Fast. No moving parts.
v2

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.

cancel Hitting a platform ceiling. Need a different architecture, not a bigger plan.

Alternatives considered and rejected

close Supabase as interim storage

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.

close Upstash Redis

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.

check_circle Netlify Blobs — chosen

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.

v3 — current

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.

check_circle No timeout ceiling. Privacy maintained. Works on the free plan.

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.

check_circle
File data never leaves the browser as a file

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.

check_circle
The payslip data is never written to disk

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.

check_circle
The result is deleted the moment it's read

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.

check_circle
Logging is deliberately constrained

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.

check_circle
Anthropic's enterprise API commitments

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.

check_circle
Compliance by design, not by policy

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.

$0 – $14,000 10.5%
$14,001 – $48,000 17.5%
$48,001 – $70,000 30%
$70,001 – $180,000 33%

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.

architecture

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.

psychology

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.

security

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.

gavel

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.

rocket_launch

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.

person

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