# Billing Module — Implementation Plan

**Spec:** docs/specs/2026-05-30-billing-module.md  ·  **Slug:** billing-module  ·  **Wave:** 8
**Depends on:** customers-module, foundation-auth-rbac, invoices-adapters, invoices-core, projects-module

## Goal
Deliver subscription and recurring-payment management for a tenant's *own customers* (distinct from Zync's internal subscription billing). Tenants configure payment plans per customer or project; the system stores tokenized payment methods, charges them through Israeli payment processors (Morning / Green Invoice Pay, Isracard direct debit, Upay, iCount Pay), records payments, and auto-issues legally-compliant tax invoices via invoices-core. Card data never touches the Worker — providers' hosted iframes/redirects perform tokenization, so Zync stays out of PCI scope for card data.

## Architecture
- **New package `@zync/billing`** holds the `PaymentAdapter` interface, the four provider adapters (Morning, Isracard, Upay, iCount Pay), an adapter registry/factory `getPaymentAdapter(id)`, the Drizzle schema for `payment_methods`/`payment_plans`/`payments`, and pure query/mutation helpers consumed by the Hono API and queue consumers.
- **Encryption:** provider tokens and mandate references are encrypted with `INTEGRATION_ENCRYPTION_KEY` (AES-256-GCM) using the existing `encryptSecret` / `decryptSecret` helpers from `@zync/auth` — the same pattern used by `adapter_credentials`. Adapter API credentials are loaded via the upstream `loadAdapterCredential` / `saveAdapterCredential` and the `adapter_credentials` table (shared with the Morning/iCount invoice adapters when the tenant uses both products).
- **Invoice creation flow:** auto-charge NEVER inserts into `invoices` directly. On a successful charge it calls the invoices-core internal endpoint `POST /api/invoices/auto-issue` (`source = 'auto_charge'`), which assigns a gap-free sequential `invoice_number` and transitions to `TAX_ISSUED` atomically — required by IL law. Non-auto-charge plans create a `DRAFT` invoice for staff review via the same endpoint family (`POST /api/invoices`).
- **Async charge:** the daily cron `/api/cron/billing-charge` only enqueues `payment.charge` jobs onto `QUEUE` and returns fast; it does NOT advance `next_billing_date`. The queue consumer performs the charge (1–5 s, 3× exponential backoff), records the `payments` row, advances `next_billing_date` on success only, and fires `payment.completed` / `payment.failed` webhooks plus an in-app notification on failure.
- **Inbound webhooks:** `POST /api/webhooks/billing/:provider` verifies each provider's signature via `adapter.verifyWebhook`, matches the `payments` row by `provider_transaction_id`, updates status, and on `COMPLETED` transitions the linked invoice to `PAID` via invoices-core and fires `payment.completed`.
- **Consumed upstream tables:** `customers` (FK `customer_id`), `projects`(FK `project_id`), `invoices` (FK `invoice_id`), `tenants` (FK `tenant_id`), `users`, `adapter_credentials`, `notifications`.
- **Consumed upstream exports:** `tenantQuery`, `authMiddleware`, `requirePermission`, `requireModuleEnabled`, `buildPaginated` / `clampLimit`, `encryptSecret`, `decryptSecret`, `loadAdapterCredential`, `createNotification`, `webhook.deliver` (queue job type), `payment.charge` (queue job type), `serializeInvoice`, `InvoiceObject`, the `POST /api/invoices/auto-issue` route, and `useCustomer` (app side).

