# Subscription module

This application uses **Laravel Cashier** (Stripe) for billing, with a **local catalog** (`plans` + `prices`) that admins manage and that syncs to Stripe in the background. End users do not see Stripe IDs or webhook terminology in the UI; admins do for support and operations.

## High-level architecture

```mermaid
flowchart TB
    subgraph Admin
        A[Plan CRUD] --> B[PlanService]
        B --> C[PlanChanged event]
        C --> D[SyncPlan job on queue stripe]
        D --> E[StripeService]
        E --> F[(Stripe API)]
        E --> G[(plans / prices DB)]
    end

    subgraph User
        U1[Plans page] --> U2[Hosted Checkout]
        U2 --> F
        F --> W[POST /stripe/webhook]
        W --> H[Cashier WebhookController]
        H --> I[(subscriptions DB)]
        U3[Subscription dashboard] --> I
    end

    G -. maps via stripe_price_id .-> I
```

**Source of truth for “is the user subscribed?”** — Stripe webhooks update Cashier tables (`subscriptions`, customer on `users`). The post-checkout success redirect is **not** trusted alone; the success page polls until webhooks mark the subscription active.

---

## Data model

### `plans` (product catalog)

| Column | Purpose |
|--------|---------|
| `name`, `slug`, `description` | Customer-facing copy (description required) |
| `interval`, `interval_count` | Stripe-compatible recurring billing (`day`/`week`/`month`/`year` + count, default `month`/`1`) |
| `stripe_product_id` | Stripe Product ID (nullable until sync) |
| `status` | `active` or `archive` |

One plan has **one** related price (`Plan::price()` is `hasOne`).

### `prices` (billing amount per plan)

| Column | Purpose |
|--------|---------|
| `plan_id` | Parent plan |
| `amount`, `currency` | Decimal in DB (USD only via `config('plans.currency')`); sent to Stripe in **minor units** (×100) |
| `stripe_price_id` | Stripe Price ID (recurring `interval` + `interval_count` from plan) |
| `status` | `active` or `archive` |

Checkout and plan switching require `stripe_price_id`. If missing, the plan is treated as not ready.

### `subscriptions` (Cashier)

Standard Cashier columns plus app-specific fields:

| Column | Purpose |
|--------|---------|
| `user_id`, `type` | Subscription name is always `default` (single slot) |
| `stripe_id` | Stripe Subscription ID |
| `stripe_status` | e.g. `active`, `trialing`, `canceled`, `past_due` |
| `stripe_price` | Current Stripe Price ID → links to `prices.stripe_price_id` |
| `ends_at`, `trial_ends_at` | Grace period / trial |
| `current_period_start`, `current_period_end`, `cancel_at` | Synced by custom webhook listener |
| `pending_stripe_price_id`, `pending_plan_effective_at` | Scheduled downgrade (see Plan changes) |

Model: `App\Models\CashierSubscription` (registered in `AppServiceProvider`).

### `users` (Billable)

`App\Models\User` uses `Laravel\Cashier\Billable`:

- `stripe_id` — Stripe Customer ID
- `SubscriptionPlanResolver::activeSubscription($user)` — the billing slot (null after `cancelNow()` / ended)
- `subscription('default')` — newest default row (may be ended; use resolver in app code)
- `subscriptions()` — subscription history

### Linking local plan ↔ subscription

`App\Support\SubscriptionPlanResolver` matches:

`subscriptions.stripe_price` → `prices.stripe_price_id` → `plans`.

Used on the public plans page, user dashboard, admin user detail, and the JSON status endpoint.

---

## Configuration

| Item | Location |
|------|----------|
| Stripe secret / publishable | `config('services.stripe.*')`, `.env` (`STRIPE_KEY`, `STRIPE_SECRET`) |
| Webhook endpoint | `POST /stripe/webhook` (`routes/web.php`, Cashier `WebhookController`) |
| Webhook signature | Cashier `VerifyWebhookSignature` middleware |
| Custom subscription model | `AppServiceProvider::boot()` → `Cashier::useSubscriptionModel(CashierSubscription::class)` |
| Plan sync queue | `SyncPlan` job on queue **`stripe`** |

