# Affiliate Manager — Plan & Phased Implementation

> **Phase 4d complete. All phases passing — 62 tests, 119 assertions. Package has zero dependency on any payment gateway, Cashier, or Vanilo — all commissions triggered only by package-owned events. Site-side bridge listeners wired in `EventServiceProvider`.**

---

## Package Identity

- **Location:** `packages/agencypulse/affiliate/`
- **Composer name:** `agencypulse/affiliate`
- **PHP namespace:** `AgencyPulse\Affiliate`
- **Wiring:** Main app's `composer.json` points to it via a `path` repository (Composer symlinks it).
- **Exit strategy:** When complete, push `packages/agencypulse/affiliate/` to its own git repo, publish to Packagist, swap the path entry for a version constraint — zero rework.

---

## Database Schema (all tables prefixed `aff_`)

| Table | Key columns |
|---|---|
| `aff_affiliates` | `id`, `user_id` (FK→users), `code` (unique slug), `status` (active/pending/suspended), `commission_type` (percent/flat), `commission_value`, `referred_by_affiliate_id` (nullable self-FK), `payout_instructions` (text, nullable), `stripe_account_id` (nullable), `stripe_onboarding_completed` (bool), `notes`, `timestamps` |
| `aff_clicks` | `id`, `affiliate_id`, `ip`, `user_agent`, `landing_url`, `referrer`, `converted_at` (nullable), `timestamps` |
| `aff_conversions` | `id`, `affiliate_id`, `click_id` (nullable FK), `order_id` (nullable), `amount` (gross sale), `commission` (calculated), `status` (pending/approved/rejected/paid), `source` (initial/renewal ← Phase 4d), `user_id` (nullable FK→users ← Phase 4d), `tier` (1/2 ← Phase 4c), `parent_conversion_id` (nullable FK→self ← Phase 4c), `timestamps` |
| `aff_payouts` | `id`, `affiliate_id`, `amount`, `method`, `reference`, `status` (pending/paid), `paid_at`, `notes`, `timestamps` |
| `aff_payout_items` | `id`, `payout_id`, `conversion_id` |

---

## Phase 1 — Package scaffold & DB foundation

**What gets built:**
- `packages/agencypulse/affiliate/` full directory tree
- Package `composer.json` and `AffiliateServiceProvider` (registers routes, migrations, views, config)
- `config/affiliate.php` — commission defaults, cookie lifetime, route prefix
- All 5 migrations
- Eloquent models: `Affiliate`, `Click`, `Conversion`, `Payout`, `PayoutItem` with relationships
- Wire into main app's `composer.json` repositories block; run `composer require agencypulse/affiliate:*`

**Validation / tests after this phase:**
- `composer dump-autoload` resolves all package classes without errors
- `php artisan migrate` runs cleanly, all 5 `aff_` tables present
- `php artisan tinker` can instantiate all 5 models and traverse relationships
- Unit tests: model relationships return correct Eloquent query builder types

---

## Phase 2 — Referral link generation & click tracking

**What gets built:**
- `AffiliateController@apply` — for Mode A & B: logged-in user applies or self-activates
- Admin approve / suspend endpoints (always present regardless of mode)
- `TrackClickMiddleware` — reads `?ref=CODE` on any request; behaviour varies by mode:
  - **Mode A/B:** only tracks clicks for existing `active` affiliates
  - **Mode C:** if no `aff_affiliates` row exists for the referenced user, creates one (`active`) then records the click — full lazy auto-enrolment
- Duplicate-click guard: same IP + affiliate within configurable window does not create a second row
- `ReferralLink` helper: `ReferralLink::for($affiliate)->url('/pricing')` returns full URL with `?ref=CODE`
- In Mode C, every user automatically has a usable referral link: `ReferralLink::forUser($user)->url('/')`

**Validation / tests after this phase:**
- Feature (Mode A/B): `GET /?ref=VALID_CODE` → click row created, cookie set
- Feature (Mode A/B): `GET /?ref=INVALID_CODE` → passes through, nothing written
- Feature (Mode C): `GET /?ref=USER_CODE` with no existing affiliate row → affiliate auto-created + click recorded
- Feature: repeated hit from same IP within guard window → no duplicate click row
- Unit: `ReferralLink::for($affiliate)->url('/pricing')` returns correctly formed URL

---

## Phase 3 — Conversion tracking

**What gets built:**
- `AffiliateTracker` service — called after a successful order/payment; reads affiliate cookie, finds the originating `Click`, calculates commission, writes `aff_conversions` row (status `pending`)
- Hook into Vanilo order completion event **and** a standalone `AffiliateTracker::record($orderId, $amount)` method for manual dispatch
- `ConversionCalculator` — handles `percent` and `flat` types; respects per-affiliate override, falls back to global config default
- Admin: approve / reject individual conversion endpoints
- **Commission is triggered entirely by events.** The package owns two events that the host app fires:
  - `PaymentReceived` — initial purchases, one-off charges (replaces any direct Vanilo coupling)
  - `SubscriptionRenewalPaid` — subscription renewals (Phase 4d)
  - The `AffiliateServiceProvider` registers listeners for both; no gateway or order-system dependency inside the package

**Validation / tests after this phase:**
- Feature: order completes with affiliate cookie present → `Conversion` row created with correct commission
- Feature: order completes with no cookie → no `Conversion` created
- Feature: order with cookie for a suspended affiliate → no `Conversion` created
- Unit: `ConversionCalculator::calculate('percent', 10, 150.00)` → `15.00`
- Unit: `ConversionCalculator::calculate('flat', 25, 150.00)` → `25.00`

