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::PayRatecan 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
| Context | Details |
|---|---|
| Entity | Gig::PayRate (independent) |
| Layer | Compensation Configuration |
| Upstream dependencies | Org::Company (required), Org::Outlet (optional — NULL means company-wide default), Org::JobRole (which role this rate applies to) |
| Downstream dependents | Gig::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:
-
System determines the shift's conditions from
starts_at:day_type: Is the date a public holiday (viaGeo::PublicHoliday)? →public_holiday. Is it Saturday/Sunday? →weekend. Otherwise →weekday.- Time-of-day in outlet's timezone: convert
shift.starts_atto the outlet's local time and extract the time of day. e.g. 9pm SGT.
-
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)withstarts_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).
- First: Outlet-specific band match →
-
Gig::Shift.hourly_rateis set to the resolved rate. Employer can override before publishing.
Condition Dimensions
| Dimension | Values | Notes |
|---|---|---|
| day_type | weekday, weekend, public_holiday | Public holidays checked via Geo::PublicHoliday (country-specific calendar). |
| time-of-day band | [starts_at, ends_at) time-of-day pair, both nullable | Both 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_idset): readgig_outlet_settingsfor that outlet. The row always exists (created eagerly at outlet creation) and always carries explicit values. - If the rate is company-wide (
org_outlet_idNULL): readgig_company_settingsfor 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):
| Label | Hourly Rate | day_type | starts_at | ends_at | is_default |
|---|---|---|---|---|---|
| Default | $12.00 | weekday | NULL | NULL | true |
| PH Day | $24.00 | public_holiday | 06:00 | 18:00 | false |
| PH Night | $27.00 | public_holiday | 18:00 | 06:00 | false |
Outlet-specific overrides (outlet = Tampines):
| Label | Hourly Rate | day_type | starts_at | ends_at | is_default |
|---|---|---|---|---|---|
| Weekday Day | $12.00 | weekday | 06:00 | 18:00 | false |
| Weekday Night | $13.50 | weekday | 18:00 | 06:00 | false |
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
| ID | Use Case | Trigger | Actor |
|---|---|---|---|
| UC-1 | Create pay rates for a role (company-wide) | New client onboarding or new role added | Admin or Employer |
| UC-2 | Create outlet-specific pay rates for a role | Outlet pays differently from company default | Admin or Employer |
| UC-3 | Edit a pay rate | Rate changes (e.g., annual review, market adjustment) | Admin or Employer |
| UC-4 | Reset an outlet rate to company default | Outlet no longer needs a custom rate for a condition | Admin or Employer |
| UC-5 | Resolve applicable rate for a shift | Shift creation — system auto-resolves rate | System |
| UC-6 | View pay rates for a company | Employer reviewing their rate configuration | Employer |
| UC-7 | Admin views pay rates across companies | Ops reviewing rate configuration | Admin |
UC-1: Create pay rates for a role (company-wide)
| Field | Details |
|---|---|
| Actor | Identities::Admin (during onboarding) or Org::UserProfile (self-serve) |
| Trigger | New client onboarding or new role added to the company |
Preconditions:
Org::CompanyexistsOrg::JobRoleexists for the company
System Behavior:
- Actor selects a company and role.
- Actor creates one or more PayRate entries. Each carries:
label,hourly_rate,day_type, optionalstarts_at/ends_atband. - The rate form pre-fills
starts_at/ends_atfrom the relevant settings row: if the rate is outlet-specific, fromgig_outlet_settingsfor that outlet; if the rate is company-wide, fromgig_company_settingsfor 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. - Actor marks one entry as
is_default: true(the fallback rate when no specific match exists). - System creates the PayRate records with
org_outlet_id: NULL(company-wide).
Business Rules:
- At least one PayRate with
is_default: truemust exist for each(company, role)combination. hourly_ratemust 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_atis allowed and means the band wraps midnight (e.g.22:00–06:00covers 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::Jobreferencing this company + role will use these rates for shift creation
UC-2: Create outlet-specific pay rates for a role
| Field | Details |
|---|---|
| Actor | Identities::Admin or Org::UserProfile |
| Trigger | Outlet pays differently from company default |
Preconditions:
Org::CompanyandOrg::OutletexistOrg::JobRoleexists for the company- Company-wide defaults exist (UC-1) — outlet-specific rates supplement, not replace
System Behavior:
- Actor selects a company, outlet, and role
- Actor creates PayRate entries with
org_outlet_idset to the specific outlet - 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
| Field | Details |
|---|---|
| Actor | Identities::Admin or Org::UserProfile |
| Trigger | Rate changes (annual review, market adjustment, etc.) |
Preconditions:
- PayRate exists and
is_active: true
System Behavior:
- Actor modifies:
hourly_rate,label - 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_attriggers 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
| Field | Details |
|---|---|
| Actor | Identities::Admin or Org::UserProfile |
| Trigger | Outlet no longer needs a custom rate for a specific condition |
Preconditions:
- An outlet-specific PayRate exists (
org_outlet_id IS NOT NULL)
System Behavior:
- Employer clicks "reset to company default" on a cell in the outlet's rate grid
- System shows the inherited value that will apply: "This cell will revert to the company default of $11.41. Continue?"
- System soft-deletes the outlet-specific PayRate row
- System creates an
Gig::PayRateHistoryrecord 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_defaultbase 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
| Field | Details |
|---|---|
| Actor | System |
| Trigger | Gig::Shift creation |
Preconditions:
Gig::Jobexists withorg_outlet_idandorg_job_role_id- At least one active
Gig::PayRateexists for the company + role
System Behavior:
- System determines shift conditions from
shift.starts_at:day_type: public holiday (viaGeo::PublicHoliday) →public_holiday, Saturday/Sunday →weekend, otherwise →weekday.- Time of day in outlet's timezone: convert
shift.starts_atto the outlet's local time and extract the clock reading. e.g.21:00.
- 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 bothstarts_atandends_atNULL. - d. Company day_type-wide row →
(company, outlet=NULL, role, day_type)with both NULL. - e. Default rate → the
is_default = truerow for(company, role).
- a. Outlet-specific band match →
- System sets
Gig::Shift.hourly_rateto the resolved rate. - 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_defaultrate 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_rateand frozen there — later edits toGig::PayRatedo not affect existing Shifts. - Boundary-crossing warning (UX, not invariant): if a shift's
starts_atandends_atresolve 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_rateis set
UC-6: View pay rates for a company
| Field | Details |
|---|---|
| Actor | Org::UserProfile (employer) |
| Trigger | Employer reviewing their rate configuration |
Preconditions:
- Employer belongs to an
Org::Company
System Behavior:
- Employer navigates to rate management
- System displays all PayRates grouped by role, then by outlet:
- Company-wide defaults shown first
- Outlet-specific overrides shown per outlet
- 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
| Field | Details |
|---|---|
| Actor | Identities::Admin |
| Trigger | Ops reviewing rate configuration |
Preconditions:
- None
System Behavior:
- Admin navigates to pay rates admin page
- System displays all PayRates across all companies
- 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
- A PayRate must reference exactly one
Org::Companyand oneOrg::JobRole. org_outlet_idis nullable — NULL means the rate is a company-wide default for the role.- There must always be at least one PayRate with
is_default: truefor each(company, role)combination — the base rate can never be deleted. - 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. - 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). hourly_ratemust be a positive decimal.day_typemust be one of:weekday,weekend,public_holiday.starts_atandends_ataret.time(time-of-day, no date) and both nullable. Both NULL means "this rate applies to the wholeday_type". Both set means a specific band. One set and one NULL is rejected — bands are either complete or absent.ends_at < starts_atis a valid band that wraps midnight (e.g.22:00–06:00).- Immutable fields after creation:
org_company_id,org_outlet_id,org_job_role_id,day_type.hourly_rate,label,starts_at,ends_atare editable (subject to the overlap check in invariant 5). - Changes to PayRate only affect future Shifts — existing Shifts keep their resolved
hourly_ratesnapshot. - Resolution order (UC-5): outlet-specific band → company-wide band → outlet-specific day_type-wide → company-wide day_type-wide →
is_default. - Time-of-day comparison during resolution is performed in the outlet's timezone (
outlet.address_geo_area.timezone), not the company HQ's zone. - Every create, update, and delete of a PayRate creates a
Gig::PayRateHistoryrecord for audit.
Model Interactions
| Related Model | Relationship | Interaction |
|---|---|---|
Org::Company | PayRate belongs_to Company | Every rate is owned by one company. |
Org::Outlet | PayRate optionally belongs_to Outlet | NULL = company-wide default. Set = outlet-specific rate that overrides the default. |
Org::JobRole | PayRate belongs_to JobRole | Defines which role this rate applies to. |
Gig::Shift | Shift resolves hourly_rate from PayRate | At shift creation, the system evaluates PayRates and stores the resolved rate on Gig::Shift.hourly_rate. |
Gig::Job | Indirect — via Job's outlet + role | The Job's outlet and role determine which PayRates are evaluated during shift creation. |
Identities::Admin | Admin configures rates | Ops team sets up rates during client onboarding. |
Gig::PayRateHistory | PayRate has_many History records | Every 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::UserProfile | Employer manages rates | Employers 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 rawjod_jobs.job_title. day_typeis derived via calendar lookup againstGeo::PublicHoliday. SG holidays for 2025–2026 are ready to seed inmigration/sg-public-holidays-2025-2026.csv.- Bands are collapsed to a single
starts_at: NULL, ends_at: NULLrow 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 theis_defaultinvariant.
Schema Gaps
| Gap | Impact | Suggested Resolution |
|---|---|---|
gig_pay_rates table not yet in production | Rate card concept is entirely missing | Open. 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 production | No audit trail for rate changes | Open. 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 production | Cannot auto-detect public holidays for rate resolution | Open. Defined in gig.dbml as geo_public_holidays; needs migration. |
Outlet timezone resolution depends on org_outlets.geo_area_id | UC-5 requires converting shift.starts_at to outlet local time, but org_outlets has no geo_area_id FK yet | Open. 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 bands | Open. See gig-company-setting.md, gig-outlet-setting.md, and the cascade decision note for the target shape. |
No uuid on gig_pay_rates | Inconsistent with other models | Add uuid string [unique, not null] |