# APWP-AO Subscription Quantity Management — Phased Implementation Steps

Reference plan: `apwp-ao-subscription-management-plan.md`

---

## Completion Log

| Phase | Status | Notes |
|---|---|---|
| Phase 1 | ✅ Complete | Migration generated via Sail artisan; columns verified in DB |
| Phase 2 | ✅ Complete | See deviation note — `order_item` returned as `stdClass` from `DB::table()` |
| Phase 3 | ✅ Complete | No deviations from plan |
| Phase 4 | ✅ Complete | Routes added inline (no top-level `use` import — used FQCN to match existing convention in that file) |
| Phase 5 | ✅ Complete | See deviation notes below |
| Phase 6 | Pending | Requires browser/Stripe testing |

### Build Deviations

**Phase 2 — `findActiveAoSubscription` return type for `order_item`**
The plan described `order_item` as an `OrderItem` Eloquent model. Implementation uses `DB::table('order_items')->...->first()` which returns a `stdClass`. This was intentional — we join against `products` for the SKU filter and Eloquent's `OrderItem` model (from Vanilo vendor) doesn't expose a convenient scoped query for this. The controller accesses `$aoSub['order_item']->id` and `$aoSub['order_item']->quantity` which are available on `stdClass`. The `DB::table()->update()` in the controller uses `->where('id', $aoSub['order_item']->id)` directly.

**Phase 4 — Route declaration style**
The plan specified a top-level `use App\Http\Controllers\AoSubscriptionController;` import. The actual web.php file uses fully-qualified class name strings (`[\App\Http\Controllers\...\::class, 'method']`) for all protected-group routes, so we matched that convention rather than adding an import.

**Phase 5 — `findActiveAoSubscription` called inside the active-state content layer**
The plan placed the `@php` for `$aoSubInfo` at the top of the licence management row. In implementation it sits inside the active content layer `<div>` (which is already gated by `$aoHasActiveAccess`). This avoids a second Stripe API call for lapsed users since the block is never rendered for them. The `$aoSubInfo` variable is therefore only in scope within the content layer — the JS block is guarded by `@if($aoHasActiveAccess)` which is consistent.

**Phase 5 — JS guard**
The plan used `@if($aoHasActiveAccess)` as the JS guard. This is correct — the buttons only exist when the content layer is rendered (active state), so the JS only needs to load then.

**Post-phase 5 feature addition — gift subscriber Subscribe flow**
Two additional changes made after the initial build based on user feedback:

1. **`app/Http/Controllers/PaymentController.php`** — When cart contains `apwp-ao` and the authenticated user has an active gift subscription for it, the Stripe subscription is created with `->trialUntil($gift->end_date)` so the first charge is deferred to the gift expiry date. The Payment record's `expiry_date` is calculated from `$billingStart` (gift expiry date) rather than `now()`, so the expiry correctly reflects the end of the first paid billing period.

2. **`resources/views/dashboard.blade.php`** — Added `$aoGiftExpiry` query in the licence management `@php` block (only runs when `$aoSubInfo` is null, i.e. no purchased sub). The `@else` branch (gift-only / no purchased sub) now renders: a context note showing the gift expiry date ("Complimentary access expires [date]. Subscribe to continue your 5 slots uninterrupted.") and a "Subscribe" button (cart.add, qty=1) replacing "Purchase Additional Slots". Users can adjust qty in the cart after clicking Subscribe.

**Phase 5 bug fix — licence summary showing wrong count for gift-only users**
After initial implementation, a gift-only subscriber (no purchased sub) saw "5 licences · 25 slots" instead of nothing. Root cause: the `$currentQty` fallback was `$aoSubInfo['quantity'] ?? $aoLicenseCount`, and `$aoLicenseCount` is the total *slot* count (e.g. 5 for 1 gift), not the licence count. Multiplying 5 × 5 = 25 slots displayed. Fix: changed fallback to `?? null` and wrapped the entire summary `<div>` in `@if($aoSubInfo && $currentQty !== null)` so it only renders when a purchased subscription exists. Gift-only users now see only the "Purchase Additional Slots" cart button with no misleading summary.

---

## Post-Build Changes — Checkout Quantity Display & Downloads Page

### Checkout page quantity controls (`resources/views/checkout/show.blade.php` + `CheckoutController.php`)

**Feature added:** The checkout order summary now shows quantity controls (`[−] N [+]`) for products whose SKU is in `CheckoutController::$adjustableSkus` (currently `['apwp-ao']`). All other products continue to display `Product Name xN` with no controls. Adding a new adjustable-quantity product in future requires only appending its SKU to that array.

**Architecture change — item data pre-computed in controller, not blade:**

