Retires the Charles-pay spreadsheets. Square-paid jobs → owner enters actual per-job costs → the system computes true gross profit, margin %, and Charles's recommended pay (profit-share above break-even), nets cash advances, and produces the payroll HOURS number + a Square-verified month close. Owner only — the field role (Charles) must never see any of it.
js/pay-engine.js — the money math (single source of truth). Pure, dual browser/Node, window.PayEngine.tests/pay-engine-goldens.json + tests/run-pay-goldens.js — acceptance. npm run test:pay.supabase/functions/pay-data/index.ts — owner-only edge function (verify_jwt + profiles.role='owner', 403 for field). The ONLY server path to pay data. Returns raw jobs + pay_model; persists edits + close snapshot. Never duplicates the math.admin/pay.html → /admin/pay/ — owner-login page: Pay (month picker, per-job costs/advances/overrides), Month Close (P&L footer + immutable close), Settings (dials + per-month actual overhead).supabase/migrations/20260702_pay_tenant_settings.sql — table + owner RLS (idempotent).supabase/pay-prod-cutover.sql — the manual prod data steps (anchors + deploy). Not auto-run.tenant_settings.pay_model jsonb — dials + monthlyActualOverhead + monthlyPay (live override) + monthlySettled (immutable close snapshots).jobs.pay jsonb — pay_month ("YYYY-MM" anchor), costs[], advances[] (+ derived debited_cash), pay_override, pay_notes. Canonical billed = jobs.amount. Legacy keys billed/charles_pay/gross_profit/variable_cost are IGNORED.GP = billed − Σ costs (actuals only — no per-service estimates, model changed 7/2).sharePct × (GP above the month's overhead), capped at sharePct × jobGP, optional per-job floor. Overhead = monthlyActualOverhead[m] (flagged est until entered, then falls back to defaultMonthlyOverhead).payroll due = total comp − advances (floor 0); hours = payroll due / rate.pre-tax = billed − costs − total comp − overhead; tax reserve = taxReservePct × pre-tax; take-home = pre-tax − reserve; margin = pre-tax / billed.npm run test:pay and the live /admin/pay/ (Month Close) both reproduce EXACTLY:
billed 9,266 · costs 20 · GP 9,246 · overhead 2,462.13 · model comp 2,713.55 · advances 900 ·
total comp 2,980 · payroll due 2,080 = 52.00 hrs · pre-tax 3,803.87 · margin 41.1%.
pay.pay_month (explicit owner anchor — e.g. a July-1 job counted in June) wins; else the service month
derived from completed/scheduled/created in America/New_York. Backfill/boundary jobs should carry an explicit
pay_month (see cutover SQL) — Karen Hart completed 2026-07-01 but belongs to June's close.
Owner login → June reproduces exactly; field login → 403; no-auth → 401; cost add/remove, advance, override, overhead, dials, close/reopen all round-trip. Closed months are read-only in the UI (can't drift).
supabase/pay-prod-cutover.sql (anchors 15 June jobs, canonicalizes advances, records the let-ride override).pay-data to prod (verify_jwt=true)./admin/pay/ already defaults to prod. ?env=staging switches to staging for testing.owner, not field. The auth gate is airtight, but as an owner he'd see his own
pay. Fix: update profiles set role='field' where full_name='Charles'; (owner's call).jobs.pay is column-readable by field via direct PostgREST (RLS is row-level, both roles are
authenticated). The Pay UI + edge function are owner-only, but a field user hitting the REST API directly
could read the raw column. True hardening needs a redacting view or moving pay to an owner-only table
(spec says no new tables) — recommend as a follow-up.