# Multi-Currency Support — Implementation Plan

**Spec:** docs/specs/2026-05-31-multi-currency.md  ·  **Slug:** multi-currency  ·  **Wave:** 10
**Depends on:** contractor-payouts, expenses-module, foundation-auth-rbac, invoices-core, settings-module

## Goal
Extend Zync beyond ILS-only invoicing so Business+ tenants can issue invoices in ILS, USD, or EUR while Freelancer tenants stay locked to ILS. Exchange rates are tenant-declared (manual entry, optionally seeded from the Bank of Israel official feed but never auto-applied). All internal accounting stays in ILS: foreign-currency invoices snapshot both the face amount (invoice currency) and the ILS equivalent at the rate in effect at `TAX_ISSUED`, satisfying Israeli revenue law (Income Tax Ordinance §2(e)) which requires the declared rate and ILS amount on every tax invoice (חשבונית מס).

## Architecture
This spec adds **no new tables**. It is a set of ALTER columns plus behavior wired into upstream-owned code:

- **invoices** (owned by `invoices-core`): add `ils_exchange_rate NUMERIC(10,4)` and `total_ils NUMERIC(12,2)`, snapshotted immutably at the `TAX_ISSUED` transition by reading the tenant's current rate. The existing `currency TEXT DEFAULT 'ILS'` column (already present, unconstrained) gains a `CHECK (currency IN ('ILS','USD','EUR'))` constraint. The invoice HTML renderer (`GET /api/invoices/:id/html`, the R2 snapshot path) gains the legally-mandatory "Exchange rate at issue" + ILS-equivalent line.
- **expenses** (owned by `expenses-module`): add `original_currency TEXT` and `original_amount NUMERIC(12,2)`; the existing `amount` column remains the ILS-normalized deductibility base.
- **tenant_settings**: add `exchange_rates JSONB` and `boi_rate_sync_enabled BOOLEAN NOT NULL DEFAULT false`. The `tenant_settings` base table (`id` UUID PK, `tenant_id` UUID UNIQUE → `tenants(id)`, timestamps) is owned by `foundation-auth-rbac` and is in every tenant's transitive closure, so it always exists before this plan's ALTER runs. This plan only `ALTER TABLE tenant_settings ADD COLUMN IF NOT EXISTS ...`; it must NOT create the table.
- **Settings UI** at `/settings/business` (owned by `settings-module`): currency default selector + manual rate editor + BoI opt-in toggle + staff-confirmation diff panel.
- **Bank of Israel cron**: a Cloudflare cron worker fetches `https://www.boi.org.il/currency.xml`, parses USD/EUR, stores pending rates in `KV` under `boi_rates:YYYY-MM-DD`, and emits the `exchange_rates_updated` notification (already wired in `notification-center` to deep-link `/settings/business`). Rates are never auto-applied; staff confirm via `PATCH /api/settings/business`.

Upstream exports consumed: `InvoiceStatus`, `InvoiceObject`, `InvoiceLineObject`, `serializeInvoice`, `requireTier`, `meetsMinimumTier`, `useTierGate`, `requirePermission`, `createNotification`, `tenantQuery`, `KV`, `Env`, `SUPPORTED_LOCALES`, `settings:read`, `settings:write`. The `currency` field already exists on `InvoiceObject`; this plan adds `totalIls` and `ilsExchangeRate`.

