---
name: md-afill
description: Use when working on Multideal affiliate or referral program logic, config, UI, admin, tracking, payouts, or related tests.
---

# md-afill — Affiliate & Referral Program Skill

Handles all affiliate program + referral program logic, config, UI, and admin in Multideal.

## When to invoke

Invoke **before any code** touching:
- `server/referrals/**`
- `server/affiliate/**`
- `pages/api/referrals/**`
- `pages/api/affiliate/**`
- `features/affiliate/**`
- `features/referral/**`
- `lib/i18n/namespaces/*/affiliate_landing.ts`
- `lib/i18n/namespaces/*/referral*.ts`
- `lib/i18n/namespaces/*/affiliate.ts`
- Admin pages: `pages/admin/affiliate*`, `pages/admin/referral*`
- DB tables: `referral_settings`, `referral_links`, `referral_events`, `affiliate_enrollments`, `affiliate_payouts`, `credit_ledger`, `wallet_balances`
- Any commission calculation, payout trigger, tier logic, or KYC enrollment flow

Cross-layer changes → invoke **md-afill + md-ui-developer + md-server-dev** together.

---

## Program Architecture

Two distinct programs — different eligibility, payout type, commission model.

### 1. Referral Program (P2P)

| Property | Value |
|----------|-------|
| Eligibility | Any registered user — no enrollment, no KYC |
| Link | Auto-generated on first use (`referral_links.kind='referral'`) |
| Commission | Flat % of platform net — DB-configurable (`referral_pct`, default 2%) |
| Tiers | None |
| Payout type | Store credit only (`credit_ledger` entry_type='referral_reward') |
| Hold period | Configurable (`hold_days`, default 30) |
| Tracking | Cookie `md_ref`, `cookie_days` from settings |

Referral = friend-get-friend. Frictionless, instant credit, no paperwork.

### 2. Affiliate Program (Professional)

| Property | Value |
|----------|-------|
| Eligibility | Enrollment + KYC via `/api/referrals/enroll` |
| Link | Issued after approval (`referral_links.kind='affiliate'`) |
| Commission | Tiered % of **sale** — all thresholds + rates DB-configurable |
| Tiers | T1 (default 3%), T2 (default 4%), T3 (default 5%) — **locked at accrual**, rolling 30-day window |
| Tier thresholds | T2: 20+ purchases/30 days, T3: 50+ purchases/30 days (DB-configurable) |
| Payout type | Bank transfer — **never mention "Stripe" in marketing copy** |
| KYC | Required before first withdrawal (`kyc_verifications` + `stripe_payouts_enabled`) |
| Hold period | Same `hold_days` as referral |

Affiliate = professional publisher/creator. Bank transfer. Tiered locked-at-accrual commissions.

---

## Commission Calculation

**All rates from DB `referral_settings` — zero hardcoded percentages.**

```
PLATFORM_FEE_PCT = 10  (SUMIT takes 10% of every sale)

// NEW convention (as of 2026-05-29 redesign):
// tier*_pct and referral_pct store % of SALE directly (WYSIWYG).
// Admin types "3", user sees "3%", formula uses 3 directly.
// DB CHECK constraints: 1 ≤ value ≤ 10.

// Referral (store credit):
referralCommission = floor(amountPaidAgorot × referralPct / 100)
  // referralPct = 2 → floor(amountPaid × 2/100)

// Affiliate (bank transfer):
resolvedTierPct = resolveAffiliateTierPct(monthlySales, settings)  // 3, 4, or 5
affiliateCommission = floor(amountPaidAgorot × resolvedTierPct / 100)

// resolvedPct stored on credit_ledger row for audit trail.
```

**Tier assignment**: **locked at accrual**. Tier resolved once per sale using trailing-30-day purchase count AT THE MOMENT OF SALE. No recomputation at maturation. Maturation is a pure time-gate.

- `monthlySales` = COUNT of completed purchases via this affiliate's link in trailing 30 days (current purchase included — it's committed before `accrueAffiliateCommission` runs).
- Off-by-one: 20th purchase sees COUNT=20 → reaches T2 threshold immediately.

**Self-deal block (§M.10a)**: referrer === vendorOwnerUserId → affiliate commission = 0. Referee referral_reward still applies.

**Per-affiliate override**: `referral_links.commission_pct_override` (not null) = admin-set explicit rate. If set, skips tier system. Stored as % of platformNet (legacy); converted: `resolvedPct = floor(override × PLATFORM_FEE_PCT / 100)`.