If Stripe is not configured, `StripeService` skips API calls and logs; local plans still persist.

---

## Admin: Plans management

**Routes:** `routes/admin.php` — `admin.plans.*`

| Route | Action |
|-------|--------|
| `GET /admin/plans` | List (AJAX partial table) |
| `GET /admin/plans/create` | Create form |
| `POST /admin/plans` | Store |
| `GET /admin/plans/{plan}/edit` | Edit form |
| `PUT /admin/plans/{plan}` | Update |
| `PATCH /admin/plans/{plan}/archive` | Archive (no hard delete) |

**Code path:**

1. `PlanRequest` validates name, required description, billing period preset (maps to `interval` + `interval_count`), amount, price status. Currency is forced to USD in `prepareForValidation()`.
2. `PlanService::store` / `update` / `archive` writes `plans` + `price` in a transaction.
3. Fires `App\Events\Admin\Subscription\PlanChanged`.
4. `SyncStripePlanListener` dispatches `App\Jobs\Admin\Subscription\SyncPlan`.
5. `StripeService::syncPlan`:
   - Create/update Stripe **Product** → `stripe_product_id`
   - `syncPrice`: create/update Stripe **Price** (recurring `interval` + `interval_count` from plan)
   - If amount, currency, or billing period change on an existing Stripe price → new Stripe price, deactivate old, update `stripe_price_id`
   - Archive → Stripe product/price `active: false`

**UI:** `resources/views/pages/admin/subscription/plans/`, `resources/js/admin/subscription/plans-management.js`, `plan-form.js` (shared `resources/js/admin/table/common.js` patterns).

---

## Admin: Subscription (purchase history)

**Routes:** `admin.subscriptions.*`

| Route | Action |
|-------|--------|
| `GET /admin/subscriptions` | All subscriptions (AJAX table) |
| `GET /admin/subscriptions/users/{user}` | Per-user current + history |
| `POST .../cancel` | Cancel at period end (`Subscription::cancel()`) |
| `POST .../cancel-now` | Cancel immediately (`cancelNow()`) |

**Service:** `App\Services\Admin\SubscriptionService`

**UI:** `resources/views/pages/admin/subscription/subscriptions/`, `subscriptions-management.js`, `user-subscriptions.js`

Subscriptions are created by user checkout/webhooks (or seeders), not from this UI.

---

## Free trial (30 days)

Fixed **30-day** trial on first subscription checkout only. Not configurable in admin.

| Piece | Role |
|-------|------|
| `App\Support\SubscriptionTrial` | `DAYS = 30`, eligibility, badge label |
| `users.has_trialed` | One-time global flag (abuse prevention) |
| `PlansController::checkout` | `trialDays(30)` when `SubscriptionTrial::eligible($user)` |
| `MarkUserTrialConsumed` | Webhook listener sets `has_trialed` when Stripe status is `trialing` |
| `User::markTrialAsUsed()` | Idempotent update (not in `$fillable`) |

**Rules:**

- New checkout: trial applied if user has not trialed before.
- Returning users with `has_trialed = true`: checkout proceeds **without** trial (immediate paid subscription after checkout).
- Plan changes (upgrade/downgrade) are unchanged — no trial on swap.
- Success polling treats `trialing` the same as `active`.
- Stripe bills automatically when the trial ends (`trial_ends_at` / `trialing` → `active`).

---

## User: Plans & checkout

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

| Route | Name | Auth |
|-------|------|------|
| `GET /plans` | `plans.index` | Public |
| `GET /subscription/checkout/{plan}` | `subscription.checkout` | `auth:user` |
| `GET /subscription/success` | `subscription.success` | `auth:user` |
| `GET /subscription/cancel` | `subscription.cancel` | `auth:user` |
| `GET /subscription/status` | `subscription.status` | `auth:user` |

**Controller:** `App\Http\Controllers\PlansController`

### Checkout rules

1. Plan and price must be `active` with `stripe_price_id`.
2. **One subscription slot:** if `SubscriptionPlanResolver::occupiesSubscriptionSlot($user)`, checkout is blocked; user must use dashboard **Switch plan** or wait until the subscription has fully ended.
3. Otherwise: `$user->newSubscription('default', $stripe_price_id)` with optional `trialDays(30)` when eligible → `checkout([...])` → Stripe hosted checkout (no charge during trial).

