# Phase 2 + 3 Gap-Fill Design

> **Date:** 2026-04-04
> **Status:** Approved
> **Scope:** Complete remaining Phase 2 (Session Import) and Phase 3 (Financial Processing) features

---

## Context

PlutoHub already has working Livewire components, services, models, routes, and Blade views for the majority of Phase 2 and Phase 3. This spec covers the **remaining gaps** between the SPEC.md and the current implementation.

### What Already Works

**Phase 2 (complete):**
- Session import pipeline: upload → parse → preview → commit (SessionImporter)
- Import lock mechanism (one-at-a-time, 30-min TTL)
- Sessions database with search, sort, filters, pagination, CSV export (SessionsDatabase)
- Session detail slide-over with role-based field editability (SessionDetailSlideOver)
- Import history table with expandable row details (ImportHistoryTable)
- Import field management (ImportFieldManagement)
- Post-import report with attention sessions and email copy

**Phase 3 (core pages complete):**
- Fee Processing page: summary table, "Process All", confirmation, result display
- Revenue Disbursement page: date filters, summary bar, clinician table, auth modal, batch creation
- Completed Disbursements page: batches/individual toggle with full column set
- Support Services Catalog: session-based price editing, manual service CRUD via slide-over
- Admin Settings: auth code management only

---

## Gaps to Fill (9 vertical slices)

### 1. Admin Settings Completion

**Files:** `app/Livewire/Admin/AdminSettings.php`, `resources/views/livewire/admin/admin-settings.blade.php`

The existing component only has auth code management. Add three sections:

#### 1a. Insurance Payers Management

- Properties: `$payers` (array from `Setting::get('insurance_payers')`), `$newPayerName` (string), `$removingPayer` (string|null), `$affectedClinicians` (array)
- `addPayer()` — validate non-empty, non-duplicate, append to JSON array in settings, audit log
- `confirmRemovePayer(string $name)` — query clinician profiles that have this payer selected, populate `$affectedClinicians`, show warning modal
- `executeRemovePayer()` — remove payer from all clinician profiles + settings, audit log
- Storage: `Setting::get('insurance_payers')` as JSON array
- View: list of payer name badges with X remove button, text input + "Add" button, removal warning modal showing affected clinician names

#### 1b. Session Charge Prices

- Properties: `$ipPrice` (string), `$oopPrice` (string)
- Mount: load current prices from `ServiceCatalog` where `is_session_based = true`
- `savePrices()` — validate numeric > 0, update `ServiceCatalog.default_price` for IP and OOP entries, audit log
- Forward-only: existing charges are NOT retroactively updated
- View: two input fields labeled "IP Session Charge" and "OOP Session Charge", Save button, success flash

#### 1c. Card Surcharge Rate

- Property: `$surchargeRate` (string, displayed as percentage e.g. "3.5")
- Mount: load from `Setting::get('card_surcharge_rate', '0.035')`, display as percentage
- `saveSurchargeRate()` — validate numeric 0-100, store as decimal (divide by 100), audit log
- View: single input field with "%" suffix, Save button

---

### 2. Import Acceptance Settings Page

**New files:**
- `app/Livewire/Admin/ImportAcceptanceSettings.php`
- `resources/views/livewire/admin/import-acceptance-settings.blade.php`
- Controller (or inline route)

**Route:** `GET /admin/import-acceptance-settings`

Read-only reference table. No editing capability.

**Table columns:**
| Column | Source |
|--------|--------|
| Field Name | `ImportFieldMapping.hub_field_name` display label |
| Acceptable Values/Format | Hardcoded descriptions derived from validation rules |
| Flag Condition | Where `import_action` is PreImportFlag or PostImportFlag |
| Rejection Condition | Where `import_action` is Rejection |

**Data source:** Combination of `ImportFieldMapping` records and hardcoded validation rule descriptions from `SessionImportService`. The component builds the reference data in `render()`.

**Navigation:** Added to admin sidebar in the Sessions/Import section.

---

### 3. Clinician Support Services Page

**New files:**
- `app/Livewire/Admin/ClinicianSupportServices.php`
- `resources/views/livewire/admin/clinician-support-services.blade.php`
- Controller route

**Route:** `GET /admin/clinicians/{clinician}/support-services`

Standalone page scoped to a single clinician. Three sections per SPEC 7.5:

#### Section A: Monthly/Ongoing Services

- Lists `ClinicianService` records for this clinician (active and inactive)
- Each row: service name, monthly price, activated date, status toggle
- "Add Service" dropdown: selects from `ServiceCatalog` where `type = recurring` and `is_active = true`, excluding already-assigned services
- `activateService(int $serviceCatalogId)` — creates `ClinicianService` record with `is_active = true`, `activated_at = today`
- `deactivateService(int $clinicianServiceId)` — sets `is_active = false`, `deactivated_at = today`
- Admin only. Clinicians see this section read-only.

#### Section B: Pending Charges