## Tech Stack
- **Packages:** new `@zync/billing` (TypeScript, Drizzle schema + adapters + service helpers). Depends on `@zync/db`, `@zync/auth`, `@zync/types`, `@zync/notifications`.
- **API:** Hono routes mounted in `apps/zync-api` under `/api/billing/*`, `/api/customers/:id/payment-methods*`, `/api/webhooks/billing/:provider`, `/api/cron/billing-charge`. Zod validation on every body (`require-zod-validation-in-routes`); audit writes inside transactions (`require-audit-in-transaction`); no raw Drizzle from routes (`no-raw-drizzle-from-routes`).
- **App UI:** Vite+React pages in `apps/zync-app` at `/billing/plans` and `/billing/payments`, plus a payment-methods section embedded in the customer detail page. Uses `@zync/ui` primitives (`DataTable`, `Sheet`, `Badge`, `Button`, `Select`, `Switch`, `Input`, `Form`, `EmptyState`, `toast`).
- **Cloudflare bindings:** `DB` (Hyperdrive→Neon Postgres), `QUEUE` (charge + webhook delivery jobs), `STORAGE` (R2, only via invoices-core for invoice HTML — not used directly here). Cron trigger registered for `/api/cron/billing-charge` (daily) guarded by `CRON_SECRET`. Secret binding `INTEGRATION_ENCRYPTION_KEY`.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 8a | 1 (schema), 2 (permissions seed) | `packages/billing/src/schema.ts`, migration, `packages/auth` seed | Tasks 1 & 2 parallel |
| 8b | 3 (adapter interface + registry), 4 (Morning), 5 (Isracard), 6 (Upay), 7 (iCount Pay) | `packages/billing/src/adapters/*` | 4–7 parallel after 3 |
| 8c | 8 (service helpers), 9 (plans API), 10 (payments API), 11 (payment-methods API) | `packages/billing/src/service.ts`, `apps/zync-api/src/routes/billing/*` | 9–11 parallel after 8 |
| 8d | 12 (charge queue consumer), 13 (auto-billing cron), 14 (inbound webhooks) | `apps/zync-api/src/queue/*`, `apps/zync-api/src/routes/billing/webhooks.ts`, `apps/zync-api/src/cron/*` | 12 before 13; 14 parallel |
| 8e | 15 (plans UI), 16 (payments UI), 17 (customer payment-methods UI) | `apps/zync-app/src/pages/billing/*`, customer detail | 15–17 parallel after APIs |

## Tasks

