# `@platform-modules/affiliate` — Extraction Design

> **Audience: AI coding agents first.** Optimize for activation, not prose. Do NOT prettify into narrative.
> **Status:** DESIGN (brainstorm output). Promotes the WATCH disposition in `2026-06-13-affiliate-entitlements-invoicing-analytics-tax-module-boundaries.md` §1 to **BUILD** under the forward-need expansion mandate (CLAUDE.md north-star + §3 forward-need rule). This doc is the seam contract; the build plan derives from it.
> **Canonical law (read, do not re-inline):** CLAUDE.md §2 (ladder) · §3 (governance/R5) · §4 (seam checklist) · §5 (runtime) · `docs/standards/coding-standard.md` §4/§5.1/R1–R4 · `docs/specs/2026-06-13-ledger-module-boundaries.md` (the dep).

---

## BLUF — what, why, the three decisions

**What:** Extract `@platform-modules/affiliate` (L3 composite) from multideal's `apps/web/src/server/referrals/`, **and migrate multideal onto it**.

**Why (forward-need, not donor-count):** Referral/affiliate is a standard SaaS/commerce capability with very-high predicted cross-class future demand. Donor count is ×1 (multideal only — Press.zone wallet and trance payment-splits are *different concepts*), so this build is justified by forward-need, which **waives donor-count but NOT convergence/abstraction-strength**. The whole design therefore freezes the contract to the **ecosystem-standard referral shape**, never to multideal's tier quirks.

**Three decisions locked (brainstorm 2026-06-17):**
1. **Affiliate first** (invoicing is a separate later spec — divergent ×2 donors + Pattern-A invoice-on-charge=billing-config).
2. **Capability altitude = generic engine + host-supplied policy** (the ×1 over-fit guard).
3. **Full extract + migrate multideal** — gated behind a parity harness + `security-guard` pass before any production swap (R4/§5.1: never swap a hardened money path on faith).

**Layer / deps:** L3. Hard deps `@platform-modules/db` (shipped) + `@platform-modules/ledger` (shipped). No host/framework/provider imposed (north-star agnosticism).

---

## 1. Verified donor surface (census 2026-06-17, spot-verified)

> **Provenance + corrections.** Mapped by Explore census, then load-bearing facts spot-verified directly against disk (per `verify-census-before-enshrining`). **Two stale claims corrected:** the prior boundary-spec/report said **"19/20+ fraud adapters"** — real count is **13** (`ls apps/web/src/server/referrals/fraud/adapters/`). The Explore census **wrongly** said "no PGLite oracle exists"; it **does** (`tests/integration/referrals/fraud/helpers/pglite-db.ts`). All file:line refs below are donor (`/home/user/Projects/multideal/apps/web/src/`).

**Core (`server/referrals/`):**
- `attribution.ts` — `resolveRefCode(db, code)` · `isSelfReferral(db,{referrerUserId,refereeUserId})` · `bindReferralOnSignup(db, BindReferralInput)` (`BindReferralInput`={refereeUserId,linkId,clickedAt?,refereeEmail?,refereePhone?,visitorId?,ipHash?,cfBotScore?}).
- `commission.ts` — `resolveAffiliateTierPct(monthlySales, settings)` (tier locked at accrual, 30-day rolling) · `computeAffiliateCommission(amountPaidAgorot, pct)` (`floor(amt·min(pct,10)/100)`) · `computeReferralCommission(amountPaidAgorot, settings)` · `computePlatformNet(amountPaidAgorot, feePct=10)` · `isSelfVendorPurchase(...)`.
- `payout-debit.ts` — `debitPayoutInTx(tx, userId, payoutId, amountAgorot) → ledgerEntryId` (**TOCTOU-safe `FOR UPDATE` on walletBalances; guards `withdrawableAgorot ≥ amt AND maturedAgorot ≥ amt`; throws `InsufficientBalanceError`**) · `ensurePayoutLedgerDebit` · `verifyPayoutReadyForSettlement` · `restorePayoutDebitInTx` (clawback/restore).
- Also: `maturation.ts`, `auto-suspend.ts`, `settings.ts` (`ReferralSettings`: tier1/2/3Pct, tier2/3MinSales, referralPct, windows), `service.ts`, `admin-actions.ts`, `analytics-rollup.ts`, `welcome.ts`, `graph-scan.ts`.

