From 775e076bb7fe840bf63ade452711b68149b6c43c Mon Sep 17 00:00:00 2001 From: Ovidiu U Date: Wed, 29 Apr 2026 18:14:03 +0100 Subject: [PATCH] Add current_period tracking to subscriptions, document prediction engine, and refactor station list UI 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. --- .claude/rules/prediction.md | 211 ++++++++++++++++++ .claude/settings.json | 24 ++ .env.example | 40 ++-- app/Models/Subscription.php | 17 ++ ...rent_period_end_to_subscriptions_table.php | 24 ++ resources/js/components/PredictionFull.vue | 93 ++++++++ resources/js/components/StationList.vue | 22 +- resources/js/components/UpsellBanner.vue | 52 +++++ todo.md | 54 +++++ 9 files changed, 517 insertions(+), 20 deletions(-) create mode 100644 .claude/rules/prediction.md create mode 100644 .claude/settings.json create mode 100644 app/Models/Subscription.php create mode 100644 database/migrations/2026_04_29_092713_add_current_period_end_to_subscriptions_table.php create mode 100644 resources/js/components/PredictionFull.vue create mode 100644 resources/js/components/UpsellBanner.vue create mode 100644 todo.md diff --git a/.claude/rules/prediction.md b/.claude/rules/prediction.md new file mode 100644 index 0000000..5a56f0a --- /dev/null +++ b/.claude/rules/prediction.md @@ -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" +--- diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..a9274e7 --- /dev/null +++ b/.claude/settings.json @@ -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)" + ] + } +} diff --git a/.env.example b/.env.example index 49a1c80..c25ad06 100644 --- a/.env.example +++ b/.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 \ No newline at end of file diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php new file mode 100644 index 0000000..2cab26b --- /dev/null +++ b/app/Models/Subscription.php @@ -0,0 +1,17 @@ + 'datetime', + 'quantity' => 'integer', + 'trial_ends_at' => 'datetime', + 'current_period_start' => 'datetime', + 'current_period_end' => 'datetime', + 'stripe_data' => 'array', + ]; +} diff --git a/database/migrations/2026_04_29_092713_add_current_period_end_to_subscriptions_table.php b/database/migrations/2026_04_29_092713_add_current_period_end_to_subscriptions_table.php new file mode 100644 index 0000000..1b80aeb --- /dev/null +++ b/database/migrations/2026_04_29_092713_add_current_period_end_to_subscriptions_table.php @@ -0,0 +1,24 @@ +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']); + }); + } +}; diff --git a/resources/js/components/PredictionFull.vue b/resources/js/components/PredictionFull.vue new file mode 100644 index 0000000..b435c19 --- /dev/null +++ b/resources/js/components/PredictionFull.vue @@ -0,0 +1,93 @@ + + + diff --git a/resources/js/components/StationList.vue b/resources/js/components/StationList.vue index e14ee30..862901b 100644 --- a/resources/js/components/StationList.vue +++ b/resources/js/components/StationList.vue @@ -38,12 +38,22 @@
-
+
-
+ Over 7 days old — likely inaccurate ({{ outdated.length }}) + + +
diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..e915903 --- /dev/null +++ b/todo.md @@ -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. + +---