Cleaning company quoting, contracts and job tracking tool
Next.js and Postgres internal tool with versioned price rules, fast on-site quotes, contracts that generate recurring jobs, photo check-ins, and margin reports.
nextjspostgrestypescriptdrizzlezodplaywright
Built 1 time, downloaded 0 times.
Install from your terminal
Run this in your project folder. It signs you in, shows what changes and asks before it overwrites a file.
npx claude-lab add cleaning-company-quoting-contracts-and-job-tracking-tool-53aa51CLAUDE.md# Cleaning company quoting, contracts and job tracking
## Purpose
Internal tool that replaces 3 spreadsheets and a shared calendar.
A salesperson on site must get a quote out in under 10 minutes,
from versioned price rules, on a phone or tablet.
Approved quotes become contracts that generate recurring jobs.
Cleaners check in and out with a photo, and managers see margin reports.
Pricing errors lose real money, so pricing code is held to the strictest rules in this repo.
Domain terms and the data model live in @SPEC.md.
Phases and /goal prompts live in PLAN.md.
Review rules are imported here so `/code-review` applies them: @REVIEW.md
## Layout
pnpm workspace:
- `apps/web`: Next.js App Router, server actions, mobile-first UI.
- `packages/pricing`: pure TypeScript. Zero I/O, zero DB imports.
Exports `calculateQuote(input, ruleSet)` and the zod rule set schema.
- `packages/db`: Drizzle schema and migrations, Postgres 16.
## Commands
- `docker compose up -d db`: start local Postgres.
- `pnpm dev`: run the web app.
- `pnpm test`: all tests (Vitest).
- `pnpm --filter @app/pricing test`: pricing tests only.
- `pnpm --filter @app/pricing test --coverage`: pricing tests with coverage.
- `pnpm typecheck`
- `pnpm lint`
- `pnpm build`
- `pnpm e2e`: Playwright (`--project=mobile` for the 390x844 viewport).
- `pnpm verify`: typecheck, lint, pricing coverage, all tests, build.
- `pnpm db:generate`: create a new migration from schema changes.
- `pnpm db:migrate`: ask first. Local or test DB only.
## Hard rule: pricing changes
- Any change under `packages/pricing/` must add or update tests in the same change.
- `pnpm --filter @app/pricing test --coverage` must keep 100% line
and branch coverage on `packages/pricing/src`.
- Never edit files in `packages/pricing/test/golden/` to make a failing test pass.
Report the diff instead and let a human decide.
- New golden cases are new files named `YYYY-QN-<case>.json`.
## Money rules
- Integer cents only, using the `Cents` branded type. Never floats, never bare `Number` for money.
- Rounding happens once, at the line-item level, half-up.
It lives only in `packages/pricing/src/round.ts`, which documents it.
- Currency is fixed to one value in config. Do not add multi-currency logic.
## Versioning rules
- Price rule sets are immutable once published (`status = published`).
A DB trigger and an app check both enforce this.
- A price change means a new `price_rule_set` row with its own `effective_from`.
- Quotes store `price_rule_set_id` plus a JSON snapshot of inputs and line items.
- Recalculating an old quote must use its stored rule set, never the current one.
## Database
- Never run destructive SQL (DROP, TRUNCATE, unscoped DELETE).
- Never edit an applied migration. Create a new one with `pnpm db:generate`.
- Use the `db-migration` skill for schema changes.
## Scope
- Accounting integration is out of scope for v1.
Keep only the `InvoiceExporter` interface stub. No API calls.
- No customer portal, no payroll in v1.
## Secrets
- Secrets live in `.env.local` (and `.env.test` for tests). Neither is committed.
- In examples, docs and fixtures use placeholders:
`YOUR_DATABASE_URL`, `YOUR_S3_BUCKET`.
- No real customer data in code or fixtures.
## Gotchas
- Store times in UTC; display in the company timezone. Recurrence must survive DST changes.
- The job generator must be idempotent: running it twice creates no duplicates.
- Photo uploads use signed URLs to S3-compatible storage. No public buckets.
- Salespeople cannot edit rule sets or see margins; cleaners see only their own jobs.
## Workflow
1. Explore, then plan, then code.
2. Use the `pricing-change` skill for any price logic or rule schema edit.
3. Run the `verify` skill before calling a task done.
4. Use the `test-runner` subagent for full test runs to keep output out of context.
5. For diffs that touch money, ask the `pricing-reviewer` subagent for a review.
## Token habits
- `/clear` between unrelated tasks.
- `/compact` at the end of each phase.
- For pricing work, raise effort with `/effort high` rather than switching models.
Changing effort keeps the prompt cache; switching models breaks it.
.claude/agents/pricing-reviewer.md---
name: pricing-reviewer
description: Read-only strong-model reviewer for diffs that touch money, pricing logic, rule set schemas or quote calculation. Use after verify passes on any pricing change.
model: opus
effort: high
tools: Read, Grep, Glob, Bash(git diff*)
disallowedTools: Edit, Write
maxTurns: 20
---
You review changes that affect prices. Pricing errors lose real money.
Before reviewing:
1. Read `REVIEW.md`.
2. Read the pricing sections of `SPEC.md`: quote inputs, the pricing pipeline order
and the price rule set model.
3. Run `git diff` (and `git diff --staged`) to get the changes.
Check the diff for:
- Float math on money, `Number` used instead of `Cents`, or results that are not integer cents.
- Rounding outside `packages/pricing/src/round.ts`, rounding more than once per line item,
or drift between line items and the total.
- Changes in `packages/pricing/src` without matching test changes.
- Edits to existing files in `packages/pricing/test/golden/` (always a blocker).
- Versioning breaks: a published rule set mutated, a schema change without a schema version
and a snapshot migration function, or a recalculation that uses the current rule set
instead of the quote's stored `price_rule_set_id`.
- Pipeline order changed from the SPEC.md order, or steps without explanation text.
- Unhandled edge inputs: 0 m2, zero rooms, empty extras, huge values, unknown room types,
a discount above the cap, results below the minimum charge.
- I/O, DB imports or non-determinism (dates, randomness) inside `packages/pricing`.
You are read-only. Do not edit files or run anything other than `git diff`.
Output a list grouped as blocker, should-fix and nit.
Each item: `file:line`, the problem, and a one-line suggested fix.
If there are no blockers, say so explicitly.
.claude/agents/test-runner.md---
name: test-runner
description: Runs typecheck, lint, test, coverage, build and e2e suites in isolation and returns only the failures. Use for full test runs to keep long output out of the main context.
model: haiku
effort: low
tools: Bash, Read, Grep
disallowedTools: Edit, Write
maxTurns: 15
---
You run the test commands from the `verify` skill and report only what failed.
Commands, in order (run only the ones you were asked for, or all of them):
1. `docker compose up -d db` if the db is not running
2. `pnpm typecheck` and `pnpm lint`
3. `pnpm --filter @app/pricing test --coverage`.
This fails if `packages/pricing/src` is below 100% lines or branches.
4. `pnpm test` and `pnpm build`
5. If asked: `pnpm e2e --project=mobile`
Rules:
- Never edit, create or delete files. You only run commands and read output.
- Never run `pnpm db:migrate` against anything but the local test DB.
- Never run destructive SQL or `docker compose down`.
Report format, max 40 lines:
- One line per step: pass or fail.
- For each failing test: `file:line`, the test name, and the assertion message.
- For coverage failures: the uncovered files and line or branch numbers.
- For type or lint errors: the first error line per file.
- Never paste full logs or passing test names.
.claude/settings.json{
"permissions": {
"allow": [
"Bash(pnpm test*)",
"Bash(pnpm --filter * test*)",
"Bash(pnpm typecheck)",
"Bash(pnpm lint*)",
"Bash(pnpm build)",
"Bash(pnpm e2e*)",
"Bash(pnpm verify)",
"Bash(pnpm db:generate)",
"Bash(git status)",
"Bash(git diff*)",
"Bash(docker compose up -d db)"
],
"ask": [
"Bash(pnpm db:migrate*)",
"Bash(git push*)",
"Bash(git commit*)",
"Bash(pnpm add*)",
"Bash(docker compose down*)"
],
"deny": [
"Bash(rm -rf*)",
"Bash(psql*DROP*)",
"Bash(*drizzle-kit drop*)",
"Bash(git push --force*)",
"Read(./.env*)",
"Edit(packages/db/migrations/meta/**)"
],
"blockWritesOutsideWorkingDirectories": true
},
"env": {
"CLAUDE_CODE_SUBAGENT_MODEL": "haiku"
},
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "f=$(jq -r '.tool_input.file_path // empty' 2>/dev/null); [ -n \"$f\" ] && pnpm prettier --write --ignore-unknown \"$f\" >/dev/null 2>&1; exit 0"
}
]
}
]
}
}
.claude/skills/db-migration/SKILL.md---
name: db-migration
description: Use when changing the Drizzle schema in packages/db, adding tables, columns or constraints, or generating and applying Postgres migrations.
---
# DB migration
Safe workflow for Drizzle migrations on Postgres 16.
## Steps
1. Edit the schema in `packages/db/src/schema/*.ts`.
2. Run `pnpm db:generate`.
3. Read the generated SQL in `packages/db/migrations/`.
Check for drops, renames and table rewrites. Flag anything destructive and ask.
4. Add a test in `packages/db/test` for every new constraint, trigger or index that enforces a rule.
5. Run `pnpm db:migrate` only against the local/test DB (`DATABASE_URL` from `.env.test`).
Ask before migrating anything else.
6. Run the `verify` skill.
## Rules
- Never edit an applied migration. Create a new one with `pnpm db:generate`.
- Never edit `packages/db/migrations/meta/**` by hand.
- Never run destructive SQL (`DROP`, `TRUNCATE`, unbounded `DELETE`).
- Change in order: additive change first, then backfill, then constraints (`NOT NULL`, FKs, uniques).
Each step is its own migration.
## Price rule sets
- Keep the immutability trigger on `price_rule_set` when touching the table.
Published rows must reject updates to `rules` and `effective_from`.
- If a migration recreates or alters the table, recreate the trigger in the same migration.
- The DB test proving the trigger rejects updates must still pass.
## Quotes
- Never rewrite stored `inputs`, `line_items` or `total_cents` on existing quotes.
- Keep the `price_rule_set_id` foreign key intact.
.claude/skills/pricing-change/SKILL.md---
name: pricing-change
description: Use whenever changing packages/pricing, the rule set zod schema, or quote calculation, so price logic changes stay tested, deterministic and version-safe.
effort: high
---
# Pricing change
Pricing errors lose real money. Follow every step, in order.
See `SPEC.md` for the pipeline order and `REVIEW.md` for blockers.
## Rules that never bend
- Money is integer cents only (`Cents` branded type). No floats, no plain `Number` for money.
- Rounding happens once, per line item, half-up, only in `packages/pricing/src/round.ts`.
- `calculateQuote(input, ruleSet)` stays pure and deterministic: no I/O, no DB imports, no `Date.now()`, no randomness.
- Published rule sets are immutable. Old quotes recompute with their stored `price_rule_set_id` and snapshot.
- 100% line and branch coverage on `packages/pricing/src`.
## Steps
### Step 1: failing test first
- Write or update a unit test for the pipeline step you are changing.
- Add a golden case describing the business example in plain words
(for example: "2-bed flat, weekly, oven extra, hits minimum charge").
- Run `pnpm --filter @app/pricing test` and confirm the new test fails for the right reason.
### Step 2: implement
- Change the smallest amount of code that makes the test pass.
- Keep `calculateQuote` pure and deterministic.
- Each pipeline step still emits a line item with explanation text for the salesperson.
- Keep `quotedMinutes` correct alongside the price.
### Step 3: properties still hold
The fast-check properties must still pass:
- price >= minimum charge
- adding a room never lowers price
- more frequent visits never raise per-visit price above one-off
- totals equal the sum of line items
- output is integer cents
If a change needs a property to be weakened, stop and ask.
### Step 4: rule schema changes
Only if the zod rule set schema changes:
- Bump the schema version.
- Add a migration function that upgrades old snapshots to the new shape.
- Old quotes must recompute to the same total.
The golden test over `test/golden/*.json` proves it.
### Step 5: verify and review
- Run the `verify` skill.
- Ask the `pricing-reviewer` subagent to review the diff.
- Fix every blocker before calling the task done.
## Golden files
- Never edit existing files in `packages/pricing/test/golden/`.
If one fails, report the diff and ask.
- Add new ones as `test/golden/YYYY-QN-<case>.json`, for example `2027-Q1-many-bathrooms.json`.
.claude/skills/verify/SKILL.md---
name: verify
description: Use after any code change and before declaring a task done, to prove typecheck, lint, pricing coverage, all tests, build and (if UI changed) mobile e2e pass.
---
# Verify
The one command sequence that proves a change works.
Run it before saying a task is done.
For long runs, delegate to the `test-runner` subagent so logs stay out of the main context.
## Steps
### Step 1: local test database
- Check the db container: `docker compose ps db`.
- If it is not running: `docker compose up -d db`.
- Run `pnpm db:migrate` against the local test DB only.
Load `DATABASE_URL` from `.env.test`, never from `.env.local` or any shared DB.
- If `DATABASE_URL` does not point at localhost, stop and ask.
### Step 2: static checks
- `pnpm typecheck`
- `pnpm lint`
### Step 3: pricing coverage
- `pnpm --filter @app/pricing test --coverage`
- Fail if coverage on `packages/pricing/src` is below 100% lines or 100% branches.
- Do not lower thresholds or add ignore comments to pass. Add tests instead.
### Step 4: all tests and build
- `pnpm test` (all packages)
- `pnpm build`
### Step 5: mobile e2e (only if UI changed)
- If anything under `apps/web` UI changed (pages, components, server actions behind forms):
`pnpm e2e --project=mobile` (Playwright, 390x844 viewport).
## Shortcut
`pnpm verify` in the root `package.json` chains steps 2 to 4.
Step 1 and step 5 still run separately.
## Report format
- One line per step: `pass` or `fail`.
- For failures: only the failing test names and the first error lines.
- Never paste full logs.
- If a golden fixture in `packages/pricing/test/golden/` fails, report the diff. Do not edit the fixture.
INSTALL.md# Install
Setup for the two developers working on the quoting and job tracking
tool with Claude Code.
## Prerequisites
- Node 22
- pnpm 9
- Docker (for the local Postgres 16 `db` service)
- Claude Code 2.1.318 or newer: check with `claude --version`
## Environment
1. Copy `.env.example` to `.env.local` and to `.env.test`.
2. Fill in `YOUR_DATABASE_URL`, `YOUR_S3_BUCKET` and `YOUR_S3_KEY`.
`.env.test` must point at the local test database only.
3. Never commit either file. Claude is denied from reading `.env*`.
4. Start Postgres: `docker compose up -d db`.
5. Install dependencies: `pnpm install`.
## Plugins
The official marketplace is added automatically.
```sh
claude plugin install code-review@claude-plugins-official
claude plugin install typescript-lsp@claude-plugins-official
claude plugin install security-guidance@claude-plugins-official
claude plugin install commit-commands@claude-plugins-official
claude plugin marketplace add anthropics/skills
claude plugin install example-skills@anthropic-agent-skills
```
`example-skills` provides `webapp-testing` for Playwright mobile e2e.
## First session
1. Run `claude` in the repo root.
2. Run `/doctor` to check the install.
3. Run `/effort high` before Phase 1 (pricing work).
## Running phases
1. Open `PLAN.md` and find the current phase.
2. Paste that phase's `/goal` prompt into Claude Code.
3. Stop early with `/goal clear`.
4. When a phase ends, run `/code-review`, then `/compact`,
then move to the next phase.
## Personal overrides
Put personal settings in `.claude/settings.local.json` (not
committed). Shared permissions and hooks stay in
`.claude/settings.json`.
## Review rules
`/code-review` reads CLAUDE.md but not REVIEW.md directly.
CLAUDE.md imports it with `@REVIEW.md`, so the pricing and data rules
are applied on every review. Keep that import line in place.
PLAN.md# Build plan
Six phases. Each one ends with a `/goal` prompt you can paste into Claude Code.
- `/goal clear` stops a running goal.
- The goal evaluator judges only what Claude shows in the conversation. Every prompt therefore asks Claude to show the `pnpm verify` summary.
- At the end of each phase:
- run `/code-review`
- then run `/compact`
- then `/clear` before the next phase if the context is large
## Phase 1: Foundation and pricing engine
Covers:
- workspace scaffold
- Drizzle schema for rule sets and quotes
- pure `calculateQuote(input, ruleSet)` with golden and property tests
Before you start:
- Effort: `/effort high`
- At the end: `/compact`
```
/goal Phase 1: workspace + versioned pricing engine with hard test coverage.
End state:
1. pnpm workspace with apps/web (Next.js App Router, TypeScript strict), packages/pricing (pure TS, no I/O, no DB imports), packages/db (Drizzle + Postgres 16 via docker compose service `db`).
2. packages/db schema and first migration for price_rule_set (id, version_label, effective_from, status draft|published|retired, rules jsonb, created_at), quote (id, price_rule_set_id FK, inputs jsonb, line_items jsonb, total_cents int, quoted_minutes int, status, created_at). A Postgres trigger rejects UPDATE of rules/effective_from when status = 'published'; a DB test proves it.
3. packages/pricing exports a zod RuleSetSchema (base rate by size band, minutes per room type, hourly rate, frequency multipliers for one-off/weekly/fortnightly/monthly, extras price list, minimum charge, max discount) and calculateQuote(input, ruleSet) returning { lineItems[{code,label,cents,explanation}], totalCents, quotedMinutes }. Money is a branded integer Cents type; rounding only in src/round.ts, half-up per line item.
4. Tests (Vitest): unit tests per pipeline step; at least 8 golden cases in packages/pricing/test/golden/2027-Q1-*.json covering small flat, large office, many bathrooms, every frequency, extras, minimum charge, zero rooms, max discount; fast-check properties: totals equal sum of line items, output is integer, price >= minimum charge, adding a room never lowers price; a versioning test showing a quote calculated with rule set A returns the same total after rule set B is published.
5. Root script `pnpm verify` runs typecheck, lint, pricing tests with coverage, all tests, build.
Check: run `pnpm verify` and show the summary output. Pricing coverage on packages/pricing/src must be 100% lines and branches. The DB immutability test must pass against the local docker db.
Constraints:
- Write tests before implementation for each pricing step; use the pricing-change skill.
- No floats for money anywhere. No network calls. No accounting integration.
- Do not edit golden fixtures after they are written to make tests pass; fix the code or ask me.
- Do not edit applied migrations; create new ones with pnpm db:generate.
- Do not run destructive SQL; only migrate the local test DB.
- Use the test-runner subagent for full test runs and show only failures.
- Use placeholder values for secrets (YOUR_DATABASE_URL).
- When done, ask the pricing-reviewer subagent to review the diff and fix any blockers.
Stop after 40 turns even if not done, and list what remains.
```
## Phase 2: Versioned rule sets and quote UI
Covers:
- manager rule-set editor (draft to publish)
- immutability trigger
- mobile quote form that returns price and line items in under 1s
- quote PDF and share link
Before you start:
- Effort: `/effort high`
- At the end: `/compact`
```
/goal Phase 2: versioned rule sets and mobile quote UI.
End state:
1. Manager rule-set editor in apps/web: create draft, edit rules validated by RuleSetSchema, publish (sets status=published and effective_from). Published sets are read-only in the UI and the app layer rejects edits, in addition to the DB trigger.
2. Mobile-first quote form for salespeople: size m2, rooms by type with counts, frequency, extras, postcode. A server action calls calculateQuote with the rule set effective today and returns total, quoted hours and line items with explanations in under 1s.
3. Saving stores price_rule_set_id plus a JSON snapshot of inputs and line items. Reopening or recalculating an old quote uses its stored rule set.
4. Quote PDF and a read-only share link with an unguessable token.
5. Playwright e2e (project mobile, 390x844): creates a quote and sees line items; another test publishes a new rule set and asserts an old quote total is unchanged.
Check: run `pnpm verify` and `pnpm e2e --project=mobile`; show the summary output of both.
Constraints:
- Use the pricing-change skill for any change in packages/pricing or the rule schema.
- Do not edit golden fixtures in packages/pricing/test/golden/. Do not edit applied migrations; add new ones with pnpm db:generate (db-migration skill).
- Integer cents only. No network calls. No accounting integration.
- Use the test-runner subagent for full runs; show only failures.
Stop after 40 turns even if not done, and list what remains.
```
## Phase 3: Contracts, recurring jobs and calendar
Covers:
- approve a quote, which creates a contract
- idempotent job generator
- area-based team assignment
- week calendar view
Before you start:
- Effort: `/effort medium`
- At the end: `/compact`
```
/goal Phase 3: contracts, recurring jobs and calendar.
End state:
1. Approving a quote creates a contract with an RRULE-style recurrence rule; one-off quotes create a single job.
2. Job generator creates jobs 12 weeks ahead, idempotent via a unique key on contract and scheduled date. Jobs carry quoted minutes and price per job.
3. New jobs are assigned to the team whose team_area contains the property postcode; managers can reassign team or date.
4. Times are stored in UTC and displayed in the company timezone from config.
5. Week calendar view filterable by team.
6. Tests: running the generator twice produces no duplicates; recurrence unit tests across a DST change (spring and autumn); assignment test for a postcode with no team.
Check: run `pnpm verify` and show the summary output.
Constraints:
- Do not edit golden fixtures or applied migrations; new migrations via pnpm db:generate (db-migration skill).
- Do not touch pricing logic; if needed, use the pricing-change skill.
- Only migrate the local test DB. No destructive SQL.
- Use the test-runner subagent for full runs; show only failures.
Stop after 40 turns even if not done, and list what remains.
```
## Phase 4: Mobile check-in and check-out with photo
Covers:
- time_entry
- camera upload to a signed URL
- auth by staff login
Before you start:
- Effort: `/effort medium`
- At the end: `/compact`
```
/goal Phase 4: mobile check-in/out with photo.
End state:
1. Staff login; cleaners see only their own jobs for today.
2. One-tap check-in and check-out page; photo required via camera input (capture). Upload to S3-compatible storage (YOUR_S3_BUCKET) via a server-issued signed URL with size and type limits; no public bucket.
3. time_entry stores check_in_at, check_out_at, photo_url and optional GPS; hours computed from the two timestamps.
4. Playwright e2e on the mobile project (390x844) checks in and out with a fixture photo and asserts the time entry is stored with hours. The signer is stubbed in tests; no real network calls.
Check: run `pnpm verify` and `pnpm e2e --project=mobile`; show the summary output of both.
Constraints:
- Do not edit golden fixtures or applied migrations; new migrations via pnpm db:generate.
- Placeholders only for secrets (YOUR_S3_BUCKET, YOUR_S3_KEY). No offline queue (v1.1).
- Use the test-runner subagent for full runs; show only failures.
Stop after 30 turns even if not done, and list what remains.
```
## Phase 5: Margin reports
Covers:
- SQL views or queries for per-client and per-team margin by month
- negative-margin flag
- CSV export
Before you start:
- Effort: `/effort high`
- At the end: `/compact`
```
/goal Phase 5: margin reports.
End state:
1. SQL views or queries: revenue = quoted price per job; cost = actual hours x staff cost rate; margin per client and per team per month, all in integer cents.
2. Flag clients with negative margin over the last 3 months.
3. Report page for managers with CSV export.
4. Test with seeded fixture data (no real customer data) asserting the expected margins and the negative-margin flag.
Check: run `pnpm verify` and show the summary output, including the margin test and a passing render test of the report page.
Constraints:
- No floats for money. Do not edit golden fixtures or applied migrations; new migrations via pnpm db:generate.
- Salespeople and cleaners cannot access the report.
- Use the test-runner subagent for full runs; show only failures.
Stop after 30 turns even if not done, and list what remains.
```
## Phase 6: Hardening and accounting seam
Covers:
- role-based access checks
- audit log on rule set and quote changes
- `InvoiceExporter` interface stub with no network calls
- seed and backup docs
Before you start:
- Effort: `/effort medium`
- At the end: `/compact`
```
/goal Phase 6: hardening and accounting seam.
End state:
1. Role checks in every server action and route: salesperson, manager, cleaner/team lead, admin, matching SPEC.md.
2. Audit log table recording who changed rule sets and quotes, when, and the before/after snapshot.
3. InvoiceExporter interface stub in apps/web with a no-op implementation; no network calls, no accounting SDKs.
4. docs/seed.md and docs/backup.md describing the seed script and Postgres backup/restore.
5. Authorization tests for each role, including denied cases.
Check: run `pnpm verify` and show the summary output, including the authorization tests.
Constraints:
- Do not edit golden fixtures or applied migrations; new migrations via pnpm db:generate.
- No secrets or real customer data in code, fixtures or docs.
- Use the test-runner subagent for full runs; show only failures.
Stop after 30 turns even if not done, and list what remains.
```
REVIEW.md# Review checklist
Imported from CLAUDE.md with `@REVIEW.md`, so `/code-review` and the
`pricing-reviewer` subagent apply it. Pricing errors lose real money:
treat every money path as high risk.
## Blockers
- Any change in `packages/pricing/src` without matching test changes
in the same diff (unit, golden or property tests).
- Floating-point math on money: `Number` or plain `number` used where
`Cents` is required, division without integer rounding, `toFixed`.
- Rounding anywhere other than `packages/pricing/src/round.ts`.
- Mutation of a published price rule set (`status = published`), in
app code, SQL or migrations, or removal of the immutability trigger.
- Quote recalculation using the current rule set instead of the
quote's stored `price_rule_set_id` and snapshot.
- Edits to applied migrations in `packages/db/migrations/`.
- Edits to existing files in `packages/pricing/test/golden/`.
## Checks
- Job generator is idempotent: running it twice for the same contract
and window creates no duplicate jobs.
- Timezones: store UTC, display in the company timezone; recurrence
stays correct across DST changes.
- Authorization: salespeople cannot edit rule sets or see margin
reports; cleaners see only their own jobs; managers approve quotes.
- Photo upload uses signed URLs, enforces size and type limits, and
never relies on a public bucket.
- No accounting API calls in v1; only the `InvoiceExporter` stub.
- No secrets or real customer data in code, fixtures or seeds; use
placeholders like `YOUR_DATABASE_URL`, `YOUR_S3_BUCKET`.
## Reporting
Group findings as:
- blocker: must fix before merge
- should-fix: fix in this change unless there is a stated reason
- nit: optional
Each finding gives `file:line`, the rule it breaks and a one-line fix.
If there are no blockers, say so explicitly.
SPEC.md# Spec: cleaning company quoting, contracts and job tracking
Status: starter spec. Edit it as decisions are made and add each one to the decision log at the bottom.
## Purpose
Replace 3 spreadsheets and a shared calendar with one internal tool.
A salesperson can get a quote out in under 10 minutes while on site.
Pricing errors lose real money, so the pricing engine is pure, versioned and fully tested.
## Users and roles
- **Salesperson**: creates quotes on a phone or tablet at the property. Sends quotes to clients.
- **Manager**: approves quotes, edits and publishes price rule sets, and sees margin reports.
- **Cleaner / team lead**: checks in and out of jobs on a phone, with a photo. Sees only their own jobs.
- **Admin**: manages staff, teams, team areas, roles and company config.
## Quote inputs
- Property size in m2.
- Room list by type, with counts:
- kitchen
- bathroom
- bedroom
- office
- common area
- other
- Frequency: one-off, weekly, fortnightly or monthly.
- Extras: windows, oven, carpet, fridge and others. The list comes from the rule set.
- Area / postcode. This sets team assignment and can feed area pricing later.
## Pricing pipeline
The order is fixed and deterministic. `calculateQuote(input, ruleSet)` runs these steps in sequence:
1. **Base rate** by size band. The bands come from the rule set.
2. **Rooms**: minutes per room type x count, converted to cents with the hourly rate.
3. **Frequency multiplier**: one-off, weekly, fortnightly or monthly.
4. **Extras**: price per selected extra.
5. **Minimum charge**: tops the total up to the rule set minimum.
6. **Discount cap**: a discount never exceeds the rule set maximum.
7. **Rounding**: half-up, once per line item, only in `packages/pricing/src/round.ts`.
Each step emits a line item: `{ code, label, cents, explanation }`.
- The explanation is plain text that the salesperson can read out to the client.
- The total always equals the sum of the line items.
- All money values are integer cents (`Cents` branded type). The currency is fixed in config.
### Quoted hours
The output includes `quotedMinutes` alongside the price.
Quoted hours are stored on the quote and copied to its jobs, so margin can be computed later against actual hours.
## Price rule sets
| Field | Notes |
|---|---|
| id | primary key |
| version_label | for example `2027-Q1` |
| effective_from | date from which new quotes use it |
| status | `draft`, `published` or `retired` |
| rules | JSON, validated by the zod `RuleSetSchema` |
| created_by | staff id |
- Published sets are immutable. Two layers enforce this:
- a DB trigger that rejects updates to `rules` and `effective_from`
- an app-level check
- Any change to a published set means creating a new draft, then publishing it with a new `effective_from`.
- A new quote uses the published set whose `effective_from` is the latest one on or before today.
- Each quote stores:
- `price_rule_set_id`
- a JSON snapshot of its inputs and line items
- Recalculating an old quote always uses its stored rule set.
- If the rule schema changes, add a schema version and a migration function for old snapshots.
## Entities
- **client**: name, contact details and billing notes.
- **property**: belongs to a client. Holds the address, postcode and size in m2.
- **quote**:
- status: `draft`, `sent`, `approved`, `rejected` or `expired`
- `price_rule_set_id`, inputs snapshot, `total_cents`, `quoted_minutes`
- **quote_line_item**: code, label, cents and explanation. It is also kept in the quote snapshot.
- **price_rule_set**: see above.
- **contract**: created from an approved quote. Holds the client, property, price per job and start date.
- **recurrence rule**: RRULE-style (`FREQ`, `INTERVAL`, `BYDAY`, `DTSTART`). Attached to a contract.
- **job**: one visit. Holds the contract, scheduled start (UTC), team, status, quoted minutes and price.
- **team**: a named group of staff.
- **team_area**: postcodes owned by a team.
- **staff**: role, login, team and cost rate in cents per hour.
- **time_entry**:
- job and staff
- `check_in_at`, `check_out_at`, `photo_url`
- optional GPS
- **invoice_export**: v2 placeholder. In v1 there is only an `InvoiceExporter` interface stub.
## Quote lifecycle
- A new quote starts as `draft`. Sending it sets the status to `sent` and creates a share link.
- A manager sets a sent quote to `approved` or `rejected`.
- Quotes expire after a configurable number of days. The default is 30.
- An approved quote cannot be edited. A change needs a new quote.
## Contracts and jobs
- Approving a quote creates a contract with a recurrence rule. One-off quotes become a single job.
- The job generator:
- creates jobs 12 weeks ahead
- is idempotent, enforced by a unique key on contract and scheduled date
- can run twice without creating duplicates
- New jobs are assigned to the team whose `team_area` contains the property postcode.
- A manager can reassign a job to another team or move its date.
- Times are stored in UTC and displayed in the company timezone (config). Recurrence must hold across DST changes.
- The calendar has a week view, filterable by team.
## Check-in and check-out
- Uses a mobile web page with one tap to check in and one tap to check out.
- A photo is required. It comes from a camera input (`capture`).
- Uploads go to S3-compatible storage (`YOUR_S3_BUCKET`) through a signed URL:
- with size and type limits
- never to a public bucket
- Hours are computed from `check_in_at` and `check_out_at`.
- An offline retry queue is planned for v1.1. It is not in v1.
## Margin
- **Revenue**: the quoted price per job.
- **Cost**: actual hours from time entries x staff cost rate.
- Reports show margin per client per month and per team per month.
- Any client with negative margin over the last 3 months is flagged.
- Reports can be exported as CSV. Only managers and admins can see them.
## Non-goals v1
- Accounting / invoice integration (only the `InvoiceExporter` stub, no API calls).
- Customer portal.
- Payroll.
## Success metrics
- Median quote time under 10 minutes.
- Zero pricing regressions caught after release.
- 100% of jobs have a check-in and a check-out.
## Open questions
- Do size bands overlap with room minutes, or does one replace the other for large offices?
- Does a discount apply before or after the minimum charge for regular contracts?
- How long should a quote share link stay valid?
- Are there per-area surcharges in v1?
## Decision log
| Date | Decision | By |
|---|---|---|
| | | |