# Project Hourly Budget — Implementation Plan

**Spec:** docs/specs/2026-05-31-project-hourly-budget.md  ·  **Slug:** project-hourly-budget  ·  **Wave:** 6
**Depends on:** foundation-auth-rbac, projects-module, real-time-infrastructure, time-management

## Goal
Add an optional lifetime **hours budget** to hourly projects (`billing_type = 'hourly'`). Owners set `budget_hours` and a `budget_alert_pct`; the system tracks consumed hours against the budget, surfaces progress and over-budget states across the project UI and time-entry form, and fires a one-time notification (in-app + email) to the project owner and tenant admins when the alert threshold is first crossed. No schema migration is required — all budget fields live inside the existing `projects.billing_config` JSONB column.

## Architecture
- **Storage:** Budget settings extend the existing `projects.billing_config` JSONB shape (owned by `projects-module`). No new table, no new column. New keys: `budget_hours`, `budget_alert_pct`, `budget_alert_email`, `budget_alert_inapp`, and the fire-once state flag `budget_alert_sent`.
- **Consumed-hours source:** `logged_hours` is computed from `time_entries` (owned by `time-management`): `SUM(duration_seconds) / 3600` for all entries where `project_id` matches — every status, all team members AND external contractors (i.e. no `billable` filter, no `user_id` filter). `billable_hours` is the same sum restricted to `billable = true`.
- **One new read endpoint:** `GET /api/projects/:id/budget` returns the budget summary. Budget writes reuse the existing `PATCH /api/projects/:id` (updates `billing_config`).
- **Alert evaluation:** A new exported helper `evaluateBudgetAlert(db, queue, env, tenantId, projectId)` is invoked from the time-entry create/stop path in `time-management` (its `POST /api/time/start` consumed-hours don't change, but `POST /api/time/:id/stop`, `POST /api/time` manual log, `PATCH /api/time/:id`, the beacon stop, and the stale-timer cron all finalize `duration_seconds` and therefore change consumed hours — hook those). The helper recomputes `logged_hours`, and if `logged_hours / budget_hours >= budget_alert_pct / 100` AND `budget_alert_sent` is not already true, it: creates an in-app `createNotification` for the project owner + each tenant admin, sends an email via `sendEmail`, then sets `budget_alert_sent = true` inside `billing_config`. When `PATCH /api/projects/:id` raises `budget_hours` such that the threshold is no longer met, it resets `budget_alert_sent = false` so a future crossing re-fires.
- **Realtime:** The `RealtimeEventType` union is closed and owned by `real-time-infrastructure`; it defines no budget event. The live time-entry budget indicator is therefore **purely client-side** (recomputed from the duration field as the user types) — no realtime event is published or consumed. The dependency is satisfied only insofar as budget summaries refresh on the existing data refetch.
- **Upstream consumed:** tables `projects`, `project_members`, `time_entries`; exports `tenantQuery`, `requirePermission`, `createNotification`, `sendEmail`, `buildPaginated` (not needed here), `Progress` (UI), `Sheet`, `Switch`, `Input`, `Button`, `Alert`, `Badge`, `useDirection`. Permissions `projects:read` / `projects:write` (owned by projects-module).
- **Cross-module integration points (parallel, same wave 6 — do NOT build into them):**
  - **[Notify client]** on the over-budget banner links to the invoices-core invoice-compose route (`/projects/:id/invoices/compose` or equivalent), pre-filling a custom email (subject "Additional hours notification") — this is a plain link/navigation, not an invoice. invoices-core is wave 6; do not implement compose here.
  - The **Time Reports** budget column (spec 56 `/reports/time`) consumes the exported `getProjectBudgetSummary` helper / `useProjectBudget` hook; time-reports is wave 6 and builds the column itself. Export the helper/hook so it can.

## Tech Stack
- **API:** `apps/zync-api` (Hono on Cloudflare Workers) — new route file for the budget endpoint, new server helper module for budget math + alert evaluation. Drizzle ORM over Neon Postgres via Hyperdrive.
- **App:** `apps/zync-app` (Vite + React) — project-settings budget section, Overview progress bar, detail over-budget banner, time-entry budget indicator, `useProjectBudget` query hook.
- **Packages:** `@zync/types` (budget types), `@zync/notifications` (`createNotification`, `sendEmail`), `@zync/ui` (`Progress`, `Sheet`, `Switch`, `Alert`, `Badge`, design tokens `--success`/`--warning`/`--danger`).
- **Bindings:** Hyperdrive (DB). No new bindings. `REALTIME_QUEUE` is NOT used by this spec.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 6a | 1 (types + billing_config shape) | `packages/types`, project schema docs | No — blocks all |
| 6b | 2 (budget math helpers), 3 (budget endpoint), 4 (alert eval + wiring) | `apps/zync-api`, `packages/notifications` | 2 blocks 3 & 4; 3 ∥ 4 after 2 |
| 6c | 5 (settings UI), 6 (overview progress bar), 7 (over-budget banner), 8 (time-entry indicator), 9 (query hook) | `apps/zync-app`, `@zync/ui` | 9 blocks 5/6/7/8; 5/6/7/8 ∥ after 9 |

## Tasks

### Task 1: Extend `billing_config` hourly shape + budget types
**Blocks:** 2, 3, 4, 5, 6, 7, 8, 9  ·  **Blocked by:** —
**Files:**
- Modify: `packages/types/src/projects.ts` (or wherever the hourly `billing_config` type is declared)
- Modify: `packages/types/src/index.ts` (re-export new types)
**Steps:**
- [ ] Extend the hourly `billing_config` TypeScript type with the new optional budget fields. No SQL migration — `projects.billing_config` is already `JSONB`.
- [ ] Add `BudgetSummary` and `ProjectBudgetConfig` types and export them.
- [ ] Document the canonical hourly `billing_config` shape (below) in a comment so the implementing agent and downstream consumers agree on key names.
**Schema / Interfaces:**
```ts
// Canonical hourly billing_config shape (projects.billing_config JSONB — NO migration)
export interface HourlyBillingConfig {
  rate_per_hour: number;
  overtime_enabled: boolean;
  overtime_threshold_hours: number;
  overtime_multiplier: number;
  // --- budget extension (this spec) ---
  budget_hours: number | null;        // total hours budgeted for project lifetime; null = no budget
  budget_alert_pct: number;           // percent consumed at which to alert; default 80
  budget_alert_email: boolean;        // send email on alert; default true
  budget_alert_inapp: boolean;        // create in-app notification on alert; default true
  budget_alert_sent: boolean;         // fire-once state: true once threshold alert fired; reset on budget raise
}

export interface ProjectBudgetConfig {
  budget_hours: number | null;
  budget_alert_pct: number;           // default 80
  budget_alert_email: boolean;        // default true
  budget_alert_inapp: boolean;        // default true
}

export interface BudgetSummary {
  budget_hours: number | null;
  logged_hours: number;     // SUM(duration_seconds)/3600 over ALL entries for project
  billable_hours: number;   // same, restricted to billable = true
  alert_pct: number;        // budget_alert_pct
  over_budget: boolean;     // logged_hours >= budget_hours (false when budget_hours null)
}
```
**Acceptance:**
- [ ] `HourlyBillingConfig`, `ProjectBudgetConfig`, `BudgetSummary` are exported from `@zync/types`.
- [ ] No `.sql` migration file is added; `git grep` for `ALTER TABLE projects` yields nothing for this spec.

### Task 2: Budget math helpers (server)
**Blocks:** 3, 4  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-api/src/lib/budget.ts`
**Steps:**
- [ ] Implement `computeBudgetHours(db, tenantId, projectId)` → returns `{ logged_hours, billable_hours }` by querying `time_entries` scoped via `tenantQuery` to the tenant; `logged_hours` = `SUM(duration_seconds) / 3600.0` over ALL entries with the given `project_id` (no `billable`, no `user_id` filter, includes contractor entries); `billable_hours` = same restricted to `billable = true`. Round both to 2 decimal places.
- [ ] Implement `getProjectBudgetSummary(db, tenantId, projectId)` → loads the project, reads `billing_config`, calls `computeBudgetHours`, returns a `BudgetSummary`. `over_budget = budget_hours != null && logged_hours >= budget_hours`.
- [ ] Treat NULL `duration_seconds` (running timers) as 0 in the SUM via `COALESCE`.
- [ ] Export both from a barrel so the route and the time-reports column can import `getProjectBudgetSummary`.
**Schema / Interfaces:**
```ts
export async function computeBudgetHours(
  db: Db, tenantId: string, projectId: string
): Promise<{ logged_hours: number; billable_hours: number }>;

export async function getProjectBudgetSummary(
  db: Db, tenantId: string, projectId: string
): Promise<BudgetSummary>;
// SQL (Drizzle): SELECT
//   ROUND(COALESCE(SUM(duration_seconds),0)/3600.0, 2) AS logged_hours,
//   ROUND(COALESCE(SUM(duration_seconds) FILTER (WHERE billable),0)/3600.0, 2) AS billable_hours
//   FROM time_entries WHERE tenant_id = $tenant AND project_id = $project
```
**Acceptance:**
- [ ] `getProjectBudgetSummary` returns `over_budget = false` when `budget_hours` is null.
- [ ] `logged_hours` counts contractor entries (`user_id IS NULL, contractor_id` set) and non-billable entries.
- [ ] Running entries (`duration_seconds IS NULL`) contribute 0, not error.

### Task 3: `GET /api/projects/:id/budget` endpoint
**Blocks:** 9  ·  **Blocked by:** 2
**Files:**
- Modify: `apps/zync-api/src/routes/projects.ts` (add route to existing projects router)
**Steps:**
- [ ] Add `GET /api/projects/:id/budget`. Apply `authMiddleware` + `requirePermission('projects:read')`.
- [ ] Resolve `:id`, ensure the project belongs to the session tenant via `tenantQuery`; 404 if not found.
- [ ] Call `getProjectBudgetSummary(db, session.tid, id)` and return the `BudgetSummary` JSON.
- [ ] Confirm budget WRITES are already covered by the existing `PATCH /api/projects/:id` (it persists `billing_config`); no new write route. Add Task 4's reset logic into that PATCH handler.
**Schema / Interfaces:**
```
GET /api/projects/:id/budget       (requires projects:read)
  200 → { budget_hours, logged_hours, billable_hours, alert_pct, over_budget }
  404 → project not found in tenant
```
**Acceptance:**
- [ ] Response shape exactly matches `BudgetSummary` (keys: `budget_hours`, `logged_hours`, `billable_hours`, `alert_pct`, `over_budget`).
- [ ] A user without `projects:read` gets 403.
- [ ] Cross-tenant project id returns 404, never another tenant's data.

### Task 4: Budget alert evaluation + reset + wiring
**Blocks:** —  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-api/src/lib/budget-alert.ts`
- Modify: `apps/zync-api/src/routes/projects.ts` (reset flag in PATCH handler)
- Modify: `apps/zync-api/src/routes/time.ts` (invoke evaluator after duration-finalizing mutations)
- Modify: `apps/zync-api/src/cron/time-cleanup.ts` (invoke evaluator after stale-timer auto-stop)
**Steps:**
- [ ] Implement `evaluateBudgetAlert(db, env, tenantId, projectId)`:
  - Load project + `billing_config`. If `billing_type != 'hourly'` or `budget_hours` is null → return (no-op).
  - Compute `logged_hours` via `computeBudgetHours`.
  - If `budget_alert_sent === true` → return (already fired).
  - If `logged_hours / budget_hours < budget_alert_pct / 100` → return (threshold not reached).
  - Threshold crossed for first time: determine recipients = project owner (`project_members.role = 'owner'`, fallback `projects.created_by`) + all tenant admins (users whose role is admin/owner per `foundation-auth-rbac`).
  - If `budget_alert_inapp`: for each recipient call `createNotification` with title `Budget alert: {project_name} has reached {pct}%` and body containing current hours, budget hours, hours remaining, and a link to `/projects/:id`.
  - If `budget_alert_email`: call `sendEmail` to each recipient, subject `Budget alert: {project_name} has reached {pct}%`, same body.
  - Set `billing_config.budget_alert_sent = true` and persist via `tenantQuery` update on `projects`.
  - Wrap the whole call site in `ctx.waitUntil(...)` so it never blocks the time-entry response.
- [ ] In the `PATCH /api/projects/:id` handler: after applying `billing_config`, if the new `budget_hours` is set AND `logged_hours / budget_hours < budget_alert_pct / 100` (threshold no longer met) → set `budget_alert_sent = false` in the persisted `billing_config` so a future crossing re-fires. (Recompute `logged_hours` cheaply via `computeBudgetHours`.)
- [ ] Wire `ctx.waitUntil(evaluateBudgetAlert(...))` into every time mutation that finalizes `duration_seconds`: `POST /api/time/:id/stop`, `POST /api/time` (manual log), `PATCH /api/time/:id`, `POST /api/time/beacon`, and the `/api/cron/time-cleanup` auto-stop loop. Use the entry's `project_id`.
- [ ] Do NOT make an HTTP self-call to `POST /api/notifications`; call `createNotification` directly (the spec's "POST /api/notifications" is conceptual).
**Schema / Interfaces:**
```ts
export async function evaluateBudgetAlert(
  db: Db, env: Env, tenantId: string, projectId: string
): Promise<void>;
// fires at most once per threshold crossing; idempotent via billing_config.budget_alert_sent
```
**Acceptance:**
- [ ] Logging time that crosses `budget_alert_pct` fires exactly ONE in-app + email alert to owner + admins; a second crossing time entry does not re-fire.
- [ ] Raising `budget_hours` above current usage (so threshold no longer met) resets `budget_alert_sent`; subsequently re-crossing fires again.
- [ ] Non-hourly projects and projects with `budget_hours = null` never alert.
- [ ] Alert evaluation runs in `ctx.waitUntil` and does not delay the time-entry response.

### Task 5: Budget configuration UI (project settings, hourly-only)
**Blocks:** —  ·  **Blocked by:** 9
**Files:**
- Create: `apps/zync-app/src/features/projects/BudgetSettings.tsx`
- Modify: project settings tab / edit sheet (`apps/zync-app/src/features/projects/ProjectSettings.tsx` or the edit `Sheet`)
**Steps:**
- [ ] Render the "Budget" section ONLY when `billing_type === 'hourly'`.
- [ ] Controls: enabled/none radio (Enabled sets `budget_hours` to a number; None sets it `null`), `budget_hours` number `Input` (suffix "h"), `budget_alert_pct` number `Input` (suffix "%") with a live helper "({pct}% × {budget_hours}h = {threshold}h of {budget_hours}h)", a `Switch`/checkbox for `budget_alert_email`, and one for `budget_alert_inapp`.
- [ ] Save via existing `PATCH /api/projects/:id` sending `{ billing_config: { ...existing, budget_hours, budget_alert_pct, budget_alert_email, budget_alert_inapp } }`. Preserve all existing hourly keys (`rate_per_hour`, overtime fields).
- [ ] Defaults when enabling: `budget_alert_pct = 80`, `budget_alert_email = true`, `budget_alert_inapp = true`.
- [ ] Requires `projects:write`; hide/disable for users without it.
- [ ] A11y: label every input; the email/in-app toggles are real `Switch`/checkbox with associated labels. RTL: layout uses logical properties / `useDirection`. No hardcoded colors.
**Acceptance:**
- [ ] Section is absent for `fixed` and `retainer` billing types.
- [ ] Saving preserves existing `billing_config` keys and only mutates budget fields.
- [ ] Selecting "None" persists `budget_hours: null`.

### Task 6: Budget progress bar (project Overview)
**Blocks:** —  ·  **Blocked by:** 9
**Files:**
- Create: `apps/zync-app/src/features/projects/BudgetProgress.tsx`
- Modify: project Overview tab (`apps/zync-app/src/features/projects/ProjectOverview.tsx`)
**Steps:**
- [ ] Render only for hourly projects with `budget_hours != null`.
- [ ] Use the `useProjectBudget(projectId)` hook (Task 9). Compute `pct = round(logged_hours / budget_hours * 100)`.
- [ ] Render a `Progress`-style bar plus text: `{pct}% · {logged_hours}h of {budget_hours}h used`, and a billed line `₪{billed} of ₪{budget_amount} billed [ {remaining_hours}h · ₪{remaining_amount} remaining ]` where amounts derive from `billable_hours`/`budget_hours × rate_per_hour` (use project `currency` symbol via existing currency formatting).
- [ ] Color by band using design tokens: 0–79% → `--success`; 80–99% → `--warning`; 100%+ → `--danger` and show an "Over budget" `Badge`.
- [ ] A11y: bar is `role="progressbar"` with `aria-valuenow={pct}` `aria-valuemin={0}` `aria-valuemax={100}` and an `aria-label`. Bar fill transition respects `prefers-reduced-motion` (no animation when reduced). No hardcoded color hex — token classes only.
**Acceptance:**
- [ ] Bar color matches the band (green < 80, amber 80–99, red ≥ 100) using tokens, not literals.
- [ ] "Over budget" badge shows only at ≥ 100%.
- [ ] `role="progressbar"` with correct `aria-valuenow/min/max` is present; reduced-motion disables the fill transition.

### Task 7: Over-budget banner (project detail)
**Blocks:** —  ·  **Blocked by:** 9
**Files:**
- Create: `apps/zync-app/src/features/projects/OverBudgetBanner.tsx`
- Modify: project detail shell (`apps/zync-app/src/features/projects/ProjectDetail.tsx`)
**Steps:**
- [ ] Render only when hourly and `logged_hours >= budget_hours` (i.e. `over_budget === true`).
- [ ] Use `Alert` (warning/danger variant) with text: `This project is over budget` and line `{budget_hours}h budgeted · {logged_hours}h logged · {over_hours}h over (₪{over_amount})` where `over_hours = logged_hours - budget_hours` and `over_amount = over_hours × rate_per_hour`.
- [ ] Actions: `[Update budget]` button → navigates/scrolls to the Budget settings section (Task 5). `[Notify client]` button → navigates to the invoices-core invoice-compose route for this project, pre-filling subject "Additional hours notification" to the project's customer (plain custom email, NOT an invoice). invoices-core owns compose; this is a link with query params, not a built screen.
- [ ] A11y: banner has `role="alert"` / appropriate live region from the `Alert` component; buttons are real `Button`s with discernible labels. RTL-safe. No hardcoded colors.
**Acceptance:**
- [ ] Banner appears only when `over_budget` is true.
- [ ] `[Notify client]` links to the project's invoice-compose route with the prefilled subject; it does not attempt to create an invoice here.
- [ ] `[Update budget]` reaches the budget settings section.

### Task 8: Time-entry budget indicator (client-side, live)
**Blocks:** —  ·  **Blocked by:** 9
**Files:**
- Modify: time log/edit form (`apps/zync-app/src/features/time/LogTimeSheet.tsx` and/or the timer start popover)
**Steps:**
- [ ] When the selected project is hourly with `budget_hours != null`, show a mini indicator under the project field using `useProjectBudget(projectId)`: `Budget: {logged_hours}h / {budget_hours}h ▓▓░ {pct}%`.
- [ ] Add a second "After this entry" line that recomputes live as the duration field changes: `After this entry: {logged_hours + entryHours}h / {budget_hours}h ({newPct}%)`. This recompute is **purely client-side** — derive `entryHours` from the form's duration field; do NOT call the API on each keystroke and do NOT publish/consume a realtime event (the realtime union has no budget event).
- [ ] Reuse the same color-band tokens as Task 6 for the mini bar.
- [ ] A11y: mini bar `role="progressbar"` with aria values; updates do not spam screen readers (no per-keystroke `aria-live` flooding — use the static computed label). No hardcoded colors. RTL-safe.
**Acceptance:**
- [ ] Indicator shows only for hourly projects with a budget.
- [ ] "After this entry" percentage updates as the duration field changes, with no network request per change.
- [ ] Color band matches Task 6.

### Task 9: `useProjectBudget` query hook + summary export
**Blocks:** 5, 6, 7, 8  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-app/src/features/projects/useProjectBudget.ts`
**Steps:**
- [ ] Implement `useProjectBudget(projectId)` (react-query) calling `GET /api/projects/:id/budget`, returning the `BudgetSummary`. Enabled only when `projectId` is set and the project is hourly with a budget.
- [ ] Invalidate / refetch the budget query when the project's `billing_config` is saved (Task 5) and on the existing time-data refetch (after logging/editing/deleting a time entry), so progress reflects new hours without a dedicated realtime event.
- [ ] Export `useProjectBudget` and re-export `BudgetSummary` for downstream consumers (time-reports column, spec 56).
**Schema / Interfaces:**
```ts
export function useProjectBudget(projectId: string | null): {
  data: BudgetSummary | undefined; isLoading: boolean; refetch: () => void;
};
```
**Acceptance:**
- [ ] Hook fetches `/api/projects/:id/budget` and is consumed by Tasks 5–8.
- [ ] Saving budget settings or mutating a time entry triggers a budget refetch.
- [ ] `useProjectBudget` and `BudgetSummary` are exported for time-reports to import.
