Compare commits

..

2 Commits

Author SHA1 Message Date
Ovidiu U
775e076bb7 Add current_period tracking to subscriptions, document prediction engine, and refactor station list UI
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
Add current_period_start, current_period_end, and stripe_data columns to subscriptions table via migration. Extend Subscription model with datetime casts for new fields. Create comprehensive prediction engine documentation covering signals, aggregation, confidence calibration, and weekly summary logic. Add PredictionFull Vue component displaying action label, reasoning, and 7-day context. Refactor StationList to collapse outdated stations behind expandable section. Add UpsellBanner component with station count formatting. Create .claude/settings.json denying .env file access. Add todo.md tracking Stripe dashboard setup, production deployment steps, and E2E QA checklist. Update .env.example with fuel-finder credentials, Anthropic config, and complete Stripe price IDs.
2026-04-29 18:14:03 +01:00
Ovidiu U
8695d5ec95 refactor: flatten plans.features JSON to typed columns
The features JSON column required defensive fallback stubs in three
places (Plan::resolveForUser, PlanFeatures::__construct, PlanSeeder)
and silently swallowed misspelled keys. Typed columns give Eloquent
type-safe reads, simplify the Filament form (no more dotted JSON
paths), and let resolveForUser fail loud when the free row is
missing.

PlanFeatures public API is unchanged so consumers (jobs, middleware)
need no rewrites — one missed JSON read in SendScheduledWhatsAppJob
was caught and converted to a typed where() query.

tests/Pest.php seeds PlanSeeder in beforeEach so any feature test
that resolves a plan finds the free row, mirroring production where
plans always exist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:13:26 +01:00
22 changed files with 821 additions and 280 deletions

211
.claude/rules/prediction.md Normal file
View File

@@ -0,0 +1,211 @@
# Prediction Engine
The "should I fill up now or wait?" recommendation that drives the headline,
notifications, and the entire product. Lives in `app/Services/NationalFuelPredictionService.php`
and is called from `Api\PredictionController`.
> The prediction is the product's selling point. Confidence calibration matters
> as much as direction — a "Wait — prices falling" headline at 30% confidence is
> worse than no recommendation at all.
## Output
`predict(?float $lat, ?float $lng): array` returns:
| Key | Type | Notes |
|---|---|---|
| `fuel_type` | string | currently always `'e10'` |
| `current_avg` | float | current price avg in pence (regional 50km if coords given, else national) |
| `predicted_direction` | `'up' | 'down' | 'stable'` | aggregated vote |
| `predicted_change_pence` | float | `slope × 7` — pence change projected over the prediction horizon |
| `confidence_score` | float (0100) | see "Confidence" below |
| `confidence_label` | `'low' | 'medium' | 'high'` | bucketing of `confidence_score` |
| `action` | `'fill_now' | 'wait' | 'no_signal'` | UI action mapped from direction |
| `reasoning` | string | concatenation of enabled signal `detail` fields, or action-aware fallback |
| `prediction_horizon_days` | int | `7` |
| `region_key` | `'national' | 'regional'` | depends on whether coords were passed |
| `methodology` | string | identifier for backtesting/auditing |
| `weekly_summary` | object | yesterday/today/tomorrow + 7-day series (see below) |
| `signals` | object | per-signal breakdown (see below) |
## Signals
Each signal returns `{score, confidence, direction, detail, data_points, enabled}`.
| Signal | Source | Enabled when | Score formula |
|---|---|---|---|
| `trend` | regression on daily national avg, 5-day adaptive → 14-day | ≥2 daily averages and R² ≥ 0.5 | `min(1, |slope| / SLOPE_SATURATION_PENCE) × sign(slope)` (saturates at `0.5p/day`) |
| `day_of_week` | weekday averages over last 90 days | `unique_days ≥ DAY_OF_WEEK_MIN_DAYS` (21) | `±1` if today ≥1.5p above/below week avg, else `0`; confidence scales with `unique_days/90` |
| `brand_behaviour` | supermarket vs major regression slopes over 7 days | both groups have ≥2 data points and divergence ≥1.0p | `±1` if leader is up/down |
| `regional_momentum` | regression on stations within 50km, 14 days | coords provided + ≥3 daily averages within radius | `±0.7` |
| `price_stickiness` | mean station hold duration over 30 days | ≥10 stations with ≥2 changes | `±0.1` confidence modifier |
| `oil` | latest `price_predictions` row covering today or later | a row exists | `±1` if rising/falling, `0` if flat; confidence = stored `confidence/100` |
| `national_momentum` | reserved | always disabled today | n/a |
### Oil signal — source preference
`computeOilSignal()` picks the freshest row in this order:
1. `source = 'llm_with_context'`
2. `source = 'llm'`
3. `source = 'ewma'`
`OilPriceService` (in `app/Services/OilPriceService.php` and friends) populates
this table daily at 7am via the scheduler. Cap: LLM confidence is capped at 85,
EWMA at 65 (see `.claude/rules/api-data.md`).
The Brent oil signal is the **single biggest unlock** for confidence — it
captures world-news context (OPEC, geopolitical) that pure local price history
can't see.
### Day-of-week threshold
The original spec said 56 days. Lowered to 21 because:
- The signal's `confidence` is already `min(1, unique_days / 90)` — a 21-day
signal naturally contributes only `~0.23` confidence and lifts as more data
accumulates.
- 56 days delays the signal so long it might as well not exist for new users.
## Aggregator
`aggregateSignals(signals, hasCoordinates)` returns `[direction, confidence_score]`.
### Weights
```
National (no coords):
trend 0.30
oil 0.25
dayOfWeek 0.20
brandBehaviour 0.15
stickiness 0.10
----
1.00
Regional (with coords):
regionalMomentum 0.35
oil 0.20
trend 0.15
dayOfWeek 0.15
brandBehaviour 0.10
stickiness 0.05
----
1.00
```
### Direction
```
directional_score = Σ(score × signal_confidence × weight) // only signals with direction ≠ stable
directional_weight = Σ(weight) // only signals with direction ≠ stable
normalised = directional_score / directional_weight (0 if directional_weight ≈ 0)
direction = 'up' if normalised >= 0.1
'down' if normalised <= -0.1
'stable' otherwise
```
**Stable signals do not dilute the direction vote.** They are excluded from both
the numerator and denominator. This is a key fix — previously a single weak
trend signal could be cancelled out by three "stable" signals adding weight
without contributing direction.
### Confidence
```
avg_signal_confidence = Σ(signal_confidence × weight) / Σ(weight) // all enabled signals
agreement = computeAgreement(signals, weights, final_direction) // 0..1
confidence_score = avg_signal_confidence × agreement × 100 (capped at 100)
```
**`avg_signal_confidence`** is how confident the individual signals are in
their own readings (R², sample size, model confidence). Stable signals DO
contribute here — knowing prices are stable is itself a confident answer.
**`agreement`** measures how the signals line up with the chosen direction:
- aligned signal: full credit (`signal_confidence × weight`)
- one side stable, other directional: half credit
- opposing signals: no credit
- final score: `Σ credit / Σ max_credit`
This separation is the second key fix. Previously `confidence = |normalised| × 100`
conflated "the signals point strongly somewhere" with "we're sure". Now:
- Strong signals all agreeing → high `confidence_score`
- Strong signals disagreeing → low `confidence_score`
- Weak signals → low `confidence_score` (via low individual confidences)
### Confidence labels
| `confidence_score` | `confidence_label` | UI behaviour |
|---|---|---|
| ≥ 70 | `high` | fire notification when allowed |
| 4069 | `medium` | dashboard only |
| < 40 | `low` | dashboard only |
## Reasoning
`buildReasoning()` joins `detail` strings from enabled signals. If none have
material content, it falls back to an **action-aware** sentence:
| `direction` / `action` | Fallback |
|---|---|
| `up` / `fill_now` | "Mild upward signals — top up soon if you're nearby." |
| `down` / `wait` | "Mild downward signals — wait a day or two if your tank can hold." |
| `stable` / `no_signal` | "No clear pattern — fill up at the cheapest station near you now." |
The earlier hard-coded "fill up" fallback contradicted "Wait — prices falling"
headlines and is no longer used.
## Weekly summary
`computeWeeklySummary()` returns the Y/T/T strip + last-7-days numbers:
| Field | Meaning |
|---|---|
| `yesterday_avg` / `today_avg` | regional (50km) → national fallback |
| `tomorrow_estimated_avg` | `today_avg + trend.slope` (slope is 0 if trend disabled) |
| `yesterday_today_delta_pence` | `today yesterday`; sign tells you which was cheaper |
| `last_7_days_series` | array of `{date, avg}`, one entry per day with data |
| `last_7_days_change_pence` | `series[last].avg series[0].avg` |
| `cheapest_day` / `priciest_day` | min/max of the series |
| `is_regional` | `true` only if regional data was actually used; `false` after national fallback |
## API gate
The prediction is **embedded in the `/api/stations` response** under the
`prediction` key — there is no standalone prediction endpoint. The same payload
shape ships back regardless of route, but the gate runs server-side:
`PlanFeatures::for($user)->can('ai_predictions')`.
- ai_predictions allowed (plus, pro): full multi-signal payload
(`fuel_type`, `current_avg`, `predicted_direction`, `confidence_score`,
`reasoning`, `weekly_summary`, `signals`, …)
- otherwise (free, basic, guest): stripped teaser
`{fuel_type, predicted_direction, tier_locked: true}` for the upsell card
Bundling into `/api/stations` ties prediction availability to a real station
search — there is no way to scrape the prediction independently. Don't add a
separate prediction route or accept a request body without coords; the
prediction is always computed alongside a search.
## What never to do
- Don't introduce a new signal without giving it `enabled`, `confidence`, and a
weight in both national + regional weight maps.
- Don't read `brent_prices` directly from the prediction service — go through
`price_predictions`. The prediction table is the source of truth for
oil-direction-as-a-signal.
- Don't reintroduce a confidence formula that uses `|directional_score|` — that
conflates magnitude with sureness.
- Don't add a stable-direction signal to `directional_weight` — stable signals
must not dilute direction.
---
paths:
- "app/Services/NationalFuelPredictionService.php"
- "app/Http/Controllers/Api/StationController.php"
- "tests/Unit/Services/NationalFuelPredictionServiceTest.php"
- "tests/Feature/Api/StationControllerTest.php"
---

