DEV Community

Rostery Team
Rostery Team

Posted on

Modelling the SCHADS Award as Data: Effective Dates, Additive Casual Loading and Test Fixtures for 2026-27

Originally based on our article How to automate SCHADS award interpretation for NDIS payroll on rostery.com.au. This version is rewritten for developers, payroll analysts and anyone maintaining the pay rules inside a disability or home care provider's systems. It also corrects two points from the original (casual overtime and the vehicle allowance), covered below.

The arithmetic in SCHADS payroll is easy. A rate times hours, a percentage, an allowance. What goes wrong is everything around the arithmetic: which rate was in force, which band a shift falls in, whether the casual loading stacks or multiplies, and whether last year's allowance amount is still sitting in a config file somewhere.

2026-27 is a good year to look at this, because the Social, Community, Home Care and Disability Services Industry Award (MA000100) changes twice in five months. The Annual Wage Review lifted minimum rates by 4.75% from the first full pay period starting on or after 1 July 2026. Then home care employees doing disability work (Schedule E) get an increase of up to 15% from the first full pay period starting on or after 1 December 2026, under the Fair Work Commission's decision in [2026] FWCFB 232. A rules engine that treats rates as constants will be wrong twice this year.

This post walks through a small Python model that keeps the rules as dated data, splits shifts into bands, applies the casual loading the right way, and pins it all down with test fixtures you can check against the Fair Work Ombudsman pay guide.

General information only, not payroll or legal advice. Figures are from the Fair Work Ombudsman's SCHADS pay guide effective from the first full pay period on or after 1 July 2026, using Social and Community Services (SACS) Level 2, pay point 1 at $36.22 an hour. Check the current award and pay guide before you rely on any number.

Three kinds of rule, three kinds of table

Most hand-rolled SCHADS logic mixes three different things in the same formula. Pull them apart first.

Flow from a worked shift to pay lines: look up the rate in force for the employee's stream, level and pay point, split the hours into bands, apply the band percentage plus the casual loading, add allowances from a dated list, then output one explained line per band and allowance

  1. Rates. A dollar amount per classification (stream, level, pay point), with the date it took effect. These change at least once a year.
  2. Multipliers. Percentages of the ordinary rate for each band: Saturday 150%, Sunday 200%, public holidays 250%, and for employees engaged as shiftworkers, 12.5% extra for an afternoon shift and 15% for a night shift. These rarely change, but the rules for when they apply are where most bugs live.
  3. Allowances. Flat amounts with a unit: per night, per shift, per kilometre. They are adjusted on their own schedule and they appear as separate lines on a payslip.

Here's the shape of it in code. Money is Decimal, never float.

from datetime import date, timedelta
from decimal import Decimal, ROUND_HALF_UP

CENT = Decimal("0.01")

def money(x: Decimal) -> Decimal:
    return x.quantize(CENT, rounding=ROUND_HALF_UP)

# Rates: one row per classification per effective date
BASE_RATES = [
    # (stream, level, pay_point, effective_from, hourly)
    ("SACS", 2, 1, date(2026, 7, 1), Decimal("36.22")),
]

# Multipliers: share of the ordinary rate, by band
PENALTY = {
    "weekday": Decimal("1.00"),
    "afternoon": Decimal("1.125"),  # shiftworker: finishes after 8pm, at or before midnight
    "night": Decimal("1.15"),       # shiftworker: finishes after midnight or starts before 6am
    "saturday": Decimal("1.50"),
    "sunday": Decimal("2.00"),
    "public_holiday": Decimal("2.50"),
}
CASUAL_LOADING = Decimal("0.25")

# Allowances: flat amounts with a unit, also dated
ALLOWANCES = [
    # (code, unit, effective_from, amount)
    ("sleepover", "per_night", date(2026, 7, 1), Decimal("62.87")),
    ("broken_shift_1", "per_shift", date(2026, 7, 1), Decimal("21.81")),
    ("broken_shift_2", "per_shift", date(2026, 7, 1), Decimal("28.87")),
    ("vehicle", "per_km", date(2026, 7, 1), Decimal("1.05")),
]
Enter fullscreen mode Exit fullscreen mode

The original article suggested the kilometre allowance sits outside the annual review cycle. It doesn't: the vehicle allowance moved to $1.05 per kilometre from July 2026 along with everything else. If your allowance table has no effective date column, that's the first thing to add.

Effective dating, and the "first full pay period" problem

Every lookup takes a date. The simplest version picks the latest row that started on or before that date:

def rate_in_force(rows, key, on: date) -> Decimal:
    """Latest row for this key whose effective_from is on or before `on`."""
    candidates = [r for r in rows if r[:len(key)] == key and r[-2] <= on]
    if not candidates:
        raise LookupError(f"no rate for {key} on {on}")
    return max(candidates, key=lambda r: r[-2])[-1]
Enter fullscreen mode Exit fullscreen mode

The catch is what date you pass in. Award increases don't start on a calendar date. They start from the first full pay period that begins on or after the operative date. So the real start date depends on the employer's pay calendar:

def first_full_period_start(change: date, anchor: date, period_days: int) -> date:
    """First pay period starting on or after `change`, given any known period start."""
    offset = (change - anchor).days % period_days
    return change if offset == 0 else change + timedelta(days=period_days - offset)

first_full_period_start(date(2026, 12, 1), date(2026, 11, 2), 7)    # 2026-12-07
first_full_period_start(date(2026, 12, 1), date(2026, 11, 30), 14)  # 2026-12-14
first_full_period_start(date(2026, 12, 1), date(2026, 11, 23), 14)  # 2026-12-07
Enter fullscreen mode Exit fullscreen mode

1 December 2026 is a Tuesday. A weekly pay run that starts on Mondays picks up the new rates from Monday 7 December. A fortnightly run that started on Monday 30 November doesn't get them until 14 December, because that fortnight began before the operative date.

When the new Schedule E rates start for three example pay calendars around Tuesday 1 December 2026: weekly from Monday, new rates from 7 December; fortnightly starting 30 November, from 14 December; fortnightly starting 23 November, from 7 December; Schedule B (SACS) staff have no rate change on 1 December

There are two sensible ways to store this. You can write the rate row with effective_from set to the employer's actual first full period, which keeps lookups simple but means the table is employer-specific. Or you can keep the award's operative date in the table and map each shift to its pay period start before the lookup. Some systems, Rostery included, look up the rate in force on the date of the shift. Whatever your system does, put a shift from the days between the operative date and the next period start into your tests, so you know which rate it picks and can decide whether that matches how you pay.

For the December change in particular, check which schedule each employee is under. The up-to-15% increase applies to Schedule E (home care employees doing disability work). SACS employees under Schedule B have no rate change on 1 December. A table keyed on level alone, without the stream, will get this wrong.

Bands come from real times and calendar days

A shift's band depends on when it was worked, so the input should be the approved actual start and finish, not the rostered times. Two rules matter most:

  • Weekend and public holiday rates replace shift loadings. A Saturday shift that finishes at 10pm is paid at the Saturday rate, not Saturday plus the afternoon loading.
  • Calendar days split shifts. A shift running from Saturday night into Sunday morning has Saturday hours and Sunday hours. A public holiday covers midnight to midnight.

The afternoon and night loadings work the other way: if a weekday shift qualifies, the loading applies to the whole shift, not just the hours after 8pm. That is an easy bug to write, because the "split by clock time" logic you need for weekends is exactly wrong for weekday shift loadings. Note too that these two loadings are defined for employees engaged as shiftworkers. For a day worker, time outside the 6am to 8pm span is treated differently, so the employee's engagement type needs to be an input.

The casual loading adds; it doesn't multiply

This is the error that shows up most often in spreadsheets, and it's worth a function of its own:

def hourly(base: Decimal, band: str, casual: bool) -> Decimal:
    multiplier = PENALTY[band] + (CASUAL_LOADING if casual else Decimal("0"))
    return money(base * multiplier)
Enter fullscreen mode Exit fullscreen mode

For a casual SACS Level 2.1 worker on a Saturday, that gives 150% + 25% = 175% of $36.22, which is $63.39 an hour, or $507.12 for eight hours. The tempting version takes the casual rate ($45.28) and multiplies it by 150%, giving $67.92 an hour and $543.36 for the same shift. That's $36.24 too much on one shift, and the mirror-image mistake on other lines underpays.

The same addition gives the casual Sunday rate (225%, $81.50) and the casual public holiday rate (275%, $99.61).

The original article also said the casual loading doesn't simply add on top of overtime. For casuals it does: the pay guide lists casual overtime at 175% for the first two hours and 225% after that (Monday to Saturday), 225% on Sundays and 275% on public holidays, which is the full-time overtime rate plus 25%. Overtime triggers (daily hours, outside the span, weekly hours) deserve their own module and their own tests; we've left them out of this sketch to keep it short.

Allowances are configuration, with structure rules attached

