# Customer Duplicate Detection & Merge — Implementation Plan

**Spec:** docs/specs/2026-05-31-customer-dedup-merge.md  ·  **Slug:** customer-dedup-merge  ·  **Wave:** 7
**Depends on:** customers-module, foundation-auth-rbac, invoices-core, projects-module

## Goal
Detect duplicate customers (same email, or fuzzy name/company similarity ≥ 0.85 via PostgreSQL `pg_trgm`) and provide a merge workflow that consolidates two customer records into one inside a single transaction, reassigning every linked entity (invoices, projects, contacts, tickets, portal users, activities). Detection runs in the background (on import, on create/update) and on-demand. Merge is non-reversible and soft-deletes the losing record. The whole feature is gated to Business+ tiers.

## Architecture
A new table `customer_merge_suggestions` stores detected duplicate pairs. Detection is implemented as helpers in a new package area `packages/db/src/queries/customer-dedup.ts` consumed by:
- A **Cloudflare Queue** consumer (`customer.dedup_check`) that runs the full O(n) trigram scan asynchronously (triggered by data-import completion and by customer create/update).
- An **inline lookup** on customer create that calls `GET /api/customers?email=` (exact, case-insensitive) to warn staff before they create a true email duplicate.

The merge transaction reassigns FK references in upstream-owned tables — `invoices` (invoices-core), `projects` (projects-module), `customer_contacts` + `customer_portal_users` (customers-module), `tickets` (crm-support-center, owned upstream), `customer_activities` (activity-timeline, owned upstream) — then marks the suggestion `accepted`, writes a `customer.merged` audit row **inside the same transaction** (the destructive op demands a synchronous audit, matching the `require-audit-in-transaction` lint rule and the spec's explicit `INSERT INTO tenant_audit_log` step), and archives the losing customer (`status='archived'`, the customers-module soft-delete pattern).

**Dependency gap note:** the merge touches `tickets` and `customer_activities`, which are NOT in this spec's declared `depends_on`. They are owned by crm-support-center and activity-timeline respectively. This plan references them by their exact upstream names and never redefines them; the implementing agent must reassign them defensively (skip the statement gracefully if the table is absent in the current build slice — both are guaranteed present by wave 7).

Consumes upstream exports/tables: `customers`, `customer_contacts`, `customer_portal_users`, `customer_communications`, `tenants`, `users`, `invoices`, `projects`, `tickets`, `customer_activities`, `tenant_audit_log`, `tenantQuery`, `systemQuery`, `requireTier`, `requirePermission`, `authMiddleware`, `useTierGate`, `useUpgradeModal`, `serializeCustomer`, `getCustomerWithStats`, `buildPaginated`, `DataTable`, `Dialog`, `Button`, `Badge`, `Alert`, `EmptyState`, `Toast`/`toast`, `TenantTier`.

## Tech Stack
- **packages/db** — Drizzle schema (`customer_merge_suggestions`), migration (table + `pg_trgm` extension + GIN trgm indexes), query helpers (`packages/db/src/queries/customer-dedup.ts`).
- **apps/zync-api** — Hono routes under `/api/customers/*`, queue consumer for `customer.dedup_check`.
- **apps/zync-app** — React/Vite: `/customers/merge` page, merge modal, Duplicates tab + badge, inline create-warning, TanStack Query hooks.
- **Cloudflare bindings** — `QUEUE` (new `customer.dedup_check` queue), Neon Postgres via Hyperdrive, Drizzle ORM.
- **Libraries** — Zod (route validation), TanStack Query (client data), `@zync/ui` primitives, `@zync/types`.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 7a | Task 1 (schema + migration), Task 2 (Drizzle table + types) | `packages/db/**` | Task 2 after Task 1 |
| 7b | Task 3 (detection helpers), Task 4 (merge transaction helper) | `packages/db/src/queries/customer-dedup.ts` | Yes (both helpers, same file) |
| 7c | Task 5 (queue consumer + wiring), Task 6 (duplicates/dismiss routes), Task 7 (merge route), Task 8 (inline email lookup) | `apps/zync-api/**` | Tasks 6–8 parallel; 5 needs 3 |
| 7d | Task 9 (client hooks), Task 10 (/customers/merge page + Duplicates tab/badge), Task 11 (merge modal), Task 12 (inline create warning) | `apps/zync-app/**` | 10–12 after 9 |

## Tasks

### Task 1: Migration — `customer_merge_suggestions` table, `pg_trgm` extension & GIN indexes
**Blocks:** 2, 3, 4  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/migrations/00XX_customer_merge_suggestions.sql`
**Steps:**
- [ ] Enable the trigram extension and add GIN trigram indexes on `customers(name)` and `customers(company)` so fuzzy detection is index-supported (monorepo rule: index ships in the same migration that needs it).
- [ ] Create `customer_merge_suggestions` with real `CHECK` constraints (the spec wrote enum values as SQL comments — convert them) and `created_at NOT NULL DEFAULT now()`.
- [ ] Add a canonicalized unique pair guard so re-running detection cannot regenerate an already-stored pair: store the pair with `customer_a_id < customer_b_id` ordering and a unique index on `(tenant_id, customer_a_id, customer_b_id)`.
- [ ] Add supporting indexes for the pending-list query and badge count.
**Schema / Interfaces:**
```sql
CREATE EXTENSION IF NOT EXISTS pg_trgm;

CREATE INDEX IF NOT EXISTS idx_customers_name_trgm
  ON customers USING gin (name gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_customers_company_trgm
  ON customers USING gin (company gin_trgm_ops);

CREATE TABLE customer_merge_suggestions (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id     UUID NOT NULL REFERENCES tenants(id),
  customer_a_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
  customer_b_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
  reason        TEXT NOT NULL CHECK (reason IN ('email_match', 'name_similarity')),
  similarity    NUMERIC(4,3) CHECK (similarity IS NULL OR (similarity >= 0 AND similarity <= 1)),
  status        TEXT NOT NULL DEFAULT 'pending'
                  CHECK (status IN ('pending', 'accepted', 'dismissed')),
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  resolved_at   TIMESTAMPTZ,
  resolved_by   UUID REFERENCES users(id),
  CHECK (customer_a_id < customer_b_id)
);

CREATE UNIQUE INDEX uq_customer_merge_pair
  ON customer_merge_suggestions (tenant_id, customer_a_id, customer_b_id);
CREATE INDEX idx_cms_pending
  ON customer_merge_suggestions (tenant_id, status, created_at DESC);
```
**Acceptance:**
- [ ] `drizzle-kit migrate` applies cleanly against a Neon branch; `pg_trgm` is present (`SELECT similarity('a','ab')` works).
- [ ] Inserting two rows for the same canonical pair in one tenant raises a unique-violation.
- [ ] Inserting `reason='other'` or `status='foo'` is rejected by the CHECK constraints.

### Task 2: Drizzle table definition & shared types
**Blocks:** 3, 4, 6, 7  ·  **Blocked by:** 1
**Files:**
- Create: `packages/db/src/schema/customer-merge-suggestions.ts`
- Modify: `packages/db/src/schema/index.ts` (export new table)
- Modify: `packages/types/src/customer.ts` (or nearest customer types module) — add suggestion + merge DTOs
**Steps:**
- [ ] Define the Drizzle `pgTable` mirroring Task 1 exactly (UUID PKs/FKs, `numeric` for `similarity`, `timestamp` with timezone, text CHECK columns).
- [ ] Export `customerMergeSuggestions` from the schema barrel.
- [ ] Add and export the TypeScript interfaces below.
**Schema / Interfaces:**
```ts
export type MergeReason = 'email_match' | 'name_similarity'
export type MergeSuggestionStatus = 'pending' | 'accepted' | 'dismissed'

export interface CustomerMergeSuggestion {
  id: string
  tenantId: string
  customerAId: string
  customerBId: string
  reason: MergeReason
  similarity: number | null
  status: MergeSuggestionStatus
  createdAt: string
  resolvedAt: string | null
  resolvedBy: string | null
}

// enriched for the /customers/merge UI
export interface MergeSuggestionView {
  id: string
  reason: MergeReason
  similarity: number | null
  customerA: { id: string; name: string; company: string | null; email: string | null; invoiceCount: number; projectCount: number }
  customerB: { id: string; name: string; company: string | null; email: string | null; invoiceCount: number; projectCount: number }
}

export interface MergeRequest { keepId: string; deleteId: string }
export interface MergeResult { keptCustomerId: string; archivedCustomerId: string; reassigned: { invoices: number; projects: number; contacts: number; tickets: number; portalUsers: number; activities: number } }
```
**Acceptance:**
- [ ] `customerMergeSuggestions` is importable from `@zync/db` and typechecks against the migration columns.
- [ ] DTO types are exported from `@zync/types`.

### Task 3: Duplicate detection helpers
**Blocks:** 5, 8  ·  **Blocked by:** 1, 2
**Files:**
- Create: `packages/db/src/queries/customer-dedup.ts`
**Steps:**
- [ ] Implement `findEmailDuplicate(db, tenantId, email)` — exact, case-insensitive (`lower(email) = lower($1)`), non-null email, returns matching active customers excluding a given id.
- [ ] Implement `detectDuplicatesForCustomer(db, tenantId, customerId)` — for one customer, find (a) other active customers with the same lowercased email; (b) other active customers where `similarity(name, $name) >= 0.85` OR `similarity(company, $company) >= 0.85` (only compare non-null company). Upsert a `customer_merge_suggestions` row per pair using canonical `a<b` ordering; `ON CONFLICT (tenant_id, customer_a_id, customer_b_id) DO NOTHING` so dismissed/accepted pairs are never regenerated. Record `reason` (email match takes precedence) and `similarity` (max of the two trigram scores for name_similarity).
- [ ] Implement `detectDuplicatesForTenant(db, tenantId)` — full-tenant scan used by the queue consumer and on-demand refresh; only considers `status='active'` customers; skips pairs already present in `customer_merge_suggestions` (any status).
- [ ] Implement `listPendingSuggestions(db, tenantId, { limit, cursor })` returning `MergeSuggestionView[]` joined with per-customer invoice/project counts (`COUNT` over `invoices`/`projects` filtered by `customer_id`), plus `countPendingSuggestions(db, tenantId)` for the badge.
- [ ] All queries go through `tenantQuery(db, tenantId)` — never raw Drizzle from routes (`no-raw-drizzle-from-routes`).
**Schema / Interfaces:**
```ts
export function findEmailDuplicate(db: Db, tenantId: string, email: string, excludeId?: string): Promise<Customer[]>
export function detectDuplicatesForCustomer(db: Db, tenantId: string, customerId: string): Promise<number> // # suggestions inserted
export function detectDuplicatesForTenant(db: Db, tenantId: string): Promise<number>
export function listPendingSuggestions(db: Db, tenantId: string, opts: { limit: number; cursor?: string }): Promise<{ suggestions: MergeSuggestionView[]; total: number }>
export function countPendingSuggestions(db: Db, tenantId: string): Promise<number>
```
**Acceptance:**
- [ ] Two customers with `Acme@x.com` and `acme@x.com` produce one `email_match` suggestion.
- [ ] `'Dana Cohen'` vs `'Dana Kohen'` yields a `name_similarity` suggestion with `similarity` ≈ 0.9 (≥ 0.85).
- [ ] Re-running detection after a pair is dismissed does not recreate the pair (ON CONFLICT DO NOTHING).
- [ ] All helpers are tenant-scoped; cross-tenant customers never match.

### Task 4: Merge transaction helper
**Blocks:** 7  ·  **Blocked by:** 1, 2
**Files:**
- Modify: `packages/db/src/queries/customer-dedup.ts`
**Steps:**
- [ ] Implement `mergeCustomers(db, tenantId, actor, { keepId, deleteId })` running every step in ONE transaction.
- [ ] Validate both ids are tenant-owned active customers; throw a typed `MergeNotFoundError` (→ 400) if either missing/not tenant-owned, and `AlreadyMergedError` (→ 409) if `deleteId` is already `status='archived'`.
- [ ] Reassign FKs (return affected counts per table), guarding optional upstream tables (`tickets`, `customer_activities`) so a missing table in an early build slice does not abort — they are guaranteed present at wave 7.
- [ ] Mark the originating suggestion(s) for this pair `accepted` (`resolved_at=now()`, `resolved_by=actor.userId`).
- [ ] Write the `customer.merged` audit row **inside the transaction** (synchronous, per spec + `require-audit-in-transaction`).
- [ ] Archive the losing customer (soft delete).
- [ ] Return `MergeResult`.
**Schema / Interfaces:**
```ts
export class MergeNotFoundError extends Error {}   // → HTTP 400
export class AlreadyMergedError extends Error {}   // → HTTP 409

export function mergeCustomers(
  db: Db,
  tenantId: string,
  actor: { userId: string; name: string | null; email: string | null },
  req: MergeRequest,
): Promise<MergeResult>
```
Transaction body (canonical SQL, all parameterized & tenant-scoped):
```sql
-- preflight (inside tx, FOR UPDATE on both customer rows):
--   SELECT id, status FROM customers WHERE tenant_id=$tid AND id IN (keepId, deleteId) FOR UPDATE;
--   -> if <2 rows or either not tenant-owned: MergeNotFoundError
--   -> if deleteId.status = 'archived': AlreadyMergedError

UPDATE invoices               SET customer_id = $keepId WHERE tenant_id=$tid AND customer_id = $deleteId;
UPDATE projects               SET customer_id = $keepId WHERE tenant_id=$tid AND customer_id = $deleteId;
UPDATE customer_contacts      SET customer_id = $keepId WHERE tenant_id=$tid AND customer_id = $deleteId;
UPDATE tickets                SET customer_id = $keepId WHERE tenant_id=$tid AND customer_id = $deleteId;
UPDATE customer_portal_users  SET customer_id = $keepId WHERE tenant_id=$tid AND customer_id = $deleteId;
UPDATE customer_activities    SET customer_id = $keepId WHERE tenant_id=$tid AND customer_id = $deleteId;

UPDATE customer_merge_suggestions
   SET status='accepted', resolved_at=now(), resolved_by=$actorId
 WHERE tenant_id=$tid
   AND ((customer_a_id=$keepId AND customer_b_id=$deleteId)
     OR (customer_a_id=$deleteId AND customer_b_id=$keepId));

INSERT INTO tenant_audit_log (tenant_id, user_id, actor_name, actor_email, event_type, entity_type, entity_id, entity_label, metadata)
VALUES ($tid, $actorId, $actorName, $actorEmail, 'customer.merged', 'customer', $keepId,
        $keptLabel, jsonb_build_object('kept_id',$keepId,'archived_id',$deleteId,'reassigned',$reassignedJson));

UPDATE customers SET status='archived', updated_at=now() WHERE tenant_id=$tid AND id=$deleteId;
```
**Acceptance:**
- [ ] After merge, all `invoices`/`projects`/`contacts`/`tickets`/`portal_users`/`activities` previously on `deleteId` point to `keepId`.
- [ ] `deleteId` customer has `status='archived'`; `keepId` keeps its name/email.
- [ ] One `tenant_audit_log` row with `event_type='customer.merged'` exists, written in the same tx (rolls back if any step fails).
- [ ] Second merge of the same `deleteId` raises `AlreadyMergedError` (→ 409); unknown/cross-tenant id raises `MergeNotFoundError` (→ 400).

### Task 5: Queue consumer & enqueue wiring for background detection
**Blocks:** —  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-api/src/queues/customer-dedup-check.ts`
- Modify: `apps/zync-api/wrangler.toml` (declare `customer.dedup_check` producer + consumer binding)
- Modify: `apps/zync-api/src/index.ts` (route queue messages to the consumer)
- Modify: customer create/update handlers + data-import completion handler to enqueue
**Steps:**
- [ ] Declare a new Cloudflare Queue `customer.dedup_check` (producer binding on `QUEUE`, consumer on the api Worker).
- [ ] Implement the consumer: message `{ tenantId, customerId? }` → call `detectDuplicatesForCustomer` (when `customerId` present) or `detectDuplicatesForTenant` (import completion). Idempotent via ON CONFLICT.
- [ ] On `POST /api/customers` and `PATCH /api/customers/:id` success, enqueue `{ tenantId, customerId }` (non-blocking — never await detection inside the request, per the async architecture decision).
- [ ] On data-import (spec 40) completion, enqueue `{ tenantId }` for a full-tenant scan.
- [ ] Gate enqueue on Business+ (`meetsMinimumTier(tier,'business')`) so lower tiers never accrue suggestions.
**Acceptance:**
- [ ] Creating a duplicate-email customer (Business+ tenant) results in a pending suggestion shortly after, with no added latency on the create response.
- [ ] Freelancer-tier tenants generate no suggestions.

### Task 6: Routes — list duplicates & dismiss
**Blocks:** 9  ·  **Blocked by:** 2, 3
**Files:**
- Create: `apps/zync-api/src/routes/customers/duplicates.ts`
- Modify: `apps/zync-api/src/routes/customers/index.ts` (mount)
**Steps:**
- [ ] `GET /api/customers/duplicates` → `requireTier('business')` + `requirePermission('customers:read')`; returns `{ suggestions: MergeSuggestionView[], total: number }` via `listPendingSuggestions`. Include pending count usable for the tab badge (`countPendingSuggestions`).
- [ ] `POST /api/customers/duplicates/:id/dismiss` → `requireTier('business')` + `requirePermission('customers:write')`; set `status='dismissed'`, `resolved_at=now()`, `resolved_by=user`; 404 if not tenant-owned/pending. Validate `:id` with Zod (`require-zod-validation-in-routes`).
- [ ] All DB access through `tenantQuery` (`no-raw-drizzle-from-routes`).
**Schema / Interfaces:**
```
GET  /api/customers/duplicates          → { suggestions: MergeSuggestionView[]; total: number }
POST /api/customers/duplicates/:id/dismiss → 204
```
**Acceptance:**
- [ ] Non-Business tier gets the tier-gate response (not 200).
- [ ] Dismiss flips status to `dismissed` and the row disappears from the next list call.
- [ ] A user from tenant B cannot dismiss tenant A's suggestion (404).

### Task 7: Route — execute merge
**Blocks:** 9  ·  **Blocked by:** 2, 4
**Files:**
- Modify: `apps/zync-api/src/routes/customers/duplicates.ts` (or a `merge.ts` sibling, mounted under `/api/customers`)
**Steps:**
- [ ] `POST /api/customers/merge` → `requireTier('business')` + `requirePermission('customers:delete')` (merge archives a customer; mirrors customers-module archive permission — chosen explicitly since this spec does not state one).
- [ ] Zod-validate `{ keepId: uuid, deleteId: uuid }`; reject `keepId === deleteId` (400).
- [ ] Call `mergeCustomers`; map `MergeNotFoundError`→400, `AlreadyMergedError`→409; return `MergeResult` on success.
- [ ] Build `actor` from session (`userId`, name, email) for the audit row.
**Schema / Interfaces:**
```
POST /api/customers/merge
  body: { keepId: string (uuid), deleteId: string (uuid) }
  200 → MergeResult
  400 → either id not found / not tenant-owned / keepId===deleteId
  409 → deleteId already archived (already merged)
```
**Acceptance:**
- [ ] Happy path returns 200 + `MergeResult`; data reassigned per Task 4.
- [ ] Unknown/cross-tenant id → 400; re-merge → 409.
- [ ] Below Business tier → tier-gate response.

### Task 8: Inline email-duplicate lookup on customer create
**Blocks:** 12  ·  **Blocked by:** 3
**Files:**
- Modify: `apps/zync-api/src/routes/customers/index.ts` (`GET /api/customers` query support)
**Steps:**
- [ ] Extend `GET /api/customers` to accept an `email` query param: when present, return active customers whose `lower(email)` equals the lowercased param (exact match — the warning needs precision), scoped via `tenantQuery`, using `findEmailDuplicate`.
- [ ] Keep existing list pagination/serialization (`serializeCustomer`, `buildPaginated`) intact for the no-`email` path.
- [ ] Zod-validate the optional `email` param.
**Acceptance:**
- [ ] `GET /api/customers?email=acme@x.com` returns the existing `acme@x.com` customer (case-insensitive) and nothing from other tenants.
- [ ] Omitting `email` preserves the standard paginated list behaviour.

### Task 9: Client data hooks
**Blocks:** 10, 11, 12  ·  **Blocked by:** 6, 7
**Files:**
- Create: `apps/zync-app/src/features/customers/dedup/hooks.ts`
**Steps:**
- [ ] `useDuplicateSuggestions()` — TanStack Query against `GET /api/customers/duplicates`; exposes `suggestions`, `total`, `pendingCount`.
- [ ] `useDismissSuggestion()` — mutation → `POST /api/customers/duplicates/:id/dismiss`; invalidates the suggestions query.
- [ ] `useMergeCustomers()` — mutation → `POST /api/customers/merge`; on success invalidate suggestions + customer list + the two customer detail queries; surface 400/409 via `toast`.
- [ ] `useEmailDuplicateCheck(email)` — query against `GET /api/customers?email=` for the inline create warning (enabled only when email is a valid format and non-empty).
**Acceptance:**
- [ ] Hooks return typed data (`MergeSuggestionView`, `MergeResult`) and invalidate the right caches on mutation.

### Task 10: Page `/customers/merge` + Duplicates tab & badge
**Blocks:** —  ·  **Blocked by:** 9
**Files:**
- Create: `apps/zync-app/src/features/customers/dedup/MergePage.tsx`
- Modify: `apps/zync-app/src/routes` (register `/customers/merge`)
- Modify: customers list header (`/customers`) — add "Duplicates" tab with pending-count `Badge`
**Steps:**
- [ ] Render pending suggestions as cards: both customers' name/company/email, invoice + project counts, and the human reason ("same email address" / "similar name (91%)" from `similarity`). Use `DataTable`/card primitives + `EmptyState` when none.
- [ ] Each card: `[Dismiss]` (calls `useDismissSuggestion`) and `[Review & Merge →]` (opens the merge modal from Task 11).
- [ ] Add a "Duplicates" tab on the `/customers` list header showing a `Badge` with `pendingCount`; hide the tab entirely for sub-Business tiers via `useTierGate('business')` (and route-guard `/customers/merge` to the upgrade modal for those tiers).
- [ ] Header shows total pending count ("[3 pending]").
- [ ] Cross-cutting: keyboard-operable cards, `aria` roles on the list and per-card action buttons, RTL-safe layout (logical CSS properties — Hebrew/RTL), respect `prefers-reduced-motion` on any card transitions.
**Acceptance:**
- [ ] Page lists pending suggestions with correct counts and reason text; empty state shown when none.
- [ ] Badge reflects `pendingCount`; tab/page are gated for sub-Business tiers (upgrade modal).
- [ ] Dismiss removes the card without a full reload.

### Task 11: Merge modal
**Blocks:** —  ·  **Blocked by:** 9
**Files:**
- Create: `apps/zync-app/src/features/customers/dedup/MergeModal.tsx`
**Steps:**
- [ ] `Dialog`-based modal: two record cards each with a radio "Keep this one"; the unchosen record is the `deleteId`. Default selection = the record with more invoices, but the user MUST be able to override (automated direction is advisory only).
- [ ] Show the merged-result summary (combined invoice/project/contact totals; "Name + email from the record you choose to keep").
- [ ] Clear, irreversible warning copy; `[Cancel]` and `[Merge (cannot be undone)]`.
- [ ] On confirm: call `useMergeCustomers({ keepId, deleteId })`; on success close + toast + refresh list; on 409 toast "already merged" and refresh; on 400 toast the validation error.
- [ ] Cross-cutting: focus-trap within the dialog, `aria-modal`, radios labelled, RTL-safe, reduced-motion respected.
**Acceptance:**
- [ ] Choosing "keep" on either card sets the correct `keepId`/`deleteId`; confirm triggers the merge and the kept record retains its name/email.
- [ ] 409/400 surfaced as toasts; modal closes on success.

### Task 12: Inline duplicate-email warning on Add Customer
**Blocks:** —  ·  **Blocked by:** 9, 8
**Files:**
- Modify: `apps/zync-app/src/features/customers/CustomerFormDialog.tsx` (the add/edit customer modal)
**Steps:**
- [ ] On email blur/change (debounced) in the create form, call `useEmailDuplicateCheck(email)`.
- [ ] If a match exists, render an `Alert` (warning): `⚠ A customer with this email already exists: "<name>".` with `[View existing customer]` (link to `/customers/:id`) and `[Create anyway]`.
- [ ] `[Create anyway]` proceeds with creation; the background `customer.dedup_check` enqueue (Task 5) records the suggestion. Do NOT hard-block creation.
- [ ] Only run the check for Business+ tenants (`useTierGate('business')`); skip silently otherwise.
- [ ] Cross-cutting: `role="alert"` on the warning, RTL-safe, reduced-motion respected.
**Acceptance:**
- [ ] Entering an existing customer email shows the warning with a working "View existing customer" link.
- [ ] "Create anyway" creates the customer; a suggestion appears in the Duplicates tab afterward.
- [ ] No warning appears for a brand-new email or for sub-Business tenants.
