# Billing Plans Management UI — Implementation Plan

**Spec:** docs/specs/2026-05-31-billing-plans-management-ui.md  ·  **Slug:** billing-plans-management-ui  ·  **Wave:** 9
**Depends on:** billing-module, customers-module, foundation-auth-rbac, invoices-core, projects-module

## Goal
This spec delivers the complete tenant-app frontend for managing recurring/one-time/installment payment plans whose backend (tables `payment_methods`, `payment_plans`, `payments`; API routes under `/api/billing/*`; the daily charge cron) is already shipped by `billing-module` (spec 18). It adds two route surfaces — a cross-customer Payment Plans list at `/billing/plans` and a per-customer **Billing** tab on `/customers/:id` — plus create/detail/edit sheets, plan state-transition controls (pause/resume/cancel), payment history with invoice links, and a PCI-safe provider-hosted "Add payment method" tokenization modal. No new database tables and no new API endpoints are introduced; this spec is pure UI + client data-access wired to spec 18's contracts.

## Architecture
- **Consumes upstream (billing-module / spec 18) — do not redefine:** tables `payment_methods`, `payment_plans`, `payments`; API routes `GET/POST /api/billing/plans`, `GET/PATCH/DELETE /api/billing/plans/:id`, `GET /api/billing/payments` (filtered by `payment_plan_id` for plan history), `GET /api/customers/:id/payment-methods`, `POST /api/customers/:id/payment-methods`, `DELETE /api/customers/:id/payment-methods/:mid`. `PaymentAdapter` interface, providers `morning | isracard | upay | icount_pay`. The plan-payment-history view in this spec maps to spec 18's `GET /api/billing/payments?payment_plan_id=:id`.
- **Consumes upstream (customers-module):** `useCustomer`, `useCustomerList`, `CustomerListPage` patterns, the `/customers/:id` tabbed-detail page (Overview · Contacts · Projects · Invoices · Support · Portal Users · Files · Communications) into which the new **Billing** tab is inserted. `Customer`, `CustomerObject` serializers for the customer search field.
- **Consumes upstream (invoices-core):** `InvoiceObject`, `InvoiceStatus`, route `GET /api/customers/:id` and the invoice-detail route for "[View invoice]" deep-links in payment history (link target `/invoices/:invoiceId`).
- **Consumes upstream (projects-module):** project list for the optional "Link to project" field (read-only reference; project list fetched via existing projects query).
- **Consumes upstream (foundation-design-system):** `DataTable`, `DataTablePagination`, `Sheet`, `Dialog`, `Button`, `Input`, `Select`, `Checkbox`, `Switch`, `Badge`, `Form`, `FormField`, `FormLabel`, `FormError`, `Card`, `Stack`, `Divider`, `EmptyState`, `Skeleton`, `Spinner`, `Toast`/`toast`, `Tabs`, `cn`.
- **Consumes upstream (foundation-auth-rbac):** `authMiddleware`, `requirePermission`, `hasScope`, `useTierGate`; permissions `billing:read` (view) and `billing:write` (create/edit/pause/resume/cancel, manage methods) — already defined by spec 18.
- **Consumes upstream (system-i18n / rtl):** `useDirection`, `LocaleProvider`, `translations`; all currency/date display localized (₪ symbol, `he`/`en` locale), RTL-mirrored layout, `prefers-reduced-motion` honored on sheet/modal transitions.
- **Data flow:** React Query hooks (`useBillingPlanList`, `useBillingPlan`, `useBillingPlanPayments`, `useCustomerPaymentMethods`) call the spec-18 Hono routes through the app's shared `apiClient`. Mutations (create/update/cancel/add-method/remove-method) invalidate the relevant query keys. The Add-Payment-Method modal opens the provider-hosted tokenization surface (iframe `postMessage` for morning/upay, redirect-return for hosted pages, mandate-reference text input for `isracard` direct debit) and on success POSTs the returned `provider_token` to `POST /api/customers/:id/payment-methods` — card PAN never enters the Worker or React app state.