**Fraud (`server/referrals/fraud/`):**
- `types.ts` — `type DecisionPoint = 'CLICK'|'SIGNUP'|'EARN'|'WITHDRAW'` (stored lowercase) · `interface FraudAdapter<Ctx>{ key; points: DecisionPoint[]; evaluate(ctx,cfg): Promise<FraudSignal|null> }` · context types `ClickCtx`/`SignupCtx`/`EarnCtx`/`WithdrawCtx`.
- `registry.ts` — `runFraudPipeline<Ctx>(RunPipelineInput)` (runs enabled adapters in parallel; **EARN fail-closed: adapter failure → hold signal**; returns max-severity action) · `registerAdapter` · `getAdaptersForPoint`. `RunPipelineInput`={point,ctx,userId,referralId?,fraudConfig:Record<string,AdapterConfig>,db,persistEvents?=true}.
- **13 adapters** (`fraud/adapters/`): ip-reputation, ip-cluster, device-fingerprint, email-canonical, email-disposable, email-catchall-domain, referral-graph, payment-instrument, phone-linetype, phone-required-earn, bot-signup, velocity-conversion, identity-ring.

**Affiliate-link abuse (`server/referrals/security/`, SEPARATE from fraud — 10 files):** index, cloaking, velocity, geo, brand-keyword, coupon-poaching, cookie-stuffing, fingerprint, referer-eval.

**DB tables (`schema.ts`):**
- **Affiliate-owned (module exports as `pgTable`, Axis B):** `referralLinks` (2787) · `referrals` (2804, status enum pending|qualified|quarantined|…) · `affiliatePayouts` (3454, Stripe transfer/payout ids + idempotencyKey + ledgerEntryId, all UNIQUE) · `affiliateEnrollments` (referenced by payouts.enrollmentId) · **`creditLedger`** (2843; bigint agorot, `credit_ledger_idem_uq(entryType,sourceType,sourceId)`, `resolvedPct`/`matureAt`/`withdrawableAt`) · **`walletBalances`** (2868; 5-col maturity wallet: balance/pending/matured/withdrawable/lifetimeEarned — payout debit guards the `matured`+`withdrawable` buckets).
- **⚠ CORRECTION (spec-first kickback 2026-06-17, verified vs the real `@platform-modules/ledger` on disk):** the prior draft mislabeled `creditLedger` + `walletBalances` as *ledger-owned* — WRONG, and it contradicted ADR-1. Verified: ledger exports only GENERIC primitives — `appendEntry`/`debit`/`debitWithRead`/`getBalance`, an `InsufficientBalanceError` class, a generic single-balance `walletBalances` (`ownerId`/`balance`/`updatedAt`) + `ledgerEntries` (`id`/`delta`/`reason`/`ref`/`idempotencyKey`/`createdAt`). It carries NO maturity buckets and NO `(entryType,sourceType,sourceId)` idempotency. multideal's maturity-aware credit-ledger + 5-col wallet are RICHER → they are **affiliate-owned projection tables**, renamed on extraction (`affiliate_credit_ledger`, `affiliate_wallet_balances`) to avoid colliding with ledger's `wallet_balances`/`ledger_entries`. This *reinforces* ADR-1. Affiliate consumes ledger's `debitWithRead` (parameterizable over `input.lock.table` — verified signature) as the atomic FOR-UPDATE+idempotent-insert primitive over affiliate's OWN wallet table; ledger's generic wallet is unused by affiliate.