## Tech Stack
- **apps/zync-api** (Hono on Cloudflare Workers): migrations (Drizzle), the issue-tax handler change, `PATCH /api/settings/business` extension, new `GET /api/settings/exchange-rates/suggested`, `GET /api/invoices` list serializer extension, invoice HTML renderer change, and the BoI cron in `src/cron/boi-exchange-rates.ts`.
- **apps/zync-app** (Vite + React): `/settings/business` currency + exchange-rate panel, invoice-form currency dropdown, list/detail display of currency + ILS equivalent.
- **packages/db** (Drizzle schema): column additions + currency CHECK.
- **packages/types**: `Currency` union, `ExchangeRates`, `SuggestedRates` types.
- **Cloudflare bindings:** `KV` (BoI pending rates), cron trigger (`0 14 * * 1-5`). Outbound `fetch` to BoI with `cf: { cacheTtl: 3600 }`.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 10a — schema & types | 1, 2 | packages/db schema, migrations, packages/types | Task 2 after Task 1 |
| 10b — issue-tax snapshot + tier gate | 3, 4 | zync-api invoices service/routes | After 1; 3 and 4 parallel |
| 10c — settings API & UI | 5, 6, 7 | zync-api settings routes, zync-app settings page | 5 before 6/7; 6,7 parallel |
| 10d — invoice document + list serializer | 8, 9 | zync-api invoice renderer & list route, zync-app | After 3; parallel |
| 10e — BoI cron + notification + suggested API | 10, 11, 12 | zync-api cron, settings route, wrangler | 10 before 11/12; 11,12 parallel |
| 10f — reports aggregation | 13 | reports-analytics (cross-spec) | After 3 |

## Tasks

### Task 1: Schema migrations — invoices, expenses, tenant_settings columns
**Blocks:** 2, 3, 5, 8, 10  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/migrations/0102_multi_currency.sql`
- Modify: `packages/db/src/schema/invoices.ts`
- Modify: `packages/db/src/schema/expenses.ts`
- Modify: `packages/db/src/schema/tenant-settings.ts`
**Steps:**
- [ ] Add `ils_exchange_rate` and `total_ils` columns to `invoices`.
- [ ] Add a currency CHECK to `invoices.currency` (spec restricts to ILS/USD/EUR; invoices-core left it unconstrained).
- [ ] Add `original_currency` and `original_amount` columns to `expenses`.
- [ ] Add `exchange_rates` (JSONB) and `boi_rate_sync_enabled` columns to `tenant_settings` (assume table exists upstream — see GAP NOTE in Architecture; do NOT CREATE the table here).
- [ ] Mirror every column in the Drizzle schema files.
**Schema / Interfaces:**
```sql
-- invoices (owned by invoices-core): foreign-currency snapshot columns
ALTER TABLE invoices ADD COLUMN ils_exchange_rate NUMERIC(10,4);
  -- NULL for ILS invoices; set at TAX_ISSUED for foreign currency; immutable after issue
ALTER TABLE invoices ADD COLUMN total_ils         NUMERIC(12,2);
  -- total converted to ILS at ils_exchange_rate; NULL for ILS invoices; immutable after issue
ALTER TABLE invoices ADD CONSTRAINT invoices_currency_check
  CHECK (currency IN ('ILS','USD','EUR'));

-- expenses (owned by expenses-module): foreign-currency informational columns
ALTER TABLE expenses ADD COLUMN original_currency TEXT;          -- 'USD' | 'EUR' (informational)
ALTER TABLE expenses ADD COLUMN original_amount   NUMERIC(12,2); -- face amount in foreign currency
  -- existing `amount` column remains the ILS-equivalent deductibility base

-- tenant_settings (assumed-upstream table; ALTER only — see GAP NOTE):
ALTER TABLE tenant_settings ADD COLUMN exchange_rates        JSONB;
  -- shape: { "USD": 3.72, "EUR": 4.01, "updatedAt": "ISO-8601", "updatedBy": "<user uuid>" }
ALTER TABLE tenant_settings ADD COLUMN boi_rate_sync_enabled BOOLEAN NOT NULL DEFAULT false;
```
**Acceptance:**
- [ ] Migration applies cleanly against Neon Postgres; `\d invoices` shows both new columns and the currency CHECK.
- [ ] Inserting an invoice with `currency = 'GBP'` is rejected by the CHECK.
- [ ] Drizzle types compile and expose the new columns.

### Task 2: Currency & rate types in `@zync/types`
**Blocks:** 4, 5, 8, 9, 11  ·  **Blocked by:** 1
**Files:**
- Create: `packages/types/src/currency.ts`
- Modify: `packages/types/src/index.ts`
**Steps:**
- [ ] Define the `Currency` union and constant list.
- [ ] Define `ExchangeRates`, `SuggestedRates`, and the extended invoice currency fields.
- [ ] Re-export from the package index.
**Schema / Interfaces:**
```ts
export const SUPPORTED_CURRENCIES = ['ILS', 'USD', 'EUR'] as const;
export type Currency = (typeof SUPPORTED_CURRENCIES)[number];