### Task 1: Billing schema + Drizzle models + migration
**Blocks:** 3, 8, 9, 10, 11, 12, 13, 14  ·  **Blocked by:** —
**Files:**
- Create: `packages/billing/src/schema.ts`
- Create: `packages/billing/drizzle/0001_billing.sql`
- Create: `packages/billing/package.json`, `packages/billing/tsconfig.json`, `packages/billing/src/index.ts`
**Steps:**
- [ ] Scaffold `@zync/billing` package (depends on `@zync/db`, `@zync/auth`, `@zync/types`, `@zync/notifications`).
- [ ] Define the three tables in Drizzle with canonical Postgres types (UUID PKs, UUID→UUID FKs, TIMESTAMPTZ, BOOLEAN, NUMERIC, inline CHECK enums).
- [ ] Add indexes: `payment_methods(tenant_id, customer_id)`, `payment_plans(tenant_id, status, next_billing_date)` (for the cron scan), `payments(tenant_id, customer_id)`, `payments(provider_transaction_id)` (for webhook lookup).
- [ ] Export table objects and inferred row types from `packages/billing/src/index.ts`.
**Schema / Interfaces:**
```sql
CREATE TABLE payment_methods (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  customer_id UUID NOT NULL REFERENCES customers(id),
  type TEXT NOT NULL CHECK (type IN ('credit_card', 'direct_debit', 'bank_transfer', 'check')),
  provider TEXT CHECK (provider IN ('morning', 'isracard', 'upay', 'icount_pay')),
  provider_token TEXT,                 -- AES-256-GCM encrypted (INTEGRATION_ENCRYPTION_KEY); card token OR Isracard mandate ref
  last_four TEXT,                      -- display only; unused for direct_debit
  expiry_month INTEGER,
  expiry_year INTEGER,
  is_default BOOLEAN NOT NULL DEFAULT false,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX payment_methods_tenant_customer_idx ON payment_methods (tenant_id, customer_id);

CREATE TABLE payment_plans (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  customer_id UUID NOT NULL REFERENCES customers(id),
  project_id UUID REFERENCES projects(id),          -- nullable (customer-level plan)
  name TEXT NOT NULL,
  type TEXT NOT NULL CHECK (type IN ('one_time', 'recurring', 'installments')),
  amount NUMERIC(12,2) NOT NULL,
  currency TEXT NOT NULL DEFAULT 'ILS',
  interval TEXT CHECK (interval IN ('monthly', 'quarterly', 'annual')),  -- recurring only
  next_billing_date DATE,
  installment_count INTEGER,
  installments_paid INTEGER NOT NULL DEFAULT 0,
  status TEXT NOT NULL DEFAULT 'ACTIVE' CHECK (status IN ('ACTIVE', 'PAUSED', 'CANCELLED', 'COMPLETED')),
  payment_method_id UUID REFERENCES payment_methods(id),
  auto_charge BOOLEAN NOT NULL DEFAULT false,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX payment_plans_due_idx ON payment_plans (tenant_id, status, next_billing_date);

CREATE TABLE payments (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  customer_id UUID NOT NULL REFERENCES customers(id),
  invoice_id UUID REFERENCES invoices(id),          -- nullable for direct/manual payments
  payment_plan_id UUID REFERENCES payment_plans(id),-- nullable
  payment_method_id UUID REFERENCES payment_methods(id),
  amount NUMERIC(12,2) NOT NULL,
  currency TEXT NOT NULL DEFAULT 'ILS',
  status TEXT NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'REFUNDED')),
  provider TEXT,
  provider_transaction_id TEXT,
  provider_reference TEXT,
  failure_reason TEXT,
  paid_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX payments_tenant_customer_idx ON payments (tenant_id, customer_id);
CREATE INDEX payments_provider_txn_idx ON payments (provider_transaction_id);
```
**Acceptance:**
- [ ] `pnpm --filter @zync/billing build` and `drizzle-kit generate` produce the migration with no SQLite/D1 syntax.
- [ ] All enums are inline `CHECK (col IN (...))` matching the spec verbatim; all FKs are UUID→UUID; no INTEGER booleans, no TEXT JSON.

### Task 2: Seed `billing:read` / `billing:write` permissions
**Blocks:** 9, 10, 11  ·  **Blocked by:** —
**Files:**
- Modify: `packages/auth/src/permissions.ts` (the `seedPermissions` permission catalog)
**Steps:**
- [ ] Add `billing:read` and `billing:write` to the permission catalog seeded by `seedPermissions`.
- [ ] Grant `billing:read` + `billing:write` to OWNER and ADMIN system roles; grant `billing:read` to MEMBER in `seedSystemRoles` (read-only for non-managers); adapter configuration stays under existing `settings:write`.
**Acceptance:**
- [ ] After `seedPermissions` + `seedSystemRoles`, `role_permissions` contains `billing:read`/`billing:write` rows for the intended roles.

### Task 3: PaymentAdapter interface + registry/factory
**Blocks:** 4, 5, 6, 7, 8, 12, 14  ·  **Blocked by:** 1
**Files:**
- Create: `packages/billing/src/adapters/types.ts`
- Create: `packages/billing/src/adapters/index.ts`
**Steps:**
- [ ] Define the `PaymentAdapter` interface and the param/result types verbatim from the spec.
- [ ] Implement `getPaymentAdapter(id)` returning the registered adapter instance, throwing a typed `PaymentProviderError` for unknown ids.
- [ ] Define `AdapterCredentials` shape loaded from `adapter_credentials` (reuse the `loadAdapterCredential` upstream helper to decrypt).
**Schema / Interfaces:**
```ts
export type PaymentProviderId = 'morning' | 'isracard' | 'upay' | 'icount_pay';

export interface TokenizeParams {
  tenantId: string; customerId: string;
  returnUrl: string;          // provider redirects back here after hosted card entry
  credentials: AdapterCredentials;
}
export interface ChargeParams {
  amount: number; currency: 'ILS';
  token: string;              // decrypted provider_token
  description: string;
  credentials: AdapterCredentials;
}

export interface PaymentAdapter {
  id: PaymentProviderId;
  name: string;
  // Card data NEVER flows through the Worker; provider hosted iframe/redirect handles entry.
  tokenizeCard(params: TokenizeParams): Promise<{ token: string; lastFour: string; expiry: string }>;
  charge(params: ChargeParams): Promise<{ transactionId: string; reference: string; status: 'completed' | 'failed'; failureReason?: string }>;
  refund(transactionId: string, amount: number, credentials: AdapterCredentials): Promise<{ refundId: string }>;
  verifyWebhook(payload: unknown, signature: string, secret: string): boolean;
}

export function getPaymentAdapter(id: PaymentProviderId): PaymentAdapter;
```
**Acceptance:**
- [ ] `getPaymentAdapter('morning')` returns the Morning adapter; unknown id throws.
- [ ] `verifyWebhook` uses `timingSafeEqual` for signature comparison (no `===` on secrets — `no-string-equality-for-tokens`).