**External payout (`payments/connect/affiliate-payout.ts:37`):** `runAffiliatePayout(stripe, db, payoutDbId, {amountAgorot,stripeAccountId,enrollmentId,stripePayoutsEnabled}) → {ok:true,transferId,payoutId} | {ok:false,error,code}`. Two-step, idempotency-keyed: `stripe.transfers.create` (platform→affiliate Express) then `stripe.payouts.create` (Express→bank). Failure → status=failed + `restorePayoutDebitInTx`.

**Parity oracle (EXISTS — port it):** `tests/integration/referrals/` PGLite suite via `helpers/pglite-db.ts` + `helpers/fixtures.ts`: adapters-db, decision-points, fraud-admin, ledger-clawback, maturation-sweep, resolve-autosuspend, last-click-earn. Unit: clawback-logic, guardrails, maturation-logic, analytics-rollup, referral-earn-handler, reconcile-wallet, ledger-wallet-consistency-repair.

---

## 2. Capability altitude — what is module, what is host

> **Rule (R5 + generic engine + host policy):** module owns the **generic referral engine + the audit-hardened money primitives**; the host supplies **policy values, the external payout executor, and DB tables**. Freeze the *standard* contract; never the donor's tier numbers, provider, or schema names.

| Capability | Disposition | Where |
|---|---|---|
| Attribution (link resolve, self-referral guard, bind-on-signup) | **module core** | `@platform-modules/affiliate` |
| Commission **engine** (compute given a policy) | **module core** | takes host-supplied `CommissionPolicy` fn — NOT multideal's tier %s |
| Accrual / maturity state machine (pending→matured→withdrawable) | **module core** | module owns the maturity-wallet projection (see §3 ADR-1) |
| Payout-debit (`FOR UPDATE` TOCTOU two-bucket guard, restore/clawback) | **module core** | consumes `@platform-modules/ledger` `debitWithRead` + `appendEntry` |
| Fraud pipeline (engine + 13 adapters) | **module `/fraud` subpath** | extractable to `@platform-modules/risk` on a 2nd non-affiliate consumer |
| Affiliate-link abuse (cloaking/coupon-poaching/cookie-stuffing/…) | **module `/security` subpath — OPT-IN, weakest convergence** | see ADR-3 |
| Commission policy *values* (tier %s, windows, referralPct) | **host config** | `CommissionPolicy` the host passes in (shape = part of contract) |
| External payout executor (Stripe Connect transfer+payout) | **host adapter behind `PayoutExecutor` seam** | provider SDK is host's; module never imports `stripe` |
| `referral_links`/`referrals`/`affiliate_payouts`/`enrollments` tables | **module exports `pgTable` schema, ships NO migration (Axis B)** | host runs as its own migration |
| `affiliate_credit_ledger` + `affiliate_wallet_balances` (maturity projection) | **affiliate-owned `pgTable` (Axis B)** | renamed to avoid colliding with ledger's generic `wallet_balances`/`ledger_entries`; see §1 correction + ADR-1 |
| ledger's generic `appendEntry`/`debit`/`debitWithRead` primitives | **`@platform-modules/ledger`-owned** | affiliate consumes them over its OWN tables; ledger's generic wallet is unused |
| analytics-rollup, admin-actions, welcome | **host** | domain BI = host SQL (analytics DISSOLVED); admin UI = host wiring |

---

## 3. The seam contract (typed; no host/provider import)

All public exports are frozen semver-grade contracts (north-star). Typed inputs/outputs, **typed errors (no bare throw)**, **structural type-guards not `instanceof`** (cross-package identity — the mail-module lesson).

