# ECStores Subscription Tiers — Design Spec

## Goal

Gate existing ECStores features behind three subscription tiers (Starter / Growth / Pro) so that Denis can monetise the platform and upsell merchants. All gating is enforced in the tenant Filament admin panel. Billing is handled separately on eastcoastwebcraft.ca.

## Context

- **Platform:** Laravel 13, Livewire 4.3, Filament 5.6, stancl/tenancy 3.x
- **Already built:** `Plan` model, `plans` table, `plan_id` on `tenants`, Cashier `Billable` trait on `Tenant`, super-admin `PlanResource` (CRUD), super-admin `TenantResource` (assign plan, swap Stripe subscription, 30-day trial, suspend/reactivate)
- **All existing data is test data** — seeder and deploy migration may freely reset plan rows

---

## Tier Feature Matrix

| Feature | Starter | Growth | Pro |
|---|---|---|---|
| **Max products** | 25 | 100 | Unlimited |
| **Product variants** | No | Yes | Yes |
| **Coupons & discounts** | No | Yes | Yes |
| **Advanced shipping** (weight tiers, threshold, separate shipment cost) | No | Yes | Yes |
| **Report tier** | Basic (1) | Standard (2) | Full (3) |
| **Refund management** | No | No | Yes *(feature not yet built)* |
| **Expense tracking** | No | No | Yes *(feature not yet built)* |
| **Abandoned cart recovery** | No | No | Yes *(feature not yet built)* |
| **Customer lifetime value tracking** | No | No | Yes *(feature not yet built)* |

### Report Tier Detail

| Capability | Starter (1) | Growth (2) | Pro (3) |
|---|---|---|---|
| Stat cards (orders, revenue, net) | 3 basic cards, current month only | All 6 cards, full date range + presets | All 6 cards, full date range + presets |
| Sales-by-product breakdown | No | Yes | Yes |
| Export: sales-by-product CSV | No | Yes | Yes |
| Export: full order detail CSV | No | No | Yes |
| Export: monthly tax summary CSV | No | No | Yes |

---

## Data Layer

### Migration: add columns to `plans`

```sql
ALTER TABLE plans ADD COLUMN slug VARCHAR(32) UNIQUE AFTER name;
ALTER TABLE plans ADD COLUMN max_products SMALLINT UNSIGNED NULL AFTER slug;
ALTER TABLE plans ADD COLUMN has_advanced_shipping TINYINT(1) NOT NULL DEFAULT 0 AFTER max_products;
ALTER TABLE plans ADD COLUMN has_variants TINYINT(1) NOT NULL DEFAULT 0 AFTER has_advanced_shipping;
ALTER TABLE plans ADD COLUMN has_coupons TINYINT(1) NOT NULL DEFAULT 0 AFTER has_variants;
ALTER TABLE plans ADD COLUMN report_tier TINYINT UNSIGNED NOT NULL DEFAULT 1 AFTER has_coupons;
ALTER TABLE plans ADD COLUMN has_refunds TINYINT(1) NOT NULL DEFAULT 0 AFTER report_tier;
ALTER TABLE plans ADD COLUMN has_expense_tracker TINYINT(1) NOT NULL DEFAULT 0 AFTER has_refunds;
ALTER TABLE plans ADD COLUMN has_abandoned_cart TINYINT(1) NOT NULL DEFAULT 0 AFTER has_expense_tracker;
ALTER TABLE plans ADD COLUMN has_clv_tracking TINYINT(1) NOT NULL DEFAULT 0 AFTER has_abandoned_cart;
```

### Seeder: PlanSeeder

Truncates `plans` and inserts the three canonical rows:

| slug | name | max_products | has_advanced_shipping | has_variants | has_coupons | report_tier | has_refunds | has_expense_tracker | has_abandoned_cart | has_clv_tracking |
|---|---|---|---|---|---|---|---|---|---|---|
| `starter` | Starter | 25 | false | false | false | 1 | false | false | false | false |
| `growth` | Growth | 100 | true | true | true | 2 | false | false | false | false |
| `pro` | Pro | null | true | true | true | 3 | true | true | true | true |

`price` and `stripe_price_id` are left at their defaults (Denis sets pricing in the super-admin UI).

### Deploy migration: assign Pro to unassigned tenants

```sql
UPDATE tenants SET plan_id = (SELECT id FROM plans WHERE slug = 'pro') WHERE plan_id IS NULL;
```

Run after the seeder, so the Pro plan ID is known.

### Plan model — updated `$fillable` and `casts()`

Add: `slug`, `max_products`, `has_advanced_shipping`, `has_variants`, `has_coupons`, `report_tier`, `has_refunds`, `has_expense_tracker`, `has_abandoned_cart`, `has_clv_tracking`.

---

## PlanService

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

Singleton service bound in the service container. Resolves the current tenant's plan once per request and caches it in memory.

```
tenant() → plan() → Plan model
```

**Null-plan rule:** if `tenant()->plan_id` is null (unassigned), treat as Pro — unlimited access, no lockouts.

### Public interface

