# System: i18n & Localization — Implementation Plan

**Spec:** docs/specs/2026-05-30-system-i18n.md  ·  **Slug:** system-i18n  ·  **Wave:** 2
**Depends on:** foundation-monorepo, foundation-auth-rbac

## Goal
Deliver the internationalization framework for Zync: per-user language switching (English/Hebrew first), RTL layout direction driven by locale, a pluggable country-adapter layer (Israel as the v1 adapter), and a seeded `vat_rates` history table with a date-aware lookup. This package is the foundation every UI spec and the invoice/expense/tax modules consume for translated strings, VAT rates, currency, and timezone defaults — it must be complete and correctly named because downstream plans lock to its exports.

## Architecture
- **Translation strings** live as JSON in `packages/ui/src/i18n/{en,he}.json`, re-exported by `packages/ui/src/i18n/index.ts` as `{ translations, supportedLocales }`. No namespace splitting; one file per locale.
- **Runtime i18n** is initialized once in `apps/zync-app/src/i18n.ts` using `i18next` + `react-i18next`. The provider wraps the app root. Locale resolution order: `user_preferences.locale` → tenant default → browser `Accept-Language` → `en`.
- **RTL** is applied imperatively on locale change by setting `document.documentElement.lang` and `dir`. Hebrew flips `--font-sans` to the Heebo stack (design-system tokens). Components rely on CSS logical properties (`ms-/me-`) — no JS layout recalc.
- **Country adapters** implement the `CountryAdapter` interface (`packages/types/src/country-adapter.ts`). `packages/db/src/queries/country.ts` resolves the adapter for a tenant from `tenants.country_code` (this plan adds that column — auth-rbac's `tenants` table does not carry it).
- **VAT rates** are stored in the seeded `vat_rates` table (composite PK `country_code, effective_from`). `packages/db/src/queries/vat.ts` exports `getVatRate(db, countryCode, date)` returning the most recent rate effective on or before `date`. Consumed by invoices, expenses, and tax reports.

Upstream consumed exactly:
- `user_preferences.locale` (TEXT, nullable; `'he' | 'en'`) and `user_preferences.default_currency`, `user_preferences.timezone` — from foundation-auth-rbac.
- `tenants(id, slug, name, tier, require_approval, created_at)` — from foundation-auth-rbac; this plan adds `country_code`, `default_currency`, `default_timezone` columns via migration.
- `createDb(env)` and the `packages/db` schema/queries/migrations layout — from foundation-monorepo.
- `packages/types` (DTOs, branded `TenantId`) and `packages/ui` (React component library) — from foundation-monorepo.

## Tech Stack
- **Packages:** `packages/ui` (translation JSON + `useDirection` hook + `LocaleProvider`), `packages/types` (`CountryAdapter`, `InvoiceRequirements`, `Locale`), `packages/db` (`vat_rates` schema, `tenants` delta, `vat.ts` + `country.ts` queries), `packages/config` (Tailwind RTL preset additions), `apps/zync-app` (i18n bootstrap + `/settings/locale` UI).
- **Libraries:** `i18next`, `react-i18next`, `i18next-resources-for-ts` (typed-key codegen), `@tailwindcss/typography`.
- **Cloudflare bindings:** Neon Postgres via Hyperdrive `DB` binding (through `createDb(env)`); no new bindings introduced.
- **ORM:** Drizzle (schema + drizzle-kit migration + seed).

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — Types & schema | 1, 2, 3 | `packages/types/src/country-adapter.ts`, `packages/db/src/schema/vat-rates.ts`, `packages/db/src/schema/tenants.ts`, migration SQL | Tasks 1 & 2 parallel; 3 depends on nothing new but touches tenants |
| B — DB queries & seed | 4, 5 | `packages/db/src/queries/vat.ts`, `packages/db/src/queries/country.ts`, seed file | After A |
| C — Translation assets | 6, 7 | `packages/ui/src/i18n/{en,he,index}.ts(json)`, codegen config | Parallel with A/B |
| D — Runtime & RTL | 8, 9, 10 | `apps/zync-app/src/i18n.ts`, `packages/ui` provider/hook, `packages/config` tailwind preset | After C; 8→9→10 sequential |
| E — Settings UI | 11 | `apps/zync-app/src/routes/settings/locale.tsx`, API route | After B + D |
| F — Governance & tests | 12, 13 | CI script, vitest specs | After all |

## Tasks

### Task 1: `CountryAdapter` type contract
**Blocks:** 5, 11  ·  **Blocked by:** —
**Files:**
- Create: `packages/types/src/country-adapter.ts`
- Modify: `packages/types/src/index.ts`
**Steps:**
- [ ] Define the `Locale` union type (`'en' | 'he'`) and `SUPPORTED_LOCALES` const array.
- [ ] Define `InvoiceRequirements` interface (fields below — these encode IL legal invoice rules consumed by invoices-core).
- [ ] Define `CountryAdapter` interface verbatim per spec.
- [ ] Re-export all three from `packages/types/src/index.ts`.
**Schema / Interfaces:**
```ts
// packages/types/src/country-adapter.ts
export type Locale = 'en' | 'he'
export const SUPPORTED_LOCALES: readonly Locale[] = ['en', 'he'] as const

export interface InvoiceRequirements {
  requiresTaxId: boolean          // IL: business must show its tax/VAT ID
  taxIdLabel: string              // "ע.מ / ח.פ" for IL, "Tax ID" otherwise
  requiresSequentialNumbering: boolean
  allowsVatExemptZeroRate: boolean
  retentionYears: number          // IL: 7
}

export interface CountryAdapter {
  code: string                          // ISO 3166-1 alpha-2 (e.g. "IL")
  name: string
  defaultCurrency: string               // ISO 4217 (e.g. "ILS")
  defaultTimezone: string               // e.g. "Asia/Jerusalem"
  getVatRate(date: Date): number        // decimal, e.g. 0.18
  vatLabel: string                      // "מע\"מ" for IL, "VAT" for others
  invoiceRequirements: InvoiceRequirements
}
```
**Acceptance:**
- [ ] `import { CountryAdapter, Locale, SUPPORTED_LOCALES } from '@zync/types'` type-checks.
- [ ] No DB or React imports in this file (pure types).

### Task 2: `vat_rates` schema (Drizzle)
**Blocks:** 4, 5  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/src/schema/vat-rates.ts`
- Modify: `packages/db/src/index.ts` (re-export)
**Steps:**
- [ ] Define the `vatRates` Drizzle table with composite primary key `(countryCode, effectiveFrom)`.
- [ ] Use `numeric` with precision 5, scale 4 for `rate`; `date` for `effectiveFrom`; `text` for `countryCode`.
- [ ] Export the table and its inferred select/insert types.
**Schema / Interfaces:**
```sql
CREATE TABLE vat_rates (
  country_code   TEXT NOT NULL,
  effective_from DATE NOT NULL,
  rate           NUMERIC(5, 4) NOT NULL,  -- e.g. 0.1800 for 18%
  PRIMARY KEY (country_code, effective_from)
);
```
```ts
// packages/db/src/schema/vat-rates.ts
import { pgTable, text, date, numeric, primaryKey } from 'drizzle-orm/pg-core'

export const vatRates = pgTable('vat_rates', {
  countryCode:   text('country_code').notNull(),
  effectiveFrom: date('effective_from').notNull(),
  rate:          numeric('rate', { precision: 5, scale: 4 }).notNull(),
}, (t) => ({
  pk: primaryKey({ columns: [t.countryCode, t.effectiveFrom] }),
}))

export type VatRate = typeof vatRates.$inferSelect
export type NewVatRate = typeof vatRates.$inferInsert
```
**Acceptance:**
- [ ] `vatRates` re-exported from `packages/db`.
- [ ] Composite PK present; `rate` is `NUMERIC(5,4)`, never float.

### Task 3: `tenants` localization delta migration
**Blocks:** 5, 11  ·  **Blocked by:** —
**Files:**
- Modify: `packages/db/src/schema/tenants.ts`
- Create: `packages/db/migrations/<timestamp>_i18n_tenant_locale.sql` (drizzle-kit generated)
**Steps:**
- [ ] Add `countryCode`, `defaultCurrency`, `defaultTimezone` columns to the existing `tenants` Drizzle table (auth-rbac owns the base table; this plan extends it — do not redefine it).
- [ ] `country_code` defaults to `'IL'` (v1 ships Israel only); `default_currency` defaults `'ILS'`; `default_timezone` defaults `'Asia/Jerusalem'`.
- [ ] Add a CHECK constraint restricting `country_code` to the supported set for v1 (`'IL'`).
- [ ] Run `drizzle-kit generate` to emit the migration; verify it is additive (ALTER TABLE ADD COLUMN, no data loss).
**Schema / Interfaces:**
```sql
ALTER TABLE tenants
  ADD COLUMN country_code     TEXT NOT NULL DEFAULT 'IL'
    CHECK (country_code IN ('IL')),
  ADD COLUMN default_currency TEXT NOT NULL DEFAULT 'ILS',
  ADD COLUMN default_timezone TEXT NOT NULL DEFAULT 'Asia/Jerusalem';
```
```ts
// added to packages/db/src/schema/tenants.ts tenants definition
countryCode:     text('country_code').notNull().default('IL'),
defaultCurrency: text('default_currency').notNull().default('ILS'),
defaultTimezone: text('default_timezone').notNull().default('Asia/Jerusalem'),
// + CHECK (country_code IN ('IL')) via sql constraint in the table's extras callback
```
**Acceptance:**
- [ ] Migration applies cleanly against a Neon branch with existing tenant rows.
- [ ] Existing tenants backfill to `IL` / `ILS` / `Asia/Jerusalem`.
- [ ] `country_code` CHECK rejects unsupported codes.

### Task 4: VAT rate lookup query
**Blocks:** 5, 13  ·  **Blocked by:** 2
**Files:**
- Create: `packages/db/src/queries/vat.ts`
- Modify: `packages/db/src/queries/index.ts`
**Steps:**
- [ ] Implement `getVatRate(db, countryCode, date)` returning the most recent rate with `effective_from <= date`.
- [ ] Order by `effective_from DESC`, `limit 1`; coerce the `numeric` string to `Number`; return `0` when no row matches.
- [ ] Export from the queries barrel.
**Schema / Interfaces:**
```ts
// packages/db/src/queries/vat.ts
import { and, desc, eq, lte } from 'drizzle-orm'
import type { DB } from '../index'
import { vatRates } from '../schema/vat-rates'

export async function getVatRate(db: DB, countryCode: string, date: Date): Promise<number> {
  const row = await db
    .select({ rate: vatRates.rate })
    .from(vatRates)
    .where(and(eq(vatRates.countryCode, countryCode), lte(vatRates.effectiveFrom, date)))
    .orderBy(desc(vatRates.effectiveFrom))
    .limit(1)
  return Number(row[0]?.rate ?? 0)
}
```
**Acceptance:**
- [ ] `getVatRate(db, 'IL', new Date('2025-06-01'))` returns `0.18`.
- [ ] `getVatRate(db, 'IL', new Date('2010-06-01'))` returns `0.16`.
- [ ] Unknown country returns `0`.

### Task 5: Country adapter resolver + Israel adapter
**Blocks:** 11  ·  **Blocked by:** 1, 2, 3, 4
**Files:**
- Create: `packages/db/src/queries/country.ts`
- Create: `packages/db/src/adapters/israel.ts`
- Modify: `packages/db/src/queries/index.ts`
**Steps:**
- [ ] Implement the Israel adapter as an async factory `createIsraelAdapter(db)` that preloads the full IL VAT history (ordered `effective_from DESC`) once at construction, so the synchronous `CountryAdapter.getVatRate(date)` does an in-memory find over the preloaded rates and returns the correct decimal — honoring the spec's sync interface verbatim while staying DB-driven.
- [ ] Implement `loadCountryAdapter(db, countryCode)` returning a `Promise<CountryAdapter>` for the matching adapter; throw a typed error for unsupported codes (v1 supports `'IL'` only).
- [ ] Implement `getTenantCountryAdapter(db, tenantId)` that reads `tenants.country_code` then calls `loadCountryAdapter`.
- [ ] Set `vatLabel = 'מע"מ'`, `defaultCurrency = 'ILS'`, `defaultTimezone = 'Asia/Jerusalem'`, and IL `invoiceRequirements` (`requiresTaxId: true`, `taxIdLabel: 'ע.מ / ח.פ'`, `requiresSequentialNumbering: true`, `allowsVatExemptZeroRate: true`, `retentionYears: 7`).
**Schema / Interfaces:**
```ts
// packages/db/src/queries/country.ts
import { eq } from 'drizzle-orm'
import type { CountryAdapter } from '@zync/types'
import type { DB } from '../index'
import { tenants } from '../schema/tenants'
import { createIsraelAdapter } from '../adapters/israel'

export async function loadCountryAdapter(db: DB, countryCode: string): Promise<CountryAdapter> {
  switch (countryCode) {
    case 'IL': return createIsraelAdapter(db)
    default: throw new Error(`Unsupported country_code: ${countryCode}`)
  }
}

export async function getTenantCountryAdapter(db: DB, tenantId: string): Promise<CountryAdapter> {
  const [t] = await db.select({ cc: tenants.countryCode }).from(tenants).where(eq(tenants.id, tenantId)).limit(1)
  return loadCountryAdapter(db, t?.cc ?? 'IL')
}
```
```ts
// packages/db/src/adapters/israel.ts
import { desc, eq } from 'drizzle-orm'
import type { CountryAdapter } from '@zync/types'
import type { DB } from '../index'
import { vatRates } from '../schema/vat-rates'

export async function createIsraelAdapter(db: DB): Promise<CountryAdapter> {
  // Preload the IL VAT history once; sync getVatRate does an in-memory find.
  const rates = await db
    .select({ effectiveFrom: vatRates.effectiveFrom, rate: vatRates.rate })
    .from(vatRates)
    .where(eq(vatRates.countryCode, 'IL'))
    .orderBy(desc(vatRates.effectiveFrom))
  return {
    code: 'IL',
    name: 'Israel',
    defaultCurrency: 'ILS',
    defaultTimezone: 'Asia/Jerusalem',
    getVatRate: (date) => {
      const hit = rates.find((r) => new Date(r.effectiveFrom) <= date)
      return hit ? Number(hit.rate) : 0
    },
    vatLabel: 'מע"מ',
    invoiceRequirements: {
      requiresTaxId: true,
      taxIdLabel: 'ע.מ / ח.פ',
      requiresSequentialNumbering: true,
      allowsVatExemptZeroRate: true,
      retentionYears: 7,
    },
  }
}
```
**Acceptance:**
- [ ] `getTenantCountryAdapter(db, tenantId)` resolves the IL adapter with `vatLabel === 'מע"מ'` and ILS currency.
- [ ] `adapter.getVatRate(new Date('2025-06-01')) === 0.18` and `adapter.getVatRate(new Date('2010-06-01')) === 0.16`.
- [ ] `loadCountryAdapter(db, 'US')` rejects.
- [ ] IL `invoiceRequirements.retentionYears === 7`.

### Task 6: VAT seed data
**Blocks:** 4 (data), 13  ·  **Blocked by:** 2
**Files:**
- Create: `packages/db/src/seed/vat-rates.seed.ts`
- Modify: `packages/db/src/seed/index.ts`
**Steps:**
- [ ] Seed all 18 Israel VAT rows from the spec table as decimal rates (e.g. 8.00% → `0.0800`).
- [ ] Use `onConflictDoNothing` on the composite PK so re-seeding is idempotent.
- [ ] Wire into the seed runner invoked by `pnpm db:seed`.
**Schema / Interfaces:**
```ts
// packages/db/src/seed/vat-rates.seed.ts — IL rows (country_code, effective_from, rate)
export const IL_VAT_RATES = [
  ['IL', '1976-07-01', '0.0800'], ['IL', '1977-11-01', '0.1200'],
  ['IL', '1982-08-01', '0.1500'], ['IL', '1985-06-01', '0.1700'],
  ['IL', '1985-10-01', '0.1500'], ['IL', '1990-03-01', '0.1600'],
  ['IL', '1991-01-01', '0.1800'], ['IL', '1993-01-01', '0.1700'],
  ['IL', '2002-06-15', '0.1800'], ['IL', '2004-03-01', '0.1700'],
  ['IL', '2005-09-01', '0.1650'], ['IL', '2006-07-01', '0.1550'],
  ['IL', '2009-07-01', '0.1650'], ['IL', '2010-01-01', '0.1600'],
  ['IL', '2012-09-01', '0.1700'], ['IL', '2013-06-02', '0.1800'],
  ['IL', '2015-10-01', '0.1700'], ['IL', '2025-01-01', '0.1800'],
] as const
```
**Acceptance:**
- [ ] After seeding, `SELECT count(*) FROM vat_rates WHERE country_code='IL'` returns 18.
- [ ] Re-running the seed does not duplicate rows or error.
- [ ] `getVatRate(db, 'IL', new Date('2013-06-02'))` returns `0.18`.

### Task 7: Translation files (`en.json`, `he.json`, barrel)
**Blocks:** 8, 12  ·  **Blocked by:** —
**Files:**
- Create: `packages/ui/src/i18n/en.json`
- Create: `packages/ui/src/i18n/he.json`
- Create: `packages/ui/src/i18n/index.ts`
**Steps:**
- [ ] Create `en.json` with flat plural-suffix keys (`_one` / `_other`) and base keys for the locale settings screen (`settings.locale.*`).
- [ ] Create `he.json` mirroring every key, supplying all 5 CLDR Hebrew plural forms (`zero`, `one`, `two`, `many`, `other`) for any count-bearing key.
- [ ] Create `index.ts` exporting `{ translations, supportedLocales }` where `translations = { en, he }` and `supportedLocales = ['en','he']`.
- [ ] Include the canonical `tasks_count` example key in both files as the reference pattern.
**Schema / Interfaces:**
```ts
// packages/ui/src/i18n/index.ts
import en from './en.json'
import he from './he.json'
export const translations = { en, he } as const
export const supportedLocales = ['en', 'he'] as const
export type SupportedLocale = (typeof supportedLocales)[number]
```
```json
// en.json (excerpt — flat suffix plurals)
{
  "settings.locale.title": "Language & Region",
  "settings.locale.siteLanguage": "Site Language",
  "settings.locale.country": "Country",
  "tasks_count_one": "1 task",
  "tasks_count_other": "{{count}} tasks"
}
```
```json
// he.json (excerpt — nested 5-form plural per spec)
{
  "settings.locale.title": "שפה ואזור",
  "settings.locale.siteLanguage": "שפת האתר",
  "settings.locale.country": "מדינה",
  "tasks_count": {
    "zero":  "אין משימות",
    "one":   "משימה אחת",
    "two":   "שתי משימות",
    "many":  "{{count}} משימות",
    "other": "{{count}} משימות"
  }
}
```
**Acceptance:**
- [ ] `import { translations, supportedLocales } from '@zync/ui/i18n'` resolves.
- [ ] Every count-bearing key in `he.json` defines all 5 CLDR forms.
- [ ] Key sets of `en.json` and `he.json` are equivalent (modulo plural form expansion).

### Task 8: Typed-key codegen
**Blocks:** 12  ·  **Blocked by:** 7
**Files:**
- Create: `packages/ui/src/i18n/resources.d.ts` (generated)
- Create: `packages/ui/i18next-resources.config.ts`
- Modify: `packages/ui/package.json` (add `i18n:types` script + `i18next-resources-for-ts` dev dep)
**Steps:**
- [ ] Configure `i18next-resources-for-ts` to read `en.json` and emit a `Resources` interface augmenting `react-i18next` so `t()` keys are type-checked.
- [ ] Add `pnpm i18n:types` script that regenerates `resources.d.ts`.
- [ ] Augment `react-i18next` module typing so unknown keys are TypeScript errors.
**Acceptance:**
- [ ] `t('settings.locale.title')` type-checks; `t('does.not.exist')` is a TS error.
- [ ] Regenerating produces no diff when keys are unchanged.

### Task 9: Tailwind RTL preset additions
**Blocks:** 10, 11  ·  **Blocked by:** —
**Files:**
- Modify: `packages/config/tailwind.preset.ts`
- Modify: `packages/config/package.json` (add `@tailwindcss/typography`)
**Steps:**
- [ ] Add `@tailwindcss/typography` to the shared preset plugins.
- [ ] Define the Hebrew font stack token so `html[lang="he"]` sets `--font-sans` to `'Heebo Variable', 'Heebo', Arial, sans-serif`; Latin default keeps Fraunces (display) + IBM Plex Sans (product) per design-system.
- [ ] Ensure utilities favor logical properties (`ms-*`, `me-*`, `ps-*`, `pe-*`, `text-start`, `text-end`) — document that components must not use `ml-/mr-/pl-/pr-/text-left/text-right`.
**Schema / Interfaces:**
```css
/* emitted via preset base layer */
:root { --font-sans: 'IBM Plex Sans', system-ui, sans-serif; }
html[lang="he"] { --font-sans: 'Heebo Variable', 'Heebo', Arial, sans-serif; }
```
**Acceptance:**
- [ ] `@tailwindcss/typography` is active in the shared preset.
- [ ] `html[lang="he"]` resolves `--font-sans` to the Heebo stack.

### Task 10: `LocaleProvider` + `useDirection` (RTL application)
**Blocks:** 11  ·  **Blocked by:** 9
**Files:**
- Create: `packages/ui/src/i18n/LocaleProvider.tsx`
- Create: `packages/ui/src/i18n/useDirection.ts`
- Modify: `packages/ui/src/index.ts`
**Steps:**
- [ ] `useDirection(locale)` returns `'rtl'` for `he`, else `'ltr'`.
- [ ] On locale change, set `document.documentElement.lang = locale` and `document.documentElement.dir = useDirection(locale)`.
- [ ] Respect `prefers-reduced-motion`: gate the `html[dir]` CSS transition so users who request reduced motion get an instant (no-transition) direction swap.
- [ ] `LocaleProvider` wraps children, exposes current locale + a `setLocale` callback, and triggers the language change instantly (no page reload).
**Schema / Interfaces:**
```ts
// packages/ui/src/i18n/useDirection.ts
import type { Locale } from '@zync/types'
export function useDirection(locale: Locale): 'rtl' | 'ltr' {
  return locale === 'he' ? 'rtl' : 'ltr'
}
```
**Acceptance:**
- [ ] Switching to `he` sets `document.documentElement.dir === 'rtl'` and `lang === 'he'` with no reload.
- [ ] With `prefers-reduced-motion: reduce`, the direction change applies without a CSS transition.

### Task 11: i18n runtime bootstrap + `/settings/locale` UI + API
**Blocks:** 13  ·  **Blocked by:** 5, 7, 9, 10
**Files:**
- Create: `apps/zync-app/src/i18n.ts`
- Create: `apps/zync-app/src/routes/settings/locale.tsx`
- Create: `apps/zync-api/src/routes/preferences.ts` (or extend existing user-preferences route)
- Modify: `apps/zync-app/src/main.tsx` (mount provider)
**Steps:**
- [ ] Initialize `i18next` once in `apps/zync-app/src/i18n.ts` per the spec snippet, resources `{ en: { translation: en }, he: { translation: he } }`, `fallbackLng: 'en'`, `interpolation.escapeValue: false`.
- [ ] Implement `getUserLocale()` resolving `user_preferences.locale` → tenant default (`tenants` via active session) → browser `Accept-Language` → `'en'`, with `localStorage` as the immediate client cache.
- [ ] Build `/settings/locale`: Site Language dropdown (English/Hebrew, data-driven from `supportedLocales`) and Country dropdown (Israel only in v1). Language change is instant; persists to `user_preferences.locale`. Country change persists to `tenants.country_code` (business setting; requires tenant-admin permission).
- [ ] Add `PATCH /api/preferences` to update `user_preferences.locale` for the current user, and `PATCH /api/tenant/settings` (or existing route) to update `tenants.country_code` — guarded by RBAC.
- [ ] Wire `aria-label`s on both dropdowns; ensure the page heading uses `t('settings.locale.title')`. No hardcoded English strings in JSX.
**Schema / Interfaces:**
```ts
// apps/zync-app/src/i18n.ts
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import { translations } from '@zync/ui/i18n'
i18n.use(initReactI18next).init({
  resources: { en: { translation: translations.en }, he: { translation: translations.he } },
  lng: getUserLocale(),
  fallbackLng: 'en',
  interpolation: { escapeValue: false },
})
```
**Acceptance:**
- [ ] Selecting Hebrew flips the UI to RTL + Heebo font instantly and persists across reloads (read back from `user_preferences.locale`).
- [ ] Selecting Country=Israel writes `tenants.country_code='IL'`; the field is gated to tenant admins.
- [ ] No hardcoded English strings remain in the locale settings page (`t()` everywhere).

### Task 12: Translation governance CI check
**Blocks:** —  ·  **Blocked by:** 7, 8
**Files:**
- Create: `packages/ui/scripts/check-translations.mjs`
- Modify: `turbo.json` (add `i18n:check` to lint/CI pipeline)
- Modify: `packages/ui/package.json` (add `i18n:check` script)
**Steps:**
- [ ] Script asserts `en.json` and `he.json` have matching key sets (after normalizing plural suffixes vs nested forms).
- [ ] Script asserts every English count key (`*_one`/`*_other` or `{{count}}`-bearing) has all 5 CLDR Hebrew forms (`zero`, `one`, `two`, `many`, `other`) in `he.json`.
- [ ] Fail the build (non-zero exit) when a new English string lacks a Hebrew counterpart or a count key is missing any of the 5 Hebrew forms.
- [ ] Add to the CI/lint pipeline so PRs are blocked per the spec's same-PR governance rule.
**Acceptance:**
- [ ] Removing a Hebrew key (or a Hebrew plural form) makes `pnpm i18n:check` exit non-zero.
- [ ] Adding an English count key without all 5 Hebrew forms fails the check.

### Task 13: Unit tests (VAT lookup, adapter, direction)
**Blocks:** —  ·  **Blocked by:** 4, 5, 6, 10
**Files:**
- Create: `packages/db/src/queries/vat.test.ts`
- Create: `packages/db/src/queries/country.test.ts`
- Create: `packages/ui/src/i18n/useDirection.test.ts`
**Steps:**
- [ ] VAT tests: assert boundary lookups against the seeded history (`2025-01-01 → 0.18`, `2010-06-01 → 0.16`, `1976-07-01 → 0.08`, pre-1976 → `0`, unknown country → `0`). Use a seeded Neon test branch.
- [ ] Country adapter tests: IL adapter has `vatLabel='מע"מ'`, ILS currency, `retentionYears=7`; `loadCountryAdapter('US')` throws.
- [ ] Direction test: `useDirection('he') === 'rtl'`, `useDirection('en') === 'ltr'`.
**Acceptance:**
- [ ] `pnpm turbo test` passes for `packages/db` and `packages/ui` i18n suites.
- [ ] VAT boundary cases all pass against seeded data.