---

## Phase 4 — Payout management (manual baseline)

**What gets built:**
- `aff_affiliates` gains `payout_instructions` (text, nullable) — free-text field where affiliates store their preferred payment details (PayPal email, bank info, etc.)
- Affiliate dashboard: "Payment Details" form to save `payout_instructions` (displayed to admin at payout time, never shown to other affiliates)
- `PayoutService@createForAffiliate($affiliateId)` — batches all `approved` conversions not in any payout into a new `Payout` + `PayoutItem` records
- Admin payout view shows the affiliate's `payout_instructions` prominently so admin knows where to send funds
- Admin: mark payout as paid, enter payment reference number and notes
- CSV and JSON export of a payout and its line items
- `php artisan affiliate:payouts:summary {--from=} {--to=}` Artisan command

**Validation / tests after this phase:**
- Feature: `PayoutService@createForAffiliate` includes all approved un-paid conversions and nothing else
- Feature: marking a payout as paid updates all linked conversions to `paid` status
- Feature: affiliate can save and update `payout_instructions` from their dashboard
- Feature: admin sees `payout_instructions` on the payout detail page
- Feature: CSV export returns correct headers and correct row count
- Unit: `Payout::totalOwed()` correctly sums commission across all payout items

---

## Phase 5 — Affiliate self-service dashboard

**Single page, combined view.** No separate tier-2 page. All earning types visible in one place with breakdown and filtering.

**Layout:**
```
┌─────────────────────────────────────────────────────┐
│  Total Earnings   Pending       Paid to date      │
│  $1,240.00        $340.00       $900.00           │
└─────────────────────────────────────────────────────┘
[ Your Referral Link + copy button ]  [ Payment Details ]

─── Earnings Breakdown ──────────────────────────────────
  Direct commissions (your referrals)      $1,040.00  | Available for Payout  $800.00
  Referral commissions (your recruits)       $200.00  | (Approved > N days ago)
                                                      | [ Request Payout ($800.00) ]

─── Tabs ───────────────────────────────────────────────
  [ Conversions ]  [ My Recruits ]  [ Payouts ]

Conversions tab — single list, filterable by Direct | Tier-2
My Recruits tab  — sub-affiliate code, join date, # conversions,
                   commission earned from each (no order details)
Payouts tab      — payout history with status and reference
```

**What gets built:**
- Route group `GET /affiliate/*` behind `auth` + `RequireActiveAffiliate` middleware (checks `aff_affiliates.status = active`)
- Pending affiliates redirected to an "application under review" page rather than a 403
- Single `dashboard.blade.php` with:
  - Three summary stat cards (total / pending / paid)
  - Earnings breakdown section (direct vs tier-2 commission totals)
  - **Available for Payout** figure — approved conversions older than `payout_approval_days`; shown alongside a disabled or active **Request Payout** button depending on whether `payout_minimum_amount` is met
  - Referral link widget with copy-to-clipboard
  - Tabbed section: Conversions (with Direct/Tier-2 badge + filter), My Recruits, Payouts
- `PaymentDetails` form (inline or modal) for affiliate to save `payout_instructions`
- Tier-2 recruit visibility: sub-affiliate referral code only; no name/email unless `show_customer_identity` is enabled in config

**Views needed:** `dashboard.blade.php`, `pending.blade.php` (review page)

**Validation / tests after this phase:**
- Feature: non-affiliate user hitting `/affiliate/dashboard` → 403
- Feature: pending affiliate → redirected to review page, not dashboard
- Feature: active affiliate sees only their own clicks, conversions, payouts (no data leakage from other affiliates)
- Feature: tier-2 conversions appear in Conversions tab with correct badge and are excluded from Direct total
- Feature: My Recruits tab shows correct sub-affiliate count and commission totals
- Feature: affiliate cannot see another affiliate's recruit list

---

## Phase 6 — Admin dashboard ✅ COMPLETE

**What was built:**
- `GET /admin/affiliates` → `AffiliateAdminController@index` — paginated affiliate list filterable by status, global stats card (total owed / paid YTD), bulk-payout checkbox form
- `GET /admin/affiliates/{id}` → `AffiliateAdminController@show` — affiliate detail with tabbed Clicks / Conversions / Payouts sections; inline approve/suspend/create-payout actions; payout_instructions displayed
- `POST /admin/affiliates/bulk-payout` → `AffiliateAdminController@bulkPayout` — creates payout batches for all selected affiliates; silently skips those with no eligible conversions and reports skipped IDs
- `GET /admin/affiliates/payouts/{id}` → `PayoutAdminController@show` — payout detail page with line items and mark-paid form (`payout-create.blade.php`)
- Views: `admin/index.blade.php`, `admin/show.blade.php`, `admin/payout-create.blade.php` — all extending `appshell::layouts.private` (Bootstrap + existing admin style)
- Nav entry: "Affiliates" link added to existing admin sidebar (`_nav.blade.php`)

**Validation / tests — all 4 passing:**
- Feature: non-admin cannot access any `/admin/affiliates/*` route
- Feature: admin affiliate list shows correct counts and commission totals
- Feature: approving an affiliate grants them access to the affiliate dashboard
- Feature: bulk payout creation generates correct `Payout` records for all selected affiliates

---

### Phase 4d — Recurring affiliate commissions (subscription renewals)

> **Status: COMPLETE — built and tested.**

#### Decisions recorded

