Skip to main content

Gig::PayRate

Purpose

A PayRate defines how much a company pays gig workers for a specific role under specific conditions — for example, "$12.91/hr for a gig Service Crew night shift at the Tampines outlet." PayRate is the living source of truth for gig wage rates, configured once during client onboarding and maintained over time.

PayRate lives in the Gig domain (not Org) because gig rates are specific to gig work. A company may pay their own full-time Service Crew a different rate than a gig Service Crew — gig rates reflect the market rate for ad-hoc workers, not the company's internal pay structure. If the Workforce domain later needs its own rate management, it would have Workforce::PayRate independently.

PayRates replace the legacy system's "template explosion" pattern, where companies created duplicate job templates for every rate variation (e.g., "Casual Crew", "Casual Crew (PH)", "Casual Crew (CNY)", "Casual Crew Night Shift"). Instead of N templates per role, a single role has N PayRate entries — one per condition.

PayRates are resolved at Gig::Shift creation time: the system looks up the applicable rate based on the Job's outlet + role + the shift's timing, and stores the resolved rate on the Shift. This means:

  • Gig::PayRate can be updated without affecting existing Shifts (their rates are already locked)
  • Future Shifts automatically use the latest rates
  • No "stale snapshot" problem — there is no intermediate copy that can diverge

See Pay Rate Patterns for the data analysis that drove this design.

Model Context

ContextDetails
EntityGig::PayRate (independent)
LayerCompensation Configuration
Upstream dependenciesOrg::Company (required), Org::Outlet (optional — NULL means company-wide default), Org::JobRole (which role this rate applies to)
Downstream dependentsGig::Shift (resolves hourly_rate from PayRate at creation time)

Rate Resolution Logic

When a Gig::Shift is created for a Job at Outlet X for Role Y starting at 9pm on a Tuesday:

  1. System determines the shift's conditions from starts_at:

    • day_type: Is the date a public holiday (via Geo::PublicHoliday)? → public_holiday. Is it Saturday/Sunday? → weekend. Otherwise → weekday.
    • Time-of-day in outlet's timezone: convert shift.starts_at to the outlet's local time and extract the time of day. e.g. 9pm SGT.
  2. System looks up the rate using a fallback chain:

    • First: Outlet-specific band match → (company, outlet=X, role=Y, day_type=weekday) row where shift time of day falls within [starts_at, ends_at).
    • Fallback: Company-wide band match → (company, outlet=NULL, role=Y, day_type=weekday) row where shift time of day falls within the band.
    • Fallback: Outlet-specific day_type-wide row → (company, outlet=X, role=Y, day_type=weekday) with starts_at IS NULL AND ends_at IS NULL.
    • Fallback: Company-wide day_type-wide row → same shape at company level.
    • Fallback: Default rate → (company, role=Y, is_default=true).
  3. Gig::Shift.hourly_rate is set to the resolved rate. Employer can override before publishing.

Condition Dimensions

DimensionValuesNotes
day_typeweekday, weekend, public_holidayPublic holidays checked via Geo::PublicHoliday (country-specific calendar).
time-of-day band[starts_at, ends_at) time-of-day pair, both nullableBoth NULL means the rate applies to the whole day_type. Set means a specific band. ends_at < starts_at wraps midnight (e.g. 22:00–06:00 covers 22:00 to next-day 06:00).

