# Invoices: Adapters — Implementation Plan

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

## Goal
Add optional outbound sync of Israeli tax invoices to five external accounting/invoicing providers (Morning/Green Invoice, iCount, Rivhit, Invoice4u, Easycount). When a tenant configures an adapter, invoices that transition to `TAX_ISSUED` (and credit notes) are pushed to the provider via a queue-backed job; payments and reconciliation flow back where the provider supports it. This spec owns the adapter interface, the per-adapter implementations, the sync-log table, the push/reconcile lifecycle, and the settings/config + manual-push API. The per-adapter setup wizard UI lives in spec 127; this plan provides the data shape and endpoints it consumes.

## Architecture
- New package `@zync/invoice-adapters` exporting an `InvoiceAdapter` interface, a registry (`getInvoiceAdapter`), and five concrete adapter implementations. Pure TypeScript with `fetch`; no provider SDKs.
- Credentials are stored encrypted in the upstream-owned `adapter_credentials` table (column `credentials BYTEA`, AES-256-GCM via `INTEGRATION_ENCRYPTION_KEY`). This plan reads/writes that table scoped to invoice adapters using the upstream `saveAdapterCredential` / `loadAdapterCredential` / `encryptCredential` / `decryptCredential` helpers from `@zync/db` (locked names). It does NOT create the table.
- Push lifecycle hooks into invoices-core: the `POST /api/invoices/:id/issue-tax` and `POST /api/invoices/:id/credit-note` transitions (from invoices-core) enqueue `invoice.push`; `POST /api/invoices/:id/record-payment` enqueues `invoice.payment_sync`. The queue consumer loads the invoice (`invoices` table) + decrypted credentials, calls the adapter, then writes `invoices.external_id` / `invoices.external_provider` (columns already defined by invoices-core) and appends an `integration_sync_logs` row.
- New table `integration_sync_logs` records every push/pull attempt for both the admin tenant-detail "Services Log" tab and the tenant settings per-adapter status card.
- A daily cron `POST /api/cron/invoice-reconcile` (CRON_SECRET) pulls provider status for `TAX_ISSUED` invoices older than 24h via `adapter.getStatus()` and flips them to `PAID` when the provider reports paid, firing the `invoice.paid` webhook.
- Tenant automation settings (`InvoiceAutomationSettings`) are persisted as JSONB on `tenant_settings` (owned by settings-module); this plan defines the type and the read/write endpoints under `/api/settings/integrations/invoicing`.

Consumes upstream exports: `adapter_credentials`, `saveAdapterCredential`, `loadAdapterCredential`, `encryptCredential`, `decryptCredential`, `createDb`/`Db`, `tenantQuery`, `authMiddleware`, `requirePermission`, `createNotification`, `Env`, `QUEUE`, `InvoiceStatus`, `InvoiceObject`, `serializeInvoice`, `buildPaginated`, `PaginatedResponse`, `ApiError`. Consumes invoices-core tables `invoices`, `invoice_lines` and their `external_id`/`external_provider` columns.

## Tech Stack
- **Package:** `packages/invoice-adapters` (`@zync/invoice-adapters`) — TS, depends on `@zync/db`, `@zync/types`. No external HTTP libs (native `fetch`).
- **API:** Hono routes in `apps/zync-api` (`apps/zync-api/src/routes/invoice-integrations.ts`, cron + queue consumer wiring in `apps/zync-api/src/index.ts`).
- **DB:** Drizzle schema in `packages/db` for `integration_sync_logs`; migration file under `packages/db/migrations`.
- **Cloudflare bindings:** `QUEUE` (push/payment-sync/reconcile jobs), Neon Postgres via Hyperdrive (`createDb`), secret `INTEGRATION_ENCRYPTION_KEY`, secret `CRON_SECRET`.
- **Validation:** Zod schemas in route handlers (`require-zod-validation-in-routes`).

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — schema & types | 1, 2 | `packages/db/src/schema`, `packages/db/migrations`, `packages/types` | Task 2 after Task 1 (type imports independent — parallel OK) |
| B — adapter package | 3, 4, 5 | `packages/invoice-adapters` | Task 3 first; 4 and 5 parallel after 3 |
| C — server lifecycle | 6, 7 | `apps/zync-api/src` (queue consumer, cron) | After B; 6 then 7 |
| D — API surface | 8, 9 | `apps/zync-api/src/routes` | After 6; 8 and 9 parallel |
| E — verification | 10 | test files | Last |