The original implementation put `getBuyable()` and `getQuantity()` calls inside a blade `@php` block. This caused a silent failure in PHP 8: `$buyable->sku ?? null` throws `Cannot access property on null` when `getBuyable()` returns null — the null coalescing operator `??` does NOT suppress property-access errors on null objects (it only handles undefined variables and `isset()` checks). The result was `$qty = null`, rendering as an empty string between the +/− buttons ("no qty" visible to user).

**Fix: `CheckoutController::show()` now maps raw Vanilo cart items into plain arrays** before passing to the view:

```php
$items = $rawItems->map(function ($item) {
    try {
        $buyable = $item->getBuyable();
        $name    = $buyable?->name ?? 'Product';
        $sku     = $buyable?->sku  ?? null;
    } catch (\Throwable $e) {
        $name = 'Product'; $sku = null;
    }
    $qty = (int) $item->getQuantity();
    if ($qty < 1) { $qty = (int) ($item->quantity ?? 1); }
    if ($qty < 1) { $qty = 1; }
    return [
        'raw' => $item, 'id' => $item->id,
        'name' => $name, 'sku' => $sku,
        'qty' => $qty,
        'adjustable' => $sku && in_array($sku, $this->adjustableSkus),
        'total' => $item->total(), 'price' => $item->price,
    ];
});
```

Key points:
- `getBuyable()` is wrapped in `try/catch` — a missing or unresolvable product never crashes the page.
- Nullsafe operator `?->` used on `$buyable` so null is handled correctly.
- Qty has a three-level fallback: `(int) getQuantity()` → `(int) $item->quantity` → `1`. This guards against Vanilo versions where `getQuantity()` returns null or 0.
- The view accesses `$item['qty']`, `$item['name']`, `$item['adjustable']` etc. — no method calls in the template at all.
- The yearly total row was updated from `$item->price * N * $item->getQuantity()` to `$i['price'] * N * $i['qty']` to use the pre-computed safe values.

**`CheckoutController::$adjustableSkus`** — protected property, single source of truth:
```php
protected array $adjustableSkus = ['apwp-ao'];
```

**Route used for +/− forms:** `PATCH /cart/{item}` → `CartController::update()` (already existed). `redirect()->back()` returns to the checkout page after each qty change.

---

### Payment form order summary (`resources/views/payment/form.blade.php`)

**Bug:** The order summary table on the payment page (`/payment`) was showing the per-unit price with no quantity displayed, regardless of cart quantity. Root cause: `$monthly = $item->price` used the raw unit price without multiplying by `getQuantity()`. The plan-selection radios below (which use `$total = Cart::total()`) showed the correct amount, creating a confusing mismatch where the table showed e.g. `$49.00/mo` but the radio said `Monthly Total – $98.00/month`.

**Fix:** Added a `Qty` column to the table and changed the per-line calculation:
```php
$itemQty     = max(1, (int)($item->getQuantity() ?: $item->quantity ?? 1));
$monthly     = $item->price * $itemQty;
$yearlyTotal = $monthly * 10;
$yearlyPerMonth = $yearlyTotal / 12;
```
Same three-level qty fallback used in CheckoutController. The tfoot colspan was also updated from 2→3 to account for the new Qty column.

Note: `$cartItems` is passed from `PaymentController::showForm()` as `Cart::getItems()->load('product')` (raw Vanilo items with product relation eager-loaded). The `$item->product->name` relationship access is safe because `->load('product')` is called first. `$item->product->sku` has a `?? '—'` fallback for null.

---

### Downloads page apwp-ao button logic (`resources/views/account/downloads.blade.php` + `DownloadController.php`)

**Four-way button conditional** replaced the previous three-way logic:

| State | Button | Notes |
|---|---|---|
| `$aoHasActiveAccess && $aoEverPurchased` | **Purchase Additional Slots** | Active purchased sub |
| `$aoEverPurchased && !$aoHasActiveAccess` | **Reactivate Subscription** | Lapsed purchased sub; sends `$aoReactivateQty` |
| `$aoHasActiveAccess && !$aoEverPurchased` | **Subscribe** | Gift-only; qty=1; 3-line gift note below buttons |
| neither | **Purchase Now** | No gift, never purchased |

**`$aoGiftExpiry` added to `DownloadController`** — only queried when `$aoHasActiveAccess && !$aoEverPurchased` (gift-only path). Uses `GiftedProduct` query with `end_date` filter. Passed to view; used to render the 3-line note.

**Layout fix:** Download button and action button always on the same row. The 3-line gift message is a separate `<p>` element below the button row, not nested inside the button wrapper. This ensures layout consistency across all four states.

---

## Key Facts (confirmed from codebase before writing these steps)

