# Payment Gateway Adapters (Tenant Invoice Payment Collection)

**Date:** 2026-05-31  
**Status:** Draft  
**Depends on:** `foundation-auth-rbac`, `invoices-core`, `tenant-portals`, `settings-module`  
**Referenced by:** `invoices-core`, `tenant-portals`, `billing-module`, `zync-subscription`

---

## Overview

Defines how tenant customers pay invoices online. This spec covers **outbound payment collection** — money flowing from a tenant's customers to the tenant. It does **not** cover Zync's own subscription billing (see `zync-subscription` spec).

Payment gateways are configured per tenant in `/settings/integrations/payments`. An adapter pattern allows new gateways to be added without touching invoice or portal code. Primary market is Israel: **Morning** (morning.co.il / ex-Green Invoice) is the tier-1 adapter and the one currently built end-to-end; Payplus and Cardcom are specced adapters; Stripe covers international tenants. The settings UI exposes **only built gateways** — a gateway whose adapter is a stub must not be selectable.

---

## Adapter Interface

All gateway adapters implement a single TypeScript interface:

```ts
// packages/payments/src/adapter.ts

export interface PaymentGatewayAdapter {
  readonly gateway: GatewaySlug

  /**
   * Create a hosted payment session for an invoice.
   * Returns a redirect URL for the customer.
   */
  createPaymentSession(
    invoice: Invoice,
    config: GatewayConfig,
    returnUrl: string,
    webhookUrl: string
  ): Promise<{ sessionId: string; redirectUrl: string }>

  /**
   * Verify the authenticity of an inbound webhook/return and parse it.
   * Authenticity is established per-gateway: by signature where the gateway signs
   * (Payplus/Cardcom/Stripe — throws if signature invalid), or by an authenticated
   * re-fetch of payment status where it does not (Morning — never trusts the raw body;
   * if Morning also supplies a signature it is verified too). `signature` may be empty
   * for re-fetch gateways. Throws if authenticity cannot be established.
   */
  verifyWebhook(
    payload: string,
    signature: string,
    config: GatewayConfig
  ): Promise<WebhookEvent>

  /**
   * Poll gateway for payment status (used for reconciliation, not primary flow).
   */
  getPaymentStatus(
    sessionId: string,
    config: GatewayConfig
  ): Promise<PaymentStatus>

  /**
   * Validate credentials by making a lightweight API call.
   * Used for "Test connection" button.
   */
  testConnection(config: GatewayConfig): Promise<{ ok: boolean; error?: string }>
}

export type GatewaySlug = 'morning' | 'payplus' | 'cardcom' | 'stripe'

export interface GatewayConfig {
  gateway: GatewaySlug
  testMode: boolean
  credentials: Record<string, string>  // decrypted at call time
}

export interface WebhookEvent {
  type: 'payment.succeeded' | 'payment.failed' | 'payment.pending'
  sessionId: string
  amount: number
  currency: string
  metadata: Record<string, unknown>
}

export type PaymentStatus = 'pending' | 'paid' | 'failed' | 'refunded'
```

### Adapter registry

```ts
// packages/payments/src/registry.ts
import { MorningAdapter } from './adapters/morning'
import { PayplusAdapter } from './adapters/payplus'
import { CardcomAdapter } from './adapters/cardcom'
import { StripeAdapter } from './adapters/stripe'

const adapters: Record<GatewaySlug, PaymentGatewayAdapter> = {
  morning: new MorningAdapter(),  // tier-1, built end-to-end
  payplus: new PayplusAdapter(),
  cardcom: new CardcomAdapter(),
  stripe: new StripeAdapter(),
}

// Gateways exposed in the settings UI selector. Stub adapters are registered (so the
// registry type is total) but excluded here until built — selecting a stub is impossible.
export const SELECTABLE_GATEWAYS: GatewaySlug[] = ['morning']

export function getAdapter(gateway: GatewaySlug): PaymentGatewayAdapter {
  const adapter = adapters[gateway]
  if (!adapter) throw new Error(`Unknown gateway: ${gateway}`)
  return adapter
}
```

