# Phase 4 — Stripe Integration Design

> **Date:** 2026-04-04
> **Status:** Approved
> **Scope:** Full Stripe integration: Express Connect, Customer/payments, API calls, webhooks, transactional emails

---

## Context

PlutoHub needs Stripe to handle two financial flows:
1. **Disbursements** — pay clinicians via Stripe Express Connect transfers
2. **Fee collection** — charge clinicians for admin support services via Stripe Customer + PaymentIntent

The codebase already has job stubs (`ProcessDisbursementTransfer`, `ProcessFeeCharge`), database schema for Stripe IDs, a `TransferStatus` enum, and queue infrastructure. This spec fills in the actual Stripe API integration.

### Constraints
- cPanel shared hosting: no persistent workers, cron-based queue (`queue:work --stop-when-empty` every minute)
- Stripe account fully set up with Express Connect enabled
- All Stripe SDK calls go through a `StripeService` wrapper (consistent with existing service pattern)
- Webhook signature verification via official SDK
- Transactional emails queued, not synchronous

---

## 1. Infrastructure & Configuration

### Package
Install `stripe/stripe-php` via Composer.

### Config
Add to `config/services.php`:
```php
'stripe' => [
    'secret' => env('STRIPE_SECRET_KEY'),
    'public' => env('STRIPE_PUBLIC_KEY'),
    'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
],
```

### Environment
Add to `.env.example`:
```
STRIPE_PUBLIC_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
```

### Migration
Add to `clinicians` table:
- `stripe_connect_id` — nullable string, Express account ID
- `stripe_customer_id` — nullable string, Customer ID for fee collection
- `stripe_account_status` — nullable string (pending/active/restricted), default null

### Model Update
Add new fields to `Clinician` model `$fillable`.

---

## 2. StripeService

`app/Services/StripeService.php` — thin wrapper around all Stripe SDK calls.

### Express Connect Methods

**`createExpressAccount(Clinician $clinician): string`**
- Creates a Stripe Express account via `Account::create(['type' => 'express', ...])`
- Sets business type, capabilities (transfers), country, email from clinician's user
- Returns the account ID string

**`createOnboardingLink(string $accountId, string $returnUrl, string $refreshUrl): string`**
- Creates an AccountLink via `AccountLink::create([...])`
- Type: `account_onboarding`
- Returns the URL to redirect the clinician to

**`getAccountStatus(string $accountId): array`**
- Retrieves the account via `Account::retrieve($accountId)`
- Returns: `['charges_enabled' => bool, 'payouts_enabled' => bool, 'details_submitted' => bool]`

### Customer Methods

**`createCustomer(User $user): string`**
- Creates a Stripe Customer via `Customer::create(['email' => ..., 'name' => ..., 'metadata' => [...]])`
- Returns the customer ID string

**`createSetupSession(string $customerId, string $returnUrl): string`**
- Creates a Checkout Session in `setup` mode for the customer to add a payment method
- Returns the session URL

**`getDefaultPaymentMethod(string $customerId): ?array`**
- Retrieves the customer's default payment method
- Returns `['id' => string, 'type' => 'card'|'us_bank_account']` or null

### Transfer Methods

**`createTransfer(int $amountCents, string $destinationAccountId, array $metadata = []): Transfer`**
- Creates a Transfer via `Transfer::create([...])`
- Returns the Transfer object

### PaymentIntent Methods

**`createPaymentIntent(int $amountCents, string $customerId, string $paymentMethodId, array $metadata = []): PaymentIntent`**
- Creates and confirms a PaymentIntent via `PaymentIntent::create([..., 'confirm' => true])`
- Returns the PaymentIntent object

### Webhook Methods

**`constructWebhookEvent(string $payload, string $signature): Event`**
- Verifies and constructs a Stripe Event via `Webhook::constructEvent($payload, $signature, $webhookSecret)`
- Throws `SignatureVerificationException` on failure

### Error Handling
All methods catch `ApiErrorException` and re-throw as domain-specific exceptions (e.g., `StripeTransferException`, `StripePaymentException`) with the Stripe error message for logging.

---

## 3. Express Connect — Clinician Onboarding

### Flow
1. Clinician clicks "Connect Bank Account" in settings/onboarding
2. Hub calls `StripeService::createExpressAccount()` → stores `stripe_connect_id` and sets `stripe_account_status = 'pending'`
3. Hub generates onboarding link → redirects clinician to Stripe
4. Clinician completes verification on Stripe
5. Stripe redirects back to Hub return URL
6. Hub checks status via `StripeService::getAccountStatus()` → updates `stripe_account_status`
7. Ongoing: `account.updated` webhook keeps status in sync