## Tech Stack
- **App:** `apps/zync-app` (Vite + React, Cloudflare Workers SPA). Routes registered in the app router; pages under `apps/zync-app/src/features/billing/`.
- **Shared client serializers/types:** `packages/types` (re-export plan/method/payment view-model types) consumed by app.
- **API serializers (if not already exported by spec 18):** thin serializers added in `apps/zync-api` only if spec 18 did not already export them — this plan reuses spec 18 routes verbatim and only adds a `serializePaymentPlan`/`serializePaymentMethod`/`serializePayment` view-model in the app layer if the API returns raw rows. Default assumption: spec 18 routes already return serialized JSON; app maps to view-models.
- **Libraries:** `@tanstack/react-query` (data), `react-hook-form` + `zod` (forms), `date-fns` (date math for day-of-month / interval display), design-system primitives from `@zync/ui`.
- **Bindings:** none new. Routes proxy to existing `apps/zync-api` worker; tokenization uses provider-hosted surfaces (no new Cloudflare binding).
- **i18n:** Hebrew + English translation keys added under a `billing` namespace.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 9a | 1, 2 | types, query-client hooks, validation schemas | Yes (1 and 2 independent) |
| 9b | 3, 4 | shared components (StatusBadge, MethodCard, PaymentHistoryList) | Yes |
| 9c | 5, 6, 7 | Create sheet, Detail sheet, Add-method modal | 5/6/7 after 9a+9b |
| 9d | 8 | Payment Plans list page + route | After 5,6 |
| 9e | 9 | Customer Billing tab | After 4,5,6,7 |
| 9f | 10, 11 | i18n/RTL/a11y pass, tests | After 8,9 |

## Tasks