- `order_items.price` stores the **per-billing-period unit price** (already ×10 for yearly plans). Set in `PaymentController` line 250: `$unitPrice = $plan === 'yearly' ? $item->price * 10 : $item->price`
- `payment.amount` = pre-tax subtotal for all cart items combined
- `payment.tax_amount` = tax charged; `payment.total_amount` = gross (amount + tax)
- `payment.stripe_transaction_id` may be a Stripe invoice ID (`in_xxx`) OR subscription ID (`sub_xxx`) — depends on whether `latest_invoice` was set at the time of creation
- `payment.remote_id` = Stripe payment method string (`pm_xxx`) — NOT the subscription ID
- Cashier `subscriptions.stripe_id` = Stripe subscription ID (`sub_xxx`)
- Tax rate is stored in Stripe subscription metadata under key `'tax_rate'` (as a string)
- `order.billing_interval` = `'monthly'` or `'yearly'`

---

## Phase 1 — Database Migration

**File to create:** `database/migrations/YYYY_MM_DD_HHMMSS_add_quantity_pending_to_payments_table.php`

**Step 1.1** — Generate the migration file:
```
php artisan make:migration add_quantity_pending_to_payments_table --table=payments
```

**Step 1.2** — Add columns in `up()`:
```php
$table->unsignedSmallInteger('quantity_pending')->nullable()->after('status');
$table->timestamp('quantity_pending_at')->nullable()->after('quantity_pending');
```

**Step 1.3** — Add corresponding `down()` dropColumn calls.

**Step 1.4** — Run the migration:
```
./vendor/bin/sail artisan migrate
```

**Verify:** `payments` table has `quantity_pending` and `quantity_pending_at` columns; both nullable; existing rows unaffected.

---

## Phase 2 — Service Layer

**File:** `app/Services/PluginAccessService.php`

Add two new public static methods after the existing `aoSlotAvailability()` method.

---

### Step 2.1 — `findActiveAoSubscription(User $user): ?array`

**Purpose:** Locate everything needed to manage the apwp-ao subscription — Payment, Order, OrderItem, Cashier subscription, and Stripe state. Returns `null` if no manageable subscription exists.

**Implementation logic:**

```php
public static function findActiveAoSubscription(User $user): ?array
{
    // 1. Find completed orders containing apwp-ao with a valid Payment
    //    (same join pattern as purchasedLicenseQty — reuse that query up to the $orderIds step)
    $orderIds = ... (apwp-ao completed orders for user);

    if (empty($orderIds)) return null;

    $now = Carbon::now();
    $orderClass = 'Vanilo\\Order\\Models\\Order';

    // 2. Find the most recent valid (non-expired, non-cancelled) Payment
    $payment = Payment::whereIn('payable_id', $orderIds)
        ->where('payable_type', $orderClass)
        ->where(function ($q) use ($now) {
            $q->whereNull('expiry_date')->orWhere('expiry_date', '>=', $now);
        })
        ->where(function ($q) {
            $q->whereNull('status')->orWhere('status', '!=', 'cancelled');
        })
        ->latest('subscription_date')
        ->first();

    if (!$payment) return null;

    // 3. Load the Order and its apwp-ao OrderItem
    $order = \Vanilo\Order\Models\Order::find($payment->payable_id);
    if (!$order) return null;

    $aoOrderItem = DB::table('order_items')
        ->join('products', 'order_items.product_id', '=', 'products.id')
        ->where('products.sku', 'apwp-ao')
        ->where('order_items.order_id', $order->id)
        ->select('order_items.*')
        ->first();

    if (!$aoOrderItem) return null;

    // 4. Determine if the Order is pure apwp-ao (all items are apwp-ao)
    $totalItemCount = DB::table('order_items')
        ->where('order_id', $order->id)->count();
    $aoItemCount = DB::table('order_items')
        ->join('products', 'order_items.product_id', '=', 'products.id')
        ->where('order_items.order_id', $order->id)
        ->where('products.sku', 'apwp-ao')
        ->count();
    $isPureAo = ($totalItemCount === $aoItemCount && $totalItemCount > 0);

    // 5. Resolve Stripe subscription ID
    //    stripe_transaction_id may be invoice ID (in_xxx) — resolve to sub ID via Stripe API
    $stripeSubId = null;
    $stripeItemId = null;
    $taxRate = 0.0;
    $periodEnd = null;
    $cashierSub = null;

    try {
        $stripe = new \Stripe\StripeClient(config('cashier.secret'));
        $rawId = $payment->stripe_transaction_id;

        if ($rawId && str_starts_with($rawId, 'in_')) {
            // Invoice ID — retrieve to get the subscription ID
            $invoice = $stripe->invoices->retrieve($rawId, ['expand' => ['subscription']]);
            $stripeSubId = $invoice->subscription->id ?? null;
        } else {
            $stripeSubId = $rawId;
        }

        if ($stripeSubId) {
            // 6. Find the Cashier subscription record
            $cashierSub = $user->subscriptions()
                ->where('stripe_id', $stripeSubId)
                ->first();

            // 7. Retrieve Stripe subscription for item ID, metadata, and period end
            $stripeSub = $stripe->subscriptions->retrieve(
                $stripeSubId,
                ['expand' => ['items.data.price']]
            );
            $stripeItem = $stripeSub->items->data[0] ?? null;
            $stripeItemId = $stripeItem?->id;
            $taxRate = (float) ($stripeSub->metadata['tax_rate'] ?? 0);
            $periodEnd = Carbon::createFromTimestamp($stripeSub->current_period_end);
        }
    } catch (\Throwable $e) {
        Log::warning('PluginAccessService::findActiveAoSubscription Stripe lookup failed for user '
            . $user->id . ': ' . $e->getMessage());
        return null;
    }

    if (!$stripeSubId || !$stripeItemId) return null;

    return [
        'payment'          => $payment,
        'order'            => $order,
        'order_item'       => $aoOrderItem,     // stdClass from DB::table
        'cashier_sub'      => $cashierSub,
        'stripe_sub_id'    => $stripeSubId,
        'stripe_item_id'   => $stripeItemId,
        'quantity'         => (int) $aoOrderItem->quantity,
        'unit_price'       => (float) $aoOrderItem->price,  // per-period, pre-tax
        'tax_rate'         => $taxRate,
        'plan'             => $order->billing_interval,
        'period_end'       => $periodEnd,
        'is_pure_ao'       => $isPureAo,
        'quantity_pending' => $payment->quantity_pending,
    ];
}
```