## Tasks

### Task 1: `integration_sync_logs` table + Drizzle schema
**Blocks:** 6, 7, 9  ·  **Blocked by:** —
**Files:**
- Create: `packages/db/src/schema/integration-sync-logs.ts`
- Modify: `packages/db/src/schema/index.ts` (export new table)
- Create: `packages/db/migrations/00XX_integration_sync_logs.sql`
**Steps:**
- [ ] Write the canonical Postgres DDL for `integration_sync_logs` (below) as a migration. Use UUID PK, UUID FK to `tenants(id)`, JSONB payloads, TIMESTAMPTZ.
- [ ] Add a composite index `(tenant_id, entity_type, entity_id)` for the per-invoice status card and `(tenant_id, created_at DESC)` for the paginated log view.
- [ ] Mirror the table as a Drizzle `pgTable` definition and export it.
- [ ] Do NOT recreate `adapter_credentials` — it is owned upstream; only reference it.
**Schema / Interfaces:**
```sql
CREATE TABLE integration_sync_logs (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id     UUID NOT NULL REFERENCES tenants(id),
  adapter_id    TEXT NOT NULL CHECK (adapter_id IN ('morning','icount','rivhit','invoice4u','easycount')),
  entity_type   TEXT NOT NULL CHECK (entity_type IN ('invoice','credit_note','payment')),
  entity_id     UUID NOT NULL,
  direction     TEXT NOT NULL CHECK (direction IN ('push','pull')),
  status        TEXT NOT NULL CHECK (status IN ('success','error')),
  request_payload   JSONB,
  response_payload  JSONB,
  error_message     TEXT,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_integration_sync_logs_entity
  ON integration_sync_logs (tenant_id, entity_type, entity_id);
CREATE INDEX idx_integration_sync_logs_recent
  ON integration_sync_logs (tenant_id, created_at DESC);
```
**Acceptance:**
- [ ] Migration applies cleanly to Neon Postgres; `\d integration_sync_logs` shows UUID PK, the four CHECK constraints, and both indexes.
- [ ] Drizzle schema compiles and is re-exported from `@zync/db`.

### Task 2: Shared adapter types in `@zync/invoice-adapters`
**Blocks:** 3, 8, 9  ·  **Blocked by:** —
**Files:**
- Create: `packages/invoice-adapters/package.json` (`@zync/invoice-adapters`, deps `@zync/db`, `@zync/types`)
- Create: `packages/invoice-adapters/src/types.ts`
- Create: `packages/invoice-adapters/src/index.ts`
**Steps:**
- [ ] Define `InvoiceAdapterId`, `InvoiceAdapter`, `InvoicePayload`, `PaymentPayload`, `AdapterCredentials`, and `InvoiceAutomationSettings` exactly as the spec dictates (below).
- [ ] Export all types plus the (to-be-added) registry from `src/index.ts`.
**Schema / Interfaces:**
```ts
export type InvoiceAdapterId = 'morning' | 'icount' | 'rivhit' | 'invoice4u' | 'easycount';

export type AdapterCredentials = Record<string, string>; // shape per-adapter; decrypted JSON

export interface InvoicePayload {
  internalId: string;
  invoiceNumber: string;
  issueDate: string;            // ISO date
  customer: { name: string; vatId?: string; email: string; address?: string };
  lines: {
    description: string;
    quantity: number;
    unitPrice: number;
    discount: number;
    total: number;
    taxable: boolean;
  }[];
  vatRate: number;
  subtotal: number;
  vatAmount: number;
  total: number;
  currency: 'ILS';
  notes?: string;
}

export interface PaymentPayload {
  amount: number;
  date: string;                 // ISO date
  method?: string;
  currency: 'ILS';
}

export interface InvoiceAdapter {
  id: InvoiceAdapterId;
  name: string;
  testConnection(credentials: AdapterCredentials): Promise<{ ok: boolean; error?: string }>;
  createInvoice(invoice: InvoicePayload, credentials: AdapterCredentials): Promise<{ externalId: string; externalUrl?: string }>;
  createCreditNote(invoice: InvoicePayload, credentials: AdapterCredentials): Promise<{ externalId: string }>;
  recordPayment?(externalId: string, payment: PaymentPayload, credentials: AdapterCredentials): Promise<void>;
  getStatus?(externalId: string, credentials: AdapterCredentials): Promise<{ status: string; paidAt?: Date }>;
}

export interface InvoiceAutomationSettings {
  adapter: InvoiceAdapterId | null;
  autoSyncOnTaxIssue: boolean;        // default true when adapter set
  autoDraftFromRetainer: boolean;
  autoSendRetainerInvoice: boolean;
  taskStatusTrigger: {
    enabled: boolean;
    statusId: string;
    invoiceType: 'draft' | 'sent';
  } | null;
}
```
**Acceptance:**
- [ ] `@zync/invoice-adapters` builds and exports every type above.
- [ ] `InvoiceAdapterId` union exactly matches the five provider ids.