### Task 1: View-model types & client validation schemas
**Blocks:** 2,3,4,5,6,7,8,9  ·  **Blocked by:** —
**Files:**
- Create: `apps/zync-app/src/features/billing/types.ts`
- Create: `apps/zync-app/src/features/billing/schemas.ts`
- Modify: `packages/types/src/index.ts` (re-export the three view-model interfaces for cross-package consumers)
**Steps:**
- [ ] Define `PaymentPlanView`, `PaymentMethodView`, `PaymentView` interfaces mirroring spec-18 columns exactly (snake_case API → camelCase view-model), including the union literals for `type`, `interval`, `status`.
- [ ] Define the create/update zod schemas matching the spec-18 write contract (do not invent fields; map UI "Billing cycle" labels → `interval` enum values).
- [ ] Map UI billing-cycle labels to backend `interval` values: Monthly→`monthly`, Quarterly→`quarterly`, Annual→`annual`. NOTE: the UI mock lists "Weekly" and "Bi-weekly"; spec 18's `payment_plans.interval` CHECK only allows `monthly | quarterly | annual`. Restrict the Billing-cycle `Select` to the three backend-supported values plus `one_time` (via `type`) — do NOT render Weekly/Bi-weekly options, since the backend cannot persist them. Add a code comment citing this constraint.
- [ ] Export `formatPlanAmount(amount, currency, interval)` helper producing `₪2,000/mo` style strings, locale-aware.
**Schema / Interfaces:**
```ts
// View-models (camelCase) — backed by spec-18 tables, NOT redefined here
export interface PaymentMethodView {
  id: string;                 // uuid
  customerId: string;         // uuid
  type: 'credit_card' | 'direct_debit' | 'bank_transfer' | 'check';
  provider: 'morning' | 'isracard' | 'upay' | 'icount_pay' | null;
  lastFour: string | null;    // display only; never the full PAN
  expiryMonth: number | null;
  expiryYear: number | null;
  isDefault: boolean;
  createdAt: string;          // ISO TIMESTAMPTZ
}

export interface PaymentPlanView {
  id: string;                 // uuid
  customerId: string;         // uuid
  customerName: string;       // joined for list display
  projectId: string | null;   // uuid
  projectName: string | null;
  name: string;
  type: 'one_time' | 'recurring' | 'installments';
  amount: string;             // NUMERIC(12,2) as string to preserve precision
  currency: string;           // default 'ILS'
  interval: 'monthly' | 'quarterly' | 'annual' | null;
  nextBillingDate: string | null;   // ISO DATE
  installmentCount: number | null;
  installmentsPaid: number;
  status: 'ACTIVE' | 'PAUSED' | 'CANCELLED' | 'COMPLETED';
  paymentMethodId: string | null;    // uuid
  autoCharge: boolean;
  createdAt: string;
  updatedAt: string;
}

export interface PaymentView {
  id: string;                 // uuid
  customerId: string;         // uuid
  invoiceId: string | null;   // uuid → link to /invoices/:invoiceId
  invoiceNumber: string | null;
  paymentPlanId: string | null;
  paymentMethodId: string | null;
  amount: string;             // NUMERIC(12,2) as string
  currency: string;
  status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED' | 'REFUNDED';
  provider: string | null;
  providerReference: string | null;
  failureReason: string | null;
  paidAt: string | null;      // ISO TIMESTAMPTZ
  createdAt: string;
}
```
```ts
// Client write-schemas (zod) — match spec-18 POST/PATCH bodies
export const createPlanSchema = z.object({
  customerId: z.string().uuid(),
  projectId: z.string().uuid().nullable().optional(),
  name: z.string().min(1).max(200),
  type: z.enum(['one_time', 'recurring', 'installments']),
  amount: z.coerce.number().positive().finite(),
  currency: z.string().default('ILS'),
  interval: z.enum(['monthly', 'quarterly', 'annual']).nullable().optional(),
  dayOfMonth: z.number().int().min(1).max(28).nullable().optional(), // monthly UI affordance
  startDate: z.string(),                 // ISO DATE, maps to first next_billing_date
  endDate: z.string().nullable().optional(),
  installmentCount: z.number().int().min(2).max(60).nullable().optional(),
  paymentMethodId: z.string().uuid().nullable().optional(),
  autoCharge: z.boolean().default(false),       // 'Generate invoice automatically' = auto_charge
  autoSend: z.boolean().default(false),         // 'Email invoice to customer' (passes through to spec-18 flag if present)
}).refine(d => d.type !== 'recurring' || !!d.interval, { message: 'Recurring plans require an interval', path: ['interval'] })
  .refine(d => d.type !== 'installments' || (d.installmentCount ?? 0) >= 2, { message: 'Installment plans require a count', path: ['installmentCount'] });

export const updatePlanSchema = z.object({
  name: z.string().min(1).max(200).optional(),
  amount: z.coerce.number().positive().finite().optional(),
  interval: z.enum(['monthly', 'quarterly', 'annual']).nullable().optional(),
  paymentMethodId: z.string().uuid().nullable().optional(),
  autoCharge: z.boolean().optional(),
  status: z.enum(['ACTIVE', 'PAUSED', 'CANCELLED']).optional(), // pause/resume/cancel via PATCH
});

export const addPaymentMethodSchema = z.object({
  type: z.enum(['credit_card', 'direct_debit', 'bank_transfer', 'check']),
  provider: z.enum(['morning', 'isracard', 'upay', 'icount_pay']).nullable().optional(),
  providerToken: z.string().min(1),       // token or mandate reference; encrypted server-side
  lastFour: z.string().length(4).nullable().optional(),
  expiryMonth: z.number().int().min(1).max(12).nullable().optional(),
  expiryYear: z.number().int().min(2024).max(2100).nullable().optional(),
  isDefault: z.boolean().default(false),
});
```
**Acceptance:**
- [ ] Types compile and are re-exported from `@zync/types`.
- [ ] `createPlanSchema` rejects a recurring plan with no interval and an installments plan with count < 2.
- [ ] No "Weekly"/"Bi-weekly" interval value is reachable from the schema.