### Task 4: Morning (Green Invoice Pay) adapter
**Blocks:** 8  ·  **Blocked by:** 3
**Files:**
- Create: `packages/billing/src/adapters/morning.ts`
**Steps:**
- [ ] Implement `tokenizeCard` returning the hosted payment-page URL handshake result (token via redirect/postMessage callback).
- [ ] Implement `charge` → `POST https://api.greeninvoice.co.il/api/v1/payments` with API key + secret from credentials.
- [ ] Implement `refund` against the Morning refund endpoint.
- [ ] Implement `verifyWebhook` using Morning's HMAC scheme with `timingSafeEqual`.
- [ ] Reuse the same `adapter_credentials` row as the Morning invoice adapter when the tenant has both products configured.
**Acceptance:**
- [ ] `charge` maps Morning success/failure responses to `{ status: 'completed' | 'failed', failureReason? }`.
- [ ] Webhook signature verification rejects a tampered payload.

### Task 5: Isracard direct-debit (הוראת קבע) adapter
**Blocks:** 8  ·  **Blocked by:** 3
**Files:**
- Create: `packages/billing/src/adapters/isracard.ts`
**Steps:**
- [ ] Implement `tokenizeCard` semantics for a **bank mandate**, not a card: store the mandate ID / bank-account reference into `token`; leave `lastFour`/`expiry` empty (payment method `type = 'direct_debit'`).
- [ ] Implement `charge` → `POST https://gateway.isracard.co.il/api/debit` (bank-to-bank debit, not card network).
- [ ] Implement `refund` against Isracard's reversal endpoint.
- [ ] Implement `verifyWebhook` (Isracard scheme; `timingSafeEqual`). Note Isracard has no inbound payment webhook route in this spec — `verifyWebhook` may be a no-op returning false if unused.
**Acceptance:**
- [ ] A method created via Isracard tokenization stores `type = 'direct_debit'` with empty `last_four`/`expiry_*` and a non-null encrypted `provider_token` (mandate ref).

### Task 6: Upay adapter
**Blocks:** 8  ·  **Blocked by:** 3
**Files:**
- Create: `packages/billing/src/adapters/upay.ts`
**Steps:**
- [ ] Implement `tokenizeCard` via Upay's hosted iframe (token from Upay domain).
- [ ] Implement `charge` + `refund` via Upay REST API with credentials.
- [ ] Implement `verifyWebhook` for the `POST /api/webhooks/billing/upay` inbound updates (`timingSafeEqual`).
**Acceptance:**
- [ ] `charge` returns a normalized result; `verifyWebhook` validates Upay's signature.