### Success page

1. Requires `?checkout_session_id=...`
2. Retrieves session from Stripe; **403** if session customer ≠ user's `stripe_id`
3. Polls `subscription/status` until `active` / `trialing`, then redirects to dashboard
4. User-facing labels via `App\Support\SubscriptionStatusLabel` (no Stripe IDs in UI)

---

## User: Subscription dashboard

**Routes:** `routes/user.php` — `user.subscription.*`

| Route | Action |
|-------|--------|
| `GET /user/dashboard/subscription` | `index` — current plan, pending change, history |
| `POST /user/dashboard/subscription/change/{plan}` | Upgrade or schedule downgrade |
| `POST /user/dashboard/subscription/cancel` | Cancel at period end |
| `POST /user/dashboard/subscription/resume` | Resume during grace period |
| `GET /user/dashboard/subscription/invoices/{invoice}/download` | Download invoice PDF (Stripe hosted or Cashier) |

**Controller:** `App\Http\Controllers\User\Dashboard\SubscriptionController`

**View:** `resources/views/pages/user/dashboard/subscription/index.blade.php`  
**JS:** `resources/js/user/subscription-actions.js`

---

## Plan changes

**Controller:** `SubscriptionController::change`

### Guards

- Active `default` subscription that has not fully ended
- No second scheduled change while `pending_plan_effective_at` is in the future
- Cannot switch to current or already-pending plan

### Free trial (any plan change)

Handled by `App\Support\SubscriptionPlanChange::applyTrialSwap()`:

1. Clears any stale pending downgrade fields
2. `noProrate()->swap($newStripePriceId)` — immediate change, no invoice
3. Cashier passes the existing `trial_ends_at` timestamp to Stripe (`trial_end` in swap payload)
4. Success toast: *Plan changed successfully. Your free trial remains active.*

Trial users use **Change plan** on `/plans` or the dashboard — never a second Checkout session.

### Paid upgrade (new amount **greater** than current)

1. Clears `pending_stripe_price_id` and `pending_plan_effective_at`
2. `swapAndInvoice($newStripePriceId)` — immediate change + proration
3. On `IncompletePayment` → redirect to `cashier.payment`

### Paid downgrade (new amount **less than or equal**)

1. Does **not** swap on Stripe immediately
2. Sets `pending_stripe_price_id` + `pending_plan_effective_at` (current period end from DB or Stripe API)
3. UI shows current plan until effective date
4. `App\Listeners\Cashier\ApplyPendingPlanChange` on `invoice.payment_succeeded` webhook runs `noProrate()->swap()` when effective date has passed, then clears pending fields

---

## Business rules

Centralized in `App\Support\SubscriptionPlanResolver`:

| Method | Meaning |
|--------|---------|
| `subscription($user)` | Newest `default` row (includes ended — admin/history) |
| `activeSubscription($user)` | Default row only while it occupies the billing slot |
| `subscriptionOccupiesSlot($subscription)` | Not `ended()`, not `incomplete_expired`, not canceled without grace |
| `occupiesSubscriptionSlot($user)` | Slot blocked for Checkout (includes grace period) |
| `canStartNewSubscription($user)` | May use hosted checkout |
| `canChangePlan($user)` | Valid active/trialing on `activeSubscription`, not canceling, no pending scheduled change |
| `isOnFreeTrial($subscription)` | `onTrial()` on an occupying subscription |
| `hasScheduledPlanChange($user)` | Pending downgrade with future effective date |
| `currentPlan` / `pendingPlan` | Resolve `Plan` from `activeSubscription` only — ended rows return null |

**One plan at a time:** users cannot hold multiple concurrent active subscriptions; they switch or wait for the slot to free.

---

## Webhooks & event listeners

Registered in `App\Providers\AppServiceProvider`:

| Event | Listener | Behavior |
|-------|----------|----------|
| `PlanChanged` | `SyncStripePlanListener` | Dispatch `SyncPlan` to `stripe` queue |
| `WebhookHandled` | `SyncSubscriptionPeriodFields` | On subscription created/updated/deleted, sync period and `cancel_at` columns |
| `WebhookHandled` | `ApplyPendingPlanChange` | On `invoice.payment_succeeded`, apply deferred downgrade swap |