> **CONVENTION GOTCHA**: `referral_settings.affiliate_pct` (legacy field) = % of platformNet, NOT % of sale. New `tier1_pct/tier2_pct/tier3_pct/referral_pct` = % of SALE. Never mix them.

Engine: `server/referrals/commission.ts`

---

## DB Tables (complete schema)

### `referral_settings` (single row, id=1)

| Column | Type | Default | Notes |
|--------|------|---------|-------|
| `id` | int PK | CHECK id=1 | singleton |
| `affiliate_pct` | int | 30 | LEGACY — % of platformNet. New tier fields replace this |
| `tier1_pct` | smallint | 3 | T1 affiliate % of SALE (CHECK 1–10). 3 = "3% of purchase amount" |
| `tier2_pct` | smallint | 4 | T2 affiliate % of SALE (CHECK 1–10) |
| `tier3_pct` | smallint | 5 | T3 affiliate % of SALE (CHECK 1–10) |
| `tier2_min_sales` | int | 20 | Trailing-30-day purchase count to reach T2 |
| `tier3_min_sales` | int | 50 | Trailing-30-day purchase count to reach T3 |
| `referral_pct` | smallint | 2 | Referral store credit % of SALE (CHECK 1–10) |
| `affiliate_window_days` | int | 90 | Attribution window |
| `affiliate_max_orders` | int | 50 | Max orders per referral |
| `cookie_days` | int | 14 | md_ref cookie lifetime |
| `reward_agorot` | int | 2000 | Referee welcome discount (store credit) |
| `referee_discount_agorot` | int | 2000 | Same as above |
| `hold_days` | int | 30 | Maturation hold period |
| `withdrawal_min_agorot` | int | 30000 | Min withdrawal (₪300) |
| `auto_approve_lifetime_agorot` | int | 50000 | Auto-approve threshold |
| `brand_keyword_blocklist` | text[] | ['multideal',...] | Auto-suspend triggers |
| `tos_version` | text | 'v1-...' | |

### `referral_links`
`id`, `owner_user_id`, `code` (UNIQUE), `kind` ('referral'|'affiliate'), `commission_pct_override`, `commission_window_days_override`, `active`, `created_at`

### `referrals`
`id`, `referrer_user_id`, `referee_user_id` (UNIQUE), `link_id`, `kind`, `status` ('pending'|'qualified'|'rejected'), `qualified_at`, `commission_pct`, `commission_window_ends_at`, `commission_orders_remaining`, `referee_promo_code_id`, `created_at`

### `credit_ledger`
`id`, `user_id`, `amount_agorot` (signed bigint), `entry_type` ('referral_reward'|'affiliate_commission'|'redemption'|'refund_clawback'|'adjustment'), `source_type`, `source_id`, `referral_id`, `memo`, `resolved_pct` (smallint, nullable — % of sale at accrual time, for audit), `mature_at`, `swept_at`, `created_at`

Idempotent on `(entry_type, source_type, source_id)`. `resolved_pct` populated for 'affiliate_commission' and 'referral_reward' entries.

### `wallet_balances`
`user_id` (PK), `balance_agorot` (legacy), `lifetime_earned_agorot`, `pending_agorot`, `matured_agorot`, `updated_at`

### `affiliate_enrollments`
`id`, `user_id` (UNIQUE), `status` ('active'|'suspended'|'revoked'), `commission_pct` (per-affiliate override), `enrolled_at`, `suspended_at`, `suspended_reason`, `notes`, `stripe_account_id` (UNIQUE), `stripe_status` ('none'|'pending'|'restricted'|'enabled'|'rejected'), `stripe_payouts_enabled`, `stripe_updated_at`, `tos_accepted_at`, `tos_version`

### `affiliate_payouts`
`id`, `user_id`, `enrollment_id`, `amount_agorot` (CHECK>0), `status` ('requested'|'approved'|'processing'|'paid'|'failed'|'cancelled'), `requested_at`, `approved_at`, `approved_by_user_id`, `processing_at`, `paid_at`, `failed_at`, `failure_reason`, `stripe_transfer_id` (UNIQUE), `stripe_payout_id` (UNIQUE), `idempotency_key` (UNIQUE), `ledger_entry_id` (UNIQUE)