### Task 3: Adapter registry + base HTTP helpers
**Blocks:** 4, 5, 6, 7, 8  ·  **Blocked by:** 2
**Files:**
- Create: `packages/invoice-adapters/src/registry.ts`
- Create: `packages/invoice-adapters/src/http.ts`
**Steps:**
- [ ] Implement `getInvoiceAdapter(id: InvoiceAdapterId): InvoiceAdapter` returning the matching concrete adapter; throw `AdapterNotFoundError` for unknown ids.
- [ ] Implement `listInvoiceAdapters(): { id: InvoiceAdapterId; name: string }[]` for the settings UI.
- [ ] Add an `AdapterError` class (carries `adapterId`, `httpStatus?`, `providerMessage?`) thrown by adapters on non-2xx provider responses.
- [ ] Add a `fetchJson` / `fetchForm` helper in `http.ts` wrapping native `fetch` with timeout (10s via `AbortSignal.timeout`), JSON/form encoding, and uniform error mapping to `AdapterError`.
- [ ] Name the registry export `getInvoiceAdapter` (NOT `getAdapter`, which is reserved upstream).
**Schema / Interfaces:**
```ts
export class AdapterNotFoundError extends Error {}
export class AdapterError extends Error {
  constructor(public adapterId: InvoiceAdapterId, message: string, public httpStatus?: number, public providerMessage?: string);
}
export function getInvoiceAdapter(id: InvoiceAdapterId): InvoiceAdapter;
export function listInvoiceAdapters(): { id: InvoiceAdapterId; name: string }[];
```
**Acceptance:**
- [ ] `getInvoiceAdapter('morning')` returns the Morning adapter; `getInvoiceAdapter('x' as any)` throws `AdapterNotFoundError`.
- [ ] `fetchJson` aborts after 10s and surfaces provider error bodies as `AdapterError.providerMessage`.