### Task 2: React Query data-access hooks
**Blocks:** 5,6,7,8,9  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-app/src/features/billing/api.ts` (typed fetch wrappers over spec-18 routes)
- Create: `apps/zync-app/src/features/billing/hooks.ts`
**Steps:**
- [ ] Implement typed fetchers over the existing spec-18 endpoints (no new endpoints): list/detail/create/update/cancel plans; list plan payments via `GET /api/billing/payments?payment_plan_id=:id`; list/add/remove customer payment methods.
- [ ] Implement query hooks `useBillingPlanList(filters)`, `useBillingPlan(id)`, `useBillingPlanPayments(planId)`, `useCustomerPaymentMethods(customerId)` with stable query keys `['billing','plans',filters]`, `['billing','plan',id]`, `['billing','plan',id,'payments']`, `['billing','methods',customerId]`.
- [ ] Implement mutation hooks `useCreatePlan`, `useUpdatePlan` (handles pause/resume/cancel/edit), `useCancelPlan` (DELETE), `useAddPaymentMethod`, `useRemovePaymentMethod` — each invalidates affected keys and surfaces `toast` on success/error.
- [ ] All fetchers send credentials; on 403 surface a "You need billing:write permission" toast (do not crash).
**Schema / Interfaces:**
```ts
export interface PlanListFilters { status?: 'ACTIVE'|'PAUSED'|'CANCELLED'|'COMPLETED'; customerId?: string; projectId?: string; }
export function useBillingPlanList(filters: PlanListFilters): UseQueryResult<PaginatedResponse<PaymentPlanView>>;
export function useBillingPlan(id: string): UseQueryResult<PaymentPlanView>;
export function useBillingPlanPayments(planId: string): UseQueryResult<PaymentView[]>;
export function useCustomerPaymentMethods(customerId: string): UseQueryResult<PaymentMethodView[]>;
export function useCreatePlan(): UseMutationResult<PaymentPlanView, ApiError, z.infer<typeof createPlanSchema>>;
export function useUpdatePlan(id: string): UseMutationResult<PaymentPlanView, ApiError, z.infer<typeof updatePlanSchema>>;
export function useCancelPlan(id: string): UseMutationResult<void, ApiError, void>;
export function useAddPaymentMethod(customerId: string): UseMutationResult<PaymentMethodView, ApiError, z.infer<typeof addPaymentMethodSchema>>;
export function useRemovePaymentMethod(customerId: string): UseMutationResult<void, ApiError, { methodId: string }>;
```
**Acceptance:**
- [ ] `useBillingPlanList({status:'ACTIVE'})` issues `GET /api/billing/plans?status=ACTIVE`.
- [ ] `useUpdatePlan(id).mutate({status:'PAUSED'})` issues `PATCH /api/billing/plans/:id` and invalidates both list and detail keys.
- [ ] Cancelling a plan invalidates `['billing','plans']` so the list re-renders with `CANCELLED`.

### Task 3: Plan status badge & amount/cycle formatting components
**Blocks:** 6,8,9  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-app/src/features/billing/components/PlanStatusBadge.tsx`
- Create: `apps/zync-app/src/features/billing/components/PlanAmountCell.tsx`
**Steps:**
- [ ] `PlanStatusBadge` maps status → design-system `Badge` variant/color: `ACTIVE`→success/green, `PAUSED`→warning/amber, `CANCELLED`→muted, `COMPLETED`→neutral. Use design tokens only (no hardcoded colors — honors `no-hardcoded-colors`). Localized label text.
- [ ] `PlanAmountCell` renders `formatPlanAmount` output (`₪2,000/mo`); for one-time shows amount only, for installments shows `₪X (n/total)`.
- [ ] Badge carries `aria-label` with full status text for screen readers.
**Acceptance:**
- [ ] Each status renders a distinct, token-driven badge with an accessible label.
- [ ] In RTL the badge and amount mirror correctly (no hardcoded left/right).

### Task 4: Payment method card & payment-history list components
**Blocks:** 6,7,9  ·  **Blocked by:** 1,3
**Files:**
- Create: `apps/zync-app/src/features/billing/components/PaymentMethodCard.tsx`
- Create: `apps/zync-app/src/features/billing/components/PaymentHistoryList.tsx`
**Steps:**
- [ ] `PaymentMethodCard` shows type icon, masked display (`Visa ••4242`, `Exp 08/2027`), a `Default` badge when `isDefault`, and a `[Remove]` button gated on `billing:write`. For `direct_debit` (Isracard mandate) show "הוראת קבע" / "Direct debit" label and the mandate reference suffix — never show `last_four`/expiry (unused for that type per spec 18).
- [ ] Never render a full PAN — only `lastFour`. Card data is provider-hosted; the app only ever holds `lastFour`.
- [ ] `PaymentHistoryList` renders rows: date · amount · status · invoice link · retry badge. Status `COMPLETED`→Paid, `FAILED`→Failed with a retry chronology badge (`Failed → Retried <date> → Paid`) reconstructed from consecutive same-plan payment rows. Each paid row links `[View invoice]` → `/invoices/:invoiceId` (only when `invoiceId` present).
- [ ] Empty state uses `EmptyState` ("No payments yet"); loading uses `Skeleton`.
**Acceptance:**
- [ ] A `direct_debit` method renders without exposing last_four/expiry.
- [ ] A failed-then-retried-then-paid sequence renders the retry chronology badge.
- [ ] Paid rows with an `invoiceId` deep-link to the invoice detail route.