A company that wants the classic "day rate vs night rate" pattern uses two bands per day_type. A company that wants a single rate per day_type leaves both NULL. A company with more granular needs (NTUC's overnight stock-take, late-lunch helpers) adds more bands as needed.

The bands are interpreted in the outlet's timezone. A company-wide PayRate (outlet = NULL) with starts_at: 18:00 means "18:00 local at whichever outlet is resolving the rate", not "18:00 at HQ converted to each outlet". HR's mental model is "our night rate applies during night, at each outlet's local time" — the schema preserves that.

UI Default Bands (settings tables seed the form)

When HR creates a PayRate in the rate-management UI, the form pre-fills starts_at/ends_at directly from the relevant settings row:

  • If the rate is outlet-specific (org_outlet_id set): read gig_outlet_settings for that outlet. The row always exists (created eagerly at outlet creation) and always carries explicit values.
  • If the rate is company-wide (org_outlet_id NULL): read gig_company_settings for the company. The row always exists (created at onboarding) with explicit values.

So an HR opening the rate form for the Tuas outlet (where HR previously edited the outlet settings to 22:00/06:00) gets a 22:00–06:00 night-band pre-fill. The same HR opening the form for Orchard (which still has the company-seeded 18:00/06:00) gets 18:00–06:00.

No fallback walk happens at read time. Each settings row is the source of truth for its level. See gig-company-setting.md and gig-outlet-setting.md for the per-level specs, and notes/2026-05-18-settings-cascade.md for the broader pattern (eager initialisation, independent rows, explicit values).

The HR can edit the pre-filled bands before saving. The settings rows only seed the form; PayRate is the authoritative rule at resolution time.

Example Rate Card

Company: McDonald's, Outlet: Tampines Mall, Role: Casual Crew. McDonald's company settings use the system default night band (18:00–06:00).

Company-wide defaults (outlet = NULL):

LabelHourly Rateday_typestarts_atends_atis_default
Default$12.00weekdayNULLNULLtrue
PH Day$24.00public_holiday06:0018:00false
PH Night$27.00public_holiday18:0006:00false

Outlet-specific overrides (outlet = Tampines):

LabelHourly Rateday_typestarts_atends_atis_default
Weekday Day$12.00weekday06:0018:00false
Weekday Night$13.50weekday18:0006:00false

How resolution works for Tampines shifts (all times in SGT, outlet's timezone):

  • Tuesday 9am → weekday, time 09:00 → outlet weekday-day band match: $12.00
  • Tuesday 9pm → weekday, time 21:00 → outlet weekday-night band match: $13.50
  • Saturday 9am → weekend, time 09:00 → no outlet weekend band, no company weekend band → fallback default: $12.00
  • Public Holiday 9am → public_holiday, time 09:00 → no outlet PH band → company ph-day band match: $24.00
  • Public Holiday 9pm → public_holiday, time 21:00 → no outlet PH band → company ph-night band match: $27.00

Tampines has its own weekday bands but inherits the company-wide public holiday bands and the default.

What the Employer Sees in the UI

The UI hides the band columns by default and presents a familiar grid:

Role: Casual Crew — Tampines Mall

Day Night Custom Band
Weekday: $12.00 $13.50 —
Weekend: (default) (default) —
Public Holiday: $24.00* $27.00* —

* inherited from company-wide defaults

"Day" and "Night" are labelled buttons that, under the hood, write the cascade-resolved band into starts_at/ends_at. "Custom Band" is an explicit escape hatch for rare cases (NTUC overnight stock-take, late-lunch helper). The HR fills in only the cells that differ from the default; "inherited" cells come from the company-wide rows.

State Machine

PayRate has no status lifecycle. A rate either exists (active override) or doesn't (inherited from company default). There is no deactivation — to remove an outlet override, delete it and the cell reverts to the inherited value. See the UI grid description above.

Use Cases

IDUse CaseTriggerActor
UC-1Create pay rates for a role (company-wide)New client onboarding or new role addedAdmin or Employer
UC-2Create outlet-specific pay rates for a roleOutlet pays differently from company defaultAdmin or Employer
UC-3Edit a pay rateRate changes (e.g., annual review, market adjustment)Admin or Employer
UC-4Reset an outlet rate to company defaultOutlet no longer needs a custom rate for a conditionAdmin or Employer
UC-5Resolve applicable rate for a shiftShift creation — system auto-resolves rateSystem
UC-6View pay rates for a companyEmployer reviewing their rate configurationEmployer
UC-7Admin views pay rates across companiesOps reviewing rate configurationAdmin

UC-1: Create pay rates for a role (company-wide)

FieldDetails
ActorIdentities::Admin (during onboarding) or Org::UserProfile (self-serve)
TriggerNew client onboarding or new role added to the company

Preconditions:

  • Org::Company exists
  • Org::JobRole exists for the company

System Behavior:

  1. Actor selects a company and role.
  2. Actor creates one or more PayRate entries. Each carries: label, hourly_rate, day_type, optional starts_at/ends_at band.
  3. The rate form pre-fills starts_at/ends_at from the relevant settings row: if the rate is outlet-specific, from gig_outlet_settings for that outlet; if the rate is company-wide, from gig_company_settings for the company. Both settings rows always exist (eager creation) and always have explicit values — there is no fallback walk. The actor can edit the band before saving. For an unbanded "applies to whole day_type" row, both fields stay NULL.
  4. Actor marks one entry as is_default: true (the fallback rate when no specific match exists).
  5. System creates the PayRate records with org_outlet_id: NULL (company-wide).

Business Rules:

  • At least one PayRate with is_default: true must exist for each (company, role) combination.
  • hourly_rate must be a positive decimal.
  • No two PayRate rows can share the same (company, outlet, role, day_type, starts_at, ends_at). NULL bands are not-distinct (nulls_not_distinct=true) — two rows cannot both have NULL bands for the same key.
  • No two PayRate rows can have overlapping time bands within the same (company, outlet, role, day_type). Enforced at the application layer: the create/update Manager checks for overlap before committing.
  • ends_at < starts_at is allowed and means the band wraps midnight (e.g. 22:00–06:00 covers 22:00 to next-day 06:00). Overlap checks must respect the wrap.
  • Only the day_types/bands that differ from the default need an explicit entry.

Postconditions:

  • Company-wide PayRate entries exist for the role
  • Any Gig::Job referencing this company + role will use these rates for shift creation

UC-2: Create outlet-specific pay rates for a role

FieldDetails
ActorIdentities::Admin or Org::UserProfile
TriggerOutlet pays differently from company default

Preconditions:

  • Org::Company and Org::Outlet exist
  • Org::JobRole exists for the company
  • Company-wide defaults exist (UC-1) — outlet-specific rates supplement, not replace

System Behavior:

  1. Actor selects a company, outlet, and role
  2. Actor creates PayRate entries with org_outlet_id set to the specific outlet
  3. These entries take precedence over company-wide defaults during rate resolution

Business Rules:

  • Outlet-specific rates override company-wide defaults for matching conditions
  • Not all conditions need outlet-specific rates — the system falls back to company-wide for unmatched conditions
  • Example: outlet may have its own day/night rates but use company-wide holiday rates

Postconditions:

  • Outlet-specific PayRate entries exist
  • Future Shifts at this outlet will use these rates instead of company-wide defaults

UC-3: Edit a pay rate

FieldDetails
ActorIdentities::Admin or Org::UserProfile
TriggerRate changes (annual review, market adjustment, etc.)

Preconditions:

  • PayRate exists and is_active: true

System Behavior:

  1. Actor modifies: hourly_rate, label
  2. System validates inputs

Business Rules:

  • Editable fields: hourly_rate, label, starts_at, ends_at.
  • Immutable fields: org_company_id, org_outlet_id, org_job_role_id, day_type — these define which slot the rate occupies. To change them, deactivate the old rate and create a new one.
  • Editing starts_at/ends_at triggers the no-overlap check against other PayRate rows for the same (company, outlet, role, day_type).
  • Changes only affect future Shifts — existing Shifts already have their rate locked.
  • The system may optionally notify the employer: "Your rates have been updated. Future shifts will use the new rates."

Postconditions:

  • PayRate reflects updated values
  • Future Shift rate resolution will use the new rate

UC-4: Reset an outlet rate to company default

FieldDetails
ActorIdentities::Admin or Org::UserProfile
TriggerOutlet no longer needs a custom rate for a specific condition

Preconditions:

  • An outlet-specific PayRate exists (org_outlet_id IS NOT NULL)

System Behavior:

  1. Employer clicks "reset to company default" on a cell in the outlet's rate grid
  2. System shows the inherited value that will apply: "This cell will revert to the company default of $11.41. Continue?"
  3. System soft-deletes the outlet-specific PayRate row
  4. System creates an Gig::PayRateHistory record capturing: old value, new inherited value, who reset it

Business Rules:

  • Only outlet-specific overrides can be reset — company-wide defaults cannot be deleted (they are the base)
  • The is_default base rate for a company + role combination can never be deleted
  • Existing Shifts are NOT affected — their rates are already locked
  • After reset, the grid cell shows the inherited company-wide value

Postconditions:

  • Outlet-specific PayRate row is soft-deleted
  • Future shifts at this outlet will resolve to the company-wide rate for this condition

UC-5: Resolve applicable rate for a shift

FieldDetails
ActorSystem
TriggerGig::Shift creation

Preconditions:

  • Gig::Job exists with org_outlet_id and org_job_role_id
  • At least one active Gig::PayRate exists for the company + role

System Behavior:

  1. System determines shift conditions from shift.starts_at:
    • day_type: public holiday (via Geo::PublicHoliday) → public_holiday, Saturday/Sunday → weekend, otherwise → weekday.
    • Time of day in outlet's timezone: convert shift.starts_at to the outlet's local time and extract the clock reading. e.g. 21:00.
  2. System walks the resolution chain:
    • a. Outlet-specific band match → (company, outlet=job.outlet, role, day_type) where the shift's time of day falls within [starts_at, ends_at) (respecting midnight wrap).
    • b. Company-wide band match → (company, outlet=NULL, role, day_type) band match.
    • c. Outlet day_type-wide row → (company, outlet=job.outlet, role, day_type) with both starts_at and ends_at NULL.
    • d. Company day_type-wide row → (company, outlet=NULL, role, day_type) with both NULL.
    • e. Default rate → the is_default = true row for (company, role).
  3. System sets Gig::Shift.hourly_rate to the resolved rate.
  4. Employer is shown the auto-resolved rate and can override before publishing the Shift.

Business Rules:

  • The fallback chain (a → e) is deterministic. The first match wins.
  • If no is_default rate exists, shift creation is blocked with an error: "No pay rate configured for this role at this outlet."
  • The resolved rate is stored on Gig::Shift.hourly_rate and frozen there — later edits to Gig::PayRate do not affect existing Shifts.
  • Boundary-crossing warning (UX, not invariant): if a shift's starts_at and ends_at resolve to different PayRate bands (e.g. shift 17:30–22:00 straddles an 18:00 day/night boundary), the create form shows: "This shift crosses the rate boundary at 18:00. The pay rate is set from the start time. Split into two shifts if you want each segment to use its own rate."
  • The time-of-day comparison uses the outlet's timezone (resolved via outlet.address_geo_area.timezone). NULL outlet (company-wide) PayRate bands are interpreted in the resolving outlet's zone, not the company HQ's zone.

Postconditions:

  • Gig::Shift.hourly_rate is set

UC-6: View pay rates for a company

FieldDetails
ActorOrg::UserProfile (employer)
TriggerEmployer reviewing their rate configuration

Preconditions:

  • Employer belongs to an Org::Company

System Behavior:

  1. Employer navigates to rate management
  2. System displays all PayRates grouped by role, then by outlet:
    • Company-wide defaults shown first
    • Outlet-specific overrides shown per outlet
  3. Each entry shows: label, hourly_rate, day_type, time band (starts_at/ends_at, or "whole day_type" if both NULL), is_active

Business Rules:

  • Filterable by role, outlet, status (active/inactive)
  • Inactive rates are visible but greyed out

Postconditions:

  • Read-only operation — no data changes

UC-7: Admin views pay rates across companies

FieldDetails
ActorIdentities::Admin
TriggerOps reviewing rate configuration

Preconditions:

  • None

System Behavior:

  1. Admin navigates to pay rates admin page
  2. System displays all PayRates across all companies
  3. Filterable by company, outlet, role, status

Business Rules:

  • Admin can edit rates on behalf of employers (during onboarding or support)

Postconditions:

  • Read-only unless admin edits (see UC-3)

Invariants

  1. A PayRate must reference exactly one Org::Company and one Org::JobRole.
  2. org_outlet_id is nullable — NULL means the rate is a company-wide default for the role.
  3. There must always be at least one PayRate with is_default: true for each (company, role) combination — the base rate can never be deleted.
  4. No two PayRate rows can share the same (company, outlet, role, day_type, starts_at, ends_at). Enforced by a standard composite unique index. Standard SQL treats NULLs as distinct, so the index alone does NOT prevent two "whole day_type" rows (both bands NULL) for the same key — the create/update Manager enforces this via a "no duplicate NULL-band per (company, outlet, role, day_type)" check.
  5. No overlapping time bands within the same (company, outlet, role, day_type). Enforced at the application layer by the create/update Manager. The check must respect midnight wrap (ends_at < starts_at).
  6. hourly_rate must be a positive decimal.
  7. day_type must be one of: weekday, weekend, public_holiday.
  8. starts_at and ends_at are t.time (time-of-day, no date) and both nullable. Both NULL means "this rate applies to the whole day_type". Both set means a specific band. One set and one NULL is rejected — bands are either complete or absent.
  9. ends_at < starts_at is a valid band that wraps midnight (e.g. 22:00–06:00).
  10. Immutable fields after creation: org_company_id, org_outlet_id, org_job_role_id, day_type. hourly_rate, label, starts_at, ends_at are editable (subject to the overlap check in invariant 5).
  11. Changes to PayRate only affect future Shifts — existing Shifts keep their resolved hourly_rate snapshot.
  12. Resolution order (UC-5): outlet-specific band → company-wide band → outlet-specific day_type-wide → company-wide day_type-wide → is_default.
  13. Time-of-day comparison during resolution is performed in the outlet's timezone (outlet.address_geo_area.timezone), not the company HQ's zone.
  14. Every create, update, and delete of a PayRate creates a Gig::PayRateHistory record for audit.

Model Interactions

Related ModelRelationshipInteraction
Org::CompanyPayRate belongs_to CompanyEvery rate is owned by one company.
Org::OutletPayRate optionally belongs_to OutletNULL = company-wide default. Set = outlet-specific rate that overrides the default.
Org::JobRolePayRate belongs_to JobRoleDefines which role this rate applies to.
Gig::ShiftShift resolves hourly_rate from PayRateAt shift creation, the system evaluates PayRates and stores the resolved rate on Gig::Shift.hourly_rate.
Gig::JobIndirect — via Job's outlet + roleThe Job's outlet and role determine which PayRates are evaluated during shift creation.
Identities::AdminAdmin configures ratesOps team sets up rates during client onboarding.
Gig::PayRateHistoryPayRate has_many History recordsEvery create, update, and delete creates an immutable history record capturing: changed fields (JSON with old/new values), who made the change, and when. Used for audit: "who changed the night rate and what was it before?"
Org::UserProfileEmployer manages ratesEmployers can view and modify their company's rates.

Migration from legacy data

The pipeline that pre-seeds Gig::PayRate rows from legacy jodgig data when a client migrates is documented at migration/migration-gig-pay-rate.md. Key decisions baked into the seed:

  • Title canonicalisation uses the existing Gig::TempJobs::JobTitleNormalizer (Rails sync layer); the seed reads canonical titles, not raw jod_jobs.job_title.
  • day_type is derived via calendar lookup against Geo::PublicHoliday. SG holidays for 2025–2026 are ready to seed in migration/sg-public-holidays-2025-2026.csv.
  • Bands are collapsed to a single starts_at: NULL, ends_at: NULL row per (outlet, role, day_type) when rates are uniform across bands (the common case — validated at 81% for NTUC FP Hyper). Bands are preserved only when rates genuinely vary by more than 20%.
  • The seed produces one company-wide default row per (company, role) honouring the is_default invariant.

Schema Gaps

GapImpactSuggested Resolution
gig_pay_rates table not yet in productionRate card concept is entirely missingOpen. Create table per gig.dbml: columns include org_company_id, org_outlet_id (nullable), org_job_role_id, label, hourly_rate, day_type, starts_at (t.time, nullable), ends_at (t.time, nullable), is_default.
gig_pay_rate_histories table not yet in productionNo audit trail for rate changesOpen. Create table: gig_pay_rate_id, diff (jsonb of old/new), action, changed_by_type, changed_by_id, reason, created_at.
Geo::PublicHoliday table not yet in productionCannot auto-detect public holidays for rate resolutionOpen. Defined in gig.dbml as geo_public_holidays; needs migration.
Outlet timezone resolution depends on org_outlets.geo_area_idUC-5 requires converting shift.starts_at to outlet local time, but org_outlets has no geo_area_id FK yetOpen. Tracked in roadmap issue #34 Lane A1. Blocker on UC-5's time-of-day matching.
Settings cascade tables (gig_company_settings adjustments, gig_outlet_settings)UC-1 references the cascade for UI pre-fill of bandsOpen. See gig-company-setting.md, gig-outlet-setting.md, and the cascade decision note for the target shape.
No uuid on gig_pay_ratesInconsistent with other modelsAdd uuid string [unique, not null]