# Timesheets — time-only design

Status: **phase 1 built (2026-10-29)**: tables, `TimesheetService`, ShiftService hooks, backfill,
the Timesheets page with CSV export. Payroll lines and pay runs are unchanged and now sit in the
Payroll module.
Owner module: **Rostering (core)**.

## Why

Industry practice (Fair Work record keeping, Xero / Employment Hero / Deputy imports, labour-hire
tools like foundU): a timesheet is a record of **time worked**, with the non-money facts needed to
price it. Rates, loadings and amounts are applied downstream: the Payroll module, Award
Interpretation, an external payroll system, or Agency billing (charge rates). See CLAUDE.md "No
money on shifts or timesheets".

Before this, there was no timesheet. The page called Timesheets read `payroll_lines`, which are
money rows (rate, loading, gross, `rate_card_id`) with no start, end or break of their own. That
page is now **Payroll Lines** under the Payroll section.

## Business rule: pay on completion, bill on approval

This is deliberate and must be kept:

- Facilities are often late to approve. HCPs are paid anyway, because they did the work.
- A timesheet is issued **as soon as the shift ends**, and is payable without approval.
- **Billing** (Agency) waits for approval, because approval is the facility agreeing to be billed.
- If the shift is approved after an export, the approval still updates the timesheet (and flags
  it for re-export if anything changed).

So approval and export are **independent**. The export defaults to *all completed* timesheets,
with an Approval column per row.

## Tables (tenant)

### `timesheets`: one per worked shift (`shift_id` unique)

| Group | Columns |
|---|---|
| Who / where | `hcp_id`, `facility_id`, `client_id`, `facility_group_id`, `state_id`, `time_zone` |
| Classification (snapshot) | `profession_id`, `level_id`, `award_code` (profession), `award_classification_key` + `award_pay_point` (level) |
| Time | `work_date` (date of clock-in), `clock_in`, `clock_out`, `break_minutes`, `worked_minutes`, `is_sleepover`, `sleepover_start`, `sleepover_end` |
| Origin | `source`: `clock` (shift end), `approval` (first seen at approval), `backfill` |
| Approval | `approval_status` (`pending` → `approved`), `approved_by`, `approved_at` |
| Void | `voided_at`, `void_reason` |
| Export | `exported_at`, `last_export_id`, `needs_reexport` |

Times are **shift-local wall-clock** in the state's timezone, like every shift time; the timezone
is stored so exports never re-derive it. `clock_in` / `clock_out` come from
`shifts.hcp_shift_start_time` / `hcp_shift_end_time`, which approval overwrites with the
approved times (the original clock times survive in the amendment trail).

`worked_minutes` = work segments − break + disturbances. **Sleepover time is not worked time**:
it's its own segment kind, paid (if at all) as an allowance downstream.

**No money columns, ever.** `pay_status` from the earlier draft was dropped: without the Payroll
module there's no pay run to set it. `exported_at` / `needs_reexport` replace it.

### `timesheet_segments`: the time split into award-neutral pieces

`kind` (`work` | `sleepover` | `disturbance`), `date`, `start`, `end`, `minutes`,
`break_minutes`, `day_type` (`weekday` | `saturday` | `sunday` | `public_holiday`), `is_manual`.

Generation (`TimesheetService::buildSegments`, pure and unit-tested):

1. Split clock-in → clock-out at the sleepover window (clipped to the worked span) into work /
   sleepover / work.
2. Split each piece at every midnight (shift-local).
3. Day type from the date: the shift state's active public holidays first, then Saturday /
   Sunday, else weekday.
4. The break comes off the **work** segment holding the shift's midpoint; if the midpoint is in
   a sleepover, the longest work segment; a break longer than that segment spills into the next.
5. Minutes are real elapsed time (daylight-saving safe).

When segments are made: on create, and again on every sync (approval, adjustment, backfill
`--sync`). Generated segments are deleted and rebuilt; **manual** ones (disturbances, entered by
hand during a sleepover) are kept. Sleepover rules themselves (allowance, minimum disturbance
payment) are an award question, so they live in Award Interpretation, not here. Sleepover shifts
needed no change to the `shifts` table: it already has `is_sleepover_shift`,
`sleepover_start_time` / `end_time` and their `approved_` versions.

Not built yet: entering disturbances (no endpoint or UI). The schema is ready for it.

### `timesheet_amendments`

`changes` (JSON, field → [before, after]), `reason`, `after_export`, `changed_by`, `created_at`.
Fair Work's rule: records are altered only to correct an error, with a trail. Every sync that
changes time, break, sleepover window, HCP, profession or level writes one; so does a void.

### `timesheet_exports` + `timesheet_export_items`

One row per export (format, filters, date range, rows, file, who), and exactly which timesheets
it carried. Exporting sets `exported_at`, `last_export_id`, clears `needs_reexport`. A change or
void after export sets `needs_reexport`. Files are stored through `TenantFileStorageService`
(`storage_paths.timesheet_exports`) and downloaded by a short-lived signed URL.

## Hooks

`TimesheetService` is Rostering (core), so `ShiftService` calls it directly:

| ShiftService | TimesheetService |
|---|---|
| `end()` | `createFromShift()` (pending) |
| `approve()` with "save for approval" | `syncFromShift()` + amendment "Adjusted before approval" |
| `approve()` | `syncFromShift()` + amendment "Adjusted at approval", then `markApproved()` |
| `void()` | `void()` |
| `cancel()` of a completed (status 4) shift | `void()` |

Each hook runs in a savepoint inside the shift's transaction; **a failure is reported
(`report()`), never blocking the shift action**. `timesheets:backfill --sync` repairs any gap.

Known gap (pre-existing, not changed): `cancel()` of a completed shift doesn't void its payroll
line, unlike `void()`.

## Backfill: `timesheets:backfill`

- Per tenant: `php artisan tenants:run timesheets:backfill --option=since=2026-01-01`
  (bare flags are rejected by `tenants:run`: use `--option=dry-run=1`, `--option=sync=1`).
- Completed (4) and approved (6) shifts with an HCP and clock-in/out. Approved shifts are
  marked approved (by the shift's `updated_by`, at `shift_approved_time`). Shifts voided before
  timesheets existed are not backfilled.
- Safe to re-run: existing timesheets are skipped, or re-synced with `--sync`.
- Reports totals and worked hours against `payroll_lines.billable_hours`, listing mismatches.

## Export (phase 1: generic CSV)

`POST /timesheets/exports` with `format` and the list's filters (`from`, `to`, `approval`,
`export_state`, `facility_id`, `hcp_id`). Voided timesheets are never exported.

- `csv`: one row per timesheet: employee, date, site, state, timezone, profession / level,
  award / classification / pay point, clock-in/out, break, worked hours, sleepover window and
  hours, hours by weekday / Saturday / Sunday / public holiday, approval, re-export flag.
- `csv-segments`: one row per segment: the same context plus kind, date, day type, start, end,
  break, hours.

Cells starting `= + - @` are prefixed with `'` (spreadsheet formula injection).

## Modules

- Timesheets: Rostering (core), always on. SPA `/admin/timesheets`, sidebar Rostering.
- **Payroll** module: pay runs and payroll lines, `module:payroll` on their routes
  (`/accounting/payroll-runs*`, `/accounting/payroll-lines*`, old alias `/accounting/timesheets*`).
  Default **off**; Smart Healthcare keeps it on as a stopgap. Existing tenants got explicit
  rows when modules were introduced, so switch it off in the console for the others.

## Known bugs (payroll side, left alone while payroll lines are on hold)

1. `formatRunDetail` reads `approved_start_time` / `approved_end_time`, which don't exist, so
   pay run detail times show as "now".
2. The accounting re-sync (`ShiftAccountingSyncService`) uses a 0-minute break when it backfills
   payroll lines, which overstates hours. It also writes `billing_lines` columns that have been
   dropped (around :417–420), and `BillingLine::$fillable` still lists them.
3. `ShiftPriceCalculator` around :166: `'hcp_rate_with_loading' => $hcpRateWithLoading, 2,` (a
   leftover `round` argument).

## Phases

| Phase | Scope | State |
|---|---|---|
| **1** | Tables, `TimesheetService`, hooks, backfill, Timesheets page, generic CSV export, Payroll Lines moved under the Payroll module | **Built** |
| 2 | Provider layouts (Employment Hero: employee code, date, start, end, unpaid break, location; Xero: hours per earnings rate per day; KeyPay), with an employee code per HCP and pay-item mapping per agency; disturbance entry | Next |
| 3 | Payroll module reads timesheets (not shifts / rate cards); module events (`TimesheetCreated` / `TimesheetApproved`) replace ShiftService's direct accounting calls | Later |
| 4 | Rates leave booking; Award Interpretation and Agency billing price timesheets after the fact | Later |

## HCP pay statements (with the Payroll module)

Legacy sent each HCP a weekly PDF statement (`GenerateHcpInvoiceCron`): that week's approved
shifts and amounts, built from the dropped `transactions` billing. It's not ported. Its
replacement belongs to Payroll and is built from timesheets, not shifts:

- After each pay run (cadence from Organisation settings), every HCP paid in it gets a PDF
  statement: the run's period, each timesheet (date, site, clock-in/out, break, hours) and what
  was paid for it, plus totals. Emailed through `NotificationService`.
- Statements are stored per pay run (`TenantFileStorageService`) and listed on the HCP profile's
  **Reports** tab to re-download. The tab's "Pay statements" card is a placeholder until then.
- **Never facility-side amounts** (charge rates, admin fees, margins): this goes to the HCP.
- Until this exists, Reports → **Shift history** exports an HCP's full shift list (no money
  columns, no date cap; large exports are queued and emailed).

## Sources

- Fair Work record keeping: https://www.fairwork.gov.au/pay-and-wages/paying-wages/record-keeping
- Employment Hero timesheet CSV import: https://help.employmenthero.com/hc/en-au/articles/17426619544463-Set-up-your-Timesheet-Import-CSV
- Xero Payroll AU timesheets: https://cdn.cdata.com/help/DXN/ado/pg_payrollaustable-timesheets.htm
- Deputy → Xero pay rates: https://help.deputy.com/hc/en-au/articles/15229492204431-Pay-Rate-Configuration-Guide-for-Xero-Reseller
- foundU labour hire invoicing: https://help.foundu.com.au/hc/en-us/articles/16743061425295-Labour-hire-invoicing-in-foundU