- All charges for this clinician where `status = pending`
- Table: date, description (service title), amount, status badge
- "Add One-Time Charge" button: opens inline form with service dropdown (type=one_time) + amount field (pre-filled from default_price if exists, editable)
- Negative amounts allowed (credits)
- `createManualCharge()` — delegates to `ChargeService::createManualCharge()`

#### Section C: Paid/Processed Charges

- Historical charges where `status = settled` or `status = waived`
- Table: date, description, amount, settlement_id, settlement_date
- Read-only.

**Accessible from:** Clinician Master table (clinician name click), Fee Processing table (clinician name link).

---

### 4. Monthly Charge Auto-Generation

**New file:** `app/Console/Commands/GenerateMonthlyCharges.php`

**Command:** `app:generate-monthly-charges`

**Schedule:** 1st of each month, 00:00 America/Los_Angeles

**Logic:**
1. Query `clinician_services` where `is_active = true`, eager load `serviceCatalog`
2. For each active service:
   - Check idempotency: skip if a charge already exists for this `clinician_id + service_catalog_id` in the current month
   - Calculate amount:
     - If `activated_at` is in the current month: prorate as `(days_remaining / days_in_month) * default_price`
     - Otherwise: full `default_price`
   - Create `Charge` record: `clinician_id`, `service_catalog_id`, `amount`, `status = pending`, `created_by = system` (nullable or a system user ID)
3. Audit log the run with total charges created

**Registration:** In `routes/console.php`:
```php
Schedule::command('app:generate-monthly-charges')
    ->monthlyOn(1, '00:00')
    ->timezone('America/Los_Angeles');
```

**Edge cases:**
- Service activated on the 1st: full price (not prorated)
- Service deactivated before the 1st: no charge (is_active = false)
- Default price is null: skip with warning log

---

### 5. Card Surcharge in Fee Processing

**Modify:** `app/Livewire/Admin/FeeProcessing.php`, `app/Services/ChargeService.php`

**Prerequisite:** A `payment_method_type` field on the `clinicians` table (enum: `ach`, `card`, default `ach`). Populated during Phase 4 Stripe onboarding. For now, defaults to `ach` (no surcharge active).

**Migration:** Add `payment_method_type` enum column to `clinicians` table.

**Service catalog entry:** Seed a "Card Processing Surcharge" entry in `ServiceCatalog` with `is_session_based = false`, `is_active = true`.

**Logic in `FeeProcessing::confirmProcess()`:**
1. After summing pending charges per clinician, check `clinician.payment_method_type`
2. If `card`: calculate surcharge = `total * Setting::get('card_surcharge_rate', 0.035)`
3. Create a separate `Charge` record for the surcharge amount, linked to the "Card Processing Surcharge" service catalog entry
4. Include surcharge in the total settlement

**View update:** In the confirmation modal, if any clinician has card surcharge:
- Show: Charges $X + Card Surcharge $Y = Total $Z
- Surcharge line items are visible in the results

---

### 6. Selective Clinician Processing in Fee Processing

**Modify:** `app/Livewire/Admin/FeeProcessing.php`, view

**New properties:**
- `$selectedClinicianIds` (array of int)
- `$selectAll` (bool)

**New methods:**
- `toggleClinician(int $id)` — add/remove from selection
- `toggleSelectAll()` — select/deselect all visible clinicians
- `processSelected()` — same as `processAllPending()` but filtered to selected IDs

**View changes:**
- Add checkbox on each clinician row
- Add "Select All" checkbox in header
- Add "Process Selected (N)" button alongside "Process All"
- Button disabled when no selection

---

### 7. Immutable Financial Records

**Modify:** `app/Models/Charge.php`, `app/Services/ChargeService.php`

**Model-level enforcement:**
```php
// Charge model boot method
protected static function booted(): void
{
    static::updating(function (Charge $charge) {
        if ($charge->getOriginal('settlement_id') !== null) {
            throw new \DomainException('Settled charges cannot be modified.');
        }
    });

    static::deleting(function (Charge $charge) {
        if ($charge->settlement_id !== null) {
            throw new \DomainException('Settled charges cannot be deleted.');
        }
    });
}
```

**Convenience method:**
```php
public function isSettled(): bool
{
    return $this->settlement_id !== null;
}
```

**UI enforcement:**
- In Clinician Support Services and any charge-editing UI: hide edit/delete buttons when `isSettled()`
- Show "Settled" badge with settlement_id instead
- Admin creates a new negative charge (credit) to correct errors on settled charges

---

### 8. Queue Job Infrastructure

**New files:**
- `app/Jobs/ProcessDisbursementTransfer.php`
- `app/Jobs/ProcessFeeCharge.php`

#### ProcessDisbursementTransfer

Dispatched once per `Disbursement` record during batch creation.

```php
class ProcessDisbursementTransfer implements ShouldQueue
{
    use Queueable;

    public int $tries = 3;
    public int $backoff = 60;

    public function __construct(public Disbursement $disbursement) {}

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

        // TODO Phase 4: Stripe\Transfer::create([
        //     'amount' => $this->disbursement->net_amount * 100,
        //     'currency' => 'usd',
        //     'destination' => $clinician->stripe_connect_id,
        // ]);

        // Simulate success for now
        $this->disbursement->update([
            'status' => 'complete',
            'stripe_transfer_id' => 'stub_' . uniqid(),
        ]);
    }

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

#### ProcessFeeCharge

Dispatched once per clinician during fee settlement.

```php
class ProcessFeeCharge implements ShouldQueue
{
    use Queueable;