### Routes
```
GET  /stripe/connect/start    → StripeConnectController@start
GET  /stripe/connect/return   → StripeConnectController@return
GET  /stripe/connect/refresh  → StripeConnectController@refresh
```

All routes require auth. The `start` route creates the account (if not exists) and redirects. The `return` route checks status and redirects back to settings. The `refresh` route re-generates a link if the original expired.

### Controller: `StripeConnectController`

**`start()`:**
1. Get authenticated user's clinician record
2. If no `stripe_connect_id`: call `StripeService::createExpressAccount()`, save ID
3. Generate onboarding link with return/refresh URLs
4. Redirect to Stripe

**`return()`:**
1. Check account status via `StripeService::getAccountStatus()`
2. Update `stripe_account_status` based on `charges_enabled` / `payouts_enabled`
3. Audit log
4. Redirect to settings page with success/pending flash message

**`refresh()`:**
1. Re-generate onboarding link
2. Redirect to Stripe

### UI
- Button in user settings: "Connect Bank Account" or "Manage Bank Account"
- Status indicator: "Not connected" / "Pending verification" / "Active"
- If connected + active: show "Bank account connected" with green badge

---

## 4. Stripe Customer — Fee Payment Setup

### Flow
1. Clinician clicks "Set Up Payment Method" in settings
2. Hub creates a Stripe Customer if none exists → stores `stripe_customer_id`
3. Hub creates a Checkout setup session → redirects clinician to Stripe
4. Clinician adds ACH or card on Stripe's hosted page
5. Stripe redirects back to Hub
6. Hub queries customer's payment methods → updates `payment_method_type` (ach/card)

### Routes
```
GET  /stripe/billing/setup    → StripeBillingController@setup
GET  /stripe/billing/return   → StripeBillingController@return
```

### Controller: `StripeBillingController`

**`setup()`:**
1. Get clinician record
2. If no `stripe_customer_id`: call `StripeService::createCustomer()`, save ID
3. Create setup session with return URL
4. Redirect to session URL

**`return()`:**
1. Query customer's default payment method via `StripeService::getDefaultPaymentMethod()`
2. Update `payment_method_type` on clinician (ach/card) based on method type
3. Redirect to settings with success flash

### UI
- Button: "Set Up Payment Method" / "Update Payment Method"
- Status: "No payment method" / "ACH on file" / "Card on file"
- Note: "ACH is preferred. Card payments incur a 3.5% surcharge."

---

## 5. Fill Job Stubs

### ProcessDisbursementTransfer

Replace the current stub:

```php
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,
            'batch_id' => $this->disbursement->batch->batch_id,
            'clinician_code' => $clinician->clinician_code,
        ],
    );

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

    // Queue disbursement receipt email
    Mail::to($clinician->user)->queue(new DisbursementReceipt($this->disbursement));
}
```

### ProcessFeeCharge

Replace the current stub:

```php
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::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),
        ],
    );

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

    // Store Stripe reference on each charge
    Charge::whereIn('id', $this->chargeIds)->update([
        'stripe_charge_id' => $paymentIntent->id,
    ]);

    // Queue fee settlement receipt email
    Mail::to($clinician->user)->queue(new FeeSettlementReceipt($clinician, $charges, $settlementId));
}
```

---

## 6. Webhook Handler

### Route
```php
Route::post('/stripe/webhook', [StripeWebhookController::class, 'handle'])
    ->name('stripe.webhook')
    ->withoutMiddleware([\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class]);
```

Registered outside the auth middleware group — Stripe sends webhooks unauthenticated.

### Controller: `StripeWebhookController`

```php
public function handle(Request $request): Response
{
    $event = StripeService::constructWebhookEvent(
        $request->getContent(),
        $request->header('Stripe-Signature'),
    );

    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, // Ignore unhandled events
    };

    return response('OK', 200);
}
```

### Event Handlers

**`handleTransferPaid(Event $event)`:**
- Find Disbursement by `stripe_transfer_id = $event->data->object->id`
- If found and status != Complete: update status to Complete
- Audit log

**`handleTransferFailed(Event $event)`:**
- Find Disbursement by `stripe_transfer_id`
- Update status to Failed, store error from `$event->data->object->failure_message`
- Send `PayoutFailedNotification` to admin
- Audit log

