# Phase 4 — Stripe Integration Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Integrate Stripe for clinician payouts (Express Connect) and fee collection (Customer + PaymentIntent), with webhook handling and transactional emails.

**Architecture:** A `StripeService` wraps all Stripe SDK calls. Two controllers handle OAuth-like redirect flows (Connect onboarding, billing setup). A webhook controller processes 5 event types. Existing job stubs are filled with real API calls. Queued Mailable classes send receipt emails.

**Tech Stack:** Laravel 13, stripe/stripe-php SDK, Livewire 4, Pest 4

---

## File Map

| File | Action | Responsibility |
|------|--------|----------------|
| `app/Services/StripeService.php` | Create | Wrapper around all Stripe SDK calls |
| `app/Http/Controllers/Stripe/StripeConnectController.php` | Create | Express Connect onboarding redirect flow |
| `app/Http/Controllers/Stripe/StripeBillingController.php` | Create | Customer payment method setup redirect flow |
| `app/Http/Controllers/Stripe/StripeWebhookController.php` | Create | Webhook event handler |
| `app/Mail/DisbursementReceipt.php` | Create | Queued email for payout receipts |
| `app/Mail/FeeSettlementReceipt.php` | Create | Queued email for fee settlement receipts |
| `app/Notifications/PayoutFailedNotification.php` | Create | Admin notification for failed transfers |
| `app/Notifications/ChargeFailedNotification.php` | Create | Admin notification for failed charges |
| `resources/views/mail/disbursement-receipt.blade.php` | Create | Payout receipt email template |
| `resources/views/mail/fee-settlement-receipt.blade.php` | Create | Fee settlement email template |
| Migration: add Stripe fields to clinicians | Create | stripe_connect_id, stripe_customer_id, stripe_account_status |
| `tests/Feature/Services/StripeServiceTest.php` | Create | Test StripeService with mocked SDK |
| `tests/Feature/Stripe/StripeConnectTest.php` | Create | Test Connect controller redirect flow |
| `tests/Feature/Stripe/StripeBillingTest.php` | Create | Test billing setup redirect flow |
| `tests/Feature/Stripe/StripeWebhookTest.php` | Create | Test webhook event handling |
| `tests/Feature/Jobs/ProcessDisbursementTransferTest.php` | Modify | Update for real StripeService calls |
| `tests/Feature/Jobs/ProcessFeeChargeTest.php` | Modify | Update for real StripeService calls |
| `tests/Feature/Mail/DisbursementReceiptTest.php` | Create | Test email content |
| `tests/Feature/Mail/FeeSettlementReceiptTest.php` | Create | Test email content |
| `config/services.php` | Modify | Add Stripe config block |
| `app/Models/Clinician.php` | Modify | Add Stripe fields to fillable |
| `app/Jobs/ProcessDisbursementTransfer.php` | Modify | Replace stub with real Stripe Transfer |
| `app/Jobs/ProcessFeeCharge.php` | Modify | Replace stub with real Stripe PaymentIntent |
| `routes/web.php` | Modify | Add Stripe routes |

---

## Task 1: Install Stripe SDK + Configuration

**Files:**
- Modify: `composer.json`
- Modify: `config/services.php:36-38`
- Modify: `.env.example`

- [ ] **Step 1: Install the Stripe PHP SDK**

Run:
```bash
composer require stripe/stripe-php --no-interaction
```

- [ ] **Step 2: Add Stripe config block**

Add to `config/services.php` after the `'slack'` entry (line 36):

```php
    'stripe' => [
        'secret' => env('STRIPE_SECRET_KEY'),
        'public' => env('STRIPE_PUBLIC_KEY'),
        'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
    ],
```

- [ ] **Step 3: Add env vars to `.env.example`**

Append to `.env.example`:

```
STRIPE_PUBLIC_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
```

- [ ] **Step 4: Commit**

```bash
git add composer.json composer.lock config/services.php .env.example
git commit -m "feat: install stripe-php SDK and add configuration"
```

---

## Task 2: Migration — Add Stripe Fields to Clinicians

**Files:**
- Create: migration file
- Modify: `app/Models/Clinician.php:14-21`

- [ ] **Step 1: Create migration**

Run:
```bash
php artisan make:migration add_stripe_fields_to_clinicians_table --table=clinicians --no-interaction
```

- [ ] **Step 2: Write migration**

```php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('clinicians', function (Blueprint $table) {
            $table->string('stripe_connect_id')->nullable()->after('payment_method_type');
            $table->string('stripe_customer_id')->nullable()->after('stripe_connect_id');
            $table->string('stripe_account_status', 20)->nullable()->after('stripe_customer_id');
        });
    }

    public function down(): void
    {
        Schema::table('clinicians', function (Blueprint $table) {
            $table->dropColumn(['stripe_connect_id', 'stripe_customer_id', 'stripe_account_status']);
        });
    }
};
```

- [ ] **Step 3: Update Clinician model fillable**

Add `'stripe_connect_id'`, `'stripe_customer_id'`, `'stripe_account_status'` to the `$fillable` array in `app/Models/Clinician.php`.

- [ ] **Step 4: Run migration**

```bash
php artisan migrate
```

- [ ] **Step 5: Commit**

```bash
vendor/bin/pint --dirty --format agent
git add database/migrations/*stripe_fields* app/Models/Clinician.php
git commit -m "feat: add Stripe fields to clinicians table"
```

---

## Task 3: StripeService

**Files:**
- Create: `app/Services/StripeService.php`
- Create: `tests/Feature/Services/StripeServiceTest.php`

- [ ] **Step 1: Write the tests**

Create `tests/Feature/Services/StripeServiceTest.php`:

```php
<?php

use App\Models\Clinician;
use App\Models\User;
use App\Services\StripeService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Stripe\Account;
use Stripe\AccountLink;
use Stripe\Checkout\Session as CheckoutSession;
use Stripe\Customer;
use Stripe\PaymentIntent;
use Stripe\Transfer;

uses(RefreshDatabase::class);

beforeEach(function () {
    config(['services.stripe.secret' => 'sk_test_fake']);
    config(['services.stripe.webhook_secret' => 'whsec_test_fake']);
});

it('creates an express account', function () {
    $clinician = Clinician::factory()->create();

    $mockAccount = new Account('acct_test_123');
    $mockAccount->id = 'acct_test_123';

    Account::shouldReceive('create')
        ->once()
        ->andReturn($mockAccount);

    $accountId = StripeService::createExpressAccount($clinician);

    expect($accountId)->toBe('acct_test_123');
});

it('creates an onboarding link', function () {
    $mockLink = new AccountLink;
    $mockLink->url = 'https://connect.stripe.com/setup/e/test';

    AccountLink::shouldReceive('create')
        ->once()
        ->andReturn($mockLink);

    $url = StripeService::createOnboardingLink('acct_test_123', 'https://hub.test/return', 'https://hub.test/refresh');

    expect($url)->toBe('https://connect.stripe.com/setup/e/test');
});

it('creates a customer', function () {
    $user = User::factory()->create(['first_name' => 'Jane', 'last_name' => 'Doe', 'email' => 'jane@test.com']);

    $mockCustomer = new Customer('cus_test_456');
    $mockCustomer->id = 'cus_test_456';

    Customer::shouldReceive('create')
        ->once()
        ->andReturn($mockCustomer);

    $customerId = StripeService::createCustomer($user);

    expect($customerId)->toBe('cus_test_456');
});

it('creates a transfer', function () {
    $mockTransfer = new Transfer('tr_test_789');
    $mockTransfer->id = 'tr_test_789';

    Transfer::shouldReceive('create')
        ->once()
        ->andReturn($mockTransfer);

    $transfer = StripeService::createTransfer(1500, 'acct_test_123', ['note' => 'test']);

    expect($transfer->id)->toBe('tr_test_789');
});

it('creates a payment intent', function () {
    $mockIntent = new PaymentIntent('pi_test_000');
    $mockIntent->id = 'pi_test_000';

    PaymentIntent::shouldReceive('create')
        ->once()
        ->andReturn($mockIntent);

    $intent = StripeService::createPaymentIntent(2500, 'cus_test_456', 'pm_test_card', ['note' => 'test']);

    expect($intent->id)->toBe('pi_test_000');
});
```