    public int $tries = 3;
    public int $backoff = 60;

    public function __construct(
        public Clinician $clinician,
        public array $chargeIds,
        public string $settlementId,
    ) {}

    public function handle(): void
    {
        // TODO Phase 4: Stripe\PaymentIntent::create([
        //     'amount' => $totalInCents,
        //     'currency' => 'usd',
        //     'customer' => $this->clinician->stripe_customer_id,
        //     'payment_method' => $defaultPaymentMethod,
        //     'confirm' => true,
        // ]);

        // Mark charges as settled
        Charge::whereIn('id', $this->chargeIds)
            ->update([
                'status' => ChargeStatus::Settled,
                'settlement_id' => $this->settlementId,
                'settlement_date' => now(),
            ]);
    }

    public function failed(\Throwable $exception): void
    {
        // Log failure, admin notified via failed_jobs table
    }
}
```

**Service updates:**
- `DisbursementService::createBatch()` — after creating Disbursement records, dispatch `ProcessDisbursementTransfer` for each
- `ChargeService::processSettlement()` — dispatch `ProcessFeeCharge` instead of synchronous update (or keep synchronous with option to queue)

---

### 9. Minor UI Polish

#### 9a. ID Click Behavior in Sessions Database

**Modify:** `resources/views/livewire/admin/sessions-database.blade.php`

- SessionID cell: clicking the ID text calls `$set('search', sessionId)` to populate it in the search bar
- Small arrow (→) icon beside the ID dispatches `openDetail(sessionId)` to the slide-over
- ClientID cell: clicking navigates to sessions database pre-filtered by that ClientID

#### 9b. Clinician Name Link in Fee Processing

**Modify:** `resources/views/livewire/admin/fee-processing.blade.php`

- Clinician name in the summary table becomes a link to `/admin/clinicians/{id}/support-services`
- Opens in the same tab per UI conventions

---

## Migration Summary

| Migration | Description |
|-----------|-------------|
| Add `payment_method_type` to `clinicians` | Enum (ach, card), default ach |

## New Files Summary

| File | Type |
|------|------|
| `app/Livewire/Admin/ImportAcceptanceSettings.php` | Livewire component |
| `resources/views/livewire/admin/import-acceptance-settings.blade.php` | Blade view |
| `app/Livewire/Admin/ClinicianSupportServices.php` | Livewire component |
| `resources/views/livewire/admin/clinician-support-services.blade.php` | Blade view |
| `app/Console/Commands/GenerateMonthlyCharges.php` | Artisan command |
| `app/Jobs/ProcessDisbursementTransfer.php` | Queue job |
| `app/Jobs/ProcessFeeCharge.php` | Queue job |
| Migration for `payment_method_type` | Database migration |

## Modified Files Summary

| File | Changes |
|------|---------|
| `app/Livewire/Admin/AdminSettings.php` | Add insurance payers, charge prices, surcharge rate sections |
| `resources/views/livewire/admin/admin-settings.blade.php` | Add 3 new settings sections UI |
| `app/Livewire/Admin/FeeProcessing.php` | Add selective processing, card surcharge logic |
| `resources/views/livewire/admin/fee-processing.blade.php` | Add checkboxes, "Process Selected", surcharge display, clinician name links |
| `app/Models/Charge.php` | Add immutability guards, `isSettled()` method |
| `app/Services/ChargeService.php` | Surcharge creation, job dispatch option |
| `app/Services/DisbursementService.php` | Dispatch jobs in `createBatch()` |
| `resources/views/livewire/admin/sessions-database.blade.php` | ID click behavior |
| `routes/web.php` | New routes for import-acceptance-settings, clinician support services |
| `routes/console.php` | Schedule monthly charge command |
| Admin sidebar component | New nav entries |

## Testing Strategy

Each slice gets its own Pest test file:
1. `tests/Feature/Admin/AdminSettingsTest.php` — payer CRUD, price updates, surcharge rate
2. `tests/Feature/Admin/ImportAcceptanceSettingsTest.php` — page renders, correct data displayed
3. `tests/Feature/Admin/ClinicianSupportServicesTest.php` — service assignment, charge creation, settled charge immutability
4. `tests/Feature/Commands/GenerateMonthlyChargesTest.php` — full/prorated/idempotent/null-price
5. `tests/Feature/Admin/FeeProcessingEnhancedTest.php` — selective processing, card surcharge
6. `tests/Feature/Models/ChargeImmutabilityTest.php` — settled charges block edit/delete
7. `tests/Feature/Jobs/ProcessDisbursementTransferTest.php` — status transitions, failure handling
8. `tests/Feature/Jobs/ProcessFeeChargeTest.php` — settlement flow, failure handling