export interface ExchangeRates {
  USD?: number;
  EUR?: number;
  updatedAt?: string;   // ISO 8601
  updatedBy?: string;   // user UUID
}

export interface SuggestedRates {
  USD?: number;
  EUR?: number;
  publishedAt?: string;                 // ISO 8601
  source?: 'bank_of_israel';
}

// extends InvoiceObject (tenant-public-api) and the list response
export interface InvoiceCurrencyFields {
  currency: Currency;
  totalIls?: string | null;             // decimal string; null for ILS invoices
  ilsExchangeRate?: string | null;      // decimal string e.g. "3.7200"; null for ILS
}
```
**Acceptance:**
- [ ] `import { Currency, SUPPORTED_CURRENCIES, ExchangeRates, SuggestedRates } from '@zync/types'` resolves.

### Task 3: Snapshot ILS rate & total at `TAX_ISSUED`
**Blocks:** 8, 9, 13  ·  **Blocked by:** 1
**Files:**
- Modify: `apps/zync-api/src/services/invoices/issue-tax.ts`
- Modify: `apps/zync-api/src/routes/invoices.ts` (`POST /api/invoices/:id/issue-tax`)
**Steps:**
- [ ] In the existing `TAX_ISSUED` transition (same DB transaction that assigns the sequential invoice number), read the invoice `currency`.
- [ ] If `currency === 'ILS'`: leave `ils_exchange_rate` and `total_ils` NULL (face value is already ILS).
- [ ] If foreign: read `tenant_settings.exchange_rates->>currency` for the tenant; if the rate is missing, return `422` with a clear error ("No exchange rate set for <CUR>; set it in Settings → Business before issuing").
- [ ] Compute `total_ils = round(total * rate, 2)` and persist `ils_exchange_rate = rate` and `total_ils` atomically with the status update.
- [ ] Treat `ils_exchange_rate` / `total_ils` as immutable after `TAX_ISSUED`: reject any later write (guard in the update path).
**Schema / Interfaces:**
```ts
// within the issue-tax transaction (tenantQuery scoped to tenant_id):
async function snapshotIls(tx, invoice, tenantId): Promise<{ ilsExchangeRate: string | null; totalIls: string | null }> {
  if (invoice.currency === 'ILS') return { ilsExchangeRate: null, totalIls: null };
  const settings = await tx.query.tenantSettings.findFirst({ where: eq(tenantSettings.tenantId, tenantId) });
  const rate = settings?.exchangeRates?.[invoice.currency];   // 'USD' | 'EUR'
  if (rate == null) throw new ApiError(422, `No exchange rate set for ${invoice.currency}`);
  const totalIls = (Number(invoice.total) * Number(rate)).toFixed(2);
  return { ilsExchangeRate: Number(rate).toFixed(4), totalIls };
}
```
**Acceptance:**
- [ ] Issuing a USD invoice with `exchange_rates.USD = 3.72` and `total = 936.00` persists `ils_exchange_rate = 3.7200` and `total_ils = 3481.92`.
- [ ] Issuing a foreign invoice with no rate set returns `422` and does NOT assign an invoice number (transaction rolls back).
- [ ] Issuing an ILS invoice leaves both columns NULL.
- [ ] A later rate change in settings does not alter an already-issued invoice's snapshot.

### Task 4: Tier gate — Business+ unlocks USD/EUR; Freelancer locked to ILS
**Blocks:** 6  ·  **Blocked by:** 2
**Files:**
- Modify: `apps/zync-api/src/services/invoices/validate.ts`
- Modify: `apps/zync-api/src/routes/settings.ts` (business handler currency path)
- Modify: `apps/zync-app/src/features/invoices/InvoiceForm.tsx`
**Steps:**
- [ ] In invoice create/update validation, if requested `currency !== 'ILS'` enforce `meetsMinimumTier(tenant.tier, 'business')`; otherwise reject `403` ("Multi-currency requires Business or higher").
- [ ] In the business-settings handler, reject `defaultInvoiceCurrency`/`exchangeRates` writes from Freelancer tenants the same way.
- [ ] In the React invoice form, gate the currency dropdown with `useTierGate('business')`: Freelancer sees ILS-only (disabled) with an upgrade affordance.
**Schema / Interfaces:**
```ts
// server-side guard (foundation-auth-rbac exports)
if (currency !== 'ILS' && !meetsMinimumTier(tenant.tier, 'business')) {
  throw new ApiError(403, 'Multi-currency requires Business or higher');
}
```
**Acceptance:**
- [ ] A Freelancer tenant POSTing an invoice with `currency: 'USD'` gets `403`.
- [ ] A Business tenant can create USD/EUR invoices.
- [ ] Freelancer invoice form shows the currency dropdown disabled/locked to ILS.

### Task 5: Extend `PATCH /api/settings/business` — default currency + manual rates
**Blocks:** 6, 7, 11  ·  **Blocked by:** 1, 2
**Files:**
- Modify: `apps/zync-api/src/routes/settings.ts` (`GET`/`PATCH /api/settings/business`)
- Modify: `apps/zync-api/src/schemas/settings.ts` (zod)
**Steps:**
- [ ] Extend the `PATCH /api/settings/business` zod body with optional `defaultInvoiceCurrency` and `exchangeRates`.
- [ ] Persist `defaultInvoiceCurrency` to `tenants.settings->>'default_invoice_currency'` (default `ILS`).
- [ ] Persist `exchangeRates` to `tenant_settings.exchange_rates` JSONB, stamping `updatedAt` (now) and `updatedBy` (session user id). Merge per-currency (a partial `{ USD }` patch does not clear `EUR`).
- [ ] Extend the `GET /api/settings/business` response to return `defaultInvoiceCurrency` and `exchangeRates`.
- [ ] Require `settings:write` on PATCH, `settings:read` on GET (`requirePermission`).
**Schema / Interfaces:**
```ts
// zod
const exchangeRatesSchema = z.object({
  USD: z.number().positive().optional(),
  EUR: z.number().positive().optional(),
}).optional();

