From c2466e5a616dd509a1713d45b9ef9171ef0a1d3a Mon Sep 17 00:00:00 2001 From: Ovidiu U Date: Mon, 20 Apr 2026 18:57:24 +0100 Subject: [PATCH] feat(tiers): add display-name layer, push.frequency entitlement, and rename pricing cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconciles tier docs with `PlanSeeder` reality (basic has price_threshold and score_alerts; schema is stripe_price_id_monthly + stripe_price_id_annual) and introduces the display-name layer from pricing-plan.md v2. - PlanTier::label() + Plan::displayName() + PlanFeatures::displayName() expose user-facing names (Free/Daily/Smart/Pro); backend identifiers stay basic/plus/pro so every call site, Stripe mapping, and test keeps working. - push.frequency key added to features JSON (none/daily/triggered), mirroring email.frequency so Daily's daily push is distinguishable from Smart/Pro's triggered push. Seeder, factory, free-tier stubs, and Filament form updated. - Homepage pricing cards renamed: Basic→Daily, Plus→Smart; badge "Most Popular"→"Most pick this"; CTAs refreshed. - docs/tiers.md change log records the full diff. Fleet tier, 14-day trial copy, and Smart dark-card treatment deferred. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/rules/tiers.md | 33 +++++--- app/Enums/PlanTier.php | 10 +++ .../Resources/Plans/Schemas/PlanForm.php | 8 ++ app/Models/Plan.php | 10 ++- app/Services/PlanFeatures.php | 8 +- database/factories/PlanFactory.php | 8 +- database/seeders/PlanSeeder.php | 8 +- docs/tiers.md | 80 +++++++++++++++---- resources/js/views/Home.vue | 18 ++--- tests/Feature/Tiers/PlanFeaturesTest.php | 28 +++++++ tests/Unit/Enums/PlanTierTest.php | 10 +++ 11 files changed, 179 insertions(+), 42 deletions(-) create mode 100644 tests/Unit/Enums/PlanTierTest.php 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') }} - +
-

Basic

+

Daily

{{ PRICES[cadence].basic }} {{ PRICE_SUFFIX[cadence] }} @@ -276,11 +276,11 @@ {{ ctaLabel('basic') }}
- +
-
Most Popular
+
Most pick this
-

Plus

+

Smart

{{ PRICES[cadence].plus }} {{ PRICE_SUFFIX[cadence] }} @@ -454,15 +454,15 @@ function ctaHref(tier) { function ctaLabel(tier) { if (tier === 'free') { - return isAuthenticated.value ? 'Go to dashboard' : 'Get started' + return isAuthenticated.value ? 'Go to dashboard' : 'Start free' } if (isAuthenticated.value && userTier.value === tier) { return 'Manage subscription' } return { - basic: 'Select Basic', - plus: 'Join Plus', - pro: 'Go Pro', + basic: 'Choose Daily', + plus: 'Choose Smart', + pro: 'Choose Pro', }[tier] } diff --git a/tests/Feature/Tiers/PlanFeaturesTest.php b/tests/Feature/Tiers/PlanFeaturesTest.php index d2f5908..c008a0e 100644 --- a/tests/Feature/Tiers/PlanFeaturesTest.php +++ b/tests/Feature/Tiers/PlanFeaturesTest.php @@ -208,3 +208,31 @@ it('scopeForFuelType filters by fuel type', function (): void { expect(UserNotificationPreference::forFuelType(FuelType::E10->value)->where('user_id', $user->id)->count())->toBe(1); }); + +// ─── push frequency ─────────────────────────────────────────────────────────── + +it('seeds push.frequency for every tier', function (): void { + expect(Plan::where('name', 'free')->first()->features['push']) + ->toBe(['enabled' => false, 'frequency' => 'none']) + ->and(Plan::where('name', 'basic')->first()->features['push']) + ->toBe(['enabled' => true, 'frequency' => 'daily']) + ->and(Plan::where('name', 'plus')->first()->features['push']) + ->toBe(['enabled' => true, 'frequency' => 'triggered']) + ->and(Plan::where('name', 'pro')->first()->features['push']) + ->toBe(['enabled' => true, 'frequency' => 'triggered']); +}); + +// ─── display name ───────────────────────────────────────────────────────────── + +it('Plan::displayName returns the user-facing label for each seeded tier', function (): void { + expect(Plan::where('name', 'free')->first()->displayName())->toBe('Free') + ->and(Plan::where('name', 'basic')->first()->displayName())->toBe('Daily') + ->and(Plan::where('name', 'plus')->first()->displayName())->toBe('Smart') + ->and(Plan::where('name', 'pro')->first()->displayName())->toBe('Pro'); +}); + +it('PlanFeatures::displayName delegates to the resolved tier', function (): void { + $user = User::factory()->create(); + + expect(PlanFeatures::for($user)->displayName())->toBe('Free'); +}); diff --git a/tests/Unit/Enums/PlanTierTest.php b/tests/Unit/Enums/PlanTierTest.php new file mode 100644 index 0000000..39e0083 --- /dev/null +++ b/tests/Unit/Enums/PlanTierTest.php @@ -0,0 +1,10 @@ +label())->toBe('Free') + ->and(PlanTier::Basic->label())->toBe('Daily') + ->and(PlanTier::Plus->label())->toBe('Smart') + ->and(PlanTier::Pro->label())->toBe('Pro'); +});