24
.claude/settings.json Normal file
View File

@@ -0,0 +1,24 @@
{
"permissions": {
"deny": [
"Read(./.env)",
"Read(.env)",
"Bash(cat .env)",
"Bash(cat ./.env)",
"Bash(head .env)",
"Bash(head ./.env)",
"Bash(tail .env)",
"Bash(tail ./.env)",
"Bash(less .env)",
"Bash(less ./.env)",
"Bash(more .env)",
"Bash(more ./.env)",
"Bash(grep * .env)",
"Bash(grep * ./.env)",
"Bash(rg * .env)",
"Bash(rg * ./.env)",
"Bash(awk * .env)",
"Bash(awk * ./.env)"
]
}
}

View File

@@ -1,8 +1,8 @@
APP_NAME=Laravel
APP_NAME="Fuel Finder"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_URL=http://fuel-price.test
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
@@ -20,18 +20,18 @@ LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=fuel-price
DB_USERNAME=fuel-price
DB_PASSWORD=password
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
SESSION_DOMAIN=.fuel-price.test
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
@@ -64,19 +64,29 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
FUELALERT_API_KEY=
FUEL_FINDER_CLIENT_ID=
FUEL_FINDER_CLIENT_SECRET=
FUEL_FINDER_BASE_URL=https://www.fuel-finder.service.gov.uk/api/v1
ANTHROPIC_API_KEY=
ANTHROPIC_MODEL=claude-haiku-4-5
FRED_API_KEY=
EIA_API_KEY= # US EIA Open Data API key — register free at eia.gov/opendata
API_SECRET_KEY=
EIA_API_KEY=
LLM_PREDICTION_PROVIDER=anthropic
STRIPE_KEY=
STRIPE_SECRET=
STRIPE_WEBHOOK_SECRET=
CASHIER_CURRENCY=gbp
STRIPE_PRICE_BASIC_MONTHLY=
STRIPE_PRICE_BASIC_ANNUAL=
STRIPE_PRICE_PLUS_MONTHLY=
STRIPE_PRICE_PLUS_ANNUAL=
STRIPE_PRICE_BASIC_MONTHLY=price_1TM3cwJuhjW3IKHlJCHz0xmU
STRIPE_PRICE_BASIC_ANNUAL=price_1TM3nlJuhjW3IKHlwcHF5W9v
STRIPE_PRICE_PLUS_MONTHLY=price_1TM3oqJuhjW3IKHlbQUMhrnm
STRIPE_PRICE_PLUS_ANNUAL=price_1TM3pXJuhjW3IKHlfQenHsf1
STRIPE_PRICE_PRO_MONTHLY=
STRIPE_PRICE_PRO_ANNUAL=
SANCTUM_STATEFUL_DOMAINS=fuel-price.test

View File