---

## Supported Gateways

### 0. Morning (Israeli, tier-1 — the built adapter)

Morning (morning.co.il, ex-Green Invoice) provides both invoice issuance (see
`invoices-adapters`) and **clearing** (סליקה — card payment collection). This adapter uses the
clearing capability. Endpoint paths/payload shapes are extracted at build time from live docs
(`app.greeninvoice.co.il/api`) + the open-source `wc-gateway-greeninvoice` WooCommerce plugin
`includes/` source — not hardcoded from guesses.

- API base: `https://api.greeninvoice.co.il/api/v1`
- Auth: `apiKey` + `secret` → `POST /account/token` → short-lived JWT bearer (cached until
  expiry). Same credential pair `invoices-adapters` stores for Morning issuance.
- Hosted page: `createPaymentSession` requests a Morning-hosted clearing page (redirect,
  PCI-safe — card data never touches Zync) and returns its redirect URL + a queryable payment
  id as `sessionId`.
- **Webhook/return verification — authenticated re-fetch confirm, NOT raw-body trust.** Morning
  is not assumed to HMAC-sign callbacks. `verifyWebhook` (and the return-URL handler)
  **re-fetch the payment/document status from the Morning API with the JWT and confirm `paid`**
  before settling — never trusts the callback body. Correct regardless of Morning's signature
  scheme; if Morning *does* supply a signature, the adapter verifies it in addition. Both push
  (webhook) and poll (`getPaymentStatus`) funnel into the same re-fetch confirmation.
- **Correlation handle (gating sandbox check):** the session model
  (`UNIQUE(gateway, session_id)`) requires Morning's page-creation endpoint to return a queryable
  id AND accept a return/callback URL carrying our session id back. Confirm the exact handle
  against sandbox creds **before** building downstream — capability is proven by the WooCommerce
  plugin (it correlates payments to orders), but the exact mechanism is verified at build start.
- Test mode: Morning sandbox credentials (`test_mode = true`).

Config fields (encrypted in DB):
| Field | Label |
|-------|-------|
| `apiKey` | API Key |
| `secret` | API Secret |

The settings UI MAY offer a "use my Morning invoicing credentials" prefill (if the tenant has a
Morning issuance adapter configured), but the payment config is stored as an **independent**
`payment_gateway_configs` row — the two subsystems stay decoupled and neither breaks if the
other's credentials rotate.

### 1. Payplus (Israeli, primary)

- API base: `https://restapi.payplus.co.il/api/v1.0/`
- Auth: `Authorization: {apiKey}:{secretKey}` header
- Hosted page: `createPaymentSession` POSTs to `/PaymentPages/generateLink` with invoice data → returns `payment_page_link`
- Webhook: HMAC-SHA256 of payload body with secret key; header `x-ppplus-signature`
- Test credentials: Payplus sandbox at `https://sandboxapi.payplus.co.il/`

Config fields (encrypted in DB):
| Field | Label |
|-------|-------|
| `apiKey` | API Key |
| `secretKey` | Secret Key |
| `terminalNumber` | Terminal Number |

### 2. Cardcom (Israeli)

- API base: `https://secure.cardcom.solutions/api/v11/`
- Auth: terminal number + API name + password in request body
- Hosted page: `createPaymentSession` POSTs to `/BillGold/` → returns `LowProfileCode` → construct redirect URL
- Webhook: Cardcom POSTs to configured notification URL; verify via `TerminalNumber` + `ReturnValue` match
- Test mode: Cardcom provides separate test terminal credentials

Config fields:
| Field | Label |
|-------|-------|
| `terminalNumber` | Terminal Number |
| `apiName` | API Name |
| `apiPassword` | API Password |

### 3. Stripe (international)

- Uses Stripe Checkout Sessions API
- `createPaymentSession` creates a Checkout Session with line items matching invoice
- Webhook: verified via `stripe.webhooks.constructEvent(payload, sig, webhookSecret)` → adapted to Workers via `crypto.subtle`
- Supports both ILS and other currencies via Stripe's currency handling

