# Morning Payments — Epic A Core Slice Implementation Plan

> **For agentic workers:** implemented via `cursor-orchestrator` (cursor-agent codes, Claude
> reviews). Waves run in order; each task commits its own work inside the worktree. Tasks use
> checkbox (`- [ ]`) tracking.

**Goal:** Build the per-tenant Morning payment-collection vertical slice end-to-end: a portal
customer pays a tenant invoice via Morning's hosted clearing page, the payment is confirmed by
authenticated re-fetch, the invoice flips to PAID, and a receipt is sent.

**Architecture:** New `packages/payments/` adapter package (interface + registry + AES-256-GCM
config crypto + `MorningAdapter`). zync-api gains payment services (create-session, settle,
load-config), a public webhook route, portal session/status endpoints, and a tenant settings
API. zync-app gains the `/settings/integrations/payments` UI (Morning-only) and portal Pay Now
UI. Money flows tenant-customer → tenant; Zync never touches funds or card data (hosted page).

**Tech Stack:** TypeScript, Cloudflare Workers (`crypto.subtle`), Neon Postgres, React
(zync-app), Hono/route handlers per existing zync-api conventions.

**Canonical spec:** `docs/specs/2026-05-31-payment-gateway-adapters.md` (Morning deltas applied
2026-06-09). **Design doc:** `docs/specs/2026-06-09-morning-payments-design.md`. **Audit:**
`docs/plans/audit/payment-gateway-adapters.json`.

**Scope boundary:** This plan is the **core slice ONLY**. Excluded (follow-on plans, gated on
this slice transacting a real sandbox payment): partial-payment-recording, invoice-payment-ux,
invoice-payment-reminders, payment-retry-dunning, payment-reconciliation, and **Epic B platform
billing in zync-admin**.

---

## ⚠️ Gating sandbox check (do FIRST when creds land)

Before/at the start of Wave 2, with Morning sandbox `apiKey`+`secret`, confirm the **correlation
handle**: Morning's clearing-page creation endpoint must (a) return a queryable payment/document
id usable as `session_id`, and (b) accept a return/callback URL that carries our session id back
(path param or attachable metadata). The `UNIQUE(gateway, session_id)` model depends on this. If
it can't be confirmed, STOP and escalate — the session model needs revision before Wave 3+.
Extract exact endpoint paths/payloads from `app.greeninvoice.co.il/api` + the
`wc-gateway-greeninvoice` WooCommerce plugin `includes/` source. **No sandbox creds yet → build
to documented behavior, mark the live correlation/e2e check as a pending DoD item; do not fake a
green e2e.**

---

## Wave Plan

| Wave | Tasks | Files touched | Safe to parallelize? |
|------|-------|---------------|----------------------|
| 1 | T1 adapter interface, T2 config-crypto, T3 DB migration | `packages/payments/src/adapter.ts` + `types.ts`; `packages/payments/src/config-crypto.ts`; migration SQL | ✅ no overlap |
| 2 | T4 MorningAdapter + registry | `packages/payments/src/adapters/morning.ts`, `registry.ts` | single task |
| 3 | T5 payment services | `apps/zync-api/src/services/payment/*.ts` | single task |
| 4 | T6 webhook route, T7 portal endpoints, T8 settings API, T9 fix payment-link auth | distinct route files (see tasks) | ✅ no overlap |
| 5 | T10 invoice.paid event + receipt email | event emit in settle + `comms` receipt template/handler | single task |
| 6 | T11 settings UI, T12 portal Pay Now UI, T13 invoice-detail sessions section | distinct zync-app files | ✅ no overlap |

Semantic deps: T4 needs T1/T2; T5 needs T4+T3; T6/T7/T8 import T5; T9 independent; T10 hooks T5's
settle; T11–T13 consume T6/T7/T8 APIs (later wave).

---

## Wave 1 — Foundation (`packages/payments` + DB)

### Task 1: Adapter interface + types

**Wave:** 1 · **Blocks:** T4 · **Blocked by:** —