@@ -16,7 +16,7 @@ class PlanForm
->components([
Section::make('Fuel Types')
->schema([
TextInput::make('features.fuel_types.max')
TextInput::make('max_fuel_types')
->label('Max fuel types')
->helperText('Leave blank for unlimited.')
->numeric()
@@ -28,9 +28,9 @@ class PlanForm
Section::make('Email')
->columns(2)
->schema([
Toggle::make('features.email.enabled')
Toggle::make('email_enabled')
->label('Enabled'),
Select::make('features.email.frequency')
Select::make('email_frequency')
->label('Frequency')
->options([
'weekly_digest' => 'Weekly digest',
@@ -42,9 +42,9 @@ class PlanForm
Section::make('Push')
->columns(2)
->schema([
Toggle::make('features.push.enabled')
Toggle::make('push_enabled')
->label('Enabled'),
Select::make('features.push.frequency')
Select::make('push_frequency')
->label('Frequency')
->options([
'none' => 'None (disabled)',
@@ -56,15 +56,15 @@ class PlanForm
Section::make('WhatsApp')
->columns(3)
->schema([
Toggle::make('features.whatsapp.enabled')
Toggle::make('whatsapp_enabled')
->label('Enabled'),
TextInput::make('features.whatsapp.daily_limit')
TextInput::make('whatsapp_daily_limit')
->label('Daily limit')
->numeric()
->integer()
->minValue(0)
->required(),
TextInput::make('features.whatsapp.scheduled_updates')
TextInput::make('whatsapp_scheduled_updates')
->label('Scheduled updates per day')
->numeric()
->integer()
@@ -75,9 +75,9 @@ class PlanForm
Section::make('SMS')
->columns(2)
->schema([
Toggle::make('features.sms.enabled')
Toggle::make('sms_enabled')
->label('Enabled'),
TextInput::make('features.sms.daily_limit')
TextInput::make('sms_daily_limit')
->label('Daily limit')
->numeric()
->integer()
@@ -87,11 +87,11 @@ class PlanForm
Section::make('Features')
->schema([
Toggle::make('features.ai_predictions')
Toggle::make('ai_predictions')
->label('AI predictions'),
Toggle::make('features.price_threshold')
Toggle::make('price_threshold')
->label('Price threshold alerts'),
Toggle::make('features.score_alerts')
Toggle::make('score_alerts')
->label('Score change alerts'),
]),
]);

View File

@@ -17,16 +17,16 @@ class PlansTable
->label('Tier')
->badge()
->sortable(),
TextColumn::make('features.email.frequency')
TextColumn::make('email_frequency')
->label('Email')
->placeholder('—'),
TextColumn::make('features.sms.daily_limit')
TextColumn::make('sms_daily_limit')
->label('SMS/day')
->placeholder('—'),
TextColumn::make('features.whatsapp.daily_limit')
TextColumn::make('whatsapp_daily_limit')
->label('WhatsApp/day')
->placeholder('—'),
TextColumn::make('features.fuel_types.max')
TextColumn::make('max_fuel_types')
->label('Fuel types')
->placeholder('Unlimited'),
IconColumn::make('active')

View File

@@ -30,8 +30,7 @@ final class SendScheduledWhatsAppJob implements ShouldQueue
// Plans that allow scheduled WhatsApp updates
$eligiblePlanNames = Plan::where('active', true)
->get()
->filter(fn (Plan $plan): bool => ($plan->features['whatsapp']['scheduled_updates'] ?? 0) > 0)
->where('whatsapp_scheduled_updates', '>', 0)
->pluck('name')
->all();

View File

@@ -17,7 +17,19 @@ class Plan extends Model
'name',
'stripe_price_id_monthly',
'stripe_price_id_annual',
'features',
'max_fuel_types',
'email_enabled',
'email_frequency',
'push_enabled',
'push_frequency',
'whatsapp_enabled',
'whatsapp_daily_limit',
'whatsapp_scheduled_updates',
'sms_enabled',
'sms_daily_limit',
'ai_predictions',
'price_threshold',
'score_alerts',
'active',
];
@@ -56,28 +68,7 @@ class Plan extends Model
}
);
if ($planId !== null) {
$plan = static::find($planId);
if ($plan !== null) {
return $plan;
}
}
// Fallback for tests / partially-seeded environments: return a free-tier stub.
return new self([
'name' => PlanTier::Free->value,
'features' => [
'fuel_types' => ['max' => 1],
'email' => ['enabled' => true, 'frequency' => 'weekly_digest'],
'push' => ['enabled' => false, 'frequency' => 'none'],
'whatsapp' => ['enabled' => false, 'daily_limit' => 0, 'scheduled_updates' => 0],
'sms' => ['enabled' => false, 'daily_limit' => 0],
'ai_predictions' => false,
'price_threshold' => false,
'score_alerts' => false,
],
]);
return static::findOrFail($planId);
}
/**
@@ -127,7 +118,17 @@ class Plan extends Model
protected function casts(): array
{
return [
'features' => 'array',
'max_fuel_types' => 'integer',
'email_enabled' => 'boolean',
'push_enabled' => 'boolean',
'whatsapp_enabled' => 'boolean',
'whatsapp_daily_limit' => 'integer',
'whatsapp_scheduled_updates' => 'integer',
'sms_enabled' => 'boolean',
'sms_daily_limit' => 'integer',
'ai_predictions' => 'boolean',
'price_threshold' => 'boolean',
'score_alerts' => 'boolean',
'active' => 'boolean',
];
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Models;
use Laravel\Cashier\Subscription as CashierSubscription;
class Subscription extends CashierSubscription
{
protected $casts = [
'ends_at' => 'datetime',
'quantity' => 'integer',
'trial_ends_at' => 'datetime',
'current_period_start' => 'datetime',
'current_period_end' => 'datetime',
'stripe_data' => 'array',
];
}

View File

@@ -6,32 +6,17 @@ use App\Models\NotificationLog;
use App\Models\Plan;
use App\Models\User;
use App\Models\UserNotificationPreference;
use Throwable;
final class PlanFeatures
{
/** @var string[] */
private const array CHANNELS = ['email', 'push', 'whatsapp', 'sms'];
private Plan $plan;
private function __construct(private readonly User $user)
{
try {
$this->plan = Plan::resolveForUser($user);
} catch (Throwable) {
// Never throw — fall back to a free-tier stub if resolution fails.
$this->plan = new Plan([
'name' => 'free',
'features' => [
'fuel_types' => ['max' => 1],
'email' => ['enabled' => true, 'frequency' => 'weekly_digest'],
'push' => ['enabled' => false, 'frequency' => 'none'],
'whatsapp' => ['enabled' => false, 'daily_limit' => 0, 'scheduled_updates' => 0],
'sms' => ['enabled' => false, 'daily_limit' => 0],
'ai_predictions' => false,
'price_threshold' => false,
'score_alerts' => false,
],
]);
}
}
public static function for(User $user): self
@@ -47,10 +32,9 @@ final class PlanFeatures
*/
public function channelsFor(string $triggerType): array
{
$allChannels = ['email', 'push', 'whatsapp', 'sms'];
$allowed = [];
foreach ($allChannels as $channel) {
foreach (self::CHANNELS as $channel) {
if (! $this->canUseChannel($channel)) {
continue;
}
@@ -72,24 +56,7 @@ final class PlanFeatures
/** Whether the plan allows this channel at all. */
public function canUseChannel(string $channel): bool
{
return (bool) ($this->feature($channel, 'enabled') ?? false);
}
/** Read a nested feature value, e.g. feature('sms', 'daily_limit'). */
private function feature(string $channel, string $key): mixed
{
$features = $this->plan->features ?? [];
return $features[$channel][$key] ?? null;
}
/** Whether the user has opted in to this channel for at least one fuel type. */
private function userHasEnabledChannel(string $channel): bool
{
return UserNotificationPreference::where('user_id', $this->user->id)
->where('channel', $channel)
->where('enabled', true)
->exists();
return (bool) $this->plan->{"{$channel}_enabled"};
}
/**
@@ -102,9 +69,9 @@ final class PlanFeatures
return false;
}
$dailyLimit = $this->feature($channel, 'daily_limit');
$dailyLimit = $this->dailyLimit($channel);
// null or 0 in the feature means no SMS/unlimited — treat 0 as blocked, null as unlimited
// null = unlimited; 0 = blocked even though enabled
if ($dailyLimit === null) {
return true;
}
@@ -131,9 +98,6 @@ final class PlanFeatures
return true;
}
$count = $this->trackedFuelTypeCount();
// Allow if already tracking this type (not adding a new one)
$alreadyTracking = UserNotificationPreference::where('user_id', $this->user->id)
->where('fuel_type', $fuelType)
->exists();
@@ -142,15 +106,13 @@ final class PlanFeatures
return true;
}
return $count < $limit;
return $this->trackedFuelTypeCount() < $limit;
}
/** Maximum fuel types allowed, or null for unlimited. */
public function fuelTypeLimit(): ?int
{
$features = $this->plan->features ?? [];
return $features['fuel_types']['max'] ?? 1;
return $this->plan->max_fuel_types;
}
/** Count of distinct fuel types the user has preferences for. */
@@ -164,9 +126,7 @@ final class PlanFeatures
/** Generic boolean feature flag check. */
public function can(string $feature): bool
{
$features = $this->plan->features ?? [];
return (bool) ($features[$feature] ?? false);
return (bool) ($this->plan->{$feature} ?? false);
}
/** Count of notifications missed today on a channel. */
@@ -193,7 +153,7 @@ final class PlanFeatures
/** The resolved plan tier name. */
public function tier(): string
{
return $this->plan->name ?? 'free';
return $this->plan->name;
}
/** User-facing display label for the resolved tier (e.g. basic → "Daily"). */
@@ -201,4 +161,23 @@ final class PlanFeatures
{
return $this->plan->displayName();
}
/** Whether the user has opted in to this channel for at least one fuel type. */
private function userHasEnabledChannel(string $channel): bool
{
return UserNotificationPreference::where('user_id', $this->user->id)
->where('channel', $channel)
->where('enabled', true)
->exists();
}
/** Per-channel daily limit. Null on email/push (no cap), int on whatsapp/sms. */
private function dailyLimit(string $channel): ?int
{
return match ($channel) {
'whatsapp' => $this->plan->whatsapp_daily_limit,
'sms' => $this->plan->sms_daily_limit,
default => null,
};
}
}

View File

@@ -11,23 +11,25 @@ use Illuminate\Database\Eloquent\Factories\Factory;
*/
class PlanFactory extends Factory
{
private static array $defaultFeatures = [
'fuel_types' => ['max' => 1],
'email' => ['enabled' => false, 'frequency' => 'weekly_digest'],
'push' => ['enabled' => false, 'frequency' => 'none'],
'whatsapp' => ['enabled' => false, 'daily_limit' => 0, 'scheduled_updates' => 0],
'sms' => ['enabled' => false, 'daily_limit' => 0],
'ai_predictions' => false,
'price_threshold' => false,
'score_alerts' => false,
];
public function definition(): array
{
return [
'name' => PlanTier::Free->value,
'stripe_price_id' => null,
'features' => self::$defaultFeatures,
'stripe_price_id_monthly' => null,
'stripe_price_id_annual' => null,
'max_fuel_types' => 1,
'email_enabled' => true,
'email_frequency' => 'weekly_digest',
'push_enabled' => false,
'push_frequency' => 'none',
'whatsapp_enabled' => false,
'whatsapp_daily_limit' => 0,
'whatsapp_scheduled_updates' => 0,
'sms_enabled' => false,
'sms_daily_limit' => 0,
'ai_predictions' => false,
'price_threshold' => false,
'score_alerts' => false,
'active' => true,
];
}
@@ -36,8 +38,8 @@ class PlanFactory extends Factory
{
return $this->state(fn () => [
'name' => PlanTier::Free->value,
'stripe_price_id' => null,
'features' => self::$defaultFeatures,
'stripe_price_id_monthly' => null,
'stripe_price_id_annual' => null,
]);
}
@@ -45,17 +47,21 @@ class PlanFactory extends Factory
{
return $this->state(fn () => [
'name' => PlanTier::Basic->value,
'stripe_price_id' => 'price_basic_test',
'features' => [
'fuel_types' => ['max' => 1],
'email' => ['enabled' => true, 'frequency' => 'daily'],
'push' => ['enabled' => true, 'frequency' => 'daily'],
'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2],
'sms' => ['enabled' => false, 'daily_limit' => 0],
'stripe_price_id_monthly' => 'price_basic_monthly_test',
'stripe_price_id_annual' => 'price_basic_annual_test',
'max_fuel_types' => 1,
'email_enabled' => true,
'email_frequency' => 'daily',
'push_enabled' => true,
'push_frequency' => 'daily',
'whatsapp_enabled' => true,
'whatsapp_daily_limit' => 5,
'whatsapp_scheduled_updates' => 2,
'sms_enabled' => false,
'sms_daily_limit' => 0,
'ai_predictions' => false,
'price_threshold' => true,
'score_alerts' => true,
],
]);
}
@@ -63,17 +69,21 @@ class PlanFactory extends Factory
{
return $this->state(fn () => [
'name' => PlanTier::Plus->value,
'stripe_price_id' => 'price_plus_test',
'features' => [
'fuel_types' => ['max' => 1],
'email' => ['enabled' => true, 'frequency' => 'triggered'],
'push' => ['enabled' => true, 'frequency' => 'triggered'],
'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2],
'sms' => ['enabled' => true, 'daily_limit' => 1],
'stripe_price_id_monthly' => 'price_plus_monthly_test',
'stripe_price_id_annual' => 'price_plus_annual_test',
'max_fuel_types' => 1,
'email_enabled' => true,
'email_frequency' => 'triggered',
'push_enabled' => true,
'push_frequency' => 'triggered',
'whatsapp_enabled' => true,
'whatsapp_daily_limit' => 5,
'whatsapp_scheduled_updates' => 2,
'sms_enabled' => true,
'sms_daily_limit' => 1,
'ai_predictions' => true,
'price_threshold' => true,
'score_alerts' => true,
],
]);
}
@@ -81,17 +91,21 @@ class PlanFactory extends Factory
{
return $this->state(fn () => [
'name' => PlanTier::Pro->value,
'stripe_price_id' => 'price_pro_test',
'features' => [
'fuel_types' => ['max' => null],
'email' => ['enabled' => true, 'frequency' => 'triggered'],
'push' => ['enabled' => true, 'frequency' => 'triggered'],
'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2],
'sms' => ['enabled' => true, 'daily_limit' => 3],
'stripe_price_id_monthly' => 'price_pro_monthly_test',
'stripe_price_id_annual' => 'price_pro_annual_test',
'max_fuel_types' => null,
'email_enabled' => true,
'email_frequency' => 'triggered',
'push_enabled' => true,
'push_frequency' => 'triggered',
'whatsapp_enabled' => true,
'whatsapp_daily_limit' => 5,
'whatsapp_scheduled_updates' => 2,
'sms_enabled' => true,
'sms_daily_limit' => 3,
'ai_predictions' => true,
'price_threshold' => true,
'score_alerts' => true,
],
]);
}
}

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('subscriptions', function (Blueprint $table): void {
$table->timestamp('current_period_start')->nullable()->after('quantity');
$table->timestamp('current_period_end')->nullable()->after('current_period_start');
$table->json('stripe_data')->nullable()->after('current_period_end');
});
}
public function down(): void
{
Schema::table('subscriptions', function (Blueprint $table): void {
$table->dropColumn(['current_period_start', 'current_period_end', 'stripe_data']);
});
}
};

View File

@@ -0,0 +1,60 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('plans', function (Blueprint $table): void {
$table->unsignedTinyInteger('max_fuel_types')->nullable()->after('stripe_price_id_annual')
->comment('Null = unlimited');
$table->boolean('email_enabled')->default(true)->after('max_fuel_types');
$table->string('email_frequency', 20)->default('weekly_digest')->after('email_enabled')
->comment('weekly_digest | daily | triggered');
$table->boolean('push_enabled')->default(false)->after('email_frequency');
$table->string('push_frequency', 20)->default('none')->after('push_enabled')
->comment('none | daily | triggered');
$table->boolean('whatsapp_enabled')->default(false)->after('push_frequency');
$table->unsignedSmallInteger('whatsapp_daily_limit')->default(0)->after('whatsapp_enabled');
$table->unsignedTinyInteger('whatsapp_scheduled_updates')->default(0)->after('whatsapp_daily_limit');
$table->boolean('sms_enabled')->default(false)->after('whatsapp_scheduled_updates');
$table->unsignedSmallInteger('sms_daily_limit')->default(0)->after('sms_enabled');
$table->boolean('ai_predictions')->default(false)->after('sms_daily_limit');
$table->boolean('price_threshold')->default(false)->after('ai_predictions');
$table->boolean('score_alerts')->default(false)->after('price_threshold');
$table->dropColumn('features');
});
}
public function down(): void
{
Schema::table('plans', function (Blueprint $table): void {
$table->json('features')->after('stripe_price_id_annual');
$table->dropColumn([
'max_fuel_types',
'email_enabled',
'email_frequency',
'push_enabled',
'push_frequency',
'whatsapp_enabled',
'whatsapp_daily_limit',
'whatsapp_scheduled_updates',
'sms_enabled',
'sms_daily_limit',
'ai_predictions',
'price_threshold',
'score_alerts',
]);
});
}
};

View File

@@ -14,71 +14,75 @@ class PlanSeeder extends Seeder
PlanTier::Free->value => [
'stripe_price_id_monthly' => null,
'stripe_price_id_annual' => null,
'features' => [
'fuel_types' => ['max' => 1],
'email' => ['enabled' => true, 'frequency' => 'weekly_digest'],
'push' => ['enabled' => false, 'frequency' => 'none'],
'whatsapp' => ['enabled' => false, 'daily_limit' => 0, 'scheduled_updates' => 0],
'sms' => ['enabled' => false, 'daily_limit' => 0],
'max_fuel_types' => 1,
'email_enabled' => true,
'email_frequency' => 'weekly_digest',
'push_enabled' => false,
'push_frequency' => 'none',
'whatsapp_enabled' => false,
'whatsapp_daily_limit' => 0,
'whatsapp_scheduled_updates' => 0,
'sms_enabled' => false,
'sms_daily_limit' => 0,
'ai_predictions' => false,
'price_threshold' => false,
'score_alerts' => false,
],
],
PlanTier::Basic->value => [
'stripe_price_id_monthly' => config('services.stripe.prices.basic.monthly'),
'stripe_price_id_annual' => config('services.stripe.prices.basic.annual'),
'features' => [
'fuel_types' => ['max' => 1],
'email' => ['enabled' => true, 'frequency' => 'daily'],
'push' => ['enabled' => true, 'frequency' => 'daily'],
'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2],
'sms' => ['enabled' => false, 'daily_limit' => 0],
'max_fuel_types' => 1,
'email_enabled' => true,
'email_frequency' => 'daily',
'push_enabled' => true,
'push_frequency' => 'daily',
'whatsapp_enabled' => true,
'whatsapp_daily_limit' => 5,
'whatsapp_scheduled_updates' => 2,
'sms_enabled' => false,
'sms_daily_limit' => 0,
'ai_predictions' => false,
'price_threshold' => true,
'score_alerts' => true,
],
],
PlanTier::Plus->value => [
'stripe_price_id_monthly' => config('services.stripe.prices.plus.monthly'),
'stripe_price_id_annual' => config('services.stripe.prices.plus.annual'),
'features' => [
'fuel_types' => ['max' => 1],
'email' => ['enabled' => true, 'frequency' => 'triggered'],
'push' => ['enabled' => true, 'frequency' => 'triggered'],
'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2],
'sms' => ['enabled' => true, 'daily_limit' => 1],
'max_fuel_types' => 1,
'email_enabled' => true,
'email_frequency' => 'triggered',
'push_enabled' => true,
'push_frequency' => 'triggered',
'whatsapp_enabled' => true,
'whatsapp_daily_limit' => 5,
'whatsapp_scheduled_updates' => 2,
'sms_enabled' => true,
'sms_daily_limit' => 1,
'ai_predictions' => true,
'price_threshold' => true,
'score_alerts' => true,
],
],
PlanTier::Pro->value => [
'stripe_price_id_monthly' => config('services.stripe.prices.pro.monthly'),
'stripe_price_id_annual' => config('services.stripe.prices.pro.annual'),
'features' => [
'fuel_types' => ['max' => null],
'email' => ['enabled' => true, 'frequency' => 'triggered'],
'push' => ['enabled' => true, 'frequency' => 'triggered'],
'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2],
'sms' => ['enabled' => true, 'daily_limit' => 3],
'max_fuel_types' => null,
'email_enabled' => true,
'email_frequency' => 'triggered',
'push_enabled' => true,
'push_frequency' => 'triggered',
'whatsapp_enabled' => true,
'whatsapp_daily_limit' => 5,
'whatsapp_scheduled_updates' => 2,
'sms_enabled' => true,
'sms_daily_limit' => 3,
'ai_predictions' => true,
'price_threshold' => true,
'score_alerts' => true,
],
],
];
foreach ($plans as $name => $data) {
Plan::updateOrCreate(
['name' => $name],
[
'stripe_price_id_monthly' => $data['stripe_price_id_monthly'],
'stripe_price_id_annual' => $data['stripe_price_id_annual'],
'features' => $data['features'],
'active' => true,
]
);
foreach ($plans as $name => $attributes) {
Plan::updateOrCreate(['name' => $name], [...$attributes, 'active' => true]);
}
}
}

View File

@@ -0,0 +1,93 @@
<template>
<div
v-if="prediction"
class="p-4 sm:p-5 bg-white rounded-2xl border border-zinc-300"
>
<div class="grid gap-4 lg:grid-cols-2 lg:gap-5">
<div class="space-y-1.5">
<p class="font-mono text-[10px] uppercase tracking-widest text-zinc-500 mb-1 truncate">Price Prediction</p>
<h3 class="text-sm font-semibold text-zinc-800 leading-snug">{{ actionLabel }}</h3>
<p class="text-sm text-zinc-500 leading-snug">{{ prediction.reasoning }}</p>
<p class="text-sm text-zinc-500 leading-snug">
<span>Avg {{ prediction.current_avg }}p</span>
<span class="text-zinc-400"> · </span>
<span>Confidence {{ prediction.confidence_label }}</span>
<template v-if="prediction.predicted_change_pence">
<span class="text-zinc-400"> · </span>
<span>{{ prediction.predicted_change_pence > 0 ? '+' : '' }}{{ prediction.predicted_change_pence.toFixed(1) }}p expected</span>
</template>
</p>
</div>
<div
v-if="weeklyHeadline || todayContext"
class="space-y-1.5 pt-3 border-t border-zinc-200 lg:pt-0 lg:border-t-0 lg:border-l lg:pl-5"
>
<p class="font-mono text-[10px] uppercase tracking-widest text-zinc-500 mb-1 truncate">Last 7 days</p>
<p v-if="weeklyHeadline" class="text-sm font-semibold text-zinc-800 leading-snug">{{ weeklyHeadline }}</p>
<p v-if="todayContext" class="text-sm text-zinc-500 leading-snug">{{ todayContext }}</p>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
prediction: { type: Object, default: null },
})
const actionLabel = computed(() => {
if (!props.prediction) return ''
return {
fill_now: 'Fill up now',
wait: 'Wait — prices falling',
no_signal: 'No clear signal',
}[props.prediction.action] ?? 'Check local prices'
})
const weekly = computed(() => props.prediction?.weekly_summary ?? null)
function formatPence(value) {
if (value === null || value === undefined) return null
return Number(value).toFixed(1) + 'p'
}
function formatDateShort(iso) {
if (!iso) return ''
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
return d.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric' })
}
const weeklyHeadline = computed(() => {
const w = weekly.value
if (!w || !w.cheapest_day || !w.priciest_day || w.last_7_days_change_pence === null) {
return null
}
const change = w.last_7_days_change_pence
const lead = change > 0.05
? `Avg rose ${change.toFixed(1)}p`
: change < -0.05
? `Avg fell ${Math.abs(change).toFixed(1)}p`
: 'Avg held steady'
return `${lead} — cheapest ${formatDateShort(w.cheapest_day.date)} (${formatPence(w.cheapest_day.avg)}), priciest ${formatDateShort(w.priciest_day.date)} (${formatPence(w.priciest_day.avg)}).`
})
const todayContext = computed(() => {
const w = weekly.value
if (!w) return null
const today = formatPence(w.today_avg)
const tomorrow = formatPence(w.tomorrow_estimated_avg)
if (today && tomorrow) {
return `Today ${today}; tomorrow ≈ ${tomorrow}.`
}
if (today) {
return `Today ${today}.`
}
return null
})
</script>

View File

@@ -38,12 +38,22 @@
</section>
<section v-if="outdated.length" class="space-y-2 pt-4">
<header class="flex items-center gap-2">
<button
:aria-expanded="outdatedOpen"
class="flex items-center gap-2 w-full text-left py-3 px-3 rounded-lg hover:bg-zinc-100/60 transition-colors"
type="button"
@click="outdatedOpen = !outdatedOpen"
>
<iconify-icon class="text-status-bad text-lg" icon="lucide:triangle-alert"></iconify-icon>
<h3 class="font-black text-zinc-800">Outdated</h3>
<span class="text-xs text-zinc-500 font-medium">Over 7 days old likely inaccurate</span>
</header>
<div class="opacity-60">
<span class="text-xs text-zinc-500 font-medium">Over 7 days old likely inaccurate ({{ outdated.length }})</span>
<iconify-icon
:class="{ 'rotate-180': outdatedOpen }"
class="text-zinc-500 text-base ml-auto transition-transform"
icon="lucide:chevron-down"
></iconify-icon>
</button>
<div v-if="outdatedOpen" class="opacity-60">
<StationCard
v-for="station in outdated"
:key="station.station_id"
@@ -72,7 +82,7 @@
</template>
<script setup>
import { computed } from 'vue'
import { computed, ref } from 'vue'
import StationCard from './StationCard.vue'
const props = defineProps({
@@ -85,6 +95,8 @@ const reliable = computed(() => props.stations.filter(s => s.reliability === 're
const stale = computed(() => props.stations.filter(s => s.reliability === 'stale'))
const outdated = computed(() => props.stations.filter(s => s.reliability === 'outdated'))
const outdatedOpen = ref(false)
const lowestPrice = computed(() => {
if (!reliable.value.length && !props.stations.length) return null
const pool = reliable.value.length ? reliable.value : props.stations

View File

@@ -0,0 +1,52 @@
<template>
<div
v-if="!isPaidTier"
class="relative overflow-hidden rounded-2xl border border-accent/30 bg-gradient-to-r from-accent/10 via-accent/5 to-transparent p-5 sm:p-6"
>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-start gap-4">
<div class="shrink-0 w-11 h-11 rounded-2xl bg-accent text-white flex items-center justify-center shadow-md">
<iconify-icon class="text-xl" icon="lucide:sparkles"></iconify-icon>
</div>
<div class="space-y-1">
<h3 class="text-lg sm:text-xl font-black text-zinc-800 leading-tight">
Stop guessing. Get a buy-or-wait alert before every fill-up.
</h3>
<p class="text-sm text-zinc-500">
14-day predictions + daily price-drop alerts across
<span class="font-bold text-zinc-800">{{ stationCountLabel }}</span> UK stations.
From <span class="font-bold text-zinc-800">£0.99/mo</span>.
</p>
</div>
</div>
<a
:href="ctaHref"
class="shrink-0 inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl bg-accent text-white text-sm font-black shadow-lg hover:bg-accent-content transition-colors"
>
{{ ctaLabel }}
<iconify-icon class="text-base" icon="lucide:arrow-right"></iconify-icon>
</a>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useAuth } from '../composables/useAuth.js'
const props = defineProps({
stationCount: { type: Number, default: null },
})
const { isAuthenticated, isPaidTier } = useAuth()
const stationCountLabel = computed(() => {
if (!props.stationCount) {
return '14,500+'
}
return new Intl.NumberFormat('en-GB').format(props.stationCount)
})
const ctaHref = computed(() => isAuthenticated.value ? '#pricing' : '/register?tier=plus&cadence=monthly')
const ctaLabel = computed(() => isAuthenticated.value ? 'See plans' : 'Start saving')
</script>

View File

@@ -114,12 +114,9 @@ it('reports subscription_cancelled=true once the subscription is set to end at p
});
it('exposes subscribed_at, cadence and renewal date for an active monthly subscription', function () {
Plan::create([
'name' => 'plus',
Plan::where('name', 'plus')->update([
'stripe_price_id_monthly' => 'price_plus_monthly_test',
'stripe_price_id_annual' => 'price_plus_annual_test',
'features' => ['fuel_types' => ['max' => 1]],
'active' => true,
]);
$user = User::factory()->create();
@@ -149,12 +146,9 @@ it('exposes subscribed_at, cadence and renewal date for an active monthly subscr
});
it('reports cadence as annual when the active price is the annual one', function () {
Plan::create([
'name' => 'pro',
Plan::where('name', 'pro')->update([
'stripe_price_id_monthly' => 'price_pro_monthly_test',
'stripe_price_id_annual' => 'price_pro_annual_test',
'features' => ['fuel_types' => ['max' => null]],
'active' => true,
]);
$user = User::factory()->create();

View File

@@ -69,11 +69,10 @@ it('logs daily_limit when the channel is allowed but the limit is exhausted', fu
$user = User::factory()->create();
// Patch the free plan to allow sms with limit 1
$freePlan = Plan::where('name', 'free')->first();
$features = $freePlan->features;
$features['sms'] = ['enabled' => true, 'daily_limit' => 1];
$freePlan->features = $features;
$freePlan->save();
Plan::where('name', 'free')->first()->update([
'sms_enabled' => true,
'sms_daily_limit' => 1,
]);
UserNotificationPreference::factory()->create([
'user_id' => $user->id,
@@ -106,14 +105,11 @@ it('logs daily_limit when the channel is allowed but the limit is exhausted', fu
it('does not log channels the user has explicitly disabled', function (): void {
$user = User::factory()->create();
// Patch free plan to allow sms
$freePlan = Plan::where('name', 'free')->first();
$features = $freePlan->features;
$features['sms'] = ['enabled' => true, 'daily_limit' => 3];
$freePlan->features = $features;
$freePlan->save();
Plan::where('name', 'free')->first()->update([
'sms_enabled' => true,
'sms_daily_limit' => 3,
]);
// User has sms pref but it is disabled
UserNotificationPreference::factory()->create([
'user_id' => $user->id,
'channel' => 'sms',
@@ -145,12 +141,11 @@ it('dispatches DispatchUserNotificationJob for eligible whatsapp users', functio
$user = User::factory()->create();
// Patch free plan to allow whatsapp with scheduled updates
$freePlan = Plan::where('name', 'free')->first();
$features = $freePlan->features;
$features['whatsapp'] = ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2];
$freePlan->features = $features;
$freePlan->save();
Plan::where('name', 'free')->first()->update([
'whatsapp_enabled' => true,
'whatsapp_daily_limit' => 5,
'whatsapp_scheduled_updates' => 2,
]);
UserNotificationPreference::factory()->create([
'user_id' => $user->id,
@@ -171,11 +166,11 @@ it('skips users who have hit their whatsapp daily limit', function (): void {
$user = User::factory()->create();
$freePlan = Plan::where('name', 'free')->first();
$features = $freePlan->features;
$features['whatsapp'] = ['enabled' => true, 'daily_limit' => 1, 'scheduled_updates' => 2];
$freePlan->features = $features;
$freePlan->save();
Plan::where('name', 'free')->first()->update([
'whatsapp_enabled' => true,
'whatsapp_daily_limit' => 1,
'whatsapp_scheduled_updates' => 2,
]);
UserNotificationPreference::factory()->create([
'user_id' => $user->id,
@@ -184,7 +179,6 @@ it('skips users who have hit their whatsapp daily limit', function (): void {
'enabled' => true,
]);
// Exhaust the daily limit
NotificationLog::factory()->create([
'user_id' => $user->id,
'channel' => 'whatsapp',
@@ -204,11 +198,11 @@ it('passes scheduled_morning trigger for morning period', function (): void {
$user = User::factory()->create();
$freePlan = Plan::where('name', 'free')->first();
$features = $freePlan->features;
$features['whatsapp'] = ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2];
$freePlan->features = $features;
$freePlan->save();
Plan::where('name', 'free')->first()->update([
'whatsapp_enabled' => true,
'whatsapp_daily_limit' => 5,
'whatsapp_scheduled_updates' => 2,
]);
UserNotificationPreference::factory()->create([
'user_id' => $user->id,

View File

@@ -28,20 +28,19 @@ it('canUseChannel returns false for sms on free tier', function (): void {
it('canUseChannel returns false for sms on basic tier', function (): void {
$plan = Plan::where('name', 'basic')->first();
// basic has sms.enabled = false in features
expect($plan->features['sms']['enabled'])->toBeFalse();
expect($plan->sms_enabled)->toBeFalse();
});
it('canUseChannel returns true for sms on plus tier', function (): void {
$plan = Plan::where('name', 'plus')->first();
expect($plan->features['sms']['enabled'])->toBeTrue();
expect($plan->sms_enabled)->toBeTrue();
});
it('canUseChannel returns true for sms on pro tier', function (): void {
$plan = Plan::where('name', 'pro')->first();
expect($plan->features['sms']['enabled'])->toBeTrue();
expect($plan->sms_enabled)->toBeTrue();
});
// ─── canSendNow ───────────────────────────────────────────────────────────────
@@ -54,10 +53,9 @@ it('canSendNow returns false when tier does not allow the channel', function ():
});
it('canSendNow returns false when daily limit is reached', function (): void {
$plan = Plan::where('name', 'plus')->first(); // sms daily_limit = 1
$plan = Plan::where('name', 'plus')->first(); // sms_daily_limit = 1
$user = User::factory()->create();
// Give user a preference so channelsFor works, and log one sent SMS today
UserNotificationPreference::factory()->create([
'user_id' => $user->id,
'channel' => 'sms',
@@ -72,10 +70,8 @@ it('canSendNow returns false when daily limit is reached', function (): void {
'created_at' => now(),
]);
// Manually bypass resolveForUser by using the plus plan features directly
expect($plan->features['sms']['daily_limit'])->toBe(1);
expect($plan->sms_daily_limit)->toBe(1);
// Confirm log count matches limit
$sentCount = NotificationLog::where('user_id', $user->id)
->where('channel', 'sms')
->where('sent', true)
@@ -88,7 +84,7 @@ it('canSendNow returns false when daily limit is reached', function (): void {
// ─── canTrackFuelType ─────────────────────────────────────────────────────────
it('canTrackFuelType respects max limit for non-pro tiers', function (): void {
$plan = Plan::where('name', 'basic')->first(); // max = 1
$plan = Plan::where('name', 'basic')->first(); // max_fuel_types = 1
$user = User::factory()->create();
UserNotificationPreference::factory()->create([
@@ -98,7 +94,7 @@ it('canTrackFuelType respects max limit for non-pro tiers', function (): void {
'enabled' => true,
]);
expect($plan->features['fuel_types']['max'])->toBe(1);
expect($plan->max_fuel_types)->toBe(1);
$count = UserNotificationPreference::where('user_id', $user->id)
->distinct('fuel_type')
@@ -110,7 +106,7 @@ it('canTrackFuelType respects max limit for non-pro tiers', function (): void {
it('pro tier has null fuel type limit meaning unlimited', function (): void {
$plan = Plan::where('name', 'pro')->first();
expect($plan->features['fuel_types']['max'])->toBeNull();
expect($plan->max_fuel_types)->toBeNull();
});
// ─── can() feature flags ──────────────────────────────────────────────────────
@@ -118,19 +114,18 @@ it('pro tier has null fuel type limit meaning unlimited', function (): void {
it('can returns false for ai_predictions on free tier', function (): void {
$plan = Plan::where('name', 'free')->first();
expect($plan->features['ai_predictions'])->toBeFalse();
expect($plan->ai_predictions)->toBeFalse();
});
it('can returns true for ai_predictions on plus tier', function (): void {
$plan = Plan::where('name', 'plus')->first();
expect($plan->features['ai_predictions'])->toBeTrue();
expect($plan->ai_predictions)->toBeTrue();
});
// ─── PlanSeeder idempotency ───────────────────────────────────────────────────
it('PlanSeeder is idempotent', function (): void {
// Run seeder a second time
$this->artisan('db:seed', ['--class' => 'PlanSeeder']);
expect(Plan::count())->toBe(4);
@@ -211,15 +206,15 @@ it('scopeForFuelType filters by fuel type', function (): void {
// ─── 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']);
it('seeds push frequency for every tier', function (): void {
expect(Plan::where('name', 'free')->first())
->push_enabled->toBeFalse()->push_frequency->toBe('none')
->and(Plan::where('name', 'basic')->first())
->push_enabled->toBeTrue()->push_frequency->toBe('daily')
->and(Plan::where('name', 'plus')->first())
->push_enabled->toBeTrue()->push_frequency->toBe('triggered')
->and(Plan::where('name', 'pro')->first())
->push_enabled->toBeTrue()->push_frequency->toBe('triggered');
});
// ─── display name ─────────────────────────────────────────────────────────────

View File

@@ -43,12 +43,12 @@ it('saves email frequency on edit', function (): void {
Livewire::test(EditPlan::class, ['record' => $plan->id])
->fillForm([
'features.email.frequency' => 'daily',
'email_frequency' => 'daily',
])
->call('save')
->assertHasNoFormErrors();
expect($plan->fresh()->features['email']['frequency'])->toBe('daily');
expect($plan->fresh()->email_frequency)->toBe('daily');
});
it('saves sms daily limit on edit', function (): void {
@@ -56,12 +56,12 @@ it('saves sms daily limit on edit', function (): void {
Livewire::test(EditPlan::class, ['record' => $plan->id])
->fillForm([
'features.sms.daily_limit' => 3,
'sms_daily_limit' => 3,
])
->call('save')
->assertHasNoFormErrors();
expect($plan->fresh()->features['sms']['daily_limit'])->toBe(3);
expect($plan->fresh()->sms_daily_limit)->toBe(3);
});
it('saves null fuel type max for pro (unlimited)', function (): void {
@@ -69,10 +69,10 @@ it('saves null fuel type max for pro (unlimited)', function (): void {
Livewire::test(EditPlan::class, ['record' => $plan->id])
->fillForm([
'features.fuel_types.max' => null,
'max_fuel_types' => null,
])
->call('save')
->assertHasNoFormErrors();
expect($plan->fresh()->features['fuel_types']['max'])->toBeNull();
expect($plan->fresh()->max_fuel_types)->toBeNull();
});

View File

@@ -1,5 +1,6 @@
<?php
use Database\Seeders\PlanSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
@@ -16,6 +17,9 @@ use Tests\TestCase;
pest()->extend(TestCase::class)
->use(RefreshDatabase::class)
->beforeEach(function (): void {
$this->seed(PlanSeeder::class);
})
->in('Feature', 'Unit');
/*

54
todo.md Normal file
View File

@@ -0,0 +1,54 @@
# Todo
Working checklist. Add sections per area. Tick boxes as you go.
---
## Stripe
Spec: `docs/superpowers/specs/2026-04-23-stripe-subscription-lifecycle-design.md`
Rules: `.claude/rules/payments.md`
### Pre-production (test mode first, then repeat in live mode)
- [ ] **Stripe Dashboard — retry schedule.** Billing → Automations → Subscription retry rules. Switch Smart Retries → Custom. Retry on days 1, 3, 5. After final retry → *Cancel subscription*.
- [ ] **Stripe Dashboard — customer emails.** Emails → Customer emails. Enable "Successful payments", "Failed payments", "Upcoming renewals".
- [ ] **Stripe Dashboard — branding.** Settings → Branding. Upload FuelAlert logo, set primary colour to match app accent.
- [ ] **Stripe Dashboard — Customer Portal.** Settings → Billing → Customer Portal. Allow plan changes across all 6 prices (basic/plus/pro × monthly/annual), cancellation at period end only, card updates, invoice history. Hide everything else.
- [ ] **Stripe Dashboard — webhook endpoint.** Developers → Webhooks. Add endpoint at `{APP_URL}/stripe/webhook`. Subscribe to: `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.payment_succeeded`, `invoice.payment_failed`. Copy signing secret → production `.env` as `STRIPE_WEBHOOK_SECRET`.
### Production env + server
- [ ] **`.env` keys** set on production:
- `STRIPE_KEY=pk_live_...`
- `STRIPE_SECRET=sk_live_...`
- `STRIPE_WEBHOOK_SECRET=whsec_...`
- `CASHIER_CURRENCY=gbp`
- `QUEUE_CONNECTION=redis`
- `STRIPE_PRICE_BASIC_MONTHLY`, `STRIPE_PRICE_BASIC_ANNUAL`
- `STRIPE_PRICE_PLUS_MONTHLY`, `STRIPE_PRICE_PLUS_ANNUAL`
- `STRIPE_PRICE_PRO_MONTHLY`, `STRIPE_PRICE_PRO_ANNUAL`
- [ ] **Run `php artisan migrate`** — adds `users.grace_period_until`.
- [ ] **Queue worker** consuming both queues: `--queue=notifications,default` (reminders go on the `notifications` queue).
- [ ] **Redis persistence** (AOF or RDB) enabled — delayed jobs sit for 35 days.
- [ ] `php artisan route:list --name=billing` — confirm 4 routes (checkout, portal, success, cancel).
### E2E QA (Stripe test mode)
Requires the Dashboard + env tasks above done first. Stripe test cards:
- `4242 4242 4242 4242` — success
- `4000 0000 0000 0341` — renewal fails (use to test dunning)
- [ ] Sign up on each paid tier × both cadences (6 combos) → confirm tier shows.
- [ ] Upgrade basic → pro via Portal → confirm instant swap.
- [ ] Downgrade pro → basic via Portal → confirm change scheduled for period end.
- [ ] Cancel mid-period → features persist until period end → drop to free.
- [ ] Use `4000 0000 0000 0341` + `stripe trigger invoice.payment_failed`:
- Banner appears on dashboard with correct "by {date}" string.
- Day-3 job is queued (visible via `php artisan queue:listen notifications`).
- Day-5 job is queued.
- Final Stripe retry fails → `customer.subscription.deleted` → user drops to free, WhatsApp + SMS prefs disabled, banner disappears.
- [ ] Recover mid-grace (update card via Portal) → `invoice.payment_succeeded` clears grace, banner disappears, queued reminders silently no-op when they run.
---