### Task 7: iCount Pay adapter
**Blocks:** 8  ·  **Blocked by:** 3
**Files:**
- Create: `packages/billing/src/adapters/icount-pay.ts`
**Steps:**
- [ ] Implement `tokenizeCard` via iCount hosted flow.
- [ ] Implement `charge` → `POST https://api.icount.co.il/api/v3.php?action=charge` reusing the same `adapter_credentials` as the iCount invoice adapter when configured.
- [ ] Implement `refund` + `verifyWebhook` for `POST /api/webhooks/billing/icount_pay` (`timingSafeEqual`).
**Acceptance:**
- [ ] iCount adapter loads credentials via `loadAdapterCredential(tenantId, 'icount')` when the tenant already has the invoice adapter.

### Task 8: Billing service helpers (queries + mutations)
**Blocks:** 9, 10, 11, 12, 13, 14  ·  **Blocked by:** 3, 4, 5, 6, 7
**Files:**
- Create: `packages/billing/src/service.ts`
- Modify: `packages/billing/src/index.ts`
**Steps:**
- [ ] Implement tenant-scoped query/mutation helpers (all use `tenantQuery`; encrypt/decrypt tokens with `encryptSecret`/`decryptSecret` + `INTEGRATION_ENCRYPTION_KEY`).
- [ ] Implement `listPaymentPlans`, `getPaymentPlan`, `createPaymentPlan`, `updatePaymentPlan` (pause/cancel/amount), `cancelPaymentPlan`.
- [ ] Implement `listPayments` (filterable by date range, customer, status, provider; cursor pagination via `buildPaginated`/`clampLimit`), `getPayment`, `recordManualPayment`, `createPaymentRecord`.
- [ ] Implement `listPaymentMethods`, `addPaymentMethod` (stores encrypted token, enforces single default), `removePaymentMethod`, `setDefaultPaymentMethod`.
- [ ] Implement `advanceNextBillingDate(plan, interval)` (monthly/quarterly/annual; for installments increments `installments_paid`, sets `COMPLETED` when `installments_paid >= installment_count`).
- [ ] Implement `serializePayment` / `serializePaymentPlan` / `serializePaymentMethod` (mask token — never emit `provider_token`; only `last_four`/`expiry`).
- [ ] Export all from `index.ts`.
**Schema / Interfaces:**
```ts
export interface PaymentPlanObject {
  id: string; customerId: string; projectId: string | null; name: string;
  type: 'one_time' | 'recurring' | 'installments'; amount: string; currency: string;
  interval: 'monthly' | 'quarterly' | 'annual' | null; nextBillingDate: string | null;
  installmentCount: number | null; installmentsPaid: number;
  status: 'ACTIVE' | 'PAUSED' | 'CANCELLED' | 'COMPLETED';
  paymentMethodId: string | null; autoCharge: boolean; createdAt: string; updatedAt: string;
}
export interface PaymentObject {
  id: string; customerId: string; invoiceId: string | null; paymentPlanId: string | null;
  paymentMethodId: string | null; amount: string; currency: string;
  status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED' | 'REFUNDED';
  provider: string | null; providerReference: string | null; failureReason: string | null;
  paidAt: string | null; createdAt: string;
}
export interface PaymentMethodObject {
  id: string; customerId: string; type: 'credit_card' | 'direct_debit' | 'bank_transfer' | 'check';
  provider: PaymentProviderId | null; lastFour: string | null;
  expiryMonth: number | null; expiryYear: number | null; isDefault: boolean; createdAt: string;
  // provider_token is NEVER serialized
}
```
**Acceptance:**
- [ ] No serializer emits `provider_token`.
- [ ] `setDefaultPaymentMethod` clears `is_default` on the customer's other methods in the same transaction.
- [ ] `advanceNextBillingDate` is called only on success paths (see Task 12).