### Task 4: Morning (Green Invoice) + Easycount adapters
**Blocks:** 6, 7  ·  **Blocked by:** 3
**Files:**
- Create: `packages/invoice-adapters/src/adapters/morning.ts`
- Create: `packages/invoice-adapters/src/adapters/easycount.ts`
**Steps:**
- [ ] **Morning:** base `https://api.greeninvoice.co.il/api/v1`. Credentials `{ apiKey, secret }`. `testConnection` calls the token/auth endpoint with apiKey+secret and reports `{ ok }`. `createInvoice` → `POST /documents` with document type `320` (tax invoice); map `InvoicePayload` lines/customer/vat to the Morning document body; return `{ externalId: id, externalUrl: url }` from `{ id, number, url }`. `createCreditNote` → same `POST /documents` with type `305`, return `{ externalId }`. Implement `recordPayment` (post payment row against the document) and `getStatus` (read document, map provider status → string, parse `paidAt`).
- [ ] **Easycount:** base `https://app.easycount.co.il/api`. Credentials `{ apiKey }`. `createInvoice` → `POST /invoices`. `testConnection` verifies the API key against a lightweight authenticated GET. Implement `createCreditNote` via the credit-note variant of `POST /invoices`. Omit `recordPayment`/`getStatus` if Easycount does not expose them (interface marks both optional).
- [ ] All credential fields read from the decrypted `AdapterCredentials` object — never from env.
**Acceptance:**
- [ ] Morning `createInvoice` issues a `POST /documents` with `type: 320`; credit note uses `305`.
- [ ] Both adapters implement the required `testConnection`, `createInvoice`, `createCreditNote`; optional methods present only where the provider supports them.

### Task 5: iCount + Rivhit + Invoice4u adapters
**Blocks:** 6, 7  ·  **Blocked by:** 3
**Files:**
- Create: `packages/invoice-adapters/src/adapters/icount.ts`
- Create: `packages/invoice-adapters/src/adapters/rivhit.ts`
- Create: `packages/invoice-adapters/src/adapters/invoice4u.ts`
**Steps:**
- [ ] **iCount:** base `https://api.icount.co.il/api/v3.php`. Credentials `{ cid, user, pass }`. `createInvoice` → `POST /api/v3.php?action=doc_create` with form params (use `fetchForm`); map payload to iCount doc params; return doc id as `externalId`. `testConnection` → `action=login` (or equivalent) reporting `{ ok }`. `createCreditNote` → `doc_create` with the credit-note doctype.
- [ ] **Rivhit:** base `https://secure.rivhit.co.il/API/PurchaseGroupAPI.svc`. Credentials `{ apiKey }`. `createInvoice` → `CreateInvoice` endpoint; return provider id. `createCreditNote` → the credit-document call. `testConnection` pings an auth/echo endpoint.
- [ ] **Invoice4u:** base `https://api.invoice4u.co.il/Services/InvoicesService.svc`. Credentials `{ token }` (token-based). `createInvoice` → `CreateDoc`; `createCreditNote` → `CreateDoc` with credit doctype. `testConnection` validates the token.
- [ ] Each maps `InvoicePayload` → provider line/customer/VAT fields and throws `AdapterError` on non-2xx.
**Acceptance:**
- [ ] iCount push posts form-encoded body to `?action=doc_create`; returns the provider doc id.
- [ ] All three implement `testConnection`, `createInvoice`, `createCreditNote`; optional methods only where supported.