const patchBusinessSchema = existingBusinessSchema.extend({
  defaultInvoiceCurrency: z.enum(['ILS', 'USD', 'EUR']).optional(),
  exchangeRates: exchangeRatesSchema,
});
// PATCH body: { defaultInvoiceCurrency?, exchangeRates?: { USD?, EUR? } }
```
**Acceptance:**
- [ ] `PATCH /api/settings/business` with `{ exchangeRates: { USD: 3.78 } }` writes `tenant_settings.exchange_rates` with `updatedAt`/`updatedBy` set and leaves any existing `EUR` rate intact.
- [ ] `default_invoice_currency` round-trips through `GET`.
- [ ] Missing `settings:write` permission returns `403`.

### Task 6: Settings UI — currency default + manual exchange-rate editor
**Blocks:** —  ·  **Blocked by:** 4, 5
**Files:**
- Modify: `apps/zync-app/src/features/settings/BusinessProfile.tsx`
- Create: `apps/zync-app/src/features/settings/ExchangeRatesPanel.tsx`
**Steps:**
- [ ] Add a "Default invoice currency" select (ILS/USD/EUR) to the Business Profile section, gated by `useTierGate('business')`.
- [ ] Build the Exchange Rates panel: editable `1 USD = [__] ILS`, `1 EUR = [__] ILS` rows showing "Last updated: <date> by <user>" from `exchange_rates.updatedAt`/`updatedBy`.
- [ ] "Update rates" submits via `PATCH /api/settings/business`.
- [ ] Render the informational note: "Rates apply to new invoices. Issued invoices retain the rate in effect at issue time."
- [ ] A11y: every rate input has an associated `<FormLabel>`; the note uses `role="note"`; respect `prefers-reduced-motion` on any save transition.
**Acceptance:**
- [ ] Business tenant can edit and save USD/EUR rates; the "Last updated" line reflects the saved metadata.
- [ ] Freelancer tenant sees the panel disabled with an upgrade prompt.
- [ ] Inputs are keyboard-reachable and labeled (axe: no violations).

### Task 7: Invoice form — per-invoice currency dropdown, locked after SENT
**Blocks:** —  ·  **Blocked by:** 4, 5
**Files:**
- Modify: `apps/zync-app/src/features/invoices/InvoiceForm.tsx`
**Steps:**
- [ ] Default the currency dropdown to the tenant's `default_invoice_currency`.
- [ ] Allow changing currency per invoice while status is `DRAFT`; disable the dropdown once status is `SENT` or later.
- [ ] Format line `unit_price`/`subtotal`/`total` with `Intl.NumberFormat(locale, { style: 'currency', currency })` using the selected invoice currency.
- [ ] When a foreign currency is selected and no rate exists in settings, show an inline warning linking to `/settings/business`.
**Acceptance:**
- [ ] Currency defaults to tenant default; changeable in DRAFT.
- [ ] Dropdown is disabled on a `SENT`/`APPROVED`/`TAX_ISSUED` invoice.
- [ ] Amounts render with the correct currency symbol/format.

### Task 8: Invoice document — mandatory exchange-rate + ILS-equivalent line
**Blocks:** —  ·  **Blocked by:** 1, 3
**Files:**
- Modify: `apps/zync-api/src/services/invoices/render-html.ts` (the renderer behind `GET /api/invoices/:id/html` and the R2 `TAX_ISSUED` snapshot)
**Steps:**
- [ ] For foreign-currency invoices, render line items and totals in the face currency (`currency`).
- [ ] Append the legally-mandatory block: `Exchange rate at issue: 1 <CUR> = <ils_exchange_rate> ILS` and `Total in ILS: ₪<total_ils>  (for tax purposes)`.
- [ ] Source the ILS figures from the immutable snapshot columns (`ils_exchange_rate`, `total_ils`) — never recompute from current settings.
- [ ] For ILS invoices, omit the exchange block entirely.
- [ ] Ensure the same block is present in the R2 HTML snapshot written at `TAX_ISSUED` (compliance record).
**Schema / Interfaces:**
```
Subtotal                      USD 800.00
VAT (17%)                     USD 136.00
Total                         USD 936.00

