PAYROLL ENGINE SPECIFICATION V3.0

How Tidex calculates your pay

A transparent, auditable reference for every payroll rule we apply. This specification enables re-implementation in any language (Swift, Kotlin, Go, etc.) with identical results.

Overview

What the payroll engine does

The Tidex payroll engine computes wage earnings for work shifts. It takes shift data (date, start/end time) and wage settings (hourly rate, supplements, tax, break deductions) to produce deterministic earnings values.

Inputs

  • Shift data: shift_date (ISO), start_time (HH:MM), end_time (HH:MM), optional custom supplements, job_id
  • Wage snapshot: Hourly wage, supplement rules, tax settings, break deduction settings — scoped to a job
  • Job: Name, color, payroll_day, half_tax_month, monthly_goal — primary source for payroll configuration
  • User settings: Global preferences; payroll_day/half_tax_month/monthly_goal kept as legacy fallback during compatibility window

Outputs

  • computeShift output: durationHours, paidHours, basePay, supplementPay, gross, wagePeriods, originalWagePeriods, breakAudit
  • Downstream totals output: taxAmount and net (calculated outside computeShift)

Key invariants

  • Deterministic: Same inputs always produce same outputs
  • Pure computation: Zero I/O, all inputs explicit
  • Cross-midnight support: Shifts spanning midnight are calculated as continuous time
  • Dual-date snapshot logic: Wage/supplements/breaks use shift date; tax uses payout date
  • Precision: 3 decimal places for hours, 2 decimal places for currency
  • Job-scoped snapshots: Each shift uses wage snapshots belonging to the same job; legacy (job-less) snapshots serve as fallback
  • Job-scoped payroll day: payroll_day is resolved from the shift's job first, then falls back to user settings

Definitions

Key terminology
TermDefinition
ShiftA stored work period with date, start time, end time
Virtual shiftA computed occurrence from a recurring shift template (not persisted)
Wage snapshotPoint-in-time capture of wage, supplement, tax, and break settings — scoped to a job
Baseline snapshotSnapshot with from_date = NULL, serves as fallback within its job bucket
Supplement windowTime-of-day range when a supplement rate applies
Payout dateDate when wages are paid (typically month after work + payroll day)
Payroll periodThe calendar month whose earnings are grouped for a payout
Month groupingShifts worked in month M are paid in month M+1
JobAn employer/workplace entity that groups shifts and wage snapshots; owns payroll_day, half_tax_month, monthly_goal
Default jobEach user has exactly one active default job; shifts without an explicit job_id are assigned here
Legacy snapshotA wage_snapshot with job_id = NULL; used as fallback when no job-specific snapshot exists

Entry points

The payroll engine can be called in two ways. The optional job parameter is reserved for future use — payroll day resolution is performed upstream in ShiftsService before calling computeShift.

// Pure function (core calculation)
computeShift(shift, settings, presetRules, snapshot, job?) // from @/lib/payroll/calc.ts

// Effect-wrapped (with validation)
computeShift(shift, settings, presetRules, snapshot, job?) // from @/lib/payroll/effect.ts

Data Model & Schema

user_shifts - Core shift data

Each shift is stored with the following structure:

When end_time <= start_time, shift crosses midnight (e.g., 22:00 to 06:00). Custom supplements when present completely replace snapshot supplements for this shift. Legacy clients that do not send job_id are automatically assigned the user's default job by a DB trigger.

user_shifts table schema
ColumnTypePurpose
iduuidPrimary key, unique shift identifier
user_iduuidForeign key to auth.users
job_iduuidForeign key to jobs (NOT NULL; assigned by DB trigger when legacy clients omit it)
shift_datedateThe date of the shift (ISO: YYYY-MM-DD)
start_timetextStart time in HH:MM format
end_timetextEnd time in HH:MM format (supports cross-midnight)
custom_supplementsjsonbShift-specific supplement overrides

recurring_shifts - Recurring shift templates

Recurring shifts generate virtual shifts at runtime:

Virtual shifts are generated at runtime, never persisted. selected_days keys are weekday numbers (0=Sunday to 6=Saturday). A recurring pattern and all its virtual shifts belong to one job — there is no per-occurrence job override.

recurring_shifts table schema
ColumnTypePurpose
iduuidPrimary key
user_iduuidForeign key to auth.users
job_iduuidForeign key to jobs (NOT NULL; all generated virtual shifts inherit this job)
start_timetimetzShift start time with timezone
end_timetimetzShift end time with timezone
repeat_interval_weekssmallint0 = every week, 1 = every 2 weeks, ..., 8 = every 9 weeks
selected_daysjsonbAnchor dates by weekday: { "1": "2025-01-27" }
end_conditionjsonbEnd rule: { type: "never" | "months" | "years" | "end_date" }
exclusionsjsonbArray of ISO dates to skip
date_specific_supplementsjsonbPer-date custom supplements

wage_snapshots - Point-in-time wage settings

Wage snapshots preserve historical wage accuracy and are now scoped per job:

Snapshots are job-scoped: one baseline (from_date = NULL) allowed per (user, job) pair. Snapshot selection uses shift date for wage/supplements/breaks, but payout date for tax settings — both lookups are job-scoped with fallback to legacy (job_id = NULL) snapshots.