**Note:** Stripe SDK classes don't natively support `shouldReceive`. Tests should mock using Laravel's service container or by mocking the StripeService itself in controller/job tests. The above tests are **conceptual** — the implementer should adapt to use `Mockery::mock()` on the Stripe client or wrap the Stripe client instance in the service for testability. The key principle: the service is tested by verifying it calls the SDK with correct parameters.

- [ ] **Step 2: Create the StripeService**

Create `app/Services/StripeService.php`:

```php
<?php

namespace App\Services;

use App\Models\Clinician;
use App\Models\User;
use Stripe\Account;
use Stripe\AccountLink;
use Stripe\Checkout\Session as CheckoutSession;
use Stripe\Customer;
use Stripe\Event;
use Stripe\PaymentIntent;
use Stripe\Stripe;
use Stripe\Transfer;
use Stripe\Webhook;

class StripeService
{
    private static function setApiKey(): void
    {
        Stripe::setApiKey(config('services.stripe.secret'));
    }

    public static function createExpressAccount(Clinician $clinician): string
    {
        self::setApiKey();

        $account = Account::create([
            'type' => 'express',
            'country' => 'US',
            'email' => $clinician->user->email,
            'capabilities' => [
                'transfers' => ['requested' => true],
            ],
            'metadata' => [
                'clinician_code' => $clinician->clinician_code,
                'hub_clinician_id' => $clinician->id,
            ],
        ]);

        return $account->id;
    }

    public static function createOnboardingLink(string $accountId, string $returnUrl, string $refreshUrl): string
    {
        self::setApiKey();

        $link = AccountLink::create([
            'account' => $accountId,
            'return_url' => $returnUrl,
            'refresh_url' => $refreshUrl,
            'type' => 'account_onboarding',
        ]);

        return $link->url;
    }

    /**
     * @return array{charges_enabled: bool, payouts_enabled: bool, details_submitted: bool}
     */
    public static function getAccountStatus(string $accountId): array
    {
        self::setApiKey();

        $account = Account::retrieve($accountId);

        return [
            'charges_enabled' => $account->charges_enabled,
            'payouts_enabled' => $account->payouts_enabled,
            'details_submitted' => $account->details_submitted,
        ];
    }

    public static function createCustomer(User $user): string
    {
        self::setApiKey();

        $customer = Customer::create([
            'email' => $user->email,
            'name' => $user->full_name,
            'metadata' => [
                'hub_user_id' => $user->id,
            ],
        ]);

        return $customer->id;
    }

    public static function createSetupSession(string $customerId, string $returnUrl): string
    {
        self::setApiKey();

        $session = CheckoutSession::create([
            'mode' => 'setup',
            'customer' => $customerId,
            'payment_method_types' => ['card', 'us_bank_account'],
            'success_url' => $returnUrl . '?session_id={CHECKOUT_SESSION_ID}',
            'cancel_url' => $returnUrl . '?cancelled=1',
        ]);

        return $session->url;
    }

    /**
     * @return array{id: string, type: string}|null
     */
    public static function getDefaultPaymentMethod(string $customerId): ?array
    {
        self::setApiKey();

        $customer = Customer::retrieve([
            'id' => $customerId,
            'expand' => ['invoice_settings.default_payment_method'],
        ]);

        $pm = $customer->invoice_settings->default_payment_method;

        if (! $pm) {
            $methods = \Stripe\PaymentMethod::all([
                'customer' => $customerId,
                'limit' => 1,
            ]);

            $pm = $methods->data[0] ?? null;
        }

        if (! $pm) {
            return null;
        }

        return [
            'id' => $pm->id,
            'type' => $pm->type === 'us_bank_account' ? 'ach' : 'card',
        ];
    }

    public static function createTransfer(int $amountCents, string $destinationAccountId, array $metadata = []): Transfer
    {
        self::setApiKey();

        return Transfer::create([
            'amount' => $amountCents,
            'currency' => 'usd',
            'destination' => $destinationAccountId,
            'metadata' => $metadata,
        ]);
    }

    public static function createPaymentIntent(int $amountCents, string $customerId, string $paymentMethodId, array $metadata = []): PaymentIntent
    {
        self::setApiKey();

        return PaymentIntent::create([
            'amount' => $amountCents,
            'currency' => 'usd',
            'customer' => $customerId,
            'payment_method' => $paymentMethodId,
            'confirm' => true,
            'automatic_payment_methods' => [
                'enabled' => true,
                'allow_redirects' => 'never',
            ],
            'metadata' => $metadata,
        ]);
    }

    public static function constructWebhookEvent(string $payload, string $signature): Event
    {
        return Webhook::constructEvent(
            $payload,
            $signature,
            config('services.stripe.webhook_secret'),
        );
    }
}
```

- [ ] **Step 3: Run tests**

Run: `php artisan test --compact --filter=StripeServiceTest`

The implementer should adapt the test mocking strategy to work with Stripe SDK's static methods. Options:
- Use `Mockery::mock('alias:Stripe\Account')` for static method mocking
- Or wrap the Stripe client as an instance property and inject it
- Simplest: test the service indirectly through controller/job tests with a mocked StripeService

- [ ] **Step 4: Run Pint and commit**

```bash
vendor/bin/pint --dirty --format agent
git add app/Services/StripeService.php tests/Feature/Services/StripeServiceTest.php
git commit -m "feat: add StripeService wrapping all Stripe SDK calls"
```

---

## Task 4: Stripe Connect Controller

**Files:**
- Create: `app/Http/Controllers/Stripe/StripeConnectController.php`
- Create: `tests/Feature/Stripe/StripeConnectTest.php`
- Modify: `routes/web.php`

- [ ] **Step 1: Write the tests**

Create `tests/Feature/Stripe/StripeConnectTest.php`:

```php
<?php

use App\Models\Clinician;
use App\Models\User;
use App\Services\StripeService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;

uses(RefreshDatabase::class);

it('creates an express account and redirects to Stripe', function () {
    $user = User::factory()->admin()->create();
    $clinician = Clinician::factory()->create(['user_id' => $user->id]);

    Mockery::mock('alias:' . StripeService::class)
        ->shouldReceive('createExpressAccount')
        ->once()
        ->andReturn('acct_test_123')
        ->shouldReceive('createOnboardingLink')
        ->once()
        ->andReturn('https://connect.stripe.com/onboarding');

    $this->actingAs($user)
        ->get(route('stripe.connect.start'))
        ->assertRedirect('https://connect.stripe.com/onboarding');

    expect($clinician->fresh()->stripe_connect_id)->toBe('acct_test_123');
});

it('updates account status on return', function () {
    $user = User::factory()->admin()->create();
    $clinician = Clinician::factory()->create([
        'user_id' => $user->id,
        'stripe_connect_id' => 'acct_test_123',
    ]);

    Mockery::mock('alias:' . StripeService::class)
        ->shouldReceive('getAccountStatus')
        ->once()
        ->with('acct_test_123')
        ->andReturn([
            'charges_enabled' => true,
            'payouts_enabled' => true,
            'details_submitted' => true,
        ]);

    $this->actingAs($user)
        ->get(route('stripe.connect.return'))
        ->assertRedirect();

    expect($clinician->fresh()->stripe_account_status)->toBe('active');
});

it('requires authentication', function () {
    $this->get(route('stripe.connect.start'))
        ->assertRedirect(route('login'));
});
```

**Note:** Mocking static methods on StripeService with `Mockery::mock('alias:...')` may require the tests to run in separate processes. The implementer should choose the best mocking approach — options include making StripeService non-static with dependency injection, or using a `StripeService` facade. Adapt the tests to whatever pattern works cleanly.

- [ ] **Step 2: Create the controller**

Create `app/Http/Controllers/Stripe/StripeConnectController.php`:

```php
<?php

namespace App\Http\Controllers\Stripe;

use App\Http\Controllers\Controller;
use App\Services\AuditLogService;
use App\Services\StripeService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

class StripeConnectController extends Controller
{
    public function start(Request $request): RedirectResponse
    {
        $clinician = $request->user()->clinician;

        if (! $clinician) {
            return redirect()->back()->with('error', 'No clinician profile found.');
        }

        if (! $clinician->stripe_connect_id) {
            $accountId = StripeService::createExpressAccount($clinician);
            $clinician->update([
                'stripe_connect_id' => $accountId,
                'stripe_account_status' => 'pending',
            ]);

            AuditLogService::log(
                action: 'stripe.connect_account_created',
                actor: $request->user(),
                targetType: 'Clinician',
                targetId: $clinician->id,
                targetLabel: $clinician->display_name,
                metadata: ['stripe_connect_id' => $accountId],
            );
        }

        $onboardingUrl = StripeService::createOnboardingLink(
            $clinician->stripe_connect_id,
            route('stripe.connect.return'),
            route('stripe.connect.refresh'),
        );

        return redirect()->away($onboardingUrl);
    }

    public function return(Request $request): RedirectResponse
    {
        $clinician = $request->user()->clinician;

        if ($clinician?->stripe_connect_id) {
            $status = StripeService::getAccountStatus($clinician->stripe_connect_id);
            $accountStatus = ($status['charges_enabled'] && $status['payouts_enabled']) ? 'active' : 'pending';

            $clinician->update(['stripe_account_status' => $accountStatus]);

            AuditLogService::log(
                action: 'stripe.connect_onboarding_returned',
                actor: $request->user(),
                targetType: 'Clinician',
                targetId: $clinician->id,
                metadata: ['status' => $accountStatus],
            );
        }

        return redirect()->route('dashboard')->with('success', 'Bank account setup updated.');
    }

    public function refresh(Request $request): RedirectResponse
    {
        $clinician = $request->user()->clinician;

        if (! $clinician?->stripe_connect_id) {
            return redirect()->route('dashboard')->with('error', 'No Stripe account found.');
        }

        $onboardingUrl = StripeService::createOnboardingLink(
            $clinician->stripe_connect_id,
            route('stripe.connect.return'),
            route('stripe.connect.refresh'),
        );

        return redirect()->away($onboardingUrl);
    }
}
```

- [ ] **Step 3: Add routes**

Add to `routes/web.php` inside the protected auth group (after line 68, inside `['auth', 'active', 'password.changed', 'onboarding.complete']`), but **outside** the admin group:

```php
    // Stripe Connect routes (any authenticated user with clinician)
    Route::prefix('stripe')->name('stripe.')->group(function () {
        Route::get('/connect/start', [\App\Http\Controllers\Stripe\StripeConnectController::class, 'start'])->name('connect.start');
        Route::get('/connect/return', [\App\Http\Controllers\Stripe\StripeConnectController::class, 'return'])->name('connect.return');
        Route::get('/connect/refresh', [\App\Http\Controllers\Stripe\StripeConnectController::class, 'refresh'])->name('connect.refresh');
    });
```

- [ ] **Step 4: Run tests**

Run: `php artisan test --compact --filter=StripeConnectTest`

- [ ] **Step 5: Run Pint and commit**

```bash
vendor/bin/pint --dirty --format agent
git add app/Http/Controllers/Stripe/StripeConnectController.php tests/Feature/Stripe/StripeConnectTest.php routes/web.php
git commit -m "feat: add Stripe Express Connect onboarding flow"
```

---

## Task 5: Stripe Billing Controller (Customer + Payment Method)

**Files:**
- Create: `app/Http/Controllers/Stripe/StripeBillingController.php`
- Create: `tests/Feature/Stripe/StripeBillingTest.php`
- Modify: `routes/web.php`

- [ ] **Step 1: Write the tests**

Create `tests/Feature/Stripe/StripeBillingTest.php`:

```php
<?php

use App\Models\Clinician;
use App\Models\User;
use App\Services\StripeService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;

uses(RefreshDatabase::class);

it('creates a customer and redirects to setup session', function () {
    $user = User::factory()->admin()->create();
    $clinician = Clinician::factory()->create(['user_id' => $user->id]);

    Mockery::mock('alias:' . StripeService::class)
        ->shouldReceive('createCustomer')
        ->once()
        ->andReturn('cus_test_456')
        ->shouldReceive('createSetupSession')
        ->once()
        ->andReturn('https://checkout.stripe.com/session/test');

    $this->actingAs($user)
        ->get(route('stripe.billing.setup'))
        ->assertRedirect('https://checkout.stripe.com/session/test');

    expect($clinician->fresh()->stripe_customer_id)->toBe('cus_test_456');
});

it('updates payment method type on return', function () {
    $user = User::factory()->admin()->create();
    $clinician = Clinician::factory()->create([
        'user_id' => $user->id,
        'stripe_customer_id' => 'cus_test_456',
    ]);

    Mockery::mock('alias:' . StripeService::class)
        ->shouldReceive('getDefaultPaymentMethod')
        ->once()
        ->with('cus_test_456')
        ->andReturn(['id' => 'pm_test', 'type' => 'card']);

    $this->actingAs($user)
        ->get(route('stripe.billing.return'))
        ->assertRedirect();

    expect($clinician->fresh()->payment_method_type)->toBe('card');
});

it('requires authentication', function () {
    $this->get(route('stripe.billing.setup'))
        ->assertRedirect(route('login'));
});
```

- [ ] **Step 2: Create the controller**