The amounts belong in the dated ALLOWANCES table. What the code has to work out is when each one applies:

  • Broken shift. Two or more periods of work on the same day with an unpaid break between them, all inside a 12-hour span. One unpaid break attracts $21.81; two attract $28.87. Count the gaps in the approved times for the day.
  • Minimum engagement. Part-time and casual staff doing disability or home care work are paid at least two hours for each shift, and for each period of work in a broken shift. A 45-minute visit is paid as two hours, at the rate for that day. The rules are on our minimum engagement glossary page.
  • Sleepover. A flat $62.87 for the sleepover period, with work before and after it treated as separate periods for band purposes.
  • Vehicle. Kilometres times the dated per-km amount.

Each allowance should come out as its own line with a reference to the clause behind it, so a person checking the payslip can see it. Our broken shift glossary entry and the casual loading explainer have the plain-English versions if you need to agree the rules with a payroll officer before coding them.

Fixtures: the expected values come from the pay guide, not from your code

Unit tests that compare the engine with itself prove nothing. Write the expected values by hand from the Fair Work Ombudsman pay guide and keep them in a fixture file. Here's a starting set for a casual SACS Level 2.1 employee:

Test fixtures for a casual SACS Level 2 pay point 1 employee from July 2026: Saturday 8 hours $507.12; Sunday hourly $81.50; public holiday hourly $99.61; weekday afternoon shift 3pm to 9:30pm $323.70; broken shift 7 to 9am and 4 to 7pm $248.21 including the $21.81 allowance; sleepover allowance $62.87

import pytest
from decimal import Decimal as D

BASE = D("36.22")  # SACS L2.1 from the first full pay period on or after 1 July 2026

@pytest.mark.parametrize("band,hours,expected", [
    ("saturday", D("8"), D("507.12")),
    ("sunday", D("1"), D("81.50")),
    ("public_holiday", D("1"), D("99.61")),
    ("afternoon", D("6.5"), D("323.70")),   # Thu 3pm to 9:30pm, shiftworker
    ("weekday", D("6"), D("271.68")),
])
def test_casual_bands(band, hours, expected):
    assert money(hourly(BASE, band, casual=True) * hours) == expected

def test_broken_shift_with_one_break():
    worked = money(hourly(BASE, "weekday", casual=True) * D("5"))  # 7-9am and 4-7pm
    assert worked + D("21.81") == D("248.21")

def test_casual_loading_is_not_multiplied():
    assert hourly(BASE, "saturday", casual=True) != money(money(BASE * D("1.25")) * D("1.5"))
Enter fullscreen mode Exit fullscreen mode

These numbers match the worked example on Rostery's SCHADS payroll engine page, which builds a full week for a casual Level 2.1 worker and shows each line. Add fixtures for the cases that bite: a shift crossing midnight into a public holiday, a 1.5-hour visit (minimum engagement), a shift between 1 December and your first full pay period, and a Schedule B employee in December who should see no change. When the 1 December pay guide is your source, write those expected values from it rather than calculating 15% yourself, because not every level rises by the full amount.

The free SCHADS pay rates table and the SCHADS pay calculator are handy for cross-checking a fixture before you commit it.

Show the working

The last design rule is the one that keeps payroll staff on side: every number on the output should be explainable. One line per band and per allowance, each with hours, rate and amount. A total with no breakdown can't be checked, and a payroll officer who can't check a number will rebuild it in a spreadsheet, which brings you back where you started.

In Rostery, approved timesheets come out line by line in exactly this way before they're exported to Xero Payroll, Employment Hero or QuickBooks Online, where the pay run itself happens. Allowance amounts live in an editable list rather than in code. Whether you build or buy, the pattern holds: dated rates, bands from real times, an additive casual loading, allowances as data, and fixtures written from the pay guide.

If you want the plain-English background before writing any code, we covered the penalty and broken-shift rules in SCHADS Award explained: penalties, broken shifts, sleepovers and overtime, and a short list of test shifts for non-developers in Five test shifts to run before you trust your SCHADS payroll.

Sources: Social, Community, Home Care and Disability Services Industry Award 2010 (MA000100), clauses 10, 25, 26, 28, 29 and 34; Fair Work Ombudsman, SCHADS pay guide from the first full pay period on or after 1 July 2026; Fair Work Commission, Annual Wage Review 2025-26 [2026] FWCFB 3500; Fair Work Commission, [2026] FWCFB 232 (Schedule E increases from 1 December 2026). Rostery feature and tool pages read 4 November 2026.

Written by the Rostery team in Sydney. Rostery makes rostering, care and billing software for NDIS and aged care providers.

Top comments (0)