**`handlePaymentSucceeded(Event $event)`:**
- Find Charges by `stripe_charge_id = $event->data->object->id`
- Confirm settlement status (should already be settled from job — this is confirmation)
- Audit log

**`handlePaymentFailed(Event $event)`:**
- Find Charges by `stripe_charge_id`
- Log the failure, send `ChargeFailedNotification` to admin
- Audit log

**`handleAccountUpdated(Event $event)`:**
- Find Clinician by `stripe_connect_id = $event->data->object->id`
- Update `stripe_account_status` based on `charges_enabled` / `payouts_enabled`
- If fully active and was pending: audit log status change

All handlers are idempotent — they check current state before updating.

---

## 7. Transactional Emails

### Mailable Classes (all `ShouldQueue`)

**`DisbursementReceipt`** — `app/Mail/DisbursementReceipt.php`
- Sent to clinician after successful disbursement
- Data: session count, date range, gross amount, Stripe fee, net amount, Stripe transfer ID
- Template: `resources/views/mail/disbursement-receipt.blade.php`

**`FeeSettlementReceipt`** — `app/Mail/FeeSettlementReceipt.php`
- Sent to clinician after successful fee settlement
- Data: itemized charge list, total amount, payment method type, settlement ID
- Template: `resources/views/mail/fee-settlement-receipt.blade.php`

### Notification Classes

**`PayoutFailedNotification`** — `app/Notifications/PayoutFailedNotification.php`
- Sent to admin when a Stripe transfer fails
- Via mail channel
- Data: clinician name, amount, error message, disbursement ID

**`ChargeFailedNotification`** — `app/Notifications/ChargeFailedNotification.php`
- Sent to admin when a Stripe charge fails
- Via mail channel
- Data: clinician name, amount, error message

---

## 8. UI Updates

### User Settings — Stripe Section

Add a "Banking & Payments" section to the clinician's settings page:

**Payout Account (Express Connect):**
- Not connected → "Connect Bank Account" button
- Pending → "Verification pending" badge + "Complete Setup" button
- Active → "Bank account connected" green badge + "Manage" link (to Stripe Express dashboard)

**Payment Method (Customer):**
- No method → "Set Up Payment Method" button
- ACH on file → "ACH bank account" badge + "Update" button
- Card on file → "Credit/debit card" badge + "Update" button + surcharge note

### Clinician Master — Stripe Status Column

In the Clerical View of Clinician Master, add a "Stripe Status" column showing:
- Connect status (Not connected / Pending / Active)
- Payment method status (None / ACH / Card)

---

## New Files Summary

| File | Type |
|------|------|
| `app/Services/StripeService.php` | Service |
| `app/Http/Controllers/Stripe/StripeConnectController.php` | Controller |
| `app/Http/Controllers/Stripe/StripeBillingController.php` | Controller |
| `app/Http/Controllers/Stripe/StripeWebhookController.php` | Controller |
| `app/Mail/DisbursementReceipt.php` | Mailable |
| `app/Mail/FeeSettlementReceipt.php` | Mailable |
| `app/Notifications/PayoutFailedNotification.php` | Notification |
| `app/Notifications/ChargeFailedNotification.php` | Notification |
| `resources/views/mail/disbursement-receipt.blade.php` | Email template |
| `resources/views/mail/fee-settlement-receipt.blade.php` | Email template |
| Migration: add Stripe fields to clinicians | Migration |

## Modified Files Summary

| File | Changes |
|------|---------|
| `composer.json` | Add `stripe/stripe-php` |
| `config/services.php` | Add Stripe config block |
| `.env.example` | Add Stripe env vars |
| `app/Models/Clinician.php` | Add Stripe fields to fillable |
| `app/Jobs/ProcessDisbursementTransfer.php` | Replace stub with real Stripe Transfer |
| `app/Jobs/ProcessFeeCharge.php` | Replace stub with real Stripe PaymentIntent |
| `routes/web.php` | Add Stripe routes (connect, billing, webhook) |

## Testing Strategy

- Unit tests for `StripeService` methods with mocked Stripe SDK
- Feature tests for controllers (mock StripeService)
- Feature tests for webhook handler (mock StripeService::constructWebhookEvent)
- Feature tests for jobs with mocked StripeService
- Mailable tests (assert correct data passed to templates)
- Integration test: full flow from batch creation → job dispatch → webhook confirmation