### Task 6: Push queue consumer (`invoice.push`, `invoice.payment_sync`)
**Blocks:** 8  ·  **Blocked by:** 1, 3, 4, 5
**Files:**
- Create: `apps/zync-api/src/jobs/invoice-sync.ts`
- Modify: `apps/zync-api/src/index.ts` (register queue consumer message types)
- Modify: invoices-core transition handlers (`apps/zync-api/src/routes/invoices.ts`) to enqueue on `TAX_ISSUED`, credit-note, and `PAID`
**Steps:**
- [ ] In the `issue-tax` handler (invoices-core): after the `TAX_ISSUED` transition commits, load `InvoiceAutomationSettings` from `tenant_settings`; if an adapter is set and `autoSyncOnTaxIssue` is true, `QUEUE.send({ type: 'invoice.push', invoiceId, entityType: 'invoice' })`.
- [ ] In the `credit-note` handler: enqueue `{ type: 'invoice.push', invoiceId: <creditNoteInvoiceId>, entityType: 'credit_note' }`.
- [ ] In the `record-payment` handler: if adapter set and its `recordPayment` is supported, `QUEUE.send({ type: 'invoice.payment_sync', invoiceId })`.
- [ ] Implement the consumer: (a) load invoice + lines, build `InvoicePayload` (subtotal/vat/total/currency from invoice row; lines from `invoice_lines`; customer from `customers`); (b) `loadAdapterCredential(db, tenantId, adapterId)` → `decryptCredential` → `AdapterCredentials`; (c) call `getInvoiceAdapter(adapterId)` then `createInvoice` / `createCreditNote` / `recordPayment`; (d) on success write `invoices.external_id` + `invoices.external_provider` (only for invoice/credit_note push) and insert an `integration_sync_logs` row with `status='success'`, `direction='push'`, `request_payload`, `response_payload`; (e) on failure insert a `status='error'` log row with `error_message`.
- [ ] Retry up to 3× with exponential backoff using the queue's `retry()` / `message.retry({ delaySeconds })`; on the final attempt (`message.attempts >= 3`) call `createNotification` for tenant admins ("Invoice sync to {adapter} failed").
- [ ] Never log decrypted credentials into `integration_sync_logs`; redact secret fields from `request_payload`.
**Schema / Interfaces:**
```ts
type InvoiceSyncMessage =
  | { type: 'invoice.push'; invoiceId: string; entityType: 'invoice' | 'credit_note' }
  | { type: 'invoice.payment_sync'; invoiceId: string };
```
**Acceptance:**
- [ ] Issuing a tax invoice with an adapter configured enqueues `invoice.push`; the consumer sets `invoices.external_id`/`external_provider` and writes a `success` sync log.
- [ ] A provider error retries up to 3×, then creates an admin notification and a final `error` sync log.
- [ ] No credential secret appears in any `integration_sync_logs` row.

### Task 7: Reconciliation cron (`/api/cron/invoice-reconcile`)
**Blocks:** —  ·  **Blocked by:** 1, 3, 4, 5
**Files:**
- Create: `apps/zync-api/src/routes/cron-invoice-reconcile.ts`
- Modify: `apps/zync-api/src/index.ts` (mount route; register scheduled trigger if cron-driven)
**Steps:**
- [ ] Authenticate via `CRON_SECRET` (timing-safe equality using `timingSafeEqual`); reject with 401 otherwise.
- [ ] For each tenant with an adapter configured and whose adapter implements `getStatus`: select `TAX_ISSUED` invoices with `external_id IS NOT NULL` and `tax_issued_at < now() - interval '24 hours'`.
- [ ] For each, `decrypt` credentials, call `adapter.getStatus(externalId, credentials)`; if provider status maps to paid, update `invoices.status = 'PAID'`, set `paid_at` = `paidAt` (or now), and fire the `invoice.paid` webhook (`webhook.deliver`).
- [ ] Write an `integration_sync_logs` row per check (`direction='pull'`, `entity_type='invoice'`, success/error).
- [ ] Adapters without `getStatus` are skipped (rely on manual record-payment / inbound billing webhooks).
**Acceptance:**
- [ ] Hitting the route without the correct `CRON_SECRET` returns 401 and does nothing.
- [ ] A `TAX_ISSUED` invoice older than 24h that the provider reports paid is flipped to `PAID`, `paid_at` set, `invoice.paid` webhook fired, and a `pull`/`success` log written.
- [ ] Tenants whose adapter lacks `getStatus` are skipped without error.

