ShiftPay
How I built a precision pay-tracking app for NZ shift workers — one that calculates PAYE, ACC, KiwiSaver, and penal rates in real time, so workers know exactly what they should earn before the payslip arrives.
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. And public holiday Mondayisation rules can change the gross amount entirely.
Penal rates add another layer of complexity: weekend loading, time-and-a-half on public holidays, and alternative holiday credits are all legitimate entitlements — but most workers have no way to verify they were applied correctly. A shift that crosses midnight into a public holiday earns at a different rate for each minute on either side of that boundary.
The tools that exist are either general-purpose calculators requiring manual entry, or payslip verifiers that audit after the fact. Neither gives workers the information they actually need: what should I earn from this shift I'm logging right now?
That's the gap ShiftPay fills. Log a shift, get the gross pay, net pay, and every deduction calculated from the rules — before the payslip arrives.
2 Architecture Overview
The app is built on a local-first architecture. All pay calculations happen on-device — no network call required to log a shift or see your earnings. Supabase handles identity only. RevenueCat handles subscriptions. The financial logic never leaves the phone.
3 The Finance Engine
The intellectual heart of ShiftPay is a singleton service called FinanceEngine. Every pay calculation in the app flows through it. The design choices in this service — iteration over formulas, Rational over Decimal during accumulation, String over float in the database — were each made to solve a real precision problem.
Minute-by-Minute Loop
Most payroll calculators apply a single multiplier to an entire shift. ShiftPay processes each minute independently. This isn't over-engineering — it's the only approach that handles the real edge cases:
- → A shift that starts on a regular weekday and ends on a public holiday earns at different rates on either side of midnight
- → Penal rate windows (e.g. time-and-a-half after 10pm on Sundays) have exact start and end times — minute-level accuracy matters
- → Unpaid breaks are deducted at the average rate earned during the shift — not the base rate — which can only be computed after iterating the full shift
Rational Accumulation → Decimal Output
During the loop, each minute's pay contribution is rate / 60 — a division that produces a repeating decimal. If you accumulate those divisions as floating-point doubles, rounding errors compound across hundreds of iterations. Even Dart's Decimal type introduces truncation on division.
The solution is to use Rational — a type that represents exact fractions with no precision loss — for the entire accumulation phase, then convert to Decimal only at the very end.
// Each minute is processed independently
Rational totalValue = Rational.zero;
DateTime cursor = startTime;
while (cursor.isBefore(endTime)) {
// Resolve rate for this exact minute (holiday? penal rate window?)
final rate = _resolveRate(cursor, baseRate, multipliers, isHoliday);
// Accumulate as exact fraction — no rounding during the loop
totalValue += Rational.parse(rate.toString()) / Rational.fromInt(60);
cursor = cursor.add(const Duration(minutes: 1));
}
// Decimal conversion happens once, at the end
final gross = Decimal.parse(totalValue.toDecimal(scaleOnInfinitePrecision: 10).toString());
final breakCost = unpaidBreak * (gross / totalHours);
final grossPay = gross - breakCost;
String Storage in SQLite
SQLite has no native decimal type. Storing financial values as REAL (float) introduces IEEE 754 rounding the moment the value hits the database. ShiftPay stores every financial figure as a TEXT column — the exact string representation of the Decimal — and parses it back on read. The value that goes in is exactly the value that comes out.
The Snapshot Pattern
When a shift is saved, the current hourly rate and tax code are captured alongside the calculated figures. This means every shift record is a self-contained audit document. If a user changes their hourly rate or tax code in settings later, past shifts are unaffected — they reflect exactly what was earned at the time they were logged. This is the foundation of ShiftPay's payslip verification capability.
Public Holiday Detection with Mondayisation
NZ public holidays that fall on a weekend are "Mondayised" — observed on the following Monday. The engine hardcodes NZ national holidays for the current year, then applies the Mondayisation rules programmatically. A nurse starting a shift at 11:30pm on ANZAC Sunday earns at 1.5x from midnight, not from the start of their shift — and the minute-by-minute loop captures that transition exactly.
4 The Engineering Journey
ShiftPay didn't start as a Flutter app. The path to a cross-platform, production-grade tool involved a platform pivot, deliberate stack selection, and learning that simulator confidence doesn't always survive contact with a real app store build.
Swift — native iOS
The first version was built in Swift using Xcode. Native iOS development was the natural starting point — Xcode handles the App Store submission pipeline directly, and Swift gives you full access to Apple's frameworks without abstraction layers.
It worked. The finance logic was sound, the UI felt native, and the App Store path was straightforward. But shipping it meant shipping to roughly half the market.
Flutter — the cross-platform decision
The pivot to Flutter wasn't driven by technical preference — it was a market decision. NZ's mobile market is roughly split between iOS and Android. A tool built for shift workers that only runs on iPhones excludes a significant portion of the people it's designed to help.
Flutter was the right cross-platform choice because it compiles to native ARM code on both platforms, uses a single codebase without platform-specific workarounds for the core app logic, and produces UI that feels native without requiring two separate development tracks.
The finance engine — the hardest part of the app — translated directly from Swift to Dart. The logic didn't change; only the language did.
Stack selection — analysis over defaults
Supabase was chosen through deliberate market analysis — not by default. The criteria: price at scale, built-in authentication (no need for a separate auth service), PostgreSQL as the underlying database (standard, well-documented), and an active SDK ecosystem. Supabase scored highest across all of them. Firebase was the obvious alternative but its pricing model becomes unpredictable at volume.
Implementing App Store and Play Store subscription validation from scratch is a significant engineering undertaking — receipt validation, server-side verification, entitlement management, and webhook handling across two platforms with different APIs. RevenueCat handles all of it with a single SDK. The same analysis applied: price, feature completeness, and quality of Flutter SDK. RevenueCat was the clear winner.
Flutter — live on both stores
The path from a working simulator build to a stable live app required more iterations than expected. Simulator testing is fast and useful, but it doesn't replicate the actual device environment — hardware differences, OS-level permission flows, biometric authentication behaviour, and App Store entitlement checks all behave differently in production.
Multiple rounds of TestFlight and Play Store beta releases were needed to stabilise behaviour across real devices. Each round revealed issues that had been invisible in the simulator. This isn't a failure of the development process — it's the reality of shipping to physical hardware at scale, and it's why testing on real devices before a public release isn't optional.
ShiftPay is now live on both the App Store and Google Play.
5 NZ Payroll Domain Complexity
Building ShiftPay required understanding NZ employment law, IRD tax rules, ACC legislation, and KiwiSaver regulations — not at a surface level, but well enough to implement them correctly to the cent. The following is what the finance engine calculates on every shift entry.
PAYE — Pay As You Earn
PAYE is calculated on annualised income — not the pay period amount. The engine converts the period's gross to an annual equivalent, applies the marginal brackets, then converts back. This is the correct IRD method; a flat-rate approximation produces wrong answers.
The ME tax code additionally applies the Income Earner Tax Credit (IETC) — $520 for income between $24,000–$44,000, phasing out at 13% withdrawal rate up to $48,000. Secondary tax codes (S, SH, ST, SA) apply flat rates instead of the marginal bracket method.
ACC Earner Levy
The ACC earner levy applies at 1.70% of liable earnings for FY2026/27, capped at an annual maximum of liable earnings. High earners stop paying ACC partway through the year — a zero ACC deduction on a late-year payslip is correct, not an error. The engine accounts for the cap and handles it correctly for users tracking pay across a full year.
KiwiSaver
From FY2026/27, the minimum KiwiSaver contribution rate increased to 3.5%. ShiftPay accommodates this by allowing free text input for the contribution rate rather than a fixed dropdown — so any valid rate (including future changes) can be entered without an app update. The elected rate is stored in the user's profile and captured in each shift snapshot, so contribution records remain accurate even if the user changes their rate later.
Student Loan
Student loan repayments are 12% of gross earnings above a per-period repayment threshold. The threshold must be converted to the relevant pay period before comparing — it cannot be applied annually and then divided down. ShiftPay detects the student loan flag from the tax code suffix (SL) and applies the per-period calculation automatically. Users on M SL, ME SL, and other SL-suffixed codes have this handled transparently.
Penal Rates & Public Holidays
All NZ national public holidays are hardcoded with Mondayisation logic — when a holiday falls on a weekend, the observed date shifts to the following Monday. Shifts on public holidays earn at 1.5x the base rate, or workers can elect to take an alternative holiday (a "day in lieu") instead. Weekend and night penal rates are supported through configurable pay multiplier rules that apply to specific day-of-week and time windows, including wrap-around times that span midnight.
6 Auth & Privacy
Multi-Provider Authentication
ShiftPay supports three sign-in methods, chosen to cover the full range of user preferences across iOS and Android:
A CSPRNG nonce is generated locally, hashed with SHA-256, and passed to Apple during authentication. Apple signs the nonce and returns it in the identity token. Supabase verifies the signature server-side — the raw nonce never leaves the device. This is Apple's required security model and prevents replay attacks.
Standard OAuth 2.0 flow via Google Cloud. The app requests an ID token (not just an access token) which Supabase validates to create a session. Configured separately per platform with the appropriate client IDs — a necessary distinction between iOS and Android in Google's auth model.
After the initial sign-in, subsequent app opens prompt for biometric authentication via the local_auth package. The biometric check is device-level — no keys are stored by the app. A successful check returns a boolean; the app never handles the biometric data itself.
Local-First Privacy
Pay data is among the most sensitive information a person has. ShiftPay's architecture reflects that — financial data is kept on device by design, not by policy.
The FinanceEngine runs entirely in Dart on the user's phone. No shift data, no hourly rates, no tax figures are sent to a server to be calculated. The network is not involved in any pay computation.
Shifts, pay calculations, tax figures, and KiwiSaver contributions are stored in a Drift-managed SQLite database in the app's local documents directory. The database schema includes an isSynced flag as architectural intent for a future optional backup feature — but no sync to Supabase is active. The data does not leave the device.
Supabase stores a user ID and authentication token — nothing else. It has no knowledge of the user's employer, pay rate, tax code, shift history, or earnings. Identity is managed cloud-side; financial records are managed locally.
Each shift record stores the hourly rate and tax code that were active at the time of entry. This means the app can show exactly what was calculated and why — independently of any future changes to settings — giving workers a verifiable record they can compare against employer payslips.
7 What This Demonstrates
ShiftPay exists because a real problem needed solving. But building it required a range of engineering competencies that don't often come together in a single solo project.
Cross-platform mobile architecture
Flutter, Provider, GoRouter, and Drift — a production-grade mobile stack with reactive state, type-safe routing, and a local database. Built to feel native on both platforms from a single codebase.
Financial precision engineering
Rational accumulation, Decimal arithmetic, and String storage in SQLite — three deliberate choices that together eliminate floating-point rounding from every step of a financial calculation pipeline.
NZ payroll domain depth
PAYE annualisation, ACC levy caps, KiwiSaver rates, student loan period thresholds, Mondayisation, and penal rates — implemented correctly, not approximated. Domain understanding is not separable from the engineering.
Deliberate stack selection
Supabase and RevenueCat weren't picked by default — they were selected through structured analysis against alternatives. Price, feature completeness, SDK quality, and privacy implications were all evaluated before committing.
Real-world testing discipline
Simulator confidence didn't always survive contact with a live store build. Multiple TestFlight and Play Store beta rounds were needed to stabilise real-device behaviour. Shipping a mobile app is not the same as running it in a simulator.
Solo end-to-end
Architecture, finance engine, UI, auth integration, App Store and Play Store submission, beta testing, and iteration — all by one person. No team, no agency, no boilerplate starter kit.
Try ShiftPay
Available now on the App Store and Google Play. Track your shifts and know exactly what you should earn — before the payslip arrives.
phone_iphone Discover ShiftPay