---

### Step 2.2 — `previewAoIncrease(array $aoSub): array`

**Purpose:** Calculate the display values for the "Add 1 Licence" confirmation modal without hitting Stripe's proration preview endpoint.

```php
public static function previewAoIncrease(array $aoSub): array
{
    $newQty          = $aoSub['quantity'] + 1;
    $unitPricePretax = $aoSub['unit_price'];                              // per-period, pre-tax
    $taxRate         = $aoSub['tax_rate'];
    $newPretaxTotal  = $newQty * $unitPricePretax;
    $newTax          = round($newPretaxTotal * $taxRate, 2);
    $newGrossTotal   = $newPretaxTotal + $newTax;

    // Proration: charge for the remaining fraction of the current billing period
    $now        = Carbon::now();
    $periodEnd  = $aoSub['period_end'];
    $interval   = $aoSub['plan'] === 'yearly' ? 365 : 30;  // approximate days
    $daysLeft   = max(0, (int) $now->diffInDays($periodEnd, false));
    $addedGross = ($unitPricePretax * (1 + $taxRate));       // per-licence gross per period
    $prorated   = round($addedGross * ($daysLeft / $interval), 2);

    return [
        'new_qty'          => $newQty,
        'new_pretax_total' => $newPretaxTotal,
        'new_tax'          => $newTax,
        'new_gross_total'  => $newGrossTotal,
        'prorate_amount'   => $prorated,
        'days_remaining'   => $daysLeft,
        'period_end'       => $periodEnd,
    ];
}
```

**Verify Phase 2:** Call both methods manually in `php artisan tinker` against a test user with an active apwp-ao subscription to confirm the returned array structure and values are correct before proceeding.

---

## Phase 3 — AoSubscriptionController

**File to create:** `app/Http/Controllers/AoSubscriptionController.php`

Uses `PluginAccessService::findActiveAoSubscription()` for all three actions.

---

### Step 3.1 — `show()` — GET

Returns current subscription state as JSON. Called via `fetch()` when the user opens a modal.

```php
public function show(Request $request): JsonResponse
{
    $user  = $request->user();
    $aoSub = PluginAccessService::findActiveAoSubscription($user);

    if (!$aoSub) {
        return response()->json(['error' => 'No active apwp-ao subscription found.'], 404);
    }

    $preview = PluginAccessService::previewAoIncrease($aoSub);

    return response()->json([
        'quantity'          => $aoSub['quantity'],
        'unit_price'        => $aoSub['unit_price'],
        'tax_rate'          => $aoSub['tax_rate'],
        'plan'              => $aoSub['plan'],
        'period_end'        => $aoSub['period_end']->toDateString(),
        'is_pure_ao'        => $aoSub['is_pure_ao'],
        'quantity_pending'  => $aoSub['quantity_pending'],
        'preview_increase'  => $preview,   // pre-calculated for the modal
    ]);
}
```

---

### Step 3.2 — `increase()` — POST

