# Plugin-Backend Core — Design Spec

**Slug:** `plugin-backend-core`
**Date:** 2026-07-03
**Status:** Design (Spec 1 of 2). Spec 2 = translation service, later cycle.
**Repo home:** `platform/apps/press-zone` (new app) + upstream contributions under `platform/packages/*`.

---

## 1. Mission & Scope

Build the shared **plugin-backend platform** for all Press.Zone WordPress plugins: a single Cloudflare-Workers app that owns **identity, per-site credential provisioning, multi-tenant accounts + RBAC, per-plugin subscriptions/entitlements, usage metering, PayPal billing, and Morning (Green Invoice) VAT invoicing**. Translation is the *first* plugin service and is deliberately **out of this spec** — it extends `@platform-modules/i18n-translator` on top of this core in Spec 2.

The predecessor `press-zone-backend` (Express + Prisma + Bull on a dying VPS, **test data only**) is **discarded, not migrated**. This is greenfield: no live-data reconciliation, no backward-compat with old routes.

**In scope (Spec 1):**
- App scaffold on CF Workers (Hono, envelope, config, rate-limit, cron, queues).
- Accounts (tenants) + members + capability-level RBAC + per-plugin isolation.
- Plugin/product catalog + per-plugin subscription packages/tiers.
- "Click to connect" OAuth2 credential provisioning + optional API-key path.
- PayPal subscription billing + recurring-payment webhook ingest.
- Credit ledger (per-plugin metering primitive) with monthly reset.
- Morning VAT invoicing + Israeli place-of-supply taxability.
- Two admin faces (customer dashboard + internal staff admin) — SPA on CF Pages.
- Observability, abuse/rate-limiting, audit.

**Out of scope (Spec 2 / future):** translation engine + i18n-translator write-side store; non-credit metering units; pricing **bundles** (schema must not preclude them, build nothing); EU VAT-OSS (only relevant if selling to EU consumers + registering — future).

---

## 2. Architecture Overview

```
WordPress plugin (per site)  ──OAuth2 authz-code (PKCE)──►  press-zone Worker (Hono)
        │  Authorization: Bearer <site-credential>                │
        ▼                                                         ├─ auth (JWT access + opaque refresh + DB blocklist)
  plugin API calls (translate, …)                                 ├─ oauth-provider (authorize / token / clients)   [contrib]
                                                                  ├─ tenancy (membership + rbac-triad + isolation-fk)
  Customer dashboard SPA ──► /api/account/*   (auth cookie)       ├─ entitlements (can / quota / setTier)            [contrib build]
  Staff admin SPA       ──► /api/staff/*     (staff role)         ├─ ledger (append-only credits; guarded debit)
                                                                  ├─ billing (PaymentProvider + subscriptions)      [subs contrib]
  PayPal  ──webhook──►  /api/webhooks/paypal ──► ingestWebhook    │    └─ paypal adapter                            [contrib]
  Morning (Green Invoice) ◄── issueInvoice (invoice / credit-note)├─ invoicing (issueInvoice + InvoiceProvider)     [contrib]
                                                                  │    └─ morning adapter                           [contrib]
                                                                  ├─ tax (resolveVatRate/applyVat + taxability)     [taxability contrib]
                                                                  ├─ jobs (CF Queues + native Cron Triggers)
                                                                  ├─ cache (KV) · audit · db (Neon serverless)
                                                                  ▼
                                             Neon Postgres · KV · R2 · CF Queues · CF Rate-limit
```

**Runtime:** Cloudflare Workers, ESM-only, `tsup`, `nodejs_compat` off where avoidable (module core is Web-standard: `crypto.subtle`, `URL`, `Request`/`Response`). Router = **Hono** (pure JSON API). We consume platform **modules** behind their seams and design our own app scaffold (§9) per the platform coding standard — we do **not** template off any existing platform app (those are CMS/commerce distros, unrelated to this API). Platform modules are router-agnostic (web-standard `Request`/`Response`), so they wire into Hono directly.