### Task 5: Create Payment Plan sheet
**Blocks:** 8,9  ·  **Blocked by:** 1,2,4
**Files:**
- Create: `apps/zync-app/src/features/billing/components/CreatePlanSheet.tsx`
**Steps:**
- [ ] Right-side `Sheet` form (react-hook-form + `createPlanSchema`). Fields: Customer (required, async search via customer list), Project (optional, project select filtered to chosen customer), Billing cycle (`Select`: Monthly/Quarterly/Annual + a One-time toggle that sets `type`), Amount (required, ₪), Day of month (1–28 select, shown only for monthly recurring — clamp to 28 to avoid month-overflow), Start date (required date picker), End date (Ongoing default or pick date), Payment method (select from `useCustomerPaymentMethods(customerId)` or `[+ Add card]` opening the Add-method modal — Task 7), Auto-invoice checkbox (maps to `auto_charge`), Auto-send checkbox.
- [ ] Disable the Payment-method picker until a customer is chosen (methods are per-customer).
- [ ] On submit call `useCreatePlan`; on success close sheet, toast success, the parent list/tab refetches via invalidation.
- [ ] Gate the whole sheet behind `billing:write`; if absent, render a permission notice instead of the form.
- [ ] Honor `prefers-reduced-motion` on sheet open/close; full keyboard nav; focus trapped in sheet; `aria-labelledby` on the sheet title.
**Acceptance:**
- [ ] Selecting "Monthly" reveals Day-of-month; selecting "One-time" hides interval/day/end-date.
- [ ] Submitting issues `POST /api/billing/plans` with a body validated by `createPlanSchema`.
- [ ] Payment-method picker is disabled until a customer is selected.

### Task 6: Plan Detail sheet (view + state transitions + edit)
**Blocks:** 8,9  ·  **Blocked by:** 2,3,4
**Files:**
- Create: `apps/zync-app/src/features/billing/components/PlanDetailSheet.tsx`
**Steps:**
- [ ] `Sheet` opened by clicking a plan row. Header: `{customerName} — {plan.name}`. Summary line: `₪amount / interval · {StatusBadge} · Since {createdAt}`. Show Next charge date, masked payment method (`Visa ••4242`).
- [ ] Action buttons reflect state machine: `ACTIVE`→`[Pause]` (PATCH status=PAUSED), `[Edit]`, `[Cancel plan]`; `PAUSED`→`[Resume]` (PATCH status=ACTIVE), `[Edit]`, `[Cancel plan]`; `CANCELLED`/`COMPLETED`→read-only (no action buttons; history visible).
- [ ] `[Cancel plan]` opens a confirmation `Dialog` with copy "This will stop all future charges" and a destructive confirm; on confirm call `useCancelPlan` (DELETE) or `useUpdatePlan({status:'CANCELLED'})` consistent with spec-18 DELETE semantics (DELETE = cancel, history retained).
- [ ] `[Edit]` switches the sheet to an edit form (subset: name, amount, interval, payment method, auto_charge) using `updatePlanSchema` → PATCH.
- [ ] Render `PaymentHistoryList` (Task 4) fed by `useBillingPlanPayments(plan.id)`.
- [ ] All buttons gated on `billing:write`; read-only viewers (`billing:read` only) see history but no mutating controls.
- [ ] a11y: confirmation dialog focus-trapped, `role="alertdialog"`, ESC cancels; reduced-motion honored.
**Acceptance:**
- [ ] An ACTIVE plan shows Pause/Edit/Cancel; a CANCELLED plan shows none and is read-only.
- [ ] Cancel requires explicit dialog confirmation before firing the request.
- [ ] Edit submits a `PATCH /api/billing/plans/:id` and the detail refetches.