Create `app/Http/Controllers/Stripe/StripeBillingController.php`:

```php
<?php

namespace App\Http\Controllers\Stripe;

use App\Http\Controllers\Controller;
use App\Services\AuditLogService;
use App\Services\StripeService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

class StripeBillingController extends Controller
{
    public function setup(Request $request): RedirectResponse
    {
        $clinician = $request->user()->clinician;

        if (! $clinician) {
            return redirect()->back()->with('error', 'No clinician profile found.');
        }

        if (! $clinician->stripe_customer_id) {
            $customerId = StripeService::createCustomer($request->user());
            $clinician->update(['stripe_customer_id' => $customerId]);

            AuditLogService::log(
                action: 'stripe.customer_created',
                actor: $request->user(),
                targetType: 'Clinician',
                targetId: $clinician->id,
                targetLabel: $clinician->display_name,
                metadata: ['stripe_customer_id' => $customerId],
            );
        }

        $sessionUrl = StripeService::createSetupSession(
            $clinician->stripe_customer_id,
            route('stripe.billing.return'),
        );

        return redirect()->away($sessionUrl);
    }

    public function return(Request $request): RedirectResponse
    {
        $clinician = $request->user()->clinician;

        if ($clinician?->stripe_customer_id) {
            $paymentMethod = StripeService::getDefaultPaymentMethod($clinician->stripe_customer_id);

            if ($paymentMethod) {
                $clinician->update(['payment_method_type' => $paymentMethod['type']]);

                AuditLogService::log(
                    action: 'stripe.payment_method_updated',
                    actor: $request->user(),
                    targetType: 'Clinician',
                    targetId: $clinician->id,
                    metadata: ['payment_method_type' => $paymentMethod['type']],
                );
            }
        }

        return redirect()->route('dashboard')->with('success', 'Payment method updated.');
    }
}
```

- [ ] **Step 3: Add routes**

Add to the Stripe routes group in `routes/web.php` (alongside the connect routes):

```php
        Route::get('/billing/setup', [\App\Http\Controllers\Stripe\StripeBillingController::class, 'setup'])->name('billing.setup');
        Route::get('/billing/return', [\App\Http\Controllers\Stripe\StripeBillingController::class, 'return'])->name('billing.return');
```

- [ ] **Step 4: Run tests**

Run: `php artisan test --compact --filter=StripeBillingTest`

- [ ] **Step 5: Run Pint and commit**

```bash
vendor/bin/pint --dirty --format agent
git add app/Http/Controllers/Stripe/StripeBillingController.php tests/Feature/Stripe/StripeBillingTest.php routes/web.php
git commit -m "feat: add Stripe Customer billing setup flow"
```

---

## Task 6: Fill Job Stubs — ProcessDisbursementTransfer

**Files:**
- Modify: `app/Jobs/ProcessDisbursementTransfer.php:1-44`
- Modify: `tests/Feature/Jobs/ProcessDisbursementTransferTest.php`

- [ ] **Step 1: Update the test**

Replace `tests/Feature/Jobs/ProcessDisbursementTransferTest.php`:

```php
<?php

use App\Enums\TransferStatus;
use App\Jobs\ProcessDisbursementTransfer;
use App\Models\Batch;
use App\Models\Clinician;
use App\Models\Disbursement;
use App\Models\User;
use App\Services\StripeService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Stripe\Transfer;

uses(RefreshDatabase::class);

it('creates a Stripe transfer and marks disbursement complete', function () {
    $admin = User::factory()->admin()->create();
    $clinician = Clinician::factory()->create(['stripe_connect_id' => 'acct_test_123']);
    $batch = Batch::factory()->create(['processed_by' => $admin->id]);
    $disbursement = Disbursement::factory()->create([
        'batch_id' => $batch->id,
        'clinician_id' => $clinician->id,
        'status' => TransferStatus::Queued,
        'net_amount' => 150.00,
    ]);

    $mockTransfer = Mockery::mock(Transfer::class);
    $mockTransfer->id = 'tr_test_789';

    Mockery::mock('alias:' . StripeService::class)
        ->shouldReceive('createTransfer')
        ->once()
        ->with(15000, 'acct_test_123', Mockery::type('array'))
        ->andReturn($mockTransfer);

    (new ProcessDisbursementTransfer($disbursement))->handle();

    expect($disbursement->fresh()->status)->toBe(TransferStatus::Complete);
    expect($disbursement->fresh()->stripe_transfer_id)->toBe('tr_test_789');
});

it('throws if clinician has no Stripe Connect account', function () {
    $admin = User::factory()->admin()->create();
    $clinician = Clinician::factory()->create(['stripe_connect_id' => null]);
    $batch = Batch::factory()->create(['processed_by' => $admin->id]);
    $disbursement = Disbursement::factory()->create([
        'batch_id' => $batch->id,
        'clinician_id' => $clinician->id,
        'status' => TransferStatus::Queued,
    ]);

    (new ProcessDisbursementTransfer($disbursement))->handle();
})->throws(RuntimeException::class, 'has no Stripe Connect account');

it('marks disbursement as failed on exception', function () {
    $admin = User::factory()->admin()->create();
    $clinician = Clinician::factory()->create(['stripe_connect_id' => 'acct_test_123']);
    $batch = Batch::factory()->create(['processed_by' => $admin->id]);
    $disbursement = Disbursement::factory()->create([
        'batch_id' => $batch->id,
        'clinician_id' => $clinician->id,
        'status' => TransferStatus::Queued,
    ]);

    $job = new ProcessDisbursementTransfer($disbursement);
    $job->failed(new RuntimeException('Stripe API error'));

    expect($disbursement->fresh()->status)->toBe(TransferStatus::Failed);
    expect($disbursement->fresh()->error_message)->toBe('Stripe API error');
});
```

- [ ] **Step 2: Update the job**

Replace `app/Jobs/ProcessDisbursementTransfer.php`:

```php
<?php

namespace App\Jobs;

use App\Enums\TransferStatus;
use App\Models\Disbursement;
use App\Services\StripeService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class ProcessDisbursementTransfer implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;

    /** @var int[] */
    public array $backoff = [1, 5, 10];

    public function __construct(public Disbursement $disbursement) {}

    public function handle(): void
    {
        $this->disbursement->update(['status' => TransferStatus::Processing]);

        $clinician = $this->disbursement->clinician;

        if (! $clinician->stripe_connect_id) {
            throw new \RuntimeException("Clinician {$clinician->clinician_code} has no Stripe Connect account.");
        }

        $amountCents = (int) round($this->disbursement->net_amount * 100);

        $transfer = StripeService::createTransfer(
            amountCents: $amountCents,
            destinationAccountId: $clinician->stripe_connect_id,
            metadata: [
                'disbursement_id' => $this->disbursement->disbursement_id_display,
                'clinician_code' => $clinician->clinician_code,
            ],
        );

        $this->disbursement->update([
            'status' => TransferStatus::Complete,
            'stripe_transfer_id' => $transfer->id,
        ]);
    }

    public function failed(\Throwable $exception): void
    {
        $this->disbursement->update([
            'status' => TransferStatus::Failed,
            'error_message' => $exception->getMessage(),
        ]);
    }
}
```

- [ ] **Step 3: Run tests**