### Task 9: Payment Plans API routes
**Blocks:** 15  ·  **Blocked by:** 1, 2, 8
**Files:**
- Create: `apps/zync-api/src/routes/billing/plans.ts`
- Modify: `apps/zync-api/src/routes/index.ts` (mount `/api/billing`)
**Steps:**
- [ ] `GET /api/billing/plans` — list (filter by customer, status; cursor pagination). Guard `authMiddleware` + `requirePermission('billing:read')` + `requireModuleEnabled('billing')`.
- [ ] `POST /api/billing/plans` — create. `requirePermission('billing:write')`. Zod body: customer, project?, type, amount, currency, interval? (required if recurring), startDate? (sets `next_billing_date`), installmentCount? (required if installments), paymentMethodId?, autoCharge.
- [ ] `GET /api/billing/plans/:id` — detail. `billing:read`.
- [ ] `PATCH /api/billing/plans/:id` — update (pause→PAUSED, cancel→CANCELLED, change amount). `billing:write`.
- [ ] `DELETE /api/billing/plans/:id` — cancel plan (sets status CANCELLED, soft). `billing:write`.
- [ ] All writes wrapped in a transaction with an audit entry (`require-audit-in-transaction`); no raw Drizzle (`no-raw-drizzle-from-routes`).
**Schema / Interfaces:**
```ts
const createPlanSchema = z.object({
  customerId: z.string().uuid(),
  projectId: z.string().uuid().nullable().optional(),
  name: z.string().min(1),
  type: z.enum(['one_time', 'recurring', 'installments']),
  amount: z.string().regex(/^\d+(\.\d{1,2})?$/),
  currency: z.string().default('ILS'),
  interval: z.enum(['monthly', 'quarterly', 'annual']).optional(),
  startDate: z.string().date().optional(),
  installmentCount: z.number().int().positive().optional(),
  paymentMethodId: z.string().uuid().optional(),
  autoCharge: z.boolean().default(false),
}).refine(d => d.type !== 'recurring' || !!d.interval, { message: 'interval required for recurring' })
  .refine(d => d.type !== 'installments' || !!d.installmentCount, { message: 'installmentCount required for installments' });
```
**Acceptance:**
- [ ] Creating a recurring plan without `interval` returns 422; installments without `installmentCount` returns 422.
- [ ] Endpoints reject callers lacking `billing:read`/`billing:write` with 403.

### Task 10: Payments History API routes
**Blocks:** 16  ·  **Blocked by:** 1, 2, 8
**Files:**
- Create: `apps/zync-api/src/routes/billing/payments.ts`
**Steps:**
- [ ] `GET /api/billing/payments` — filterable (date range, customer, status, provider), cursor-paginated, max 100 rows/request. `billing:read`. Support CSV export variant (`?format=csv`).
- [ ] `POST /api/billing/payments` — record manual payment (bank transfer / check not processed via adapter). `billing:write`. Optionally links `invoice_id`; if linked invoice becomes fully paid, transition it to `PAID` via invoices-core.
- [ ] `GET /api/billing/payments/:id` — detail. `billing:read`.
- [ ] Audit each manual-payment write in-transaction.
**Schema / Interfaces:**
```ts
const manualPaymentSchema = z.object({
  customerId: z.string().uuid(),
  invoiceId: z.string().uuid().optional(),
  amount: z.string().regex(/^\d+(\.\d{1,2})?$/),
  currency: z.string().default('ILS'),
  method: z.enum(['bank_transfer', 'check']),
  reference: z.string().optional(),
  paidAt: z.string().datetime().optional(),
});
```
**Acceptance:**
- [ ] CSV export returns `text/csv` with the columns Date, Customer, Amount, Invoice #, Status, Method, Provider ref.
- [ ] Manual payment linked to an invoice that reaches its total flips the invoice to `PAID`.

### Task 11: Customer Payment Methods API routes
**Blocks:** 17  ·  **Blocked by:** 1, 2, 8, 3
**Files:**
- Create: `apps/zync-api/src/routes/billing/payment-methods.ts`
**Steps:**
- [ ] `GET /api/customers/:id/payment-methods` — list methods for a customer (masked). `billing:read`.
- [ ] `POST /api/customers/:id/payment-methods` — start the provider hosted tokenization flow; on callback, persist the returned `{ token, lastFour, expiry }` encrypted via `encryptSecret`. `billing:write`. For Isracard, accept a mandate reference and store `type = 'direct_debit'`.
- [ ] `DELETE /api/customers/:id/payment-methods/:mid` — remove a method. `billing:write`. Reject (409) if referenced by an ACTIVE auto-charge plan.
- [ ] Provide a `set-default` action (PATCH or dedicated route) calling `setDefaultPaymentMethod`.
**Acceptance:**
- [ ] Stored `provider_token` is ciphertext (never plaintext); responses never include it.
- [ ] Deleting a method bound to an ACTIVE auto-charge plan returns 409.