Cashier's `WebhookController` handles signature verification and standard subscription/customer updates.

---

## Key files

| Area | Path |
|------|------|
| Models | `app/Models/Plan.php`, `Price.php`, `CashierSubscription.php` |
| User checkout | `app/Http/Controllers/PlansController.php` |
| User dashboard | `app/Http/Controllers/User/Dashboard/SubscriptionController.php` |
| User invoices | `app/Services/User/SubscriptionInvoiceService.php`, `app/Support/InvoiceStatusLabel.php` |
| Admin plans | `app/Http/Controllers/Admin/Subscription/PlanController.php` |
| Admin subscriptions | `app/Http/Controllers/Admin/Subscription/SubscriptionController.php` |
| Services | `app/Services/Admin/PlanService.php`, `SubscriptionService.php`, `StripeService.php` |
| Config | `config/plans.php` (USD currency, billing period presets) |
| Support | `app/Support/PlanBillingPeriod.php` (labels, Stripe recurring mapping) |
| Display | `app/Support/PriceFormatter.php`, Blade `<x-money />`, `<x-forms.amount-input />` |
| Support | `app/Support/SubscriptionPlanResolver.php`, `SubscriptionPlanChange.php`, `SubscriptionStatusLabel.php` |
| Stripe sync | `app/Jobs/Admin/Subscription/SyncPlan.php`, `app/Listeners/Admin/Subscription/SyncStripePlanListener.php` |
| Migrations | `2026_06_02_000001_create_plans_table`, `2026_06_02_000002_create_prices_table`, `2026_06_02_085109_add_billing_columns_to_users_table`, `2026_06_02_085110_create_subscriptions_table`, `2026_06_02_085111_create_subscription_items_table` |
| Seeders | `database/seeders/PlanSeeder.php`, `SubscriptionSeeder.php` (no Stripe API) |

---

## Typical flows

### New subscriber

1. Admin creates plan → `SyncPlan` syncs product/price to Stripe  
2. User visits `/plans` → Subscribe → Stripe Checkout  
3. Payment completes → redirect to `/subscription/success`  
4. Webhook creates/updates `subscriptions`  
5. Success poll sees active status → user dashboard  

### Change plan during free trial

1. User on Silver trial visits `/plans` → **Change plan** on Gold  
2. `POST user.subscription.change` → `SubscriptionPlanChange::apply` → `noProrate()->swap`  
3. `trial_ends_at` unchanged; webhooks sync `stripe_price`  
4. No Checkout, no second subscription row  

### Upgrade mid-cycle (paid)

1. Dashboard → higher plan → confirm  
2. `swapAndInvoice`  
3. Webhooks update `stripe_price` and status  

### Downgrade at renewal

1. User selects cheaper plan  
2. Pending fields set; UI shows next plan date  
3. Next `invoice.payment_succeeded` → `ApplyPendingPlanChange` swaps price  

### Cancel & resume

1. `cancel()` sets end of period / grace  
2. `resume()` during grace period  
3. After fully ended, user may subscribe again via checkout (new row in history)  

---

## Local development

```bash
# Migrations
php artisan migrate

# Optional seed data (no Stripe calls)
php artisan db:seed --class=PlanSeeder
php artisan db:seed --class=SubscriptionSeeder

# Plan sync to Stripe (after admin saves a plan)
php artisan queue:work --queue=stripe
```

**Environment:**

- `STRIPE_KEY`, `STRIPE_SECRET`, Cashier webhook secret  
- Stripe Dashboard or CLI: webhook URL → `{APP_URL}/stripe/webhook`  

---

## Current limitations

- One **price** per plan in the admin form (not multiple tiers per plan in one screen)  
- Billing period presets: Daily, Weekly, 15 Days, Monthly, Quarterly, Yearly (stored as `interval` + `interval_count`)  
- Catalog currency is **USD only** (`config('plans.currency')`)  
- Subscription name is hardcoded as **`default`**  
- User UI hides Stripe identifiers; admin UI shows them  
- Public plans list only includes `status = active` plans  