### Task 7: Add Payment Method modal (provider-hosted tokenization)
**Blocks:** 5,9  ·  **Blocked by:** 2
**Files:**
- Create: `apps/zync-app/src/features/billing/components/AddPaymentMethodModal.tsx`
**Steps:**
- [ ] `Dialog` modal launched from Create sheet or Customer Billing tab. Step 1: choose provider (`morning | isracard | upay | icount_pay`) — default to the tenant's configured billing adapter when available.
- [ ] For hosted-page providers (`morning`, `upay`): embed the provider iframe/redirect surface. Receive the token via the provider's `postMessage` (validate `event.origin` against the provider's known origin allowlist — never trust arbitrary origins) or via redirect-return query handling. Card PAN entry happens entirely on the provider domain; the Worker/app only ever receives `{ token, lastFour, expiry }`.
- [ ] For `isracard` (direct debit / הוראת קבע): render the mandate-form download link (PDF) plus a text input for the returned **mandate reference**; `type='direct_debit'`, `provider_token` = mandate reference, `last_four`/`expiry` left null.
- [ ] On token/mandate receipt, POST to `POST /api/customers/:id/payment-methods` via `useAddPaymentMethod` with `addPaymentMethodSchema`; on success close modal, toast, and the calling surface refetches `['billing','methods',customerId]` so the new method appears in the dropdown/list.
- [ ] Enforce CSP/frame-ancestors compatibility: only the configured provider origins are allowed as iframe `src`; document the required `frame-src` CSP entries in a code comment. Validate `postMessage` origin with a strict equality check against the provider origin (no substring matching).
- [ ] Never log or store full card numbers; the app holds only `lastFour`.
- [ ] a11y: modal focus-trapped, labelled, ESC closes (with a guard if a hosted flow is mid-redirect); reduced-motion honored.
**Acceptance:**
- [ ] A `postMessage` from an origin not on the provider allowlist is ignored.
- [ ] Successful tokenization POSTs only `{ token, lastFour, expiry }` — no PAN.
- [ ] Isracard path stores a mandate reference as `direct_debit` with null last_four/expiry.

