# Time Entries → Invoice Creation UI — Implementation Plan

**Spec:** docs/specs/2026-05-31-time-to-invoice.md  ·  **Slug:** time-to-invoice  ·  **Wave:** 7
**Depends on:** foundation-auth-rbac, invoices-core, projects-module, time-management

## Goal
Close the loop between time tracking (spec 13) and invoicing (spec 15): let users select unbilled billable time entries and turn them into invoice line items. Time entries gain an `invoice_id` / `billed_at` link so each entry is billed at most once, and the link is cleared if the invoice is deleted/voided (via `ON DELETE SET NULL`). The feature spans a selector modal in the invoice form, a "Generate Invoice" flow from the `/time` page, unbilled indicators on `/time` and project detail, and a transactional "mark billed on save" extension to `POST /api/invoices`.

## Architecture
This spec adds **no new tables** — it ALTERs the existing `time_entries` table (owned by `time-management`) with two columns and wires UI + API on top of `invoices-core` and `projects-module`.

Data flow:
- **Selector → invoice form:** User opens the Time Entry Selector modal from the New Invoice form. The modal loads unbilled entries via `GET /api/time?unbilled=true&projectId=&customerId=` (the `/time`-page filter endpoint, **extended here**). It computes line items per the chosen grouping and appends them to the in-progress invoice form; selected entry IDs are held client-side in `pendingBilledEntries[]`.
- **/time → invoice:** Checkboxes on `/time` select entries; "Generate Invoice" navigates to `/invoices/new?project_id={projectId}&from_time=true` carrying entry IDs.
- **Save:** `POST /api/invoices` (owned by `invoices-core`) is **extended** to accept `billedEntryIds?: string[]`. In the same transaction that creates the invoice + lines, it sets `time_entries.invoice_id = newInvoiceId, billed_at = now()` for those IDs.
- **Unbilled query data source:** `invoices-core` already owns `GET /api/invoices/unbilled-time?projectId=` (the invoice-form data source). This spec does **not** redefine it; it extends the `/time` filter (`GET /api/time?unbilled=true`) and adds the `billedEntryIds[]` contract.

Upstream tables consumed: `time_entries` (cols: `id, tenant_id, user_id, contractor_id, project_id, task_id, description, started_at, stopped_at, duration_seconds, billable`), `invoices`, `invoice_lines`, `projects` (`billing_config` JSONB, `billing_type`), `project_members` (`hourly_rate` override).

**Hourly rate resolution (IMPORTANT — `projects` has no `hourly_rate` column):** the project rate lives in `projects.billing_config->>'hourly_rate'` when `billing_type = 'hourly'`. A per-member override may exist in `project_members.hourly_rate` for the entry's `user_id`. Resolution: `project_members.hourly_rate` (for entry.user_id) → else `projects.billing_config->>'hourly_rate'` → else 0; always overridable in the modal's rate field.

Upstream exports consumed: `serializeInvoice`, `serializeInvoiceLine`, `InvoiceObject`, `InvoiceLineObject`, `InvoiceStatus`, `authMiddleware`, `requirePermission`, `tenantQuery`, `buildPaginated`, `Dialog`, `Checkbox`, `Button`, `DataTable`, `Select`, `Input`, `useDirection`, `toast`.

## Tech Stack
- **apps/zync-api** (Hono on Workers): extend `GET /api/time` filter and `POST /api/invoices` handler; new service helpers in `packages/db` for unbilled queries + billed-marking.
- **apps/zync-app** (Vite + React): selector modal, `/time` checkbox/selection UI + "Generate Invoice", `/invoices/new` `from_time` handling, unbilled indicators, project-detail unbilled chip; React Query hooks.
- **packages/db** (Drizzle): `time_entries` schema delta + migration; `markTimeEntriesBilled` / `listUnbilledTimeEntries` query helpers.
- **packages/types**: `BilledEntryGrouping` type, `UnbilledTimeEntry` shape.
- Bindings: Hyperdrive (Neon Postgres) via existing `createDb`. No new bindings.
- Cross-cutting: zod validation on routes, audit-in-transaction for billed-marking, dialog a11y (role, focus trap, labeled checkboxes), RTL/Hebrew (₪ amounts, `useDirection`), `prefers-reduced-motion` on modal transitions.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A (schema) | 1 | packages/db schema + migration | No (blocks all) |
| B (server) | 2, 3, 4 | packages/db helpers, apps/zync-api routes | After A; 2 before 3/4 |
| C (client data) | 5 | apps/zync-app hooks, packages/types | After B |
| D (UI) | 6, 7, 8, 9 | apps/zync-app components/pages | 6 before 7/8/9; 7/8/9 parallel |
| E (verify) | 10 | tests | Last |

## Tasks

