1# APWP-AO Subscription Quantity Management — Plan

## Context

Users with an active apwp-ao subscription currently have no way to add or remove licences without going through a full cart → checkout → payment cycle, which creates a second Stripe subscription. Each licence unit grants 5 domain slots. This plan adds:

1. **Increase quantity** — a simplified confirmation modal on the dashboard that prorates the charge and updates the existing Stripe subscription directly (bypasses cart).
2. **Decrease quantity** — a confirmation modal on the dashboard that schedules a reduction to take effect at the end of the current billing period (no proration credit). Blocked if occupied slots would exceed the new limit.

---

## Design Decisions

| Decision | Choice | Reason |
|---|---|---|
| Increase UI | Simplified confirm modal on dashboard | Bypass cart, update existing subscription in one click |
| Decrease timing | End of current billing period | No credit issued; user keeps current slots until period end |
| Slot conflict on decrease | Block with error | User must release slots manually first |
| Reduce control location | Dashboard AO section | Alongside Purchase Additional Slots |
| Mixed-subscription guard | Block with message | Only allow qty management on apwp-ao-only subscriptions |

---

## Scope Constraint: apwp-ao-only subscriptions

The current payment system stores one Stripe subscription per checkout and its `unit_amount` is the **gross total of all cart items combined**. If apwp-ao was purchased alongside other products, modifying the Stripe subscription price would incorrectly alter the billing for non-ao products. Quantity management is therefore restricted to subscriptions whose Order contains **only apwp-ao items**. Mixed-subscription users see an informational message directing them to support.

---

## UI Changes — Dashboard AO Section (active state only)

Replace the standalone "Purchase Additional Slots" button with a **Licence Management row** below the Connected Sites list:

```
┌─ Active Licence Management ──────────────────────────────────┐
│  Current: 2 licences · 10 slots · $XX/mo (monthly)           │
│                                                               │
│  [− Remove 1 Licence]          [+ Add 1 Licence]             │
└───────────────────────────────────────────────────────────────┘
```

- **"+ Add 1 Licence"** — opens an inline confirmation modal showing:
  - New total: `(current_qty + 1) licences · $YY/mo`
  - Prorated charge today: `$ZZ (N days remaining in billing period)`
  - [Cancel] [Confirm & Pay]
  - On confirm: `POST /account/ao-subscription/increase`

- **"− Remove 1 Licence"** — disabled when `current_qty === 1`; shows a warning when a pending decrease is already scheduled; otherwise opens a confirmation modal showing:
  - New total: `(current_qty - 1) licences · $YY/mo`
  - Effective date: end of current billing period (`period_end` date)
  - [Cancel] [Confirm Reduction]
  - On confirm: `POST /account/ao-subscription/decrease`

- Both buttons are hidden entirely when the user's apwp-ao access is via a mixed subscription (show the informational message instead).

---

## New Service Methods

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

### `findActiveAoSubscription(User $user): ?array`

Returns an array of everything needed to manage the subscription, or `null` if no manageable subscription is found:

```php
[
    'payment'          => Payment,         // the active Payment record
    'order'            => Order,           // the Order linked to that Payment
    'order_item'       => OrderItem,       // the apwp-ao line on that Order
    'cashier_sub'      => Subscription,    // Laravel Cashier subscription model
    'stripe_sub_id'    => string,          // Stripe subscription ID
    'stripe_item_id'   => string,          // Stripe subscription item ID
    'quantity'         => int,             // current licence count
    'unit_price'       => float,           // pre-tax per-licence price (from OrderItem.price)
    'tax_rate'         => float,           // stored in Stripe subscription metadata
    'plan'             => 'monthly'|'yearly',
    'period_end'       => Carbon,          // next renewal date (Cashier ends_at or trial_ends_at or calculated)
    'is_pure_ao'       => bool,            // true if the Order contains ONLY apwp-ao items
    'quantity_pending' => int|null,        // if a decrease is scheduled, the pending target qty
]
```

**Logic:**
1. Find completed Orders containing apwp-ao (same join as `purchasedLicenseQty`)
2. For each Order, check for a valid (non-expired, non-cancelled) Payment
3. Find the Cashier subscription whose `stripe_id` matches the Payment's `stripe_transaction_id`
4. Retrieve the Stripe subscription via the Stripe SDK to get `current_period_end` and metadata
5. Return null if no active Cashier subscription is found

### `previewAoIncrease(array $aoSub): array`

Returns proration preview data without hitting Stripe's preview endpoint:

```php
[
    'new_qty'            => int,
    'new_pretax_total'   => float,
    'new_tax'            => float,
    'new_gross_total'    => float,
    'prorate_amount'     => float,   // gross × (days_remaining / days_in_period)
    'period_end'         => Carbon,
]
```

Calculates client-side rather than using Stripe's proration preview API, keeping implementation simple. The displayed proration is an estimate; Stripe's actual prorated invoice may differ slightly.

---

## New Controller

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

### `show()` — GET `/account/ao-subscription`
Returns the current subscription state as JSON (for the dashboard modal to display dynamically). Called via `fetch()` when the user opens the Add or Remove confirmation modal.

Response:
```json
{
  "quantity": 2,
  "unit_price": 49.00,
  "tax_rate": 0.13,
  "plan": "monthly",
  "period_end": "2026-07-24",
  "is_pure_ao": true,
  "quantity_pending": null
}
```

### `increase()` — POST `/account/ao-subscription/increase`