```php
public function increase(Request $request): JsonResponse
{
    $user  = $request->user();
    $aoSub = PluginAccessService::findActiveAoSubscription($user);

    if (!$aoSub) {
        return response()->json(['error' => 'No active apwp-ao subscription found.'], 404);
    }
    if (!$aoSub['is_pure_ao']) {
        return response()->json([
            'error' => 'Your apwp-ao licence is part of a bundle subscription and cannot be modified here. Please contact support.'
        ], 422);
    }

    $newQty        = $aoSub['quantity'] + 1;
    $newPretax     = $newQty * $aoSub['unit_price'];
    $newTax        = round($newPretax * $aoSub['tax_rate'], 2);
    $newGross      = $newPretax + $newTax;
    $newGrossCents = (int) round($newGross * 100);
    $interval      = $aoSub['plan'] === 'yearly' ? 'year' : 'month';

    try {
        $stripe = new \Stripe\StripeClient(config('cashier.secret'));

        // Retrieve existing Stripe subscription to get current price's product
        $stripeSub  = $stripe->subscriptions->retrieve(
            $aoSub['stripe_sub_id'],
            ['expand' => ['items.data.price']]
        );
        $stripeProduct = $stripeSub->items->data[0]->price->product;

        // Create new Stripe Price at new gross total
        $newPrice = $stripe->prices->create([
            'unit_amount' => $newGrossCents,
            'currency'    => 'usd',
            'recurring'   => ['interval' => $interval],
            'product'     => $stripeProduct,
        ]);

        // Update subscription with immediate proration
        $stripe->subscriptions->update($aoSub['stripe_sub_id'], [
            'items'              => [['id' => $aoSub['stripe_item_id'], 'price' => $newPrice->id]],
            'proration_behavior' => 'create_prorations',
        ]);
    } catch (\Throwable $e) {
        Log::error('AoSubscriptionController::increase Stripe error for user '
            . $user->id . ': ' . $e->getMessage());
        return response()->json(['error' => 'Could not update your subscription. Please try again.'], 500);
    }

    // Update local records
    DB::table('order_items')
        ->where('id', $aoSub['order_item']->id)
        ->update(['quantity' => $newQty]);

    $aoSub['payment']->amount       = $newPretax;
    $aoSub['payment']->tax_amount   = $newTax;
    $aoSub['payment']->total_amount = $newGross;
    $aoSub['payment']->save();

    return response()->json([
        'success'  => true,
        'quantity' => $newQty,
        'message'  => "Licence added. You now have {$newQty} licence(s) and " . ($newQty * 5) . " slots.",
    ]);
}
```

---

### Step 3.3 — `decrease()` — POST

```php
public function decrease(Request $request): JsonResponse
{
    $user  = $request->user();
    $aoSub = PluginAccessService::findActiveAoSubscription($user);

    if (!$aoSub) {
        return response()->json(['error' => 'No active apwp-ao subscription found.'], 404);
    }
    if (!$aoSub['is_pure_ao']) {
        return response()->json([
            'error' => 'Your apwp-ao licence is part of a bundle subscription and cannot be modified here. Please contact support.'
        ], 422);
    }
    if ($aoSub['quantity'] <= 1) {
        return response()->json([
            'error' => 'You cannot reduce below 1 licence. To cancel your subscription entirely, use the Subscriptions page.'
        ], 422);
    }

    $newQty       = $aoSub['quantity'] - 1;
    $usedSlots    = PluginAccessService::aoUsedSlots($user);
    $newSlotLimit = $newQty * 5;

    if ($usedSlots > $newSlotLimit) {
        $mustRelease = $usedSlots - $newSlotLimit;
        return response()->json([
            'error' => "You have {$usedSlots} connected sites but {$newQty} licence(s) only allows {$newSlotLimit} slots. "
                     . "Please release {$mustRelease} slot(s) from your Connected Sites before reducing."
        ], 422);
    }

    $newPretax     = $newQty * $aoSub['unit_price'];
    $newTax        = round($newPretax * $aoSub['tax_rate'], 2);
    $newGross      = $newPretax + $newTax;
    $newGrossCents = (int) round($newGross * 100);
    $interval      = $aoSub['plan'] === 'yearly' ? 'year' : 'month';

    try {
        $stripe = new \Stripe\StripeClient(config('cashier.secret'));

        $stripeSub     = $stripe->subscriptions->retrieve(
            $aoSub['stripe_sub_id'],
            ['expand' => ['items.data.price']]
        );
        $stripeProduct = $stripeSub->items->data[0]->price->product;

        $newPrice = $stripe->prices->create([
            'unit_amount' => $newGrossCents,
            'currency'    => 'usd',
            'recurring'   => ['interval' => $interval],
            'product'     => $stripeProduct,
        ]);

        // Schedule the change — no proration, takes effect at next renewal
        $stripe->subscriptions->update($aoSub['stripe_sub_id'], [
            'items'              => [['id' => $aoSub['stripe_item_id'], 'price' => $newPrice->id]],
            'proration_behavior' => 'none',
        ]);
    } catch (\Throwable $e) {
        Log::error('AoSubscriptionController::decrease Stripe error for user '
            . $user->id . ': ' . $e->getMessage());
        return response()->json(['error' => 'Could not schedule the reduction. Please try again.'], 500);
    }

    // Record the pending decrease — do NOT update payment amounts or OrderItem yet
    // (they still reflect the current qty, which remains active until period end)
    $aoSub['payment']->quantity_pending    = $newQty;
    $aoSub['payment']->quantity_pending_at = $aoSub['period_end'];
    $aoSub['payment']->save();

    return response()->json([
        'success'      => true,
        'quantity'     => $aoSub['quantity'],     // current qty unchanged until period end
        'new_qty'      => $newQty,
        'period_end'   => $aoSub['period_end']->toDateString(),
        'message'      => "Reduction scheduled. You will have {$newQty} licence(s) from " . $aoSub['period_end']->format('M j, Y') . '.',
    ]);
}
```