Config fields:
| Field | Label |
|-------|-------|
| `publishableKey` | Publishable Key |
| `secretKey` | Secret Key |
| `webhookSecret` | Webhook Secret |

---

## Data Model

```sql
-- Gateway configuration per tenant (one active config at a time)
CREATE TABLE payment_gateway_configs (
  id                UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id         UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  gateway           TEXT NOT NULL CHECK (gateway IN ('morning', 'payplus', 'cardcom', 'stripe')),
  config_encrypted  TEXT NOT NULL,  -- AES-256-GCM encrypted JSON of credentials
  test_mode         BOOLEAN NOT NULL DEFAULT false,
  active            BOOLEAN NOT NULL DEFAULT true,
  created_at        TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at        TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (tenant_id, gateway)
);

CREATE INDEX idx_payment_gateway_configs_tenant ON payment_gateway_configs(tenant_id)
  WHERE active = true;

-- Payment sessions created when customer clicks "Pay Now"
CREATE TABLE invoice_payment_sessions (
  id               UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  invoice_id       UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
  tenant_id        UUID NOT NULL REFERENCES tenants(id),
  gateway          TEXT NOT NULL CHECK (gateway IN ('morning', 'payplus', 'cardcom', 'stripe')),
  session_id       TEXT NOT NULL,            -- gateway-provided session/page ID
  amount           INTEGER NOT NULL,         -- in agorot (ILS) or cents
  currency         TEXT NOT NULL DEFAULT 'ILS',
  status           TEXT NOT NULL DEFAULT 'pending'
                     CHECK (status IN ('pending', 'paid', 'failed', 'expired')),
  return_url       TEXT NOT NULL,
  created_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
  paid_at          TIMESTAMPTZ,
  webhook_received_at TIMESTAMPTZ,
  webhook_payload  TEXT,                     -- raw webhook body for audit
  UNIQUE (gateway, session_id)
);

CREATE INDEX idx_payment_sessions_invoice ON invoice_payment_sessions(invoice_id);
CREATE INDEX idx_payment_sessions_tenant_status ON invoice_payment_sessions(tenant_id, status);
```

### Config encryption

Gateway credentials are encrypted at rest using AES-256-GCM with key `PAYMENT_CONFIG_ENCRYPTION_KEY` (env secret). Decryption happens in the Worker at request time only. The encrypted blob structure:

```ts
interface EncryptedConfig {
  iv: string        // base64 12-byte IV
  tag: string       // base64 16-byte auth tag
  data: string      // base64 encrypted JSON
}
```

`packages/payments/src/config-crypto.ts` exports `encryptConfig` / `decryptConfig` using `crypto.subtle.encrypt('AES-GCM', ...)`.

---

## Customer Payment Flow

```
Customer views invoice in portal
    ↓
Clicks "Pay Now"
    ↓
POST /api/invoices/:id/payment/session
    ↓ Worker: load active gateway config → adapter.createPaymentSession()
    ↓ Store invoice_payment_sessions record
    ↓ Return { redirectUrl }
    ↓
Customer browser redirects to hosted payment page (Payplus / Cardcom / Stripe UI)
    ↓
Customer completes payment
    ↓
Gateway POSTs webhook → POST https://api.zync.is/webhooks/payment/:gateway
    ↓ Worker: adapter.verifyWebhook() → find session by session_id
    ↓ Update invoice_payment_sessions.status = 'paid'
    ↓ Update invoices.status = 'PAID', paid_at = now()
    ↓ Enqueue invoice.paid event (outbound webhook queue)
    ↓ Send payment receipt email to customer
    ↓
Customer redirected to returnUrl (portal invoice page with success banner)
```

### Return URL

`returnUrl = https://{portal_slug}.zync.is/portal/invoices/{invoiceId}?payment=success`

The portal invoice page checks for `?payment=success` query param and shows a confirmation banner. Actual status is verified from DB (not just the param) to prevent false positives.

---