### Task 1: Schema delta + migration — `time_entries.invoice_id`, `billed_at`
**Blocks:** 2, 3, 4  ·  **Blocked by:** —
**Files:**
- Modify: `packages/db/src/schema/time.ts` (the `timeEntries` Drizzle table)
- Create: `packages/db/migrations/<timestamp>_time_entries_invoice_link.sql`
**Steps:**
- [ ] Add `invoiceId` and `billedAt` columns to the existing `timeEntries` Drizzle table definition (do not redeclare the table; extend it).
- [ ] Emit the SQL migration. Migration ORDER constraint (per `docs/specs/00-index.md` line 493/553): this ALTER must run **after** `invoices` exists — guaranteed because `invoices-core` is wave 6 and this is wave 7. The FK targets `invoices(id)`.
- [ ] Add an index to accelerate unbilled lookups: `(tenant_id, project_id) WHERE invoice_id IS NULL AND billable = true`.
**Schema / Interfaces:**
```sql
ALTER TABLE time_entries ADD COLUMN invoice_id UUID REFERENCES invoices(id) ON DELETE SET NULL;
ALTER TABLE time_entries ADD COLUMN billed_at TIMESTAMPTZ;

CREATE INDEX time_entries_unbilled_idx
  ON time_entries (tenant_id, project_id)
  WHERE invoice_id IS NULL AND billable = true;
```
Drizzle (added to existing `timeEntries`):
```ts
invoiceId: uuid('invoice_id').references(() => invoices.id, { onDelete: 'set null' }),
billedAt: timestamp('billed_at', { withTimezone: true }),
```
**Acceptance:**
- [ ] `drizzle-kit` generates exactly the two columns + index; no recreation of `time_entries`.
- [ ] Migration applies cleanly against a DB where `invoices` already exists; FK enforced.

### Task 2: DB query helpers — list unbilled + mark billed
**Blocks:** 3, 4  ·  **Blocked by:** 1
**Files:**
- Create: `packages/db/src/queries/time-billing.ts`
- Modify: `packages/db/src/index.ts` (export new helpers)
**Steps:**
- [ ] Implement `listUnbilledTimeEntries` — returns billable entries with `invoice_id IS NULL`, filtered by tenant and optional `projectId` / `customerId` (customer resolved via `projects.customer_id`) / `userId` / date range. Joins `projects` to surface project name + resolved hourly rate.
- [ ] Implement `resolveHourlyRate(entry, project, member)` — returns `project_members.hourly_rate` for `entry.user_id` if set, else `projects.billing_config->>'hourly_rate'::numeric`, else 0.
- [ ] Implement `markTimeEntriesBilled(tx, { tenantId, invoiceId, entryIds })` — runs inside a caller-provided transaction; UPDATE sets `invoice_id`, `billed_at = now()` only for rows where `tenant_id` matches, `id = ANY(entryIds)`, `invoice_id IS NULL`, `billable = true`. Returns updated count; caller asserts count === entryIds.length (else throws → rolls back). Emits an audit row in the same transaction (require-audit-in-transaction).
- [ ] All queries go through `tenantQuery` scoping — no cross-tenant leakage.
**Schema / Interfaces:**
```ts
export interface UnbilledTimeEntry {
  id: string;
  projectId: string;
  projectName: string;
  customerId: string | null;
  userId: string | null;
  userName: string | null;
  description: string | null;
  startedAt: string;       // ISO
  durationSeconds: number;
  hours: number;           // durationSeconds / 3600, rounded to 2dp
  hourlyRate: number;      // resolved per rule above
  amount: number;          // hours * hourlyRate
}

export function listUnbilledTimeEntries(
  db: Db,
  args: { tenantId: string; projectId?: string; customerId?: string; userId?: string; from?: string; to?: string },
): Promise<UnbilledTimeEntry[]>;

export function markTimeEntriesBilled(
  tx: Db,
  args: { tenantId: string; invoiceId: string; entryIds: string[] },
): Promise<number>;  // throws if updated count !== entryIds.length
```
**Acceptance:**
- [ ] `markTimeEntriesBilled` updates 0 rows (and throws) if any entry is already billed or belongs to another tenant.
- [ ] `listUnbilledTimeEntries` excludes `billable = false` and already-billed entries.