**Files:**
- Create: `packages/payments/package.json`, `packages/payments/tsconfig.json`,
  `packages/payments/src/adapter.ts`, `packages/payments/src/types.ts`,
  `packages/payments/src/index.ts`
- Test: `packages/payments/src/adapter.test.ts`

- [ ] **Step 1:** Scaffold the package following an existing `packages/*` package's
  `package.json`/`tsconfig.json` (read one first — match module type, build, exports).
- [ ] **Step 2:** Transcribe the `PaymentGatewayAdapter` interface, `GatewaySlug`
  (`'morning' | 'payplus' | 'cardcom' | 'stripe'`), `GatewayConfig`, `WebhookEvent`,
  `PaymentStatus` **verbatim from the spec's Adapter Interface section** into `adapter.ts`,
  including the generalized `verifyWebhook` doc comment (authenticity by signature OR
  authenticated re-fetch). `Invoice` type imported from the shared invoices types package
  (grep for the existing `Invoice` type; do not redefine).
- [ ] **Step 3:** Export everything from `index.ts`.
- [ ] **Step 4:** Type-only test: a fixture object satisfying `PaymentGatewayAdapter` compiles;
  `GatewaySlug` includes `'morning'`. Run the package's typecheck/test; expect pass.
- [ ] **Step 5:** Commit `feat(payments): adapter interface + types`.

**Acceptance:** package builds; interface matches spec exactly; `'morning'` in `GatewaySlug`.

### Task 2: Config crypto (AES-256-GCM)

**Wave:** 1 · **Blocks:** T4, T5 · **Blocked by:** —

**Files:**
- Create: `packages/payments/src/config-crypto.ts`
- Test: `packages/payments/src/config-crypto.test.ts`

- [ ] **Step 1:** Write failing round-trip test: `decryptConfig(encryptConfig(obj, key), key)`
  deep-equals `obj`; wrong key throws; `EncryptedConfig` shape is `{iv, tag, data}` base64.
- [ ] **Step 2:** Implement `encryptConfig(plainObj, keyBytes)` / `decryptConfig(enc, keyBytes)`
  using `crypto.subtle` `AES-GCM` (12-byte random IV via `crypto.getRandomValues`, 16-byte tag),
  matching the spec's `EncryptedConfig` interface. Key derived from `PAYMENT_CONFIG_ENCRYPTION_KEY`
  (base64/hex per existing secret conventions — grep how other secrets are imported).
- [ ] **Step 3:** Run test; expect pass.
- [ ] **Step 4:** Commit `feat(payments): AES-256-GCM config crypto`.

**Acceptance:** round-trip works in Workers runtime; tamper/wrong-key throws; never logs plaintext.

### Task 3: DB migration — payment tables + tenant token

**Wave:** 1 · **Blocks:** T5 · **Blocked by:** —

**Files:**
- Create: new migration file under the project's migrations dir (match existing naming/numbering —
  read the latest migration first; follow the `/zc-dba` dialect rules for Neon Postgres)

- [ ] **Step 1:** `CREATE TABLE payment_gateway_configs` and `invoice_payment_sessions`
  **verbatim from the spec Data Model section**, including `gateway` CHECK with `'morning'`,
  indexes, and `UNIQUE` constraints.
- [ ] **Step 2:** `ALTER TABLE tenants ADD COLUMN payment_webhook_token TEXT;` stored **raw**;
  add `CREATE UNIQUE INDEX idx_tenants_payment_webhook_token ON tenants(payment_webhook_token)
  WHERE payment_webhook_token IS NOT NULL;` (direct equality lookup; token is routing-only, not a
  secret). Backfill a 32-byte hex token for all existing tenants (idempotent).