Exchange rate at issue: 1 USD = 3.72 ILS
Total in ILS: ₪3,482.00  (for tax purposes)
```
**Acceptance:**
- [ ] A rendered USD invoice shows the face total in USD plus the exchange-rate line and ILS total from the snapshot.
- [ ] An ILS invoice renders with no exchange block.
- [ ] The R2 snapshot stored at `TAX_ISSUED` contains the exchange block for foreign invoices.

### Task 9: Invoice list/detail serializer — expose `totalIls` & `ilsExchangeRate`
**Blocks:** —  ·  **Blocked by:** 2, 3
**Files:**
- Modify: `apps/zync-api/src/routes/invoices.ts` (`GET /api/invoices` list)
- Modify: `apps/zync-api/src/serializers/invoice.ts` (the `serializeInvoice` output shape)
- Modify: `apps/zync-app/src/features/invoices/InvoiceList.tsx`
**Steps:**
- [ ] Add `totalIls` and `ilsExchangeRate` (decimal strings, nullable) to each invoice in the `GET /api/invoices` list response and in `serializeInvoice`.
- [ ] In the list/detail UI, for foreign-currency invoices display the face total plus an ILS-equivalent sub-line when `totalIls` is present.
**Schema / Interfaces:**
```ts
// InvoiceObject (tenant-public-api) extended; `currency` already present
interface InvoiceObject {
  // (all existing InvoiceObject fields retained unchanged)
  currency: Currency;                  // 'ILS' | 'USD' | 'EUR'
  totalIls?: string | null;            // decimal string; null for ILS invoices
  ilsExchangeRate?: string | null;     // decimal string e.g. "3.7200"; null for ILS
}
```
**Acceptance:**
- [ ] `GET /api/invoices` returns `currency`, `totalIls`, `ilsExchangeRate` per invoice.
- [ ] List UI shows the ILS equivalent under foreign-currency totals.

### Task 10: Bank of Israel exchange-rate cron — fetch, parse, store in KV
**Blocks:** 11, 12  ·  **Blocked by:** 1
**Files:**
- Create: `apps/zync-api/src/cron/boi-exchange-rates.ts`
- Modify: `apps/zync-api/wrangler.toml`
- Modify: `apps/zync-api/src/index.ts` (scheduled handler dispatch)
**Steps:**
- [ ] Add the cron trigger `0 14 * * 1-5` (14:00 UTC = 17:00 IL, post-BoI publication, Mon–Fri).
- [ ] Implement `fetchBoiRates(env)`: fetch `https://www.boi.org.il/currency.xml` with `Accept: application/xml` and `cf: { cacheTtl: 3600 }`; on non-OK, log and return (silent fail — manual rates remain unchanged).
- [ ] Implement `parseBoiXml(xml)` extracting `CURRENCY[@id='USD']` and `['EUR']` RATE values.
- [ ] Store pending rates in `KV` under `boi_rates:YYYY-MM-DD` as `{ USD, EUR, publishedAt, source: 'bank_of_israel' }` with `expirationTtl: 86400 * 30`.
- [ ] Call `notifyTenantsWithAutoSync(env, rates)` (Task 11).
- [ ] Security: do not embed secrets; outbound fetch only. No rate is written to `tenant_settings` here (KV staging only).
**Schema / Interfaces:**
```ts
export async function fetchBoiRates(env: Env): Promise<void> {
  const res = await fetch('https://www.boi.org.il/currency.xml', {
    headers: { Accept: 'application/xml' },
    cf: { cacheTtl: 3600 },
  });
  if (!res.ok) { console.error('BoI feed fetch failed:', res.status); return; }
  const rates = parseBoiXml(await res.text());   // { USD: number, EUR: number }
  const dateKey = new Date().toISOString().slice(0, 10);
  await env.KV.put(`boi_rates:${dateKey}`, JSON.stringify({
    USD: rates.USD, EUR: rates.EUR,
    publishedAt: new Date().toISOString(), source: 'bank_of_israel',
  }), { expirationTtl: 86400 * 30 });
  await notifyTenantsWithAutoSync(env, rates);
}
export function parseBoiXml(xml: string): { USD: number; EUR: number } { /* extract CURRENCY[@id]/RATE */ }
```
```toml
[[triggers.crons]]
name = "boi-exchange-rates"
cron = "0 14 * * 1-5"
```
**Acceptance:**
- [ ] On a successful fetch, `KV` key `boi_rates:<today>` holds parsed USD/EUR with `source: 'bank_of_israel'`.
- [ ] A non-200 BoI response logs and leaves KV and `tenant_settings` untouched (no throw).
- [ ] The cron is registered in `wrangler.toml` and invoked by the scheduled handler.