**Commission engine (the convergence core — host policy, not donor tiers):**
```ts
// Host supplies the policy; module computes. Flat/tiered/per-product all expressible.
export interface CommissionPolicy {
  // returns commission in minor units given the settled sale + context
  resolve(input: { amountPaidMinor: bigint; context: CommissionContext }): bigint;
}
export interface CommissionContext { referrerId: string; refereeId: string;
  productId?: string; rollingSalesCount?: number; /* host fills what its policy needs */ }
// multideal's tier model becomes ONE CommissionPolicy implementation in the host, not the module's shape.
export function computeCommission(policy: CommissionPolicy, input: …): bigint;
```

**Attribution:** `resolveRefCode`, `isSelfReferral`, `bindReferralOnSignup` — ported signatures (§1), `db` typed as `@platform-modules/db` `Database`/`Querier`.

**Accrual / maturity:** module owns a maturity-wallet projection (5-column wallet — balance/pending/matured/withdrawable/lifetimeEarned; payout guards the matured+withdrawable buckets) updated from ledger entries; `accrueCommission(...)` (insert-ledger-first, idempotent) → `matureCredits(now)` (hold-period sweep) → withdrawable.

**Payout (money-out, gated):**
```ts
export interface PayoutExecutor { // host implements with its provider (Stripe Connect, …)
  execute(req: { payoutId: string; amountMinor: bigint; destination: PayoutDestination })
    : Promise<{ ok: true; externalRefs: Record<string,string> } | { ok: false; code: string; error: string }>;
}
// module owns: debitPayoutInTx (FOR UPDATE two-bucket guard) → call executor → on fail restorePayoutDebitInTx.
```

**Fraud (`/fraud` subpath):** `FraudAdapter<Ctx>`, `runFraudPipeline`, `registerAdapter`/`getAdaptersForPoint`, `DecisionPoint`, the 13 adapters — ported verbatim (preserve hardened logic; EARN stays fail-closed).