### Task 12: `payment.charge` queue consumer
**Blocks:** 13  ·  **Blocked by:** 8, 3
**Files:**
- Create: `apps/zync-api/src/queue/payment-charge.ts`
- Modify: `apps/zync-api/src/queue/index.ts` (route `payment.charge` job type)
**Steps:**
- [ ] Consume `payment.charge` jobs `{ planId }`. Load plan + payment method + credentials; decrypt token (`decryptSecret`).
- [ ] Call `adapter.charge(params)`.
- [ ] **On success:** call `POST /api/invoices/auto-issue` (internal) with `{ customerId, projectId?, lines, paymentMethodId }` → `{ invoiceId, invoiceNumber }` (creates DRAFT + assigns gap-free number + → TAX_ISSUED atomically; `source = 'auto_charge'`). Then `createPaymentRecord({ status: 'COMPLETED', invoiceId, providerTransactionId, providerReference, paidAt })`, `advanceNextBillingDate(plan, interval)` (success-only), fire `payment.completed` webhook via `webhook.deliver`. **Never INSERT into `invoices` directly.**
- [ ] **On failure:** `createPaymentRecord({ status: 'FAILED', failureReason })`, fire `payment.failed` webhook, `createNotification` for tenant admins. **Do NOT advance `next_billing_date`.**
- [ ] Retry 3× with exponential backoff before final failure; rely on queue DLQ thereafter.
**Schema / Interfaces:**
```ts
// payment.completed: { paymentId, customerId, invoiceId, amount, provider }
// payment.failed:    { paymentId, customerId, planId, failureReason }
type PaymentChargeJob = { type: 'payment.charge'; planId: string };
```
**Acceptance:**
- [ ] A failed charge leaves `next_billing_date` unchanged and creates a `FAILED` payment + admin notification.
- [ ] A successful charge produces a `TAX_ISSUED` invoice with a gap-free sequential number (via auto-issue, not direct INSERT) and advances the date by the plan interval.

### Task 13: Auto-billing cron `/api/cron/billing-charge`
**Blocks:** —  ·  **Blocked by:** 12
**Files:**
- Create: `apps/zync-api/src/cron/billing-charge.ts`
- Modify: `apps/zync-api/wrangler.toml` (daily cron trigger), `apps/zync-api/src/routes/index.ts`
**Steps:**
- [ ] Guard with `CRON_SECRET` (timing-safe compare). Runs daily.
- [ ] For each `payment_plans` where `status = 'ACTIVE' AND auto_charge = true AND next_billing_date <= today`: enqueue `QUEUE.send({ type: 'payment.charge', planId })`. Return immediately; **do NOT advance `next_billing_date` here**.
- [ ] For non-auto-charge ACTIVE plans whose `next_billing_date <= today`: auto-create an invoice DRAFT (via `POST /api/invoices` with pre-filled lines) for staff to review + send, and advance `next_billing_date` (draft creation is synchronous and idempotent per period).
**Acceptance:**
- [ ] Cron handler returns < 1 s regardless of plan count (only enqueues; charge work is async).
- [ ] Non-auto-charge due plans yield a DRAFT invoice, not a charge.