- [ ] **Step 3:** **Wire generation on tenant creation** (in-slice — without it, any tenant
  onboarded after launch has no token and silently can't collect). Grep all tenant-create paths
  (`routes/admin/provision-tenant.ts` + any others) and set `payment_webhook_token` =
  `crypto.getRandomValues(new Uint8Array(32))` → hex on insert. Commit this with the migration or
  fold into T9 if it touches a route file in Wave 4 — but it MUST ship in this slice.
- [ ] **Step 4:** Apply against a Neon branch and verify with `/zc-dba` (DDL verify only); confirm
  CHECK accepts `'morning'` and rejects unknown; `UNIQUE(gateway, session_id)` and the token index
  present.
- [ ] **Step 5:** Commit `feat(payments): payment tables + tenant webhook token (raw, indexed, gen-on-create)`.

**Acceptance:** tables/columns/constraints/index exist on branch as specced; **every tenant-create
path generates a token**; existing tenants backfilled.

---

## Wave 2 — Morning adapter

### Task 4: MorningAdapter + registry

**Wave:** 2 · **Blocks:** T5 · **Blocked by:** T1, T2

**Files:**
- Create: `packages/payments/src/adapters/morning.ts`
- Modify: `packages/payments/src/registry.ts` (create with Morning + stub registration +
  `SELECTABLE_GATEWAYS = ['morning']`)
- Test: `packages/payments/src/adapters/morning.test.ts`

> Do the **gating sandbox check** above first. Extract exact endpoint paths/payloads at build
> time from live docs + the `wc-gateway-greeninvoice` plugin source — do not invent strings.

- [ ] **Step 1:** Implement JWT auth helper: `apiKey`+`secret` → `POST /account/token` → cache
  bearer until expiry. Base `https://api.greeninvoice.co.il/api/v1`.
- [ ] **Step 2:** `createPaymentSession(invoice, config, returnUrl, webhookUrl)` → create hosted
  clearing page; return `{ sessionId, redirectUrl }` where `sessionId` is the queryable payment id
  and `returnUrl` carries our session id back (per correlation check).
- [ ] **Step 3:** `verifyWebhook(payload, signature, config)` → **authenticated re-fetch**: parse
  the id from payload, re-query Morning payment/document status with JWT, return a `WebhookEvent`
  reflecting the *fetched* status (never the raw body). If Morning supplies a signature, verify it
  too. Throw only if authenticity can't be established.
- [ ] **Step 4:** `getPaymentStatus(sessionId, config)` (poll) and
  `testConnection(config)` (token fetch + lightweight call → `{ok, error?}`).
- [ ] **Step 5:** Register `MorningAdapter` in `registry.ts`; keep payplus/cardcom/stripe as
  registered stubs; export `SELECTABLE_GATEWAYS = ['morning']`.
- [ ] **Step 6:** Tests with **mocked HTTP** (no live creds): token caching; createPaymentSession
  returns id+url; verifyWebhook ignores a lying raw body and trusts the re-fetched status;
  testConnection ok/fail. Run; expect pass.
- [ ] **Step 7:** Commit `feat(payments): Morning adapter + registry`.

**Acceptance:** all interface methods implemented; verifyWebhook proven to re-fetch (test feeds a
`paid` body but mocked API says `pending` → event is `pending`). Live correlation/e2e = pending DoD.

---

## Wave 3 — Payment services

### Task 5: create-session / settle-webhook / load-active-config

**Wave:** 3 · **Blocks:** T6, T7, T8, T10 · **Blocked by:** T3, T4

**Files:**
- Create: `apps/zync-api/src/services/payment/create-session.ts`,
  `settle-webhook.ts`, `load-active-config.ts`, `index.ts`
- Test: matching `*.test.ts`

- [ ] **Step 1:** `loadActiveConfig(db, tenantId)` → fetch active `payment_gateway_configs` row,
  `decryptConfig` → `GatewayConfig`. Returns null if none.
- [ ] **Step 2:** `createSession(db, invoice, tenant)` → load config, `getAdapter(gateway)`,
  build returnUrl (`https://{portal_slug}.zync.is/portal/invoices/{invoiceId}?payment=success`)
  and webhookUrl (`https://api.zync.is/webhooks/payment/{gateway}/{tenantWebhookToken}`), call
  `adapter.createPaymentSession`, INSERT `invoice_payment_sessions` (status pending, amount in
  agorot = full outstanding), return `{ redirectUrl }`. Validate invoice is payable —
  **`TAX_ISSUED` only** for this slice (partial-payment / `PARTIALLY_PAID` remaining-balance
  semantics land with the partial-payment follow-on; accepting it here would settle the full
  amount and overcharge) — and belongs to tenant (FK/tenant-isolation per existing handler
  patterns).
- [ ] **Step 3:** `settleWebhook(db, gateway, token, rawBody, signature)` → resolve tenant by raw
  `payment_webhook_token` (indexed equality), load config, `adapter.verifyWebhook` (re-fetch
  confirm). **Idempotent via atomic conditional UPDATE — NOT read-then-write** (read-then-write
  loses the concurrent-retry race: two deliveries both read `pending`, both settle, both emit).
  On confirmed paid run:
  `UPDATE invoice_payment_sessions SET status='paid', paid_at=now(), webhook_received_at=now(), webhook_payload=? WHERE gateway=? AND session_id=? AND status='pending'`.
  **Only if rows-affected = 1** (this delivery won the transition): UPDATE invoice → PAID +
  `paid_at`, and return a "settled" signal so T10 enqueues `invoice.paid` + receipt. rows-affected
  = 0 → already settled → no-op, enqueue nothing.
- [ ] **Step 4:** Tests: createSession rejects non-`TAX_ISSUED` / cross-tenant invoice;
  settleWebhook trusts re-fetched status not raw body; **concurrent double-delivery** (fire two
  settles against the same session, e.g. overlapping) settles once and signals settled exactly
  once — sequential replay alone is insufficient, test the concurrent case. Run; expect pass.
- [ ] **Step 5:** Commit `feat(payments): payment services`.

**Acceptance:** services enforce tenant isolation + payable-status; settle is idempotent and
re-fetch-driven.

---

## Wave 4 — Routes (parallel, distinct files)

### Task 6: Public webhook route

**Wave:** 4 · **Blocks:** — · **Blocked by:** T5

**Files:**
- Create: `apps/zync-api/src/routes/webhooks/payment.ts` (route
  `POST /webhooks/payment/:gateway/:token`)
- Modify: route registration/index for the public webhook router (read how existing webhooks
  register — keep this route OUTSIDE tenant auth middleware)

- [ ] **Step 1:** Handler reads raw body + signature header, calls `settleWebhook`. **Return 200
  even when authenticity fails** (don't leak). Rate-limit via existing `RATE_LIMITER_WEBHOOK`.
- [ ] **Step 2:** Test: valid → session/invoice settled; invalid → 200, no state change; replay →
  idempotent. Run; expect pass.
- [ ] **Step 3:** Commit `feat(payments): public payment webhook route`.

### Task 7: Portal session + status endpoints

**Wave:** 4 · **Blocks:** T12 · **Blocked by:** T5

**Files:**
- Create: `apps/zync-api/src/routes/portal/invoice-payment.ts`
  (`POST /api/invoices/:id/payment/session`, `GET /api/invoices/:id/payment/status`)
- Modify: portal route index

- [ ] **Step 1:** POST session: portal-auth (authenticated customer), call `createSession`,
  return `{ redirectUrl }`. GET status: return session status from DB (verified, not from query
  param).
- [ ] **Step 2:** Tests: auth required; only invoice's portal customer can create; status reflects
  DB. Run; expect pass.
- [ ] **Step 3:** Commit `feat(payments): portal payment session + status endpoints`.

### Task 8: Tenant settings API

**Wave:** 4 · **Blocks:** T11 · **Blocked by:** T5 (load-active-config), T2

**Files:**
- Create/Modify: `apps/zync-api/src/routes/settings/integrations-payments.ts`
  (GET/PUT/POST-test/DELETE `/api/settings/integrations/payments`)
- Modify: settings route index

- [ ] **Step 1:** GET (`settings:read`, OWNER/ADMIN) → config with credentials **redacted**
  (`{ configured: true }` per field). PUT (`settings:write`) → validate gateway ∈
  `SELECTABLE_GATEWAYS` (reject stubs), `encryptConfig`, upsert row. POST `/test` →
  `adapter.testConnection`. DELETE → remove config.
- [ ] **Step 2:** Tests: read redacts secrets; PUT with `gateway:'payplus'` (stub) → 400; test
  returns ok/fail; permissions enforced. Run; expect pass.
- [ ] **Step 3:** Commit `feat(payments): tenant payment settings API`.

### Task 9: Fix existing payment-link route auth

**Wave:** 4 · **Blocks:** — · **Blocked by:** —

**Files:**
- Modify: existing payment-link routes flagged in audit finding 005 (lack `authMiddleware` → 401)
  — locate via grep (`generatePaymentLink` / `payment-gateways`), apply the standard auth
  middleware used by sibling tenant routes.

- [ ] **Step 1:** Add the auth middleware; confirm no longer 401 for authed tenant; still 401
  unauth. Test. Commit `fix(payments): require auth on payment-link routes`.

> Note: the old `apps/zync-api/src/integrations/payment-gateways/*` stubs (cardcom/payplus) are
> superseded by `packages/payments`. Do NOT extend them. If a route imports them, repoint to the
> registry; if dead, leave for a follow-on cleanup task (out of slice scope) — just don't ship a
> selectable broken gateway.

---

## Wave 5 — Event + receipt

### Task 10: `invoice.paid` event + receipt email

**Wave:** 5 · **Blocks:** — · **Blocked by:** T5

**Files:**
- Modify: `apps/zync-api/src/services/payment/settle-webhook.ts` (wire the event enqueue hook)
- Create: receipt email template/handler in the comms adapter (spec 5) + outbound `invoice.paid`
  enqueue to the existing `webhook.deliver` queue (spec 27)
- Test: matching tests

- [ ] **Step 1:** **Gated on T5's row-winner signal (rows-affected = 1), NOT on "status is now
  paid"** — otherwise a losing concurrent delivery re-emits. Enqueue `invoice.paid` (payload
  **verbatim from spec**: `{event, invoice_id, amount, currency, gateway, paid_at}`) to
  `webhook.deliver`.
- [ ] **Step 2:** Enqueue receipt email to `invoice.customer_email` with PDF (re-generated from
  invoice HTML → R2) via the comms adapter — follow the existing invoice-email pattern (grep).
- [ ] **Step 3:** Test: a single settle (row-winner) emits exactly one `invoice.paid` + one
  receipt; **concurrent double-delivery** emits exactly one of each (loser emits zero); a
  separate replay after settle emits zero. Run; expect pass.
- [ ] **Step 4:** Commit `feat(payments): invoice.paid event + receipt email`.

**Acceptance:** payment confirmation produces event + receipt, exactly once.

---

## Wave 6 — UI (parallel, distinct files)

### Task 11: Settings UI `/settings/integrations/payments` (Morning-only)

**Wave:** 6 · **Blocks:** — · **Blocked by:** T8

**Files:**
- Create/Modify: zync-app settings integrations page + payments card component (match existing
  integrations-hub pattern — read `settings-module` UI + a sibling integration card first)

- [ ] **Step 1:** Connected + setup states per spec. **Selector lists Morning only** (drive from
  `SELECTABLE_GATEWAYS`; never render stub gateways). Morning credential form: API Key + API
  Secret (password, show/hide), optional "use my Morning invoicing credentials" prefill (only if
  issuance configured). Test-mode toggle. Webhook URL display + Copy.
- [ ] **Step 2:** Wire to the settings API (T8); secrets never shown after save (`configured:true`).
- [ ] **Step 3:** Component/integration test: only-Morning selectable; save→redacted reload; test
  button surfaces ok/fail. Run; expect pass.
- [ ] **Step 4:** Commit `feat(payments): payment settings UI (Morning)`.

### Task 12: Portal Pay Now + status poll UI

**Wave:** 6 · **Blocks:** — · **Blocked by:** T7

**Files:**
- Modify: portal invoice page (read it first)

- [ ] **Step 1:** Pay Now button shown only when invoice is `TAX_ISSUED` (core slice; no
  `PARTIALLY_PAID` until the partial-payment follow-on) AND tenant has a gateway configured.
  Click → POST session → redirect to `redirectUrl`. On return
  with `?payment=success`, verify status from API (T7), show confirmation banner + receipt link;
  do not trust the query param alone. Test-mode badge when applicable.
- [ ] **Step 2:** Test: button gating; success banner only on DB-confirmed paid. Run; expect pass.
- [ ] **Step 3:** Commit `feat(payments): portal Pay Now UI`.

### Task 13: Tenant invoice-detail payment sessions section

**Wave:** 6 · **Blocks:** — · **Blocked by:** T7 (status) / reads sessions

**Files:**
- Modify: zync-app tenant invoice detail page (read first)

- [ ] **Step 1:** Show payment-gateway-status badge if configured + a collapsible "Payment
  sessions" list (attempts with status/timestamps). Read-only.
- [ ] **Step 2:** Test renders sessions; empty state. Run; expect pass.
- [ ] **Step 3:** Commit `feat(payments): invoice-detail payment sessions section`.

---

## Definition of Done (core slice)

- [ ] All 6 waves committed; `packages/payments` builds; zync-api + zync-app typecheck/lint pass.
- [ ] Unit/integration tests green (mocked Morning HTTP).
- [ ] Settings UI exposes Morning only; selecting/saving a stub gateway is impossible (400).
- [ ] Webhook returns 200 on invalid; settle is re-fetch-driven (not raw-body) and idempotent
      under **concurrent** double-delivery (atomic conditional UPDATE; event/receipt emitted
      exactly once by the row-winner) — verified by a concurrent test, not just sequential replay.
- [ ] Every tenant-create path generates a `payment_webhook_token`; existing tenants backfilled.
- [ ] **Pending (blocked on Morning sandbox creds):** correlation-handle confirmation + live e2e
      (create session → pay on hosted page → webhook/return → invoice flips PAID → receipt). Do
      NOT mark green without real creds; track as the one open DoD item.

---

## Self-Review

- **Spec coverage:** adapter interface (T1), config-crypto (T2/finding 008), tables+token
  (T3/006/007/009), Morning adapter+verifyWebhook (T4/002), services+idempotency+mark-PAID
  (T5/003/004), webhook route+200-on-invalid (T6/001), portal endpoints (T7/012), settings API
  +PUT+test+delete+redaction (T8/013/023), payment-link auth (T9/005), invoice.paid+receipt
  (T10/017/018), settings UI (T11/016/020), portal Pay Now + invoice-detail (T12/T13/014/015).
  Tests folded per task (019). P2 amount-as-agorot honored in T5 (INTEGER agorot). Payable-status
  is `TAX_ISSUED` only in the core slice — excludes proforma SENT and defers `PARTIALLY_PAID` to
  the partial-payment follow-on (T5/T12, finding 024). Encryption key name `PAYMENT_CONFIG_ENCRYPTION_KEY`
  per spec (finding 022 — cursor-agent reconciles any `INTEGRATION_ENCRYPTION_KEY` drift).
- **Placeholders:** exact Morning endpoint strings are deliberately deferred to build-time
  extraction (spec rule: never hardcode guessed gateway strings); all other contracts pasted from
  spec. This is intentional, not a TBD.
- **Wave check:** each wave's parallel tasks touch disjoint files (W1: package iface vs crypto vs
  migration; W4: webhook vs portal vs settings vs payment-link routes; W6: settings vs portal vs
  invoice-detail pages). Importing tasks placed in later waves than their dependency.
