diff --git a/.claude/rules/tiers.md b/.claude/rules/tiers.md index ebd7f3f..9b8511d 100644 --- a/.claude/rules/tiers.md +++ b/.claude/rules/tiers.md @@ -103,11 +103,12 @@ Each tier has two prices: ### `plans` table ``` -id unsignedBigInteger PK -name string — free | basic | plus | pro -stripe_price_id string nullable — maps Cashier price to this plan -features json — see shape below -active boolean default true +id unsignedBigInteger PK +name string — free | basic | plus | pro +stripe_price_id_monthly string nullable — Cashier price ID for monthly billing +stripe_price_id_annual string nullable — Cashier price ID for annual billing +features json — see shape below +active boolean default true timestamps ``` @@ -123,7 +124,8 @@ timestamps "frequency": "triggered" }, "push": { - "enabled": true + "enabled": true, + "frequency": "triggered" }, "whatsapp": { "enabled": true, @@ -141,7 +143,20 @@ timestamps ``` `fuel_types.max: null` means unlimited. `email.frequency` values: `weekly_digest`, -`daily`, `triggered`. All boolean features default `false` on free. +`daily`, `triggered`. `push.frequency` values: `none` (when disabled), `daily`, +`triggered`. All boolean features default `false` on free. + +`database/seeders/PlanSeeder.php` is the source of truth. Per-tier reality: + +- `price_threshold` and `score_alerts` are **enabled on basic, plus, and pro** (not plus-only). +- `ai_predictions` is **plus and pro only**. +- `whatsapp` and `sms` always carry `daily_limit` (and whatsapp carries `scheduled_updates`) + even when `enabled: false` — set to `0` on disabled tiers. + +> Deeper per-tier feature flags (history window, prediction level, leaderboard size, +> saved stations, fuel log caps, brand comparison, route planner, family sharing) are +> defined in `docs/superpowers/specs/2026-04-15-tier-features-design.md` — that spec +> is the source of truth for entitlements beyond notification channels. ### `user_notification_preferences` table @@ -184,8 +199,8 @@ missed-count queries. - Casts `features` to `array`. - Has a static `resolveForUser(User $user): Plan` method — looks up the user's - active Cashier subscription price ID, matches to `stripe_price_id`, falls back - to the `free` plan row. + active Cashier subscription price ID, matches to either `stripe_price_id_monthly` + or `stripe_price_id_annual`, falls back to the `free` plan row. - Cache the resolved plan: `Cache::tags(['plans'])->remember("plan_for_user_{$user->id}", 3600, ...)`. - Bust `Cache::tags(['plans'])` in an Eloquent `saved` observer on `Plan`. diff --git a/app/Enums/PlanTier.php b/app/Enums/PlanTier.php index 2d29c58..cce0b4c 100644 --- a/app/Enums/PlanTier.php +++ b/app/Enums/PlanTier.php @@ -8,4 +8,14 @@ enum PlanTier: string case Basic = 'basic'; case Plus = 'plus'; case Pro = 'pro'; + + public function label(): string + { + return match ($this) { + self::Free => 'Free', + self::Basic => 'Daily', + self::Plus => 'Smart', + self::Pro => 'Pro', + }; + } } diff --git a/app/Filament/Resources/Plans/Schemas/PlanForm.php b/app/Filament/Resources/Plans/Schemas/PlanForm.php index 0664013..a08fec9 100644 --- a/app/Filament/Resources/Plans/Schemas/PlanForm.php +++ b/app/Filament/Resources/Plans/Schemas/PlanForm.php @@ -40,9 +40,17 @@ class PlanForm ]), Section::make('Push') + ->columns(2) ->schema([ Toggle::make('features.push.enabled') ->label('Enabled'), + Select::make('features.push.frequency') + ->label('Frequency') + ->options([ + 'none' => 'None (disabled)', + 'daily' => 'Daily', + 'triggered' => 'Triggered', + ]), ]), Section::make('WhatsApp') diff --git a/app/Models/Plan.php b/app/Models/Plan.php index 99b0ead..50271bb 100644 --- a/app/Models/Plan.php +++ b/app/Models/Plan.php @@ -70,7 +70,7 @@ class Plan extends Model 'features' => [ 'fuel_types' => ['max' => 1], 'email' => ['enabled' => true, 'frequency' => 'weekly_digest'], - 'push' => ['enabled' => false], + 'push' => ['enabled' => false, 'frequency' => 'none'], 'whatsapp' => ['enabled' => false, 'daily_limit' => 0, 'scheduled_updates' => 0], 'sms' => ['enabled' => false, 'daily_limit' => 0], 'ai_predictions' => false, @@ -96,4 +96,12 @@ class Plan extends Model 'active' => 'boolean', ]; } + + /** User-facing display label for this plan (e.g. basic → "Daily"). */ + public function displayName(): string + { + $tier = PlanTier::tryFrom((string) $this->name) ?? PlanTier::Free; + + return $tier->label(); + } } diff --git a/app/Services/PlanFeatures.php b/app/Services/PlanFeatures.php index 7b5f7e4..1a21f03 100644 --- a/app/Services/PlanFeatures.php +++ b/app/Services/PlanFeatures.php @@ -23,7 +23,7 @@ final class PlanFeatures 'features' => [ 'fuel_types' => ['max' => 1], 'email' => ['enabled' => true, 'frequency' => 'weekly_digest'], - 'push' => ['enabled' => false], + 'push' => ['enabled' => false, 'frequency' => 'none'], 'whatsapp' => ['enabled' => false, 'daily_limit' => 0, 'scheduled_updates' => 0], 'sms' => ['enabled' => false, 'daily_limit' => 0], 'ai_predictions' => false, @@ -195,4 +195,10 @@ final class PlanFeatures { return $this->plan->name ?? 'free'; } + + /** User-facing display label for the resolved tier (e.g. basic → "Daily"). */ + public function displayName(): string + { + return $this->plan->displayName(); + } } diff --git a/database/factories/PlanFactory.php b/database/factories/PlanFactory.php index 91783cc..71277bd 100644 --- a/database/factories/PlanFactory.php +++ b/database/factories/PlanFactory.php @@ -14,7 +14,7 @@ class PlanFactory extends Factory private static array $defaultFeatures = [ 'fuel_types' => ['max' => 1], 'email' => ['enabled' => false, 'frequency' => 'weekly_digest'], - 'push' => ['enabled' => false], + 'push' => ['enabled' => false, 'frequency' => 'none'], 'whatsapp' => ['enabled' => false, 'daily_limit' => 0, 'scheduled_updates' => 0], 'sms' => ['enabled' => false, 'daily_limit' => 0], 'ai_predictions' => false, @@ -49,7 +49,7 @@ class PlanFactory extends Factory 'features' => [ 'fuel_types' => ['max' => 1], 'email' => ['enabled' => true, 'frequency' => 'daily'], - 'push' => ['enabled' => true], + 'push' => ['enabled' => true, 'frequency' => 'daily'], 'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2], 'sms' => ['enabled' => false, 'daily_limit' => 0], 'ai_predictions' => false, @@ -67,7 +67,7 @@ class PlanFactory extends Factory 'features' => [ 'fuel_types' => ['max' => 1], 'email' => ['enabled' => true, 'frequency' => 'triggered'], - 'push' => ['enabled' => true], + 'push' => ['enabled' => true, 'frequency' => 'triggered'], 'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2], 'sms' => ['enabled' => true, 'daily_limit' => 1], 'ai_predictions' => true, @@ -85,7 +85,7 @@ class PlanFactory extends Factory 'features' => [ 'fuel_types' => ['max' => null], 'email' => ['enabled' => true, 'frequency' => 'triggered'], - 'push' => ['enabled' => true], + 'push' => ['enabled' => true, 'frequency' => 'triggered'], 'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2], 'sms' => ['enabled' => true, 'daily_limit' => 3], 'ai_predictions' => true, diff --git a/database/seeders/PlanSeeder.php b/database/seeders/PlanSeeder.php index b7be9eb..86a66ca 100644 --- a/database/seeders/PlanSeeder.php +++ b/database/seeders/PlanSeeder.php @@ -17,7 +17,7 @@ class PlanSeeder extends Seeder 'features' => [ 'fuel_types' => ['max' => 1], 'email' => ['enabled' => true, 'frequency' => 'weekly_digest'], - 'push' => ['enabled' => false], + 'push' => ['enabled' => false, 'frequency' => 'none'], 'whatsapp' => ['enabled' => false, 'daily_limit' => 0, 'scheduled_updates' => 0], 'sms' => ['enabled' => false, 'daily_limit' => 0], 'ai_predictions' => false, @@ -31,7 +31,7 @@ class PlanSeeder extends Seeder 'features' => [ 'fuel_types' => ['max' => 1], 'email' => ['enabled' => true, 'frequency' => 'daily'], - 'push' => ['enabled' => true], + 'push' => ['enabled' => true, 'frequency' => 'daily'], 'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2], 'sms' => ['enabled' => false, 'daily_limit' => 0], 'ai_predictions' => false, @@ -45,7 +45,7 @@ class PlanSeeder extends Seeder 'features' => [ 'fuel_types' => ['max' => 1], 'email' => ['enabled' => true, 'frequency' => 'triggered'], - 'push' => ['enabled' => true], + 'push' => ['enabled' => true, 'frequency' => 'triggered'], 'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2], 'sms' => ['enabled' => true, 'daily_limit' => 1], 'ai_predictions' => true, @@ -59,7 +59,7 @@ class PlanSeeder extends Seeder 'features' => [ 'fuel_types' => ['max' => null], 'email' => ['enabled' => true, 'frequency' => 'triggered'], - 'push' => ['enabled' => true], + 'push' => ['enabled' => true, 'frequency' => 'triggered'], 'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2], 'sms' => ['enabled' => true, 'daily_limit' => 3], 'ai_predictions' => true, diff --git a/docs/tiers.md b/docs/tiers.md index 81ccb2f..cc66f31 100644 --- a/docs/tiers.md +++ b/docs/tiers.md @@ -8,14 +8,20 @@ decision — which channels a user can receive, how often, and what features the ## Tiers at a glance -| Tier | Price | Email | Push | WhatsApp | SMS | AI predictions | Fuel types | -|-------|--------|-------------------|---------|----------|--------------|----------------|------------| -| free | £0 | weekly digest | — | — | — | — | 1 | -| basic | £0.99 | daily | daily | daily | — | — | 1 | -| plus | £2.49 | triggered | triggered | triggered | max 1/day | yes | 1 | -| pro | £3.99 | triggered | triggered | triggered | max 3/day | yes | unlimited | +| Tier | Price | Email | Push | WhatsApp | SMS | AI predictions | Price threshold | Score alerts | Fuel types | +|-------|--------|---------------|-----------|-----------|------------|----------------|-----------------|--------------|------------| +| free | £0 | weekly digest | — | — | — | — | — | — | 1 | +| basic | £0.99 | daily | daily | daily | — | — | ✓ | ✓ | 1 | +| plus | £2.49 | triggered | triggered | triggered | max 1/day | ✓ | ✓ | ✓ | 1 | +| pro | £3.99 | triggered | triggered | triggered | max 3/day | ✓ | ✓ | ✓ | unlimited | Tiers are stored in the `plans` table. The `features` JSON column defines every limit and flag. +`database/seeders/PlanSeeder.php` is the source of truth — this table mirrors it. + +> **Deeper entitlements** (history window, prediction level, leaderboard size, saved stations, +> fuel log caps, brand comparison, route planner, family sharing) are defined in +> `docs/superpowers/specs/2026-04-15-tier-features-design.md`. That spec extends the +> `features` JSON shape with additional keys beyond the notification-channel flags below. --- @@ -38,21 +44,22 @@ Tiers are stored in the `plans` table. The `features` JSON column defines every ## The `plans` table ``` -id bigint PK -name string — free | basic | plus | pro -stripe_price_id string nullable — matches Cashier's stripe_price column -features json — see shape below -active boolean +id bigint PK +name string — free | basic | plus | pro +stripe_price_id_monthly string nullable — Cashier price ID for monthly billing +stripe_price_id_annual string nullable — Cashier price ID for annual billing +features json — see shape below +active boolean timestamps ``` -### `features` JSON shape +### `features` JSON shape (notification-channel flags) ```json { "fuel_types": { "max": 1 }, "email": { "enabled": true, "frequency": "triggered" }, - "push": { "enabled": true }, + "push": { "enabled": true, "frequency": "triggered" }, "whatsapp": { "enabled": true, "daily_limit": 5, "scheduled_updates": 2 }, "sms": { "enabled": true, "daily_limit": 3 }, "ai_predictions": true, @@ -63,8 +70,14 @@ timestamps `fuel_types.max: null` means unlimited (pro only). `email.frequency` values: `weekly_digest`, `daily`, `triggered`. +`push.frequency` values: `none` (when disabled), `daily`, `triggered`. +`whatsapp` and `sms` always carry `daily_limit` (and whatsapp carries `scheduled_updates`) +even when `enabled: false` — set to `0` on disabled tiers. See `PlanSeeder`. Boolean features default to `false` on the free tier. +**`price_threshold` and `score_alerts` are enabled on basic and above** (not plus-only). +**`ai_predictions` is plus and above only.** + --- ## Resolving the plan for a user @@ -222,6 +235,45 @@ Test files live in `tests/Feature/Tiers/`: | File | Covers | |------|--------| -| `PlanFeaturesTest.php` | `canUseChannel`, `canSendNow`, `canTrackFuelType`, `can()`, middleware, log scopes | +| `PlanFeaturesTest.php` | `canUseChannel`, `canSendNow`, `canTrackFuelType`, `can()`, middleware, log scopes, display name, `push.frequency` shape | | `PlanResourceTest.php` | Filament list/edit, no create/delete, saves features correctly | | `DispatchUserNotificationJobTest.php` | Sent logging, `tier_restricted`, `daily_limit`, user-disabled suppression, queue name, fan-out | + +Unit: + +| File | Covers | +|------|--------| +| `tests/Unit/Enums/PlanTierTest.php` | `PlanTier::label()` — user-facing display names | + +--- + +## Change log + +### 2026-04-20 — display-name layer, `push.frequency`, pricing card rename + +Reconciled docs with `PlanSeeder` reality and introduced the display-name layer from `Downloads/pricing-plan.md` v2. + +**Entitlement reality (docs were stale before this pass):** +- `price_threshold` and `score_alerts` are on **basic, plus, pro** — not plus-only. +- `ai_predictions` is plus+pro only. +- Schema columns are `stripe_price_id_monthly` and `stripe_price_id_annual` (not a single `stripe_price_id`). + +**New: display-name layer.** Backend tier identifiers stay `basic/plus/pro`; UI-facing names are `Free/Daily/Smart/Pro`. +- `app/Enums/PlanTier.php` — added `label(): string` +- `app/Models/Plan.php` — added `displayName(): string` (delegates to enum) +- `app/Services/PlanFeatures.php` — added `displayName(): string` + +**New: `push.frequency` key in `features` JSON.** Mirrors `email.frequency` so Daily's "daily push" is distinguishable from Smart/Pro's "triggered push". +- Values: `none` (when disabled), `daily`, `triggered` +- Seeded: free=`none`, basic=`daily`, plus=`triggered`, pro=`triggered` +- Touched: `PlanSeeder`, `PlanFactory`, free-tier stubs in `Plan::resolveForUser` + `PlanFeatures::__construct`, Filament `PlanForm` + +**Marketing: homepage pricing cards renamed.** `resources/js/views/Home.vue`: +- Card labels: `Basic` → `Daily`, `Plus` → `Smart` +- Badge: `Most Popular` → `Most pick this` +- CTAs: `Select Basic` → `Choose Daily`, `Join Plus` → `Choose Smart`, `Go Pro` → `Choose Pro`, free unauthed `Get started` → `Start free` +- Smart retains the existing accent-ring highlight; Pro retains the dark card. + +**Deferred:** `Fleet` tier (per-seat B2B), `Start 14-day trial` CTA on Smart (no trial backend), swapping Smart to a dark card (current accent ring is sufficient). + +**Operational:** existing DB rows won't have `push.frequency` until `php artisan db:seed --class=PlanSeeder` runs. The seeder is idempotent. diff --git a/resources/js/views/Home.vue b/resources/js/views/Home.vue index eb0622b..6494820 100644 --- a/resources/js/views/Home.vue +++ b/resources/js/views/Home.vue @@ -259,10 +259,10 @@ {{ ctaLabel('free') }} - +