### Task 3: Extend `GET /api/time` with `unbilled` filter
**Blocks:** 5  ·  **Blocked by:** 2
**Files:**
- Modify: `apps/zync-api/src/routes/time.ts`
**Steps:**
- [ ] Extend the existing `GET /api/time` handler to accept query params `unbilled` (boolean), `projectId`, `customerId`, `userId`, `from`, `to`.
- [ ] When `unbilled=true`, delegate to `listUnbilledTimeEntries`; otherwise existing behavior unchanged.
- [ ] Validate query with zod (require-zod-validation-in-routes); coerce `unbilled` to boolean.
- [ ] Guard with `authMiddleware` + `requirePermission('time:read')`; paginate via `buildPaginated`.
**Schema / Interfaces:**
```
GET /api/time?unbilled=true&projectId=&customerId=&userId=&from=&to=
  → 200 PaginatedResponse<UnbilledTimeEntry> when unbilled=true
```
**Acceptance:**
- [ ] `unbilled=true` returns only unbilled billable entries; absent/false preserves prior list semantics.
- [ ] Cross-tenant project/customer IDs return empty, not other tenants' data.

### Task 4: Extend `POST /api/invoices` with `billedEntryIds[]` (transactional mark)
**Blocks:** 5  ·  **Blocked by:** 2
**Files:**
- Modify: `apps/zync-api/src/routes/invoices.ts` (the existing `POST /api/invoices` handler owned by invoices-core)
**Steps:**
- [ ] Extend the create-invoice zod body schema with optional `billedEntryIds: string[]` (uuid array).
- [ ] Within the existing create transaction: after inserting the invoice + lines, if `billedEntryIds` is non-empty call `markTimeEntriesBilled(tx, { tenantId, invoiceId, entryIds })`.
- [ ] If `markTimeEntriesBilled` throws (count mismatch / already billed), the whole transaction rolls back → invoice not created. Return 409 with `{ error: 'time_entries_already_billed' }`.
- [ ] Guard `invoices:write`. Use `serializeInvoice` / `serializeInvoiceLine` for the response (existing).
**Schema / Interfaces:**
```
POST /api/invoices
  body: { ...existing invoice fields, billedEntryIds?: string[] }
  → 201 InvoiceObject  | 409 { error: 'time_entries_already_billed' }
```
**Acceptance:**
- [ ] Saving with `billedEntryIds` atomically creates invoice AND marks entries; either both happen or neither.
- [ ] Re-submitting the same entry IDs (already billed) yields 409 and creates no invoice.
- [ ] Deleting/voiding the invoice clears `invoice_id` back to NULL (verified via FK `ON DELETE SET NULL`; void handler sets `invoice_id = NULL` for affected entries — add to the existing void handler).

### Task 5: Client types + React Query hooks
**Blocks:** 6, 7, 8, 9  ·  **Blocked by:** 3, 4
**Files:**
- Modify: `packages/types/src/index.ts` (add `BilledEntryGrouping`, re-export `UnbilledTimeEntry`)
- Create: `apps/zync-app/src/features/time-to-invoice/hooks.ts`
**Steps:**
- [ ] Define `BilledEntryGrouping = 'per_entry' | 'per_day' | 'total'`.
- [ ] `useUnbilledTimeEntries({ projectId, customerId, userId, from, to })` — fetches `GET /api/time?unbilled=true&...`.
- [ ] `buildInvoiceLinesFromEntries(entries, grouping, hourlyRate)` — pure function producing `InvoiceLineObject[]`-shaped drafts: `per_entry` → one line per entry (description = entry description); `per_day` → group by `started_at` date, line description = `"{date} — {n} entries"`, quantity = summed hours; `total` → single line, quantity = total hours. `unit_price = hourlyRate`, `line_total = quantity * unit_price`, `position` sequential.
- [ ] Provide a small client store/hook `usePendingBilledEntries()` holding `pendingBilledEntries: string[]` for the invoice form to submit as `billedEntryIds`.
**Schema / Interfaces:**
```ts
export type BilledEntryGrouping = 'per_entry' | 'per_day' | 'total';
export function buildInvoiceLinesFromEntries(
  entries: UnbilledTimeEntry[],
  grouping: BilledEntryGrouping,
  hourlyRate: number,
): Array<Pick<InvoiceLineObject, 'description' | 'quantity' | 'unit_price' | 'line_total' | 'position' | 'taxable'>>;
```
**Acceptance:**
- [ ] `buildInvoiceLinesFromEntries` yields 1 line for `total`, 1-per-day for `per_day`, 1-per-entry for `per_entry`; quantities sum to total hours in every grouping.