## API Endpoints

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/api/settings/integrations/payments` | `settings:read` | Get current gateway config (credentials redacted) |
| PUT | `/api/settings/integrations/payments` | `settings:write` | Create or update gateway config |
| POST | `/api/settings/integrations/payments/test` | `settings:write` | Test gateway credentials |
| DELETE | `/api/settings/integrations/payments` | `settings:write` | Remove gateway config |
| POST | `/api/invoices/:id/payment/session` | Portal auth (customer) | Create payment session, returns `redirectUrl` |
| GET | `/api/invoices/:id/payment/status` | Portal auth (customer) | Poll payment status |
| POST | `https://api.zync.is/webhooks/payment/:gateway` | Public (authenticity verified by adapter) | Receive payment webhook from gateway |

### Webhook endpoint security

`POST https://api.zync.is/webhooks/payment/:gateway` is unauthenticated (gateway cannot authenticate). Security:
1. Authenticity established by adapter before any processing (`adapter.verifyWebhook`) — by
   signature for signing gateways, by **authenticated re-fetch of payment status** for Morning
   (the raw callback body is never trusted as proof of payment)
2. If authenticity cannot be established → 200 (don't leak verification failure to attacker)
3. Idempotency: webhook re-delivery safe via an **atomic conditional UPDATE** — `UPDATE invoice_payment_sessions SET status='paid', paid_at=now() WHERE gateway=? AND session_id=? AND status='pending'`; only the row that actually transitioned (rows-affected = 1) proceeds to flip the invoice and enqueue `invoice.paid`. A read-then-write check loses the concurrent-retry race (two deliveries both read `pending`, both settle) — never use it.
4. Rate limited by `RATE_LIMITER_WEBHOOK` (existing binding)
5. Raw body stored in `webhook_payload` for audit

---

## Settings UI: `/settings/integrations/payments`

Page layout: Integration card in the integrations hub (see `settings-module` spec).

### Connected state (gateway configured)

```
┌─────────────────────────────────────────────────┐
│ Payment Gateway                                  │
│ Connected: Payplus ✓                             │
│                                                  │
│ [Switch Gateway ▾]  [Test Connection]  [Remove]  │
│                                                  │
│ ☐ Test mode (sandbox)                            │
│                                                  │
│ Your webhook URL (configure in Payplus dashboard):│
│ https://api.zync.is/webhooks/payment/payplus/... │
│ [Copy]                                           │
└─────────────────────────────────────────────────┘
```

### Setup state (no gateway)

Shows gateway selector then credential fields for selected gateway.

### Gateway credential forms

**Morning** (only gateway currently selectable):
- API Key (password input)
- API Secret (password input)
- Optional "Use my Morning invoicing credentials" prefill button (shown only if a Morning
  issuance adapter is configured) — populates the fields; stored independently on save.

**Payplus:**
- API Key (password input)
- Secret Key (password input)
- Terminal Number (text input)

**Cardcom:**
- Terminal Number (text input)
- API Name (text input)
- API Password (password input)

**Stripe:**
- Publishable Key (text input, `pk_...` prefix validated)
- Secret Key (password input, `sk_...` prefix validated)
- Webhook Secret (password input, `whsec_...` prefix validated)

All credential fields: show/hide toggle. Never shown in plaintext after first save (API returns `{ configured: true }` for saved fields).

### Test mode toggle

When enabled: all payment sessions use gateway sandbox. Sessions created in test mode are tagged `test_mode = true` in `invoice_payment_sessions`. Test mode badge shown on customer portal "Pay Now" button.

### Webhook URL

Per-tenant webhook URL includes a signing token:
`https://api.zync.is/webhooks/payment/{gateway}/{tenantWebhookToken}`

`tenantWebhookToken` is a 32-byte random hex stored **raw and indexed** in the tenant row (direct equality lookup on webhook receipt). Rotatable. It is **not a secret**: it is routing-only — a guessed token only triggers an authenticated re-fetch that returns not-paid → no-op. (Security is `adapter.verifyWebhook`: HMAC for signing gateways, authenticated re-fetch for Morning.) Generated on tenant creation and backfilled for existing tenants.

---

## Permissions

| Action | Required |
|--------|---------|
| View payment gateway settings | `settings:read` (OWNER/ADMIN) |
| Configure payment gateway | `settings:write` (OWNER/ADMIN) |
| Remove payment gateway | `settings:write` (OWNER/ADMIN) |
| Create payment session | Portal auth (authenticated customer) |
| Receive webhook | Public (authenticity verified by adapter) |

---

## Invoice Integration

In `invoices-core`, the invoice detail page (tenant view) shows:
- **Payment gateway status** badge if a gateway is configured
- **Payment sessions** collapsible section: list of payment session attempts with status

In customer portal, invoice page shows:
- **Pay Now** button (only if invoice is in `TAX_ISSUED` or `PARTIALLY_PAID` status and tenant has gateway configured). Payment is collected only after the tax invoice (חשבונית מס) is issued — IL law; a `SENT` proforma (חשבונית עסקה) is not payable. "Overdue" is a derived flag (`now() > due_date`), not a status.
- After payment: **Paid** badge + receipt download link

### `invoice.paid` event

On webhook confirmation, the Worker fires an `invoice.paid` event to the outbound webhook queue (spec 27). Payload:
```json
{
  "event": "invoice.paid",
  "invoice_id": "...",
  "amount": 150000,
  "currency": "ILS",
  "gateway": "payplus",
  "paid_at": "2026-05-31T12:00:00Z"
}
```

Also enqueues a receipt email via the communications adapter (spec 5): sends to `invoice.customer_email` with PDF attachment (re-generated from invoice HTML → R2).

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| Hosted payment pages | Yes (redirect, not embedded) | PCI DSS compliance — Zync never touches card data |
| Adapter pattern | Interface + registry | Easy to add new gateways; invoice/portal code is gateway-agnostic |
| Credential encryption | AES-256-GCM per config row | Credentials are sensitive; DB-level encryption ensures plaintext never stored |
| Webhook idempotency | Atomic conditional UPDATE (`... WHERE status='pending'`), only row-winner settles | Gateways retry; concurrent re-delivery makes read-then-write double-settle — the conditional UPDATE is the single point that serializes it, and the `invoice.paid`/receipt enqueue is gated on rows-affected=1 |
| Webhook authentication | Per-gateway: HMAC for signing gateways, authenticated re-fetch for Morning + return 200 on invalid | `verifyWebhook` generalized from "verify HMAC" to "establish authenticity"; Morning's callback signing is unconfirmed, so re-fetching status from the Morning API is correct regardless of scheme and never trusts the raw body. Don't reveal verification failure to attackers. |
| Morning credential decoupling | Separate `payment_gateway_configs` row, optional prefill from invoicing creds | Collection and issuance subsystems stay independent; neither breaks on the other's credential rotation |
| Test mode | Per-config toggle | Allows tenants to test without live charges; clearly labelled in portal |
| Stripe in IL | Supported but secondary | Some tenants have international customers; Stripe handles multi-currency |
| Per-tenant webhook token | Yes | Routes webhook to correct tenant without exposing tenant ID in URL |

---

## Foundation Delta Additions

### Secrets

| Secret | Purpose |
|--------|---------|
| `PAYMENT_CONFIG_ENCRYPTION_KEY` | AES-256-GCM key for encrypting gateway credentials in `payment_gateway_configs` |

### New tables

- `payment_gateway_configs`
- `invoice_payment_sessions`

(See Data Model section above.)

### Schema delta on `tenants`

```sql
ALTER TABLE tenants ADD COLUMN payment_webhook_token TEXT; -- 32-byte hex, raw, for webhook routing
CREATE UNIQUE INDEX idx_tenants_payment_webhook_token ON tenants(payment_webhook_token)
  WHERE payment_webhook_token IS NOT NULL;
```

Generate on tenant creation: `crypto.getRandomValues(new Uint8Array(32))` → hex. Backfill
existing tenants in the migration. Stored raw + indexed (routing-only, not a secret — see Settings UI).

### Queues

No new queues. Receipt email and `invoice.paid` outbound webhook use existing `webhook.deliver` queue (spec 27) and communications adapter (spec 5).