Partial unique index `affiliate_payouts_one_active_idx` prevents concurrent in-flight payouts.

### `kyc_verifications`
`id`, `user_id` (UNIQUE), `status` ('none'|'pending'|'verified'|'rejected'), `submitted_at`, `verified_at`

### `affiliate_admin_actions`
`id`, `ts`, `admin_user_id`, `target_user_id`, `action`, `payload` (JSONB), `reason`

### `referral_link_stats_daily`
`link_id`, `day`, `clicks`, `clicks_suspicious`, `signups`, `purchases`, `commission_agorot`, `reward_agorot`

---

## Key Files

```
apps/web/src/
  server/referrals/
    commission.ts        # calculation engine (51 lines)
    settings.ts          # DB settings loader, getReferralSettings(db) (67 lines)
    service.ts           # money orchestration, idempotent ledger (large)
    ledger.ts            # credit_ledger append + wallet balance
    enrollment.ts        # affiliate enrollment (145 lines)
    attribution.ts       # resolveRefCode, bindReferralOnSignup
    maturation.ts        # hold period sweep, pending→matured (125 lines)
    clawback.ts          # refund clawback
    admin-actions.ts     # suspend/revoke/reinstate/set_pct + audit
    auto-suspend.ts      # brand keyword auto-suspend
    analytics-rollup.ts  # daily stats
    clicks-ae.ts         # Analytics Engine click tracking
    security/            # rate limiting + fraud detection
  pages/api/
    referrals/touch.ts       # first-touch cookie + AE click (public)
    referrals/link.ts        # mint P2P referral link
    referrals/enroll.ts      # affiliate enrollment
    referrals/me.ts          # user referral state
    referrals/withdraw.ts    # withdrawal request (KYC required, 245 lines)
    affiliate/stats.ts       # performance stats
    affiliate/activity.ts    # activity log
    affiliate/connect/onboard.ts  # Stripe Connect onboarding URL
    affiliate/connect/status.ts   # KYC status check
    admin/affiliates/index.ts     # list enrollments
    admin/affiliates/[id].ts      # single enrollment
    admin/affiliates/settings.ts  # GET/PATCH referral_settings
    admin/affiliates/payouts/[id].ts
    admin/referrals/adjust.ts     # manual ledger adjustment
    admin/referrals/affiliate.ts  # admin affiliate ops
  features/
    affiliate/
      types.ts                     # AffiliateConfig type + DEFAULT_AFFILIATE_CONFIG
      landing/                     # /affiliate-program page components
        Hero.tsx, Benefits.tsx, HowItWorks.tsx
        TrustStrip.tsx, TierTable.tsx, PayoutOptions.tsx
        FaqSection.tsx, EnrollModal.tsx, AffiliateLandingPage.tsx
    referral/landing/
      ReferralLandingPage.tsx      # /refer page component
    referrals/
      RefCapture.tsx, ReferralDashboard.tsx, ShareInvite.tsx
    admin-affiliates/
      AffiliateList.tsx, AffiliateSettingsEditor.tsx
  pages/
    affiliate-program/index.astro   # public landing (/affiliate-program)
    en/affiliate-program/index.astro
    refer/index.astro               # public referral landing (/refer)
    en/refer/index.astro
    affiliate/index.astro           # enrolled affiliate dashboard
    affiliate/connect/              # KYC flow
    admin/affiliates/index.astro
  pages/api/
    affiliate/config.ts            # GET — public live commission rates (no auth)
    affiliate/stats.ts, activity.ts, connect/onboard.ts, connect/status.ts
    admin/affiliates/settings.ts   # GET/PATCH referral_settings
  lib/i18n/namespaces/
    he/affiliate_landing.ts, affiliate.ts, referrals.ts, admin_affiliates.ts, referral_landing.ts
    en/affiliate_landing.ts, affiliate.ts, referrals.ts, admin_affiliates.ts, referral_landing.ts
  server/db/migrations/
    0068_referral_affiliate.sql     # core tables
    0070_affiliate_enrollments.sql  # enrollments
    0071_referral_settings.sql      # settings table
    0073_wallet_affiliate_tables.sql
    0074_affiliate_payouts_one_active.sql
    0076_affiliate_tiers.sql        # tier cols + referral_pct + resolved_pct
```

---

## Enrollment + KYC Flow