| # | Decision | Resolution |
|---|---|---|
| 1 | `AffiliateTracker::record()` API for renewals | Extended with optional `?Affiliate $affiliate`, `string $source = 'initial'`, `?int $userId` params. When `$affiliate` is provided, cookie lookup is skipped entirely. |
| 2 | Per-customer renewal cap counting | Added nullable `user_id` column to `aff_conversions` via new migration. Cap is scoped to `affiliate_id + user_id + source = 'renewal'`. |
| 3 | Gateway decoupling | Package exposes **`SubscriptionRenewalPaid`** event (`AgencyPulse\Affiliate\Events\SubscriptionRenewalPaid`). Host site fires it from its own Cashier/gateway webhook handler. Package listens internally via `StripeRenewalListener`. Zero Cashier dependency in the package. |
| 4 | Renewals for pending-status affiliates | Config key `renewal_commission_pending_affiliate`: `'block'` (default, no commission) or `'allow'` (earns regardless of pending status). |

#### The problem

Currently affiliates earn commission only on the initial purchase. Monthly/yearly Stripe subscription
renewals are handled silently by Cashier — no app event fires, no `Payment` row is written, and no
affiliate conversion is recorded.

#### What gets built

**New migration — `aff_customer_attributions`**

A permanent record tying a customer to their referring affiliate. Written once (at first conversion),
never changed regardless of cookie expiry or device switches.

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `user_id` | FK → users | the paying customer |
| `affiliate_id` | FK → aff_affiliates | the affiliate who originally referred them |
| `origin_conversion_id` | FK → aff_conversions (nullable) | the conversion row that triggered this attribution |
| `created_at` | timestamp | |

Unique index on `user_id` — one attribution per customer, first-affiliate wins.

**`aff_conversions` gains `source` column** (`initial` | `renewal`) so reports can distinguish
first-sale commissions from recurring ones. Default `initial` for existing rows (non-breaking).

**`AffiliateTracker::record()` extended**

After writing the conversion row it also upserts `aff_customer_attributions` if no row exists yet
for that `user_id`. Idempotent — safe to call multiple times.

**New listener: `StripeRenewalListener`**

Listens for the package's own `AgencyPulse\Affiliate\Events\SubscriptionRenewalPaid` event.
The host application fires this event from its payment-gateway webhook handler
(e.g. Cashier webhook controller) — **the package has no Cashier or gateway dependency**.
Handler:

1. Queries `aff_customer_attributions` for the `user_id`.
2. If no attribution found → exits silently (customer was not referred).
3. Checks affiliate status — suspended exits.
4. If config `renewal_commission_pending_affiliate = 'block'` and affiliate is pending → exits.
5. Applies the `renewal_commission` policy gate (see config below).
6. Calls `AffiliateTracker::record()` with pre-resolved `$affiliate` and `source: 'renewal'`.

**`SubscriptionRenewalPaid` event** — site code fires this via the `CashierRenewalBridge` listener (see **Site Integration** section below).

The bridge is registered in `App\Providers\EventServiceProvider` and handles everything:
it checks `billing_reason = subscription_cycle`, resolves the user by `stripe_id`, and dispatches the event.
No manual code needed beyond what is already wired.

**Config additions to `config/affiliate.php`**

```php
// Which payments earn commission?
// 'first_only'        → current behaviour: initial purchase only
// 'all_renewals'      → every successful renewal indefinitely
// 'limited_renewals'  → first N renewals only (see max_renewal_periods)
'renewal_commission' => 'first_only',

// Used only when renewal_commission = 'limited_renewals'
// Total number of renewal invoices that earn commission (does not count the initial purchase)
// Cap is per-customer, not per-affiliate globally.
'max_renewal_periods' => 12,

// When 'block': pending-status affiliates earn nothing on renewals.
// When 'allow': pending affiliates earn renewal commission (approval is a separate admin step).
'renewal_commission_pending_affiliate' => 'block',
```

**Admin display**

- Affiliate detail Conversions tab: "Source" column shows `Initial` / `Renewal` badge.
- Affiliate dashboard Conversions tab: same badge with filter toggle.

#### How each policy behaves

| Policy | First purchase | Renewals | Stops |
|---|---|---|---|
| `first_only` | ✅ earns commission | ❌ nothing | — |
| `all_renewals` | ✅ earns commission | ✅ every cycle earns | Never |
| `limited_renewals` | ✅ earns commission | ✅ earns until cap | After `max_renewal_periods` renewal invoices |

For `limited_renewals`, the cap is enforced by counting `aff_conversions` rows with
`affiliate_id = X AND source = 'renewal'` for that specific customer (via the attribution record)
— not the affiliate's total renewal conversions globally.

#### Validation / tests after this phase

- Feature: first purchase with referral → `aff_customer_attributions` row written, conversion has `source = initial`
- Feature: second purchase by same user (no cookie) → no duplicate attribution row written
- Feature: renewal fired for user with no attribution → no conversion, no error
- Feature: renewal with policy `first_only` → no new conversion created
- Feature: renewal with policy `all_renewals` → conversion created, `source = renewal`, commission correct
- Feature: renewal with policy `all_renewals`, multiple cycles → every cycle earns
- Feature: renewal with policy `limited_renewals`, cap = 2, 2 renewals already processed → no new conversion created
- Feature: `limited_renewals` cap is per-customer (two customers each get their own cap)
- Feature: renewal for suspended affiliate → no conversion created
- Feature: renewal for pending affiliate with `renewal_commission_pending_affiliate = block` → no conversion
- Feature: renewal for pending affiliate with `renewal_commission_pending_affiliate = allow` → conversion created

---

