Compare commits
2 Commits
088fd11058
...
775e076bb7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
775e076bb7 | ||
|
|
8695d5ec95 |
211
.claude/rules/prediction.md
Normal file
211
.claude/rules/prediction.md
Normal 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 (0–100) | 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 |
|
||||
| 40–69 | `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
24
.claude/settings.json
Normal 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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
40
.env.example
40
.env.example
@@ -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
|
||||
@@ -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'),
|
||||
]),
|
||||
]);
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
17
app/Models/Subscription.php
Normal file
17
app/Models/Subscription.php
Normal 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',
|
||||
];
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
$this->plan = Plan::resolveForUser($user);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
'ai_predictions' => false,
|
||||
'price_threshold' => true,
|
||||
'score_alerts' => true,
|
||||
],
|
||||
'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],
|
||||
'ai_predictions' => true,
|
||||
'price_threshold' => true,
|
||||
'score_alerts' => true,
|
||||
],
|
||||
'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],
|
||||
'ai_predictions' => true,
|
||||
'price_threshold' => true,
|
||||
'score_alerts' => true,
|
||||
],
|
||||
'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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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],
|
||||
'ai_predictions' => false,
|
||||
'price_threshold' => false,
|
||||
'score_alerts' => false,
|
||||
],
|
||||
'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],
|
||||
'ai_predictions' => false,
|
||||
'price_threshold' => true,
|
||||
'score_alerts' => true,
|
||||
],
|
||||
'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],
|
||||
'ai_predictions' => true,
|
||||
'price_threshold' => true,
|
||||
'score_alerts' => true,
|
||||
],
|
||||
'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],
|
||||
'ai_predictions' => true,
|
||||
'price_threshold' => true,
|
||||
'score_alerts' => true,
|
||||
],
|
||||
'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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
93
resources/js/components/PredictionFull.vue
Normal file
93
resources/js/components/PredictionFull.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
52
resources/js/components/UpsellBanner.vue
Normal file
52
resources/js/components/UpsellBanner.vue
Normal 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>
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
54
todo.md
Normal 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 3–5 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.
|
||||
|
||||
---
|
||||
Reference in New Issue
Block a user