**Verify Phase 3:** Confirm all three methods return appropriate JSON for success and each error case.

---

## Phase 4 — Routes

**File:** `routes/web.php`

**Step 4.1** — Add the import at the top of the file alongside existing controller imports:
```php
use App\Http\Controllers\AoSubscriptionController;
```

**Step 4.2** — Add the three routes inside the existing `auth + twofactor` middleware group, near the existing `account/ao-slots` routes:

```php
Route::get( '/account/ao-subscription',          [AoSubscriptionController::class, 'show']    )->name('account.ao_subscription.show');
Route::post('/account/ao-subscription/increase', [AoSubscriptionController::class, 'increase'])->name('account.ao_subscription.increase');
Route::post('/account/ao-subscription/decrease', [AoSubscriptionController::class, 'decrease'])->name('account.ao_subscription.decrease');
```

**Verify Phase 4:** `php artisan route:list | grep ao-subscription` shows all three routes with correct middleware.

---

## Phase 5 — Dashboard UI & JavaScript

**File:** `resources/views/dashboard.blade.php`

Replace the existing "Purchase Additional Slots" button block (inside the active content layer of the AO section) with the licence management row and two modals.

---

### Step 5.1 — Replace the button block

Find and replace the current `<div class="mt-4 flex justify-end">` / "Purchase Additional Slots" block with:

```blade
{{-- Licence Management Row --}}
@php
    $aoSubInfo = auth()->check()
        ? \App\Services\PluginAccessService::findActiveAoSubscription($aoUser)
        : null;
    $isPureAo   = $aoSubInfo['is_pure_ao']       ?? false;
    $currentQty = $aoSubInfo['quantity']          ?? $aoLicenseCount;
    $hasPending = !is_null($aoSubInfo['quantity_pending'] ?? null);
@endphp

<div class="mt-4 border-t border-gray-200 dark:border-gray-700 pt-4">
    <div class="flex items-center justify-between flex-wrap gap-3">
        <div class="text-sm text-gray-600 dark:text-gray-300">
            <span class="font-medium">{{ $currentQty }} licence{{ $currentQty !== 1 ? 's' : '' }}</span>
            · {{ $currentQty * 5 }} slots
            · {{ $aoSubInfo ? ucfirst($aoSubInfo['plan']) : '' }}
            @if($hasPending)
                <span class="ml-2 text-xs text-yellow-600 dark:text-yellow-400">
                    (reducing to {{ $aoSubInfo['quantity_pending'] }} on {{ $aoSubInfo['period_end']?->format('M j') }})
                </span>
            @endif
        </div>
        @if(!$isPureAo && $aoSubInfo)
            <p class="text-xs text-gray-500 dark:text-gray-400">
                Your apwp-ao licence is part of a bundle. Contact support to adjust quantity.
            </p>
        @elseif($isPureAo)
            <div class="flex items-center gap-2">
                <button type="button" id="ao-decrease-btn"
                    {{ $currentQty <= 1 || $hasPending ? 'disabled' : '' }}
                    class="px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed">
                    &minus; Remove 1 Licence
                </button>
                <button type="button" id="ao-increase-btn"
                    class="px-3 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700">
                    + Add 1 Licence
                </button>
            </div>
        @else
            {{-- No manageable subscription (e.g. gift only) — keep a simple purchase link --}}
            @if($aoProduct)
                <form method="POST" action="{{ route('cart.add') }}">
                    @csrf
                    <input type="hidden" name="product_id" value="{{ $aoProduct->id }}">
                    <button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
                        Purchase Additional Slots
                    </button>
                </form>
            @endif
        @endif
    </div>
</div>
```

---

### Step 5.2 — Add the two confirmation modals

Place these immediately before the existing `{{-- Dashboard Message modal --}}` block:

```blade
{{-- AO Increase Licence Modal --}}
<div id="ao-increase-modal"
     class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
     style="display:none!important">
    <div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
        <h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Add 1 Licence</h3>
        <div id="ao-increase-body" class="text-sm text-gray-600 dark:text-gray-300 space-y-2 mb-4">
            <p>Loading…</p>
        </div>
        <p id="ao-increase-status" class="text-xs text-red-600 dark:text-red-400 mb-3 min-h-4" aria-live="polite"></p>
        <div class="flex justify-end gap-3">
            <button type="button" id="ao-increase-cancel"
                class="px-4 py-2 text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600">
                Cancel
            </button>
            <button type="button" id="ao-increase-confirm" disabled
                class="px-4 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50">
                Confirm &amp; Pay
            </button>
        </div>
    </div>
</div>

{{-- AO Decrease Licence Modal --}}
<div id="ao-decrease-modal"
     class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
     style="display:none!important">
    <div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
        <h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Remove 1 Licence</h3>
        <div id="ao-decrease-body" class="text-sm text-gray-600 dark:text-gray-300 space-y-2 mb-4">
            <p>Loading…</p>
        </div>
        <p id="ao-decrease-status" class="text-xs text-red-600 dark:text-red-400 mb-3 min-h-4" aria-live="polite"></p>
        <div class="flex justify-end gap-3">
            <button type="button" id="ao-decrease-cancel"
                class="px-4 py-2 text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600">
                Cancel
            </button>
            <button type="button" id="ao-decrease-confirm" disabled
                class="px-4 py-2 text-sm bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50">
                Confirm Reduction
            </button>
        </div>
    </div>
</div>
```

---

### Step 5.3 — Add the JavaScript

Add this block after the existing release-slot `@endif` (before `</x-app-layout>`), guarded so it only loads when the management controls are present:

```blade
@if($aoHasActiveAccess)
<script>
(function () {
    var csrf       = '{{ csrf_token() }}';
    var incModal   = document.getElementById('ao-increase-modal');
    var decModal   = document.getElementById('ao-decrease-modal');
    var incBtn     = document.getElementById('ao-increase-btn');
    var decBtn     = document.getElementById('ao-decrease-btn');

    function showModal(el)  { el.style.removeProperty('display'); }
    function hideModal(el)  { el.style.display = 'none'; }

    function setStatus(elId, msg, isError) {
        var el = document.getElementById(elId);
        el.textContent = msg;
        el.className = 'text-xs mb-3 min-h-4 ' + (isError ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400');
    }

    // ── Increase ──────────────────────────────────────────────
    if (incBtn) {
        incBtn.addEventListener('click', function () {
            document.getElementById('ao-increase-body').innerHTML = '<p>Loading…</p>';
            document.getElementById('ao-increase-status').textContent = '';
            document.getElementById('ao-increase-confirm').disabled = true;
            showModal(incModal);

            fetch('{{ route('account.ao_subscription.show') }}', {
                headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrf }
            })
            .then(function (r) { return r.json(); })
            .then(function (d) {
                if (d.error) { setStatus('ao-increase-status', d.error, true); return; }
                var p = d.preview_increase;
                var plan = d.plan === 'yearly' ? '/yr' : '/mo';
                document.getElementById('ao-increase-body').innerHTML =
                    '<p><strong>New total:</strong> ' + p.new_qty + ' licence' + (p.new_qty !== 1 ? 's' : '')
                    + ' &mdash; $' + p.new_gross_total.toFixed(2) + plan + '</p>'
                    + '<p><strong>Charged today (prorated):</strong> $' + p.prorate_amount.toFixed(2)
                    + ' (' + p.days_remaining + ' days remaining)</p>'
                    + '<p class="text-xs text-gray-400">Proration is an estimate; Stripe\'s invoice may differ slightly.</p>';
                document.getElementById('ao-increase-confirm').disabled = false;
            })
            .catch(function () { setStatus('ao-increase-status', 'Could not load subscription details.', true); });
        });
    }

    document.getElementById('ao-increase-confirm')?.addEventListener('click', function () {
        var btn = this;
        btn.disabled = true;
        btn.textContent = 'Processing…';
        fetch('{{ route('account.ao_subscription.increase') }}', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': csrf },
            body: JSON.stringify({})
        })
        .then(function (r) { return r.json(); })
        .then(function (d) {
            btn.textContent = 'Confirm & Pay';
            btn.disabled = false;
            if (d.success) {
                hideModal(incModal);
                window.location.reload();
            } else {
                setStatus('ao-increase-status', d.error || 'An error occurred.', true);
            }
        })
        .catch(function () {
            btn.textContent = 'Confirm & Pay';
            btn.disabled = false;
            setStatus('ao-increase-status', 'Could not reach server.', true);
        });
    });

    document.getElementById('ao-increase-cancel')?.addEventListener('click', function () { hideModal(incModal); });
    incModal?.addEventListener('click', function (e) { if (e.target === incModal) hideModal(incModal); });

    // ── Decrease ──────────────────────────────────────────────
    if (decBtn) {
        decBtn.addEventListener('click', function () {
            document.getElementById('ao-decrease-body').innerHTML = '<p>Loading…</p>';
            document.getElementById('ao-decrease-status').textContent = '';
            document.getElementById('ao-decrease-confirm').disabled = true;
            showModal(decModal);

            fetch('{{ route('account.ao_subscription.show') }}', {
                headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrf }
            })
            .then(function (r) { return r.json(); })
            .then(function (d) {
                if (d.error) { setStatus('ao-decrease-status', d.error, true); return; }
                var newQty = d.quantity - 1;
                var plan   = d.plan === 'yearly' ? '/yr' : '/mo';
                var newGross = newQty * d.unit_price * (1 + d.tax_rate);
                document.getElementById('ao-decrease-body').innerHTML =
                    '<p><strong>New total:</strong> ' + newQty + ' licence' + (newQty !== 1 ? 's' : '')
                    + ' &mdash; $' + newGross.toFixed(2) + plan + '</p>'
                    + '<p><strong>Effective:</strong> ' + d.period_end + ' (end of current billing period)</p>'
                    + '<p>You keep your current ' + d.quantity + ' licence(s) and slots until then.</p>';
                document.getElementById('ao-decrease-confirm').disabled = false;
            })
            .catch(function () { setStatus('ao-decrease-status', 'Could not load subscription details.', true); });
        });
    }

    document.getElementById('ao-decrease-confirm')?.addEventListener('click', function () {
        var btn = this;
        btn.disabled = true;
        btn.textContent = 'Processing…';
        fetch('{{ route('account.ao_subscription.decrease') }}', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': csrf },
            body: JSON.stringify({})
        })
        .then(function (r) { return r.json(); })
        .then(function (d) {
            btn.textContent = 'Confirm Reduction';
            btn.disabled = false;
            if (d.success) {
                hideModal(decModal);
                window.location.reload();
            } else {
                setStatus('ao-decrease-status', d.error || 'An error occurred.', true);
            }
        })
        .catch(function () {
            btn.textContent = 'Confirm Reduction';
            btn.disabled = false;
            setStatus('ao-decrease-status', 'Could not reach server.', true);
        });
    });

    document.getElementById('ao-decrease-cancel')?.addEventListener('click', function () { hideModal(decModal); });
    decModal?.addEventListener('click', function (e) { if (e.target === decModal) hideModal(decModal); });
}());
</script>
@endif
```