wage_snapshots table schema
ColumnTypeDefaultPurpose
iduuid-Primary key
user_iduuid-Foreign key to auth.users
job_iduuid-Foreign key to jobs (NOT NULL; controls which job's wage history applies)
from_datedate-Effective date (NULL = baseline snapshot for this job)
hourly_wagenumeric-Base hourly wage in NOK
wage_levelinteger-Tariff level (1-9) or NULL for custom
supplementsjsonb[]Array of SupplementRule objects
tax_enabledbooleanfalseWhether tax deduction is enabled
tax_percentagenumeric0Tax percentage (0-100)
break_enabledbooleantrueWhether break deduction is enabled
break_methodtextproportionalOne of: proportional, base_only, end_of_shift, none
break_threshold_hoursnumeric5.5Hours before break applies
break_deduction_minutesinteger30Break duration in minutes

user_settings - Global user preferences

User preferences. Payroll-related fields (payroll_day, half_tax_month, monthly_goal) have been moved to the jobs table but are kept here as mirrors for legacy client compatibility:

DB triggers keep user_settings and the default job in sync bidirectionally. The jobs table is the authoritative source for payroll_day, half_tax_month, and monthly_goal in job-aware clients. Resolution order for payroll_day: job value → user_settings.payroll_day → 1.

user_settings relevant columns
ColumnTypeDefaultPurpose
user_iduuid-Primary key, FK to auth.users
payroll_dayinteger15 (legacy mirror)Kept in sync with default job's payroll_day for backwards compatibility
half_tax_monthinteger-Kept in sync with default job's half_tax_month for backwards compatibility
monthly_goalinteger20000 (legacy mirror)Kept in sync with default job's monthly_goal for backwards compatibility

SupplementRule structure

Used in wage_snapshots.supplements and user_shifts.custom_supplements:

type SupplementRule = {
  days: number[];    // 1-7 (1=Monday, 7=Sunday)
  from: string;      // "HH:MM" (inclusive)
  to: string;        // "HH:MM" (inclusive)
  rate?: number;     // Fixed NOK per hour (mutually exclusive with percent)
  percent?: number;  // Percentage of base rate (mutually exclusive with rate)
};

// Example rules:
[
  { "days": [1,2,3,4,5], "from": "18:00", "to": "21:00", "rate": 22 },
  { "days": [6], "from": "13:00", "to": "24:00", "rate": 110 },
  { "days": [7], "from": "00:00", "to": "24:00", "rate": 115 }
]

Preset supplement rules (tariff default)

The standard Norwegian tariff supplements:

Preset supplement rates
PeriodDaysTimeRate
Weekday eveningMon-Fri (1-5)18:00-21:00+22 NOK/h
Weekday late nightMon-Fri (1-5)21:00-24:00+45 NOK/h
Saturday afternoonSaturday (6)13:00-15:00+45 NOK/h
Saturday late afternoonSaturday (6)15:00-18:00+55 NOK/h
Saturday eveningSaturday (6)18:00-24:00+110 NOK/h
Sunday all daySunday (7)00:00-24:00+115 NOK/h

Preset wage rates (tariff levels)

Preset wage levels
LevelRate (NOK/hour)
-1129.91
-2132.90
1184.54
2185.38
3187.46
4193.05
5210.81
6256.14

Jobs & Multi-employer Support

What is a job?

A job represents an employer or workplace. Each user starts with one default job ("Jobb") and can add more. Jobs group shifts and wage snapshots together, and own per-employer payroll configuration.

The multi-job feature is invisible for single-job users — the default job is assigned automatically and all existing flows remain unchanged.

jobs table

Each job stores employer-specific payroll configuration:

Unique constraint: UNIQUE (user_id) WHERE is_default = true AND deleted_at IS NULL AND archived_at IS NULL — exactly one active default per user.

jobs table schema
ColumnTypePurpose
iduuidPrimary key
user_iduuidFK to auth.users
nametextDisplay name (1-100 chars)
colortextHex color (#RRGGBB) for visual differentiation in shift views
is_defaultbooleanExactly one active default per user; auto-assigned to shifts from legacy clients
sort_ordersmallintDisplay ordering
payroll_dayintegerDay of month (1-31) payroll is received for this job
half_tax_monthintegerMonth (11 or 12) for half-tax; NULL = disabled
monthly_goalintegerMonthly earnings goal for this job
archived_attimestamptzSet when archived; job is hidden from Add Shift pickers but remains in historical views
deleted_attimestamptzSoft-delete; shifts remain queryable for history

Backward compatibility

Older iOS and web clients that do not send job_id continue to work unchanged. Database triggers automatically:

  • Assign the user's default job to any shift, recurring shift, or wage snapshot written without job_id
  • Mirror payroll_day, half_tax_month, and monthly_goal between user_settings and the default job in both directions
  • Ensure every user has a default job, creating one on-the-fly if missing

Legacy user_settings fields (payroll_day, half_tax_month, monthly_goal) will not be removed until telemetry confirms all clients are job-aware.

Payroll day resolution

The payroll day is resolved per shift using this priority chain:

const payrollDayForJob = (targetJobId?: string | null): number =>
  jobsById.get(targetJobId ?? '')?.payroll_day   // 1. Job's own payroll_day
  ?? defaultJob?.payroll_day                      // 2. Default job's payroll_day
  ?? userSettings?.payroll_day                    // 3. Legacy user_settings fallback
  ?? 1;                                           // 4. Hard fallback

Job-scoped snapshot buckets

All wage snapshots are loaded once and grouped into buckets by job_id. Snapshot resolution tries the shift's own job bucket first, then falls back to the legacy bucket (snapshots with no job_id):

// Fallback chain for snapshot lookup
const preferredKeys = [shift.job_id ?? '__legacy__', '__legacy__'];

for (const key of preferredKeys) {
  const dated = buckets.get(key)?.dated.find(s => s.from_date <= targetDate);
  if (dated) return dated;
}
for (const key of preferredKeys) {
  const baseline = buckets.get(key)?.baseline;
  if (baseline) return baseline;
}

Shift Acquisition Pipeline

ShiftWithComputations - The canonical shift object

Every shift flows through the pipeline and emerges with computed values:

// ShiftRow now includes job_id
type ShiftRow = {
  id: string;
  user_id: string;
  job_id?: string | null;   // Set to default job if omitted by legacy clients
  shift_date: string;
  start_time: string;
  end_time: string;
  custom_supplements?: CustomSupplementsData | null;
};

type ShiftWithComputations = ShiftRow & {
  computed: ShiftComputed;
  tax_enabled?: boolean;
  tax_percentage?: number;
};

type ShiftComputed = {
  id: string;
  durationHours: number;      // Raw duration before break
  paidHours: number;          // Duration after break deduction
  basePay: number;            // NOK from base rate
  supplementPay: number;      // NOK from supplements
  gross: number;              // basePay + supplementPay
  wagePeriods: WagePeriod[];  // After break deduction
  originalWagePeriods: WagePeriod[];  // Before break deduction
  breakAudit: BreakAudit;     // Deduction details
};

type WagePeriod = {
  fromMin: number;      // Minutes from midnight (shift-relative)
  toMin: number;        // Minutes from midnight (exclusive)
  baseRate: number;     // NOK per hour
  supplementRate: number;  // NOK per hour supplement
  totalRate: number;    // baseRate + supplementRate
};

Pipeline steps

Step 1: Concurrent fetch (shifts, recurring, jobs, snapshots)

All four queries run in parallel for maximum performance:

const [shifts, recurringShifts, jobs, allSnapshots] = await Promise.all([
  supabase.from("user_shifts")
    .select("*").eq("user_id", userId).is("deleted_at", null)
    .gte("shift_date", startDate).lte("shift_date", endDate).limit(limit),

  supabase.from("recurring_shifts")
    .select("*").eq("user_id", userId).is("deleted_at", null),

  supabase.from("jobs")
    .select("*").eq("user_id", userId).is("deleted_at", null)
    .order("sort_order", { ascending: true }),

  supabase.from("wage_snapshots")
    .select("*").eq("user_id", userId).is("deleted_at", null)
    .order("from_date", { ascending: false, nullsFirst: false }),
]);

Step 2: Build snapshot buckets (keyed by job_id)

const buckets = new Map<string, { dated: WageSnapshot[]; baseline: WageSnapshot | null }>();

for (const snapshot of allSnapshots) {
  const key = snapshot.job_id ?? '__legacy__';
  const bucket = buckets.get(key) ?? { dated: [], baseline: null };
  if (snapshot.from_date === null) bucket.baseline = snapshot;
  else bucket.dated.push(snapshot);
  buckets.set(key, bucket);
}

for (const bucket of buckets.values()) {
  bucket.dated.sort((a, b) => b.from_date.localeCompare(a.from_date));
}

Step 3: Generate virtual shifts from recurring templates

For each recurring shift template and each month in range:

  • For each selected weekday anchor in selected_days
  • Find first occurrence of that weekday in target month
  • Check if date is on/after anchor date
  • Check if date is in phase with anchor (isInPhase)
  • Check if within end window (if end condition exists)
  • Check if not in exclusions list
  • If all pass, add to virtual shifts (inheriting recurring shift's job_id)
function generateVirtualShiftsForMonth(
  yearMonth: { year: number; month: number },
  draft: RecurringDraft
): RecurringVirtualShift[]

Step 4: Phase check formula

function isInPhase(dateISO, anchorISO, interval) {
  if (interval === 0) return true; // Every week

  const daysDiff = (date - anchor) / (24 * 60 * 60 * 1000);
  const weeksDiff = Math.floor(daysDiff / 7);

  // interval 1 = every 2 weeks, interval 2 = every 3 weeks
  return weeksDiff % (interval + 1) === 0;
}

Step 5: Compute each shift (job-scoped snapshot lookup)

for (const shift of shifts) {
  const shiftJobId = shift.job_id ?? defaultJobId;

  // Wage/supplement/break snapshot: use shift date, job-scoped
  const snapshot = resolveSnapshotForDate(buckets, snapshots, shift.shift_date, shiftJobId);

  // Tax snapshot: use payout date for THIS shift's job
  const payrollDay = jobsById.get(shiftJobId)?.payroll_day ?? defaultJob?.payroll_day ?? userSettings?.payroll_day ?? 1;
  const payoutDate = calculatePayoutDate(year, month, payrollDay);
  const taxSnapshot = resolveSnapshotForDate(buckets, snapshots, payoutDate, shiftJobId);

  const computed = computeShift(shift, userSettings, PRESET_RULES, snapshot, jobsById.get(shiftJobId) ?? null);

  result.push({
    ...shift, job_id: shiftJobId, computed,
    tax_enabled: taxSnapshot?.tax_enabled ?? false,
    tax_percentage: taxSnapshot?.tax_percentage ?? 0,
  });
}

Date-range inclusion rules

  • Boundaries are inclusive on both ends
  • Query: shift_date >= startDate AND shift_date <= endDate
  • For overnight shifts: The shift belongs to the start date

A shift from 22:00 to 06:00 on 2025-01-15 is queried by shift_date = 2025-01-15

Cross-midnight handling

// Detection
const isCrossMidnight = endTime <= startTime;

// Treatment in calculation
let start = toMin(startHHMM); // e.g., 22:00 = 1320
let end = toMin(endHHMM);     // e.g., 06:00 = 360
if (end <= start) {
  end += 24 * 60;             // 360 + 1440 = 1800
}
// Duration: 1800 - 1320 = 480 minutes = 8 hours

Supplement rules are matched by the shift's weekday. For cross-midnight shifts, matching windows are projected into an extended timeline (0-2880 minutes), but rules from the next calendar day are not applied unless they are also configured for the shift weekday.

Virtual shift identity

Virtual shifts have synthetic IDs for stable React keys and conflict detection:

const id = `virtual-${recurringId}-${shiftDate}`;
// Example: "virtual-abc123-2025-01-15"

Wage Snapshot Selection & Payout Date Logic

Important: Two different lookups per shift

Snapshot selection uses TWO different dates for each shift:

  • Wage, supplements, break settings: Selected based on shift date (the date the shift is worked)
  • Tax settings only: Selected based on payout date (shift month + 1, on payroll day)

This is critical for accurate tax calculations when tax rates change between working and getting paid.

Payout date calculation

function calculatePayoutDate(
  earningsYear: number,
  earningsMonth: number, // 1-12
  payrollDay: number
): string {
  // Payout month is earnings month + 1
  let payoutYear = earningsYear;
  let payoutMonth = earningsMonth + 1;

  if (payoutMonth > 12) {
    payoutMonth = 1;
    payoutYear += 1;
  }

  // Handle edge case: payroll_day exceeds days in payout month
  const daysInPayoutMonth = new Date(payoutYear, payoutMonth, 0).getDate();
  const effectivePayrollDay = Math.min(payrollDay, daysInPayoutMonth);

  return `${payoutYear}-${padZero(payoutMonth)}-${padZero(effectivePayrollDay)}`;
}

// Example:
// Shift on 2025-01-15, payroll day = 20
// Earnings month = January (1)
// Payout month = February (2)
// Payout date = 2025-02-20

Payout date adjustment for holidays

Valid payroll days are Tuesday through Friday, excluding public holidays:

function adjustPayrollDate(
  payrollDay: number,
  month: number,      // 0-11 (JavaScript Date format)
  year: number,
  locale: Locale = 'no'
): Date {
  let date = new Date(year, month, payrollDay);

  // Move backward until valid payroll day found (max 10 iterations)
  while (isInvalidPayrollDay(date, locale)) {
    date.setDate(date.getDate() - 1);
  }

  return date;
}

function isInvalidPayrollDay(date: Date, locale: Locale): boolean {
  return isWeekend(date) || isMonday(date) || isPublicHoliday(date, locale);
}

This adjustment is used for payroll date display/countdown UX. Snapshot selection for tax uses calculatePayoutDate() (unadjusted). Holiday detection includes fixed and Easter-based Norwegian public holidays.

Job-scoped snapshot selection algorithm

Snapshots are grouped into buckets by job_id. The selection algorithm tries the shift's own job bucket first, then falls back to the legacy bucket (job_id = NULL):

const resolveSnapshotForDate = (
  buckets: Map<string, SnapshotBucket>,
  snapshots: WageSnapshot[],
  date: string,
  jobId?: string | null
): WageSnapshot | null => {
  // Try job-specific bucket first, then legacy fallback
  const preferredKeys = [jobId ?? '__legacy__', '__legacy__'];

  // 1. Try dated snapshots (bucket is sorted DESC — first match = latest valid)
  for (const key of preferredKeys) {
    const dated = buckets.get(key)?.dated.find(s => s.from_date <= date);
    if (dated) return dated;
  }

  // 2. Try baselines
  for (const key of preferredKeys) {
    const baseline = buckets.get(key)?.baseline;
    if (baseline) return baseline;
  }

  return snapshots.find(s => s.from_date === null) ?? null;
};

Selection rules

  • Job-specific bucket: find latest dated snapshot where from_date <= targetDate
  • If none, use job-specific baseline snapshot (from_date = NULL)
  • If none, fall back to legacy bucket (same search on job_id = NULL snapshots)
  • If still none, return null (calculation uses defaults)
  • Inclusive from_date: A snapshot with from_date = 2025-02-01 applies to target dates >= 2025-02-01

Example: Snapshot resolution (with job scope)

For a shift on 2025-01-15, job payroll day = 20, jobId = "job-uuid":

  • Wage snapshot lookup: targetDate = 2025-01-15 (shift date), jobId = "job-uuid"
  • Tax snapshot lookup: targetDate = 2025-02-20 (payout date), jobId = "job-uuid"
// Snapshots:
const baseline = { from_date: null, hourly_wage: 180.00, tax_percentage: 25 };
const january = { from_date: "2025-01-01", hourly_wage: 185.00, tax_percentage: 30 };
const february = { from_date: "2025-02-01", hourly_wage: 190.00, tax_percentage: 35 };

// Resolution:
// Wage snapshot (by shift date 2025-01-15): january (from_date <= 2025-01-15)
// Tax snapshot (by payout date 2025-02-20): february (from_date <= 2025-02-20)
// Hourly wage used: 185.00 (from january snapshot)
// Tax percentage used: 35% (from february snapshot)

Pay Calculation Engine (Math Spec)

Precision constants

const HOUR_DECIMAL_PRECISION = 1000;  // 3 decimal places (0.001 hours)
const CURRENCY_PRECISION = 100;       // 2 decimal places (cents)

Time conversion

function toMin(hhmm: string): number {
  const [h, m] = hhmm.split(":").map(Number);
  return h * 60 + m;
}
// "09:00" -> 540
// "17:30" -> 1050
// "24:00" -> 1440

Duration calculation

let start = toMin(startTime);  // e.g., 540
let end = toMin(endTime);      // e.g., 1050

// Handle cross-midnight
if (end <= start) {
  end += 24 * 60; // Add 1440 minutes (24 hours)
}

const totalMinutes = end - start;
const durationHours = +(totalMinutes / 60).toFixed(2);

Weekday calculation

const WEEKDAYS = [7, 1, 2, 3, 4, 5, 6]; // JS getDay(): 0=Sun -> 7, then 1..6 Mon..Sat

const date = new Date(shiftDate + "T00:00:00Z");
const weekday = WEEKDAYS[date.getUTCDay()]; // 1-7 (Mon-Sun)

Supplement rules use 1-7 (Monday-Sunday), not JavaScript's 0-6.

Base rate resolution

function resolveBaseRate(shift: ShiftRow, snapshot: WageSnapshot | null): number {
  // Priority 1: New snapshot system
  if (snapshot?.hourly_wage && snapshot.hourly_wage > 0) {
    return snapshot.hourly_wage;
  }

  // Priority 2: Legacy per-shift snapshot (backward compatibility)
  if (shift.hourly_wage_snapshot && shift.hourly_wage_snapshot > 0) {
    return shift.hourly_wage_snapshot;
  }

  // Priority 3: Fallback to tariff level 1
  return PRESET_WAGE_RATES["1"]; // 184.54
}

Supplement rules resolution

function resolveSupplementRules(
  weekday: number,
  predefinedRules: SupplementRule[],
  customSupplements: CustomSupplementsData | null
): SupplementRule[] {
  // Custom supplements completely replace predefined rules
  if (customSupplements?.rules?.length > 0) {
    return customSupplements.rules.map(rule => ({
      ...rule,
      days: [weekday], // Apply to this shift's weekday only
    }));
  }

  return predefinedRules;
}

Wage periods construction

The algorithm builds time periods with their applicable rates:

function buildWagePeriods(
  startHHMM: string,
  endHHMM: string,
  weekday: number,
  baseRate: number,
  rules: SupplementRule[]
): WagePeriod[] {
  let start = toMin(startHHMM);
  let end = toMin(endHHMM);
  if (end <= start) end += 24 * 60;

  // Collect boundaries
  const points = new Set([start, end]);
  for (const rule of rules) {
    if (!rule.days.includes(weekday)) continue;
    // Add rule boundaries that fall within shift
    // ... (handles cross-midnight rules)
  }

  const sorted = Array.from(points).sort((a, b) => a - b);
  const periods: WagePeriod[] = [];

  for (let i = 0; i < sorted.length - 1; i++) {
    const a = sorted[i], b = sorted[i + 1];

    // Find highest supplement for this period
    let supplement = 0;
    for (const rule of rules) {
      // ... check if period falls within rule
      supplement = Math.max(supplement, resolveSupplementRate(rule, baseRate));
    }

    periods.push({
      fromMin: a,
      toMin: b,
      baseRate,
      supplementRate: supplement,
      totalRate: baseRate + supplement,
    });
  }

  return periods;
}

Supplement rate resolution

function resolveSupplementRate(rule: SupplementRule, baseRate: number): number {
  // Fixed rate (NOK per hour)
  if (rule.rate != null && !isNaN(rule.rate)) {
    return rule.rate;
  }

  // Percentage of base rate
  if (rule.percent != null && !isNaN(rule.percent)) {
    return (baseRate * rule.percent) / 100;
  }

  return 0;
}

Stacking behavior: Highest-wins. Only the highest supplement rate applies to each time period.

Pay calculation

let basePay = 0, supplementPay = 0;

for (const period of periods) {
  // Round hours to 3 decimals
  const hours = Math.round((period.toMin - period.fromMin) / 60 * 1000) / 1000;

  // Round each period's contribution to cents
  basePay += Math.round(hours * period.baseRate * 100) / 100;
  supplementPay += Math.round(hours * period.supplementRate * 100) / 100;
}

basePay = +basePay.toFixed(2);
supplementPay = +supplementPay.toFixed(2);
const gross = +(basePay + supplementPay).toFixed(2);

Tax calculation

Tax is applied client-side or in aggregations, not in computeShift:

const taxEnabled = snapshot.tax_enabled;
const taxPercentage = snapshot.tax_percentage;

// Half-tax adjustment (based on payout month)
const payoutMonth = shiftMonth === 12 ? 1 : shiftMonth + 1;
const effectiveTaxPct = (halfTaxMonth === payoutMonth)
  ? taxPercentage / 2
  : taxPercentage;

const taxAmount = taxEnabled ? gross * (effectiveTaxPct / 100) : 0;
const net = gross - taxAmount;

Complete computation flow (pseudocode)

FUNCTION computeShift(shift, settings, presetRules, snapshot):
  // 1. Parse times
  start_minutes = toMinutes(shift.start_time)
  end_minutes = toMinutes(shift.end_time)
  IF end_minutes <= start_minutes THEN
    end_minutes += 1440  // Cross-midnight

  // 2. Get weekday (1-7)
  date = parseDate(shift.shift_date)
  weekday = WEEKDAYS[date.dayOfWeek]

  // 3. Resolve base rate
  baseRate = snapshot?.hourly_wage OR shift.hourly_wage_snapshot OR 184.54

  // 4. Resolve supplement rules
  rules = shift.custom_supplements OR snapshot.supplements OR presetRules

  // 5. Build wage periods
  periods = buildWagePeriods(start, end, weekday, baseRate, rules)

  // 6. Calculate raw duration
  totalMinutes = SUM(period.toMin - period.fromMin FOR period IN periods)
  durationHours = ROUND(totalMinutes / 60, 2)

  // 7. Apply break deduction
  breakEnabled = snapshot.break_enabled OR true
  breakMethod = snapshot.break_method OR "proportional"
  threshold = snapshot.break_threshold_hours OR 5.5
  breakMinutes = breakEnabled ? (snapshot.break_deduction_minutes OR 30) : 0

  IF durationHours > threshold AND breakEnabled THEN
    periods = applyBreakDeduction(periods, breakMethod, threshold, breakMinutes/60)

  // 8. Calculate paid hours
  paidMinutes = SUM(period.toMin - period.fromMin FOR period IN periods)
  paidHours = ROUND(paidMinutes / 60, 2)

  // 9. Calculate pay
  basePay = 0
  supplementPay = 0
  FOR period IN periods:
    hours = ROUND((period.toMin - period.fromMin) / 60, 3)
    basePay += ROUND(hours * period.baseRate, 2)
    supplementPay += ROUND(hours * period.supplementRate, 2)

  gross = ROUND(basePay + supplementPay, 2)

  RETURN {
    id: shift.id,
    durationHours,
    paidHours,
    basePay,
    supplementPay,
    gross,
    wagePeriods: periods,
    originalWagePeriods: originalPeriods,
    breakAudit: { method, threshold, deducted }
  }

Break Deductions

Automatic break deductions

Tidex deducts unpaid breaks automatically based on your configuration. Four deduction strategies are available.

Default settings

  • Enabled: Yes (break_enabled = true)
  • Method: Proportional
  • Threshold: 5.5 hours (break_threshold_hours)
  • Break duration: 30 minutes (break_deduction_minutes)

Threshold behavior

Breaks are only deducted when the shift exceeds the configured threshold:

// Threshold comparison: strict greater than (>)
let toDeduct = totalHours > thresholdHours ? deductionHours : 0;

// Edge case: Shift exactly at threshold (e.g., 5.5h with 5.5h threshold)
// NO break applied (uses >, not >=)

A 5.5-hour shift with a 5.5-hour threshold will NOT have a break deducted.

Method 1: Proportional (default)

Distributes the deduction proportionally across all wage periods:

if (method === "proportional") {
  // Deduct exact proportional fractions (not rounded to minutes)
  for (let i = 0; i < adjusted.length; i++) {
    const span = adjusted[i].toMin - adjusted[i].fromMin;
    const proportion = span / totalMinutes;
    const cutMinutes = proportion * toDeduct * 60;
    adjusted[i].toMin -= cutMinutes;
  }
}

Keeps ratios between base pay and supplements intact.

Method 2: End of shift

Removes the break from the end of the shift:

if (method === "end_of_shift") {
  // Subtract from the tail
  for (let i = adjusted.length - 1; i >= 0 && remaining > 0; i--) {
    const span = adjusted[i].toMin - adjusted[i].fromMin;
    const cut = Math.min(span, remaining);
    adjusted[i].toMin -= cut;
    remaining -= cut;
  }
}

Method 3: Base only

Deducts from periods with the lowest supplement first:

if (method === "base_only") {
  // Deduct from periods with lowest supplement first
  const order = adjusted
    .map((p, idx) => ({ idx, supplement: p.supplementRate }))
    .sort((a, b) => a.supplement - b.supplement)
    .map(o => o.idx);

  for (const i of order) {
    if (remaining <= 0) break;
    const span = adjusted[i].toMin - adjusted[i].fromMin;
    const cut = Math.min(span, remaining);
    adjusted[i].toMin -= cut;
    remaining -= cut;
  }
}

Useful for preserving premium hours (evening/weekend supplements).

Method 4: None

No automatic break deduction is applied. All hours are paid.

Break method summary

Break deduction methods
MethodBehavior
proportionalDeducts break time proportionally across all periods based on their duration
base_onlyDeducts from periods with lowest supplement rate first
end_of_shiftDeducts from the last period(s) of the shift
noneNo break deduction

Break audit

Each computation includes a break audit for transparency:

type BreakAudit = {
  method: BreakMethod;
  thresholdHours: number;
  deductedHours: number;
  notes?: string[];
};

Aggregations & Higher-Level Metrics

Monthly totals (TotalCard)

// Exclude higher-earning overlapping shifts first
const excludedIds = buildExcludedShiftIds(shifts);
const included = shifts.filter(s => !excludedIds.has(s.id));

const aggregates = included.reduce((acc, shift) => ({
  totalHours: acc.totalHours + shift.computed.paidHours,
  totalEarnings: acc.totalEarnings + shift.computed.gross,
}), { totalHours: 0, totalEarnings: 0 });

Next payroll (NextPayrollCard)

Which shifts are included: Earnings month is the month before the payroll month currently in view.

const payoutDate = calculatePayoutDate(earningsYear, earningsMonth, payrollDay);
const payoutTax = getTaxSettingsForPayoutDate(wageSnapshots, payoutDate);

const grossAmount = summarizeShiftTotals({ shifts: earningsMonthShifts }).gross;
const taxAmount = payoutTax?.enabled
  ? grossAmount * (payoutTax.percentage / 100)
  : 0;
const netAmount = grossAmount - taxAmount;

Projected total

Total earnings including future planned shifts:

const totals = summarizeShiftTotals({
  shifts: monthShifts,
  now: new Date(),
  month: payoutMonth,
  halfTaxMonth,
  payoutTaxOverride,
});

const earnedToDate = taxEnabled ? totals.completedNet : totals.completedGross;
const projectedTotal = taxEnabled ? totals.net : totals.gross;
const hasFutureShifts = projectedTotal !== earnedToDate;

Conflict exclusion (overlapping shifts)

When multiple shifts overlap on the same date, only one contributes to earnings totals. The shift with the lowest gross earnings is kept; all higher-earning overlapping shifts are excluded.

function buildExcludedShiftIds(shifts: ShiftWithComputations[]): Set<string> {
  const result = new Set<string>();
  const shiftsByDate = groupByDate(shifts);

  for (const [date, shiftsOnDate] of shiftsByDate) {
    if (shiftsOnDate.length < 2) continue;

    // Find overlapping clusters using union-find
    const clusters = findOverlappingClusters(shiftsOnDate);

    for (const cluster of clusters) {
      if (cluster.length < 2) continue;

      // Sort by gross ascending, keep only the lowest
      cluster.sort((a, b) => a.computed.gross - b.computed.gross);
      for (let i = 1; i < cluster.length; i++) {
        result.add(cluster[i].id);
      }
    }
  }

  return result;
}

Excluded shifts are displayed with strikethrough styling but remain visible.

Overlap detection

function shiftsOverlap(a, b): boolean {
  let startA = toMinutes(a.start_time);
  let endA = toMinutes(a.end_time);
  let startB = toMinutes(b.start_time);
  let endB = toMinutes(b.end_time);

  // Handle cross-midnight
  if (endA <= startA) endA += 24 * 60;
  if (endB <= startB) endB += 24 * 60;

  return startA < endB && startB < endA;
}

Stats aggregates

Stats calculations
AggregateFormulaPeriod
Total hoursSUM(paidHours)Selected range
Total earningsSUM(gross)Selected range
Average per shifttotalEarnings / shiftCountSelected range
Average hourlytotalEarnings / totalHoursSelected range
Month-over-month %(current - previous) / previous * 100Comparison

Test Vectors & Examples

Test case 1: Basic weekday shift (no supplements)

A simple morning shift with no supplement windows:

// Input
const shift = {
  shift_date: "2025-01-15", // Wednesday
  start_time: "09:00",
  end_time: "14:00",
};

const snapshot = {
  hourly_wage: 185.00,
  supplements: { rules: [] }, // No supplements
  break_enabled: false,
};

// Expected output
{
  durationHours: 5.00,
  paidHours: 5.00,
  basePay: 925.00,      // 5h x 185
  supplementPay: 0,
  gross: 925.00,
}

Test case 2: Weekday evening shift (with supplement)

An evening shift that spans multiple supplement windows:

// Input
const shift = {
  shift_date: "2025-01-15", // Wednesday
  start_time: "17:00",
  end_time: "22:00",
};

const snapshot = {
  hourly_wage: 185.00,
  supplements: { rules: [
    { days: [1,2,3,4,5], from: "18:00", to: "21:00", rate: 22 },
    { days: [1,2,3,4,5], from: "21:00", to: "24:00", rate: 45 },
  ]},
  break_enabled: false,
};

// Expected output
{
  durationHours: 5.00,
  paidHours: 5.00,
  basePay: 925.00,       // 5h x 185
  supplementPay: 111.00, // 1h x 0 + 3h x 22 + 1h x 45 = 0 + 66 + 45
  gross: 1036.00,
}

Test case 3: Cross-midnight shift

A night shift that crosses midnight:

// Input
const shift = {
  shift_date: "2025-01-15", // Wednesday
  start_time: "22:00",
  end_time: "06:00",
};

const snapshot = {
  hourly_wage: 185.00,
  supplements: { rules: [
    { days: [1,2,3,4,5], from: "21:00", to: "24:00", rate: 45 },
  ]},
  break_enabled: true,
  break_threshold_hours: 5.5,
  break_deduction_minutes: 30,
};

// Expected output
{
  durationHours: 8.00,        // 22:00 to 06:00 = 8 hours
  paidHours: 7.50,            // 8h - 0.5h break
  basePay: 1387.51,           // 346.88 (1.875h x 185) + 1040.63 (5.625h x 185)
  supplementPay: 84.38,       // 1.875h x 45 (proportional: 2h loses 2/8 x 0.5h)
  gross: 1471.89,
}

Test case 4: Sunday full day (high supplement)

// Input
const shift = {
  shift_date: "2025-01-19", // Sunday
  start_time: "08:00",
  end_time: "16:00",
};

const snapshot = {
  hourly_wage: 185.00,
  supplements: { rules: [
    { days: [7], from: "00:00", to: "24:00", rate: 115 },
  ]},
  break_enabled: true,
  break_threshold_hours: 5.5,
  break_deduction_minutes: 30,
};

// Expected output
{
  durationHours: 8.00,
  paidHours: 7.50,
  basePay: 1387.50,      // 7.5h x 185
  supplementPay: 862.50, // 7.5h x 115
  gross: 2250.00,
}

Test case 5: Break threshold edge case

A shift exactly at the threshold - NO break applied:

// Input
const shift = {
  shift_date: "2025-01-15",
  start_time: "09:00",
  end_time: "14:30", // Exactly 5.5 hours
};

const snapshot = {
  hourly_wage: 185.00,
  break_enabled: true,
  break_threshold_hours: 5.5,
  break_deduction_minutes: 30,
};

// Expected output
{
  durationHours: 5.50,
  paidHours: 5.50,       // NO break (threshold uses >)
  basePay: 1017.50,
  supplementPay: 0,
  gross: 1017.50,
}

The threshold comparison uses strict greater than (>), not >=.

Test case 6: Percentage-based supplement

// Input
const shift = {
  shift_date: "2025-01-15",
  start_time: "18:00",
  end_time: "22:00",
};

const snapshot = {
  hourly_wage: 200.00,
  supplements: { rules: [
    { days: [3], from: "18:00", to: "24:00", percent: 50 }, // 50% of base
  ]},
  break_enabled: false,
};

// Expected output
{
  durationHours: 4.00,
  paidHours: 4.00,
  basePay: 800.00,       // 4h x 200
  supplementPay: 400.00, // 4h x (200 x 0.50)
  gross: 1200.00,
}

Test case 7: Saturday to Sunday cross-midnight

A shift that spans from Saturday evening to Sunday morning:

// Input
const shift = {
  shift_date: "2025-01-18", // Saturday
  start_time: "20:00",
  end_time: "02:00",
};

const snapshot = {
  hourly_wage: 185.00,
  supplements: { rules: [
    { days: [6], from: "18:00", to: "24:00", rate: 110 }, // Saturday evening
    { days: [7], from: "00:00", to: "24:00", rate: 115 }, // Sunday all day
  ]},
  break_enabled: false,
};

// Expected output
{
  durationHours: 6.00,
  paidHours: 6.00,
  // Current engine matches supplements by shift weekday only (Saturday = day 6)
  // 20:00-00:00 (4h) at Saturday rate 110
  // 00:00-02:00 (2h) has no Sunday supplement in this model
  basePay: 1110.00,       // 6h x 185
  supplementPay: 440.00,  // 4h x 110
  gross: 1550.00,
}

Test case 8: Tax with half-tax month

// Input
const shift = {
  shift_date: "2025-11-15", // November
};

const snapshot = {
  tax_enabled: true,
  tax_percentage: 30,
};

const settings = {
  half_tax_month: 12, // December (payout month for November shifts)
};

// Tax calculation
// Payout month = December
// Half-tax applies because halfTaxMonth === payoutMonth
const effectiveTaxPct = 30 / 2; // = 15%
const taxAmount = gross * 0.15;

Test case 9: Overlapping shifts (conflict exclusion)

// Input
const shifts = [
  {
    id: "shift-a",
    shift_date: "2025-01-15",
    start_time: "09:00",
    end_time: "17:00", // Gross: 1480 NOK
  },
  {
    id: "shift-b",
    shift_date: "2025-01-15",
    start_time: "14:00",
    end_time: "22:00", // Gross: 1850 NOK (higher due to evening supplement)
  },
];

// Expected behavior
// shift-a and shift-b overlap (14:00-17:00)
// shift-a has lower gross (1480) -> included in totals
// shift-b has higher gross (1850) -> excluded from totals, shown with strikethrough

const excludedIds = buildExcludedShiftIds(shifts);
// excludedIds.has("shift-b") === true
// excludedIds.has("shift-a") === false

// Monthly total = 1480 (only shift-a counted)

Notice an inconsistency?

Flag potential discrepancies directly to the payroll engineering team.