From b61d8e14dd16c807d0c6de859e05d44134281b05 Mon Sep 17 00:00:00 2001 From: Sean Dolbec Date: Tue, 10 Nov 2026 23:48:53 -0500 Subject: [PATCH 1/4] Time & pay for every workspace: audit fixes and per-workspace switches A multi-workspace audit of the time system (docs/audits/2026-11-10-time-multi-tenant-audit.md). No cross-workspace read was found; these are the fixes and the settings that came out of it. Audit fixes - Payroll: timesheets import paid hours (unpaid breaks left out), so the timesheet, the Pay page and the payroll export agree (ClockShift#paid_hours_between). - Time zones: tenants.time_zone (blank = instance default). Web, staff API, kiosk and time & pay jobs run on the workspace's clock (Current.with_tenant, use_business_zone). - Push: a token registers to one account across workspaces; forgetting needs only the token digest; iPhone sign-out, offboarding and deactivation stop delivery. - PIN clock: an anonymous browser gets one answer for every PIN; a signed-in phone is compared with its own person's PIN only; tries are counted per device or network address. - Privacy: Team schedule and the punch report no longer show colleagues' hours and pay rates to people who do not review them or lack payroll access. - Store scope is enforced on punch-fix, timesheet and time-off decisions, not only in the inbox. - Jobs: sync schedulers rescue per workspace; leave accrual uses each workspace's date. - iPhone app: Switch workspace, handoff accepts only its own control plane, notifications open only a path on the current instance, push registration carries the web views' cookie. - Expo client: no prefilled workspace; sign-out clears the name and workspace. Per-workspace switches (Settings -> Time rules, settings.write) - What is turned on: web clock, phone apps, register PIN, kiosk, breaks, scheduling, trades, time-off requests, tips and pay for staff, punch fixes by staff, self-submitted timesheets, missed clock-out reminders. All on by default; off is refused by the server (pages, staff API 403 feature_disabled, PIN clock) and hidden from menus. - Punch logging: device and register name, an optional detailed audit trail (PunchAudit, time_clock.* activity lines) with or without the network address, and its retention. - Every change is logged old -> new. One additive migration: tenants.time_zone, an index on push_devices.token_digest. Co-Authored-By: Claude Fable 5.2 --- CHANGELOG.md | 1 + .../2026-11-10-time-multi-tenant-audit.md | 102 +++++ docs/employee-timesheets.md | 6 +- docs/ios/app-review-readiness.md | 9 +- docs/time-and-pay/DECISIONS.md | 33 +- ios/AppStore/checklist.md | 13 + ios/README.md | 12 +- ios/Sources/Rupert/AppSettings.swift | 23 +- ios/Sources/Rupert/Instance.swift | 10 + ios/Sources/Rupert/PushRegistration.swift | 60 ++- ios/Sources/Rupert/SceneController.swift | 19 + ios/Sources/Rupert/WorkspaceHandoff.swift | 7 + .../RupertTests/AppReviewReadinessTests.swift | 23 + mobile/.env.example | 4 +- mobile/App.tsx | 4 +- mobile/README.md | 2 +- mobile/eas.json | 11 +- .../api/v1/employee_base_controller.rb | 19 + .../api/v1/employee_clock_controller.rb | 4 +- .../v1/employee_leave_requests_controller.rb | 1 + .../api/v1/employee_schedule_controller.rb | 1 + .../api/v1/employee_tips_controller.rb | 12 +- .../api/v1/employee_trades_controller.rb | 1 + web/app/controllers/application_controller.rb | 23 + .../employee_availability_controller.rb | 1 + .../controllers/employee_pay_controller.rb | 3 + .../controllers/employee_trades_controller.rb | 1 + .../employee_workspace_controller.rb | 2 + .../native/push_devices_controller.rb | 6 +- .../people/availabilities_controller.rb | 1 + .../people/schedule_templates_controller.rb | 1 + .../people/scheduled_shifts_controller.rb | 1 + .../people/shift_trades_controller.rb | 1 + .../people/team_schedule_controller.rb | 6 + web/app/controllers/pin_clock_controller.rb | 24 +- web/app/controllers/sessions_controller.rb | 2 + web/app/controllers/time_clock_controller.rb | 10 +- .../time_rules_settings_controller.rb | 36 +- web/app/helpers/application_helper.rb | 6 + .../controllers/pin_clock_controller.js | 5 +- web/app/jobs/break_reminder_job.rb | 2 +- web/app/jobs/ctrax_labor_sync_job.rb | 3 + web/app/jobs/data_retention_job.rb | 1 + web/app/jobs/directory_sync_job.rb | 3 + web/app/jobs/leave_accrual_job.rb | 5 +- web/app/jobs/open_clock_out_alert_job.rb | 2 +- web/app/jobs/square_timecard_sync_job.rb | 3 + .../jobs/tips/attribute_authorize_net_job.rb | 2 +- web/app/jobs/tips/attribute_feed_event_job.rb | 5 +- web/app/jobs/tips/reconcile_job.rb | 2 +- web/app/models/clock_shift.rb | 19 + web/app/models/current.rb | 10 + web/app/models/employee_access_grant.rb | 5 +- web/app/models/module_registry.rb | 7 + web/app/models/push_device.rb | 27 +- web/app/models/tenant.rb | 6 + web/app/models/time_rules.rb | 113 ++++- .../policies/people/leave_request_policy.rb | 13 +- web/app/policies/people/timesheet_policy.rb | 3 +- web/app/services/business_clock.rb | 4 + web/app/services/people/offboarding.rb | 1 + web/app/services/punch_audit.rb | 32 ++ web/app/services/punch_history.rb | 9 + web/app/services/push_notifier.rb | 4 + web/app/services/time_clock_service.rb | 51 ++- web/app/services/timesheet_workflow.rb | 20 +- web/app/views/ctrax_pos/_topbar.html.erb | 8 +- web/app/views/employee_app/show.html.erb | 15 +- web/app/views/employee_pay/show.html.erb | 15 +- .../views/employee_workspace/reports.html.erb | 4 +- web/app/views/layouts/employee.html.erb | 4 + web/app/views/pin_clock/kiosk.html.erb | 7 +- web/app/views/shared/_clock_card.html.erb | 10 +- web/app/views/time_clock/index.html.erb | 6 +- .../views/time_rules_settings/show.html.erb | 54 +++ web/config/locales/time_clock.en.yml | 5 + web/config/locales/time_clock.es.yml | 5 + ...20261110100000_add_time_zone_to_tenants.rb | 13 + web/db/schema.rb | 4 +- web/test/integration/pin_clock_test.rb | 2 +- .../time_features_settings_test.rb | 414 ++++++++++++++++++ .../time_multi_tenant_isolation_test.rb | 308 +++++++++++++ web/test/models/unscoped_allowlist_test.rb | 1 + 83 files changed, 1614 insertions(+), 119 deletions(-) create mode 100644 docs/audits/2026-11-10-time-multi-tenant-audit.md create mode 100644 web/app/services/punch_audit.rb create mode 100644 web/db/migrate/20261110100000_add_time_zone_to_tenants.rb create mode 100644 web/test/integration/time_features_settings_test.rb create mode 100644 web/test/integration/time_multi_tenant_isolation_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cb2441ab..ecbb8382c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- **Time & pay works for every workspace, with a switch for each part of it.** **Settings → Time rules** gains the workspace's **time zone** (which midnight ends a day, a work week and a pay period; blank keeps the instance default), **What is turned on** (clocking from the web, from the phone apps, the register PIN and the kiosk; breaks; scheduling; shift trades; time-off requests; tips and pay estimates for staff; punch fixes by staff; self-submitted timesheets; missed clock-out reminders) and **Punch logging** (device and register name, an optional detailed audit trail with or without the network address, and how long it is kept). Everything is on by default as before; a switch that is off is refused by the server on the pages, the staff API (403 `feature_disabled`) and the PIN clock, and leaves the menus; each change is in the activity log with the old and new value. A multi-workspace audit (`docs/audits/2026-11-10-time-multi-tenant-audit.md`) found no cross-workspace read and fixed what it did find: timesheets now import **paid** hours (unpaid meals no longer reach the payroll export), a phone's push token follows the phone instead of the last workspace, the PIN pad no longer tells an anonymous visitor whether a PIN exists, Team schedule and the punch report no longer show colleagues' hours and pay rates to people who do not review them, store-limited managers cannot decide for other stores by id, and the sync schedulers no longer stop at the first workspace that fails. The iPhone app gains **Switch workspace**; the Expo client no longer prefills one workspace. - **Time, scheduling and pay for hourly staff (#640, #642, #644).** Staff clock in at the register or the counter tablet with a **4-digit PIN** (`/clock` kiosk and the Clock button on the POS strip; registered store devices only, or a signed-in phone), take **breaks and meals**, fix a forgotten clock-out in two taps, and see their day on **My time** (`/employee`): today's shift, this period's hours, Schedule, Time off and Pay. **Schedule**: managers build the week by drag and drop with conflict chips (time off, availability, hours, rest, minors, blackouts) and a labour-cost-vs-sales line, save templates, post the week to staff; staff pick up open shifts, give away or swap shifts with guardrails and manager approval. **Time off** in four taps with balances, accrual, blackout dates and notice. **Pay** shows hours net of unpaid breaks, estimated gross, **card tips** attributed from the Dejavoo terminal and settled against Authorize.net batches (owner's pool rule: shared evenly among everyone clocked in, admins excluded), declared **cash tips**, bonuses and the next payday; English and Spanish. Managers get **timesheets with exceptions first** and bulk approve, one **Approvals** inbox (punch fixes, time off, trades, timesheets), a **Tips** report with reconciliation, **Pay periods** with lock and a provider-agnostic payroll CSV, per-store **Time rules**, **Clock devices**, store-scoped roles, leave accrual and offboarding. **Push notifications** (APNs for the iPhone app, Web Push for the installed web app) for posted or changed schedules, decisions, trade offers, missed clock-outs and breaks, once `APNS_*`/`VAPID_*` keys are set. The iPhone app 1.1.0 opens on a **Time** tab and is packaged for App Store resubmission (`ios/AppStore/checklist.md`). Docs: `docs/time-and-pay/`. - **Learning center: a reusable LMS, and its first course, “How our card payments work”.** **Me → Learning center** (`/learning-center`, every signed-in account, including staff in their own shell) lists each person's courses with a progress ring, required-by dates and earned badges. A course is lessons → quiz questions as workspace data (`lms_*` tables, seeded from `db/seeds/lms/*.yml` on first visit or `bin/rails lms:seed`), so the next course (HR policies, POS training) is a YAML file. Lessons are 2–4 minutes, with tap-to-define glossary words, callouts, step cards and responsive HTML diagrams (the life of a tap, who is who, where the fees go, tips, void vs refund, a dispute timeline, PCI scope, first moves when the terminal misbehaves). Each lesson ends with a quick check; every answer must be right, wrong ones show why, and attempts are kept. Finishing every lesson awards a printable badge. Progress saves on every view and answer. **Who takes it**: rules by role (required, due within N days) enrol people now and as accounts are added; managers with `hr.write` also assign named people, edit lessons and quizzes (with preview) and keep the **support contacts** shown inside the course current; **Team completion** (`hr.read`) shows everyone against every course with filters and a CSV. English UI strings have a Spanish translation file; content carries a locale. See `docs/lms/`. - **Extra Dejavoo terminals, one per register or event.** Checkout setup → **Extra Dejavoo terminals**: add a terminal's TPN and SPIn auth key for a register name, **Check** it (a status lookup that cannot charge), and that register's card sales, refunds and batch close use it; other registers keep the main TPN. Each checkout remembers its terminal, so refunds go back to the same one. Retired terminals keep their keys for refunds of past sales. **Close terminal batch** closes every terminal's batch. diff --git a/docs/audits/2026-11-10-time-multi-tenant-audit.md b/docs/audits/2026-11-10-time-multi-tenant-audit.md new file mode 100644 index 000000000..13b9ecac5 --- /dev/null +++ b/docs/audits/2026-11-10-time-multi-tenant-audit.md @@ -0,0 +1,102 @@ +# Time & pay: multi-workspace audit (2026-11-10) + +A read of the time-keeping system on `main` at `0cb2020a` (PRs #640, #642, #644), asking one question of every +query, job, setting and client: **does this still hold when a second workspace uses it?** Followed by the fixes and +the per-workspace switches that came out of it. Companion documents: [ARCHITECTURE](../time-and-pay/ARCHITECTURE.md), +[DECISIONS](../time-and-pay/DECISIONS.md), [staff timesheets](../employee-timesheets.md). + +## How workspaces are kept apart + +Every time & pay model includes `TenantScoped` (`default_scope { where(tenant_id: Current.tenant_id) }`), so a +record id from another workspace is simply not found (404). `Current.tenant` comes from the signed-in account, the +bearer token's own row (staff API), the registered clock device's row (kiosk) or the host name for an anonymous +request. `User` and `Setting` are not scoped by default; every time & pay read of them names the workspace. +No cross-workspace read was found. The findings below are about the edges of that boundary, about what one +workspace's choices do to another, and about leaks between people inside one workspace. + +## Findings + +| # | Severity | Area | What was wrong | Fix | +| --- | --- | --- | --- | --- | +| 1 | High | Payroll | A timesheet imported each punch as **gross** hours (clock-in to clock-out). The Pay page, overtime checks and leave accrual all count **paid** hours (unpaid meals removed), so an exported pay period paid a 30-minute unpaid meal that the worker's own page did not show. | `ClockShift#paid_hours_between`; the import, the staleness checks and the export now agree. A draft imported before the fix asks for a re-import at submit. | +| 2 | High | Time zones | Day, week and pay-period boundaries, the midnight split of an imported shift, "today" and every displayed clock time used the instance zone (Eastern). A workspace elsewhere got hours on the wrong day and in the wrong period. No workspace or store could record a zone. | `tenants.time_zone` (blank = instance default, so the existing workspace is unchanged), chosen at Settings → Time rules. Every web request, staff API request, kiosk request and time & pay job runs on the workspace's clock (`Current.with_tenant`, `use_business_zone`). | +| 3 | High | Push | A phone's push token was unique per workspace only, and sign-out could not reach the server (the "forget" call needs no session now; it used to need the one that had just ended). A second person on the same phone kept receiving the first person's schedule and approval notifications. | A token registers to one account everywhere (`PushDevice.register!` stops every other row with that token); forgetting needs only the token digest; signing out of the iPhone app, offboarding and deactivation stop delivery; a deactivated account or inactive workspace is never pushed to. | +| 4 | Medium | PIN clock | An anonymous browser on a workspace's address could tell a real PIN from a wrong one (two different refusals), wrong tries were counted per session cookie (reset by discarding it), and each try ran one bcrypt comparison per worker. | Nobody vouching (no store device, no sign-in) is refused before the digits are read. A signed-in phone is only ever compared with its own person's PIN. Tries are counted per store device or network address. | +| 5 | Medium | Privacy | **Team schedule** showed every worker's daily hours to any account linked to a staff record (cashier, reader, inventory). | Reviewers see their stores' staff; everyone else sees their own row. | +| 6 | Medium | Privacy | The punch report (page and CSV) showed every worker's hourly rate and estimated pay to reviewers without payroll access (shift leads). | Rates need `payroll.read`, except your own. | +| 7 | Medium | Store scope | A manager limited to stores could approve a punch fix, a timesheet or time off for another store's staff by posting its id (only the inbox filtered). | `People::StoreScope.covers?` in `TimeClockService.review`, `TimesheetPolicy#approve?` and the time-off policy. | +| 8 | Medium | iPhone app | The workspace handoff trusted any `control` origin in its link (readiness finding 12). A notification's `url` could name another workspace's address. There was no way to leave a workspace, so a second person stayed on the first one's sign-in page with its control-plane session and cached pages still in the web views. | The handoff accepts only the app's own control plane; a notification opens only a path on the current instance; **Settings → Switch workspace** forgets the push token, clears the web views' data and returns to the control plane. Push registration now carries the web views' session cookie explicitly. | +| 9 | Medium | Jobs | The C-Trax labour, Square timecard and directory schedulers stopped at the first workspace that raised, skipping the rest. Leave accrual computed "today" once, in the instance zone, for every workspace. `Current.set(tenant:)` kept the previous workspace's per-request caches. | Per-workspace rescue; `today` per workspace; `Current.with_tenant` resets caches and sets the zone. | +| 10 | Low | PIN clock | `/clock/workers` raised a 500 for an anonymous request (nil user). The web clock-in trusted a `source` form field. The staff API recorded its punches as `web`. | Empty list; the server decides the source (`web`, `ios`, `api`). | +| 11 | Low | Expo client | Every EAS profile prefilled the Herbal Healers address; sign-out left the previous name and workspace on screen. | No prefill; cleared; the sign-in error says to check the workspace address. | +| 12 | Low | Accounts | One email signs in to one workspace. A second workspace issuing a setup link for that email was told to "link the existing account", which it cannot see. | The refusal says what to do (use a different email) without saying where the account lives. | +| 13 | Low | Audit | Time-rules changes logged only the names of the fields posted. | The log line carries each changed value, old → new, and nothing is logged when nothing changed. | + +Checked and sound: bearer tokens (workspace taken from the token row, sign-in refused on another workspace's +host), clock-device cookies (looked up inside the current workspace only), setup links, PIN uniqueness and +per-worker lockout (per workspace), pay rules, time rules, pay periods and locks, tip ledger, leave balances, +notification preferences (all stored per workspace or per user, with defaults for a workspace that has saved +nothing), writer locks (the workspace row plus the worker row, so two workspaces never wait on each other), +CSV and PDF exports (same scoped queries as the pages), notification dedupe keys, and the recurring jobs +(every active workspace, one at a time). + +## Known limits (not fixed here) + +- **One zone per workspace.** A workspace with stores in two time zones uses the workspace zone for both. `BusinessClock` + already reads `Location#timezone` when a store has one; the column and the per-store choice do not exist yet. +- **Recurring job times are Eastern** (`config/recurring.yml`): the 04:10 leave accrual and the task reminders run at + that Eastern time for every workspace. Break reminders and missed clock-outs compare instants and are unaffected. +- **The schedule builder draws times in the manager's browser zone** (`schedule_builder_controller.js`). A manager + travelling outside the workspace's zone should check the times on the posted schedule. +- **The `.ics` schedule feed needs a signed-in session**, so calendar apps cannot subscribe to it. Opening it needs an + expiring, revocable token first. +- **A trade or pick-up at another store is a warning, not a refusal** (manager approval is on by default). +- **Optional links typed into admin forms are not checked against the workspace** (a punch's or clock device's store, a + shift's position, a department's manager). A foreign id is saved but reads back as nothing, so no data crosses; + the cost is a blank where a name should be. +- **The staff API kill switch** (`RUPERT_EMPLOYEE_APP_ENABLED`) is still an instance-wide environment variable; the new + per-workspace switch narrows it, it cannot turn the API on for one workspace alone. + +## Per-workspace switches + +**Settings → Time rules** (`/settings/time_rules`, permission `settings.write`; every store selected). Each is on by +default, which is what Rupert did before the switch existed. + +| Switch | Off means | +| --- | --- | +| Clock in from the web and the installed web app | Clock in/out and breaks from a signed-in browser are refused; the clock card says to use the store clock. | +| Clock in from the phone apps | The same from the iPhone app, and `POST/PATCH /api/v1/employee/clock…` answer 403 `feature_disabled`. | +| PIN clock on the register | The Clock button leaves the POS strip; PIN punches from it are refused. | +| Clock kiosk | `/clock` says it is turned off; kiosk PIN punches are refused. | +| Breaks and meals | No new break can be started anywhere (an open break can still be ended). | +| Scheduling | Schedule builder, templates, availability, posted shifts, open shifts: pages and API refused, menus hidden. | +| Shift trades | Give-aways, swaps and pick-ups refused (web and API); the manager's trades page hidden. | +| Time-off requests | Staff cannot ask from their page or the API. Managers still record and decide leave. | +| Tips for staff | Tip figures and the cash-tip form leave the Pay page; `/api/v1/employee/pay` omits tips; declaring tips is refused. | +| Pay estimates for staff | The Pay page and API, the estimate on Today and on My reports, and payslips there. | +| Punch fixes by staff | Staff cannot request a correction (page, API or the kiosk's forgotten clock-out fix); a manager starts it. | +| Staff submit their own timesheets | Only someone with `timesheets.write` submits a sheet for review. | +| Missed clock-out reminders | No nudge to the worker or managers about an open punch. | +| Break reminders | Under Breaks, per store (already existed). | + +Punch logging, same page: + +| Setting | Default | What it does | +| --- | --- | --- | +| Keep the device and register name on each punch | On | Off stops storing them on new punches. | +| Detailed punch audit trail | Off | Writes `time_clock.*` lines to the activity log: clock in/out, breaks, fixes asked and decided, refused PINs; with source, store, device and who. | +| Record the network address of each punch | Off | Adds the address to those lines. Needs the trail. | +| Keep the punch audit trail | 7 years | 3, 4 or 7. Older lines are removed nightly. Punches, corrections and timesheets are never removed. | + +A switch that is off is refused in the controller or service (`TimeRules.require_feature!` → +`TimeRules::FeatureDisabled`), not only hidden. Every change is an activity-log line (`time_rules.updated`) with who, +when and each value old → new. + +## Tests + +- `web/test/integration/time_multi_tenant_isolation_test.rb`: two workspaces with the same staff name, employee number + and PIN. Pages, staff API, PIN clock and kiosk, exports, jobs, clock zone, settings, push and setup links. +- `web/test/integration/time_features_settings_test.rb`: defaults, permission, audit, each switch on the web, the API + and the PIN clock, the logging options, retention, and a brand-new worker in a brand-new workspace from the setup + link to the payroll export. +- `ios/Tests/RupertTests/AppReviewReadinessTests.swift`: notification links, cookie scoping, leaving a workspace. diff --git a/docs/employee-timesheets.md b/docs/employee-timesheets.md index 895102c4f..bf93b0e82 100644 --- a/docs/employee-timesheets.md +++ b/docs/employee-timesheets.md @@ -11,7 +11,7 @@ Rupert serves the staff web app at `/employee`. Staff use the same account on th Account administration and HR write access are both required to issue a link. Manager timesheet permissions do not grant account provisioning. Password setup/reset invalidates native sessions and staff browser sessions. An inactive account, inactive workspace, terminated staff member, changed staff link, or ambiguous active worker mapping invalidates native access. Existing linked cashier/inventory accounts can use their own timesheets without receiving HR or payroll access. -Account email is globally unique in Rupert. Do not reuse a single login for several staff. Historical duplicate active staff links must be resolved by an administrator; the app refuses ambiguous worker mappings. New staff link assignments are validated for workspace membership and uniqueness. +Account email is globally unique in Rupert: one email signs in to one workspace. Someone who works for two workspaces needs a different email in each (a setup link for an email that already signs in elsewhere is refused with that advice). Do not reuse a single login for several staff. Historical duplicate active staff links must be resolved by an administrator; the app refuses ambiguous worker mappings. New staff link assignments are validated for workspace membership and uniqueness. ## Timesheet workflow @@ -19,7 +19,7 @@ Account email is globally unique in Rupert. Do not reuse a single login for seve - Entries support regular, overtime, PTO, and holiday hours. Dates must fall within the period and cannot be in the future. Periods cannot overlap for the same staff member and are limited to 31 days. All entries for a worker/day together must be at most 24 hours. - Submitted/approved/rejected entries cannot be edited. A reviewer cannot approve or reject a record linked to their own login. Review decisions and comments are atomic and audited. Invalid/repeated state changes cannot silently replace an earlier review. Submission and approval check active employment, valid periods and entries, daily totals, current imported clock hours and pending clock corrections. A reviewer can still return an invalid sheet for correction. Clock corrections are reviewed by the same people who review timesheets (or payroll writers), never by the person whose shift it is. - A rejected sheet can be reopened by its staff member. An approved sheet requires a manager to reopen it. A sheet included in a payslip cannot be reopened until that payroll snapshot is removed/regenerated by an authorized payroll operator. Records with submission history cannot be deleted through the timesheet workflow. -- Clock imports split overnight shifts at midnight in the **instance time zone** (currently Eastern Time), including daylight saving boundaries. Imports rebuild only the rows attributed to clock shifts, preserve manual rows, and reject pending corrections. Re-importing does not add duplicate hours. Submitting checks for stale imported clock times and requires a fresh import if necessary. +- Clock imports split overnight shifts at midnight in the **workspace's time zone** (Settings → Time rules; the instance default, Eastern Time, until one is chosen), including daylight saving boundaries. Each day's imported hours are **paid** hours: unpaid breaks (meals by default) are left out, so the timesheet, the Pay page and the payroll export agree. Imports rebuild only the rows attributed to clock shifts, preserve manual rows, and reject pending corrections. Re-importing does not add duplicate hours. Submitting checks for stale imported clock times and requires a fresh import if necessary. - Imports classify hours as regular. Review manual entries to avoid counting the same shift twice and classify overtime according to the organization's rules. Overtime thresholds and pay rules are not inferred from the staff member's device locale. - An approved clock correction does not rewrite a submitted/approved timesheet or payslip. Reopen and re-import when permitted, or handle the adjustment through payroll. - Single-sheet and filtered-list CSV exports respect the same access boundaries and neutralize spreadsheet formulas in text fields. Regular payroll generation includes only approved **entry dates inside the pay run**, rather than all hours on an overlapping sheet. @@ -129,7 +129,7 @@ Base: `/api/v1/employee`. Requests/responses are JSON. Except login, supply `Aut | `POST /clock/:id/breaks` | `{kind}` (`rest` or `meal`) starts a break on the own open shift; 201 with the break. One open break at a time. | | `PATCH /clock/:id/breaks/:break_id` | Ends that open break; safe to retry. `GET /clock` rows now include `breaks`, `source` and `location_id`. | -IDs belonging to another worker or workspace return 404. Missing, revoked, expired, or inactive credentials return 401. Unsupported review actions/invalid entries return 422; role denial returns 403. Native APIs never accept staff member IDs, review decisions, token scopes, or administrator role changes from clients. Browser endpoints remain CSRF protected. +A feature the workspace turned off at Settings → Time rules answers 403 `{"error":"feature_disabled","feature":"…","message":"…"}` (clocking from the apps, breaks, schedule, trades, time off, pay, tips, punch fixes); a timesheet a worker may not submit themselves answers 422 with the reason. IDs belonging to another worker or workspace return 404. Missing, revoked, expired, or inactive credentials return 401. Unsupported review actions/invalid entries return 422; role denial returns 403. Native APIs never accept staff member IDs, review decisions, token scopes, or administrator role changes from clients. Browser endpoints remain CSRF protected. ## References diff --git a/docs/ios/app-review-readiness.md b/docs/ios/app-review-readiness.md index d14d1f1d2..2c643f463 100644 --- a/docs/ios/app-review-readiness.md +++ b/docs/ios/app-review-readiness.md @@ -138,11 +138,12 @@ This branch does not change it, because it changes the AI flow for every user. R - **Tokens and storage.** The app stores no API tokens or passwords. - The Rails session cookie (encrypted cookie store, 14 days, `valid_browser_session?` checked on every request) lives in the system web data store, in the app's container, under iOS Data Protection. - Google sign-in uses PKCE. The verifier is held in memory only, and the one-time code is exchanged over an ephemeral `URLSession`. The workspace handoff also uses an ephemeral session. - - `UserDefaults` holds only the instance address and the Face ID setting. + - `UserDefaults` holds the instance address, the Face ID setting, whether the notification question was answered, and the digest and host of the push registration (cleared on sign-out and on Switch workspace). No token or password. - No Keychain use is needed today. **If the app ever holds a token** (terminal pairing, API keys), use the Keychain with `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`. - **Transport.** HTTPS only: there are no App Transport Security exceptions, the app refuses non-HTTPS instances, and the control plane is fixed in `Info.plist`. Other sites open in an in-app Safari view, not the app's web view. There is no certificate pinning, which is normal for a web-content app. Web Inspector is now debug-only. -- **Hardening to do next (finding 12).** - - Only accept `rupert://workspace` when it arrives as the server's redirect from the control plane (a form submission or redirect, not a tapped link), with `control` equal to `RupertControlPlaneURL`. - - Or have the server sign the origin. +- **Workspace handoff (finding 12), partly done 2026-11-10.** + - Done: the app accepts `rupert://workspace` only when `control` is its own control plane (`RupertControlPlaneURL`); anything else is refused with a message. + - Still to do: accept it only as the server's redirect (not a tapped link), or have the server sign the origin. - Test it against a real isolated workspace before shipping. +- **Shared phones and second workspaces (2026-11-10).** Settings → **Switch workspace** signs out, forgets the push token on the server, clears the web views' data and returns to the control plane. A push token registers to one account across all workspaces, the server forgets it without a session, and a notification opens only a path on the current instance. See `docs/audits/2026-11-10-time-multi-tenant-audit.md`. - **AI vendor.** Partners and merchants will ask where business data goes. Ask Rupert's default is a cloud model (DeepSeek via OpenCode). Have the answer ready: the provider, its retention, and the `local_only` mode that keeps questions on the Rupert server. diff --git a/docs/time-and-pay/DECISIONS.md b/docs/time-and-pay/DECISIONS.md index 1e12f22a4..86fe99ae9 100644 --- a/docs/time-and-pay/DECISIONS.md +++ b/docs/time-and-pay/DECISIONS.md @@ -46,7 +46,7 @@ to Square. | Minors | Under 18; 8 h/day, 40 h/week, 7 AM–7 PM window | FLSA limits for under-16s, applied to all minors as a conservative default. The builder warns; it never blocks a manager. Date of birth is encrypted at rest already. | | Predictive scheduling | 14 days notice (warning), 10 h minimum rest between shifts | NY/OR/CA-city style fair-workweek rules are the direction of travel; a warning costs nothing where it does not apply. | | Punch rounding | None (owner) | Hours are the recorded seconds. | -| Buddy-punch prevention | PIN + registered store device (or a signed-in session). No photo, no GPS. | Privacy-respecting and works at a counter: a manager registers the register browser/tablet once (`ClockDevice`), after which a PIN punch from it is trusted. A PIN from an unknown browser is refused with "Use the store register or sign in". Photo capture is listed as a later option, off. | +| Buddy-punch prevention | PIN + registered store device (or a signed-in session). No photo, no GPS. | Privacy-respecting and works at a counter: a manager registers the register browser/tablet once (`ClockDevice`), after which a PIN punch from it is trusted. A PIN from an unknown browser is refused with "Use the store register or sign in" before the digits are read, so the answer never says whether a PIN exists; a signed-in phone is only compared with its own person's PIN. Photo capture is listed as a later option, off. | | PIN | 4 digits, 5 tries then 10 min lockout | Fast at the counter; lockout stops guessing. | | Trades | Need manager approval; 2 h minimum notice; blocked when they would create overtime, double-booking, a minor rule breach or an unqualified position | Guardrails run when a taker accepts and again at approval (`checks_json`). | | Time off | 7 days notice requested (warning); blackout dates flagged, not blocked | Managers decide; the system informs. | @@ -94,10 +94,33 @@ rules, devices), `super_admin` (platform). Managers and shift leads can be limit ## Time zones, DST, overnight -Every store has a `Location#timezone`; punches are stored in UTC and shown in the store's zone -(`BusinessClock`). Overnight shifts keep one punch pair and split at midnight only when hours are -imported into a timesheet (existing behaviour). DST: durations are computed from instants, so a shift -across the fall-back night counts the real hour. +Punches are stored in UTC. Each workspace has its own clock (`tenants.time_zone`, chosen at **Settings → +Time rules**; blank keeps the instance default, Eastern): every web request, staff API request, kiosk +request and time & pay job runs in it (`BusinessClock`, `Current.with_tenant`), so "today", the work week, +the pay period and every displayed time are the business's. A store-level zone is read when a location +has one, but no store can record one yet (one zone per workspace; see the 2026-11-10 audit). Overnight +shifts keep one punch pair and split at the workspace's midnight only when hours are imported into a +timesheet. DST: durations are computed from instants, so a shift across the fall-back night counts the +real hour. + +## What is turned on (per workspace, 2026-11-10) + +**Settings → Time rules** lists a switch for each part of the time system (web clock, phone apps, register +PIN, kiosk, breaks, scheduling, trades, time-off requests, tips and pay for staff, punch fixes by staff, +self-submitted timesheets, missed clock-out reminders) and the punch logging options (device and register +name, a detailed audit trail, the network address, how long the trail is kept). They live in the same +`tenant_time_rules` setting as the rules (`TimeRules::FEATURES`, `TimeRules::LOGGING`) and apply to the +whole workspace, never one store. Every default is what Rupert did before the switch existed; the audit +trail and the network address are new and start off. Off means refused by the server +(`TimeRules.require_feature!`), not only hidden. The full table is in +[the multi-workspace audit](../audits/2026-11-10-time-multi-tenant-audit.md). + +## Timesheet hours are paid hours (2026-11-10) + +Importing clock shifts into a timesheet takes each day's **paid** hours: the punch inside that day less +unpaid breaks (meals by default, per the store's break rules). Before this the import took gross hours +while the Pay page, overtime checks and accrual took paid hours, so the payroll export disagreed with what +the worker saw. Sheets approved before the change are not rewritten. ## Retention, PII, offboarding diff --git a/ios/AppStore/checklist.md b/ios/AppStore/checklist.md index fa11dfc86..8eecce47b 100644 --- a/ios/AppStore/checklist.md +++ b/ios/AppStore/checklist.md @@ -78,6 +78,19 @@ own staff). Messaging and chat: **Yes**. Result: **13+**. 7. **System Google sign-in sheet** (ASWebAuthenticationSession) with a PKCE code handoff. 8. **Native top bar** that follows the page's colours in all five appearances. +## Multi-workspace changes (2026-11-10) + +These Swift changes are in `main` but in no uploaded build yet: **rebuild before submitting** (a build made +before 2026-11-10 lacks them, and an Xcode archive needs `CURRENT_PROJECT_VERSION` in `RupertApp/project.yml` +raised for every upload; `ship.sh` stamps its own). + +| Item | Status | Where | +|---|---|---| +| Settings → **Switch workspace**: signs out, forgets the push token, clears the web views' Rupert data, returns to the control plane | Done; walk it once on a device with two accounts | `Sources/Rupert/AppSettings.swift`, `SceneController.swift` | +| Push registration sends the web views' session cookie itself; the token is forgotten without a session and when moving to another workspace | Done; confirm one real notification after the APNs key (item 5) is set | `Sources/Rupert/PushRegistration.swift` | +| A notification opens only a path on the current workspace; the workspace handoff accepts only this app's control plane | Done, unit tested | `PushRegistration.swift`, `WorkspaceHandoff.swift`, `Tests/RupertTests` | +| Server: a time feature a workspace turned off shows a plain "turned off" message in the app instead of the page | Done | `docs/audits/2026-11-10-time-multi-tenant-audit.md` | + ## After the other time & pay streams merge - Regenerate `screenshots/` (item 11) and the `docs/time-and-pay/screenshots/ios-*.png` set diff --git a/ios/README.md b/ios/README.md index e4a1f17df..e8b15cb0c 100644 --- a/ios/README.md +++ b/ios/README.md @@ -13,11 +13,19 @@ A native iPhone and iPad app for Rupert, built on [Hotwire Native](https://nativ | Tab | Page | |---|---| +| Time | `/employee`: the clock, today's shift, schedule, time off and pay | | Home | `/login`, which sends a signed-in person to their start page | | Team Chat | `/chat` | | Ask Rupert | `/ask_rupert` | | Notifications | `/notifications` | -| Search | `/search` | + +Search stays in every page's top bar (five tabs is iOS's limit before a "More" tab). + +**More than one workspace, or more than one person, on a phone.** The person button → **Switch workspace** +signs out, tells Rupert to stop sending this phone's notifications, clears the web views' Rupert cookies and +cached pages, and returns to the control plane's sign-in page. A push token belongs to one account at a time: +registering it for another account (in any workspace) stops the previous registration on the server. A +notification only ever opens a path on the workspace the app is pointed at. Each tab keeps its own back stack. Forms (`…/new`, `…/edit`) open as sheets. Every `*.rupertpos.com` page stays in the app (including links Rupert marks `data-turbo="false"`); @@ -50,7 +58,7 @@ This is separate from `mobile/`, the Expo staff timesheet app. |---|---|---| | Bundle ID | `xtool.yml` | `com.rupertpos.Rupert` | | Control plane | `Info.plist` `RupertControlPlaneURL` | `https://pos.rupertpos.com` | -| Version | `Info.plist` `CFBundleShortVersionString` | `1.0.0` (the build number is stamped at build time) | +| Version | `Info.plist` `CFBundleShortVersionString` | `1.1.0` (the build number is stamped at build time by `ship.sh`; an Xcode archive uses `CURRENT_PROJECT_VERSION` in `RupertApp/project.yml`, which must go up for every upload) | | Minimum iOS | `Package.swift` | 17 | ## Build on Linux (no Mac) diff --git a/ios/Sources/Rupert/AppSettings.swift b/ios/Sources/Rupert/AppSettings.swift index 023d849be..17fc20503 100644 --- a/ios/Sources/Rupert/AppSettings.swift +++ b/ios/Sources/Rupert/AppSettings.swift @@ -7,6 +7,9 @@ struct AppSettingsView: View { /// Opens a Rupert page in the current tab (the sheet closes first). var open: (URL) -> Void var close: () -> Void + /// Leaves this workspace for the Rupert sign-in page (the sheet closes first). + var switchWorkspace: () -> Void = {} + @State private var confirmSwitch = false @State private var lockEnabled = AppLock.shared.isEnabled @State private var lockAvailable = AppLock.isAvailable @@ -26,8 +29,20 @@ struct AppSettingsView: View { : "Set a passcode on this iPhone to lock Rupert.") } - Section("Workspace") { + Section { LabeledContent("Connected to", value: Instance.current.host ?? "") + Button("Switch workspace") { confirmSwitch = true } + .accessibilityIdentifier("switch-workspace") + .confirmationDialog("Switch workspace?", isPresented: $confirmSwitch, titleVisibility: .visible) { + Button("Sign out and switch", role: .destructive, action: switchWorkspace) + Button("Cancel", role: .cancel) {} + } message: { + Text("Signs you out of \(Instance.current.host ?? "this workspace") on this iPhone and stops its notifications. Sign in again to open another workspace.") + } + } header: { + Text("Workspace") + } footer: { + Text("Use this when another person, or another shop, uses this iPhone.") } Section("Help") { @@ -75,6 +90,9 @@ struct AppSettingsView: View { enum AppSettings { /// Set by SceneController: open a URL in the selected tab. @MainActor static var openInApp: ((URL) -> Void)? + /// Set by SceneController: sign out here, forget this phone's push registration, clear the web + /// views' Rupert data and start again on the control plane. + @MainActor static var switchWorkspace: (() -> Void)? @MainActor static func addButton(to controller: UIViewController) { @@ -95,7 +113,8 @@ enum AppSettings { open: { url in host?.dismiss(animated: true) { openInApp?(url) } }, - close: { host?.dismiss(animated: true) } + close: { host?.dismiss(animated: true) }, + switchWorkspace: { host?.dismiss(animated: true) { switchWorkspace?() } } ) let sheet = UIHostingController(rootView: view) host = sheet diff --git a/ios/Sources/Rupert/Instance.swift b/ios/Sources/Rupert/Instance.swift index 0aac6a3ec..a5cd11994 100644 --- a/ios/Sources/Rupert/Instance.swift +++ b/ios/Sources/Rupert/Instance.swift @@ -45,6 +45,16 @@ enum Instance { } } + /// Leave the current workspace for the control plane, where anyone signs in and is handed to + /// their own workspace. Used by Settings → Switch workspace, so a second person (or the same + /// person with another shop) is never stuck on the previous workspace's sign-in page. + static func leaveWorkspace() { + UserDefaults.standard.removeObject(forKey: currentKey) + } + + /// True when the app is pointed at an isolated workspace rather than the control plane. + static var isOnWorkspaceInstance: Bool { current.host != controlPlane.host } + static var appVersion: String { let info = Bundle.main.infoDictionary let version = info?["CFBundleShortVersionString"] as? String ?? "1.0" diff --git a/ios/Sources/Rupert/PushRegistration.swift b/ios/Sources/Rupert/PushRegistration.swift index 7db230897..1a2929369 100644 --- a/ios/Sources/Rupert/PushRegistration.swift +++ b/ios/Sources/Rupert/PushRegistration.swift @@ -1,5 +1,6 @@ import UIKit import UserNotifications +import WebKit /// Push notifications for time & pay: schedule posted or changed, a request decided, a shift offered, /// a forgotten clock-out, a break due. Rupert sends them through APNs (`PushNotifier` on the server) @@ -14,6 +15,7 @@ final class PushRegistration: NSObject, UNUserNotificationCenterDelegate { private let askedKey = "rupert.push.asked" private let tokenKey = "rupert.push.tokenDigest" + private let hostKey = "rupert.push.registeredHost" private var pendingURL: URL? /// Called by SceneController: open a URL in the Time tab. @@ -91,21 +93,54 @@ final class PushRegistration: NSObject, UNUserNotificationCenterDelegate { "environment": Self.isSandboxBuild ? "sandbox" : "production", ] request.httpBody = try? JSONSerialization.data(withJSONObject: body) - // The web views' session cookie lives in the shared cookie storage; the task shares it. - URLSession.shared.dataTask(with: request) { [weak self] data, _, _ in - guard let self, let data, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let digest = json["token_digest"] as? String else { return } - UserDefaults.standard.set(digest, forKey: self.tokenKey) - }.resume() + // The session cookie lives in the web views' own store, which URLSession does not read: + // copy the cookies for this instance onto the request, so the token is registered to the + // person who is signed in here and to nobody else. + Self.withWebCookies(request) { [weak self] request in + URLSession.shared.dataTask(with: request) { data, _, _ in + guard let self, let data, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let digest = json["token_digest"] as? String else { return } + UserDefaults.standard.set(digest, forKey: self.tokenKey) + UserDefaults.standard.set(request.url?.host, forKey: self.hostKey) + }.resume() + } } - /// Signed out: tell Rupert to stop sending to this phone. + /// Signed out, or moving to another workspace: tell the Rupert this phone registered with to + /// stop sending to it. Needs no session (the digest of the phone's own token is the proof), so + /// it works after the sign-out it follows. The next person is asked about notifications afresh. func forget() { + UserDefaults.standard.removeObject(forKey: askedKey) guard let digest = UserDefaults.standard.string(forKey: tokenKey) else { return } - var request = URLRequest(url: Instance.url("/native/push_devices/\(digest)")) + let registeredOn = UserDefaults.standard.string(forKey: hostKey).flatMap { Instance.httpsOrigin("https://\($0)") } + var request = URLRequest(url: Instance.url("/native/push_devices/\(digest)", on: registeredOn ?? Instance.current)) request.httpMethod = "DELETE" + request.httpShouldHandleCookies = false URLSession.shared.dataTask(with: request).resume() UserDefaults.standard.removeObject(forKey: tokenKey) + UserDefaults.standard.removeObject(forKey: hostKey) + } + + /// Adds the web views' cookies for the request's host to the request. + static func withWebCookies(_ request: URLRequest, then send: @escaping (URLRequest) -> Void) { + DispatchQueue.main.async { + WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in + var request = request + let fields = HTTPCookie.requestHeaderFields(with: cookies.filter { cookieApplies($0, to: request.url) }) + fields.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) } + request.httpShouldHandleCookies = false + send(request) + } + } + } + + /// Whether a cookie belongs on a request to `url`: same host or a parent domain, and never a + /// Secure cookie over plain http. + static func cookieApplies(_ cookie: HTTPCookie, to url: URL?) -> Bool { + guard let url, let host = url.host?.lowercased() else { return false } + if cookie.isSecure && url.scheme != "https" { return false } + let domain = cookie.domain.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ".")) + return !domain.isEmpty && (host == domain || host.hasSuffix("." + domain)) } /// Debug and TestFlight-less builds talk to the APNs sandbox; App Store builds to production. @@ -137,12 +172,13 @@ final class PushRegistration: NSObject, UNUserNotificationCenterDelegate { completionHandler() } - /// Where a notification wants to go: Rupert's `url` field on the current instance. Other hosts - /// and schemes are ignored (a payload is data, not a command). + /// Where a notification wants to go: Rupert's `url` field, a path on the current instance. + /// Anything that names a host or a scheme is ignored, another Rupert workspace's address + /// included: a payload is data, not a command, and it never moves the app between workspaces. static func url(in userInfo: [AnyHashable: Any]) -> URL? { - guard let path = userInfo["url"] as? String, !path.isEmpty else { return nil } + guard let path = userInfo["url"] as? String, path.hasPrefix("/"), !path.hasPrefix("//"), !path.contains("\\") else { return nil } let url = Instance.url(path) - return Instance.isRupert(url) || url.host == Instance.current.host ? url : nil + return url.host == Instance.current.host ? url : nil } /// A tap that arrived before the window existed. diff --git a/ios/Sources/Rupert/SceneController.swift b/ios/Sources/Rupert/SceneController.swift index e35ef4d63..eb0103e2f 100644 --- a/ios/Sources/Rupert/SceneController.swift +++ b/ios/Sources/Rupert/SceneController.swift @@ -1,3 +1,4 @@ +import WebKit import HotwireNative import UIKit @@ -39,6 +40,7 @@ final class SceneController: UIResponder, UIWindowSceneDelegate { WorkspaceHandoff.shared.onOpened = { [weak self] origin, landing in self?.show(instance: origin, landing: landing) } + AppSettings.switchWorkspace = { [weak self] in self?.switchWorkspace() } SessionWatcher.shared.onChange = { [weak self] state in // Signed out: Rupert must stop pushing to this phone. if state == .signedOut { PushRegistration.shared.forget() } @@ -91,6 +93,23 @@ final class SceneController: UIResponder, UIWindowSceneDelegate { } } + /// Settings → Switch workspace: stop this phone's notifications, drop every Rupert cookie and + /// cached page the web views hold (the session on the workspace and on the control plane), and + /// start again on the control plane's sign-in page. Nothing of the last person is left on screen. + private func switchWorkspace() { + PushRegistration.shared.forget() + let store = WKWebsiteDataStore.default() + store.fetchDataRecords(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes()) { [weak self] records in + store.removeData(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(), for: records) { + Instance.leaveWorkspace() + SessionWatcher.shared.set(.signedOut) + guard let self else { return } + self.tabBarController.load(HotwireTab.all(on: Instance.current)) + self.select(0) + } + } + } + /// Point every tab at `instance` (after a sign-in or a workspace handoff) and open `landing` /// on Home. Each tab starts over, since they all showed the sign-in page or the control plane. private func show(instance: URL, landing: URL) { diff --git a/ios/Sources/Rupert/WorkspaceHandoff.swift b/ios/Sources/Rupert/WorkspaceHandoff.swift index cd6082c28..030432a6e 100644 --- a/ios/Sources/Rupert/WorkspaceHandoff.swift +++ b/ios/Sources/Rupert/WorkspaceHandoff.swift @@ -30,11 +30,18 @@ final class WorkspaceHandoff { showProblem("Rupert sent an incomplete workspace link. Try Open my workspace again.") return } + // Only this app's own control plane hands out workspaces; a link naming another one is not trusted. + guard control.host?.lowercased() == Instance.controlPlane.host?.lowercased(), control.port == Instance.controlPlane.port else { + showProblem("That workspace link did not come from Rupert. Open your workspace from the Rupert sign-in page.") + return + } inFlight = true Task { defer { inFlight = false } do { let landing = try await exchange(handoff: handoff, origin: origin, control: control) + // The phone's push registration belongs to the instance it is leaving. + if origin.host != Instance.current.host { PushRegistration.shared.forget() } Instance.setCurrent(origin) onOpened?(origin, landing) } catch { diff --git a/ios/Tests/RupertTests/AppReviewReadinessTests.swift b/ios/Tests/RupertTests/AppReviewReadinessTests.swift index 4b60d05d3..825f102f1 100644 --- a/ios/Tests/RupertTests/AppReviewReadinessTests.swift +++ b/ios/Tests/RupertTests/AppReviewReadinessTests.swift @@ -170,10 +170,33 @@ final class TimeTabTests: XCTestCase { XCTAssertEqual(PushRegistration.url(in: ["url": "/employee/schedule"])?.path, "/employee/schedule") XCTAssertEqual(PushRegistration.url(in: ["url": "/employee/schedule"])?.host, Instance.current.host) XCTAssertNil(PushRegistration.url(in: ["url": "https://evil.example/phish"])) + XCTAssertNil(PushRegistration.url(in: ["url": "https://other-shop.rupertpos.com/employee"]), "never another workspace's address") + XCTAssertNil(PushRegistration.url(in: ["url": "//evil.example/phish"])) + XCTAssertNil(PushRegistration.url(in: ["url": "employee/schedule"])) XCTAssertNil(PushRegistration.url(in: ["url": ""])) XCTAssertNil(PushRegistration.url(in: [:])) } + func testSessionCookiesGoOnlyToTheirOwnHost() { + func cookie(_ domain: String, secure: Bool = true) -> HTTPCookie { + HTTPCookie(properties: [.name: "_rupert_session", .value: "x", .domain: domain, .path: "/", .secure: secure ? "TRUE" : "FALSE"])! + } + let shop = URL(string: "https://north.rupertpos.com/native/push_devices") + XCTAssertTrue(PushRegistration.cookieApplies(cookie("north.rupertpos.com"), to: shop)) + XCTAssertTrue(PushRegistration.cookieApplies(cookie(".rupertpos.com"), to: shop)) + XCTAssertFalse(PushRegistration.cookieApplies(cookie("south.rupertpos.com"), to: shop), "another workspace's session is never sent") + XCTAssertFalse(PushRegistration.cookieApplies(cookie("evilrupertpos.com"), to: URL(string: "https://rupertpos.com/"))) + XCTAssertFalse(PushRegistration.cookieApplies(cookie("north.rupertpos.com"), to: URL(string: "http://north.rupertpos.com/"))) + } + + func testLeavingAWorkspaceReturnsToTheControlPlane() { + Instance.setCurrent(URL(string: "https://north.example.org")!) + XCTAssertTrue(Instance.isOnWorkspaceInstance) + Instance.leaveWorkspace() + XCTAssertFalse(Instance.isOnWorkspaceInstance) + XCTAssertEqual(Instance.current.host, Instance.controlPlane.host) + } + func testBundledPathConfigurationOpensTimeFormsAsSheets() throws { let url = try XCTUnwrap(Bundle.module.url(forResource: "path-configuration", withExtension: "json")) let json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: url)) as? [String: Any]) diff --git a/mobile/.env.example b/mobile/.env.example index 623c60dae..af39430eb 100644 --- a/mobile/.env.example +++ b/mobile/.env.example @@ -1,4 +1,6 @@ -EXPO_PUBLIC_RUPERT_URL=https://herbalhealers.rupertpos.com +# Optional: prefill the sign-in screen with one workspace's address. Leave unset for a build that +# serves every workspace (staff type their own workspace address once). +# EXPO_PUBLIC_RUPERT_URL=https://your-workspace.example.com # Set these in the build environment after verifying ownership. # EXPO_OWNER= # EAS_PROJECT_ID= diff --git a/mobile/App.tsx b/mobile/App.tsx index 736f5c581..9bf593fce 100644 --- a/mobile/App.tsx +++ b/mobile/App.tsx @@ -38,7 +38,7 @@ function EmployeeApp() { if (value) { const saved = JSON.parse(value) as Login; instanceOrigin(saved.origin); setOrigin(saved.origin); setLogin(saved); } }).catch(() => setMessage('Sign in to reconnect your staff account.')).finally(() => setReady(true)); }, []); async function clearSession() { - loginRef.current = null; setLogin(null); setSheets([]); setSheet(null); setShifts([]); setCreating(false); setCorrection(null); setPassword(''); + loginRef.current = null; setLogin(null); setSheets([]); setSheet(null); setShifts([]); setCreating(false); setCorrection(null); setPassword(''); setName(''); setWorkspace(''); await SecureStore.deleteItemAsync(storageKey); } async function api(path: string, method = 'GET', body?: unknown, credentials = loginRef.current) { @@ -78,7 +78,7 @@ function EmployeeApp() { const response = await fetch(`${cleanOrigin}/api/v1/employee/session`, { method: 'POST', credentials: 'omit', redirect: 'error', signal: controller.signal, headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ email: email.trim(), password, device_name: `${Platform.OS === 'ios' ? 'iPhone / iPad' : 'Android'} app` }) }); const data = await response.json(); - if (!response.ok) throw new Error(data.message || (response.status === 429 ? 'Too many attempts. Try again in 15 minutes.' : 'Check your email and password.')); + if (!response.ok) throw new Error(data.message || (response.status === 429 ? 'Too many attempts. Try again in 15 minutes.' : 'Check your email and password, and that the address is your own workspace.')); const credentials = { origin: cleanOrigin, token: data.token }; await SecureStore.setItemAsync(storageKey, JSON.stringify(credentials), storageOptions); setPassword(''); setLogin(credentials); diff --git a/mobile/README.md b/mobile/README.md index a6684adb0..27172d6b3 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -66,7 +66,7 @@ See [staff timesheets](../docs/employee-timesheets.md) for account provisioning, The supported SDK 58 patch and lockfile now use Expo **58.0.17**, with SDK-compatible TypeScript **6.0.3**. `npm run check` runs the online Expo compatibility check, TypeScript validation, staff boundaries, exploit regression tests, verified security backports and the fail-closed advisory gate. CI additionally runs Expo Doctor and exports **iOS and Android with Hermes bytecode** on x86-64 Ubuntu. Its `employee-native-js-exports-` artifact contains JavaScript/assets for verification; it is not an installable or signed app. Dependency checks remain enabled. -All EAS profiles prefill the staff app with `https://herbalhealers.rupertpos.com`. The instance field stays editable, and this public URL contains no staff identity or credentials. For a separate staging build, change the selected profile's `EXPO_PUBLIC_RUPERT_URL` before building. The development profile uses the EAS `development` environment, preview uses `preview`, and store builds use `production`. +No EAS profile prefills a workspace: one build serves every workspace, and staff type their own workspace's address once (it is remembered with the sign-in). A sign-in on another workspace's address is refused with a message that says to check the address. To build for a single workspace, set `EXPO_PUBLIC_RUPERT_URL` in the selected profile's `env`; the field stays editable and the public URL contains no staff identity or credentials. The development profile uses the EAS `development` environment, preview uses `preview`, and store builds use `production`. | Profile | iPhone / iPad | Android | Intended handoff | | --- | --- | --- | --- | diff --git a/mobile/eas.json b/mobile/eas.json index a30477dc0..c1d5284f5 100644 --- a/mobile/eas.json +++ b/mobile/eas.json @@ -12,10 +12,7 @@ "android": { "buildType": "apk" }, - "environment": "development", - "env": { - "EXPO_PUBLIC_RUPERT_URL": "https://herbalhealers.rupertpos.com" - } + "environment": "development" }, "preview": { "distribution": "internal", @@ -23,9 +20,6 @@ "buildType": "apk" }, "environment": "preview", - "env": { - "EXPO_PUBLIC_RUPERT_URL": "https://herbalhealers.rupertpos.com" - }, "credentialsSource": "remote", "ios": { "simulator": false @@ -34,9 +28,6 @@ "production": { "autoIncrement": true, "environment": "production", - "env": { - "EXPO_PUBLIC_RUPERT_URL": "https://herbalhealers.rupertpos.com" - }, "credentialsSource": "remote", "distribution": "store", "android": { diff --git a/web/app/controllers/api/v1/employee_base_controller.rb b/web/app/controllers/api/v1/employee_base_controller.rb index 91db97440..65871808f 100644 --- a/web/app/controllers/api/v1/employee_base_controller.rb +++ b/web/app/controllers/api/v1/employee_base_controller.rb @@ -7,6 +7,8 @@ class EmployeeBaseController < ActionController::API around_action :isolated_context before_action :require_employee_app_enabled before_action :authenticate_employee + around_action :use_business_zone + rescue_from TimeRules::FeatureDisabled, with: :feature_disabled rescue_from ActiveRecord::RecordNotFound, with: :not_found rescue_from Pundit::NotAuthorizedError, with: :forbidden rescue_from TimesheetWorkflow::Error, ActiveRecord::RecordInvalid, AASM::InvalidTransition, ActiveRecord::StaleObjectError, with: :invalid @@ -42,6 +44,23 @@ def authenticate_employee Current.tenant = @employee_session.tenant end + # The workspace's clock for day, week and pay-period boundaries (set after the token names the workspace). + def use_business_zone(&) + name = Current.tenant&.time_zone.presence + return yield unless name + + Time.use_zone(BusinessClock.display_zone(name), &) + end + + def require_time_feature(*names) + names.each { |name| TimeRules.require_feature!(name) } + end + + # A time feature an administrator turned off (Settings → Time rules). + def feature_disabled(error) + render json: { error: 'feature_disabled', feature: error.feature, message: error.message }, status: :forbidden + end + def not_found render json: { error: 'not_found' }, status: :not_found end diff --git a/web/app/controllers/api/v1/employee_clock_controller.rb b/web/app/controllers/api/v1/employee_clock_controller.rb index ac1bcf082..1189edcfb 100644 --- a/web/app/controllers/api/v1/employee_clock_controller.rb +++ b/web/app/controllers/api/v1/employee_clock_controller.rb @@ -4,13 +4,15 @@ module Api module V1 class EmployeeClockController < EmployeeBaseController rescue_from TimeClockService::Error, with: :invalid + before_action -> { TimeClockService.require_channel!('api') }, only: %i[create update start_break end_break] + before_action { Current.metadata[:punch_ip] = request.remote_ip } def index shifts = ClockShift.where(employee_id: worker.id, user_id: Current.user.id).recent.includes(:breaks).limit(100) render json: { data: shifts.map { |s| s.as_json(only: %i[id started_at ended_at source location_id]).merge('breaks' => s.breaks.as_json(only: %i[id kind paid started_at ended_at])) } } end def create - shift = TimeClockService.clock_in(user: Current.user, request_token: params[:request_token].to_s) + shift = TimeClockService.clock_in(user: Current.user, request_token: params[:request_token].to_s, source: 'api') render json: { data: shift.as_json(only: %i[id started_at ended_at]) }, status: :created end diff --git a/web/app/controllers/api/v1/employee_leave_requests_controller.rb b/web/app/controllers/api/v1/employee_leave_requests_controller.rb index 09bd97267..69039a17a 100644 --- a/web/app/controllers/api/v1/employee_leave_requests_controller.rb +++ b/web/app/controllers/api/v1/employee_leave_requests_controller.rb @@ -4,6 +4,7 @@ module Api module V1 # The worker's own time-off requests and balances. class EmployeeLeaveRequestsController < EmployeeBaseController + before_action -> { require_time_feature(:time_off) } def index requests = worker.leave_requests.order(starts_on: :desc).limit(100) balances = People::LeaveRequest::LEAVE_TYPES.reject { |t| t == 'unpaid' }.map { |type| worker.leave_balance(type, Date.current.year) } diff --git a/web/app/controllers/api/v1/employee_schedule_controller.rb b/web/app/controllers/api/v1/employee_schedule_controller.rb index 86f18be9d..d12db71c7 100644 --- a/web/app/controllers/api/v1/employee_schedule_controller.rb +++ b/web/app/controllers/api/v1/employee_schedule_controller.rb @@ -5,6 +5,7 @@ module V1 # GET /api/v1/employee/schedule?week=YYYY-MM-DD — the worker's posted shifts and the open shifts # they could pick up for one Monday–Sunday week. Drafts are never returned. class EmployeeScheduleController < EmployeeBaseController + before_action -> { require_time_feature(:scheduling) } def index week = (params[:week].present? ? Date.iso8601(params[:week].to_s) : Date.current).beginning_of_week mine = worker.scheduled_shifts.live.published.between(week, week + 6).chronological.includes(:location, :position) diff --git a/web/app/controllers/api/v1/employee_tips_controller.rb b/web/app/controllers/api/v1/employee_tips_controller.rb index 8c5cc4c55..dc08f3faf 100644 --- a/web/app/controllers/api/v1/employee_tips_controller.rb +++ b/web/app/controllers/api/v1/employee_tips_controller.rb @@ -4,6 +4,8 @@ module Api module V1 # The staff app's pay summary and cash tip declarations. Own records only. class EmployeeTipsController < EmployeeBaseController + before_action -> { require_time_feature(:pay_view) }, only: :show + before_action -> { require_time_feature(:tips_view) }, only: :create rescue_from Tips::Declare::Error, with: :invalid # GET /api/v1/employee/pay?period=YYYY-MM-DD @@ -11,14 +13,20 @@ def show date = params[:period].present? ? Date.iso8601(params[:period].to_s) : Date.current pay = EmployeePayWeek.new(employee: worker, week_of: date) period = PayPeriod.find_by(starts_on: pay.start_date) - render json: { data: { + tips_on = TimeRules.feature?(:tips_view) + data = { period_start: pay.start_date, period_end: pay.end_date, final: period&.locked? || false, next_payday: pay.end_date + TimeRules.for(worker.location_ids.first).payday_days_after_period_end, paid_hours: pay.paid_hours.round(2), hourly_rate_cents: pay.hourly_rate_cents, base_cents: pay.base_cents, commission_cents: pay.commission_cents, tip_cents: pay.tip_cents, card_tip_cents: pay.card_tip_cents, cash_tip_cents: pay.cash_tip_cents, estimated_tip_cents: pay.estimated_tip_cents, final_tip_cents: pay.final_tip_cents, total_cents: pay.base_cents && pay.total_cents, tips: pay.tip_allocations.map { |a| a.as_json(only: %i[id occurred_at business_date tip_method status amount_cents gross_tip_cents share_count note]) } - } } + } + # Tips for staff turned off: the tip figures leave the answer (the total is base pay and commission). + unless tips_on + data = data.except(:tip_cents, :card_tip_cents, :cash_tip_cents, :estimated_tip_cents, :final_tip_cents).merge(tips: [], total_cents: pay.base_cents && (pay.total_cents - pay.tip_cents)) + end + render json: { data: data } rescue Date::Error render json: { error: 'invalid_request', message: 'period must be a date' }, status: :unprocessable_entity end diff --git a/web/app/controllers/api/v1/employee_trades_controller.rb b/web/app/controllers/api/v1/employee_trades_controller.rb index 305099773..fa8796b7e 100644 --- a/web/app/controllers/api/v1/employee_trades_controller.rb +++ b/web/app/controllers/api/v1/employee_trades_controller.rb @@ -4,6 +4,7 @@ module Api module V1 # Offer, accept and cancel shift trades from the staff app (same workflow as the web pages). class EmployeeTradesController < EmployeeBaseController + before_action -> { require_time_feature(:scheduling, :shift_trades) } rescue_from People::ShiftTradeWorkflow::Error, with: :invalid # POST { kind: give|swap|pickup, shift_id, counter_shift_id?, note? } diff --git a/web/app/controllers/application_controller.rb b/web/app/controllers/application_controller.rb index b75f5a3c9..60c3e7f8a 100644 --- a/web/app/controllers/application_controller.rb +++ b/web/app/controllers/application_controller.rb @@ -2,9 +2,11 @@ class ApplicationController < ActionController::Base layout :workspace_layout + rescue_from TimeRules::FeatureDisabled, with: :time_feature_disabled before_action :reject_preview_mutation before_action :enforce_workspace_hostname before_action :set_current_context + around_action :use_business_zone around_action :switch_locale before_action :require_login before_action :identify_trace_session @@ -177,6 +179,27 @@ def valid_browser_session?(user) end end + # Every request runs on the workspace's clock (Tenant#time_zone, else the instance default), so + # "today", week and pay-period boundaries and displayed times are the business's, not the server's. + def use_business_zone(&) + name = Current.tenant&.time_zone.presence + return yield unless name + + Time.use_zone(BusinessClock.display_zone(name), &) + end + + # A time feature an administrator turned off (Settings → Time rules). + def time_feature_disabled(error) + respond_to do |format| + format.html { redirect_to(Current.user&.employee? || controller_path.start_with?('employee_') ? employee_app_path : root_path, alert: error.message) } + format.any { render json: { ok: false, error: 'feature_disabled', feature: error.feature, message: error.message }, status: :forbidden } + end + end + + def require_time_feature(*names) + names.each { |name| TimeRules.require_feature!(name) } + end + # Staff-facing pages render in the worker's language (People::Employee#locale, 'en' or 'es'); # management pages stay in English. The locale never comes from the request. def switch_locale(&) diff --git a/web/app/controllers/employee_availability_controller.rb b/web/app/controllers/employee_availability_controller.rb index 06bedbbb4..66d3555f9 100644 --- a/web/app/controllers/employee_availability_controller.rb +++ b/web/app/controllers/employee_availability_controller.rb @@ -5,6 +5,7 @@ class EmployeeAvailabilityController < AuthenticatedController layout 'employee' before_action { response.headers['Cache-Control'] = 'no-store' } before_action :require_worker + before_action -> { require_time_feature(:scheduling) } def index @blocks = @worker.availabilities.ordered.to_a diff --git a/web/app/controllers/employee_pay_controller.rb b/web/app/controllers/employee_pay_controller.rb index 3105bf3f2..c45674f14 100644 --- a/web/app/controllers/employee_pay_controller.rb +++ b/web/app/controllers/employee_pay_controller.rb @@ -7,6 +7,8 @@ class EmployeePayController < AuthenticatedController layout 'employee' before_action -> { response.headers['Cache-Control'] = 'no-store' } before_action :require_worker + before_action -> { require_time_feature(:pay_view) }, only: :show + before_action -> { require_time_feature(:pay_view, :tips_view) }, only: :declare_tips # GET /employee/pay?period=YYYY-MM-DD (any date in the pay period; today by default) def show @@ -29,6 +31,7 @@ def show @payslips = @worker.payslips.joins(:pay_run).where(pay_runs: { tenant_id: Current.tenant_id, status: %w[finalized paid] }) .where('pay_runs.period_end >= ? AND pay_runs.period_start <= ?', @pay.start_date, @pay.end_date).includes(:pay_run).order('pay_runs.period_start DESC') @declarable_shifts = @shifts.select(&:closed?).last(7).reverse + @tips_on = TimeRules.feature?(:tips_view) end # POST /employee/tips/declare — cash tips for one of my shifts (or a day). diff --git a/web/app/controllers/employee_trades_controller.rb b/web/app/controllers/employee_trades_controller.rb index b38f1b970..04e9cae1b 100644 --- a/web/app/controllers/employee_trades_controller.rb +++ b/web/app/controllers/employee_trades_controller.rb @@ -5,6 +5,7 @@ class EmployeeTradesController < AuthenticatedController layout 'employee' before_action { response.headers['Cache-Control'] = 'no-store' } before_action :require_worker + before_action -> { require_time_feature(:scheduling, :shift_trades) } rescue_from People::ShiftTradeWorkflow::Error, with: :refused def index diff --git a/web/app/controllers/employee_workspace_controller.rb b/web/app/controllers/employee_workspace_controller.rb index f59d985bc..7ee447d51 100644 --- a/web/app/controllers/employee_workspace_controller.rb +++ b/web/app/controllers/employee_workspace_controller.rb @@ -6,6 +6,8 @@ class EmployeeWorkspaceController < AuthenticatedController layout 'employee' before_action :private_response before_action :require_worker + before_action -> { require_time_feature(:scheduling) }, only: :schedule + before_action -> { require_time_feature(:time_off) }, only: %i[time_off request_time_off] rescue_from Date::Error, ArgumentError, with: :invalid_range # GET /employee/reports?range=…&from=YYYY-MM-DD&to=YYYY-MM-DD — the signed-in worker's own diff --git a/web/app/controllers/native/push_devices_controller.rb b/web/app/controllers/native/push_devices_controller.rb index b971cb571..ab1f112fb 100644 --- a/web/app/controllers/native/push_devices_controller.rb +++ b/web/app/controllers/native/push_devices_controller.rb @@ -7,6 +7,9 @@ module Native # with the session cookie, never from a page, so there is no CSRF token to check. class PushDevicesController < ApplicationController skip_forgery_protection + # Forgetting a device needs no session: the app calls it after the sign-out it follows, and the + # digest of the phone's own push token (256 bits only that phone and this server know) is the proof. + skip_before_action :require_login, only: :destroy before_action { response.headers['Cache-Control'] = 'no-store' } def create @@ -21,8 +24,7 @@ def create end def destroy - device = PushDevice.find_by(user_id: Current.user.id, token_digest: params[:token_digest].to_s) - device&.disable!('signed out') + PushDevice.forget_token!(params[:token_digest]) head :no_content end end diff --git a/web/app/controllers/people/availabilities_controller.rb b/web/app/controllers/people/availabilities_controller.rb index cb60c1779..8df1f7994 100644 --- a/web/app/controllers/people/availabilities_controller.rb +++ b/web/app/controllers/people/availabilities_controller.rb @@ -4,6 +4,7 @@ module People # When each person can work, by weekday. Managers edit anyone here; a worker edits their own # from the Schedule tab (EmployeeAvailabilityController). class AvailabilitiesController < AuthenticatedController + before_action -> { require_time_feature(:scheduling) } before_action { authorize(:module, :schedule_write?) } def index diff --git a/web/app/controllers/people/schedule_templates_controller.rb b/web/app/controllers/people/schedule_templates_controller.rb index 2fc5cbbe4..01a41f21f 100644 --- a/web/app/controllers/people/schedule_templates_controller.rb +++ b/web/app/controllers/people/schedule_templates_controller.rb @@ -3,6 +3,7 @@ module People # Reusable weeks for the schedule builder: save the shown week as a template, apply one to a week. class ScheduleTemplatesController < AuthenticatedController + before_action -> { require_time_feature(:scheduling) } before_action { authorize(:module, :schedule_write?) } before_action :set_template, only: %i[apply destroy] rescue_from Date::Error, with: :invalid_week diff --git a/web/app/controllers/people/scheduled_shifts_controller.rb b/web/app/controllers/people/scheduled_shifts_controller.rb index b019c5a35..bdb8cb552 100644 --- a/web/app/controllers/people/scheduled_shifts_controller.rb +++ b/web/app/controllers/people/scheduled_shifts_controller.rb @@ -4,6 +4,7 @@ module People # The schedule builder: managers plan a Monday–Sunday week on a drag-and-drop grid, post it to # staff, and keep templates. Employees read their own posted shifts in the personal workspace. class ScheduledShiftsController < AuthenticatedController + before_action -> { require_time_feature(:scheduling) } before_action :set_shift, only: %i[edit update destroy cancel] rescue_from Date::Error, with: :invalid_week diff --git a/web/app/controllers/people/shift_trades_controller.rb b/web/app/controllers/people/shift_trades_controller.rb index e668cfb09..2c0a8f41d 100644 --- a/web/app/controllers/people/shift_trades_controller.rb +++ b/web/app/controllers/people/shift_trades_controller.rb @@ -4,6 +4,7 @@ module People # Managers see trades waiting for a decision and approve or deny them. The approvals inbox calls # the same workflow (`People::ShiftTradeWorkflow.review`). class ShiftTradesController < AuthenticatedController + before_action -> { require_time_feature(:scheduling, :shift_trades) } before_action { authorize(:module, :schedule_read?) } before_action :set_trade, only: %i[show approve deny] rescue_from People::ShiftTradeWorkflow::Error, with: :refused diff --git a/web/app/controllers/people/team_schedule_controller.rb b/web/app/controllers/people/team_schedule_controller.rb index d37356438..705b17062 100644 --- a/web/app/controllers/people/team_schedule_controller.rb +++ b/web/app/controllers/people/team_schedule_controller.rb @@ -11,6 +11,12 @@ def index @week = parse_week(params[:week]) || Date.current.beginning_of_week @week_days = (@week..@week + 6).to_a @employees = People::Employee.where(status: 'active').order(:last_name, :first_name) + # Everyone's hours are for reviewers (within their stores); anyone else sees only their own row. + if Current.user.can_review_timesheets? || (Current.user.can_manage_accounts? && Current.user.can?('timesheets.read')) + @employees = People::StoreScope.employees_for(Current.user, base: @employees) + else + @employees = @employees.where(id: TimeClockService.worker_for(Current.user, include_on_leave: true)&.id) + end # Store filter (#469): punches carry no store, so it narrows the list to people # planned at that store this week; their hours still cover every store. @locations = Location.for_shifts.to_a diff --git a/web/app/controllers/pin_clock_controller.rb b/web/app/controllers/pin_clock_controller.rb index 0f7172efd..5d5513c24 100644 --- a/web/app/controllers/pin_clock_controller.rb +++ b/web/app/controllers/pin_clock_controller.rb @@ -9,13 +9,17 @@ class PinClockController < ApplicationController skip_before_action :enforce_workspace_location, raise: false before_action -> { response.headers['Cache-Control'] = 'no-store' } before_action :load_device + around_action :use_business_zone # again: load_device may have just named the workspace before_action :require_store + before_action :require_pin_clock, only: %i[punch setup workers] + before_action { Current.metadata[:punch_ip] = request.remote_ip } rescue_from TimeClockService::Error, ActiveRecord::RecordInvalid, with: :punch_error rescue_from ActiveRecord::StatementInvalid, with: :database_error # Full-screen pad for a counter tablet. def kiosk + @kiosk_off = Current.tenant.present? && !TimeRules.feature?(:clock_kiosk) @trusted = @device.present? @can_register = Current.user&.can?('hr.write') @locations = Location.for_shifts.order(:name) if @can_register @@ -64,7 +68,7 @@ def setup # Names for the first-use picker (trusted device or signed-in manager only; employees see just themselves). def workers scope = People::Employee.active.where(pin_digest: nil).ordered - scope = scope.where(user_id: Current.user.id) unless @device || Current.user&.can?('hr.read') + scope = Current.user ? scope.where(user_id: Current.user.id) : scope.none unless @device || Current.user&.can?('hr.read') render json: { data: scope.map { |w| { id: w.id, name: w.name, number: w.employee_number } } } end @@ -93,6 +97,15 @@ def require_store end end + # The pad on the register and the kiosk are separate switches (Settings → Time rules); the pad says + # which it is. With both off no PIN punch is accepted anywhere. + def require_pin_clock + # The first-use name picker serves both pads. + return if action_name == 'workers' && (TimeRules.feature?(:clock_kiosk) || TimeRules.feature?(:clock_pin)) + + TimeRules.require_feature!(ActiveModel::Type::Boolean.new.cast(params[:kiosk]) ? :clock_kiosk : :clock_pin) + end + def worker_for_setup if params[:employee_id].present? && (@device || Current.user&.can?('hr.read')) People::Employee.active.find_by(id: params[:employee_id]) @@ -146,7 +159,9 @@ def fix_options(stale, zone) options end - def pad_key = "pin_pad:#{Current.tenant_id}:#{@device&.id || session.id&.to_s || request.remote_ip}" + # Wrong tries are counted per store device, else per network address: a session id would reset + # with every new cookie jar. + def pad_key = "pin_pad:#{Current.tenant_id}:#{@device ? "device:#{@device.id}" : "ip:#{request.remote_ip}"}" def pad_locked?(rules) Rails.cache.read(pad_key).to_i >= rules.pin_lockout_attempts @@ -160,7 +175,10 @@ def count_wrong_pin! def punch_error(error) message = error.is_a?(ActiveRecord::RecordInvalid) ? error.record.errors.full_messages.to_sentence : error.message - count_wrong_pin! if action_name == 'punch' && message == t('time.clock.wrong_pin') + if action_name == 'punch' && [t('time.clock.wrong_pin'), t('time.clock.untrusted')].include?(message) + count_wrong_pin! + PunchAudit.record('pin_refused', device: @device&.name, register: params[:register_name].presence, location_id: params[:location_id].presence || @device&.location_id) + end respond_to do |format| format.json { render json: { ok: false, error: message }, status: :unprocessable_entity } format.html { redirect_to clock_kiosk_path, alert: message } diff --git a/web/app/controllers/sessions_controller.rb b/web/app/controllers/sessions_controller.rb index 46883a9c0..cf366fd04 100644 --- a/web/app/controllers/sessions_controller.rb +++ b/web/app/controllers/sessions_controller.rb @@ -60,6 +60,8 @@ def create def destroy AccessLogger.record(source: 'logout', status: 'success', request: request, user: Current.user) + # Signing out of the iPhone app stops its notifications at once; the app registers again at the next sign-in. + PushDevice.disable_all_for(Current.user, 'signed out', platform: 'ios') if Current.user && hotwire_native_app? reset_session Current.user = nil redirect_to(login_path, notice: 'Signed out') diff --git a/web/app/controllers/time_clock_controller.rb b/web/app/controllers/time_clock_controller.rb index 63efba125..32ff80f7b 100644 --- a/web/app/controllers/time_clock_controller.rb +++ b/web/app/controllers/time_clock_controller.rb @@ -4,6 +4,8 @@ class TimeClockController < AuthenticatedController before_action -> { response.headers['Cache-Control'] = 'no-store' } before_action :active_context before_action :authorize_reviewer, only: %i[approve_correction reject_correction] + before_action :require_punch_channel, only: %i[clock_in clock_out start_break end_break] + before_action { Current.metadata[:punch_ip] = request.remote_ip } # Self-service punches: gated by active_context and scoped to Current.user's own shifts (TimeClockService # checks the shift owner). Correction reviews are gated by authorize_reviewer (ClockCorrection.reviewer?); # commissions still authorize against the module policy. @@ -34,7 +36,7 @@ def fix_clock_out end def clock_in - shift = TimeClockService.clock_in(user: Current.user, request_token: params[:request_token].to_s, source: params[:source].presence || 'web', + shift = TimeClockService.clock_in(user: Current.user, request_token: params[:request_token].to_s, source: punch_source, location_id: params[:location_id].presence) redirect_to return_path, notice: t('time.clock.confirm.clocked_in', time: shift.started_at.in_time_zone.strftime('%-l:%M %p')) end @@ -98,6 +100,12 @@ def return_path params[:return_to].to_s == 'home' ? employee_app_path : time_clock_path end + # Where this punch comes from is decided by the server, never the form: the iPhone app's own + # browser is 'ios', everything else 'web'. Each has its own switch at Settings → Time rules. + def punch_source = hotwire_native_app? ? 'ios' : 'web' + + def require_punch_channel = TimeClockService.require_channel!(punch_source) + def active_context return if Current.user.active? && Current.tenant&.status == 'active' diff --git a/web/app/controllers/time_rules_settings_controller.rb b/web/app/controllers/time_rules_settings_controller.rb index 5c41d1d68..39a363099 100644 --- a/web/app/controllers/time_rules_settings_controller.rb +++ b/web/app/controllers/time_rules_settings_controller.rb @@ -6,19 +6,31 @@ class TimeRulesSettingsController < AuthenticatedController before_action { authorize(:module, :settings_read?) } + SECTIONS = { 'rules' => TimeRules::RULE_KEYS, 'features' => TimeRules::FEATURES.keys, + 'logging' => TimeRules::LOGGING.keys + %w[punch_audit_retention_years] }.freeze + NOTICES = { 'rules' => 'Time rules saved for %s.', 'features' => 'Saved what is turned on for %s.', 'logging' => 'Punch logging saved for %s.' }.freeze + def show load_rules end + # One form per section: `rules` (per store), `features` and `logging` (workspace-wide). An unchecked + # box is not posted, so only the posted section's checkboxes are read as "off". def update authorize(:module, :time_rules_write?) load_rules - attrs = params.fetch(:time_rules, {}).permit(*TimeRules::DEFAULTS.keys).to_h - TimeRules::BOOLEAN_KEYS.each { |key| attrs[key] = attrs.key?(key) ? attrs[key] : '0' } if attrs.any? + section = SECTIONS.key?(params[:section].to_s) ? params[:section].to_s : 'rules' + return redirect_to(time_rules_settings_path, alert: 'These switches apply to the whole workspace. Choose “Every store” first.') if section != 'rules' && @location + + keys = SECTIONS.fetch(section) + attrs = params.fetch(:time_rules, {}).permit(*keys).to_h + (TimeRules::BOOLEAN_KEYS & keys).each { |key| attrs[key] = attrs.key?(key) ? attrs[key] : '0' } if attrs.any? || section != 'rules' TimeRules.save!(attrs, location_id: @location&.id, actor: Current.user) - redirect_to time_rules_settings_path(location_id: @location&.id), notice: "Time rules saved for #{@location ? @location.name : 'every store'}." - rescue TimeRules::Invalid => e - redirect_to time_rules_settings_path(location_id: @location&.id), alert: e.message + save_time_zone if section == 'rules' && @location.nil? && params.key?(:workspace_time_zone) + redirect_to time_rules_settings_path(location_id: @location&.id, anchor: (section unless section == 'rules')), + notice: format(NOTICES.fetch(section), @location ? @location.name : 'every store') + rescue TimeRules::Invalid, ActiveRecord::RecordInvalid => e + redirect_to time_rules_settings_path(location_id: @location&.id), alert: e.is_a?(ActiveRecord::RecordInvalid) ? e.record.errors.full_messages.to_sentence : e.message end private @@ -30,5 +42,19 @@ def load_rules @default_rules = TimeRules.for(nil) @can_write = ModulePolicy.new(Current.user, :module).time_rules_write? @overrides = @location ? (TimeRules.stored['locations'][@location.id.to_s] || {}).keys : [] + @time_zone = BusinessClock.zone + end + + # The workspace's clock: which midnight ends a day, a week and a pay period. Blank keeps the + # instance default. Recorded in the activity log like every other rule. + def save_time_zone + tenant = Current.tenant + chosen = params[:workspace_time_zone].to_s.strip.presence + return if tenant.time_zone.to_s == chosen.to_s + + was = tenant.time_zone + tenant.update!(time_zone: chosen) + ActivityLogger.log('time_rules.updated', subject: nil, label: 'Every store', + details: { changed: ['time_zone'], changes: { 'time_zone' => [was, chosen] } }.to_json) end end diff --git a/web/app/helpers/application_helper.rb b/web/app/helpers/application_helper.rb index 2b54fd600..4888b98f1 100644 --- a/web/app/helpers/application_helper.rb +++ b/web/app/helpers/application_helper.rb @@ -1,6 +1,12 @@ # frozen_string_literal: true module ApplicationHelper + # A workspace switch from Settings → Time rules (TimeRules::FEATURES): `time_feature?(:scheduling)`. + def time_feature?(name) = TimeRules.feature?(name) + + # Whether the signed-in person may punch from this browser: the iPhone app and the web each have a switch. + def clock_channel_on? = TimeRules.feature?(hotwire_native_app? ? :clock_app : :clock_web) + # Ask Rupert, the help chat, is for signed-in staff with the Rupert Chat permission # (administrators by default) in their own workspace (not customer-support views, # not the employee app) while an administrator keeps it on. diff --git a/web/app/javascript/controllers/pin_clock_controller.js b/web/app/javascript/controllers/pin_clock_controller.js index d698856f7..8ab95f6f0 100644 --- a/web/app/javascript/controllers/pin_clock_controller.js +++ b/web/app/javascript/controllers/pin_clock_controller.js @@ -161,7 +161,7 @@ export default class extends Controller { if (this.pending) return this.pending = true const body = { pin: this.pin, clock_action: action, request_token: this.token, location_id: this.locationValue || undefined, - register_name: this.registerValue || undefined, ...extra } + register_name: this.registerValue || undefined, kiosk: this.kioskValue || undefined, ...extra } try { const response = await this.post(this.urlValue, body) const data = await response.json().catch(() => ({})) @@ -272,7 +272,8 @@ export default class extends Controller { async saveSetup(event) { event.preventDefault() const body = { pin: this.setupPinTarget.value, pin_confirmation: this.setupConfirmTarget.value, - employee_id: this.setupWorkerTarget.value || undefined, employee_number: this.setupNumberTarget.value || undefined } + employee_id: this.setupWorkerTarget.value || undefined, employee_number: this.setupNumberTarget.value || undefined, + kiosk: this.kioskValue || undefined } try { const response = await this.post(this.setupUrlValue, body) const data = await response.json().catch(() => ({})) diff --git a/web/app/jobs/break_reminder_job.rb b/web/app/jobs/break_reminder_job.rb index 8ca6a88bb..ea3bc8416 100644 --- a/web/app/jobs/break_reminder_job.rb +++ b/web/app/jobs/break_reminder_job.rb @@ -8,7 +8,7 @@ class BreakReminderJob < ApplicationJob def perform Tenant.where(status: 'active').find_each do |tenant| - Current.set(tenant: tenant) { BusinessClock.use(tenant: tenant) { remind_tenant } } + Current.with_tenant(tenant) { remind_tenant } rescue StandardError => e logger.error("#{self.class}: break reminders failed for tenant #{tenant.id}: #{e.class}") end diff --git a/web/app/jobs/ctrax_labor_sync_job.rb b/web/app/jobs/ctrax_labor_sync_job.rb index fb62682d5..0bedc1be8 100644 --- a/web/app/jobs/ctrax_labor_sync_job.rb +++ b/web/app/jobs/ctrax_labor_sync_job.rb @@ -10,6 +10,9 @@ def perform(tenant_id = nil) Current.set(tenant: tenant) do self.class.perform_later(tenant.id) if CtraxLaborSync.enabled? end + rescue StandardError => e + # One workspace's broken connection must not stop the others from being scheduled. + logger.error("#{self.class}: scheduling failed for tenant #{tenant.id}: #{e.class}") end return end diff --git a/web/app/jobs/data_retention_job.rb b/web/app/jobs/data_retention_job.rb index 723e1f73c..c1597c6e6 100644 --- a/web/app/jobs/data_retention_job.rb +++ b/web/app/jobs/data_retention_job.rb @@ -9,6 +9,7 @@ def perform Tenant.find_each do |tenant| Current.tenant = tenant DataRetention.prune_all! + PunchAudit.prune! rescue StandardError => e logger.error("#{self.class}: retention prune failed for tenant #{tenant.id}: #{e.class}: #{e.message}") ensure diff --git a/web/app/jobs/directory_sync_job.rb b/web/app/jobs/directory_sync_job.rb index 543202d82..03f06764f 100644 --- a/web/app/jobs/directory_sync_job.rb +++ b/web/app/jobs/directory_sync_job.rb @@ -16,6 +16,9 @@ def perform(tenant_id = nil, provider = nil, kind = nil, manual: false) kinds.each { |directory| self.class.perform_later(tenant.id, source, directory) } end end + rescue StandardError => e + # One workspace's broken connection must not stop the others from being scheduled. + logger.error("#{self.class}: scheduling failed for tenant #{tenant.id}: #{e.class}") end return end diff --git a/web/app/jobs/leave_accrual_job.rb b/web/app/jobs/leave_accrual_job.rb index 4c53796fd..1a20ca1f2 100644 --- a/web/app/jobs/leave_accrual_job.rb +++ b/web/app/jobs/leave_accrual_job.rb @@ -12,9 +12,10 @@ class LeaveAccrualJob < ApplicationJob queue_as :default - def perform(today: Date.current) + # `today` is each workspace's own date unless a caller pins it (tests, backfills). + def perform(today: nil) Tenant.where(status: 'active').find_each do |tenant| - Current.set(tenant: tenant) { BusinessClock.use(tenant: tenant) { LeaveAccrual.run!(today: today) } } + Current.with_tenant(tenant) { LeaveAccrual.run!(today: today || Date.current) } rescue StandardError => e logger.error("#{self.class}: leave accrual failed for tenant #{tenant.id}: #{e.class}") end diff --git a/web/app/jobs/open_clock_out_alert_job.rb b/web/app/jobs/open_clock_out_alert_job.rb index ec3a31eb6..12fe3c57a 100644 --- a/web/app/jobs/open_clock_out_alert_job.rb +++ b/web/app/jobs/open_clock_out_alert_job.rb @@ -7,7 +7,7 @@ class OpenClockOutAlertJob < ApplicationJob def perform Tenant.where(status: 'active').find_each do |tenant| - Current.set(tenant: tenant) { BusinessClock.use(tenant: tenant) { OpenClockOuts.new.notify_managers! } } + Current.with_tenant(tenant) { OpenClockOuts.new.notify_managers! if TimeRules.feature?(:missed_clock_out_reminders) } rescue StandardError => e logger.error("#{self.class}: open clock-out alerts failed for tenant #{tenant.id}: #{e.class}") end diff --git a/web/app/jobs/square_timecard_sync_job.rb b/web/app/jobs/square_timecard_sync_job.rb index 427e50634..19a4843a4 100644 --- a/web/app/jobs/square_timecard_sync_job.rb +++ b/web/app/jobs/square_timecard_sync_job.rb @@ -10,6 +10,9 @@ def perform(tenant_id = nil) Current.set(tenant: tenant) do self.class.perform_later(tenant.id) if SquareTimecardSync.enabled? end + rescue StandardError => e + # One workspace's broken connection must not stop the others from being scheduled. + logger.error("#{self.class}: scheduling failed for tenant #{tenant.id}: #{e.class}") end return end diff --git a/web/app/jobs/tips/attribute_authorize_net_job.rb b/web/app/jobs/tips/attribute_authorize_net_job.rb index d58c26d9a..8c9c1de0e 100644 --- a/web/app/jobs/tips/attribute_authorize_net_job.rb +++ b/web/app/jobs/tips/attribute_authorize_net_job.rb @@ -10,7 +10,7 @@ def perform(tenant_id:, transaction_ids: nil) tenant = Tenant.find_by(id: tenant_id, status: 'active') return unless tenant - Current.set(tenant: tenant) do + Current.with_tenant(tenant) do scope = AuthorizeNetTransaction.all scope = scope.where(id: transaction_ids) if transaction_ids scope.find_each { |txn| Tips::Reconciliation.new.authorize_net_transaction(txn) } diff --git a/web/app/jobs/tips/attribute_feed_event_job.rb b/web/app/jobs/tips/attribute_feed_event_job.rb index 8e583f406..455e9ef1c 100644 --- a/web/app/jobs/tips/attribute_feed_event_job.rb +++ b/web/app/jobs/tips/attribute_feed_event_job.rb @@ -9,7 +9,10 @@ def perform(event_id) event = DejavooFeedEvent.unscoped.find_by(id: event_id) return unless event - Current.set(tenant: Tenant.find_by(id: event.tenant_id)) do + tenant = Tenant.find_by(id: event.tenant_id) + return unless tenant + + Current.with_tenant(tenant) do if event.event_type == 'Settlement' Tips::Reconciliation.new.settle_dejavoo_batch(event) else diff --git a/web/app/jobs/tips/reconcile_job.rb b/web/app/jobs/tips/reconcile_job.rb index ab56d5a7f..5cd1ddd6e 100644 --- a/web/app/jobs/tips/reconcile_job.rb +++ b/web/app/jobs/tips/reconcile_job.rb @@ -7,7 +7,7 @@ class ReconcileJob < ApplicationJob def perform Tenant.where(status: 'active').find_each do |tenant| - Current.set(tenant: tenant) { Tips::Reconciliation.new.run } + Current.with_tenant(tenant) { Tips::Reconciliation.new.run } rescue StandardError => e Rails.logger.warn("[tips] reconciliation failed for #{tenant.id}: #{e.class}: #{e.message}") end diff --git a/web/app/models/clock_shift.rb b/web/app/models/clock_shift.rb index d707fa7d5..c59080935 100644 --- a/web/app/models/clock_shift.rb +++ b/web/app/models/clock_shift.rb @@ -55,6 +55,25 @@ def paid_hours(at: Time.current, rules: nil) [(hours(at: at) * 3600) - unpaid_break_seconds(at: at, rules: rules), 0].max / 3600.0 end + # Paid hours of a finished shift inside the window [from, to): the punch clipped to the window, + # minus the unpaid break time that falls in it. This is what a timesheet imports for one day, so the + # timesheet, the Pay page and the payroll export all count the same hours. + def paid_hours_between(from, to, rules: nil) + return 0.0 unless ended_at + + start_at = [started_at, from].max + end_at = [ended_at, to].min + return 0.0 unless end_at > start_at + + rules ||= TimeRules.for(location_id) + unpaid = breaks.sum do |pause| + next 0 if pause.ended_at.nil? || rules.break_paid?(pause.kind) + + [[pause.ended_at, end_at].min - [pause.started_at, start_at].max, 0].max + end + [(end_at - start_at) - unpaid, 0].max / 3600.0 + end + def open_break = breaks.detect(&:open?) def on_break? = open_break.present? def open? = ended_at.nil? diff --git a/web/app/models/current.rb b/web/app/models/current.rb index 22d8a9c66..c34b9803e 100644 --- a/web/app/models/current.rb +++ b/web/app/models/current.rb @@ -31,6 +31,16 @@ def sync_run_id sync_run&.id end + # Runs the block as `tenant`: workspace scope, that workspace's clock (BusinessClock) and fresh + # per-request caches, all restored afterwards. Jobs that walk every workspace use this so one + # workspace's rules, flags or time zone never carry into the next. + def with_tenant(tenant, &) + set(tenant: tenant, metadata: {}, pay_rules: nil) do + name = tenant.respond_to?(:time_zone) ? tenant.time_zone.presence : nil + name ? Time.use_zone(BusinessClock.display_zone(name), &) : BusinessClock.use(tenant: tenant, &) + end + end + def user=(value) super self.tenant = value&.tenant diff --git a/web/app/models/employee_access_grant.rb b/web/app/models/employee_access_grant.rb index c909d0b26..3f29fd1a3 100644 --- a/web/app/models/employee_access_grant.rb +++ b/web/app/models/employee_access_grant.rb @@ -22,7 +22,10 @@ def self.issue!(employee) if user raise TimesheetWorkflow::Error, 'Manage this existing account through Accounts; setup links are only for the staff role.' unless user.employee? && user.active? && user.tenant_id == Current.tenant_id else - raise TimesheetWorkflow::Error, 'An account already uses this email. Link the verified existing account through the staff record.' if User.where('LOWER(TRIM(email)) = ?', email).exists? + if User.where('LOWER(TRIM(email)) = ?', email).exists? + # Says the same thing whether the account is here or in another workspace, so it reveals nothing more. + raise TimesheetWorkflow::Error, 'An account already uses this email. One email signs in to one workspace: link the existing account on this staff record if it belongs here, or use a different email.' + end user = User.create!(tenant: Current.tenant, email: email, name: employee.name, role: 'employee', password: SecureRandom.urlsafe_base64(48)) employee.update!(user: user) diff --git a/web/app/models/module_registry.rb b/web/app/models/module_registry.rb index df9f2a8bf..a55abc851 100644 --- a/web/app/models/module_registry.rb +++ b/web/app/models/module_registry.rb @@ -187,6 +187,9 @@ def areas end end + # Menu entries that belong to a switchable time feature (TimeRules::FEATURES). + TIME_FEATURE_ENTRIES = { 'schedule' => :scheduling, 'shift_trades' => :shift_trades }.freeze + # Nav entries visible to a user, sorted by position. def nav_for(user) return [] unless user&.active? @@ -196,6 +199,10 @@ def nav_for(user) end all.select do |entry| + # A time feature the workspace turned off (Settings → Time rules) leaves the menu for everyone. + feature = TIME_FEATURE_ENTRIES[entry.key] + next false if feature && !TimeRules.feature?(feature) + next user.can_use_pos? if %w[ctrax_pos point_of_sale].include?(entry.key) next false if %w[platform system].include?(entry.key) && !user.super_admin? next false if entry.key == 'employee_reports' && !user.can_manage_accounts? diff --git a/web/app/models/push_device.rb b/web/app/models/push_device.rb index bb3d17ba8..fcf24a34b 100644 --- a/web/app/models/push_device.rb +++ b/web/app/models/push_device.rb @@ -24,15 +24,40 @@ def self.digest(token) = Digest::SHA256.hexdigest(token.to_s) # Registers or refreshes a device for `user`; the same token re-registered by another account moves # to that account (one phone, one person signed in). + # + # A token is one physical phone or browser. When it signs in to a different workspace (or as a + # different person) every other registration of that token stops, in every workspace, so the last + # person's schedule and approval notifications never reach the next person's lock screen. def self.register!(user:, platform:, token:, environment: nil, app_version: nil, keys: nil) - device = find_or_initialize_by(token_digest: digest(token)) + device = unscoped.where(tenant_id: user.tenant_id).find_or_initialize_by(token_digest: digest(token)) device.assign_attributes(user: user, platform: platform, token: token, environment: environment.presence, app_version: app_version.presence, keys_json: keys.present? ? keys.to_json : device.keys_json, last_seen_at: Time.current, disabled_at: nil, failure_reason: nil) + device.tenant_id = user.tenant_id device.save! + unscoped.where(token_digest: device.token_digest).where.not(id: device.id).where(disabled_at: nil) + .update_all(disabled_at: Time.current, failure_reason: 'registered to another account', updated_at: Time.current) device end + # Stops every device of `user` (offboarding, deactivation), or only one platform's (signing out + # of the iPhone app). A user belongs to one workspace, so the user id is the scope. + def self.disable_all_for(user, reason, platform: nil) + return 0 unless user + + scope = unscoped.where(user_id: user.id, disabled_at: nil) + scope = scope.where(platform: platform) if platform + scope.update_all(disabled_at: Time.current, failure_reason: reason.to_s.truncate(200), updated_at: Time.current) + end + + # Stops the phone or browser whose token has this digest, wherever it is registered. The digest + # (SHA-256 of the device's own push token) is something only that device and this server know. + def self.forget_token!(token_digest, reason: 'signed out') + return 0 unless token_digest.to_s.match?(/\A\h{64}\z/) + + unscoped.where(token_digest: token_digest.to_s, disabled_at: nil).update_all(disabled_at: Time.current, failure_reason: reason, updated_at: Time.current) + end + def keys keys_json.present? ? JSON.parse(keys_json) : {} rescue JSON::ParserError diff --git a/web/app/models/tenant.rb b/web/app/models/tenant.rb index 61db4481a..81cb4a94d 100644 --- a/web/app/models/tenant.rb +++ b/web/app/models/tenant.rb @@ -22,6 +22,12 @@ class Tenant < ApplicationRecord allow_blank: true, format: { with: /\A[a-z0-9-]+\.myshopify\.com\z/, message: 'must look like your-store.myshopify.com' } + # Zones a workspace can choose at Settings → Time rules: the United States first, then the rest. + TIME_ZONES = (ActiveSupport::TimeZone.us_zones + (ActiveSupport::TimeZone.all - ActiveSupport::TimeZone.us_zones)).uniq { |zone| zone.tzinfo.name }.freeze + # Blank means the instance default (config.time_zone); BusinessClock reads this for every day boundary. + validates :time_zone, inclusion: { in: ->(_) { TZInfo::Timezone.all_identifiers }, message: 'is not a known time zone' }, allow_blank: true + normalizes :time_zone, with: ->(value) { value.to_s.strip.presence } + def to_param subdomain end diff --git a/web/app/models/time_rules.rb b/web/app/models/time_rules.rb index ef45a854b..58107b9dd 100644 --- a/web/app/models/time_rules.rb +++ b/web/app/models/time_rules.rb @@ -58,9 +58,66 @@ class TimeRules 'tip_pool_by_location' => true, 'cash_tips_declared' => true, # Payday: days after the pay period ends (the employee Pay page shows "Next payday"). - 'payday_days_after_period_end' => 5 + 'payday_days_after_period_end' => 5, + # What is turned on (workspace-wide; FEATURES below). Every default is what Rupert did before these + # switches existed, so a workspace that never opens the page keeps working exactly as it did. + 'clock_web_enabled' => true, + 'clock_app_enabled' => true, + 'clock_pin_enabled' => true, + 'clock_kiosk_enabled' => true, + 'breaks_enabled' => true, + 'scheduling_enabled' => true, + 'shift_trades_enabled' => true, + 'time_off_enabled' => true, + 'tips_view_enabled' => true, + 'pay_view_enabled' => true, + 'staff_corrections_enabled' => true, + 'timesheet_self_submit' => true, + 'missed_clock_out_reminders' => true, + # Logging around a punch (LOGGING below). The device and register name were always kept; the punch + # audit trail and the network address are new and stay off until an administrator turns them on. + 'punch_device_logging' => true, + 'punch_audit_trail' => false, + 'punch_ip_logging' => false, + 'punch_audit_retention_years' => 7 }.freeze + # Workspace-wide switches, in the order the settings page lists them. A switch that is off is + # refused by the server (web pages, the staff API and the PIN clock) and hidden from menus. + FEATURES = { + 'clock_web_enabled' => ['Clock in from the web and the installed web app', 'Signed-in staff use Clock in / Clock out / Break on their own page in a browser.'], + 'clock_app_enabled' => ['Clock in from the phone apps', 'The iPhone app and the staff app API accept punches.'], + 'clock_pin_enabled' => ['PIN clock on the register', 'The PIN pad on the POS screen.'], + 'clock_kiosk_enabled' => ['Clock kiosk', 'The full-screen PIN pad at /clock for a counter tablet.'], + 'breaks_enabled' => ['Breaks and meals', 'Staff start and end breaks; unpaid breaks come off paid hours.'], + 'scheduling_enabled' => ['Scheduling', 'The schedule builder, posted shifts, open shifts and availability.'], + 'shift_trades_enabled' => ['Shift trades', 'Staff give away, swap and pick up shifts.'], + 'time_off_enabled' => ['Time-off requests', 'Staff ask for time off from their own page. Managers can still record leave.'], + 'tips_view_enabled' => ['Tips for staff', 'Staff see their tip share and declare cash tips.'], + 'pay_view_enabled' => ['Pay estimates for staff', 'Staff see estimated gross pay, their rate and payslips.'], + 'staff_corrections_enabled' => ['Punch fixes by staff', 'Staff ask to fix their own punches. When off, only a manager starts a fix.'], + 'timesheet_self_submit' => ['Staff submit their own timesheets', 'When off, a manager submits each timesheet for review.'], + 'missed_clock_out_reminders' => ['Missed clock-out reminders', 'Nudge staff and managers about a punch left open.'] + }.freeze + LOGGING = { + 'punch_device_logging' => ['Keep the device and register name on each punch', 'Which registered tablet or register a punch came from.'], + 'punch_audit_trail' => ['Detailed punch audit trail', 'Every clock in, clock out, break and refused punch is written to the activity log with who, when and how.'], + 'punch_ip_logging' => ['Record the network address of each punch', 'Added to the punch audit trail. Tell your staff before turning this on.'] + }.freeze + FEATURE_KEYS = (FEATURES.keys + LOGGING.keys + %w[punch_audit_retention_years]).freeze + # The activity log keeps seven years by default; payroll records must be kept at least three (FLSA). + AUDIT_RETENTION_YEARS = [3, 4, 7].freeze + + # Raised when a switched-off feature is asked for; controllers turn it into a redirect or a 403. + class FeatureDisabled < StandardError + attr_reader :feature + + def initialize(feature) + @feature = feature.to_s + super("#{TimeRules.feature_label(feature)} is turned off for this workspace. Ask an administrator.") + end + end + PRESETS = { 'federal' => {}, 'connecticut' => { 'meal_break_after_hours' => '7.5', 'meal_break_minutes' => 30, 'sick_accrual_hours_per_hour_worked' => '0.025', 'sick_accrual_cap_hours' => '40' }, @@ -79,12 +136,37 @@ class TimeRules minor_max_weekly_hours min_rest_between_shifts_hours trade_min_notice_hours sick_accrual_hours_per_hour_worked sick_accrual_cap_hours vacation_annual_hours sick_annual_hours carryover_max_hours].freeze INTEGER_KEYS = %w[meal_break_minutes rest_break_minutes minor_age schedule_notice_days rounding_minutes grace_minutes pin_length payday_days_after_period_end - pin_lockout_attempts pin_lockout_minutes time_off_min_notice_days].freeze - BOOLEAN_KEYS = %w[meal_break_paid rest_break_paid break_reminders pin_requires_trusted_device trade_requires_approval trade_block_overtime - open_shift_requires_position tip_pool_excludes_admins tip_pool_by_location cash_tips_declared].freeze + pin_lockout_attempts pin_lockout_minutes time_off_min_notice_days punch_audit_retention_years].freeze + RULE_BOOLEAN_KEYS = %w[meal_break_paid rest_break_paid break_reminders pin_requires_trusted_device trade_requires_approval trade_block_overtime + open_shift_requires_position tip_pool_excludes_admins tip_pool_by_location cash_tips_declared].freeze + FEATURE_BOOLEAN_KEYS = (FEATURES.keys + LOGGING.keys).freeze + BOOLEAN_KEYS = (RULE_BOOLEAN_KEYS + FEATURE_BOOLEAN_KEYS).freeze + # The fields a store can override; the switches above are for the whole workspace. + RULE_KEYS = (DEFAULTS.keys - FEATURE_KEYS).freeze class Invalid < StandardError; end + # ---- workspace switches -------------------------------------------------------------------- + + def self.feature_key(name) + key = name.to_s + return key if DEFAULTS.key?(key) && FEATURE_BOOLEAN_KEYS.include?(key) + + FEATURE_BOOLEAN_KEYS.find { |candidate| candidate == "#{key}_enabled" } || raise(ArgumentError, "unknown time feature #{name}") + end + + def self.feature_label(name) + key = feature_key(name) + (FEATURES[key] || LOGGING[key]).first + end + + # True when the workspace has `name` turned on (`:scheduling`, `:clock_web`, 'timesheet_self_submit'). + def self.feature?(name) = self.for(nil).feature?(name) + + def self.require_feature!(name) + raise FeatureDisabled, feature_key(name) unless feature?(name) + end + attr_reader :data, :location_id # Rules for one store (or the workspace default when `location` is nil). One Setting read per request. @@ -103,7 +185,7 @@ def self.stored(tenant_id = Current.tenant_id) raw = Setting.for_tenant(KEY, tenant_id)&.value parsed = raw.present? ? JSON.parse(raw) : {} - cache[key] = { 'default' => (parsed['default'] || {}).slice(*DEFAULTS.keys), 'locations' => (parsed['locations'] || {}).transform_values { |v| v.to_h.slice(*DEFAULTS.keys) } } + cache[key] = { 'default' => (parsed['default'] || {}).slice(*DEFAULTS.keys), 'locations' => (parsed['locations'] || {}).transform_values { |v| v.to_h.slice(*RULE_KEYS) } } rescue JSON::ParserError cache[key] = { 'default' => {}, 'locations' => {} } end @@ -113,7 +195,9 @@ def self.stored(tenant_id = Current.tenant_id) def self.save!(attrs, location_id: nil, actor: Current.user, tenant_id: Current.tenant_id) store = stored(tenant_id).deep_dup cleaned = coerce(attrs.to_h.stringify_keys.slice(*DEFAULTS.keys)) + before = self.for(location_id).to_h if location_id.present? + cleaned = cleaned.except(*FEATURE_KEYS) base = DEFAULTS.merge(PRESETS.fetch((store['default']['preset'] || 'federal').to_s, {})).merge(store['default']) overrides = (store['locations'][location_id.to_s] || {}).merge(cleaned).reject { |k, v| base[k] == v } store['locations'][location_id.to_s] = overrides @@ -125,8 +209,14 @@ def self.save!(attrs, location_id: nil, actor: Current.user, tenant_id: Current. new(merged, location_id: location_id).validate! Setting.find_or_create_for(KEY, tenant_id) { |setting| setting.value = store.to_json } (Current.metadata ||= {}).delete("time_rules:#{tenant_id}") - ActivityLogger.log('time_rules.updated', subject: nil, details: { location_id: location_id, changed: cleaned.keys }.to_json, actor: actor) if defined?(ActivityLogger) - self.for(location_id) + saved = self.for(location_id) + # The audit line carries each value that really changed, old → new (who and when are on the log row). + changes = cleaned.keys.each_with_object({}) { |key, out| out[key] = [before[key], saved.to_h[key]] if before[key] != saved.to_h[key] } + if changes.any? + ActivityLogger.log('time_rules.updated', subject: nil, actor: actor, label: location_id.present? ? "Store #{location_id}" : 'Every store', + details: { location_id: location_id, changed: changes.keys, changes: changes }.to_json) + end + saved end def self.coerce(attrs) @@ -151,6 +241,8 @@ def validate! raise Invalid, 'Choose how tips are shared.' unless TIP_METHODS.key?(tip_method) raise Invalid, 'Minor age must be 14 to 21.' unless (14..21).cover?(minor_age) raise Invalid, 'Schedule notice is 0 to 30 days.' unless (0..30).cover?(schedule_notice_days) + raise Invalid, 'Keep the punch audit trail 3, 4 or 7 years.' unless AUDIT_RETENTION_YEARS.include?(punch_audit_retention_years) + raise Invalid, 'Turn on the punch audit trail before recording network addresses.' if punch_ip_logging? && !punch_audit_trail? self end @@ -231,4 +323,11 @@ def tip_pool_excludes_admins? = bool('tip_pool_excludes_admins') def tip_pool_by_location? = bool('tip_pool_by_location') def cash_tips_declared? = bool('cash_tips_declared') def payday_days_after_period_end = integer('payday_days_after_period_end') + + # Workspace switches and logging + def feature?(name) = bool(self.class.feature_key(name)) + def punch_device_logging? = bool('punch_device_logging') + def punch_audit_trail? = bool('punch_audit_trail') + def punch_ip_logging? = bool('punch_ip_logging') + def punch_audit_retention_years = integer('punch_audit_retention_years') end diff --git a/web/app/policies/people/leave_request_policy.rb b/web/app/policies/people/leave_request_policy.rb index ee63e79c7..6ea019ed3 100644 --- a/web/app/policies/people/leave_request_policy.rb +++ b/web/app/policies/people/leave_request_policy.rb @@ -23,15 +23,22 @@ def destroy? end def approve? - module?('leave.write') && tenant? + module?('leave.write') && tenant? && in_store_scope? end def deny? - module?('leave.write') && tenant? + module?('leave.write') && tenant? && in_store_scope? end def cancel? - module?('leave.write') && tenant? + module?('leave.write') && tenant? && in_store_scope? + end + + private + + # A manager limited to stores decides only for staff of those stores. + def in_store_scope? + People::StoreScope.covers?(user, record.employee) end end end diff --git a/web/app/policies/people/timesheet_policy.rb b/web/app/policies/people/timesheet_policy.rb index 59a295e80..9a2ede1ee 100644 --- a/web/app/policies/people/timesheet_policy.rb +++ b/web/app/policies/people/timesheet_policy.rb @@ -25,7 +25,8 @@ def update? alias remove_entry? update? alias import_shifts? update? def approve? - tenant? && user&.active? && (user.can_review_timesheets? || assigned_demo?) && record.employee.user_id != user.id + tenant? && user&.active? && (user.can_review_timesheets? || assigned_demo?) && record.employee.user_id != user.id && + People::StoreScope.covers?(user, record.employee) end alias reject? approve? def reopen? diff --git a/web/app/services/business_clock.rb b/web/app/services/business_clock.rb index 788c7b7ef..909aa435d 100644 --- a/web/app/services/business_clock.rb +++ b/web/app/services/business_clock.rb @@ -51,6 +51,10 @@ def default_zone = Time.find_zone!(Time.find_zone!(Rails.application.config.time def find(name) = name.present? ? Time.find_zone(name.to_s) : nil + # The zone for an IANA name under the name people read ("Pacific Time (US & Canada)") when Rails + # has one, so pages say the same thing they say for the instance default. + def display_zone(name) = Time.find_zone(ActiveSupport::TimeZone::MAPPING.key(name.to_s) || name.to_s) || default_zone + def location_zone_name(location) = location.respond_to?(:timezone) ? location.timezone : nil def tenant_zone_name(tenant) = tenant.respond_to?(:time_zone) ? tenant.time_zone : nil diff --git a/web/app/services/people/offboarding.rb b/web/app/services/people/offboarding.rb index 288a2949b..028a48a2f 100644 --- a/web/app/services/people/offboarding.rb +++ b/web/app/services/people/offboarding.rb @@ -22,6 +22,7 @@ def call People::Employee.transaction do result.sessions_revoked = EmployeeAppSession.where(employee_id: @employee.id, revoked_at: nil).update_all(revoked_at: Time.current) result.links_revoked = EmployeeAccessGrant.where(employee_id: @employee.id, used_at: nil).where('expires_at > ?', Time.current).update_all(expires_at: Time.current) + PushDevice.disable_all_for(@employee.user, 'staff member left') if @employee.user_id @employee.update_columns(pin_digest: nil, pin_set_at: nil, pin_failed_count: 0, pin_locked_until: nil, updated_at: Time.current) People::ScheduledShift.live.where(employee_id: @employee.id).where('starts_at > ?', Time.current).find_each do |shift| shift.update_columns(employee_id: nil, notes: [shift.notes.presence, "Opened when #{@employee.name} left"].compact.join("\n"), last_changed_at: Time.current, updated_at: Time.current) diff --git a/web/app/services/punch_audit.rb b/web/app/services/punch_audit.rb new file mode 100644 index 000000000..7087b47da --- /dev/null +++ b/web/app/services/punch_audit.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# The detailed punch audit trail (Settings → Time rules → Punch logging). When the workspace has it +# on, every clock in, clock out, break, punch fix and refused PIN is written to the activity log +# as `time_clock.` with how it was made (source, store, device, register) and, only when the +# workspace also turned that on, the network address. Off by default: nothing is written. +# +# The punch itself (ClockShift, ClockBreak, ClockCorrection) is the record of hours and is kept +# whatever this says; the trail is the extra "who touched the clock, from where" evidence. +class PunchAudit + PREFIX = 'time_clock.' + + class << self + def record(event, shift: nil, worker: nil, **details) + rules = TimeRules.for(nil) + return unless rules.punch_audit_trail? + + data = { source: shift&.source, shift_id: shift&.id, location_id: shift&.location_id, device: shift&.device_label, + register: shift&.register_name, at: Time.current.iso8601 }.merge(details).compact + ip = Current.metadata[:punch_ip] + data[:ip] = ip if rules.punch_ip_logging? && ip.present? + ActivityLogger.log("#{PREFIX}#{event}", subject: worker || shift&.employee, details: data.to_json) + end + + # Removes audit lines older than the workspace's choice (3, 4 or 7 years). The general activity + # log keeps seven years (DataRetention); this only ever shortens the punch trail. + def prune!(now: Time.current) + cutoff = now - TimeRules.for(nil).punch_audit_retention_years.years + ActivityLog.where('action LIKE ?', "#{PREFIX}%").where(created_at: ...cutoff).delete_all + end + end +end diff --git a/web/app/services/punch_history.rb b/web/app/services/punch_history.rb index 0e701a6e3..2a9cf5393 100644 --- a/web/app/services/punch_history.rb +++ b/web/app/services/punch_history.rb @@ -184,10 +184,19 @@ def ctrax_rows end end + # Pay rates are payroll data: a reviewer without payroll access sees everyone's punches but + # only their own rate (the same boundary as the staff and pay-run pages). def pay_rate_for(employee_id, started_at) return nil unless employee_id && started_at + return nil unless Current.user.nil? || Current.user.can?('payroll.read') || employee_id == own_worker_id People::PayRate.where(employee_id: employee_id).where('effective_on <= ?', started_at.to_date) .order(effective_on: :desc, id: :desc).first&.hourly_rate_cents end + + def own_worker_id + return @own_worker_id if defined?(@own_worker_id) + + @own_worker_id = Current.user && TimeClockService.worker_for(Current.user, include_on_leave: true)&.id + end end diff --git a/web/app/services/push_notifier.rb b/web/app/services/push_notifier.rb index afd97f0b1..a3d0273f4 100644 --- a/web/app/services/push_notifier.rb +++ b/web/app/services/push_notifier.rb @@ -29,6 +29,10 @@ def web_push_configured? = VAPID_KEYS.all? { |key| setting(key).present? } # Sends `notification` to every active device of its user. Returns a Result with counts. def deliver(notification) result = Result.new(sent: 0, disabled: 0, failed: 0) + # A deactivated account, or one in an inactive workspace, gets nothing on its lock screen. + user = User.find_by(id: notification.user_id) + return result unless user&.active? && user.tenant&.status == 'active' + devices = PushDevice.active.where(user_id: notification.user_id).to_a return result if devices.empty? diff --git a/web/app/services/time_clock_service.rb b/web/app/services/time_clock_service.rb index ef554d01b..3b4dc29ae 100644 --- a/web/app/services/time_clock_service.rb +++ b/web/app/services/time_clock_service.rb @@ -10,6 +10,15 @@ class Error < StandardError; end PUNCH_ACTIONS = %w[punch clock_in clock_out break meal end_break fix_and_clock_in].freeze + # Which switch (Settings → Time rules) a punch from `source` needs. Manager edits and imports are not punches. + CHANNEL_FEATURES = { 'web' => :clock_web, 'ios' => :clock_app, 'api' => :clock_app }.freeze + + # Refuses a punch from a channel the workspace turned off (TimeRules::FeatureDisabled). + def self.require_channel!(source) + feature = CHANNEL_FEATURES[source.to_s] + TimeRules.require_feature!(feature) if feature + end + def self.worker_for(user, include_on_leave: false) statuses = include_on_leave ? %w[active on_leave] : ['active'] workers = People::Employee.where(tenant_id: Current.tenant_id, user_id: user.id, status: statuses).limit(2).to_a @@ -18,11 +27,17 @@ def self.worker_for(user, include_on_leave: false) # The active worker whose clock PIN matches `pin`, or nil. PINs are unique across a workspace # (set_clock_pin! refuses a PIN another worker already has), so the first match is the person. - def self.worker_by_pin(pin) + # + # `only` narrows the search to one worker: a signed-in phone with no store device can only ever + # match its own person, so it learns nothing about a colleague's PIN. + def self.worker_by_pin(pin, only: :anyone) digits = pin.to_s.strip return nil unless digits.match?(/\A\d{4,6}\z/) + return nil if only.nil? - People::Employee.where(tenant_id: Current.tenant_id, status: 'active').where.not(pin_digest: nil).order(:id).find do |worker| + scope = People::Employee.where(tenant_id: Current.tenant_id, status: 'active').where.not(pin_digest: nil) + scope = scope.where(id: only.id) unless only == :anyone + scope.order(:id).find do |worker| !worker.pin_locked? && BCrypt::Password.new(worker.pin_digest) == digits end end @@ -52,11 +67,16 @@ def self.clock_in(user:, request_token:, source: 'web', location_id: nil, device end existing = ClockShift.find_by(employee_id: worker.id, request_token: request_token) existing ||= ClockShift.open.find_by(employee_id: worker.id) - existing || ClockShift.create!(user_id: worker.user_id, employee: worker, employee_key: worker.id.to_s, + next existing if existing + + keep_device = TimeRules.for(nil).punch_device_logging? + shift = ClockShift.create!(user_id: worker.user_id, employee: worker, employee_key: worker.id.to_s, employee_name: worker.name, employment_type: worker.employment_type, started_at: punch_time(at, location_id), request_token: request_token, source: source, - location_id: location_id.presence, device_label: device_label.presence, register_name: register_name.presence, + location_id: location_id.presence, device_label: (device_label.presence if keep_device), register_name: (register_name.presence if keep_device), scheduled_shift_id: planned_shift_id(worker, punch_time(at, location_id))) + PunchAudit.record('clock_in', shift: shift, worker: worker, queued: at.present? || nil) + shift end rescue ActiveRecord::RecordNotUnique ClockShift.find_by(employee_id: worker.id, request_token: request_token) || ClockShift.open.find_by!(employee_id: worker.id) @@ -77,6 +97,7 @@ def self.clock_out(user:, shift_id:, at: nil, worker: nil) ended_at = Time.current if ended_at <= shift.started_at shift.breaks.open.each { |b| b.update!(ended_at: [ended_at, b.started_at + 1.second].max) } shift.update!(ended_at: ended_at) + PunchAudit.record('clock_out', shift: shift, queued: at.present? || nil) end shift end @@ -85,13 +106,16 @@ def self.clock_out(user:, shift_id:, at: nil, worker: nil) def self.start_break(shift:, kind:) raise Error, 'Choose a break or a meal.' unless ClockBreak::KINDS.include?(kind.to_s) + TimeRules.require_feature!(:breaks) shift.serialize_change do raise Error, 'This shift is already clocked out.' if shift.ended_at raise Error, 'This shift was never clocked out. Fix it first.' if shift.stale_open? rules = TimeRules.for(shift.location_id) shift.breaks.open.each { |b| b.update!(ended_at: Time.current) } - shift.breaks.create!(kind: kind.to_s, paid: rules.break_paid?(kind), started_at: Time.current) + pause = shift.breaks.create!(kind: kind.to_s, paid: rules.break_paid?(kind), started_at: Time.current) + PunchAudit.record('break_start', shift: shift, kind: pause.kind) + pause end end @@ -102,6 +126,7 @@ def self.end_break(shift:) raise Error, 'You are not on a break.' unless open open.update!(ended_at: [Time.current, open.started_at + 1.second].max) + PunchAudit.record('break_end', shift: shift, kind: open.kind) ended = open end ended @@ -116,8 +141,12 @@ def self.pin_punch(pin:, action: 'punch', device: nil, user: nil, location_id: n action = action.to_s.presence || 'punch' raise Error, 'Unknown clock action.' unless PUNCH_ACTIONS.include?(action) - worker = worker_by_pin(pin) - raise Error, 'That PIN didn’t match.' unless worker + # Someone must vouch before the digits are even looked at: an anonymous browser gets the same + # answer for every PIN, so the pad never says whether a PIN exists (and costs no bcrypt work). + raise Error, I18n.t('time.clock.untrusted') unless device&.active? || user + + worker = worker_by_pin(pin, only: device&.active? ? :anyone : worker_for(user)) + raise Error, I18n.t('time.clock.wrong_pin') unless worker vouch_for!(worker, device: device, user: user) location_id ||= device&.location_id @@ -193,6 +222,8 @@ def self.state_of(shift) # along as JSON lists of {kind, started_at, ended_at} (original and proposed). def self.request_correction(user:, shift_id:, attributes:, worker: nil) shift = worker ? ClockShift.find_by!(id: shift_id, employee_id: worker.id) : correctable_shift(user, shift_id) + # A fix the worker starts on their own punch can be turned off; a manager can always start one. + TimeRules.require_feature!(:staff_corrections) if worker || !OpenClockOuts.manager?(user) correction = nil shift.serialize_change do proposed_breaks = normalize_breaks(attributes[:proposed_breaks]) @@ -201,6 +232,7 @@ def self.request_correction(user:, shift_id:, attributes:, worker: nil) original_breaks_json: shift.breaks.chronological.map { |b| { kind: b.kind, started_at: b.started_at.iso8601, ended_at: b.ended_at&.iso8601 } }.to_json, proposed_breaks_json: proposed_breaks&.to_json )) + PunchAudit.record('correction_requested', shift: shift, correction_id: correction.id, requested_by: user&.id) end correction end @@ -219,6 +251,8 @@ def self.review(correction:, reviewer:, approve:, note: nil) (reviewer.super_admin? || reviewer.tenant_id == Current.tenant_id) shift = correction.clock_shift + # A reviewer limited to stores decides only for staff of those stores (the inbox already hides the rest). + raise ActiveRecord::RecordNotFound unless shift.employee.nil? || People::StoreScope.covers?(reviewer, shift.employee) # Same rule as timesheets: nobody reviews a change to their own hours. if shift.user_id == reviewer.id || correction.requested_by_id == reviewer.id || (shift.employee && shift.employee.user_id == reviewer.id) raise Error, 'Another reviewer must review a correction to your own shift.' @@ -241,6 +275,7 @@ def self.review(correction:, reviewer:, approve:, note: nil) apply_breaks(shift, correction.proposed_breaks) if correction.proposed_breaks_json.present? end correction.update!(status: approve ? 'approved' : 'rejected', reviewed_by_id: reviewer.id, reviewed_at: Time.current, review_note: note.presence) + PunchAudit.record(approve ? 'correction_approved' : 'correction_rejected', shift: shift, correction_id: correction.id, reviewed_by: reviewer.id) end requester = correction.requester || shift.user if requester @@ -257,7 +292,7 @@ def self.vouch_for!(worker, device:, user:) return if user && worker.user_id == user.id if user - raise Error, 'That PIN belongs to someone else. Use the store register for a colleague’s punch.' + raise Error, I18n.t('time.clock.wrong_pin') else raise Error, 'Use the store register, or sign in on your phone.' end diff --git a/web/app/services/timesheet_workflow.rb b/web/app/services/timesheet_workflow.rb index b3b5d4527..65cb573b8 100644 --- a/web/app/services/timesheet_workflow.rb +++ b/web/app/services/timesheet_workflow.rb @@ -45,6 +45,10 @@ def change(sheet, action, attributes = {}) attrs = { entry_id: entry.id, before: before, after: entry.attributes.slice('worked_on', 'hours', 'work_type', 'notes') } when 'submit' draft!(sheet) + # Settings → Time rules: when staff do not submit their own timesheets, a manager does. + if !@user.can?('timesheets.write') && !TimeRules.feature?(:timesheet_self_submit) + raise Error, 'Your manager submits timesheets in this workspace. Check your hours and tell them when the week is right.' + end validate_for_review!(sheet) sheet.update!(submitted_at: Time.current, reviewed_by: nil, reviewed_at: nil, review_note: nil) sheet.submit! @@ -118,9 +122,7 @@ def validate_imports!(sheet) ClockShift.where(id: imported_ids).each do |shift| next unless shift.ended_at (sheet.period_start..sheet.period_end).each do |day| - start_at = [shift.started_at, day.in_time_zone].max - end_at = [shift.ended_at, (day + 1).in_time_zone].min - next unless end_at > start_at && ((end_at - start_at) / 3600.0).round(2).positive? + next unless shift.paid_hours_between(day.in_time_zone, (day + 1).in_time_zone).round(2).positive? unless sheet.entries.exists?(clock_shift_id: shift.id, worked_on: day) raise Error, 'Clock dates have changed. Re-import your shifts before submitting.' end @@ -131,9 +133,7 @@ def validate_imports!(sheet) if !shift || !shift.ended_at || shift.corrections.where(status: 'pending').exists? raise Error, 'Resolve clock corrections and re-import your shifts before submitting.' end - start_at = [shift.started_at, entry.worked_on.in_time_zone].max - end_at = [shift.ended_at, (entry.worked_on + 1).in_time_zone].min - expected = [((end_at - start_at) / 3600.0).round(2), 0].max + expected = shift.paid_hours_between(entry.worked_on.in_time_zone, (entry.worked_on + 1).in_time_zone).round(2) raise Error, 'Clock times have changed. Re-import your shifts before submitting.' unless expected == entry.hours end end @@ -142,7 +142,7 @@ def import_shifts(sheet) from = sheet.period_start.in_time_zone to = (sheet.period_end + 1).in_time_zone shifts = ClockShift.where(employee_id: sheet.employee_id).where.not(ended_at: nil) - .where('started_at < ? AND ended_at > ?', to, from).order(:started_at) + .where('started_at < ? AND ended_at > ?', to, from).order(:started_at).includes(:breaks) shifts = shifts.to_a if shifts.any? { |shift| shift.corrections.where(status: 'pending').exists? } raise Error, 'Review pending clock corrections before importing these shifts.' @@ -153,10 +153,8 @@ def import_shifts(sheet) raise Error, 'Review pending clock corrections before importing these shifts.' end (sheet.period_start..sheet.period_end).each do |day| - start_at = [shift.started_at, day.in_time_zone].max - end_at = [shift.ended_at, (day + 1).in_time_zone].min - next unless end_at > start_at - hours = ((end_at - start_at) / 3600.0).round(2) + # Paid hours for the day: the part of the punch inside it, less unpaid breaks (meals by default). + hours = shift.paid_hours_between(day.in_time_zone, (day + 1).in_time_zone).round(2) next if hours.zero? entry = sheet.entries.find_or_initialize_by(clock_shift_id: shift.id, worked_on: day) # Re-import refreshes a corrected shift without creating a duplicate. diff --git a/web/app/views/ctrax_pos/_topbar.html.erb b/web/app/views/ctrax_pos/_topbar.html.erb index cc700c0dd..fbd20a012 100644 --- a/web/app/views/ctrax_pos/_topbar.html.erb +++ b/web/app/views/ctrax_pos/_topbar.html.erb @@ -71,9 +71,11 @@ <%= navigation_signin_method %> <%# time & pay: clock — the PIN pad, always one tap away and never in the way of a sale. %> - <% own_shift = ClockShift.open.find_by(user_id: Current.user.id) %> - <%= render 'shared/pin_clock', location_id: @snapshot&.location_id, register_name: @registers&.first&.name, trusted: ClockDevice.authenticate(cookies.signed[ClockDevice::COOKIE]).present?, - state: own_shift.nil? ? 'clocked_out' : own_shift.stale_open? ? 'stale' : TimeClockService.state_of(own_shift) %> + <% if TimeRules.feature?(:clock_pin) %> + <% own_shift = ClockShift.open.find_by(user_id: Current.user.id) %> + <%= render 'shared/pin_clock', location_id: @snapshot&.location_id, register_name: @registers&.first&.name, trusted: ClockDevice.authenticate(cookies.signed[ClockDevice::COOKIE]).present?, + state: own_shift.nil? ? 'clocked_out' : own_shift.stale_open? ? 'stale' : TimeClockService.state_of(own_shift) %> + <% end %> <% if pos_only %> <%= link_to 'My time', employee_app_path, class: 'btn-ghost btn-sm' %> <%= button_to 'Sign out', logout_path, method: :delete, class: 'btn-ghost btn-sm', form_class: 'contents' %> diff --git a/web/app/views/employee_app/show.html.erb b/web/app/views/employee_app/show.html.erb index 39504fa77..7dde8cfdb 100644 --- a/web/app/views/employee_app/show.html.erb +++ b/web/app/views/employee_app/show.html.erb @@ -18,6 +18,10 @@ <% end %> <%= render 'shared/clock_card', worker: @worker, open_shift: @open_shift, preview: @preview, return_to: 'home' %> + <% scheduling_on = @preview || time_feature?(:scheduling) %> + <% time_off_on = @preview || time_feature?(:time_off) %> + <% pay_on = @preview || time_feature?(:pay_view) %> + <% if scheduling_on %>

<%= t('time.clock.home.next_shift') %>

@@ -31,6 +35,7 @@

<%= @preview ? 'Your next planned shift shows here.' : t('time.clock.home.no_next_shift') %>

<% end %>
+ <% end %>
aria-labelledby="week-heading">
@@ -42,7 +47,7 @@
<%= t('time.clock.home.clocked') %>

<%= format_hours(clocked) %> h

<% if @open_shift && !@open_shift.stale_open? %>

<%= t('time.clock.home.including_open') %>

<% end %>
<%= t('time.clock.home.on_timesheet') %><%= define('timesheet') %>

<%= format_hours(@summary.hours) %> h

<% if @timesheet %>

<%= timesheet_status_pill(@timesheet) %>

<% end %>
-
<%= t('time.clock.home.estimated_gross') %><%= define('estimated_pay') %>
<% waiting_for_sheet = @summary.hours.zero? && clocked.positive? %>

<%= @summary.gross_cents.nil? || waiting_for_sheet ? '–' : number_to_currency(@summary.gross_cents / 100.0) %>

<% if waiting_for_sheet %>

Appears when your clocked hours are on your timesheet.

<% end %>
+ <% if pay_on %>
<%= t('time.clock.home.estimated_gross') %><%= define('estimated_pay') %>
<% waiting_for_sheet = @summary.hours.zero? && clocked.positive? %>

<%= @summary.gross_cents.nil? || waiting_for_sheet ? '–' : number_to_currency(@summary.gross_cents / 100.0) %>

<% if waiting_for_sheet %>

Appears when your clocked hours are on your timesheet.

<% end %>
<% end %>
    <% (@summary.from..@summary.to).each do |day| %> @@ -61,14 +66,14 @@ <%= button_to week_label, employee_week_path, class: 'btn-primary', data: { turbo: false } %> <%= link_to t('time.clock.home.all_timesheets'), people_timesheets_path, class: 'text-sm text-olive underline' %>
-

<%= t('time.clock.home.pay_hint') %>

+ <% if pay_on %>

<%= t('time.clock.home.pay_hint') %>

<% end %> <% else %>
<%= link_to t('time.clock.home.week_timesheet_open'), employee_preview_timesheet_path('current'), class: 'btn-primary' %><%= link_to t('time.clock.home.all_timesheets'), employee_preview_timesheets_path, class: 'text-sm text-olive underline' %>
<% end %>
-

<%= t('time.clock.home.schedule') %>

My shifts, the team’s week, open shifts and trades.

<%= link_to t('time.clock.home.open_schedule'), personal_schedule_path, class: 'btn-ghost btn-sm' %>
-

<%= t('time.clock.home.time_off') %>

<%= @next_leave ? "#{@next_leave.leave_type.titleize} · #{@next_leave.starts_on.strftime('%b %-d')} – #{@next_leave.ends_on.strftime('%b %-d')} · #{@next_leave.status.humanize}" : 'Plan a vacation, request a day off or check your calendar.' %>

<%= link_to t('time.clock.home.open_time_off'), personal_time_off_path, class: 'btn-ghost btn-sm' %>
-

<%= t('time.clock.home.pay') %>

<%= @summary.gross_cents && !@preview ? "#{number_to_currency(@summary.gross_cents / 100.0)} #{t('time.clock.home.estimated_gross').downcase}" : 'Hours, tips, bonuses and payslips.' %>

<%= link_to t('time.clock.home.open_pay'), @preview ? employee_preview_pay_path : employee_pay_path, class: 'btn-ghost btn-sm' %><%= link_to 'My reports', personal_reports_path, class: 'btn-ghost btn-sm' %>
+ <% if scheduling_on %>

<%= t('time.clock.home.schedule') %>

My shifts, the team’s week, open shifts and trades.

<%= link_to t('time.clock.home.open_schedule'), personal_schedule_path, class: 'btn-ghost btn-sm' %>
<% end %> + <% if time_off_on %>

<%= t('time.clock.home.time_off') %>

<%= @next_leave ? "#{@next_leave.leave_type.titleize} · #{@next_leave.starts_on.strftime('%b %-d')} – #{@next_leave.ends_on.strftime('%b %-d')} · #{@next_leave.status.humanize}" : 'Plan a vacation, request a day off or check your calendar.' %>

<%= link_to t('time.clock.home.open_time_off'), personal_time_off_path, class: 'btn-ghost btn-sm' %>
<% end %> + <% if pay_on %>

<%= t('time.clock.home.pay') %>

<%= @summary.gross_cents && !@preview ? "#{number_to_currency(@summary.gross_cents / 100.0)} #{t('time.clock.home.estimated_gross').downcase}" : 'Hours, tips, bonuses and payslips.' %>

<%= link_to t('time.clock.home.open_pay'), @preview ? employee_preview_pay_path : employee_pay_path, class: 'btn-ghost btn-sm' %><%= link_to 'My reports', personal_reports_path, class: 'btn-ghost btn-sm' %>
<% end %>
<% unless @preview %> diff --git a/web/app/views/employee_pay/show.html.erb b/web/app/views/employee_pay/show.html.erb index 6989b441e..66a87fdbf 100644 --- a/web/app/views/employee_pay/show.html.erb +++ b/web/app/views/employee_pay/show.html.erb @@ -1,3 +1,4 @@ +<% tips_on = @tips_on.nil? ? true : @tips_on %> <% content_for :title, t('time.pay.title') %> <% money = ->(cents) { cents.nil? ? '–' : number_to_currency(cents / 100.0) } %> <% step = @rules.period_days %> @@ -22,18 +23,20 @@
<%= t('time.pay.hours') %>
<%= format_hours(@pay.paid_hours) %> h
-
<%= @final ? t('time.pay.gross') : t('time.pay.estimated_gross') %>
<%= money.call(@pay.base_cents.nil? ? nil : @pay.total_cents + @bonus_cents) %>
-
<%= t('time.pay.tips') %>
<%= money.call(@pay.tip_cents) %>
+
<%= @final ? t('time.pay.gross') : t('time.pay.estimated_gross') %>
<%= money.call(@pay.base_cents.nil? ? nil : @pay.total_cents + @bonus_cents - (tips_on ? 0 : @pay.tip_cents)) %>
+ <% if tips_on %>
<%= t('time.pay.tips') %>
<%= money.call(@pay.tip_cents) %>
<% end %>
<%= t('time.pay.bonuses') %>
<%= money.call(@pay.commission_cents + @bonus_cents) %>
<%= t('time.pay.how') %>
<%= t('time.pay.base') %>
<%= @pay.hourly_rate_cents ? "#{format_hours(@pay.paid_hours)} h × #{money.call(@pay.hourly_rate_cents)} = #{money.call(@pay.base_cents)}" : money.call(@pay.base_cents) %>
<% if @rules.overtime_enabled? %>
<%= t('time.pay.overtime') %>
<%= format_hours(@pay.overtime_hours) %> h · <%= money.call(@pay.overtime_premium_cents.round) %>
<% end %> + <% if tips_on %>
<%= t('time.pay.card_tips') %>
<%= money.call(@pay.card_tip_cents) %>
<%= t('time.pay.cash_tips') %>
<%= money.call(@pay.cash_tip_cents) %>
<% if @pay.adjustment_tip_cents != 0 %>
<%= t('time.pay.tip_adjustments') %>
<%= money.call(@pay.adjustment_tip_cents) %>
<% end %> <% if (@pay.tip_cents - @pay.card_tip_cents - @pay.cash_tip_cents - @pay.adjustment_tip_cents) != 0 %>
<%= t('time.pay.other_tips') %>
<%= money.call(@pay.tip_cents - @pay.card_tip_cents - @pay.cash_tip_cents - @pay.adjustment_tip_cents) %>
<% end %> + <% end %>
<%= t('time.pay.commission') %>
<%= money.call(@pay.commission_cents) %>
<%= t('time.pay.card_bonus') %>
<%= money.call(@bonus_cents) %>
@@ -45,21 +48,24 @@

<%= t('time.pay.by_day') %>

- + <% if tips_on %><% end %> <% @days.each do |row| %> + <% if tips_on %> + <% end %> <% end %> - + <% if tips_on %><% end %>
<%= t('time.pay.day') %><%= t('time.pay.hours') %><%= t('time.pay.card_tips') %><%= t('time.pay.cash_tips') %>
<%= t('time.pay.day') %><%= t('time.pay.hours') %><%= t('time.pay.card_tips') %><%= t('time.pay.cash_tips') %>
<%= row[:date].strftime('%a %-d') %><% if row[:date] > Date.current %> <%= t('time.pay.upcoming') %><% end %> <%= row[:hours].positive? ? "#{format_hours(row[:hours])} h" : '–' %><%= row[:card_cents].zero? ? '–' : money.call(row[:card_cents]) %><% if row[:estimated] && row[:card_cents] != 0 %> <%= t('time.pay.estimated') %><% end %> <%= row[:cash_cents].zero? ? '–' : money.call(row[:cash_cents]) %>
<%= t('time.pay.total') %><%= format_hours(@pay.paid_hours) %> h<%= money.call(@pay.card_tip_cents) %><%= money.call(@pay.cash_tip_cents) %>
<%= t('time.pay.total') %><%= format_hours(@pay.paid_hours) %> h<%= money.call(@pay.card_tip_cents) %><%= money.call(@pay.cash_tip_cents) %>
+<% if tips_on %>

<%= t('time.pay.tips') %>

<%= @time_rules.tip_method_label %>
<% if @allocations.empty? %> @@ -103,6 +109,7 @@ <%= f.submit t('time.pay.save_cash'), class: 'btn-primary', data: { turbo_submits_with: t('time.pay.saving') } %> <% end %>
+<% end %> <%= render 'employee_workspace/payslips' %>

<%= link_to t('time.pay.report_problem'), employee_reports_path(anchor: 'problems'), class: 'text-olive underline' %>

diff --git a/web/app/views/employee_workspace/reports.html.erb b/web/app/views/employee_workspace/reports.html.erb index cb7b69b02..a66816eff 100644 --- a/web/app/views/employee_workspace/reports.html.erb +++ b/web/app/views/employee_workspace/reports.html.erb @@ -4,7 +4,7 @@ <%= render 'shared/time_range_picker', from: @from, to: @to, hint: "Any start and end date, up to one year. Days run midnight to midnight in the business time zone (#{Time.zone.name})." %>
<%= f.submit 'Apply', class: 'btn-primary' %><%= link_to 'Download my hours', personal_reports_path(format: :csv, from: @from, to: @to), class: 'btn-ghost' %>
<% end %> -

Timesheet hours

<%= number_with_precision(@summary.hours, precision: 2, strip_insignificant_zeros: true) %>h

<%= report_range_label(@from, @to) %>

Estimated gross pay

<%= @summary.gross_cents.nil? ? '—' : number_to_currency(@summary.gross_cents / 100.0) %>

<%= report_range_label(@from, @to) %>

+

Timesheet hours

<%= number_with_precision(@summary.hours, precision: 2, strip_insignificant_zeros: true) %>h

<%= report_range_label(@from, @to) %>

<% if @preview || time_feature?(:pay_view) %>

Estimated gross pay

<%= @summary.gross_cents.nil? ? '—' : number_to_currency(@summary.gross_cents / 100.0) %>

<%= report_range_label(@from, @to) %>

<% end %>
<%= fold_explainer('Estimates use the hourly rate effective on each recorded day, with marked overtime at the pay rules multiplier when overtime is turned on (straight time while it is off). They include draft hours and exclude deductions, commissions and clock time not yet imported. Missing rates and salary periods have no hourly estimate. Square history uses its recorded wage and paid time; it does not determine overtime, deductions or payment status.', class: 'text-xs text-mocha') %>

Hours

<% if @summary.entries.empty? %>

No timesheet hours in this period.

<% end %> @@ -16,7 +16,7 @@ <% if @summary.requests.empty? %>

No time-off requests in this period.

<% end %> <% @summary.requests.each do |leave| %>

<%= leave.leave_type.humanize %> · <%= leave.starts_on.strftime('%b %-d') %> – <%= leave.ends_on.strftime('%b %-d, %Y') %> · <%= leave.status.humanize %>

<% end %>
-<%= render 'employee_workspace/payslips' %> +<%= render 'employee_workspace/payslips' if @preview || time_feature?(:pay_view) %>

Report a problem

Something wrong with your hours, pay or time off? Tell your administrator here. For a clock time that needs changing, <%= link_to 'request a clock correction', personal_time_clock_path, class: 'text-olive underline' %>.

<% if @work_report.errors.any? %><% end %> diff --git a/web/app/views/layouts/employee.html.erb b/web/app/views/layouts/employee.html.erb index 887852436..4350f2876 100644 --- a/web/app/views/layouts/employee.html.erb +++ b/web/app/views/layouts/employee.html.erb @@ -36,6 +36,10 @@