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. + +---