### Task 11: Notify auto-sync tenants — `exchange_rates_updated`
**Blocks:** —  ·  **Blocked by:** 2, 5, 10
**Files:**
- Modify: `apps/zync-api/src/cron/boi-exchange-rates.ts` (`notifyTenantsWithAutoSync`)
**Steps:**
- [ ] Query tenants where `tenant_settings.boi_rate_sync_enabled = true`.
- [ ] For each, emit an informational `exchange_rates_updated` notification via `createNotification` (deep-links `/settings/business`, already mapped in `notification-center`).
- [ ] The notification is informational only — it must NOT apply any rate. Staff must confirm.
**Schema / Interfaces:**
```ts
async function notifyTenantsWithAutoSync(env: Env, rates: { USD: number; EUR: number }): Promise<void> {
  const tenants = await listTenantsWithBoiSync(env); // boi_rate_sync_enabled = true
  for (const t of tenants) {
    await createNotification({
      tenantId: t.id,
      type: 'exchange_rates_updated',
      link: '/settings/business',
    });
  }
}
```
**Acceptance:**
- [ ] Only tenants with `boi_rate_sync_enabled = true` receive the notification.
- [ ] No `tenant_settings.exchange_rates` row is modified by the cron.

### Task 12: Suggested-rates API + BoI opt-in + staff-confirmation UI
**Blocks:** —  ·  **Blocked by:** 5, 10
**Files:**
- Create: `apps/zync-api/src/routes/exchange-rates.ts` (`GET /api/settings/exchange-rates/suggested`)
- Modify: `apps/zync-api/src/routes/settings.ts` (persist `boiRateSyncEnabled` on `PATCH /api/settings/business`)
- Modify: `apps/zync-app/src/features/settings/ExchangeRatesPanel.tsx`
**Steps:**
- [ ] Implement `GET /api/settings/exchange-rates/suggested` (requires `settings:read`): if the tenant has `boi_rate_sync_enabled = true`, read today's `boi_rates:<today>` from `KV` and return `{ USD?, EUR?, publishedAt?, source }`; otherwise return `null`.
- [ ] Extend `PATCH /api/settings/business` to accept and persist `boiRateSyncEnabled` to `tenant_settings.boi_rate_sync_enabled`.
- [ ] In the settings panel, add the "Auto-fetch from Bank of Israel (daily, 17:00 IL time)" checkbox.
- [ ] When suggested rates differ from stored rates, show a diff row per currency: `USD: 3.72 → 3.78 (BoI, <date>) [Apply] [Dismiss]`. **Apply** issues `PATCH /api/settings/business` with `{ exchangeRates: { USD: 3.78 } }`; **Dismiss** hides the suggestion locally. Rates are never auto-applied.
- [ ] A11y: checkbox labeled; diff rows reachable by keyboard; Apply/Dismiss are real `<Button>`s.
**Schema / Interfaces:**
```ts
// GET /api/settings/exchange-rates/suggested  (requires settings:read)
// → SuggestedRates | null
type SuggestedRatesResponse = {
  USD?: number; EUR?: number; publishedAt?: string; source?: 'bank_of_israel';
} | null;
```
**Acceptance:**
- [ ] With sync enabled and a KV entry present, the endpoint returns today's BoI USD/EUR with `source: 'bank_of_israel'`.
- [ ] With sync disabled, the endpoint returns `null`.
- [ ] Clicking **Apply** persists the suggested rate via `PATCH /api/settings/business`; nothing is applied without that click.
- [ ] `boi_rate_sync_enabled` round-trips through the business settings API.