**What gets built:**
- Full `README.md` inside the package (install, config, integration, hook reference)
- `vendor:publish` support: `--tag=affiliate-config`, `--tag=affiliate-migrations`, `--tag=affiliate-views`
- Remove all hard-coded `App\` namespace references; replace with config/interface bindings so the package is host-app-agnostic
- Final `AffiliateServiceProvider` audit against Laravel package conventions
- Test: install into a clean fresh Laravel 10 project via git URL and verify full flow

---

## Site Integration — Event Bridge

### Architecture

The affiliate package has **zero knowledge** of Vanilo, Cashier, Stripe, or any other gateway.
All commission logic is triggered by two package-owned events. The site app fires those events from
thin bridge listeners. The package handles everything else internally.

```
Site event                    Bridge listener                  Package event
─────────────────────────────────────────────────────────────────────────────
Vanilo::OrderWasCompleted  →  OrderCompletedBridge      →  PaymentReceived
Cashier::WebhookHandled    →  CashierRenewalBridge       →  SubscriptionRenewalPaid
(any other gateway)        →  (add your own bridge)      →  PaymentReceived
                                                              SubscriptionRenewalPaid

Package event (no bridge)     Site listener
─────────────────────────────────────────────────────────────────────────────
AffiliateActivated         →  AffiliateActivatedListener →  (mailing list #3, etc.)
```

Bridge listeners live in `app/Listeners/Affiliate/` and are registered in
`app/Providers/EventServiceProvider.php`. They are the **only** app-side code that references
the affiliate package.

---

### Package Event Reference

#### `AgencyPulse\Affiliate\Events\AffiliateActivated`

Fired by the package whenever an affiliate's status becomes `active` for the first time.
This covers all three activation paths:

| Path | Trigger |
|---|---|
| Admin approval | `AffiliateAdminController::approve()` |
| Mode B self-activation | `ApplicationController::activate()` |
| Mode C auto-enrolment | `TrackClickMiddleware` — only when the record is freshly created |

The package does **not** act on this event internally. It is provided as an outbound hook for
host-application onboarding logic: mailing list subscriptions, welcome emails, CRM updates, etc.

| Parameter | Type | Description |
|---|---|---|
| `$affiliate` | `Affiliate` | The affiliate record that just became active, with `user` relationship available via `$affiliate->user`. |

```php
use AgencyPulse\Affiliate\Events\AffiliateActivated;
use JSefton\MailingList\Models\MailingListEmail;

class AffiliateActivatedListener
{
    public function handle(AffiliateActivated $event): void
    {
        $affiliate = $event->affiliate;
        $user      = $affiliate->user;

        if (! $user) {
            return;
        }

        $listId      = 3; // Affiliates mailing list
        $contactData = MailingListEmail::map([
            'email'  => $user->email,
            'name'   => $user->name,
            'source' => 'Affiliate Programme',
        ]);
        $contactData['mailing_list_id'] = $listId;
        $contactData['subscribed']      = false;

        MailingListEmail::updateOrCreate(
            ['mailing_list_id' => $listId, 'email' => $user->email],
            $contactData
        );
    }
}
```

Registration in `app/Providers/EventServiceProvider.php`:

```php
use AgencyPulse\Affiliate\Events\AffiliateActivated;
use App\Listeners\Affiliate\AffiliateActivatedListener;

protected $listen = [
    // ...
    AffiliateActivated::class => [AffiliateActivatedListener::class],
];
```

---

#### `AgencyPulse\Affiliate\Events\PaymentReceived`

Fire this whenever a payment completes successfully — an initial order, one-off charge, or manual
invoice. The package resolves the referring affiliate from the `affiliate_ref` cookie (set by
`TrackClickMiddleware` earlier in the session), calculates commission, and writes a `Conversion`
row with `source = 'initial'`. It also writes an `aff_customer_attributions` row if `userId` is
provided and no attribution exists yet for that user (first-affiliate-wins).

| Parameter | Type | Required | Description |
|---|---|---|---|
| `$orderId` | `int\|string` | ✓ | Your order ID or gateway reference (e.g. Stripe session ID). Stored on the conversion row for traceability. |
| `$amount` | `float` | ✓ | Gross payment amount in the store's base currency. **Not cents.** |
| `$userId` | `?int` | — | Authenticated user's ID. Pass whenever the buyer is logged in. Required for customer attribution (needed for subscription renewal tracking). Null is safe for guest checkouts. |

**What the package does in response:**
1. Reads the `affiliate_ref` cookie; exits silently if absent or the code is unknown.
2. Checks affiliate status — suspended or pending affiliates earn nothing.
3. Calculates tier-1 commission via `ConversionCalculator`.
4. Creates a `Conversion` row (`status = pending`, `source = initial`).
5. If `$userId` is set, upserts `aff_customer_attributions` (idempotent).
6. Marks the originating click as converted.
7. If the affiliate was recruited by another, creates a tier-2 `Conversion` row for the recruiter.

```php
use AgencyPulse\Affiliate\Events\PaymentReceived;
use Illuminate\Support\Facades\Event;

Event::dispatch(new PaymentReceived(
    orderId: $order->id,          // int or string
    amount:  (float) $order->total(), // in dollars/pounds/etc. — not cents
    userId:  $order->user_id ?? null, // pass whenever buyer is logged in
));
```

---

#### `AgencyPulse\Affiliate\Events\SubscriptionRenewalPaid`

Fire this whenever a subscription renews autonomously — i.e. the gateway charges the customer
without any user action. The package looks up the permanent `aff_customer_attributions` record for
the user (written on first purchase), applies the configured `renewal_commission` policy, and writes
a `Conversion` row with `source = 'renewal'` if commission is owed.

**This event requires a `userId`** — there is no referral cookie on a renewal. The attribution
record is the only link between the customer and their referring affiliate.

| Parameter | Type | Required | Description |
|---|---|---|---|
| `$userId` | `int` | ✓ | The renewing customer's user ID. Used to look up `aff_customer_attributions`. |
| `$amount` | `float` | ✓ | Gross renewal amount in the store's base currency. **Not cents.** |
| `$invoiceId` | `?string` | — | Gateway invoice/reference ID. Used as `order_id` on the conversion row. Omitting it generates a fallback reference. |

**What the package does in response:**
1. Queries `aff_customer_attributions` for `$userId`; exits silently if not found.
2. Checks affiliate status — suspended affiliates earn nothing. Pending behaviour is config-controlled (`renewal_commission_pending_affiliate`).
3. Applies the `renewal_commission` policy gate:
   - `first_only` → exits (default — renewals earn nothing).
   - `all_renewals` → proceeds.
   - `limited_renewals` → counts prior renewal conversions for this customer; exits if `max_renewal_periods` reached.
4. Creates a `Conversion` row (`status = pending`, `source = renewal`, `user_id = $userId`).

```php
use AgencyPulse\Affiliate\Events\SubscriptionRenewalPaid;
use Illuminate\Support\Facades\Event;

Event::dispatch(new SubscriptionRenewalPaid(
    userId:    $user->id,        // required — no cookie available at renewal time
    amount:    $amountDue,       // in dollars/pounds/etc. — not cents
    invoiceId: $invoice['id'],   // optional but recommended for traceability
));
```

---

### Bridge Listeners (already implemented)

These live in `app/Listeners/Affiliate/` and are the only place the app references the package
events. Their sole job is to translate site-specific events into the package's events.

#### `AffiliateActivatedListener` — `AffiliateActivated` → mailing list #3

Subscribes the newly active affiliate's user account to mailing list ID #3 (Affiliates). Uses
`updateOrCreate` so re-activating a previously suspended affiliate produces no duplicate entry.
Non-fatal: mailing list failures are logged but never block activation.

```php
// app/Listeners/Affiliate/AffiliateActivatedListener.php
// See AffiliateActivated event reference above for full example.
```

#### `OrderCompletedBridge` — Vanilo → `PaymentReceived`

```php
// app/Listeners/Affiliate/OrderCompletedBridge.php

namespace App\Listeners\Affiliate;

use AgencyPulse\Affiliate\Events\PaymentReceived;
use Illuminate\Support\Facades\Event;
use Vanilo\Order\Events\OrderWasCompleted;

class OrderCompletedBridge
{
    public function handle(OrderWasCompleted $event): void
    {
        $order = $event->getOrder();

        Event::dispatch(new PaymentReceived(
            orderId: $order->id,
            amount:  (float) $order->total(),
            userId:  $order->user_id ?? null,
        ));
    }
}
```

#### `CashierRenewalBridge` — Cashier webhook → `SubscriptionRenewalPaid`

Cashier fires `WebhookHandled` after processing any Stripe webhook. This bridge filters down to
`invoice.payment_succeeded` with `billing_reason = subscription_cycle` (autonomous renewals only —
the initial charge is already handled by `OrderCompletedBridge`).

```php
// app/Listeners/Affiliate/CashierRenewalBridge.php

namespace App\Listeners\Affiliate;

use AgencyPulse\Affiliate\Events\SubscriptionRenewalPaid;
use App\Models\User;
use Illuminate\Support\Facades\Event;
use Laravel\Cashier\Events\WebhookHandled;

class CashierRenewalBridge
{
    public function handle(WebhookHandled $event): void
    {
        $payload = $event->payload;

        if (($payload['type'] ?? '') !== 'invoice.payment_succeeded') {
            return;
        }

        $invoice = $payload['data']['object'] ?? [];

        if (($invoice['billing_reason'] ?? '') !== 'subscription_cycle') {
            return; // ignores initial charge — covered by OrderCompletedBridge
        }

        $user = User::where('stripe_id', $invoice['customer'] ?? '')->first();

        if (! $user) {
            return;
        }

        Event::dispatch(new SubscriptionRenewalPaid(
            userId:    $user->id,
            amount:    ($invoice['amount_due'] ?? 0) / 100, // Stripe sends cents
            invoiceId: $invoice['id'] ?? null,
        ));
    }
}
```

Cashier registers its webhook route automatically at `/stripe/webhook`. No additional route
configuration is needed.

---

### `EventServiceProvider` registration

```php
// app/Providers/EventServiceProvider.php

use App\Listeners\Affiliate\CashierRenewalBridge;
use App\Listeners\Affiliate\OrderCompletedBridge;
use Laravel\Cashier\Events\WebhookHandled;
use Vanilo\Order\Events\OrderWasCompleted;

protected $listen = [
    // ... existing entries ...

    // Affiliate package bridges
    OrderWasCompleted::class => [OrderCompletedBridge::class],
    WebhookHandled::class    => [CashierRenewalBridge::class],
];
```

---

### Adding a new payment source

To support a gateway other than Vanilo / Cashier (e.g. PayPal, Paddle, manual invoice):

1. Create `app/Listeners/Affiliate/MyGatewayBridge.php`.
2. Type-hint the gateway's event in `handle()`.
3. Dispatch `PaymentReceived` (one-off) or `SubscriptionRenewalPaid` (recurring renewal).
4. Register it in `EventServiceProvider::$listen`.
5. No changes required inside the package.

```php
// Example: manual invoice bridge
class ManualInvoicePaidBridge
{
    public function handle(InvoiceMarkedPaid $event): void
    {
        Event::dispatch(new PaymentReceived(
            orderId: $event->invoice->id,
            amount:  $event->invoice->total,
            userId:  $event->invoice->user_id,
        ));
    }
}
```

> **Do not call `AffiliateTracker::record()` directly from site code.** Always dispatch the
> package events — they carry the correct `source` flag and handle attribution upsert,
> policy gates, tier-2 commissions, and click marking consistently.

---

## Final file structure

```
packages/agencypulse/affiliate/
├── composer.json
├── README.md
├── config/
│   └── affiliate.php
├── database/
│   └── migrations/
│       ├── 2026_xx_xx_create_aff_affiliates_table.php
│       ├── 2026_xx_xx_create_aff_clicks_table.php
│       ├── 2026_xx_xx_create_aff_conversions_table.php
│       ├── 2026_xx_xx_create_aff_payouts_table.php
│       ├── 2026_xx_xx_create_aff_payout_items_table.php
│       ├── 2026_xx_xx_add_source_user_id_to_aff_conversions_table.php  ← Phase 4d
│       └── 2026_xx_xx_create_aff_customer_attributions_table.php       ← Phase 4d
├── resources/
│   └── views/
│       ├── affiliate/
│       │   ├── dashboard.blade.php   ← combined view (stats, breakdown, 3 tabs)
│       │   └── pending.blade.php     ← "application under review" holding page
│       └── admin/
│           ├── index.blade.php
│           ├── show.blade.php
│           └── payout-create.blade.php
├── routes/
│   ├── affiliate.php
│   └── admin.php
└── src/
    ├── AffiliateServiceProvider.php
    ├── Console/
    │   └── PayoutsSummaryCommand.php
    ├── Events/
    │   ├── AffiliateActivated.php                                            ← affiliate becomes active
    │   ├── PaymentReceived.php                                               ← initial purchase event
    │   └── SubscriptionRenewalPaid.php                                       ← Phase 4d
    ├── Http/
    │   ├── Controllers/
    │   │   ├── Affiliate/
    │   │   │   ├── DashboardController.php
    │   │   │   ├── ApplicationController.php
    │   │   │   ├── PaymentDetailsController.php
    │   │   │   ├── PayoutRequestController.php                            ← affiliate self-service payout
    │   │   │   └── SlugController.php
    │   │   └── Admin/
    │   │       ├── AffiliateAdminController.php
    │   │       └── PayoutAdminController.php
    │   └── Middleware/
    │       ├── TrackClickMiddleware.php
    │       └── RequireActiveAffiliate.php
    ├── Listeners/
    │   ├── PaymentReceivedListener.php                                       ← initial purchase
    │   └── StripeRenewalListener.php                                     ← Phase 4d
    ├── Models/
    │   ├── Affiliate.php
    │   ├── Click.php
    │   ├── Conversion.php
    │   ├── CustomerAttribution.php                                       ← Phase 4d
    │   ├── Payout.php
    │   └── PayoutItem.php
    └── Services/
        ├── AffiliateCodeGenerator.php                                        ← Phase post-5
        ├── AffiliateTracker.php
        ├── ConversionCalculator.php
        ├── PayoutService.php
        └── ReferralLink.php
```

---

## Approval & auto-enrolment modes

Controlled by `config/affiliate.php`:

```php
'require_approval'      => true,   // set false to skip the approval queue entirely
'auto_create_on_click'  => true,   // only takes effect when require_approval is false
'cookie_lifetime'       => 43200,  // minutes; configurable, default = 30 days
'show_customer_identity'=> false,  // affiliates see customer name/email on conversions if true
'allow_slug_change'     => true,   // set false to hide the "Change Slug" form and lock the endpoint
'slug_format'           => 'string',  // 'string' (name-based) or 'numeric' (fixed-digit number)
'slug_numeric_digits'   => 6,         // digit count for numeric mode (minimum 5); ignored in string mode

// Payout controls
'payout_approval_days'  => 14,    // days a conversion must be approved before counting as available
'payout_minimum_amount' => 50.00, // minimum available balance before affiliate can request a payout
```

**Mode A — `require_approval: true` (default)**
- User submits an application → `aff_affiliates` row created with status `pending`
- Admin approves → status becomes `active`, referral link is activated
- Clicks are only tracked for `active` affiliates

**Mode B — `require_approval: false, auto_create_on_click: false`**
- Any logged-in user can self-activate at `/affiliate/join`
- `aff_affiliates` row is created immediately with status `active`
- No admin action needed

**Mode C — `require_approval: false, auto_create_on_click: true`**
- No application form needed at all
- When `TrackClickMiddleware` sees `?ref=USER_ID_OR_CODE` and no `aff_affiliates` row exists for that user, it creates one on the spot (status `active`) and then records the click
- A unique `code` is derived from the user's ID or a short hash — no prior setup required by the user
- This is the "streamlined / zero-friction" mode

In all modes, suspension by an admin is always possible and immediately blocks new clicks and conversions.

---

## Answered decisions

- [x] **Customer identity on conversions:** configurable via `show_customer_identity` in config. Defaults `false` (affiliates see anonymised order amounts only). Admin always sees full data.
- [x] **Affiliate slug (referral code) changes:** configurable via `allow_slug_change` in config. Defaults `true` — active affiliates can update their own referral code from the dashboard via `POST /affiliate/slug`. Set to `false` to hide the form entirely and have the endpoint return a 403.
- [x] **Referral slug format:** configurable via `slug_format` (`'string'` default or `'numeric'`) and `slug_numeric_digits` (default `6`, minimum `5`). In `'string'` mode codes are name-based slugs (e.g. `john-doe`); in `'numeric'` mode they are zero-padded random integers of exactly `slug_numeric_digits` digits (e.g. `038472`). Auto-generation (apply, self-activate, Mode C) and change-slug validation both respect this setting. The shared `AffiliateCodeGenerator` service handles all code generation.
- [x] **Payout method:** Two-track system — manual is the baseline, Stripe Express Connect is optional and additive.
  - **Manual (Phase 4):** admin creates a payout record, marks it paid, enters a payment reference (bank transfer, PayPal, cheque, etc.). Always available regardless of Stripe.
  - **Stripe Express (Phase 4b):** optional upgrade — if an affiliate connects a Stripe account, admin can trigger a Transfer directly. If they don't, the manual flow is used instead. Affiliates are never forced onto Stripe.
  - **Onboarding for non-Stripe affiliates:** affiliate dashboard shows a "Payment details" form where they enter their preferred payment method info (PayPal email, bank details as free text, etc.) stored as `payout_instructions` on `aff_affiliates`. Admin sees this when processing a manual payout.
- [x] **Cookie lifetime:** fully configurable in `config/affiliate.php` (`cookie_lifetime` in minutes). Default 30 days (43,200 min).
- [x] **Multi-tier affiliates:** yes — single level of sub-affiliates (affiliate A recruits affiliate B; A earns a configurable secondary commission rate on B's conversions). Deeper nesting not required. Adds a dedicated Phase 4c.

---

## Additional phases added

### Phase 4b — Stripe Express Connect payouts (optional upgrade)

> Stripe is entirely optional. Affiliates who do not connect Stripe continue using the manual payout flow from Phase 4 indefinitely. Both flows co-exist permanently.

**What gets built:**
- `aff_affiliates` gains `stripe_account_id` (nullable) and `stripe_onboarding_completed` (boolean)
- Affiliate dashboard: "Connect Stripe" button triggers an Express Account onboarding link (Stripe-hosted KYC). On return, `stripe_account_id` is stored.
- Affiliate dashboard: "Disconnect Stripe" option reverts them to manual payout flow
- `PayoutService@dispatchStripe($payoutId)` — calls Stripe Transfers API; only callable when affiliate has a connected, onboarded account
- Admin payout list: badge shows whether each affiliate is on Stripe or manual, with a "Pay via Stripe" button shown only when applicable
- Webhook handler: listens for `transfer.paid` / `transfer.failed`, updates payout status automatically

**Validation / tests after this phase:**
- Feature: affiliate without `stripe_account_id` — no Stripe option shown, manual flow proceeds normally
- Feature: Express onboarding callback stores `stripe_account_id` and sets `stripe_onboarding_completed = true`
- Feature: dispatching a Stripe payout calls Stripe Transfers with correct `amount` and `destination`
- Feature: `transfer.paid` webhook marks payout as `paid`
- Unit: `PayoutService` throws `AffiliateNotConnectedException` if Stripe account missing and Stripe dispatch is attempted

---

### Phase 4c — Multi-tier (sub-affiliate) commissions

**What gets built:**
- `aff_affiliates` gains `referred_by_affiliate_id` (nullable FK → self) — set when an affiliate is recruited via another affiliate's referral link
- `config/affiliate.php` gains `tier2_commission_type` and `tier2_commission_value`
- `ConversionCalculator` extended to also produce a `Tier2Conversion` record crediting the recruiting affiliate
- `aff_conversions` gains `tier` column (`1` or `2`) and `parent_conversion_id` (nullable) for traceability
- Payout service includes tier-2 conversions in the recruiting affiliate's payout batch

**Validation / tests after this phase:**
- Feature: order converts under affiliate B (recruited by A) → two conversion rows created, one for B (tier 1), one for A (tier 2)
- Feature: affiliate with no recruiter → only one conversion row (tier 1)
- Feature: tier-2 commission calculated correctly for both `percent` and `flat` types
- Unit: `ConversionCalculator` returns correct tier-2 amount

---

## All decisions resolved

- [x] **Stripe Connect account type:** Express — Stripe-hosted onboarding flow. Affiliate clicks a link, completes KYC on Stripe's side, gets redirected back. We store the returned `stripe_account_id`. No custom onboarding UI needed.
- [x] **Tier-2 dashboard display:** Combined single dashboard. Earnings breakdown section shows direct vs tier-2 commission totals. Conversions tab has a Direct/Tier-2 badge and filter. "My Recruits" tab shows sub-affiliate activity without exposing their customer order details. No separate page needed.

> **All decisions are now closed. Ready to build. Say "go phase 1" to start.**

---

## Post-phase improvements

### Navigation — affiliate link moved to user dropdown

**What changed:**
- `resources/views/layouts/navigation.blade.php` — removed the "Affiliates" entry from the main header nav bar.
- Desktop: link added to the logged-in user dropdown (after "Two Factor Auth").
- Mobile: link added to the responsive settings section (after "Profile").
- Both locations are wrapped in a conditional that queries `aff_affiliates` for the current user; the link is only rendered when `status = 'active'`. Non-affiliates and pending/suspended affiliates see no link.

---

### Referral slug self-service (`POST /affiliate/slug`)

**What was built:**
- `SlugController` — `POST affiliate/slug` → `affiliate.slug.update`; protected by the existing `affiliate.active` middleware.
- `allow_slug_change` config key (default `true`). Set `false` to return 403 and hide the dashboard form.
- Dashboard view gains a "Change Referral Slug" card (hidden when `allow_slug_change = false`).
- Validation and UI adapt to the active `slug_format` (see below).

---

### Affiliate self-service payout requests

**What was built:**
- `POST /affiliate/payout/request` → `PayoutRequestController@store`; protected by `affiliate.active` middleware.
- Two new config keys control eligibility — both enforced server-side in `PayoutService::createRequestForAffiliate()`:

| Key | Default | Description |
|-----|---------|-------------|
| `payout_approval_days` | `14` | Days a conversion must have been in `approved` status (measured by `updated_at`) before it counts toward the available balance. Prevents chargeback losses. |
| `payout_minimum_amount` | `50.00` | Minimum available balance (store base currency) required before the affiliate may request a payout. |

- `PayoutService::createRequestForAffiliate($affiliateId)` — queries approved conversions older than the cooldown, validates the minimum, then creates a `Payout` + `PayoutItem` rows (status `pending`) exactly as the admin-side `createForAffiliate()` does.
- Throws a descriptive `RuntimeException` if: no eligible conversions exist, or total is below the minimum.
- `DashboardController` computes `$availableForPayout`, `$payoutMinimum`, and `$payoutCooldownDays` and passes them to the view.
- **Dashboard Earnings Breakdown section extended:**
  - Third column: **Available for Payout** amount (green when > 0, grey otherwise) with a label "Approved > N days ago".
  - Below the breakdown: active green **Request Payout ($X.XX)** button with a JS confirm dialog when the minimum is met; disabled grey button with a contextual message when it is not (shows shortfall amount or explains that commissions must age).
  - Flash messages for `payout_success` and `payout_error` displayed inline.

**Behaviour:**
- The resulting `Payout` record has `status = pending` and `method = manual`; admin processes it through the existing payout-detail page.
- The affiliate's pending payout immediately appears in their Payouts tab.
- Eligible conversions are no longer double-counted: `whereDoesntHave('payoutItem')` ensures only un-batched approved conversions are included.

---

### Configurable referral code format (`slug_format`)

**What was built:**
- `AffiliateCodeGenerator` service (`src/Services/AffiliateCodeGenerator.php`) — single source of truth for all code generation used by `ApplicationController`, `TrackClickMiddleware`, and `SlugController`.
- Two new config keys:

| Key | Default | Description |
|-----|---------|-------------|
| `slug_format` | `'string'` | `'string'` — name-based slug (e.g. `john-doe`); `'numeric'` — fixed-digit random integer (e.g. `038472`) |
| `slug_numeric_digits` | `6` | Digit count for numeric codes. Runtime minimum of 5 is enforced regardless of config value. |

**Behaviour by mode:**

| Mode | Auto-generated code | Change-slug validation |
|------|--------------------|-----------------------|
| `string` | `Str::slug(name)`, suffix `-1`, `-2`… on collision | Required, 3–30 chars, `/^[a-zA-Z0-9_-]+$/`, unique |
| `numeric` | Random `N`-digit integer, retried until unique | Required, exactly `N` digits (`digits:N` rule), unique |

**Files changed:**
- `packages/agencypulse/affiliate/src/Services/AffiliateCodeGenerator.php` *(new)*
- `packages/agencypulse/affiliate/src/Http/Controllers/Affiliate/ApplicationController.php` — `generateCode()` delegates to `AffiliateCodeGenerator::generate()`
- `packages/agencypulse/affiliate/src/Http/Middleware/TrackClickMiddleware.php` — `uniqueCode()` delegates to `AffiliateCodeGenerator::generate()`
- `packages/agencypulse/affiliate/src/Http/Controllers/Affiliate/SlugController.php` *(new)* — branches validation on format
- `packages/agencypulse/affiliate/resources/views/affiliate/dashboard.blade.php` — "Change Slug" card renders numeric `inputmode` field or text field based on config
- `packages/agencypulse/affiliate/config/affiliate.php` and `config/affiliate.php` — `allow_slug_change`, `slug_format`, `slug_numeric_digits` added

**Validation / tests:**

*Unit — `AffiliateCodeGeneratorTest`:*
- String mode generates a name-based slug
- String mode appends numeric suffix on collision
- String mode falls back to `'ref'` for null/empty name
- Numeric mode generates correct digit count
- Numeric mode honours custom digit count
- Numeric mode enforces minimum 5 digits (config value of 3 → produces 5-digit code)
- Numeric mode retries on collision
- `numericDigits()` returns config value when ≥ 5
- `numericDigits()` returns 5 when config is below minimum
- Defaults to string mode when `slug_format` is unset

*Feature — `ChangeSlugTest`:*
- Unauthenticated → redirect to login
- Non-affiliate → 403
- Pending affiliate → redirect to pending page
- Suspended affiliate → 403
- `allow_slug_change = false` → 403, code unchanged in DB
- Active affiliate can change slug (string mode) → DB updated, success flash
- Duplicate slug rejected with validation error (string mode)
- Affiliate can resubmit their own current slug (self-unique)
- Invalid characters rejected (string mode)
- Too short (< 3) rejected (string mode)
- Too long (> 30) rejected (string mode)
- Empty code rejected
- Active affiliate can change slug (numeric mode) → DB updated
- Duplicate numeric code rejected
- Wrong digit count rejected (too few and too many)
- Non-numeric input rejected (numeric mode)

Run all new tests:
```bash
./vendor/bin/sail artisan test --filter="AffiliateCodeGeneratorTest|ChangeSlugTest"
```