**Verify Phase 5:**
- Active apwp-ao-only subscriber sees the licence row with qty, "+ Add 1 Licence" and "− Remove 1 Licence" buttons
- "− Remove 1 Licence" is disabled/greyed when qty = 1 or a pending decrease exists
- Clicking "+ Add 1 Licence" opens the modal and populates with correct totals and proration estimate
- Clicking "− Remove 1 Licence" opens the modal with the correct effective date
- Mixed-subscription users see the informational message but no buttons
- Gift-only (no `$aoSubInfo`) falls back to the plain "Purchase Additional Slots" cart form

---

## Phase 6 — End-to-End Verification

Work through the full verification checklist from the plan:

| # | Test | Method |
|---|---|---|
| 1 | Active apwp-ao-only subscriber sees qty row with +/- buttons | Log in, visit `/dashboard` |
| 2 | "Add 1 Licence" modal shows correct new total and proration | Open modal, verify figures match `previewAoIncrease()` output |
| 3 | Confirm increase — Stripe subscription updated, OrderItem qty increments, Payment amounts updated | Confirm modal; check Stripe dashboard, database `order_items` and `payments` tables |
| 4 | Dashboard reloads and shows new qty after increase | Verify page refresh after confirm |
| 5 | "Remove 1 Licence" modal shows correct new total and effective date | Open modal, verify period_end date |
| 6 | Confirm decrease — Stripe subscription updated with `proration_behavior: none`, `quantity_pending` set on Payment | Confirm modal; check Stripe dashboard, `payments` table |
| 7 | "Remove" button is disabled when qty = 1 | Set qty to 1 (or test with 1-licence subscriber) |
| 8 | "Remove" button is disabled when pending decrease already exists | After a successful decrease, confirm button is disabled |
| 9 | Slot conflict error returned when occupied slots > new limit | With 6+ active slots, attempt decrease — expect 422 with release message |
| 10 | Mixed-subscription user sees informational message, no buttons | Log in as user who bought apwp-ao with another product |
| 11 | Gift-only user falls back to "Purchase Additional Slots" cart button | Log in as gift subscriber |
| 12 | `purchasedLicenseQty()` returns new qty after increase | Call in tinker after confirming increase |
| 13 | `purchasedLicenseQty()` still returns old qty after scheduled decrease | Call in tinker after confirming decrease (period not yet ended) |

### Post-implementation reminder

The `CashierRenewalBridge` listener does **not** currently apply a scheduled decrease when the billing period turns over. After the period ends, `quantity_pending` will still be set on the Payment and `order_items.quantity` will still reflect the old (higher) qty. Clearing `quantity_pending` and syncing local records on renewal is a follow-up task documented in the plan.