Run: `php artisan test --compact --filter=ProcessDisbursementTransferTest`

- [ ] **Step 4: Run Pint and commit**

```bash
vendor/bin/pint --dirty --format agent
git add app/Jobs/ProcessDisbursementTransfer.php tests/Feature/Jobs/ProcessDisbursementTransferTest.php
git commit -m "feat: replace disbursement transfer stub with real Stripe Transfer call"
```

---

## Task 7: Fill Job Stubs — ProcessFeeCharge

**Files:**
- Modify: `app/Jobs/ProcessFeeCharge.php:1-48`
- Modify: `tests/Feature/Jobs/ProcessFeeChargeTest.php`

- [ ] **Step 1: Update the test**

Replace `tests/Feature/Jobs/ProcessFeeChargeTest.php`:

```php
<?php

use App\Enums\ChargeStatus;
use App\Jobs\ProcessFeeCharge;
use App\Models\Charge;
use App\Models\Clinician;
use App\Services\StripeService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Stripe\PaymentIntent;

uses(RefreshDatabase::class);

it('creates a Stripe PaymentIntent and settles charges', function () {
    $clinician = Clinician::factory()->create([
        'stripe_customer_id' => 'cus_test_456',
    ]);
    $charge1 = Charge::factory()->create(['clinician_id' => $clinician->id, 'status' => ChargeStatus::Pending, 'amount' => 15.00]);
    $charge2 = Charge::factory()->create(['clinician_id' => $clinician->id, 'status' => ChargeStatus::Pending, 'amount' => 6.00]);

    $mockIntent = Mockery::mock(PaymentIntent::class);
    $mockIntent->id = 'pi_test_000';

    $mockService = Mockery::mock('alias:' . StripeService::class);
    $mockService->shouldReceive('getDefaultPaymentMethod')
        ->once()
        ->with('cus_test_456')
        ->andReturn(['id' => 'pm_test_card', 'type' => 'card']);
    $mockService->shouldReceive('createPaymentIntent')
        ->once()
        ->with(2100, 'cus_test_456', 'pm_test_card', Mockery::type('array'))
        ->andReturn($mockIntent);

    (new ProcessFeeCharge($clinician, [$charge1->id, $charge2->id]))->handle();

    expect($charge1->fresh()->status)->toBe(ChargeStatus::Settled);
    expect($charge1->fresh()->settlement_id)->not->toBeNull();
    expect($charge1->fresh()->stripe_charge_id)->toBe('pi_test_000');
    expect($charge2->fresh()->status)->toBe(ChargeStatus::Settled);
});

it('throws if clinician has no payment method', function () {
    $clinician = Clinician::factory()->create([
        'stripe_customer_id' => 'cus_test_456',
    ]);
    $charge = Charge::factory()->create(['clinician_id' => $clinician->id, 'status' => ChargeStatus::Pending]);

    Mockery::mock('alias:' . StripeService::class)
        ->shouldReceive('getDefaultPaymentMethod')
        ->once()
        ->andReturn(null);

    (new ProcessFeeCharge($clinician, [$charge->id]))->handle();
})->throws(RuntimeException::class, 'has no payment method');
```

- [ ] **Step 2: Update the job**

Replace `app/Jobs/ProcessFeeCharge.php`:

```php
<?php

namespace App\Jobs;

use App\Models\Charge;
use App\Models\Clinician;
use App\Services\ChargeService;
use App\Services\StripeService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;

class ProcessFeeCharge implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;

    /** @var int[] */
    public array $backoff = [1, 5, 10];

    /**
     * @param  int[]  $chargeIds
     */
    public function __construct(
        public Clinician $clinician,
        public array $chargeIds,
    ) {}

    public function handle(): void
    {
        $clinician = $this->clinician;

        if (! $clinician->stripe_customer_id) {
            $customerId = StripeService::createCustomer($clinician->user);
            $clinician->update(['stripe_customer_id' => $customerId]);
        }

        $paymentMethod = StripeService::getDefaultPaymentMethod($clinician->stripe_customer_id);

        if (! $paymentMethod) {
            throw new \RuntimeException("Clinician {$clinician->clinician_code} has no payment method on file.");
        }

        $charges = Charge::query()->whereIn('id', $this->chargeIds)->get();
        $totalCents = (int) round($charges->sum('amount') * 100);

        $paymentIntent = StripeService::createPaymentIntent(
            amountCents: $totalCents,
            customerId: $clinician->stripe_customer_id,
            paymentMethodId: $paymentMethod['id'],
            metadata: [
                'clinician_code' => $clinician->clinician_code,
                'charge_count' => count($this->chargeIds),
            ],
        );

        ChargeService::processSettlement($this->chargeIds, $clinician->clinician_code);

        Charge::query()->whereIn('id', $this->chargeIds)->update([
            'stripe_charge_id' => $paymentIntent->id,
        ]);
    }

    public function failed(\Throwable $exception): void
    {
        Log::error('Fee charge processing failed', [
            'clinician_id' => $this->clinician->id,
            'charge_ids' => $this->chargeIds,
            'error' => $exception->getMessage(),
        ]);
    }
}
```

- [ ] **Step 3: Run tests**

Run: `php artisan test --compact --filter=ProcessFeeChargeTest`

- [ ] **Step 4: Run Pint and commit**

```bash
vendor/bin/pint --dirty --format agent
git add app/Jobs/ProcessFeeCharge.php tests/Feature/Jobs/ProcessFeeChargeTest.php
git commit -m "feat: replace fee charge stub with real Stripe PaymentIntent call"
```

---

## Task 8: Webhook Controller

**Files:**
- Create: `app/Http/Controllers/Stripe/StripeWebhookController.php`
- Create: `tests/Feature/Stripe/StripeWebhookTest.php`
- Modify: `routes/web.php`

- [ ] **Step 1: Write the tests**

Create `tests/Feature/Stripe/StripeWebhookTest.php`:

```php
<?php

use App\Enums\TransferStatus;
use App\Models\Batch;
use App\Models\Clinician;
use App\Models\Disbursement;
use App\Models\User;
use App\Services\StripeService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Stripe\Event;

uses(RefreshDatabase::class);

function makeStripeEvent(string $type, object $object): Event
{
    $event = new Event;
    $event->type = $type;
    $event->data = (object) ['object' => $object];

    return $event;
}

it('handles transfer.paid event', function () {
    $admin = User::factory()->admin()->create();
    $clinician = Clinician::factory()->create();
    $batch = Batch::factory()->create(['processed_by' => $admin->id]);
    $disbursement = Disbursement::factory()->create([
        'batch_id' => $batch->id,
        'clinician_id' => $clinician->id,
        'stripe_transfer_id' => 'tr_test_789',
        'status' => TransferStatus::Processing,
    ]);

    $transferObj = (object) ['id' => 'tr_test_789'];
    $event = makeStripeEvent('transfer.paid', $transferObj);

    Mockery::mock('alias:' . StripeService::class)
        ->shouldReceive('constructWebhookEvent')
        ->once()
        ->andReturn($event);

    $this->postJson('/stripe/webhook', [], ['Stripe-Signature' => 'test_sig'])
        ->assertStatus(200);

    expect($disbursement->fresh()->status)->toBe(TransferStatus::Complete);
});

it('handles transfer.failed event', function () {
    $admin = User::factory()->admin()->create();
    $clinician = Clinician::factory()->create();
    $batch = Batch::factory()->create(['processed_by' => $admin->id]);
    $disbursement = Disbursement::factory()->create([
        'batch_id' => $batch->id,
        'clinician_id' => $clinician->id,
        'stripe_transfer_id' => 'tr_test_fail',
        'status' => TransferStatus::Processing,
    ]);

    $transferObj = (object) ['id' => 'tr_test_fail', 'failure_message' => 'Insufficient funds'];
    $event = makeStripeEvent('transfer.failed', $transferObj);

    Mockery::mock('alias:' . StripeService::class)
        ->shouldReceive('constructWebhookEvent')
        ->once()
        ->andReturn($event);

    $this->postJson('/stripe/webhook', [], ['Stripe-Signature' => 'test_sig'])
        ->assertStatus(200);

    expect($disbursement->fresh()->status)->toBe(TransferStatus::Failed);
    expect($disbursement->fresh()->error_message)->toBe('Insufficient funds');
});

it('handles account.updated event', function () {
    $clinician = Clinician::factory()->create([
        'stripe_connect_id' => 'acct_test_updated',
        'stripe_account_status' => 'pending',
    ]);

    $accountObj = (object) [
        'id' => 'acct_test_updated',
        'charges_enabled' => true,
        'payouts_enabled' => true,
    ];
    $event = makeStripeEvent('account.updated', $accountObj);

    Mockery::mock('alias:' . StripeService::class)
        ->shouldReceive('constructWebhookEvent')
        ->once()
        ->andReturn($event);

    $this->postJson('/stripe/webhook', [], ['Stripe-Signature' => 'test_sig'])
        ->assertStatus(200);

    expect($clinician->fresh()->stripe_account_status)->toBe('active');
});

it('returns 400 on invalid signature', function () {
    Mockery::mock('alias:' . StripeService::class)
        ->shouldReceive('constructWebhookEvent')
        ->once()
        ->andThrow(new \Stripe\Exception\SignatureVerificationException('Invalid signature'));

    $this->postJson('/stripe/webhook', [], ['Stripe-Signature' => 'bad_sig'])
        ->assertStatus(400);
});

it('ignores unknown event types', function () {
    $event = makeStripeEvent('unknown.event', (object) []);

    Mockery::mock('alias:' . StripeService::class)
        ->shouldReceive('constructWebhookEvent')
        ->once()
        ->andReturn($event);

    $this->postJson('/stripe/webhook', [], ['Stripe-Signature' => 'test_sig'])
        ->assertStatus(200);
});
```

- [ ] **Step 2: Create the webhook controller**

Create `app/Http/Controllers/Stripe/StripeWebhookController.php`:

```php
<?php

namespace App\Http\Controllers\Stripe;

use App\Enums\TransferStatus;
use App\Http\Controllers\Controller;
use App\Models\Charge;
use App\Models\Clinician;
use App\Models\Disbursement;
use App\Services\AuditLogService;
use App\Services\StripeService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Stripe\Event;
use Stripe\Exception\SignatureVerificationException;

class StripeWebhookController extends Controller
{
    public function handle(Request $request): Response
    {
        try {
            $event = StripeService::constructWebhookEvent(
                $request->getContent(),
                $request->header('Stripe-Signature', ''),
            );
        } catch (SignatureVerificationException) {
            return response('Invalid signature', 400);
        }

        match ($event->type) {
            'transfer.paid' => $this->handleTransferPaid($event),
            'transfer.failed' => $this->handleTransferFailed($event),
            'payment_intent.succeeded' => $this->handlePaymentSucceeded($event),
            'payment_intent.payment_failed' => $this->handlePaymentFailed($event),
            'account.updated' => $this->handleAccountUpdated($event),
            default => null,
        };

        return response('OK', 200);
    }

    private function handleTransferPaid(Event $event): void
    {
        $transferId = $event->data->object->id;
        $disbursement = Disbursement::query()->where('stripe_transfer_id', $transferId)->first();

        if ($disbursement && $disbursement->status !== TransferStatus::Complete) {
            $disbursement->update(['status' => TransferStatus::Complete]);

            AuditLogService::log(
                action: 'stripe.transfer_paid',
                targetType: 'Disbursement',
                targetId: $disbursement->id,
                targetLabel: $disbursement->disbursement_id_display,
                metadata: ['stripe_transfer_id' => $transferId],
            );
        }
    }

    private function handleTransferFailed(Event $event): void
    {
        $obj = $event->data->object;
        $disbursement = Disbursement::query()->where('stripe_transfer_id', $obj->id)->first();

        if ($disbursement && $disbursement->status !== TransferStatus::Failed) {
            $disbursement->update([
                'status' => TransferStatus::Failed,
                'error_message' => $obj->failure_message ?? 'Transfer failed',
            ]);

            AuditLogService::log(
                action: 'stripe.transfer_failed',
                targetType: 'Disbursement',
                targetId: $disbursement->id,
                metadata: ['error' => $obj->failure_message ?? 'Unknown'],
            );

            // Notify admin
            $admin = \App\Models\User::where('role', \App\Enums\UserRole::Admin)->first();
            $admin?->notify(new \App\Notifications\PayoutFailedNotification($disbursement, $obj->failure_message ?? 'Transfer failed'));
        }
    }

    private function handlePaymentSucceeded(Event $event): void
    {
        $intentId = $event->data->object->id;

        AuditLogService::log(
            action: 'stripe.payment_succeeded',
            metadata: ['payment_intent_id' => $intentId],
        );
    }

    private function handlePaymentFailed(Event $event): void
    {
        $obj = $event->data->object;
        $errorMsg = $obj->last_payment_error->message ?? 'Unknown';

        AuditLogService::log(
            action: 'stripe.payment_failed',
            metadata: [
                'payment_intent_id' => $obj->id,
                'error' => $errorMsg,
            ],
        );

        // Find clinician from metadata and notify admin
        $clinicianCode = $obj->metadata->clinician_code ?? null;
        if ($clinicianCode) {
            $clinician = Clinician::query()->whereHas('user', fn () => true)->where('clinician_code', $clinicianCode)->first();
            if ($clinician) {
                $admin = \App\Models\User::where('role', \App\Enums\UserRole::Admin)->first();
                $admin?->notify(new \App\Notifications\ChargeFailedNotification($clinician, $errorMsg));
            }
        }
    }

    private function handleAccountUpdated(Event $event): void
    {
        $obj = $event->data->object;
        $clinician = Clinician::query()->where('stripe_connect_id', $obj->id)->first();

        if ($clinician) {
            $status = ($obj->charges_enabled && $obj->payouts_enabled) ? 'active' : 'pending';

            if ($clinician->stripe_account_status !== $status) {
                $clinician->update(['stripe_account_status' => $status]);

                AuditLogService::log(
                    action: 'stripe.account_status_updated',
                    targetType: 'Clinician',
                    targetId: $clinician->id,
                    targetLabel: $clinician->display_name,
                    metadata: ['status' => $status],
                );
            }
        }
    }
}
```

- [ ] **Step 3: Add webhook route**