### Task 6: Time Entry Selector Modal
**Blocks:** 7  ·  **Blocked by:** 5
**Files:**
- Create: `apps/zync-app/src/features/time-to-invoice/TimeEntrySelectorModal.tsx`
**Steps:**
- [ ] Build the modal on the shared `Dialog` component. Header shows resolved Customer + Project names.
- [ ] Filters row: `All unbilled` / date range / user `Select`s. Body: a checkbox-selectable `DataTable` of unbilled entries (date, user, description, duration `Xh Ym`, amount `₪`).
- [ ] Footer summary: selected count, total hours, total amount (₪, locale-formatted).
- [ ] Grouping radio group (`per_entry` / `per_day` / `total`), default `per_day` (matches spec mock's selected ●).
- [ ] Editable hourly-rate `Input` (prefilled from resolved project rate; recomputes amounts live).
- [ ] On "Add to Invoice": call `buildInvoiceLinesFromEntries`, push lines into the invoice form, store selected IDs in `usePendingBilledEntries`, close modal. "Cancel" discards.
- [ ] **A11y/RTL:** `role="dialog"` + `aria-modal`, focus trap, ESC closes, every checkbox has an accessible label (entry description + date), radios in a labeled `radiogroup`; `useDirection` for RTL; amounts render with ₪ and Hebrew-locale digits; modal transition respects `prefers-reduced-motion`.
**Acceptance:**
- [ ] Selecting entries + grouping + "Add to Invoice" appends correct lines and records IDs.
- [ ] Keyboard-only: tab cycles within modal, ESC closes, focus returns to trigger.
- [ ] In RTL locale, layout mirrors and ₪ amounts format correctly.

### Task 7: Invoice New Form integration ("Bill time entries" + `from_time`)
**Blocks:** —  ·  **Blocked by:** 6
**Files:**
- Modify: `apps/zync-app/src/features/invoices/InvoiceNewForm.tsx`
**Steps:**
- [ ] Add a "Bill time entries →" button next to "Add line item"; opens `TimeEntrySelectorModal` auto-filtered to the form's current customer + project.
- [ ] On mount, if URL has `?from_time=true`, read `project_id` (and any carried entry IDs from session/URL), pre-open the selector pre-filtered to that project.
- [ ] On submit, include `pendingBilledEntries` as `billedEntryIds` in the `POST /api/invoices` body; on 409 `time_entries_already_billed`, show a `toast` error and keep the form.
**Acceptance:**
- [ ] "Bill time entries" opens the selector scoped to the chosen customer/project.
- [ ] Arriving via `/invoices/new?project_id=X&from_time=true` pre-opens the selector for project X.
- [ ] Saved invoice carries `billedEntryIds`; entries become billed.

### Task 8: `/time` page selection + "Generate Invoice" + unbilled dot indicator
**Blocks:** —  ·  **Blocked by:** 5
**Files:**
- Modify: `apps/zync-app/src/features/time/TimeLogPage.tsx`
**Steps:**
- [ ] Add row checkboxes; when ≥1 unbilled entry selected, show a selection bar: `[N entries selected] [Generate Invoice →]`.
- [ ] "Generate Invoice" navigates to `/invoices/new?project_id={projectId}&from_time=true` carrying selected entry IDs (session/URL). Disable/guard when selection spans multiple projects (invoice is single-project); surface a `toast` hint.
- [ ] Render the unbilled indicator dot per spec: `⚬` (hollow) for unbilled billable, `●` for billed. Provide an `aria-label` ("unbilled" / "billed") — never color/glyph alone.
**Acceptance:**
- [ ] Selecting unbilled entries reveals "Generate Invoice"; click routes with project_id + from_time + IDs.
- [ ] Each billable entry shows the correct billed/unbilled indicator with an accessible label.

### Task 9: Project detail "Unbilled hours" chip
**Blocks:** —  ·  **Blocked by:** 5
**Files:**
- Modify: `apps/zync-app/src/features/projects/ProjectDetailPage.tsx`
**Steps:**
- [ ] Compute unbilled hours for the project via `useUnbilledTimeEntries({ projectId })` (sum `hours`).
- [ ] When > 0, render a chip: `[{Xh Ym} unbilled — Generate Invoice]` linking to `/invoices/new?project_id={id}&from_time=true`.
- [ ] Hide the chip when there are no unbilled billable hours. `aria-label` describes the action; RTL-aware formatting.
**Acceptance:**
- [ ] Chip shows accurate unbilled hours and links to the pre-filtered invoice form; hidden at zero.

### Task 10: Tests
**Blocks:** —  ·  **Blocked by:** 4, 5, 6
**Files:**
- Create: `apps/zync-api/test/time-to-invoice.test.ts`
- Create: `apps/zync-app/src/features/time-to-invoice/grouping.test.ts`
**Steps:**
- [ ] API: `markTimeEntriesBilled` atomicity — invoice+mark succeed together; already-billed IDs → 409 + no invoice; cross-tenant IDs ignored.
- [ ] API: void/delete invoice → entries' `invoice_id` returns to NULL (re-billable).
- [ ] API: `GET /api/time?unbilled=true` excludes billed + non-billable entries.
- [ ] Unit: `buildInvoiceLinesFromEntries` for all three groupings (line counts + summed quantities).
**Acceptance:**
- [ ] All tests pass; atomicity and double-bill-prevention proven by test.