1. Call `findActiveAoSubscription($user)` — 404 if none found
2. Abort with 422 if `!is_pure_ao` (mixed subscription)
3. Calculate new qty = `current_qty + 1`
4. Calculate new gross total = `new_qty × unit_price × (1 + tax_rate)` adjusted for plan interval
5. Create a new Stripe `Price` object (`unit_amount = new_gross_total_cents`, same `recurring.interval`, same Stripe `product` as the existing price)
6. Update the Stripe subscription item to the new price with `proration_behavior: create_prorations`
7. Update `order_item.quantity` to `new_qty`
8. Update `payment.amount`, `payment.tax_amount`, `payment.total_amount` to reflect new per-period totals
9. Return JSON success (dashboard JS reloads the section)

### `decrease()` — POST `/account/ao-subscription/decrease`

1. Call `findActiveAoSubscription($user)` — 404 if none found
2. Abort with 422 if `!is_pure_ao`
3. Abort with 422 if `current_qty <= 1` (minimum 1 licence)
4. Calculate `new_qty = current_qty - 1`
5. Check slot conflict: `aoUsedSlots($user) > new_qty * 5` → return 422 with message listing how many slots must be released
6. Calculate new gross total for `new_qty`
7. Create new Stripe `Price` for new total
8. Update Stripe subscription item to new price with `proration_behavior: none` — Stripe schedules the change at next renewal
9. Store `quantity_pending = new_qty` and `quantity_pending_at = period_end` on the Payment record (requires migration — see below)
10. Return JSON success

---

## New Routes

**File:** `routes/web.php` — inside the existing `auth + twofactor` middleware group:

```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');
```

---

## Database Migration

**New columns on `payments` table:**

```php
$table->unsignedSmallInteger('quantity_pending')->nullable();  // target qty after decrease
$table->timestamp('quantity_pending_at')->nullable();          // effective date of decrease
```

`quantity_pending` is set when a decrease is scheduled, cleared on the next successful renewal webhook (or if the user cancels the pending decrease — future enhancement).

---

## Stripe Interaction Details

### Price creation (both increase and decrease)

```php
$newPrice = $stripe->prices->create([
    'unit_amount' => (int) round($newGrossTotalCents),
    'currency'    => 'usd',
    'recurring'   => ['interval' => $interval],  // 'month' or 'year'
    'product'     => $existingStripePrice->product,  // reuse the same Stripe product
]);
```

### Subscription item update — increase (immediate proration)

```php
$stripe->subscriptions->update($stripeSubId, [
    'items' => [['id' => $stripeItemId, 'price' => $newPrice->id]],
    'proration_behavior' => 'create_prorations',
]);
```

### Subscription item update — decrease (scheduled, no proration)

```php
$stripe->subscriptions->update($stripeSubId, [
    'items' => [['id' => $stripeItemId, 'price' => $newPrice->id]],
    'proration_behavior' => 'none',
]);
```

### Retrieving existing price/product for apwp-ao

```php
$stripeSub   = $stripe->subscriptions->retrieve($stripeSubId, ['expand' => ['items.data.price']]);
$stripeItem  = $stripeSub->items->data[0];
$stripePrice = $stripeItem->price;
// $stripePrice->product  = Stripe product ID to reuse
// $stripeItem->id        = item ID for the update
```

---

## Renewal Webhook Handling (future / phase 2)

When the billing period ends after a scheduled decrease, Stripe charges the lower amount. The existing `CashierRenewalBridge` listener fires but does NOT currently update `Payment.amount` or clear `quantity_pending`. A follow-up task should:

- In `CashierRenewalBridge`, after affiliate commission: check if Payment has `quantity_pending` and `quantity_pending_at <= now()`
- If so: update `payment.amount / tax_amount / total_amount` to reflect the new lower qty, clear `quantity_pending`, update `order_item.quantity`

This is out of scope for the initial implementation but documented here to avoid data inconsistency being a surprise.

---

## Files to Create / Modify

| File | Action |
|---|---|
| `app/Http/Controllers/AoSubscriptionController.php` | Create |
| `app/Services/PluginAccessService.php` | Add `findActiveAoSubscription()` and `previewAoIncrease()` |
| `resources/views/dashboard.blade.php` | Replace "Purchase Additional Slots" button with licence management row + modals |
| `routes/web.php` | Add 3 new routes |
| `database/migrations/XXXX_add_quantity_pending_to_payments_table.php` | Create |

---

## Verification Checklist

1. **Active apwp-ao-only subscriber** — dashboard shows current qty, "Add 1 Licence" and "Remove 1 Licence" buttons.
2. **Add 1 Licence modal** — shows correct new total and estimated proration charge. Confirm updates Stripe, OrderItem qty increments, Payment amounts update, dashboard reflects new qty.
3. **Remove 1 Licence modal** — shows correct new total and effective date. Confirm schedules Stripe change, `quantity_pending` stored on Payment, dashboard shows pending note.
4. **Remove blocked at qty 1** — "Remove 1 Licence" button is disabled when only 1 licence remains.
5. **Slot conflict block** — attempt to decrease below occupied slots returns error with clear message (e.g., "You have 7 slots in use. Release 2 slots before reducing to 1 licence.").
6. **Mixed subscription** — user whose apwp-ao is part of a multi-product order sees informational message, no +/- buttons.
7. **Gift-only user** — no licence management controls shown (section shows as active but qty management requires a purchased subscription).
8. **`purchasedLicenseQty()` accuracy** — after increase, returns new higher qty; after decrease (scheduled), still returns current qty until period end.