**Persistence:** Neon Postgres via `@platform-modules/db` `createNeonServerlessClient` (WebSocket — **transactional**; `createNeonHttpClient` is non-transactional and used only for read paths). One Drizzle schema owns all app tables; module tables (ledger, audit, tenancy rbac, auth sessions, jobs outbox) are composed in per each module's schema contract.

**Layer discipline:** app consumes modules top-down along the platform DAG (L0 `util·db` → L1 `jobs·auth·tenancy·cache` → L2 `ledger·audit·entitlements·invoicing` → L3 app). No module imports the app; contributions land as module subpaths behind typed seams, never app-local islands.

---

## 3. Domain Model

Entities (Drizzle tables in the app schema unless the entity is a module's own table). All money/credit amounts are **BigInt minor units** (platform-wide convention; 1 translation character = 1 credit unit).

| Entity | Owner | Key fields / contract |
|--------|-------|----------------------|
| **Account** (tenant) | tenancy | `id`, `slug`, `type: 'individual' \| 'org'`, `status`. Individual = a 1-member account. Owns subscriptions/billing/usage. |
| **User** (identity) | auth | `id`, email, PBKDF2 secret. A person; may be a member of ≥1 accounts. |
| **Membership** | tenancy | `{ tenantId, userId, roleKey, status: 'active'\|'frozen'\|'pending_approval' }`. |
| **Role / Permission** | tenancy rbac-triad | `roles`, `permissions`, `role_permissions`. System roles (Owner/Admin/Billing/Member) seeded per tenant; enterprises add custom roles. |
| **Plugin** (product) | app catalog | `key` (e.g. `translate`, `forum`), `name`, **`permission_catalog: string[]`** — the plugin's self-declared per-action permission vocabulary (`translate.write`, `translate.delete`, …). |
| **SubscriptionPackage / Tier** | app catalog | Per **plugin**: `plugin_key`, `tier_key`, price, `credit_allocation: bigint` (0 = non-metered plugin), **`seats_config` (per plugin × package)**, `paypal_plan_id`. Bundles = future (schema allows a package to map to N plugin-entitlements; build nothing). |
| **Subscription** | app | `account_id`, `plugin_key`, `tier_key`, `status`, `current_period_start/end`, `paypal_subscription_id`, `grace_period_end_at`. **Per-plugin isolated** — own payment, tier, period, usage. Only the Account is shared. |
| **Entitlement** | entitlements module | account → `plugin:<key>` capability + tier + quota. Derived from active Subscription; `setTier` on activate, revoke on cancel. |
| **Site** | app | `id` = **stable generated site-id** (uuid, minted at connect — NOT the URL), `account_id`, `plugin_key`, `display_url` (informational), `status`. A seat is a live Site. |
| **Credential** | app + auth service-token | per `(account, plugin, site)`; opaque token (hashed, `Bearer <prefix>.<secret>`), revocable/rotatable. Stored plaintext only in the WP site's `wp_options` (never in our DB). |
| **Credit wallet + money-audit log** | ledger module | **Two distinct constructs.** *Metering:* a per-period **wallet balance** (`walletBalances`, `ownerId = <account>:<plugin>:<period>`, materialized) — allocation = host seed/upsert of the period wallet, translation charge = guarded `debit` (conditional `UPDATE … WHERE balance>=amount`, race-safe). *Money audit:* `ledger_entries` append-only log — billing posts the idempotent money-received entry here (`confirmSettlement`, keyed on `chargeKey`). |
| **Invoice reference** | app | `account_id`, `subscription_id`, `morning_document_id`, `document_number`, `document_url`, `doc_type`, `amount`, `vat_amount`, `currency`, `idempotency_key` (UNIQUE). The legal document lives in Morning; we store the reference. |
| **OAuth client / code / token** | oauth-provider (contrib) | first-party pre-registered clients (one per plugin), authz codes (PKCE-bound, short TTL), issued tokens. |
| **WebhookDedup / Intent** | app (host tables) | idempotency stores wired into billing `ingestWebhook` / `settleCharge`. |
| **AuditLog** | audit module | every staff action + security event. |

**Seat rule (derived from old backend, generalized):** cap lives per-Subscription (from the tier's `seats_config`, `-1` = unlimited); a seat = a live `Site` row; connecting a new site over the cap → **HTTP 409 `SITE_LIMIT_REACHED`**; disconnect frees the seat. Unlike the old backend (hardcoded tier-slug switch, URL-keyed), the cap is **data-driven per plugin × package** and the seat is keyed by **stable site-id** (immune to domain changes).

---

## 4. Module Map

**Adopted (built) — consumed as-is:**

| Module | Seam used | Role |
|--------|-----------|------|
| `db` | `createNeonServerlessClient({connectionString, schema})` → `TransactionalDatabase` | Postgres, transactional. |
| `auth` | `createCustomEngine(config)` → `AuthEngine & UserAdminEngine`; `getSession(headers, engine)`; `service-token` primitives | Human sessions (access-JWT + opaque refresh + DB blocklist, PBKDF2); machine credential base. |
| `tenancy` | `createTenancy(deps)`; `createTriadRbac(db)` (`resolveCapabilities`, `seedSystemRoles`, `seedPermissionCatalog`); `createFkIsolation(config)` | Accounts, members, capability RBAC, per-account data isolation. |
| `ledger` | `getBalance(db, target)` + guarded `debit` on a **`WalletBalanceTarget{ownerId}`** (credit metering, per-period wallet, throws `InsufficientBalanceError`); `appendEntry(tx, {key, delta, reason})` (append-only money-audit log). **No credit-grant primitive** → host seeds `walletBalances` (module exports the table). | Per-plugin credit metering + money audit. |
| `billing` | `PaymentProvider`, `ingestWebhook`, `settleCharge`/`refundCharge`/`reconcileCharge`, `DedupStore`/`IntentStore` (host-implemented) | One-shot charge/refund/webhook money-seam. |
| `jobs` | `createJobRegistry`, `cf-queues` (`enqueue`/`consume`), `IdempotencyStore`, `do-runner` (optional) | Async work over CF Queues. |
| `cache` | `createCache(createKvBackend(kv))` → `get/set/getOrSet` | KV-backed cache (config, rate snapshots). |
| `audit` | `logAudit(db, event)` (inline, never-throws), `listAudit(db, filter)` | Audit trail. |

**Contributed (built in this initiative — upstream, behind seams). Full contracts in §12.**
`entitlements` (BUILD-approved, build it) · `auth/entitlements` 402-guard subpath · `billing` **subscriptions capability** (net-new) · `billing/paypal` adapter · `invoicing` module · `invoicing/morning` adapter · `tax` **taxability/place-of-supply** seam · `auth/oauth-provider` · `auth/api-keys` (generalize `service-token`).

---

## 5. Authorization Model — three independent axes

Every authenticated plugin API call resolves **three** checks; all must pass:

1. **Account entitlement** — does the account hold this plugin? `entitlements.can(accountId, 'plugin:<key>')`. Fail → **402** (subscribe).
2. **Member capability** — is the member allowed this action? `tenancy` `resolveCapabilities(userId, tenantId)` ⊇ required permission (e.g. `translate.write`). Fail → **403**.
3. **Usage balance** — for metered actions, `ledger` guarded `debit` on the **current-period wallet** (`WalletBalanceTarget{ ownerId: <account>:<plugin>:<period> }`), or pre-check `getBalance`. Insufficient → **402 `INSUFFICIENT_CREDITS`**.

Machine callers (WP plugins via site credential) carry the account+plugin+site context in the resolved credential; their "member capability" is the credential's granted scope (mapped to the same permission vocabulary). Human callers (dashboard) carry a `Principal` from `getSession`.

**Effective access = entitlement (account↔plugin) ∩ capability (member/credential↔action).** The permission vocabulary is **plugin-declared** (`Plugin.permission_catalog`), seeded into rbac-triad via `seedPermissionCatalog()`.

A single Hono middleware (`requirePluginAccess(pluginKey, permission)`) composes axes 1–2; metered handlers call the ledger for axis 3.

---

## 6. Key Flows (data flow + error handling)

### 6.1 Click-to-connect (OAuth2 authorization-code + PKCE)
Zero-friction credential provisioning; user never sees a key.
1. WP plugin admin renders **"Connect"** → opens `/oauth/authorize?client_id=<plugin>&redirect_uri=<site>&scope=<plugin caps>&state&code_challenge&code_challenge_method=S256`.
2. If unauthenticated → login/consent on the platform; else `authorize()` validates client + redirect, checks **seat availability** for the account+plugin, binds the PKCE challenge, mints a short-TTL **authorization code**.
3. Redirect back to the site callback with `code`; the WP plugin does a **server-to-server** `exchangeToken({grant_type:'authorization_code', code, code_verifier, client_id})` → receives the **per-site credential** (opaque `Bearer <prefix>.<secret>`).
4. Platform mints the **Site** (stable site-id, consumes a seat) + Credential; WP stores the token in `wp_options`. Invisible, revocable, rotatable.
5. **Optional manual API-key path** (power users): dashboard issues an `api-keys` token bound to a site, same downstream shape.

Errors: invalid client/redirect → 400; over seat cap → **409 `SITE_LIMIT_REACHED`**; expired/replayed code → 400 (one-time redemption).

### 6.2 Subscribe / checkout (PayPal subscription lifecycle)
1. Dashboard picks a plugin tier → app calls **subscriptions** `createSubscription(provider, {accountId, pluginKey, tierKey, paypalPlanId})` → PayPal subscription (approval URL) → user approves at PayPal.
2. PayPal `BILLING.SUBSCRIPTION.ACTIVATED` webhook → app activates the Subscription, `entitlements.setTier(account, 'plugin:<key>', tier)`, and **seeds the first-period credit wallet** — upsert `walletBalances[ownerId = <account>:<plugin>:<period>] = tier credit_allocation` (ledger exposes no grant primitive; host owns the seed).

### 6.3 Recurring payment + monthly reset + invoice
Recurring `PAYMENT.SALE.COMPLETED` → `parseWebhook` maps to `ProviderEvent kind:'settlement'` with a **stable `chargeKey = <subscription_id>:<period>`**. Then:
1. **Module money-audit (built-in, do not duplicate):** for `kind:'settlement'` the billing module itself posts the idempotent money-received entry inside `ingestWebhook` (`confirmSettlement` → `ledger.appendEntry`, idempotent on `chargeKey`). No `IntentStore` and no app-initiated intent is required or looked up — that machinery is for one-shot charges only. The host must **not** re-post money.
2. **Host `dispatch(event)`** (idempotent on `chargeKey`/period — **never** on `eventId`) does exactly two things:
   - **Credit reset via a new period wallet:** seed the new period's credit wallet — upsert `walletBalances[ownerId = <account>:<plugin>:<period>] = tier credit_allocation`. A new period = a new `ownerId` = balance starts at 0, so the seed sets it to the allocation; prior-period unused credits are stranded in the old key (reset semantics — **no zeroing, no compensating entry, race-free** vs concurrent debits). Deliberate change from the old backend's infinite rollover.
   - **VAT + invoice:** taxability (§6.6) → `applyVat` → `invoicing.issueInvoice(morning, credential, documentSpec)` (docType `invoice`/`invoice_receipt`), store the `{documentId, documentNumber, documentUrl}` reference. `idempotencyKey = subscription+period` → **no double-issue**.

**Two idempotency domains (by design):** the module's money entry is idempotent on `chargeKey`; dispatch's credit-grant + invoice are idempotent on `chargeKey/period`. Keying dispatch on the period `chargeKey` rather than PayPal's `eventId` means a redelivery under a *new* event id still cannot double-grant credits or double-issue an invoice.

### 6.4 Authenticated plugin API call (metered)
`Authorization: Bearer <site-credential>` → resolve credential → account+plugin+site → `requirePluginAccess` (entitlement ∩ capability) → for translate, guarded `ledger.debit(chars)` on the current-period wallet (`ownerId = <account>:<plugin>:<period>`); insufficient → **402 `INSUFFICIENT_CREDITS`**. Rate-limit gate first (§9). (The translate handler body itself is Spec 2.)

### 6.5 Refund → credit note + reversal
Staff/API refund → `billing.refundCharge` (PayPal refund) → on `PAYMENT.SALE.REFUNDED` → `invoicing.issueInvoice(morning, …, docType:'credit_note')` + `ledger.appendEntry(compensating -delta, floored at 0)`. Invoices are immutable; corrections are credit-notes.

### 6.6 Israeli VAT taxability
Seller = Israeli entity. New `tax` **taxability seam** decides place-of-supply from `(supplierJurisdiction='IL', customerJurisdiction, supplyType)`:
- customer **IL** → `standard` → `resolveVatRate(IL_VAT_SCHEDULE, date)` (18%), `applyVat`.
- customer **non-IL** → `zero_rated` (export exemption) → 0%.
- `exempt` (Israeli exemption categories: financial services, education, non-profits, etc.) is a **reserved, v1-unreached** member of the `treatment` union — no rule produces it yet; this product has no exempt supplies in v1. See §13.
Computed at **checkout** (PayPal must charge gross = net + VAT before any invoice exists); Morning re-derives the same VAT on the legal invoice — the rate is single-sourced from `tax`, Morning configured to match.

### 6.7 Staff-admin ops (v1)
Account lookup; view subscriptions/usage/invoices; **refund** (→ 6.5); **manual credit** (`ledger.appendEntry` adjustment, reason-tagged); **suspend** account/membership. Impersonation gated behind a senior staff role + mandatory `audit` entry (else deferred). Every staff mutation → `logAudit`.

---

## 7. API Surface (shape, not exhaustive)

Envelope (symmetric, our design): success = `{ data }` (`jsonOk`); error = `{ error: { code, message } }` (`jsonError`). Uniform so plugin clients branch on `error` presence.

- `POST /oauth/authorize`, `POST /oauth/token` — connect flow.
- `GET/POST /api/account/*` — dashboard: subscriptions, sites/seats, invoices, members, roles, credentials. Auth = session cookie (`getSession`).
- `POST /api/plugin/:plugin/*` — plugin service calls. Auth = site credential.
- `POST /api/webhooks/paypal` — `ingestWebhook` (owns its own Response).
- `GET/POST /api/staff/*` — staff admin; `requireRole(staff)`.
- `GET /health`.

---

## 8. Error Handling Strategy

Typed error unions per module (no bare throws) mapped to HTTP by a single Hono error mapper:

| Condition | Code | HTTP |
|-----------|------|------|
| Not entitled to plugin | `PLUGIN_NOT_ENTITLED` | 402 |
| Insufficient credits | `INSUFFICIENT_CREDITS` | 402 |
| Member lacks capability | `PERMISSION_DENIED` | 403 |
| Seat cap reached | `SITE_LIMIT_REACHED` | 409 |
| Rate limited | `RATE_LIMITED` | 429 |
| Invalid OAuth client/redirect/code | `OAUTH_INVALID` | 400 |
| Invoice provider rejected | `PROVIDER_REJECTED` | 502 |

`ledger` `InsufficientBalanceError`, tenancy `ScopeViolationError`/`NotAMemberError`, auth `InvalidSessionError`/`PermissionDeniedError`, invoicing `InvoiceError` union all map here. Money paths are **idempotent** (webhook dedup, intent store, invoice unique key) and **durable-intent-first** (settle writes intent before provider call → crash-recoverable via `reconcileCharge` sweep).

---

## 9. App Scaffold (Hono)

- **Envelope** `src/lib/http.ts`: `jsonOk(data, status=200)` → `{ data }`, `jsonError(status, code, message)` → `{ error: {code,message} }`, plus the error mapper.
- **Config/env**: bindings reached via a typed `Env`; handlers receive a narrowed struct, never a global. DB client built per-request from `DATABASE_URL`.
- **Rate-limit** `src/lib/rate-limit.ts`: structural `RateLimiter { limit({key}): Promise<{success}> }`, **fail-open** if binding absent. Three tiers: per-IP on unauth (`/oauth`, login), per-credential on plugin calls, per-account job-concurrency cap. `[[ratelimits]]` `period` ∈ {10,60}.
- **Middleware chain**: rate-limit → auth/credential resolution → entitlement+capability gate. `loadConfig` fails open; auth/role resolution fails **closed**.
- **Jobs**: `createJobRegistry` + `cf-queues` with `[[queues]]` bindings. Consumer worker `consume(registry, batch, env)`; `IdempotencyStore` over KV/DB.
- **Cron**: **native CF Cron Triggers** (`[triggers] crons` + `scheduled()` handler) → dispatch sweeps (dunning, `reconcileCharge`, period resets).
- **Observability**: structured JSON logs → **Logpush**; **Sentry** for exceptions; alerts on failed payments, undelivered webhooks (DLQ depth), invoice-provider errors, credit-exceed spikes.
- **wrangler bindings**: `DB`/`DATABASE_URL` (Neon), `SESSION` KV, `MEDIA`/invoice-cache R2 (optional), `[[queues]]`, `[[ratelimits]]` (per-IP/per-credential/per-account), secrets (`AUTH_SESSION_SECRET`, `AUTH_PEPPER`, `PAYPAL_*`, `MORNING_*`, `DATABASE_URL`) via wrangler secrets — **not** `[vars]`.

---

## 10. Two Admin Faces

Single React+Vite SPA (CF Pages), two routed areas sharing `auth-react`:
- **Customer dashboard** (`/`): account, per-plugin subscriptions + usage/credits, sites/seats, invoices (link to Morning PDF), members + custom roles.
- **Staff admin** (`/staff`, `requireRole(staff)`): §6.7 ops.

E2E via Playwright (existing platform config pattern).

---

## 11. Testing Strategy

- **Per contribution seam** — one vitest per exported capability (platform rule): `SubscriptionProvider` lifecycle, `PaypalProvider` charge/refund/webhook mapping, `invoicing.issueInvoice` (incl. no-double-issue), `MorningProvider` (mocked HTTP), `tax` taxability (IL vs export), `oauth-provider` authorize/exchange (PKCE, one-time code, replay), `api-keys` issue/verify/rotate, `entitlements` can/quota/setTier.
- **Integration** — the money/idempotency invariants: webhook replay → single ledger effect; refund → credit-note + reversal; seat cap enforcement; monthly reset (not rollover).
- **E2E** — connect flow, subscribe, dashboard invoice retrieval, staff refund.
- **Mocks** — PayPal + Morning behind their adapter seams; `invoicing/mock`, in-memory tenancy/cache backends for unit tests.

---

## 12. Upstream Contributions — Seam Contracts

All contributions **must** follow the platform repo's own `CLAUDE.md`, `docs/standards/coding-standard.md`, and monorepo-architecture spec (ESM/tsup, `catalog:`/`workspace:^`, no `node:` builtins, seam = `src/index.ts`, BigInt minor units, typed error unions, one vitest per export). Agents building these load platform context first.

### 12.1 `entitlements` (L2 — build the BUILD-approved module)
```
can(subject: EntitlementSubject, capability: string): Promise<boolean>
quota(subject: EntitlementSubject, key: string): Promise<number>
setTier(subject: EntitlementSubject, tier: TierRef): Promise<void>
```
- `subject` = account (optionally scoped by tenancy). `capability` = `plugin:<key>`; `quota` key = e.g. `translate:chars` (period allocation, distinct from live ledger balance). Deps `db`+`util`, optional `tenancy`; **no `auth` import**.
- `auth/entitlements` subpath = thin HTTP-402 guard calling `can`/`quota`/`requireTier`; direction auth-guard → entitlements only.

### 12.2 `billing` subscriptions capability (net-new — billing today is one-shot only)
New subpath `@platform-modules/billing/subscriptions`. A `SubscriptionProvider` port analogous to `PaymentProvider`:
```
interface SubscriptionProvider {
  readonly provider: string
  createSubscription(req: CreateSubReq): Promise<CreateSubResult>   // → approval URL / id
  cancelSubscription(req: CancelSubReq): Promise<void>
  getSubscription(id: string): Promise<SubStatus>
}
```
- Recurring charges reuse the existing `ingestWebhook`/`parseWebhook` money-seam — the subscriptions capability adds only lifecycle (create/cancel/status), not a parallel webhook path. **Verified settlement contract:** `parseWebhook` maps a recurring `PAYMENT.SALE.COMPLETED` → `ProviderEvent kind:'settlement'` with a **stable `chargeKey = <subscription_id>:<period>`**; the billing module's `confirmSettlement` posts the idempotent money-received entry itself (`IngestWebhookDeps` has **no `IntentStore`** — no app-initiated intent required). The host `dispatch` performs the credit-grant + invoice **only**, idempotent on `chargeKey/period` (never on `eventId`), and must **not** re-post money (the module already did). Lifecycle events (`ACTIVATED`/`CANCELLED`) extend `ProviderEvent` with a `subscription` kind (or map to `other` + a typed decoder in the paypal adapter).
- Idempotent create keyed on `(account, plugin)`.

### 12.3 `billing/paypal` adapter (no platform design exists — this defines it)
`PaypalProvider implements PaymentProvider` (+ `SubscriptionProvider`): `charge`/`refund`/`parseWebhook` mapping PayPal Orders/Captures/refunds → `ChargeResult`/`RefundResult`/`ProviderEvent`; subscription lifecycle via PayPal Subscriptions API. `PaypalCreds { clientId; clientSecret; webhookId }`; webhook verification via PayPal `transmission_id`/`sig`/`cert_url`; `emitsInvoiceOnCharge = false`.

### 12.4 `invoicing` module + `morning` adapter (designed in `docs/specs/2026-06-19-invoicing-graduation-boundaries.md` — build it)
```
issueInvoice(provider: InvoiceProvider, credential, spec: DocumentSpec)
  : Promise<{ ok: true; result: InvoiceResult } | { ok: false; error: InvoiceError }>

interface InvoiceProvider { kind; credentialsSchema; validateCredentials; createInvoice(spec, credential); voidInvoice?; getDocumentPdfUrl? }
```
- `DocumentSpec` = zero commerce ids; BigInt amounts + explicit currency; `docType ∈ invoice|receipt|invoice_receipt|credit_note` (**credit-note is a docType**); `idempotencyKey` UNIQUE → M3 no-double-issue (claim `ON CONFLICT DO NOTHING RETURNING`, return stored result if already issued, never re-call provider).
- `MorningProvider`: creds `{ apiUser; apiPass; companyId }` (in request **body**, never URL/logs), `create_doc` → `{ documentId, documentNumber, documentUrl }`. Typed `InvoiceError` (`PROVIDER_REJECTED|CREDENTIAL_INVALID|PROVIDER_UNAVAILABLE`).

### 12.5 `tax` taxability / place-of-supply seam (module is already IL-first; add the decision layer)
```
resolveTaxability(input: { supplier: Jurisdiction; customer: Jurisdiction; supplyType: SupplyType })
  : { treatment: 'standard' | 'zero_rated' | 'exempt'; reason: string }
```
- Sits **above** the existing `resolveVatRate`/`applyVat`/`extractVat` (agorot BigInt, real `IL_VAT_SCHEDULE`). `standard` → resolve IL rate; `zero_rated` (export) → rate 0. This decision layer does not exist yet; the rate engine does.
- `exempt` is reserved for future Israeli-exemption-category / EU-OSS work (§13) — no v1 rule produces it; v1 acceptance requires only an exhaustiveness (never-check) test over the full union, not a fabricated exempt business rule.

### 12.6 `auth/oauth-provider` (designed/opt-in; this enumerates the seam)
```
authorize(req: { clientId; redirectUri; scope; state; codeChallenge; codeChallengeMethod: 'S256' }, principal): Promise<{ code } | ConsentRequired>
exchangeToken(req: { grantType: 'authorization_code'|'refresh_token'; code?; codeVerifier?; refreshToken?; clientId }): Promise<{ accessToken; refreshToken?; expiresIn; tokenType: 'Bearer' }>
registerClient(input: { name; redirectUris: string[]; scopes: string[]; confidential: boolean }): Promise<{ clientId; clientSecret? }>
getClient(clientId): Promise<OAuthClient | null>
```
- First-party pre-registered clients (one per plugin). PKCE S256; one-time short-TTL codes; reuse `auth` session/token + `util/tokens` (`generateOpaqueToken`/`hashToken`); DB via `db` adapter. Seat check runs inside `authorize`.

### 12.7 `auth/api-keys` (generalize the built `service-token`)
Long-lived, opaque, hashed, **scoped**, independently revocable PAT: header `Authorization: Bearer <prefix>.<secret>` → hash-lookup → scope/account/site resolve (adopt the platform Bearer/hashed-prefix convention, **not** `X-API-Key`). Built on `issueServiceToken`/`resolveServiceToken`; adds named/scoped/rotatable/last-used over a parallel table; standalone acceptance (never overloads `verifySession`).

---

## 13. Architecture Decisions (self-review Phase 2)

Modules scored by deletion / single-adapter / seam-depth tests:

- **`billing` subscriptions capability — kept, deep.** Deleting it scatters PayPal subscription lifecycle into the app; it hides provider recurring semantics behind a stable port. Second adapter is plausible (Stripe/Sumit recurring) → earns the seam.
- **`invoicing` + `InvoiceProvider` — kept, deep.** Two real adapters already contemplated (morning, sumit); hides provider document APIs. Not decorative.
- **`tax` taxability seam — kept, medium.** Small but hides a real jurisdiction-decision that will grow (EU-OSS later); separated from the rate engine cleanly.
- **`oauth-provider` — kept, deep.** Standard-protocol boundary; internals (code store, PKCE) fully replaceable behind `authorize`/`exchangeToken`.
- **`entitlements` vs `ledger` — kept distinct, both deep.** Entitlement (has-plugin, boolean/tier) and usage (credit balance) are different questions with different lifecycles; collapsing them couples subscription state to metering. Retained separation.
- **`api-keys` vs `oauth-provider` — kept distinct.** Different credential lifecycles (machine PAT vs delegated authz-code); the connect-flow uses oauth, power-user path uses api-keys. Not merged.
- **Collapse considered & rejected:** a unified "access" module folding entitlements+tenancy+ledger — rejected: three independent axes (account-plugin / member-action / usage) with independent change rates; folding produces an all-shallow god-module.
- **Per-plugin isolation vs shared pool — isolation kept** per locked product decision (translation credits meaningless to forum). Only Account is shared.

---

## 14. Open (non-blocking) items
- Confirm Morning issues acceptable documents for any **non-IL** customers (Morning is IL-centric). Israeli IL→IL + export-0% is fully covered; broader jurisdictions may need a second `InvoiceProvider` later.
- Over-limit **policy** = hard block (derived, locked); **credit lifecycle** = monthly reset (locked). Non-credit metering units + bundles = Spec 2 / future, schema-allowed only.