### Task 13: Reports aggregation — sum `total_ils` not `total` (cross-spec)
**Blocks:** —  ·  **Blocked by:** 3
**Files:**
- Modify: `apps/zync-api/src/services/reports/revenue.ts` (owned by `admin-reports-analytics` / reports — NOT in this spec's depends_on; coordinate with that owner)
- Modify: `apps/zync-app/src/features/reports/RevenueReport.tsx`
**Steps:**
- [ ] Change revenue aggregations to sum `COALESCE(invoices.total_ils, invoices.total)` so foreign-currency invoices contribute their ILS snapshot while ILS invoices (and pre-feature rows where `total_ils IS NULL`) contribute `total` (already ILS).
- [ ] Add the report-UI disclaimer: "All amounts converted to ILS at rate of issue."
- [ ] **Cross-spec flag:** this touches reports-analytics-owned code; raise with that spec's owner before merging.
**Schema / Interfaces:**
```sql
-- revenue aggregation
SELECT SUM(COALESCE(total_ils, total)) AS revenue_ils
FROM invoices
WHERE tenant_id = $1 AND status IN ('TAX_ISSUED','PAID','PARTIALLY_PAID');
```
**Acceptance:**
- [ ] A USD invoice with `total_ils = 3481.92` contributes 3481.92 to revenue, not its USD face value.
- [ ] An ILS invoice with `total_ils IS NULL` contributes its `total`.
- [ ] The revenue report shows the "converted to ILS at rate of issue" disclaimer.