Add to `routes/web.php` **outside** all middleware groups (at the top level, before the root redirect):

```php
// Stripe webhook (no auth, no CSRF)
Route::post('/stripe/webhook', [\App\Http\Controllers\Stripe\StripeWebhookController::class, 'handle'])
    ->name('stripe.webhook')
    ->withoutMiddleware([\Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class]);
```

- [ ] **Step 4: Run tests**

Run: `php artisan test --compact --filter=StripeWebhookTest`

- [ ] **Step 5: Run Pint and commit**

```bash
vendor/bin/pint --dirty --format agent
git add app/Http/Controllers/Stripe/StripeWebhookController.php tests/Feature/Stripe/StripeWebhookTest.php routes/web.php
git commit -m "feat: add Stripe webhook controller for 5 event types"
```

---

## Task 9: Transactional Emails — Disbursement Receipt

**Files:**
- Create: `app/Mail/DisbursementReceipt.php`
- Create: `resources/views/mail/disbursement-receipt.blade.php`
- Create: `tests/Feature/Mail/DisbursementReceiptTest.php`

- [ ] **Step 1: Write the test**

Create `tests/Feature/Mail/DisbursementReceiptTest.php`:

```php
<?php

use App\Mail\DisbursementReceipt;
use App\Models\Batch;
use App\Models\Clinician;
use App\Models\Disbursement;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

it('renders disbursement receipt with correct data', function () {
    $admin = User::factory()->admin()->create();
    $clinician = Clinician::factory()->create();
    $batch = Batch::factory()->create(['processed_by' => $admin->id]);
    $disbursement = Disbursement::factory()->create([
        'batch_id' => $batch->id,
        'clinician_id' => $clinician->id,
        'session_count' => 5,
        'gross_amount' => 500.00,
        'stripe_fee' => 0.25,
        'net_amount' => 499.75,
        'stripe_transfer_id' => 'tr_test_789',
        'oldest_dos' => '2026-03-01',
        'newest_dos' => '2026-03-15',
    ]);

    $mailable = new DisbursementReceipt($disbursement);

    $mailable->assertSeeInHtml('500.00');
    $mailable->assertSeeInHtml('499.75');
    $mailable->assertSeeInHtml('0.25');
    $mailable->assertSeeInHtml('5 sessions');
    $mailable->assertSeeInHtml('tr_test_789');
});
```

- [ ] **Step 2: Create the Mailable**

Create `app/Mail/DisbursementReceipt.php`:

```php
<?php

namespace App\Mail;

use App\Models\Disbursement;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

class DisbursementReceipt extends Mailable implements ShouldQueue
{
    use Queueable, SerializesModels;

    public function __construct(public Disbursement $disbursement) {}

    public function envelope(): Envelope
    {
        return new Envelope(
            subject: 'Payout Processed — Pluto Hub',
        );
    }

    public function content(): Content
    {
        return new Content(
            view: 'mail.disbursement-receipt',
        );
    }
}
```

- [ ] **Step 3: Create the email template**

Create `resources/views/mail/disbursement-receipt.blade.php`:

```blade
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <style>
        body { margin: 0; padding: 0; background: #f3f4f6; font-family: -apple-system, sans-serif; }
        .container { max-width: 600px; margin: 0 auto; background: white; }
        .header { background: #1e3a4f; padding: 24px; text-align: center; }
        .header h1 { color: white; margin: 0; font-size: 24px; letter-spacing: 0.5px; }
        .body { padding: 32px 24px; color: #374151; line-height: 1.6; }
        .row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #e5e7eb; }
        .label { color: #6b7280; font-size: 14px; }
        .value { font-weight: 600; font-size: 14px; }
        .total { font-size: 18px; color: #1e3a4f; font-weight: 700; }
        .footer { background: #f9fafb; padding: 16px 24px; text-align: center; color: #9ca3af; font-size: 12px; border-top: 1px solid #e5e7eb; }
    </style>
</head>
<body>
    <div class="container">
        <div class="header">
            <h1>Pluto Hub</h1>
        </div>
        <div class="body">
            <p>Your payout has been processed.</p>

            <table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
                <tr style="border-bottom: 1px solid #e5e7eb;">
                    <td style="padding: 8px 0; color: #6b7280; font-size: 14px;">Sessions</td>
                    <td style="padding: 8px 0; font-weight: 600; text-align: right;">{{ $disbursement->session_count }} sessions</td>
                </tr>
                <tr style="border-bottom: 1px solid #e5e7eb;">
                    <td style="padding: 8px 0; color: #6b7280; font-size: 14px;">Date Range</td>
                    <td style="padding: 8px 0; font-weight: 600; text-align: right;">{{ $disbursement->oldest_dos->format('m/d/Y') }} – {{ $disbursement->newest_dos->format('m/d/Y') }}</td>
                </tr>
                <tr style="border-bottom: 1px solid #e5e7eb;">
                    <td style="padding: 8px 0; color: #6b7280; font-size: 14px;">Gross Amount</td>
                    <td style="padding: 8px 0; font-weight: 600; text-align: right;">${{ number_format($disbursement->gross_amount, 2) }}</td>
                </tr>
                <tr style="border-bottom: 1px solid #e5e7eb;">
                    <td style="padding: 8px 0; color: #6b7280; font-size: 14px;">Payout Fee</td>
                    <td style="padding: 8px 0; font-weight: 600; text-align: right;">-${{ number_format($disbursement->stripe_fee, 2) }}</td>
                </tr>
                <tr>
                    <td style="padding: 12px 0; font-size: 16px; font-weight: 700; color: #1e3a4f;">Net Payout</td>
                    <td style="padding: 12px 0; font-size: 16px; font-weight: 700; color: #1e3a4f; text-align: right;">${{ number_format($disbursement->net_amount, 2) }}</td>
                </tr>
            </table>

            <p style="font-size: 13px; color: #6b7280;">Payout ID: {{ $disbursement->stripe_transfer_id }}</p>
        </div>
        <div class="footer">
            <p>Pluto Mental Health &middot; Pluto Hub</p>
        </div>
    </div>
</body>
</html>
```

- [ ] **Step 4: Run test**

Run: `php artisan test --compact --filter=DisbursementReceiptTest`

- [ ] **Step 5: Run Pint and commit**

```bash
vendor/bin/pint --dirty --format agent
git add app/Mail/DisbursementReceipt.php resources/views/mail/disbursement-receipt.blade.php tests/Feature/Mail/DisbursementReceiptTest.php
git commit -m "feat: add queued disbursement receipt email"
```

---

## Task 10: Transactional Emails — Fee Settlement Receipt

**Files:**
- Create: `app/Mail/FeeSettlementReceipt.php`
- Create: `resources/views/mail/fee-settlement-receipt.blade.php`
- Create: `tests/Feature/Mail/FeeSettlementReceiptTest.php`

- [ ] **Step 1: Write the test**

Create `tests/Feature/Mail/FeeSettlementReceiptTest.php`:

```php
<?php

use App\Enums\ChargeStatus;
use App\Mail\FeeSettlementReceipt;
use App\Models\Charge;
use App\Models\Clinician;
use App\Models\ServiceCatalog;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

it('renders fee settlement receipt with correct data', function () {
    $clinician = Clinician::factory()->create();
    $service = ServiceCatalog::factory()->create(['title' => 'Billing - IP']);
    $charge = Charge::factory()->settled()->create([
        'clinician_id' => $clinician->id,
        'service_catalog_id' => $service->id,
        'amount' => 15.00,
        'settlement_id' => 'stl_A01-260404-01',
    ]);

    $charges = Charge::where('clinician_id', $clinician->id)->with('serviceCatalog')->get();
    $mailable = new FeeSettlementReceipt($clinician, $charges, 'stl_A01-260404-01');

    $mailable->assertSeeInHtml('15.00');
    $mailable->assertSeeInHtml('Billing - IP');
    $mailable->assertSeeInHtml('stl_A01-260404-01');
});
```

- [ ] **Step 2: Create the Mailable**

Create `app/Mail/FeeSettlementReceipt.php`:

```php
<?php

namespace App\Mail;

use App\Models\Clinician;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

class FeeSettlementReceipt extends Mailable implements ShouldQueue
{
    use Queueable, SerializesModels;

    public function __construct(
        public Clinician $clinician,
        public Collection $charges,
        public string $settlementId,
    ) {}

    public function envelope(): Envelope
    {
        return new Envelope(
            subject: 'Fee Settlement Processed — Pluto Hub',
        );
    }

    public function content(): Content
    {
        return new Content(
            view: 'mail.fee-settlement-receipt',
        );
    }
}
```

- [ ] **Step 3: Create the email template**

Create `resources/views/mail/fee-settlement-receipt.blade.php`:

```blade
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <style>
        body { margin: 0; padding: 0; background: #f3f4f6; font-family: -apple-system, sans-serif; }
        .container { max-width: 600px; margin: 0 auto; background: white; }
        .header { background: #1e3a4f; padding: 24px; text-align: center; }
        .header h1 { color: white; margin: 0; font-size: 24px; letter-spacing: 0.5px; }
        .body { padding: 32px 24px; color: #374151; line-height: 1.6; }
        .footer { background: #f9fafb; padding: 16px 24px; text-align: center; color: #9ca3af; font-size: 12px; border-top: 1px solid #e5e7eb; }
    </style>
</head>
<body>
    <div class="container">
        <div class="header">
            <h1>Pluto Hub</h1>
        </div>
        <div class="body">
            <p>Your admin support service charges have been settled.</p>

            <table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
                <tr style="background: #f9fafb; border-bottom: 1px solid #e5e7eb;">
                    <th style="padding: 8px; text-align: left; font-size: 12px; text-transform: uppercase; color: #6b7280;">Service</th>
                    <th style="padding: 8px; text-align: right; font-size: 12px; text-transform: uppercase; color: #6b7280;">Amount</th>
                </tr>
                @foreach ($charges as $charge)
                    <tr style="border-bottom: 1px solid #e5e7eb;">
                        <td style="padding: 8px; font-size: 14px;">{{ $charge->serviceCatalog?->title ?? 'Charge' }}</td>
                        <td style="padding: 8px; font-size: 14px; text-align: right;">${{ number_format($charge->amount, 2) }}</td>
                    </tr>
                @endforeach
                <tr>
                    <td style="padding: 12px 8px; font-weight: 700; font-size: 15px; color: #1e3a4f;">Total</td>
                    <td style="padding: 12px 8px; font-weight: 700; font-size: 15px; color: #1e3a4f; text-align: right;">${{ number_format($charges->sum('amount'), 2) }}</td>
                </tr>
            </table>

            <p style="font-size: 13px; color: #6b7280;">
                Payment method: {{ $clinician->payment_method_type === 'card' ? 'Credit/debit card' : 'ACH bank transfer' }}<br>
                Settlement ID: {{ $settlementId }}
            </p>
        </div>
        <div class="footer">
            <p>Pluto Mental Health &middot; Pluto Hub</p>
        </div>
    </div>
</body>
</html>
```

- [ ] **Step 4: Run test**

Run: `php artisan test --compact --filter=FeeSettlementReceiptTest`

- [ ] **Step 5: Run Pint and commit**

```bash
vendor/bin/pint --dirty --format agent
git add app/Mail/FeeSettlementReceipt.php resources/views/mail/fee-settlement-receipt.blade.php tests/Feature/Mail/FeeSettlementReceiptTest.php
git commit -m "feat: add queued fee settlement receipt email"
```

---

## Task 11: Admin Failure Notifications

**Files:**
- Create: `app/Notifications/PayoutFailedNotification.php`
- Create: `app/Notifications/ChargeFailedNotification.php`

- [ ] **Step 1: Create PayoutFailedNotification**

Run: `php artisan make:notification PayoutFailedNotification --no-interaction`

Replace with:

```php
<?php

namespace App\Notifications;

use App\Models\Disbursement;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

class PayoutFailedNotification extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(public Disbursement $disbursement, public string $error) {}

    public function via(object $notifiable): array
    {
        return ['mail'];
    }

    public function toMail(object $notifiable): MailMessage
    {
        $clinician = $this->disbursement->clinician;

        return (new MailMessage)
            ->subject('Payout Failed — Action Required')
            ->line("A payout to {$clinician->display_name} ({$clinician->clinician_code}) has failed.")
            ->line("Amount: \${$this->disbursement->net_amount}")
            ->line("Error: {$this->error}")
            ->line("Disbursement: {$this->disbursement->disbursement_id_display}")
            ->action('View Disbursements', url('/admin/completed-disbursements'));
    }
}
```

- [ ] **Step 2: Create ChargeFailedNotification**

Run: `php artisan make:notification ChargeFailedNotification --no-interaction`

Replace with:

```php
<?php

namespace App\Notifications;

use App\Models\Clinician;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

class ChargeFailedNotification extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(public Clinician $clinician, public string $error) {}

    public function via(object $notifiable): array
    {
        return ['mail'];
    }

    public function toMail(object $notifiable): MailMessage
    {
        return (new MailMessage)
            ->subject('Fee Charge Failed — Action Required')
            ->line("A fee charge for {$this->clinician->display_name} ({$this->clinician->clinician_code}) has failed.")
            ->line("Error: {$this->error}")
            ->action('View Fee Processing', url('/admin/fee-processing'));
    }
}
```

- [ ] **Step 3: Run Pint and commit**

```bash
vendor/bin/pint --dirty --format agent
git add app/Notifications/PayoutFailedNotification.php app/Notifications/ChargeFailedNotification.php
git commit -m "feat: add admin failure notifications for payouts and charges"
```

---

## Task 12: Final Integration — Run All Tests

- [ ] **Step 1: Run the full test suite**

```bash
php artisan test --compact
```

Expected: All tests pass.

- [ ] **Step 2: Fix any failures**

Diagnose and fix. Common issues:
- Mockery alias conflicts between tests (add `Mockery::close()` in teardown or use `afterEach`)
- Missing `stripe_connect_id` default in ClinicianFactory (add to factory if needed)

- [ ] **Step 3: Run Pint on everything**

```bash
vendor/bin/pint --format agent
```

- [ ] **Step 4: Final commit if needed**

```bash
git add -A
git commit -m "fix: resolve integration test issues from Phase 4 Stripe integration"
```