### Task 8: Settings config + manual-push API
**Blocks:** —  ·  **Blocked by:** 2, 3, 6
**Files:**
- Create: `apps/zync-api/src/routes/invoice-integrations.ts`
- Modify: `apps/zync-api/src/index.ts` (mount router)
**Steps:**
- [ ] `GET /api/settings/integrations/invoicing` → returns current `InvoiceAutomationSettings` (from `tenant_settings`) plus the configured `adapter_id` and a masked credential-present flag. Requires `settings:read`.
- [ ] `PUT /api/settings/integrations/invoicing` → Zod-validate body `{ settings: InvoiceAutomationSettings, credentials?: AdapterCredentials }`; persist `InvoiceAutomationSettings` JSONB to `tenant_settings`; if `credentials` present, `encryptCredential` + `saveAdapterCredential(db, tenantId, adapterId, encrypted)` (upsert on the upstream `UNIQUE (tenant_id, adapter_id)`). Requires `settings:write`.
- [ ] `POST /api/settings/integrations/invoicing/test` → Zod-validate `{ adapter, credentials }`; call `getInvoiceAdapter(adapter).testConnection(credentials)`; return `{ ok, error? }`. Requires `settings:write`. Do not persist on test.
- [ ] `POST /api/invoices/:id/push` → manual staff-triggered retry: enqueue `invoice.push` for the invoice (must be `TAX_ISSUED` or a credit note). Requires `invoices:write`. Return 409 if invoice is not in a pushable state or no adapter configured.
- [ ] All routes go through `authMiddleware` + `requirePermission(...)`; never run raw Drizzle from routes (`no-raw-drizzle-from-routes`) — use db helper functions.
**Schema / Interfaces:**
```
GET  /api/settings/integrations/invoicing        settings:read   → { adapter, settings, credentialsConfigured: boolean }
PUT  /api/settings/integrations/invoicing        settings:write  body { settings, credentials? } → { ok: true }
POST /api/settings/integrations/invoicing/test   settings:write  body { adapter, credentials } → { ok, error? }
POST /api/invoices/:id/push                       invoices:write  → { queued: true } | 409
```
**Acceptance:**
- [ ] Saving credentials encrypts them and upserts one `adapter_credentials` row per `(tenant_id, adapter_id)`.
- [ ] `test` returns `{ ok: false, error }` for bad credentials without persisting anything.
- [ ] `POST /api/invoices/:id/push` on a non-issued invoice returns 409; on a `TAX_ISSUED` invoice enqueues `invoice.push`.

### Task 9: Sync-log read API
**Blocks:** —  ·  **Blocked by:** 1, 2
**Files:**
- Modify: `apps/zync-api/src/routes/invoice-integrations.ts`
- Create: `packages/db/src/queries/integration-sync-logs.ts`
**Steps:**
- [ ] Add `listIntegrationSyncLogs(db, tenantId, { adapterId?, entityType?, cursor?, limit })` returning cursor-paginated rows from `integration_sync_logs` ordered by `created_at DESC` (use `buildPaginated` / `encodeCursor` / `decodeCursor`; cap limit via `clampLimit`).
- [ ] `GET /api/settings/integrations/invoicing/logs` → paginated sync log. Requires `settings:read`. Filterable by `adapterId`, `entityType`.
- [ ] Provide a `getLatestSyncStatus(db, tenantId, entityType, entityId)` helper for the per-adapter status card and invoice detail badge.
**Schema / Interfaces:**
```
GET /api/settings/integrations/invoicing/logs   settings:read
    ?cursor=&limit=&adapterId=&entityType=  → PaginatedResponse<IntegrationSyncLog>
```
**Acceptance:**
- [ ] Log endpoint returns at most `limit` rows, newest first, with a `nextCursor`.
- [ ] Filtering by `adapterId` and `entityType` narrows results; tenant isolation enforced (only caller's `tenant_id`).

### Task 10: Adapter contract tests (spec-mandated verification)
**Blocks:** —  ·  **Blocked by:** 3, 4, 5, 6
**Files:**
- Create: `packages/invoice-adapters/test/adapters.contract.test.ts`
- Create: `apps/zync-api/test/invoice-sync.test.ts`
**Steps:**
- [ ] Contract test: for every id in the `InvoiceAdapterId` union, `getInvoiceAdapter(id)` returns an object implementing `testConnection`, `createInvoice`, `createCreditNote` with the correct signatures.
- [ ] Mock `fetch` and assert Morning `createInvoice` posts `type:320` and `createCreditNote` posts `type:305`; iCount posts form params to `?action=doc_create`.
- [ ] Queue-consumer test: a successful push writes `external_id`/`external_provider` and a `success` log; a failing push retries 3× then notifies and writes an `error` log with no credential leakage.
- [ ] Reconcile test: provider-paid status flips invoice to `PAID` and fires `invoice.paid`.
**Acceptance:**
- [ ] All adapter contract tests pass; queue and reconcile tests pass; no decrypted credential appears in any asserted log payload.