```php
public function plan(): Plan              // the resolved Plan model (or a synthetic Pro plan)
public function maxProducts(): ?int       // null = unlimited
public function canAddProduct(): bool     // compares limit vs. current product count
public function hasAdvancedShipping(): bool
public function hasVariants(): bool
public function hasCoupons(): bool
public function reportTier(): int         // 1 | 2 | 3
public function hasRefunds(): bool
public function hasExpenseTracker(): bool
public function hasAbandonedCart(): bool
public function hasClvTracking(): bool
```

`canAddProduct()` counts products via `Product::count()` — this runs against the active tenant DB connection automatically within a tenant context — and compares to `maxProducts()`.

### Registration

Bound as a singleton in `AppServiceProvider` (or a dedicated `PlanServiceProvider`):

```php
$this->app->singleton(PlanService::class);
```

---

## Feature Gates

### Over-limit behaviour

**Soft-lock:** existing products, coupons, and variant groups are never deleted or hidden when a tenant is downgraded. Only the ability to *create new* items is blocked. The soft-lock is communicated via a Filament notification and/or an inline upgrade notice — not a hard error.

### 1 — Max products (`CreateProduct`)

- `CreateProduct::mount()` calls `app(PlanService::class)->canAddProduct()`.
- If false: redirect to the product list with a Filament `warning` notification:
  > "You've reached your 25-product limit. Upgrade to Growth to add more products."
- The "New product" button on `ListProducts` is hidden (via `canCreate()`) when at the limit.

### 2 — Variants (`ProductForm`)

- The entire "Variants" section (variant groups repeater) is wrapped in a `visible()` closure that checks `hasVariants()`.
- On Starter, the section simply doesn't render. Existing variant data in the DB is untouched.
- No nav badge needed — the gate is inside the product form, not a separate page.

### 3 — Coupons (`CouponResource` / `ListCoupons`)

- `CouponResource::getNavigationBadge()` returns `'Growth+'` when plan is Starter, so the nav item shows a badge.
- `CouponResource::getNavigationBadgeColor()` returns `'warning'` for the badge.
- `ListCoupons::mount()` checks `hasCoupons()`. If false, sets `$this->planLocked = true`.
- The list page view renders a prominent upgrade notice panel instead of the table when `$planLocked`.
- Upgrade notice text: *"Coupons & discounts are available on the Growth plan and above. Contact us to upgrade."*

### 4 — Advanced shipping (`ShippingMethodForm`)

- The `pricing_type` Select field `->options()` is filtered through a closure: if `!hasAdvancedShipping()`, only `['flat' => 'Flat Rate']` is returned (weight-based option is not present).
- Existing weight-based shipping methods in the DB are unaffected; the tenant just can't create new ones or change existing flat-rate methods to weight-based.

### 5 — Financial Reports (`FinancialReports` page)

The page always renders (no redirect, no lock notice). Content is toggled by `reportTier()`:

| Element | Shown when |
|---|---|
| Date range picker + presets | `reportTier() >= 2` |
| "Current month" fixed header | `reportTier() == 1` |
| All 6 stat cards | `reportTier() >= 2` |
| 3 basic stat cards only | `reportTier() == 1` |
| Sales-by-product breakdown | `reportTier() >= 2` |
| Export: sales-by-product CSV | `reportTier() >= 2` |
| Export: order detail CSV | `reportTier() >= 3` |
| Export: tax summary CSV | `reportTier() >= 3` |

For Starter, `dateFrom`/`dateTo` are fixed to the current month in `mount()` and the date controls are not rendered.

---

## Super-Admin PlanResource Update

The existing Filament `PlanResource` form gets the new fields added:

- `slug` — TextInput, required, unique hint
- `max_products` — TextInput (numeric), nullable, helper: "Leave blank for unlimited"
- `has_advanced_shipping` — Toggle
- `has_variants` — Toggle
- `has_coupons` — Toggle
- `report_tier` — Select with options: 1 = Basic, 2 = Standard, 3 = Full
- `has_refunds` — Toggle
- `has_expense_tracker` — Toggle
- `has_abandoned_cart` — Toggle
- `has_clv_tracking` — Toggle

This lets Denis edit plan capabilities directly in the super-admin without touching code.

---

## Billing Integration

Plans are managed entirely within the ECStores super-admin (Filament). Denis creates a matching product on eastcoastwebcraft.ca for billing purposes. The `stripe_price_id` on the `Plan` model links to the Stripe product for automated subscription management (already wired via Cashier). No code integration between ECW and ECStores is required.

---

## Deployment Checklist

1. Run `php artisan migrate` on the central DB (adds columns to `plans`)
2. Run `php artisan db:seed --class=PlanSeeder` (creates Starter/Growth/Pro rows)
3. Run the deploy migration SQL (assigns Pro to unassigned tenants)
4. Clear config/route/view caches
5. Verify in super-admin: 3 plans visible with correct feature flags
6. Verify in tenant admin: feature gates active per plan

---

## Future Pro Features (not yet built)

The following Pro-only features are included in the Plan model and PlanService now so the gating infrastructure is ready. Each feature's UI gate will be wired when the feature itself is built — each is a separate implementation project:

| Feature | PlanService method | When built |
|---|---|---|
| Refund management | `hasRefunds()` | Future project |
| Expense tracking | `hasExpenseTracker()` | Future project |
| Abandoned cart recovery | `hasAbandonedCart()` | Future project |
| Customer lifetime value tracking | `hasClvTracking()` | Future project |