**DB (Axis B):** export `referralLinksTable`, `referralsTable`, `affiliatePayoutsTable`, `affiliateEnrollmentsTable`, **`affiliateCreditLedgerTable`, `affiliateWalletBalancesTable`** (the maturity projection, renamed to avoid colliding with ledger's generic `wallet_balances`/`ledger_entries`) as drizzle `pgTable`; ship NO migration; host generates/owns it. Ledger contributes the atomic primitive (`debitWithRead`/`appendEntry`), **not** the maturity tables.

---

## 4. Data flow

`CLICK` (resolveRefCode + fraud CLICK gate) → `SIGNUP` (bindReferralOnSignup + fraud SIGNUP gate, self-referral guard) → settled purchase → `EARN` (fraud EARN gate **fail-closed**; `computeCommission(policy,…)`; `accrueCommission` insert-ledger-first idempotent → `pending`) → maturity sweep (`matureCredits`: pending→matured→withdrawable after hold) → `WITHDRAW` request (fraud WITHDRAW gate) → payout: `ledger.debitWithRead` (FOR UPDATE) → module two-bucket guard → `PayoutExecutor.execute` → success=paid / failure=`restorePayoutDebitInTx`.

---

## 5. Error handling & money-path safety (hard floor — never on the chopping block)

- **Typed errors only**, exported with **structural type-guards** (`isInsufficientBalanceError`, `isFraudHoldError`) — never cross-package `instanceof`.
- **EARN fraud gate fail-closed** — adapter failure → hold, never silent allow (preserve donor behavior, R4).
- **Idempotency** preserved on every money mutation: ledger `(entryType,sourceType,sourceId)` unique; payout `idempotencyKey`/`stripeTransferId`/`stripePayoutId` unique. Re-running EARN/payout must not double-credit/double-pay.
- **TOCTOU**: payout debit keeps the `FOR UPDATE` row lock + two-bucket guard verbatim (R4 audit-hardened — preserve, do not "clean up").
- **No bundled provider/host** — `stripe` stays a host dep behind `PayoutExecutor`; core is web-standard, bundle-check proves SDK-free.

---

## 6. Testing — swap-survival is the verdict

- **Port the existing PGLite oracle** (`helpers/pglite-db.ts` + fixtures) into the package as co-located `*.int.test.ts`; it is the swap-survival harness, reused as the per-step guard during the staged port (Approach A).
- **One behavioral test per export** (fallback + edge, not the happy line): commission policy edge (cap at 10%, rounding floor), maturity boundary, double-spend/idempotency, EARN fail-closed, payout restore-on-failure.
- **Swap-survival parity (the load-bearing assertion):** module output ≡ multideal's deleted host output on multideal's real inputs — commission amounts (bit-identical at agorot magnitudes), tier resolution, maturity buckets, fraud verdicts per DecisionPoint.
- **Before the production swap:** full parity harness green + a `security-guard` adversarial pass over the payout + fraud paths (money-out + fail-open risk). multideal has no prod env (only `dev.multi.deal` preview) → the oracle + harness ARE the guard; live money-send can't be smoke-tested.

---

## 7. Extraction approach — A: parity-first, staged (recommended)

1. **Scaffold** `packages/affiliate` (turbo gen), deps db+ledger, peer externals only.
2. **Port `/fraud` first** (most self-contained, best oracle) 1:1 + its PGLite tests → prove green.
3. **Port core** attribution + commission + accrual + maturity-wallet 1:1 + oracle → green; **then** refactor commission to the `CommissionPolicy` seam (multideal's tier model becomes a host policy impl) — re-prove parity.
4. **Port payout LAST** (money-out, never pipelined past its gate): (a) port `debitPayoutInTx`/`restorePayoutDebitInTx` **verbatim** (donor's own FOR-UPDATE on the affiliate wallet + donor `InsufficientBalanceError`) → prove parity on the oracle; (b) **then** seam-refactor: swap the FOR-UPDATE primitive to `ledger.debitWithRead` parameterized over `affiliate_wallet_balances` (two-bucket guard in `fn`) + introduce `PayoutExecutor` seam (host keeps the Stripe Connect impl) → re-prove byte-parity. **Keep the ledger swap only if parity holds; else retain the verbatim debit (R4).**
5. **`/security` subpath** — opt-in, deferred decision (ADR-3).
6. **Migrate multideal** behind parity harness + security-guard; delete host code replaced; record rollback (revert commits, no DB down-migration if Axis-B tables unchanged).

Money/auth gate: payout + fraud waves never pipelined; each proven before next (platform-orchestrator policy).

---

## Architecture Decisions

- **ADR-1 — affiliate owns the maturity-wallet projection; does NOT force `@platform-modules/ledger/vesting`.** Ledger's vesting/lifetime subpaths are specced-but-stubbed; building them now = speculative (YAGNI) and freezing a ×1 contract. Affiliate consumes ledger for append-only entries + idempotency + `debitWithRead` (FOR UPDATE lock+read) and applies its own two-bucket (withdrawable/matured) guard + maturity sweep. Promote maturity to `ledger/vesting` only on a 2nd consumer. *Rejected alt:* build `ledger/vesting` first (scope creep + premature contract freeze). **VERIFIED 2026-06-17:** `debitWithRead(tx, {lock:{table,where}, key, delta, reason, ref}, fn)` locks `input.lock.table` (ANY table) FOR UPDATE, runs the caller `fn` for sufficiency/split on the locked rows, then idempotent `appendEntry` FIRST + the caller's `apply` (a throwing `fn` leaves no partial state). So affiliate parameterizes it over its OWN `affiliate_wallet_balances` and puts the two-bucket guard in `fn`. The ledger-primitive adoption is a **SEAM REFACTOR applied AFTER the verbatim port proves parity** (Approach A): if the swap can't preserve byte-parity on the oracle, the donor's hardened FOR-UPDATE debit stays (R4 — never break a money path to consume a sibling). Affiliate owns its own `InsufficientBalanceError` + `isInsufficientBalanceError` structural guard (donor already does) — never rely on cross-package `instanceof` against ledger's class.
- **ADR-2 — fraud stays inside `@platform-modules/affiliate/fraud`, not a standalone `@platform-modules/risk`.** ×1 consumer (affiliate). The engine shape (`runFraudPipeline` + adapter registry) is generic and pre-mapped for extraction; promote to `/risk` on a real 2nd consumer (payment-fraud/signup-fraud). *Deletion test:* deleting `/fraud` scatters 13 adapters + fail-closed EARN logic into the host → earns its boundary.
- **ADR-3 — `/security` (affiliate-link abuse) is OPT-IN and the weakest-convergence piece; flagged for advisor.** cloaking/coupon-poaching/cookie-stuffing/brand-keyword are e-commerce-affiliate-specific (×1, possibly multideal-idiosyncratic). Options: (a) ship as opt-in `/security` subpath (R5 harvest-don't-discard), or (b) leave host-resident until a 2nd consumer proves convergence. Lean (a) as opt-in subpath with no core coupling; confirm at advisor.
- **ADR-4 — commission model is a host-supplied `CommissionPolicy`, not the module's shape (the ×1 over-fit guard).** multideal's tiered `resolveAffiliateTierPct` becomes one policy implementation in the host; the module's frozen contract is `CommissionPolicy.resolve(...)`. *Rejected alt:* port the tier model as the module's commission API (freezes ×1 quirks).
- **ADR-5 — PayoutExecutor seam; module never imports a payment SDK.** Stripe Connect (transfer+payout) is a host adapter; module owns only the ledger-debit + restore + idempotency. Keeps core host-agnostic (north-star §5). *Single-adapter test:* one impl exists today (Stripe Connect), but the seam is **not** premature — provider-agnosticism is a *mandated* platform axis (CLAUDE.md §5: never bundle a provider) and payout has obvious real second adapters (PayPal Payouts, Wise, bank ACH, manual/CSV). The seam earns its abstraction by mandate + real swap axis, not speculation.
- **Rejected approaches:** B clean-room (violates R4 preserve-hardened, abandons oracle) · C big-bang swap (violates money-path gate + R4 no-faith-swap).

## Open questions — provisional resolutions (advisor gate unavailable 2026-06-17; re-review when up)
1. **ADR-3 (`/security` altitude) → RESOLVED: opt-in `/security` subpath, deferred to the last build wave, zero core coupling.** cloaking / cookie-stuffing / coupon-poaching / brand-keyword are *standard, well-documented affiliate-marketing abuse classes* (generic, not multideal-idiosyncratic) → R5 says harvest mature generic capability as importable, don't discard to host. Opt-in subpath keeps the core lean (YAGNI on the core surface) while not dumping mature capability. Revisit promoting to a standalone abuse/risk module on a 2nd consumer.
2. **ADR-1 (maturity-wallet home) → RESOLVED: affiliate owns the maturity-wallet projection; do NOT build `ledger/vesting` now.** Building ledger's stubbed vesting/lifetime to host this = speculative (YAGNI) + freezes a ×1 ledger contract before a non-donor confirms it (north-star). Affiliate consumes ledger for append-only entries + idempotency + `debitWithRead`; promote maturity to `ledger/vesting` (Pattern B) only on a 2nd consumer that needs generic vesting.
3. **ADR-6 (migrate with no prod env) → ACCEPTED RISK (user decision, informed).** The user chose full-extract+migrate knowing multideal has only `dev.multi.deal` (no prod, no live money-send smoke). Gate = ported PGLite parity harness (byte-identical commission/maturity/fraud/payout-restore) + a `security-guard` adversarial pass over payout + fraud, BOTH green before the swap; payout wave never pipelined. This is the strongest gate achievable without a prod env; live-send smoke is deferred to whenever multideal provisions prod (mirrors the mail-embed handoff). Advisor to confirm the gate is sufficient when available.