1. POST `/api/referrals/enroll` `{ tosAccepted, primaryChannel, channelUrl?, audienceSize?, notes? }`
2. `enrollAffiliate()` → creates `affiliate_enrollments` (status='active') + `referral_links` (kind='affiliate')
3. Returns `{ enrollmentId, referralLink, kycRequired: true, kycCompleted: false }`
4. Redirect to `/affiliate/connect` → POST `/api/affiliate/connect/onboard`
5. Stripe Express account created → returns Account Link URL
6. User completes KYC externally
7. Stripe webhook → `stripe_payouts_enabled = true`

KYC required before first withdrawal (not before earning).

---

## Withdrawal Flow

Preconditions (all checked server-side):
1. `kyc_verifications.status = 'verified'` → 409 KYC_REQUIRED
2. `affiliate_enrollments.status = 'active'` → 409 NOT_ENROLLED
3. `lifetime_earned >= withdrawal_min_agorot` → 409 BELOW_MIN_THRESHOLD
4. `amount >= withdrawal_min_agorot` → 409 BELOW_MIN_THRESHOLD
5. `amount <= wallet.matured_agorot` (FOR UPDATE lock) → 409 INSUFFICIENT_BALANCE

Auto-approve if `lifetime_earned < auto_approve_lifetime_agorot`, else queued for admin.

---

## Admin Dashboard Capabilities

`/admin/affiliates`:
- List all enrollments (status, commission %, join date)
- Suspend / revoke / reinstate affiliates
- Set per-affiliate `commission_pct` override
- Audit trail in `affiliate_admin_actions`

`/admin/affiliates/settings` (PATCH):
- All `referral_settings` fields editable
- Tier rates + thresholds
- Hold days, withdrawal min, cookie days
- Brand keyword blocklist

---

## Marketing Copy Rules

1. **Never mention "Stripe"** on `/affiliate-program` or any user-facing marketing copy.
2. Say "העברה בנקאית ישירה" / "direct bank transfer" — not the provider name.
3. **All displayed rates must be dynamic** — fetched from `/api/affiliate/config` endpoint at SSR time, never hardcoded in i18n strings.
4. Display rate = `tier*Pct` value directly (e.g., DB tier1_pct=3 → show "3%"). No conversion needed — WYSIWYG.
5. Use `{{placeholder}}` template slots in i18n strings for dynamic values; components replace at render time.
5. Referral page: emphasize "no signup", "instant credit", "any user".
6. Affiliate page: emphasize tiers, professional payouts, earnings growth potential.
7. Referral and affiliate = **separate pages** — `/affiliate-program` vs `/refer` (or `/invite`).

---

## Self-Learning Clause

**MANDATORY:** When you change any of the following, update this SKILL.md in the same commit:
- Commission formula or calculation logic (`commission.ts`)
- Tier structure, thresholds, or rate defaults
- Payout mechanics (hold period, min withdrawal, auto-approve threshold)
- DB schema for any affiliate/referral table
- KYC/enrollment flow steps
- Admin dashboard capabilities
- Marketing copy rules (especially Stripe mention rule)
- New API endpoints added to either program

Update the relevant section. Keep this file as single source of truth for all affiliate/referral program rules.

After updating SKILL.md, also update project memory:
`~/.claude/projects/-home-user-Projects-multideal/memory/project_affiliate_program_2026_05_28.md`

---

## Learned Rules

### cookieDays-vs-holdDays | fired:1 | 2026-05-29
`holdDays` (30d maturation hold) ≠ `cookieDays` (14d click-attribution cookie window). Passing holdDays to HowItWorks/TrustStrip shows wrong tracking-window copy to users.
Prevent: when wiring `AffiliateConfig` to UI, always include `cookieDays: s.cookieDays` as a distinct field; pass `config.cookieDays` to any component that renders "tracking window" or step2_body ({{cookieDays}} slot). Verify `AffiliateConfig` type has both fields before calling it done.

### trust-stat-no-hardcode | fired:1 | 2026-05-29
i18n key `trust_stat2_value: '30 ימים'` was hardcoded — wrong value (cookieDays default=14, not 30) and violates no-hardcoded rule. Any i18n key whose value is a DB-configured number must use `{{placeholder}}` or be removed and rendered dynamically from props.
Prevent: before shipping TrustStrip / stats sections, grep for hardcoded number values in `affiliate_landing.ts`/`referral_landing.ts`. Numbers that come from `referral_settings` must be dynamic.
