NUMP Logo
NUMP Limited
phone_iphone Mobile Case Study

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.

Flutter Drift ORM Supabase RevenueCat Provider GoRouter

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.

Data Flow
User Enters shift start, end, break, shift type via ShiftEntryScreen
App Calls FinanceEngine.calculateShiftPay() — minute-by-minute on device
Engine Calls calculateTax() — annualises, applies brackets, de-annualises
Engine Returns gross pay, net pay, PAYE, KiwiSaver, ACC as Decimal values
Repo Saves shift + calculated values to Drift/SQLite as String snapshots
Provider Reactive stream pushes updated shift list to HomeScreen
UI Calendar, shift list, and summary card update — no reload required
Mobile
Flutter + Provider
Single codebase targeting iOS and Android. Provider for dependency injection and reactive state. GoRouter for type-safe navigation.
Storage
Drift ORM + SQLite
Type-safe local database with code generation. All financial data lives on-device. Reactive streams for real-time UI updates without manual refreshes.
Backend
Supabase + RevenueCat
Supabase for authentication only. RevenueCat for subscription management. Neither service ever touches payroll data.

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.

v1

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.

warning iOS-only. 50% of the target market is on Android.
pivot

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.

check_circle One codebase. Both platforms. Full market coverage.

Stack selection — analysis over defaults

check_circle Supabase — auth + backend

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.

check_circle RevenueCat — subscription management

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.

v2 — current

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.

check_circle Live. Both platforms. Real-device tested.

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.

$0 – $15,600 10.5%
$15,601 – $53,500 17.5%
$53,501 – $78,100 30%
$78,101 – $180,000 33%
$180,001+ 39%

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:

fingerprint
Apple Sign In

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.

login
Google Sign In

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.

face
Biometric Lock (Face ID / Touch ID)

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.

check_circle
All calculations happen on-device

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.

check_circle
Financial data lives in local SQLite only

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.

check_circle
Supabase handles identity only

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.

check_circle
Snapshots as an audit trail

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.

phone_iphone

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.

calculate

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.

gavel

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.

analytics

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.

bug_report

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.

person

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