### Task 8: Payment Plans list page (`/billing/plans`) + route
**Blocks:** 11  ·  **Blocked by:** 2,3,5,6
**Files:**
- Create: `apps/zync-app/src/features/billing/pages/PaymentPlansListPage.tsx`
- Modify: `apps/zync-app/src/router.tsx` (register `/billing/plans` route behind `billing:read`)
- Modify: app nav/sidebar registration (add "Billing" / "Payment Plans" nav entry, gated on `billing:read` and the billing module being enabled)
**Steps:**
- [ ] Page header: title + `[+ New Plan]` button (opens `CreatePlanSheet`, gated `billing:write`).
- [ ] Filter bar: Status `Select` (All/Active/Paused/Cancelled), Customer `Select`/search, Project `Select`. Filters drive `useBillingPlanList` query params.
- [ ] `DataTable` columns: Customer, Project (or `—`), Amount+frequency (`PlanAmountCell`), Status (`PlanStatusBadge`), Next charge date (or `—` when paused/cancelled). Row click opens `PlanDetailSheet`.
- [ ] Footer summary: `{n} plans · ₪{sum}/mo total active` computed from the current ACTIVE-monthly rows (normalize non-monthly intervals to a monthly-equivalent for the total, or sum raw and label per spec — follow spec's "total active" wording; compute from ACTIVE plans).
- [ ] Pagination via `DataTablePagination`. Loading `Skeleton`, empty `EmptyState` ("No payment plans yet — create one").
- [ ] Route guarded: `requirePermission('billing:read')` on the API side already enforced; client guards the route and nav visibility.
**Acceptance:**
- [ ] `/billing/plans` lists plans across customers with working Status/Customer/Project filters.
- [ ] Footer shows active plan count and total active monthly revenue.
- [ ] Clicking a row opens the detail sheet; `[+ New Plan]` opens the create sheet.

### Task 9: Customer **Billing** tab on `/customers/:id`
**Blocks:** 11  ·  **Blocked by:** 4,5,6,7
**Files:**
- Create: `apps/zync-app/src/features/billing/components/CustomerBillingTab.tsx`
- Modify: customers-module customer-detail page (insert `Billing` tab into the existing `Tabs` between **Invoices** and **Portal Users**, per spec ordering "Overview Projects Invoices Billing Portal")
**Steps:**
- [ ] Register a new tab "Billing" in the customer-detail `Tabs` (Overview · Contacts · Projects · Invoices · **Billing** · Portal Users · Files · Communications). Tab visible only when the billing module is enabled and viewer has `billing:read`.
- [ ] Section "Payment methods" with `[+ Add card]` (opens `AddPaymentMethodModal` scoped to this `customerId`) listing `PaymentMethodCard` rows (default badge, remove). Uses `useCustomerPaymentMethods(customerId)`.
- [ ] Section "Active plans" with `[+ New plan]` (opens `CreatePlanSheet` pre-filled with this customer) listing this customer's plans via `useBillingPlanList({ customerId })`; row click opens `PlanDetailSheet`.
- [ ] Remove/add method and create plan invalidate the per-customer query keys so the tab updates in place.
- [ ] Gate mutating affordances on `billing:write`; `billing:read`-only viewers see read-only lists.
**Acceptance:**
- [ ] Billing tab appears in the correct position and only when module enabled + permitted.
- [ ] Adding a payment method updates the method list without a page reload.
- [ ] Creating a plan from the tab pre-fills the customer and updates "Active plans".

### Task 10: i18n, RTL & accessibility pass
**Blocks:** 11  ·  **Blocked by:** 8,9
**Files:**
- Create: `apps/zync-app/src/features/billing/i18n/en.json`
- Create: `apps/zync-app/src/features/billing/i18n/he.json`
- Modify: i18n registry to load the `billing` namespace
**Steps:**
- [ ] Extract every visible string (titles, labels, status names, buttons, confirmation copy, empty states, toasts) into `en`/`he` translation keys under a `billing` namespace; no hardcoded user-facing English.
- [ ] Verify RTL: filter bar, table, sheets, and modal mirror under `dir="rtl"` using logical CSS (no hardcoded left/right; honors `no-hardcoded-spacing`). Hebrew currency string renders `₪2,000` correctly within RTL flow.
- [ ] Verify a11y: all interactive controls keyboard-reachable; sheets/dialogs focus-trapped with correct `role`/`aria-labelledby`; status badges have text alternatives; table has proper header semantics; all transitions respect `prefers-reduced-motion`.
**Acceptance:**
- [ ] No untranslated literal renders in either locale.
- [ ] Page passes an axe scan with zero critical violations on list page, create sheet, detail sheet, and add-method modal.
- [ ] Full keyboard-only flow (create → detail → pause → cancel-confirm) works without a mouse.

### Task 11: Component/integration tests
**Blocks:** —  ·  **Blocked by:** 8,9,10
**Files:**
- Create: `apps/zync-app/src/features/billing/__tests__/plans-list.test.tsx`
- Create: `apps/zync-app/src/features/billing/__tests__/plan-detail.test.tsx`
- Create: `apps/zync-app/src/features/billing/__tests__/add-method-modal.test.tsx`
- Create: `apps/zync-app/src/features/billing/__tests__/customer-billing-tab.test.tsx`
**Steps:**
- [ ] Mock the spec-18 routes (MSW). Test: list renders rows with correct badges/amounts and filters issue the right query params.
- [ ] Test: detail sheet state machine — ACTIVE shows Pause/Edit/Cancel; PAUSED shows Resume; CANCELLED is read-only; Cancel requires dialog confirm and fires DELETE.
- [ ] Test: create sheet validation — recurring-without-interval and installments-without-count are blocked; valid submit issues `POST /api/billing/plans`.
- [ ] Test: add-method modal ignores cross-origin `postMessage`, posts only `{token,lastFour,expiry}`, and the Isracard path posts a `direct_debit` mandate with null last_four.
- [ ] Test: customer billing tab adds/removes a method and creates a plan, asserting query invalidation refreshes the lists.
**Acceptance:**
- [ ] All four test files pass.
- [ ] A test asserts no PAN is ever sent in the add-method request body.
- [ ] A test asserts the cross-customer total reflects only ACTIVE plans.