### Task 14: Inbound payment webhooks `/api/webhooks/billing/:provider`
**Blocks:** —  ·  **Blocked by:** 3, 8
**Files:**
- Create: `apps/zync-api/src/routes/billing/webhooks.ts`
**Steps:**
- [ ] Route `POST /api/webhooks/billing/:provider` for `morning` | `upay` | `icount_pay` (Isracard has no inbound webhook).
- [ ] Verify the provider signature via `getPaymentAdapter(provider).verifyWebhook(payload, signature, secret)` (loads webhook secret from `adapter_credentials`); reject 401 on mismatch.
- [ ] Find the matching `payments` row by `provider_transaction_id`.
- [ ] Update payment status. On `COMPLETED`: transition the linked invoice to `PAID` via invoices-core and fire `payment.completed` webhook (`webhook.deliver`).
- [ ] No `authMiddleware` (public provider callback) — security rests entirely on signature verification; apply rate limiting (`RATE_LIMITER_WEBHOOK`).
**Acceptance:**
- [ ] A spoofed/invalid signature returns 401 and does not mutate any payment.
- [ ] A `COMPLETED` webhook updates the payment and flips the linked invoice to `PAID`.

### Task 15: Payment Plans UI (`/billing/plans`)
**Blocks:** —  ·  **Blocked by:** 9
**Files:**
- Create: `apps/zync-app/src/pages/billing/PlansPage.tsx`
- Create: `apps/zync-app/src/pages/billing/PlanSheet.tsx`
- Create: `apps/zync-app/src/hooks/usePaymentPlans.ts`
- Modify: `apps/zync-app/src/router.tsx` (route + module guard)
**Steps:**
- [ ] `DataTable` columns: Customer, Project, Plan type, Amount, Interval, Next billing date, Status (`Badge`), Auto-charge (`Switch` read indicator).
- [ ] "New plan" `Button` → `Sheet` form: Customer, Project (optional), Type, Amount/currency, recurring interval + start date, installments total/count/interval, payment-method picker ("set up new" launches tokenization), Auto-charge `Switch` (on = auto-charge; off = invoice draft).
- [ ] Row actions: pause / resume / cancel / edit amount → `PATCH`.
- [ ] `EmptyState` when no plans; respect `prefers-reduced-motion`; route gated by `useModuleEnabled('billing')` and `billing:read`.
**Acceptance:**
- [ ] Conditional form fields appear per plan type; submit calls `POST /api/billing/plans`.
- [ ] Page is keyboard-navigable with correct aria roles; RTL layout correct under Hebrew locale.

### Task 16: Payments History UI (`/billing/payments`)
**Blocks:** —  ·  **Blocked by:** 10
**Files:**
- Create: `apps/zync-app/src/pages/billing/PaymentsPage.tsx`
- Create: `apps/zync-app/src/pages/billing/RecordPaymentSheet.tsx`
- Create: `apps/zync-app/src/hooks/usePayments.ts`
**Steps:**
- [ ] `DataTable` columns: Date, Customer, Amount, Invoice #, Status (`Badge`), Method, Provider ref. Cursor pagination.
- [ ] Filters: date range, customer, status, provider. "Export CSV" button → `GET /api/billing/payments?format=csv`.
- [ ] "Record manual payment" `Button` → `Sheet` (`POST /api/billing/payments`).
- [ ] `EmptyState` when no payments; reduced-motion respected; RTL-correct.
**Acceptance:**
- [ ] Filters and CSV export function; manual payment sheet submits and refreshes the table.

### Task 17: Customer Payment Methods UI (customer detail)
**Blocks:** —  ·  **Blocked by:** 11
**Files:**
- Create: `apps/zync-app/src/components/billing/PaymentMethodsCard.tsx`
- Modify: `apps/zync-app/src/pages/customers/CustomerDetailPage.tsx` (embed the card)
**Steps:**
- [ ] List methods with type icon, last 4, expiry — masked (never full number).
- [ ] "Add card" → launches the provider hosted tokenization flow (iframe/redirect); on callback `POST /api/customers/:id/payment-methods`.
- [ ] "Remove" and "Set default" actions; default badge.
- [ ] Gate visibility behind `billing:write` for mutating actions; `billing:read` for viewing.
**Acceptance:**
- [ ] No full card number is ever rendered; only `last_four` + expiry shown.
- [ ] Add-method flow stores an encrypted token server-side and the new method appears in the list.
