Compare commits
27 Commits
3224b186b2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25cf022964 | ||
|
|
e821a934a5 | ||
|
|
73de53994f | ||
|
|
df70e514e9 | ||
|
|
28061541d4 | ||
|
|
895d55439b | ||
|
|
aff6dd1e0f | ||
|
|
06f5f2035f | ||
|
|
69eb524e07 | ||
|
|
b4ef1177b2 | ||
|
|
8e29980dfe | ||
|
|
4ce5066596 | ||
|
|
c46b017b51 | ||
|
|
7f64c42a23 | ||
|
|
4d9df1ee19 | ||
|
|
5369b4a5a0 | ||
|
|
27c82ef103 | ||
|
|
e39618f5df | ||
|
|
00d0f7c8ec | ||
|
|
48af2083f3 | ||
|
|
783297694c | ||
|
|
775e076bb7 | ||
|
|
8695d5ec95 | ||
|
|
088fd11058 | ||
|
|
ee6de23709 | ||
|
|
2ff3aeba4d | ||
|
|
b8adb81c79 |
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"
|
||||
---
|
||||
30
.claude/settings.json
Normal file
30
.claude/settings.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"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)",
|
||||
"Bash(php artisan migrate:fresh)",
|
||||
"Bash(php artisan migrate:fresh *)",
|
||||
"Bash(php artisan migrate:reset)",
|
||||
"Bash(php artisan migrate:reset *)",
|
||||
"Bash(php artisan db:wipe)",
|
||||
"Bash(php artisan db:wipe *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
48
.env.example
48
.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,37 @@ 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
|
||||
|
||||
ONESIGNAL_APP_ID=
|
||||
ONESIGNAL_API_KEY=
|
||||
|
||||
VONAGE_KEY=
|
||||
VONAGE_SECRET=
|
||||
VONAGE_WHATSAPP_FROM=
|
||||
VONAGE_SMS_FROM=FuelAlert
|
||||
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
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,3 +23,4 @@ yarn-error.log
|
||||
/.zed
|
||||
/.tmp/
|
||||
/.worktrees/
|
||||
/ONSPD_Online_Latest_Centroids_*.csv
|
||||
|
||||
15
CLAUDE.md
15
CLAUDE.md
@@ -3,6 +3,20 @@
|
||||
UK fuel price intelligence app. Subscribers receive fill-up timing recommendations
|
||||
based on local price trends. Built solo by a PHP/Laravel developer.
|
||||
|
||||
## Destructive DB operations — HARD STOP
|
||||
|
||||
**Never run** the following commands. If one of them is the right step, stop, tell the user the exact command, and ask them to run it themselves:
|
||||
|
||||
- `php artisan migrate:fresh` (with any flags, including `--seed`)
|
||||
- `php artisan migrate:reset`
|
||||
- `php artisan db:wipe`
|
||||
- Raw `DROP TABLE`, `DROP DATABASE`, or `TRUNCATE` via tinker, `database-query`, or any MCP tool
|
||||
- Any sequence that effectively rebuilds the schema or drops tables
|
||||
|
||||
These are also blocked at the harness level via `.claude/settings.json` deny rules, but the prose rule applies everywhere the block doesn't reach (compound shell commands, MCP tools, etc.).
|
||||
|
||||
A user saying "trust me", "do the refactor", "clean up the mess", or "I want it in db" is **not** authorisation for these — the architectural decision is separate from the operational step. If a migration is awkward to apply in-place, propose the in-place version (read JSON → populate new columns → drop the old column) instead of suggesting a rebuild. Asking once at the start of a task does not authorise repeat wipes later in the session.
|
||||
|
||||
## Project overview
|
||||
|
||||
- **Product**: "Fill up now or wait?" — local fuel price trend scoring for UK drivers
|
||||
@@ -31,6 +45,7 @@ npm run dev # Vite asset watcher
|
||||
@.claude/rules/database.md
|
||||
@.claude/rules/notifications.md
|
||||
@.claude/rules/scoring.md
|
||||
@.claude/rules/prediction.md
|
||||
@.claude/rules/payments.md
|
||||
@.claude/rules/tiers.md
|
||||
@.claude/rules/livewire.md
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Concerns\PasswordValidationRules;
|
||||
use App\Concerns\ProfileValidationRules;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||
|
||||
class CreateNewUser implements CreatesNewUsers
|
||||
{
|
||||
use PasswordValidationRules, ProfileValidationRules;
|
||||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and create a newly registered user.
|
||||
@@ -20,7 +20,8 @@ class CreateNewUser implements CreatesNewUsers
|
||||
public function create(array $input): User
|
||||
{
|
||||
Validator::make($input, [
|
||||
...$this->profileRules(),
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', Rule::unique(User::class)],
|
||||
'password' => $this->passwordRules(),
|
||||
])->validate();
|
||||
|
||||
|
||||
@@ -16,14 +16,4 @@ trait PasswordValidationRules
|
||||
{
|
||||
return ['required', 'string', Password::default(), 'confirmed'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules used to validate the current password.
|
||||
*
|
||||
* @return array<int, Rule|array<mixed>|string>
|
||||
*/
|
||||
protected function currentPasswordRules(): array
|
||||
{
|
||||
return ['required', 'string', 'current_password'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Concerns;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
trait ProfileValidationRules
|
||||
{
|
||||
/**
|
||||
* Get the validation rules used to validate user profiles.
|
||||
*
|
||||
* @return array<string, array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>>
|
||||
*/
|
||||
protected function profileRules(?int $userId = null): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->nameRules(),
|
||||
'email' => $this->emailRules($userId),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules used to validate user names.
|
||||
*
|
||||
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
|
||||
*/
|
||||
protected function nameRules(): array
|
||||
{
|
||||
return ['required', 'string', 'max:255'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules used to validate user emails.
|
||||
*
|
||||
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
|
||||
*/
|
||||
protected function emailRules(?int $userId = null): array
|
||||
{
|
||||
return [
|
||||
'required',
|
||||
'string',
|
||||
'email',
|
||||
'max:255',
|
||||
$userId === null
|
||||
? Rule::unique(User::class)
|
||||
: Rule::unique(User::class)->ignore($userId),
|
||||
];
|
||||
}
|
||||
}
|
||||
52
app/Console/Commands/ArchiveOldPricesCommand.php
Normal file
52
app/Console/Commands/ArchiveOldPricesCommand.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\StationPrice;
|
||||
use App\Models\StationPriceArchive;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ArchiveOldPricesCommand extends Command
|
||||
{
|
||||
protected $signature = 'fuel:archive';
|
||||
|
||||
protected $description = 'Move station price history older than 12 months to the archive table';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$cutoff = Carbon::now()->subMonths(12);
|
||||
|
||||
$count = StationPrice::where('price_effective_at', '<', $cutoff)->count();
|
||||
|
||||
if ($count === 0) {
|
||||
$this->info('No prices to archive.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Archiving {$count} price record(s) older than {$cutoff->toDateString()}...");
|
||||
|
||||
StationPrice::where('price_effective_at', '<', $cutoff)
|
||||
->chunkById(1000, function ($prices): void {
|
||||
$rows = $prices->map(fn (StationPrice $price): array => [
|
||||
'station_id' => $price->station_id,
|
||||
'fuel_type' => $price->fuel_type->value,
|
||||
'price_pence' => $price->price_pence,
|
||||
'price_effective_at' => $price->price_effective_at,
|
||||
'price_reported_at' => $price->price_reported_at,
|
||||
'recorded_at' => $price->recorded_at,
|
||||
])->all();
|
||||
|
||||
DB::transaction(function () use ($rows, $prices): void {
|
||||
StationPriceArchive::insert($rows);
|
||||
StationPrice::whereIn('id', $prices->pluck('id'))->delete();
|
||||
});
|
||||
});
|
||||
|
||||
$this->info('Archive complete.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use App\Services\BrentPriceSources\BrentPriceFetchException;
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
use Illuminate\Console\Attributes\Signature;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
#[Signature('oil:fetch')]
|
||||
#[Description('Fetch latest Brent crude prices (EIA primary, FRED fallback)')]
|
||||
@@ -20,6 +21,7 @@ class FetchOilPrices extends Command
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (BrentPriceFetchException $e) {
|
||||
Log::warning('FetchOilPrices: EIA fetch failed, falling back to FRED', ['error' => $e->getMessage()]);
|
||||
$this->warn('EIA fetch failed: '.$e->getMessage().'. Trying FRED...');
|
||||
}
|
||||
|
||||
@@ -29,6 +31,7 @@ class FetchOilPrices extends Command
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (BrentPriceFetchException $e) {
|
||||
Log::error('FetchOilPrices: both EIA and FRED failed', ['error' => $e->getMessage()]);
|
||||
$this->error('Both EIA and FRED failed: '.$e->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
|
||||
@@ -5,7 +5,10 @@ namespace App\Console\Commands;
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
use Illuminate\Console\Attributes\Signature;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Throwable;
|
||||
|
||||
#[Signature('postcodes:import {--file= : Path to ONSPD CSV file}')]
|
||||
#[Description('Import UK postcodes (ONSPD) into the local postcodes and outcodes tables')]
|
||||
@@ -79,56 +82,78 @@ final class ImportPostcodes extends Command
|
||||
|
||||
$hasDoterm = isset($columns['doterm']);
|
||||
|
||||
DB::table('postcodes')->truncate();
|
||||
DB::table('outcodes')->truncate();
|
||||
// Stream into a staging table first. Only swap into the live
|
||||
// postcodes / outcodes tables once the full CSV has been consumed —
|
||||
// a mid-stream failure leaves production data untouched.
|
||||
Schema::dropIfExists('postcodes_staging');
|
||||
Schema::create('postcodes_staging', function (Blueprint $table): void {
|
||||
$table->string('postcode', 7);
|
||||
$table->string('outcode', 4);
|
||||
$table->decimal('lat', 10, 7);
|
||||
$table->decimal('lng', 10, 7);
|
||||
});
|
||||
|
||||
$buffer = [];
|
||||
$imported = 0;
|
||||
|
||||
while (($row = fgetcsv($handle)) !== false) {
|
||||
if ($hasDoterm && trim((string) ($row[$columns['doterm']] ?? '')) !== '') {
|
||||
continue;
|
||||
try {
|
||||
while (($row = fgetcsv($handle)) !== false) {
|
||||
if ($hasDoterm && trim((string) ($row[$columns['doterm']] ?? '')) !== '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lat = trim((string) ($row[$columns['lat']] ?? ''));
|
||||
$lng = trim((string) ($row[$columns['long']] ?? ''));
|
||||
|
||||
if ($lat === '' || $lng === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pcd = strtoupper(preg_replace('/\s+/', '', (string) $row[$columns[$pcdColumn]]));
|
||||
|
||||
if ($pcd === '' || strlen($pcd) < 5) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$buffer[] = [
|
||||
'postcode' => $pcd,
|
||||
'outcode' => substr($pcd, 0, strlen($pcd) - 3),
|
||||
'lat' => (float) $lat,
|
||||
'lng' => (float) $lng,
|
||||
];
|
||||
|
||||
if (count($buffer) >= self::CHUNK_SIZE) {
|
||||
DB::table('postcodes_staging')->insert($buffer);
|
||||
$imported += count($buffer);
|
||||
$buffer = [];
|
||||
}
|
||||
}
|
||||
|
||||
$lat = trim((string) ($row[$columns['lat']] ?? ''));
|
||||
$lng = trim((string) ($row[$columns['long']] ?? ''));
|
||||
|
||||
if ($lat === '' || $lng === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pcd = strtoupper(preg_replace('/\s+/', '', (string) $row[$columns[$pcdColumn]]));
|
||||
|
||||
if ($pcd === '' || strlen($pcd) < 5) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$buffer[] = [
|
||||
'postcode' => $pcd,
|
||||
'outcode' => substr($pcd, 0, strlen($pcd) - 3),
|
||||
'lat' => (float) $lat,
|
||||
'lng' => (float) $lng,
|
||||
];
|
||||
|
||||
if (count($buffer) >= self::CHUNK_SIZE) {
|
||||
DB::table('postcodes')->insert($buffer);
|
||||
if ($buffer !== []) {
|
||||
DB::table('postcodes_staging')->insert($buffer);
|
||||
$imported += count($buffer);
|
||||
$buffer = [];
|
||||
}
|
||||
|
||||
// Swap: empty live tables, copy from staging, derive outcodes.
|
||||
DB::table('outcodes')->truncate();
|
||||
DB::table('postcodes')->truncate();
|
||||
DB::statement(
|
||||
'INSERT INTO postcodes (postcode, outcode, lat, lng)
|
||||
SELECT postcode, outcode, lat, lng FROM postcodes_staging'
|
||||
);
|
||||
DB::statement(
|
||||
'INSERT INTO outcodes (outcode, lat, lng)
|
||||
SELECT outcode, AVG(lat), AVG(lng) FROM postcodes GROUP BY outcode'
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
$this->error('Import failed — live tables left untouched: '.$e->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
} finally {
|
||||
fclose($handle);
|
||||
Schema::dropIfExists('postcodes_staging');
|
||||
}
|
||||
|
||||
if ($buffer !== []) {
|
||||
DB::table('postcodes')->insert($buffer);
|
||||
$imported += count($buffer);
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
|
||||
DB::statement(
|
||||
'INSERT INTO outcodes (outcode, lat, lng)
|
||||
SELECT outcode, AVG(lat), AVG(lng) FROM postcodes GROUP BY outcode'
|
||||
);
|
||||
|
||||
$this->info("Imported {$imported} postcodes.");
|
||||
$this->info('Derived '.DB::table('outcodes')->count().' outcode centroids.');
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Filament\Resources\UserResource\Pages\EditUser;
|
||||
use App\Filament\Resources\UserResource\Pages\ListUsers;
|
||||
use App\Models\Plan;
|
||||
use App\Models\User;
|
||||
use App\Services\PlanFeatures;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
@@ -75,7 +76,7 @@ class UserResource extends Resource
|
||||
->live()
|
||||
->dehydrated(false)
|
||||
->afterStateHydrated(fn (Select $component, ?User $record) => $component
|
||||
->state($record ? Plan::resolveForUser($record)->name : PlanTier::Free->value)),
|
||||
->state($record ? PlanFeatures::for($record)->tier() : PlanTier::Free->value)),
|
||||
Select::make('cadence')
|
||||
->label('Billing Cadence')
|
||||
->options([
|
||||
@@ -131,7 +132,7 @@ class UserResource extends Resource
|
||||
TextColumn::make('postcode')->placeholder('—'),
|
||||
TextColumn::make('tier')
|
||||
->label('Tier')
|
||||
->state(fn (User $record): string => Plan::resolveForUser($record)->name)
|
||||
->state(fn (User $record): string => PlanFeatures::for($record)->tier())
|
||||
->badge()
|
||||
->colors([
|
||||
'gray' => 'free',
|
||||
@@ -182,7 +183,14 @@ class UserResource extends Resource
|
||||
return;
|
||||
}
|
||||
|
||||
$priceId = config("services.stripe.prices.{$tier}.{$cadence}") ?? "price_admin_{$tier}_{$cadence}";
|
||||
$priceId = config("services.stripe.prices.{$tier}.{$cadence}") ?: "price_admin_{$tier}_{$cadence}";
|
||||
|
||||
$planColumn = $cadence === 'annual' ? 'stripe_price_id_annual' : 'stripe_price_id_monthly';
|
||||
$plan = Plan::where('name', $tier)->first();
|
||||
|
||||
if ($plan && empty($plan->{$planColumn})) {
|
||||
$plan->update([$planColumn => $priceId]);
|
||||
}
|
||||
|
||||
$user->subscriptions()->create([
|
||||
'type' => 'default',
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Plan;
|
||||
use App\Models\User;
|
||||
use App\Services\PlanFeatures;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -64,9 +65,23 @@ class AuthController extends Controller
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
return response()->json(array_merge(
|
||||
$user->toArray(),
|
||||
['tier' => Plan::resolveForUser($user)->name],
|
||||
));
|
||||
if ($user === null) {
|
||||
return new JsonResponse('null', json: true);
|
||||
}
|
||||
|
||||
$subscription = $user->subscription();
|
||||
|
||||
$expiresAt = $subscription?->ends_at ?? $subscription?->current_period_end;
|
||||
|
||||
return response()->json([
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'two_factor_confirmed_at' => $user->two_factor_confirmed_at?->toIso8601String(),
|
||||
'tier' => PlanFeatures::for($user)->tier(),
|
||||
'subscription_cancelled' => $subscription?->canceled() ?? false,
|
||||
'subscription_cadence' => Plan::resolveCadenceForUser($user),
|
||||
'subscribed_at' => $subscription?->created_at?->toIso8601String(),
|
||||
'subscription_expires_at' => $expiresAt?->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\PredictionRequest;
|
||||
use App\Services\NationalFuelPredictionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class PredictionController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NationalFuelPredictionService $predictionService,
|
||||
) {}
|
||||
|
||||
public function index(PredictionRequest $request): JsonResponse
|
||||
{
|
||||
$lat = $request->filled('lat') ? (float) $request->input('lat') : null;
|
||||
$lng = $request->filled('lng') ? (float) $request->input('lng') : null;
|
||||
|
||||
$result = $this->predictionService->predict($lat, $lng);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
}
|
||||
@@ -2,23 +2,60 @@
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Enums\PriceReliability;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\NearbyStationsRequest;
|
||||
use App\Http\Resources\Api\StationResource;
|
||||
use App\Models\Search;
|
||||
use App\Models\Station;
|
||||
use App\Services\PostcodeService;
|
||||
use Illuminate\Database\Query\JoinClause;
|
||||
use App\Services\StationSearch\SearchCriteria;
|
||||
use App\Services\StationSearch\StationSearchService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class StationController extends Controller
|
||||
{
|
||||
public function __construct(private readonly PostcodeService $postcodeService) {}
|
||||
public function __construct(
|
||||
private readonly PostcodeService $postcodeService,
|
||||
private readonly StationSearchService $searchService,
|
||||
) {}
|
||||
|
||||
public function index(NearbyStationsRequest $request): JsonResponse
|
||||
{
|
||||
[$lat, $lng] = $this->resolveCoordinates($request);
|
||||
|
||||
$criteria = new SearchCriteria(
|
||||
lat: $lat,
|
||||
lng: $lng,
|
||||
fuelType: $request->fuelType(),
|
||||
radiusKm: $request->radius(),
|
||||
sort: $request->sort(),
|
||||
);
|
||||
|
||||
$result = $this->searchService->search(
|
||||
$criteria,
|
||||
$request->user(),
|
||||
hash('sha256', $request->ip() ?? ''),
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'data' => StationResource::collection($result->stations),
|
||||
'meta' => [
|
||||
'count' => $result->stations->count(),
|
||||
'fuel_type' => $criteria->fuelType->value,
|
||||
'radius_km' => $criteria->radiusKm,
|
||||
'lat' => $criteria->lat,
|
||||
'lng' => $criteria->lng,
|
||||
'lowest_pence' => $result->pricesSummary['lowest'],
|
||||
'highest_pence' => $result->pricesSummary['highest'],
|
||||
'cheapest_price_pence' => $result->pricesSummary['lowest'],
|
||||
'avg_pence' => $result->pricesSummary['avg'],
|
||||
'reliability_counts' => $result->reliabilityCounts,
|
||||
],
|
||||
'prediction' => $result->prediction,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array{0: float, 1: float} */
|
||||
private function resolveCoordinates(NearbyStationsRequest $request): array
|
||||
{
|
||||
if ($request->filled('postcode')) {
|
||||
$location = $this->postcodeService->resolve($request->string('postcode')->toString());
|
||||
@@ -27,94 +64,9 @@ class StationController extends Controller
|
||||
throw ValidationException::withMessages(['postcode' => 'Postcode not found.']);
|
||||
}
|
||||
|
||||
$lat = $location->lat;
|
||||
$lng = $location->lng;
|
||||
} else {
|
||||
$lat = (float) $request->input('lat');
|
||||
$lng = (float) $request->input('lng');
|
||||
return [$location->lat, $location->lng];
|
||||
}
|
||||
$fuelType = $request->fuelType();
|
||||
$radius = $request->radius();
|
||||
$sort = $request->sort();
|
||||
|
||||
$all = Station::query()
|
||||
->selectRaw(
|
||||
'stations.*, spc.price_pence, spc.fuel_type, spc.price_effective_at,
|
||||
(6371 * acos(GREATEST(-1.0, LEAST(1.0,
|
||||
cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?))
|
||||
+ sin(radians(?)) * sin(radians(lat))
|
||||
)))) AS distance_km',
|
||||
[$lat, $lng, $lat],
|
||||
)
|
||||
->join('station_prices_current as spc', function (JoinClause $join) use ($fuelType): void {
|
||||
$join->on('stations.node_id', '=', 'spc.station_id')
|
||||
->where('spc.fuel_type', '=', $fuelType->value);
|
||||
})
|
||||
->where('stations.temporary_closure', false)
|
||||
->where('stations.permanent_closure', false)
|
||||
->get();
|
||||
|
||||
$filtered = $all->filter(fn ($s) => (float) $s->distance_km <= $radius);
|
||||
|
||||
$stations = $sort === 'reliable'
|
||||
? $filtered
|
||||
->sort(function ($a, $b) {
|
||||
$weightA = PriceReliability::fromUpdatedAt(
|
||||
$a->price_effective_at ? Carbon::parse($a->price_effective_at) : null
|
||||
)->weight();
|
||||
$weightB = PriceReliability::fromUpdatedAt(
|
||||
$b->price_effective_at ? Carbon::parse($b->price_effective_at) : null
|
||||
)->weight();
|
||||
|
||||
return $weightA <=> $weightB
|
||||
?: ((int) $a->price_pence <=> (int) $b->price_pence)
|
||||
?: ((float) $a->distance_km <=> (float) $b->distance_km);
|
||||
})
|
||||
->values()
|
||||
: $filtered->sortBy(match ($sort) {
|
||||
'price' => fn ($s) => (int) $s->price_pence,
|
||||
'updated' => fn ($s) => $s->price_effective_at ? -strtotime($s->price_effective_at) : PHP_INT_MAX,
|
||||
default => fn ($s) => (float) $s->distance_km,
|
||||
})->values();
|
||||
|
||||
$prices = $stations->pluck('price_pence');
|
||||
|
||||
$reliabilityCounts = $stations
|
||||
->groupBy(fn ($s) => PriceReliability::fromUpdatedAt(
|
||||
$s->price_effective_at ? Carbon::parse($s->price_effective_at) : null
|
||||
)->value)
|
||||
->map->count();
|
||||
|
||||
Search::create([
|
||||
'lat_bucket' => round($lat, 2),
|
||||
'lng_bucket' => round($lng, 2),
|
||||
'fuel_type' => $fuelType->value,
|
||||
'results_count' => $stations->count(),
|
||||
'lowest_pence' => $prices->min(),
|
||||
'highest_pence' => $prices->max(),
|
||||
'avg_pence' => $prices->isNotEmpty() ? round($prices->avg(), 2) : null,
|
||||
'searched_at' => now(),
|
||||
'ip_hash' => hash('sha256', $request->ip() ?? ''),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'data' => StationResource::collection($stations),
|
||||
'meta' => [
|
||||
'count' => $stations->count(),
|
||||
'fuel_type' => $fuelType->value,
|
||||
'radius_km' => $radius,
|
||||
'lat' => $lat,
|
||||
'lng' => $lng,
|
||||
'lowest_pence' => $prices->min(),
|
||||
'highest_pence' => $prices->max(),
|
||||
'cheapest_price_pence' => $prices->min(),
|
||||
'avg_pence' => $prices->isNotEmpty() ? round($prices->avg(), 2) : null,
|
||||
'reliability_counts' => [
|
||||
'reliable' => (int) $reliabilityCounts->get(PriceReliability::Reliable->value, 0),
|
||||
'stale' => (int) $reliabilityCounts->get(PriceReliability::Stale->value, 0),
|
||||
'outdated' => (int) $reliabilityCounts->get(PriceReliability::Outdated->value, 0),
|
||||
],
|
||||
],
|
||||
]);
|
||||
return [(float) $request->input('lat'), (float) $request->input('lng')];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
|
||||
use App\Enums\PlanTier;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Laravel\Cashier\Checkout;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class BillingController extends Controller
|
||||
@@ -12,7 +13,7 @@ class BillingController extends Controller
|
||||
/**
|
||||
* Redirect the user to a Stripe Checkout session for the requested plan + cadence.
|
||||
*/
|
||||
public function checkout(Request $request, string $tier, string $cadence): Response|RedirectResponse
|
||||
public function checkout(Request $request, string $tier, string $cadence): Response|RedirectResponse|Checkout
|
||||
{
|
||||
abort_unless(in_array($tier, [PlanTier::Basic->value, PlanTier::Plus->value, PlanTier::Pro->value], true), 404);
|
||||
abort_unless(in_array($cadence, ['monthly', 'annual'], true), 404);
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class PredictionRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'lat' => ['nullable', 'numeric', 'between:-90,90'],
|
||||
'lng' => ['nullable', 'numeric', 'between:-180,180'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,13 @@ class StationResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$updatedAt = $this->price_effective_at ? Carbon::parse($this->price_effective_at) : null;
|
||||
$reliability = PriceReliability::fromUpdatedAt($updatedAt);
|
||||
// The controller pre-computes _updated_at / _reliability / _classification
|
||||
// per row. Falling back to fresh computation keeps the resource usable
|
||||
// outside that path (e.g. tests or future callers).
|
||||
$updatedAt = $this->_updated_at
|
||||
?? ($this->price_effective_at ? Carbon::parse($this->price_effective_at) : null);
|
||||
$reliability = $this->_reliability ?? PriceReliability::fromUpdatedAt($updatedAt);
|
||||
$classification = $this->_classification ?? PriceClassification::fromUpdatedAt($updatedAt);
|
||||
|
||||
return [
|
||||
'station_id' => $this->node_id,
|
||||
@@ -32,11 +37,9 @@ class StationResource extends JsonResource
|
||||
'open_today' => $this->openTodayPayload(),
|
||||
'price_pence' => (int) $this->price_pence,
|
||||
'price' => round((int) $this->price_pence / 100, 2),
|
||||
'price_updated_at' => $this->price_effective_at
|
||||
? Carbon::parse($this->price_effective_at)->toISOString()
|
||||
: null,
|
||||
'price_classification' => PriceClassification::fromUpdatedAt($updatedAt)->value,
|
||||
'price_classification_label' => PriceClassification::fromUpdatedAt($updatedAt)->label(),
|
||||
'price_updated_at' => $updatedAt?->toISOString(),
|
||||
'price_classification' => $classification->value,
|
||||
'price_classification_label' => $classification->label(),
|
||||
'reliability' => $reliability->value,
|
||||
'reliability_label' => $reliability->label(),
|
||||
];
|
||||
|
||||
@@ -5,15 +5,15 @@ namespace App\Jobs;
|
||||
use App\Models\NotificationLog;
|
||||
use App\Models\User;
|
||||
use App\Models\UserNotificationPreference;
|
||||
use App\Notifications\FuelPriceAlert;
|
||||
use App\Services\PlanFeatures;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
|
||||
/**
|
||||
* Resolves allowed notification channels for a user and trigger, sends
|
||||
* notifications, and logs every outcome (sent, daily_limit, tier_restricted).
|
||||
*
|
||||
* Actual sending is stubbed until FuelPriceAlert notification class exists.
|
||||
* Resolves allowed notification channels for a user and trigger, dispatches
|
||||
* the FuelPriceAlert notification (which fans out to email + push + WhatsApp +
|
||||
* SMS), and logs every outcome (sent, daily_limit, tier_restricted).
|
||||
*/
|
||||
final class DispatchUserNotificationJob implements ShouldQueue
|
||||
{
|
||||
@@ -38,9 +38,21 @@ final class DispatchUserNotificationJob implements ShouldQueue
|
||||
// Step 3: channels that pass tier + user-pref + daily-limit checks
|
||||
$allowed = $features->channelsFor($this->triggerType);
|
||||
|
||||
// Step 4: send and log sent notifications
|
||||
// Step 4: dispatch the multi-channel notification — Laravel fans out
|
||||
// to mail / OneSignal / Vonage WhatsApp / Vonage SMS based on via().
|
||||
if ($allowed !== []) {
|
||||
$this->user->notify(new FuelPriceAlert(
|
||||
$this->triggerType,
|
||||
$this->fuelType,
|
||||
$this->price,
|
||||
$allowed,
|
||||
));
|
||||
}
|
||||
|
||||
// Step 5: log a sent entry per allowed channel. The notify() call
|
||||
// above queues per-channel sends; per-channel HTTP outcomes are
|
||||
// captured in api_logs by the channel adapters themselves.
|
||||
foreach ($allowed as $channel) {
|
||||
// TODO: $this->user->notify(new FuelPriceAlert($this->triggerType, $this->fuelType, $this->price));
|
||||
$this->log($channel, sent: true);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,16 +2,26 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Events\PricesUpdatedEvent;
|
||||
use App\Services\FuelPriceService;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
/**
|
||||
* Background full station refresh + price poll, dispatched from the admin
|
||||
* "Trigger Full Poll" button. Mirrors the `fuel:poll --full` command but
|
||||
* calls the service directly so typed exceptions surface to the queue's
|
||||
* failed-job handler instead of being swallowed by Artisan output buffering.
|
||||
*/
|
||||
class PollFuelPricesJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function handle(): void
|
||||
public function handle(FuelPriceService $service): void
|
||||
{
|
||||
Artisan::call('fuel:poll', ['--full' => true]);
|
||||
$service->refreshStations();
|
||||
$inserted = $service->pollPrices();
|
||||
|
||||
PricesUpdatedEvent::dispatch($inserted, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,18 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Plan;
|
||||
use App\Models\User;
|
||||
use App\Models\UserNotificationPreference;
|
||||
use App\Services\PlanFeatures;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
|
||||
/**
|
||||
* Fan-out job for scheduled WhatsApp updates (morning / evening).
|
||||
* Finds all eligible users and dispatches DispatchUserNotificationJob per user.
|
||||
* Dispatches one DispatchUserNotificationJob per eligible user so each
|
||||
* user's send is its own queueable unit (independent retry, no shared
|
||||
* failure mode across the cohort).
|
||||
*
|
||||
* Scheduled at 07:30 (morning) and 18:00 (evening) via routes/console.php.
|
||||
*/
|
||||
@@ -28,37 +30,24 @@ final class SendScheduledWhatsAppJob implements ShouldQueue
|
||||
{
|
||||
$triggerType = $this->period === 'morning' ? 'scheduled_morning' : 'scheduled_evening';
|
||||
|
||||
// Plans that allow scheduled WhatsApp updates
|
||||
$eligiblePlanNames = Plan::where('active', true)
|
||||
->get()
|
||||
->filter(fn (Plan $plan): bool => ($plan->features['whatsapp']['scheduled_updates'] ?? 0) > 0)
|
||||
->pluck('name')
|
||||
->all();
|
||||
|
||||
if (empty($eligiblePlanNames)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Users who have whatsapp preference enabled
|
||||
// Candidates: users who have explicitly opted in to WhatsApp.
|
||||
// Per-user tier + daily-limit + scheduled-updates checks happen via
|
||||
// canSendNow('whatsapp'); that single call covers tier eligibility
|
||||
// (canUseChannel) AND today's notification_log count.
|
||||
$userIds = UserNotificationPreference::where('channel', 'whatsapp')
|
||||
->where('enabled', true)
|
||||
->distinct()
|
||||
->pluck('user_id');
|
||||
|
||||
User::whereIn('id', $userIds)
|
||||
->each(function (User $user) use ($triggerType, $eligiblePlanNames): void {
|
||||
$features = PlanFeatures::for($user);
|
||||
->chunkById(500, function (Collection $users) use ($triggerType): void {
|
||||
foreach ($users as $user) {
|
||||
if (! PlanFeatures::for($user)->canSendNow('whatsapp')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if their tier isn't eligible or daily limit is hit
|
||||
if (! in_array($features->tier(), $eligiblePlanNames, strict: true)) {
|
||||
return;
|
||||
DispatchUserNotificationJob::dispatch($user, $triggerType, fuelType: 'all');
|
||||
}
|
||||
|
||||
if (! $features->canSendNow('whatsapp')) {
|
||||
return;
|
||||
}
|
||||
|
||||
DispatchUserNotificationJob::dispatch($user, $triggerType, fuelType: 'all');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Jobs\SendPaymentFailedReminderJob;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\User;
|
||||
use App\Models\UserNotificationPreference;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -28,15 +29,27 @@ final class HandleStripeWebhook
|
||||
|
||||
match ($type) {
|
||||
'customer.subscription.created',
|
||||
'customer.subscription.updated' => $this->bustPlanCache($user),
|
||||
'customer.subscription.deleted' => $this->handleSubscriptionDeleted($user),
|
||||
'customer.subscription.updated' => $this->handleSubscriptionUpserted($user, $event->payload['data']['object'] ?? []),
|
||||
'customer.subscription.deleted' => $this->handleSubscriptionDeleted($user, $event->payload['data']['object'] ?? []),
|
||||
'invoice.payment_succeeded' => $this->handlePaymentSucceeded($user),
|
||||
'invoice.payment_failed' => $this->handlePaymentFailed($user),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function handleSubscriptionDeleted(User $user): void
|
||||
/**
|
||||
* @param array<string, mixed> $stripeSubscription
|
||||
*/
|
||||
private function handleSubscriptionUpserted(User $user, array $stripeSubscription): void
|
||||
{
|
||||
$this->syncPeriodFromStripePayload($stripeSubscription);
|
||||
$this->bustPlanCache($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $stripeSubscription
|
||||
*/
|
||||
private function handleSubscriptionDeleted(User $user, array $stripeSubscription): void
|
||||
{
|
||||
UserNotificationPreference::query()
|
||||
->where('user_id', $user->id)
|
||||
@@ -45,9 +58,52 @@ final class HandleStripeWebhook
|
||||
|
||||
$user->forceFill(['grace_period_until' => null])->save();
|
||||
|
||||
$this->syncPeriodFromStripePayload($stripeSubscription);
|
||||
$this->bustPlanCache($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror current_period_start / current_period_end from a Stripe subscription
|
||||
* payload onto our local row so we don't depend on Stripe at read time.
|
||||
*
|
||||
* Stripe API ≤ 2024-11-19 places the period fields at the root of the
|
||||
* subscription; later versions move them to items.data[0]. We accept either.
|
||||
*
|
||||
* @param array<string, mixed> $stripeSubscription
|
||||
*/
|
||||
private function syncPeriodFromStripePayload(array $stripeSubscription): void
|
||||
{
|
||||
$stripeId = $stripeSubscription['id'] ?? null;
|
||||
|
||||
if ($stripeId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$subscription = Subscription::where('stripe_id', $stripeId)->first();
|
||||
|
||||
if ($subscription === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$start = $stripeSubscription['current_period_start']
|
||||
?? ($stripeSubscription['items']['data'][0]['current_period_start'] ?? null);
|
||||
|
||||
$end = $stripeSubscription['current_period_end']
|
||||
?? ($stripeSubscription['items']['data'][0]['current_period_end'] ?? null);
|
||||
|
||||
$subscription->stripe_data = $stripeSubscription;
|
||||
|
||||
if ($start !== null) {
|
||||
$subscription->current_period_start = Carbon::createFromTimestamp($start);
|
||||
}
|
||||
|
||||
if ($end !== null) {
|
||||
$subscription->current_period_end = Carbon::createFromTimestamp($end);
|
||||
}
|
||||
|
||||
$subscription->save();
|
||||
}
|
||||
|
||||
private function handlePaymentSucceeded(User $user): void
|
||||
{
|
||||
$user->forceFill(['grace_period_until' => null])->save();
|
||||
@@ -73,6 +129,8 @@ final class HandleStripeWebhook
|
||||
|
||||
private function bustPlanCache(User $user): void
|
||||
{
|
||||
Cache::tags(['plans'])->forget("plan_for_user_{$user->id}");
|
||||
$tag = Cache::tags(['plans']);
|
||||
$tag->forget("plan_for_user_{$user->id}");
|
||||
$tag->forget("plan_cadence_for_user_{$user->id}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,50 @@ class Plan extends Model
|
||||
}
|
||||
);
|
||||
|
||||
if ($planId !== null) {
|
||||
$plan = static::find($planId);
|
||||
return static::findOrFail($planId);
|
||||
}
|
||||
|
||||
if ($plan !== null) {
|
||||
return $plan;
|
||||
/**
|
||||
* Resolve the active subscription cadence for a user.
|
||||
* Returns 'monthly' | 'annual', or null if the user has no paid subscription.
|
||||
*/
|
||||
public static function resolveCadenceForUser(User $user): ?string
|
||||
{
|
||||
$cache = Cache::supportsTags() ? Cache::tags(['plans']) : Cache::store();
|
||||
|
||||
return $cache->remember(
|
||||
"plan_cadence_for_user_{$user->id}",
|
||||
3600,
|
||||
function () use ($user): ?string {
|
||||
if (! method_exists($user, 'subscriptions')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$priceId = $user->subscriptions()->active()->value('stripe_price');
|
||||
|
||||
if ($priceId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$plan = static::where('stripe_price_id_monthly', $priceId)
|
||||
->orWhere('stripe_price_id_annual', $priceId)
|
||||
->first();
|
||||
|
||||
if ($plan === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($plan->stripe_price_id_monthly === $priceId) {
|
||||
return 'monthly';
|
||||
}
|
||||
|
||||
if ($plan->stripe_price_id_annual === $priceId) {
|
||||
return 'annual';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
],
|
||||
]);
|
||||
);
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
@@ -92,7 +126,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',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
#[Fillable(['predicted_for', 'source', 'direction', 'confidence', 'reasoning', 'generated_at'])]
|
||||
class PricePrediction extends Model
|
||||
@@ -39,11 +38,17 @@ class PricePrediction extends Model
|
||||
*/
|
||||
public function scopeBestFirst(Builder $query): Builder
|
||||
{
|
||||
$priority = implode(', ', array_map(
|
||||
fn (string $v) => "'$v'",
|
||||
[PredictionSource::LlmWithContext->value, PredictionSource::Llm->value, PredictionSource::Ewma->value],
|
||||
));
|
||||
$priority = [
|
||||
PredictionSource::LlmWithContext->value,
|
||||
PredictionSource::Llm->value,
|
||||
PredictionSource::Ewma->value,
|
||||
];
|
||||
|
||||
return $query->orderByRaw("FIELD(source, $priority)");
|
||||
$cases = '';
|
||||
foreach ($priority as $rank => $source) {
|
||||
$cases .= " WHEN '$source' THEN $rank";
|
||||
}
|
||||
|
||||
return $query->orderByRaw("CASE source$cases ELSE ".count($priority).' END');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,15 +10,17 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
#[Fillable(['station_id', 'fuel_type', 'price_pence', 'price_effective_at', 'price_reported_at', 'recorded_at'])]
|
||||
class StationPriceArchive extends Model
|
||||
{
|
||||
protected $table = 'station_prices_archive';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'fuel_type' => FuelType::class,
|
||||
'fuel_type' => FuelType::class,
|
||||
'price_effective_at' => 'datetime',
|
||||
'price_reported_at' => 'datetime',
|
||||
'recorded_at' => 'datetime',
|
||||
'price_reported_at' => 'datetime',
|
||||
'recorded_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,9 @@ class StationPriceCurrent extends Model
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $primaryKey = null;
|
||||
protected $primaryKey = 'station_id';
|
||||
|
||||
protected $keyType = 'string';
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
|
||||
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',
|
||||
];
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\FuelType;
|
||||
use Database\Factories\UserNotificationPreferenceFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -44,6 +45,7 @@ class UserNotificationPreference extends Model
|
||||
{
|
||||
return [
|
||||
'enabled' => 'boolean',
|
||||
'fuel_type' => FuelType::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
70
app/Notifications/Channels/OneSignalChannel.php
Normal file
70
app/Notifications/Channels/OneSignalChannel.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications\Channels;
|
||||
|
||||
use App\Services\ApiLogger;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Sends push notifications via the OneSignal REST API.
|
||||
*
|
||||
* Notifications targeting this channel must implement `toOneSignal($notifiable)`
|
||||
* returning ['heading' => string, 'message' => string] (or `null` to skip).
|
||||
*
|
||||
* No-ops when ONESIGNAL_APP_ID/API_KEY are unset, when the notifiable user has
|
||||
* no `push_token`, or when toOneSignal() returns null. Each call is logged to
|
||||
* api_logs through ApiLogger.
|
||||
*/
|
||||
final class OneSignalChannel
|
||||
{
|
||||
public const string NAME = 'onesignal';
|
||||
|
||||
public function __construct(
|
||||
private readonly ApiLogger $apiLogger,
|
||||
) {}
|
||||
|
||||
public function send(mixed $notifiable, Notification $notification): void
|
||||
{
|
||||
$appId = config('services.onesignal.app_id');
|
||||
$apiKey = config('services.onesignal.api_key');
|
||||
|
||||
if ($appId === null || $apiKey === null) {
|
||||
Log::info('OneSignalChannel: skipped — credentials not configured');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$playerId = $notifiable->push_token ?? null;
|
||||
|
||||
if ($playerId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = method_exists($notification, 'toOneSignal')
|
||||
? $notification->toOneSignal($notifiable)
|
||||
: null;
|
||||
|
||||
if ($payload === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$url = 'https://api.onesignal.com/notifications';
|
||||
|
||||
try {
|
||||
$this->apiLogger->send(self::NAME, 'POST', $url, fn () => Http::timeout(10)
|
||||
->withToken($apiKey)
|
||||
->acceptJson()
|
||||
->post($url, [
|
||||
'app_id' => $appId,
|
||||
'include_player_ids' => [$playerId],
|
||||
'headings' => ['en' => $payload['heading'] ?? 'Fuel Alert'],
|
||||
'contents' => ['en' => $payload['message'] ?? ''],
|
||||
]));
|
||||
} catch (Throwable $e) {
|
||||
Log::error('OneSignalChannel: send failed', ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
71
app/Notifications/Channels/VonageSmsChannel.php
Normal file
71
app/Notifications/Channels/VonageSmsChannel.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications\Channels;
|
||||
|
||||
use App\Services\ApiLogger;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Sends SMS messages via the Vonage SMS API (raw HTTP — no SDK).
|
||||
*
|
||||
* Notifications targeting this channel must implement `toVonageSms($notifiable)`
|
||||
* returning a string body (or `null` to skip).
|
||||
*
|
||||
* No-ops when VONAGE_KEY/SECRET are unset or when the notifiable user has no
|
||||
* phone number on `whatsapp_number` (the same verified column doubles as SMS
|
||||
* destination).
|
||||
*/
|
||||
final class VonageSmsChannel
|
||||
{
|
||||
public const string NAME = 'vonage-sms';
|
||||
|
||||
public function __construct(
|
||||
private readonly ApiLogger $apiLogger,
|
||||
) {}
|
||||
|
||||
public function send(mixed $notifiable, Notification $notification): void
|
||||
{
|
||||
$key = config('services.vonage.key');
|
||||
$secret = config('services.vonage.secret');
|
||||
$from = config('services.vonage.sms_from', 'FuelAlert');
|
||||
|
||||
if ($key === null || $secret === null) {
|
||||
Log::info('VonageSmsChannel: skipped — credentials not configured');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$to = $notifiable->whatsapp_number ?? null;
|
||||
|
||||
if ($to === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$body = method_exists($notification, 'toVonageSms')
|
||||
? $notification->toVonageSms($notifiable)
|
||||
: null;
|
||||
|
||||
if ($body === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$url = 'https://rest.nexmo.com/sms/json';
|
||||
|
||||
try {
|
||||
$this->apiLogger->send(self::NAME, 'POST', $url, fn () => Http::timeout(10)
|
||||
->asForm()
|
||||
->post($url, [
|
||||
'api_key' => $key,
|
||||
'api_secret' => $secret,
|
||||
'from' => $from,
|
||||
'to' => ltrim($to, '+'),
|
||||
'text' => $body,
|
||||
]));
|
||||
} catch (Throwable $e) {
|
||||
Log::error('VonageSmsChannel: send failed', ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
73
app/Notifications/Channels/VonageWhatsAppChannel.php
Normal file
73
app/Notifications/Channels/VonageWhatsAppChannel.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications\Channels;
|
||||
|
||||
use App\Services\ApiLogger;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Sends WhatsApp messages via the Vonage Messages API (raw HTTP — no SDK).
|
||||
*
|
||||
* Notifications targeting this channel must implement `toVonageWhatsApp($notifiable)`
|
||||
* returning a string body (or `null` to skip).
|
||||
*
|
||||
* No-ops when VONAGE_KEY/SECRET/whatsapp_from are unset, when the user is not
|
||||
* verified (no whatsapp_verified_at), when whatsapp_number is missing, or when
|
||||
* the notification returns null.
|
||||
*/
|
||||
final class VonageWhatsAppChannel
|
||||
{
|
||||
public const string NAME = 'vonage-whatsapp';
|
||||
|
||||
public function __construct(
|
||||
private readonly ApiLogger $apiLogger,
|
||||
) {}
|
||||
|
||||
public function send(mixed $notifiable, Notification $notification): void
|
||||
{
|
||||
$key = config('services.vonage.key');
|
||||
$secret = config('services.vonage.secret');
|
||||
$from = config('services.vonage.whatsapp_from');
|
||||
|
||||
if ($key === null || $secret === null || $from === null) {
|
||||
Log::info('VonageWhatsAppChannel: skipped — credentials not configured');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$to = $notifiable->whatsapp_number ?? null;
|
||||
$verified = $notifiable->whatsapp_verified_at ?? null;
|
||||
|
||||
if ($to === null || $verified === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$body = method_exists($notification, 'toVonageWhatsApp')
|
||||
? $notification->toVonageWhatsApp($notifiable)
|
||||
: null;
|
||||
|
||||
if ($body === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$url = 'https://api.nexmo.com/v1/messages';
|
||||
|
||||
try {
|
||||
$this->apiLogger->send(self::NAME, 'POST', $url, fn () => Http::timeout(10)
|
||||
->withBasicAuth($key, $secret)
|
||||
->acceptJson()
|
||||
->post($url, [
|
||||
'message_type' => 'text',
|
||||
'channel' => 'whatsapp',
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'text' => $body,
|
||||
]));
|
||||
} catch (Throwable $e) {
|
||||
Log::error('VonageWhatsAppChannel: send failed', ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
116
app/Notifications/FuelPriceAlert.php
Normal file
116
app/Notifications/FuelPriceAlert.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Notifications\Channels\OneSignalChannel;
|
||||
use App\Notifications\Channels\VonageSmsChannel;
|
||||
use App\Notifications\Channels\VonageWhatsAppChannel;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
/**
|
||||
* Multi-channel fuel price alert. The dispatching job already filters channels
|
||||
* by tier, user preference, and daily limit — `via()` returns exactly that
|
||||
* filtered set. The notification is queued so individual channel sends don't
|
||||
* block the dispatch job.
|
||||
*
|
||||
* Channel keys map to:
|
||||
* 'email' → mail (Laravel built-in)
|
||||
* 'push' → OneSignalChannel
|
||||
* 'whatsapp' → VonageWhatsAppChannel
|
||||
* 'sms' → VonageSmsChannel
|
||||
*/
|
||||
final class FuelPriceAlert extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/** @var array<string, class-string> */
|
||||
private const array CHANNEL_MAP = [
|
||||
'email' => 'mail',
|
||||
'push' => OneSignalChannel::class,
|
||||
'whatsapp' => VonageWhatsAppChannel::class,
|
||||
'sms' => VonageSmsChannel::class,
|
||||
];
|
||||
|
||||
/** @param string[] $channels Pre-filtered channel keys ('email', 'push', 'whatsapp', 'sms') */
|
||||
public function __construct(
|
||||
public readonly string $triggerType,
|
||||
public readonly string $fuelType,
|
||||
public readonly ?float $price,
|
||||
public readonly array $channels,
|
||||
) {
|
||||
$this->onQueue('notifications');
|
||||
}
|
||||
|
||||
/** @return array<int, string> */
|
||||
public function via(mixed $notifiable): array
|
||||
{
|
||||
return array_values(array_map(
|
||||
fn (string $key) => self::CHANNEL_MAP[$key] ?? $key,
|
||||
$this->channels,
|
||||
));
|
||||
}
|
||||
|
||||
public function toMail(mixed $notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage)
|
||||
->subject($this->headline())
|
||||
->greeting("Hi {$notifiable->name},")
|
||||
->line($this->body())
|
||||
->action('Open FuelAlert', route('dashboard'))
|
||||
->line('You can change which alerts you receive in your account settings.');
|
||||
}
|
||||
|
||||
/** @return array{heading: string, message: string} */
|
||||
public function toOneSignal(mixed $notifiable): array
|
||||
{
|
||||
return [
|
||||
'heading' => $this->headline(),
|
||||
'message' => $this->body(),
|
||||
];
|
||||
}
|
||||
|
||||
public function toVonageWhatsApp(mixed $notifiable): string
|
||||
{
|
||||
return $this->shortBody();
|
||||
}
|
||||
|
||||
public function toVonageSms(mixed $notifiable): string
|
||||
{
|
||||
return $this->shortBody();
|
||||
}
|
||||
|
||||
private function headline(): string
|
||||
{
|
||||
return match ($this->triggerType) {
|
||||
'price_threshold' => 'Price hit your threshold',
|
||||
'score_change' => 'Fill-up signal changed',
|
||||
'scheduled_morning' => 'Morning fuel update',
|
||||
'scheduled_evening' => 'Evening fuel update',
|
||||
default => 'Fuel alert',
|
||||
};
|
||||
}
|
||||
|
||||
private function body(): string
|
||||
{
|
||||
$fuel = strtoupper($this->fuelType);
|
||||
$price = $this->price !== null ? number_format($this->price, 1).'p' : null;
|
||||
|
||||
return match ($this->triggerType) {
|
||||
'price_threshold' => $price !== null
|
||||
? "{$fuel} dropped to {$price} near you."
|
||||
: "{$fuel} hit your alert threshold.",
|
||||
'score_change' => "The {$fuel} fill-up score has changed near you.",
|
||||
'scheduled_morning', 'scheduled_evening' => "Latest {$fuel} update is ready in your dashboard.",
|
||||
default => "There's a new {$fuel} alert for you.",
|
||||
};
|
||||
}
|
||||
|
||||
/** SMS/WhatsApp must stay short — single line, ~160 chars max. */
|
||||
private function shortBody(): string
|
||||
{
|
||||
return $this->headline().': '.$this->body();
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Listeners\HandleStripeWebhook;
|
||||
use App\Models\Subscription;
|
||||
use App\Services\ApiLogger;
|
||||
use App\Services\LlmPrediction\AnthropicPredictionProvider;
|
||||
use App\Services\LlmPrediction\GeminiPredictionProvider;
|
||||
@@ -14,6 +15,7 @@ use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Laravel\Cashier\Cashier;
|
||||
use Laravel\Cashier\Events\WebhookReceived;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
@@ -41,6 +43,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
$this->configureDefaults();
|
||||
|
||||
Cashier::useSubscriptionModel(Subscription::class);
|
||||
|
||||
Event::listen(WebhookReceived::class, HandleStripeWebhook::class);
|
||||
}
|
||||
|
||||
@@ -55,13 +59,6 @@ class AppServiceProvider extends ServiceProvider
|
||||
app()->isProduction(),
|
||||
);
|
||||
|
||||
// SQLite lacks GREATEST/LEAST scalar functions — register them for tests.
|
||||
if (DB::connection()->getDriverName() === 'sqlite') {
|
||||
$pdo = DB::connection()->getPdo();
|
||||
$pdo->sqliteCreateFunction('GREATEST', fn (...$args) => max($args), -1);
|
||||
$pdo->sqliteCreateFunction('LEAST', fn (...$args) => min($args), -1);
|
||||
}
|
||||
|
||||
Password::defaults(fn (): ?Password => app()->isProduction()
|
||||
? Password::min(12)
|
||||
->mixedCase()
|
||||
|
||||
@@ -12,8 +12,6 @@ use Illuminate\Support\Facades\Log;
|
||||
|
||||
final class BrentPricePredictor
|
||||
{
|
||||
private const float EWMA_ALPHA = 0.3;
|
||||
|
||||
private const float EWMA_THRESHOLD_PCT = 1.5;
|
||||
|
||||
private const int EWMA_MAX_CONFIDENCE = 65;
|
||||
@@ -33,8 +31,10 @@ final class BrentPricePredictor
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate EWMA + LLM predictions, store them, and flag the latest
|
||||
* brent_prices row as having a prediction generated.
|
||||
* Try LLM first; persist EWMA only as a fallback when the LLM provider
|
||||
* returns null. The downstream OilSignal already prefers LLM
|
||||
* (llm_with_context > llm > ewma), so writing both rows on every run is
|
||||
* dead weight 95% of the time. EWMA still acts as the safety net.
|
||||
*/
|
||||
public function generatePrediction(): ?PricePrediction
|
||||
{
|
||||
@@ -48,25 +48,23 @@ final class BrentPricePredictor
|
||||
return null;
|
||||
}
|
||||
|
||||
$ewma = $this->generateEwmaPrediction($prices);
|
||||
|
||||
if ($ewma !== null) {
|
||||
PricePrediction::create($ewma->toArray());
|
||||
}
|
||||
|
||||
$llm = $this->provider->predict($prices);
|
||||
|
||||
if ($llm !== null) {
|
||||
PricePrediction::create($llm->toArray());
|
||||
$prices->first()->forceFill(['prediction_generated_at' => now()])->save();
|
||||
|
||||
return $llm;
|
||||
}
|
||||
|
||||
$result = $llm ?? $ewma;
|
||||
$ewma = $this->generateEwmaPrediction($prices);
|
||||
|
||||
if ($result !== null) {
|
||||
if ($ewma !== null) {
|
||||
PricePrediction::create($ewma->toArray());
|
||||
$prices->first()->forceFill(['prediction_generated_at' => now()])->save();
|
||||
}
|
||||
|
||||
return $result;
|
||||
return $ewma;
|
||||
}
|
||||
|
||||
public function generateEwmaPrediction(Collection $prices): ?PricePrediction
|
||||
@@ -77,8 +75,8 @@ final class BrentPricePredictor
|
||||
return null;
|
||||
}
|
||||
|
||||
$ewma3 = $this->computeEwma(array_slice($chronological, -3));
|
||||
$ewma7 = $this->computeEwma(array_slice($chronological, -7));
|
||||
$ewma3 = Ewma::compute(array_slice($chronological, -3));
|
||||
$ewma7 = Ewma::compute(array_slice($chronological, -7));
|
||||
|
||||
$changePct = (($ewma3 - $ewma7) / $ewma7) * 100;
|
||||
|
||||
@@ -112,20 +110,6 @@ final class BrentPricePredictor
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param float[] $prices Chronological (oldest first).
|
||||
*/
|
||||
private function computeEwma(array $prices): float
|
||||
{
|
||||
$ema = $prices[0];
|
||||
|
||||
foreach (array_slice($prices, 1) as $price) {
|
||||
$ema = self::EWMA_ALPHA * $price + (1 - self::EWMA_ALPHA) * $ema;
|
||||
}
|
||||
|
||||
return round($ema, 4);
|
||||
}
|
||||
|
||||
private function ewmaConfidence(float $changePct): int
|
||||
{
|
||||
$scaled = min($changePct / 5.0, 1.0) * self::EWMA_MAX_CONFIDENCE;
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
namespace App\Services\BrentPriceSources;
|
||||
|
||||
use App\Services\ApiLogger;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
final class EiaBrentPriceSource
|
||||
@@ -14,12 +15,16 @@ final class EiaBrentPriceSource
|
||||
public function __construct(private readonly ApiLogger $apiLogger) {}
|
||||
|
||||
/**
|
||||
* @return array{date: string, price_usd: float}[]|null
|
||||
* @return array{date: string, price_usd: float}[]|null null only when the response carried no usable rows
|
||||
*
|
||||
* @throws BrentPriceFetchException on network failure or non-2xx response after retries
|
||||
*/
|
||||
public function fetch(): ?array
|
||||
{
|
||||
try {
|
||||
$response = $this->apiLogger->send('eia', 'GET', self::URL, fn () => Http::timeout(10)
|
||||
$response = $this->apiLogger->send('eia', 'GET', self::URL, fn () => Http::timeout(30)
|
||||
->retry(3, 200, fn (Throwable $e) => $this->shouldRetry($e))
|
||||
->throw()
|
||||
->get(self::URL, [
|
||||
'api_key' => config('services.eia.api_key'),
|
||||
'frequency' => 'daily',
|
||||
@@ -29,32 +34,26 @@ final class EiaBrentPriceSource
|
||||
'sort[0][direction]' => 'desc',
|
||||
'length' => 30,
|
||||
]));
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('EiaBrentPriceSource: request failed', ['status' => $response->status()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$rows = collect($response->json('response.data') ?? [])
|
||||
->filter(fn (array $row) => ($row['value'] ?? '.') !== '.')
|
||||
->map(fn (array $row) => [
|
||||
'date' => $row['period'],
|
||||
'price_usd' => (float) $row['value'],
|
||||
])
|
||||
->all();
|
||||
|
||||
if ($rows === []) {
|
||||
Log::warning('EiaBrentPriceSource: no valid observations returned');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $rows;
|
||||
} catch (Throwable $e) {
|
||||
Log::error('EiaBrentPriceSource: fetch failed', ['error' => $e->getMessage()]);
|
||||
|
||||
return null;
|
||||
} catch (ConnectionException $e) {
|
||||
throw new BrentPriceFetchException("EIA connection failed: {$e->getMessage()}", previous: $e);
|
||||
} catch (RequestException $e) {
|
||||
throw new BrentPriceFetchException("EIA returned HTTP {$e->response->status()}", previous: $e);
|
||||
}
|
||||
|
||||
$rows = collect($response->json('response.data') ?? [])
|
||||
->filter(fn (array $row) => ($row['value'] ?? '.') !== '.')
|
||||
->map(fn (array $row) => [
|
||||
'date' => $row['period'],
|
||||
'price_usd' => (float) $row['value'],
|
||||
])
|
||||
->all();
|
||||
|
||||
return $rows === [] ? null : $rows;
|
||||
}
|
||||
|
||||
private function shouldRetry(Throwable $e): bool
|
||||
{
|
||||
return $e instanceof ConnectionException
|
||||
|| ($e instanceof RequestException && $e->response->serverError());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
namespace App\Services\BrentPriceSources;
|
||||
|
||||
use App\Services\ApiLogger;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
final class FredBrentPriceSource
|
||||
@@ -14,12 +15,16 @@ final class FredBrentPriceSource
|
||||
public function __construct(private readonly ApiLogger $apiLogger) {}
|
||||
|
||||
/**
|
||||
* @return array{date: string, price_usd: float}[]|null
|
||||
* @return array{date: string, price_usd: float}[]|null null only when the response carried no usable rows
|
||||
*
|
||||
* @throws BrentPriceFetchException on network failure or non-2xx response after retries
|
||||
*/
|
||||
public function fetch(): ?array
|
||||
{
|
||||
try {
|
||||
$response = $this->apiLogger->send('fred', 'GET', self::URL, fn () => Http::timeout(10)
|
||||
$response = $this->apiLogger->send('fred', 'GET', self::URL, fn () => Http::timeout(30)
|
||||
->retry(3, 200, fn (Throwable $e) => $this->shouldRetry($e))
|
||||
->throw()
|
||||
->get(self::URL, [
|
||||
'series_id' => 'DCOILBRENTEU',
|
||||
'api_key' => config('services.fred.api_key'),
|
||||
@@ -27,32 +32,26 @@ final class FredBrentPriceSource
|
||||
'limit' => 30,
|
||||
'file_type' => 'json',
|
||||
]));
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('FredBrentPriceSource: request failed', ['status' => $response->status()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$rows = collect($response->json('observations') ?? [])
|
||||
->filter(fn (array $obs) => $obs['value'] !== '.')
|
||||
->map(fn (array $obs) => [
|
||||
'date' => $obs['date'],
|
||||
'price_usd' => (float) $obs['value'],
|
||||
])
|
||||
->all();
|
||||
|
||||
if ($rows === []) {
|
||||
Log::warning('FredBrentPriceSource: no valid observations returned');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $rows;
|
||||
} catch (Throwable $e) {
|
||||
Log::error('FredBrentPriceSource: fetch failed', ['error' => $e->getMessage()]);
|
||||
|
||||
return null;
|
||||
} catch (ConnectionException $e) {
|
||||
throw new BrentPriceFetchException("FRED connection failed: {$e->getMessage()}", previous: $e);
|
||||
} catch (RequestException $e) {
|
||||
throw new BrentPriceFetchException("FRED returned HTTP {$e->response->status()}", previous: $e);
|
||||
}
|
||||
|
||||
$rows = collect($response->json('observations') ?? [])
|
||||
->filter(fn (array $obs) => $obs['value'] !== '.')
|
||||
->map(fn (array $obs) => [
|
||||
'date' => $obs['date'],
|
||||
'price_usd' => (float) $obs['value'],
|
||||
])
|
||||
->all();
|
||||
|
||||
return $rows === [] ? null : $rows;
|
||||
}
|
||||
|
||||
private function shouldRetry(Throwable $e): bool
|
||||
{
|
||||
return $e instanceof ConnectionException
|
||||
|| ($e instanceof RequestException && $e->response->serverError());
|
||||
}
|
||||
}
|
||||
|
||||
25
app/Services/Ewma.php
Normal file
25
app/Services/Ewma.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
/**
|
||||
* Exponentially-weighted moving average. Pure function — used by
|
||||
* BrentPricePredictor for the EWMA fallback prediction and by
|
||||
* AnthropicPredictionProvider to enrich the basic-flow prompt.
|
||||
*/
|
||||
final class Ewma
|
||||
{
|
||||
public const float DEFAULT_ALPHA = 0.3;
|
||||
|
||||
/** @param float[] $prices Chronological order (oldest first). */
|
||||
public static function compute(array $prices, float $alpha = self::DEFAULT_ALPHA): float
|
||||
{
|
||||
$ema = $prices[0];
|
||||
|
||||
foreach (array_slice($prices, 1) as $price) {
|
||||
$ema = $alpha * $price + (1 - $alpha) * $ema;
|
||||
}
|
||||
|
||||
return round($ema, 4);
|
||||
}
|
||||
}
|
||||
@@ -67,57 +67,15 @@ class FuelPriceService
|
||||
*/
|
||||
public function pollPrices(): int
|
||||
{
|
||||
$token = $this->getAccessToken();
|
||||
$inserted = 0;
|
||||
$batch = 1;
|
||||
$pollStartedAt = now();
|
||||
$since = Cache::get(self::LAST_PRICE_POLL_CACHE_KEY);
|
||||
$completedCleanly = false;
|
||||
$sinceCarbon = $since instanceof CarbonInterface ? $since : null;
|
||||
|
||||
do {
|
||||
try {
|
||||
$baseUrl = config('services.fuel_finder.base_url').'/pfs/fuel-prices';
|
||||
$params = ['batch-number' => $batch];
|
||||
|
||||
if ($since instanceof CarbonInterface) {
|
||||
$params['effective-start-timestamp'] = $since->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
$logUrl = $baseUrl.'?'.http_build_query($params);
|
||||
$response = $this->apiLogger->send('fuel_finder', 'GET', $logUrl, fn () => Http::timeout(30)
|
||||
->withToken($token)
|
||||
->get($baseUrl, $params));
|
||||
|
||||
if ($response->notFound()) {
|
||||
$completedCleanly = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('FuelPriceService: price batch returned error', [
|
||||
'batch' => $batch,
|
||||
'status' => $response->status(),
|
||||
]);
|
||||
break;
|
||||
}
|
||||
|
||||
$stations = $response->json() ?? [];
|
||||
} catch (Throwable $e) {
|
||||
Log::error('FuelPriceService: price batch fetch failed', [
|
||||
'batch' => $batch,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
break;
|
||||
}
|
||||
|
||||
if (empty($stations)) {
|
||||
$completedCleanly = true;
|
||||
break;
|
||||
}
|
||||
|
||||
$inserted += $this->processPriceBatch($stations);
|
||||
$batch++;
|
||||
} while (true);
|
||||
[$inserted, $completedCleanly] = $this->iterateBatches(
|
||||
'/pfs/fuel-prices',
|
||||
$sinceCarbon,
|
||||
fn (array $stations): int => $this->processPriceBatch($stations),
|
||||
);
|
||||
|
||||
if ($completedCleanly) {
|
||||
Cache::forever(self::LAST_PRICE_POLL_CACHE_KEY, $pollStartedAt);
|
||||
@@ -131,25 +89,53 @@ class FuelPriceService
|
||||
* Called on full daily refresh before pollPrices().
|
||||
*/
|
||||
public function refreshStations(): void
|
||||
{
|
||||
$this->iterateBatches('/pfs', null, function (array $stations): int {
|
||||
$this->upsertStations($stations);
|
||||
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive a paginated fuel-finder endpoint until exhausted, calling
|
||||
* $process on each non-empty batch. Returns the sum of $process return
|
||||
* values plus a flag indicating the loop exited cleanly (404 or empty
|
||||
* body) rather than via an HTTP error or thrown exception. Callers use
|
||||
* the flag to decide whether to update incremental-poll bookkeeping.
|
||||
*
|
||||
* @param callable(array<int, array<string, mixed>>): int $process
|
||||
* @return array{0: int, 1: bool}
|
||||
*/
|
||||
private function iterateBatches(string $endpoint, ?CarbonInterface $since, callable $process): array
|
||||
{
|
||||
$token = $this->getAccessToken();
|
||||
$baseUrl = config('services.fuel_finder.base_url').$endpoint;
|
||||
$total = 0;
|
||||
$batch = 1;
|
||||
$completedCleanly = false;
|
||||
|
||||
do {
|
||||
try {
|
||||
$baseUrl = config('services.fuel_finder.base_url').'/pfs';
|
||||
$params = ['batch-number' => $batch];
|
||||
|
||||
if ($since !== null) {
|
||||
$params['effective-start-timestamp'] = $since->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
$logUrl = $baseUrl.'?'.http_build_query($params);
|
||||
$response = $this->apiLogger->send('fuel_finder', 'GET', $logUrl, fn () => Http::timeout(30)
|
||||
->withToken($token)
|
||||
->get($baseUrl, $params));
|
||||
|
||||
if ($response->notFound()) {
|
||||
break; // No more batches
|
||||
$completedCleanly = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('FuelPriceService: station batch returned error', [
|
||||
Log::error('FuelPriceService: batch returned error', [
|
||||
'endpoint' => $endpoint,
|
||||
'batch' => $batch,
|
||||
'status' => $response->status(),
|
||||
]);
|
||||
@@ -158,7 +144,8 @@ class FuelPriceService
|
||||
|
||||
$stations = $response->json() ?? [];
|
||||
} catch (Throwable $e) {
|
||||
Log::error('FuelPriceService: station batch fetch failed', [
|
||||
Log::error('FuelPriceService: batch fetch failed', [
|
||||
'endpoint' => $endpoint,
|
||||
'batch' => $batch,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
@@ -166,12 +153,15 @@ class FuelPriceService
|
||||
}
|
||||
|
||||
if (empty($stations)) {
|
||||
$completedCleanly = true;
|
||||
break;
|
||||
}
|
||||
|
||||
$this->upsertStations($stations);
|
||||
$total += $process($stations);
|
||||
$batch++;
|
||||
} while (true);
|
||||
|
||||
return [$total, $completedCleanly];
|
||||
}
|
||||
|
||||
/** @param array<int, array<string, mixed>> $apiStations */
|
||||
@@ -209,9 +199,9 @@ class FuelPriceService
|
||||
'postcode' => $data['location']['postcode'],
|
||||
'lat' => $data['location']['latitude'],
|
||||
'lng' => $data['location']['longitude'],
|
||||
'amenities' => self::flattenEnabledFlags($data['amenities'] ?? []),
|
||||
'amenities' => $this->flattenEnabledFlags($data['amenities'] ?? []),
|
||||
'opening_times' => $data['opening_times'] ?? null,
|
||||
'fuel_types' => self::flattenEnabledFlags($data['fuel_types'] ?? []),
|
||||
'fuel_types' => $this->flattenEnabledFlags($data['fuel_types'] ?? []),
|
||||
'last_seen_at' => $now,
|
||||
]);
|
||||
|
||||
@@ -242,7 +232,7 @@ class FuelPriceService
|
||||
* @param array<string, bool>|array<int, string> $flags
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private static function flattenEnabledFlags(array $flags): array
|
||||
private function flattenEnabledFlags(array $flags): array
|
||||
{
|
||||
if ($flags === []) {
|
||||
return [];
|
||||
|
||||
41
app/Services/HaversineQuery.php
Normal file
41
app/Services/HaversineQuery.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
/**
|
||||
* Builds canonical haversine SQL fragments for distance and within-radius
|
||||
* filtering. Centralises the float-clamping (GREATEST/LEAST) and the column
|
||||
* naming convention used across prediction and station search queries.
|
||||
*
|
||||
* Assumes the joined/queried table exposes columns `lat` and `lng`.
|
||||
*/
|
||||
final class HaversineQuery
|
||||
{
|
||||
private const string DISTANCE_KM_SQL =
|
||||
'(6371 * acos(GREATEST(-1.0, LEAST(1.0, '
|
||||
.'cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?)) '
|
||||
.'+ sin(radians(?)) * sin(radians(lat))))))';
|
||||
|
||||
/**
|
||||
* Bare distance-in-km expression. Caller adds aliasing or comparison.
|
||||
*
|
||||
* @return array{0: string, 1: array{float, float, float}}
|
||||
*/
|
||||
public static function distanceKm(float $lat, float $lng): array
|
||||
{
|
||||
return [self::DISTANCE_KM_SQL, [$lat, $lng, $lat]];
|
||||
}
|
||||
|
||||
/**
|
||||
* `<= {km}` predicate suitable for whereRaw. The radius is embedded as a
|
||||
* numeric literal because PDO + SQLite's whereRaw binds floats as strings
|
||||
* by default, which breaks numeric comparison against the haversine
|
||||
* expression. The `float` parameter is type-checked and not user input.
|
||||
*
|
||||
* @return array{0: string, 1: array{float, float, float}}
|
||||
*/
|
||||
public static function withinKm(float $lat, float $lng, float $km): array
|
||||
{
|
||||
return [self::DISTANCE_KM_SQL.' <= '.sprintf('%F', $km), [$lat, $lng, $lat]];
|
||||
}
|
||||
}
|
||||
99
app/Services/LlmPrediction/AbstractLlmPredictionProvider.php
Normal file
99
app/Services/LlmPrediction/AbstractLlmPredictionProvider.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\LlmPrediction;
|
||||
|
||||
use App\Enums\PredictionSource;
|
||||
use App\Enums\TrendDirection;
|
||||
use App\Models\BrentPrice;
|
||||
use App\Models\PricePrediction;
|
||||
use App\Services\ApiLogger;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
abstract class AbstractLlmPredictionProvider implements OilPredictionProvider
|
||||
{
|
||||
protected const int LLM_MAX_CONFIDENCE = 85;
|
||||
|
||||
public function __construct(
|
||||
protected readonly ApiLogger $apiLogger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Default flow: gate on API key, call the provider, normalise the payload
|
||||
* to a PricePrediction. Subclasses with multi-phase flows (e.g. Anthropic
|
||||
* web-search) override `predict()` directly and reuse the helper methods.
|
||||
*/
|
||||
public function predict(Collection $prices): ?PricePrediction
|
||||
{
|
||||
$apiKey = $this->apiKey();
|
||||
|
||||
if ($apiKey === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = $this->callProvider($apiKey, $this->buildPriceList($prices));
|
||||
|
||||
return $payload === null ? null : $this->buildPrediction($payload);
|
||||
} catch (Throwable $e) {
|
||||
Log::error(static::class.': predict failed', ['error' => $e->getMessage()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the configured API key or null if not set. */
|
||||
abstract protected function apiKey(): ?string;
|
||||
|
||||
/**
|
||||
* Make the provider HTTP call and return the normalised payload, or null
|
||||
* on failure (already logged by the implementer).
|
||||
*
|
||||
* @return array{direction: string, confidence: int, reasoning: string}|null
|
||||
*/
|
||||
abstract protected function callProvider(string $apiKey, string $priceList): ?array;
|
||||
|
||||
/** @param Collection<int, BrentPrice> $prices */
|
||||
protected function buildPriceList(Collection $prices): string
|
||||
{
|
||||
return $prices->sortBy('date')
|
||||
->map(fn (BrentPrice $p) => $p->date->toDateString().': $'.$p->price_usd)
|
||||
->implode("\n");
|
||||
}
|
||||
|
||||
/** @param array{direction: string, confidence: int, reasoning: string} $input */
|
||||
protected function buildPrediction(array $input, PredictionSource $source = PredictionSource::Llm): ?PricePrediction
|
||||
{
|
||||
$direction = TrendDirection::tryFrom($input['direction'] ?? '');
|
||||
|
||||
if ($direction === null) {
|
||||
Log::error(static::class.': invalid direction', ['input' => $input]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PricePrediction([
|
||||
'predicted_for' => now()->toDateString(),
|
||||
'source' => $source,
|
||||
'direction' => $direction,
|
||||
'confidence' => min((int) ($input['confidence'] ?? 0), self::LLM_MAX_CONFIDENCE),
|
||||
'reasoning' => $input['reasoning'] ?? '',
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function defaultPrompt(string $priceList): string
|
||||
{
|
||||
return <<<PROMPT
|
||||
You are analyzing Brent crude oil price data for a UK fuel price alert service.
|
||||
Predict the short-term direction over the next 3–5 days.
|
||||
|
||||
Recent Brent crude prices (USD/barrel):
|
||||
{$priceList}
|
||||
|
||||
Respond with direction (rising, falling, or flat), a confidence score (0–85),
|
||||
and a one-sentence reasoning.
|
||||
PROMPT;
|
||||
}
|
||||
}
|
||||
@@ -3,31 +3,23 @@
|
||||
namespace App\Services\LlmPrediction;
|
||||
|
||||
use App\Enums\PredictionSource;
|
||||
use App\Enums\TrendDirection;
|
||||
use App\Models\BrentPrice;
|
||||
use App\Models\PricePrediction;
|
||||
use App\Services\ApiLogger;
|
||||
use App\Services\Ewma;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
class AnthropicPredictionProvider implements OilPredictionProvider
|
||||
class AnthropicPredictionProvider extends AbstractLlmPredictionProvider
|
||||
{
|
||||
private const int LLM_MAX_CONFIDENCE = 85;
|
||||
|
||||
private const float EWMA_ALPHA = 0.3;
|
||||
|
||||
public function __construct(
|
||||
private readonly ApiLogger $apiLogger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Tries web-search-enriched prediction first, falls back to basic tool use.
|
||||
* Overrides the parent flow because Anthropic uses two phases (web search
|
||||
* loop + forced tool call) and selects the source dynamically.
|
||||
*/
|
||||
public function predict(Collection $prices): ?PricePrediction
|
||||
{
|
||||
if (! config('services.anthropic.api_key')) {
|
||||
if ($this->apiKey() === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -36,10 +28,21 @@ class AnthropicPredictionProvider implements OilPredictionProvider
|
||||
return $prediction ?? $this->predictBasic($prices);
|
||||
}
|
||||
|
||||
protected function apiKey(): ?string
|
||||
{
|
||||
return config('services.anthropic.api_key');
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
protected function callProvider(string $apiKey, string $priceList): ?array
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-turn web search phase, then a forced submit_prediction call.
|
||||
* Phase 1: Let the model search for recent oil/geopolitical news (pause_turn loop).
|
||||
* Phase 2: Force submit_prediction with the full conversation context.
|
||||
* Phase 1: let the model search for recent oil/geopolitical news.
|
||||
* Phase 2: force submit_prediction with the full conversation context.
|
||||
*/
|
||||
private function predictWithWebContext(Collection $prices): ?PricePrediction
|
||||
{
|
||||
@@ -47,7 +50,6 @@ class AnthropicPredictionProvider implements OilPredictionProvider
|
||||
$url = 'https://api.anthropic.com/v1/messages';
|
||||
|
||||
try {
|
||||
// Phase 1: web search loop
|
||||
for ($i = 0, $response = null; $i < 5; $i++) {
|
||||
$response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(30)
|
||||
->withHeaders($this->headers())
|
||||
@@ -59,7 +61,7 @@ class AnthropicPredictionProvider implements OilPredictionProvider
|
||||
]));
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('AnthropicPredictionProvider: context search request failed', ['status' => $response->status()]);
|
||||
Log::error(self::class.': context search request failed', ['status' => $response->status()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -71,7 +73,6 @@ class AnthropicPredictionProvider implements OilPredictionProvider
|
||||
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
|
||||
}
|
||||
|
||||
// Phase 2: forced submit with full context
|
||||
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
|
||||
$messages[] = ['role' => 'user', 'content' => 'Now submit your prediction using the submit_prediction tool.'];
|
||||
|
||||
@@ -86,22 +87,61 @@ class AnthropicPredictionProvider implements OilPredictionProvider
|
||||
]));
|
||||
|
||||
if (! $submitResponse->successful()) {
|
||||
Log::error('AnthropicPredictionProvider: context submit request failed', ['status' => $submitResponse->status()]);
|
||||
Log::error(self::class.': context submit request failed', ['status' => $submitResponse->status()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$input = $this->extractToolInput($submitResponse->json('content') ?? []);
|
||||
|
||||
if ($input === null) {
|
||||
Log::error('AnthropicPredictionProvider: no tool_use block in context submit response');
|
||||
return $input === null
|
||||
? null
|
||||
: $this->buildPrediction($input, PredictionSource::LlmWithContext);
|
||||
} catch (Throwable $e) {
|
||||
Log::error(self::class.': predictWithWebContext failed', ['error' => $e->getMessage()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-turn prediction using a forced submit_prediction tool call.
|
||||
* Guarantees structured output — no JSON parsing needed.
|
||||
*/
|
||||
private function predictBasic(Collection $prices): ?PricePrediction
|
||||
{
|
||||
$chronological = $prices->sortBy('date');
|
||||
$ewma3 = Ewma::compute($chronological->take(-3)->pluck('price_usd')->values()->all());
|
||||
$ewma7 = Ewma::compute($chronological->take(-7)->pluck('price_usd')->values()->all());
|
||||
$ewma14 = Ewma::compute($chronological->pluck('price_usd')->values()->all());
|
||||
|
||||
$url = 'https://api.anthropic.com/v1/messages';
|
||||
|
||||
try {
|
||||
$response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(15)
|
||||
->withHeaders($this->headers())
|
||||
->post($url, [
|
||||
'model' => config('services.anthropic.model', 'claude-haiku-4-5-20251001'),
|
||||
'max_tokens' => 256,
|
||||
'tools' => [$this->submitPredictionTool()],
|
||||
'tool_choice' => ['type' => 'tool', 'name' => 'submit_prediction'],
|
||||
'messages' => [[
|
||||
'role' => 'user',
|
||||
'content' => $this->basicPrompt($this->buildPriceList($prices), $ewma3, $ewma7, $ewma14),
|
||||
]],
|
||||
]));
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error(self::class.': basic request failed', ['status' => $response->status()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->buildPrediction($input, PredictionSource::LlmWithContext);
|
||||
$input = $this->extractToolInput($response->json('content') ?? []);
|
||||
|
||||
return $input === null ? null : $this->buildPrediction($input);
|
||||
} catch (Throwable $e) {
|
||||
Log::error('AnthropicPredictionProvider: predictWithWebContext failed', ['error' => $e->getMessage()]);
|
||||
Log::error(self::class.': predictBasic failed', ['error' => $e->getMessage()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -126,18 +166,29 @@ class AnthropicPredictionProvider implements OilPredictionProvider
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
private function buildPriceList(Collection $prices): string
|
||||
private function basicPrompt(string $priceList, float $ewma3, float $ewma7, float $ewma14): string
|
||||
{
|
||||
return $prices->sortBy('date')
|
||||
->map(fn (BrentPrice $p) => $p->date->toDateString().': $'.$p->price_usd)
|
||||
->implode("\n");
|
||||
return <<<PROMPT
|
||||
You are analyzing Brent crude oil price data for a UK fuel price alert service.
|
||||
Predict the short-term direction over the next 3–5 days.
|
||||
|
||||
Recent Brent crude prices (USD/barrel):
|
||||
{$priceList}
|
||||
|
||||
Pre-computed indicators:
|
||||
- 3-day EWMA: \${$ewma3}
|
||||
- 7-day EWMA: \${$ewma7}
|
||||
- 14-day EWMA: \${$ewma14}
|
||||
|
||||
Use the submit_prediction tool to submit your answer.
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
private function headers(): array
|
||||
{
|
||||
return [
|
||||
'x-api-key' => config('services.anthropic.api_key'),
|
||||
'x-api-key' => $this->apiKey(),
|
||||
'anthropic-version' => '2023-06-01',
|
||||
];
|
||||
}
|
||||
@@ -177,108 +228,4 @@ class AnthropicPredictionProvider implements OilPredictionProvider
|
||||
|
||||
return $block['input'] ?? null;
|
||||
}
|
||||
|
||||
/** @param array{direction: string, confidence: int, reasoning: string} $input */
|
||||
private function buildPrediction(array $input, PredictionSource $source): ?PricePrediction
|
||||
{
|
||||
$direction = TrendDirection::tryFrom($input['direction'] ?? '');
|
||||
|
||||
if ($direction === null) {
|
||||
Log::error('AnthropicPredictionProvider: invalid direction in tool input', ['input' => $input]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PricePrediction([
|
||||
'predicted_for' => now()->toDateString(),
|
||||
'source' => $source,
|
||||
'direction' => $direction,
|
||||
'confidence' => min((int) $input['confidence'], self::LLM_MAX_CONFIDENCE),
|
||||
'reasoning' => $input['reasoning'],
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-turn prediction using a forced submit_prediction tool call.
|
||||
* Guarantees structured output — no JSON parsing needed.
|
||||
*/
|
||||
private function predictBasic(Collection $prices): ?PricePrediction
|
||||
{
|
||||
$chronological = $prices->sortBy('date');
|
||||
$ewma3 = $this->computeEwma($chronological->take(-3)->pluck('price_usd')->values()->all());
|
||||
$ewma7 = $this->computeEwma($chronological->take(-7)->pluck('price_usd')->values()->all());
|
||||
$ewma14 = $this->computeEwma($chronological->pluck('price_usd')->values()->all());
|
||||
|
||||
$priceList = $this->buildPriceList($prices);
|
||||
|
||||
$url = 'https://api.anthropic.com/v1/messages';
|
||||
|
||||
try {
|
||||
$response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(15)
|
||||
->withHeaders($this->headers())
|
||||
->post($url, [
|
||||
'model' => config('services.anthropic.model', 'claude-haiku-4-5-20251001'),
|
||||
'max_tokens' => 256,
|
||||
'tools' => [$this->submitPredictionTool()],
|
||||
'tool_choice' => ['type' => 'tool', 'name' => 'submit_prediction'],
|
||||
'messages' => [[
|
||||
'role' => 'user',
|
||||
'content' => $this->basicPrompt($priceList, $ewma3, $ewma7, $ewma14),
|
||||
]],
|
||||
]));
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('AnthropicPredictionProvider: basic request failed', ['status' => $response->status()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$input = $this->extractToolInput($response->json('content') ?? []);
|
||||
|
||||
if ($input === null) {
|
||||
Log::error('AnthropicPredictionProvider: no tool_use block in basic response');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->buildPrediction($input, PredictionSource::Llm);
|
||||
} catch (Throwable $e) {
|
||||
Log::error('AnthropicPredictionProvider: predictBasic failed', ['error' => $e->getMessage()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param float[] $prices Chronological order (oldest first)
|
||||
*/
|
||||
private function computeEwma(array $prices): float
|
||||
{
|
||||
$ema = $prices[0];
|
||||
|
||||
foreach (array_slice($prices, 1) as $price) {
|
||||
$ema = self::EWMA_ALPHA * $price + (1 - self::EWMA_ALPHA) * $ema;
|
||||
}
|
||||
|
||||
return round($ema, 4);
|
||||
}
|
||||
|
||||
private function basicPrompt(string $priceList, float $ewma3, float $ewma7, float $ewma14): string
|
||||
{
|
||||
return <<<PROMPT
|
||||
You are analyzing Brent crude oil price data for a UK fuel price alert service.
|
||||
Predict the short-term direction over the next 3–5 days.
|
||||
|
||||
Recent Brent crude prices (USD/barrel):
|
||||
{$priceList}
|
||||
|
||||
Pre-computed indicators:
|
||||
- 3-day EWMA: \${$ewma3}
|
||||
- 7-day EWMA: \${$ewma7}
|
||||
- 14-day EWMA: \${$ewma14}
|
||||
|
||||
Use the submit_prediction tool to submit your answer.
|
||||
PROMPT;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,110 +2,59 @@
|
||||
|
||||
namespace App\Services\LlmPrediction;
|
||||
|
||||
use App\Enums\PredictionSource;
|
||||
use App\Enums\TrendDirection;
|
||||
use App\Models\BrentPrice;
|
||||
use App\Models\PricePrediction;
|
||||
use App\Services\ApiLogger;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
class GeminiPredictionProvider implements OilPredictionProvider
|
||||
class GeminiPredictionProvider extends AbstractLlmPredictionProvider
|
||||
{
|
||||
private const int LLM_MAX_CONFIDENCE = 85;
|
||||
|
||||
public function __construct(
|
||||
private readonly ApiLogger $apiLogger,
|
||||
) {}
|
||||
|
||||
public function predict(Collection $prices): ?PricePrediction
|
||||
protected function apiKey(): ?string
|
||||
{
|
||||
if (! config('services.gemini.api_key')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$priceList = $prices->sortBy('date')
|
||||
->map(fn (BrentPrice $p) => $p->date->toDateString().': $'.$p->price_usd)
|
||||
->implode("\n");
|
||||
return config('services.gemini.api_key');
|
||||
}
|
||||
|
||||
protected function callProvider(string $apiKey, string $priceList): ?array
|
||||
{
|
||||
$model = config('services.gemini.model', 'gemini-2.0-flash');
|
||||
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent";
|
||||
|
||||
try {
|
||||
$response = $this->apiLogger->send('gemini', 'POST', $url, fn () => Http::timeout(15)
|
||||
->withQueryParameters(['key' => config('services.gemini.api_key')])
|
||||
->post($url, [
|
||||
'contents' => [[
|
||||
'parts' => [['text' => $this->prompt($priceList)]],
|
||||
]],
|
||||
'generationConfig' => [
|
||||
'responseMimeType' => 'application/json',
|
||||
'responseSchema' => [
|
||||
'type' => 'OBJECT',
|
||||
'properties' => [
|
||||
'direction' => [
|
||||
'type' => 'STRING',
|
||||
'enum' => ['rising', 'falling', 'flat'],
|
||||
],
|
||||
'confidence' => ['type' => 'INTEGER'],
|
||||
'reasoning' => ['type' => 'STRING'],
|
||||
$response = $this->apiLogger->send('gemini', 'POST', $url, fn () => Http::timeout(15)
|
||||
->withQueryParameters(['key' => $apiKey])
|
||||
->post($url, [
|
||||
'contents' => [[
|
||||
'parts' => [['text' => $this->defaultPrompt($priceList)]],
|
||||
]],
|
||||
'generationConfig' => [
|
||||
'responseMimeType' => 'application/json',
|
||||
'responseSchema' => [
|
||||
'type' => 'OBJECT',
|
||||
'properties' => [
|
||||
'direction' => [
|
||||
'type' => 'STRING',
|
||||
'enum' => ['rising', 'falling', 'flat'],
|
||||
],
|
||||
'required' => ['direction', 'confidence', 'reasoning'],
|
||||
'confidence' => ['type' => 'INTEGER'],
|
||||
'reasoning' => ['type' => 'STRING'],
|
||||
],
|
||||
'required' => ['direction', 'confidence', 'reasoning'],
|
||||
],
|
||||
]));
|
||||
],
|
||||
]));
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('GeminiPredictionProvider: request failed', ['status' => $response->status()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$text = $response->json('candidates.0.content.parts.0.text') ?? '';
|
||||
$data = json_decode($text, true);
|
||||
|
||||
if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
|
||||
Log::error('GeminiPredictionProvider: unexpected response format', ['text' => $text]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$direction = TrendDirection::tryFrom($data['direction']);
|
||||
|
||||
if ($direction === null) {
|
||||
Log::error('GeminiPredictionProvider: invalid direction', ['direction' => $data['direction']]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PricePrediction([
|
||||
'predicted_for' => now()->toDateString(),
|
||||
'source' => PredictionSource::Llm,
|
||||
'direction' => $direction,
|
||||
'confidence' => min((int) $data['confidence'], self::LLM_MAX_CONFIDENCE),
|
||||
'reasoning' => $data['reasoning'],
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
Log::error('GeminiPredictionProvider: predict failed', ['error' => $e->getMessage()]);
|
||||
if (! $response->successful()) {
|
||||
Log::error(self::class.': request failed', ['status' => $response->status()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function prompt(string $priceList): string
|
||||
{
|
||||
return <<<PROMPT
|
||||
You are analyzing Brent crude oil price data for a UK fuel price alert service.
|
||||
Predict the short-term direction over the next 3–5 days.
|
||||
$text = $response->json('candidates.0.content.parts.0.text') ?? '';
|
||||
$data = json_decode($text, true);
|
||||
|
||||
Recent Brent crude prices (USD/barrel):
|
||||
{$priceList}
|
||||
if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
|
||||
Log::error(self::class.': unexpected response format', ['text' => $text]);
|
||||
|
||||
Respond with direction (rising, falling, or flat), a confidence score (0–85),
|
||||
and a one-sentence reasoning.
|
||||
PROMPT;
|
||||
return null;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,112 +2,61 @@
|
||||
|
||||
namespace App\Services\LlmPrediction;
|
||||
|
||||
use App\Enums\PredictionSource;
|
||||
use App\Enums\TrendDirection;
|
||||
use App\Models\BrentPrice;
|
||||
use App\Models\PricePrediction;
|
||||
use App\Services\ApiLogger;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
class OpenAiPredictionProvider implements OilPredictionProvider
|
||||
class OpenAiPredictionProvider extends AbstractLlmPredictionProvider
|
||||
{
|
||||
private const int LLM_MAX_CONFIDENCE = 85;
|
||||
|
||||
public function __construct(
|
||||
private readonly ApiLogger $apiLogger,
|
||||
) {}
|
||||
|
||||
public function predict(Collection $prices): ?PricePrediction
|
||||
protected function apiKey(): ?string
|
||||
{
|
||||
if (! config('services.openai.api_key')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$priceList = $prices->sortBy('date')
|
||||
->map(fn (BrentPrice $p) => $p->date->toDateString().': $'.$p->price_usd)
|
||||
->implode("\n");
|
||||
return config('services.openai.api_key');
|
||||
}
|
||||
|
||||
protected function callProvider(string $apiKey, string $priceList): ?array
|
||||
{
|
||||
$url = 'https://api.openai.com/v1/chat/completions';
|
||||
|
||||
try {
|
||||
$response = $this->apiLogger->send('openai', 'POST', $url, fn () => Http::timeout(15)
|
||||
->withToken(config('services.openai.api_key'))
|
||||
->post($url, [
|
||||
'model' => config('services.openai.model', 'gpt-4o-mini'),
|
||||
'response_format' => [
|
||||
'type' => 'json_schema',
|
||||
'json_schema' => [
|
||||
'name' => 'oil_prediction',
|
||||
'strict' => true,
|
||||
'schema' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'direction' => ['type' => 'string', 'enum' => ['rising', 'falling', 'flat']],
|
||||
'confidence' => ['type' => 'integer'],
|
||||
'reasoning' => ['type' => 'string'],
|
||||
],
|
||||
'required' => ['direction', 'confidence', 'reasoning'],
|
||||
'additionalProperties' => false,
|
||||
$response = $this->apiLogger->send('openai', 'POST', $url, fn () => Http::timeout(15)
|
||||
->withToken($apiKey)
|
||||
->post($url, [
|
||||
'model' => config('services.openai.model', 'gpt-4o-mini'),
|
||||
'response_format' => [
|
||||
'type' => 'json_schema',
|
||||
'json_schema' => [
|
||||
'name' => 'oil_prediction',
|
||||
'strict' => true,
|
||||
'schema' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'direction' => ['type' => 'string', 'enum' => ['rising', 'falling', 'flat']],
|
||||
'confidence' => ['type' => 'integer'],
|
||||
'reasoning' => ['type' => 'string'],
|
||||
],
|
||||
'required' => ['direction', 'confidence', 'reasoning'],
|
||||
'additionalProperties' => false,
|
||||
],
|
||||
],
|
||||
'messages' => [[
|
||||
'role' => 'user',
|
||||
'content' => $this->prompt($priceList),
|
||||
]],
|
||||
]));
|
||||
],
|
||||
'messages' => [[
|
||||
'role' => 'user',
|
||||
'content' => $this->defaultPrompt($priceList),
|
||||
]],
|
||||
]));
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('OpenAiPredictionProvider: request failed', ['status' => $response->status()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($response->json('choices.0.message.content') ?? '{}', true);
|
||||
|
||||
if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
|
||||
Log::error('OpenAiPredictionProvider: unexpected response format', ['data' => $data]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$direction = TrendDirection::tryFrom($data['direction']);
|
||||
|
||||
if ($direction === null) {
|
||||
Log::error('OpenAiPredictionProvider: invalid direction', ['direction' => $data['direction']]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PricePrediction([
|
||||
'predicted_for' => now()->toDateString(),
|
||||
'source' => PredictionSource::Llm,
|
||||
'direction' => $direction,
|
||||
'confidence' => min((int) $data['confidence'], self::LLM_MAX_CONFIDENCE),
|
||||
'reasoning' => $data['reasoning'],
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
Log::error('OpenAiPredictionProvider: predict failed', ['error' => $e->getMessage()]);
|
||||
if (! $response->successful()) {
|
||||
Log::error(self::class.': request failed', ['status' => $response->status()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function prompt(string $priceList): string
|
||||
{
|
||||
return <<<PROMPT
|
||||
You are analyzing Brent crude oil price data for a UK fuel price alert service.
|
||||
Predict the short-term direction over the next 3–5 days.
|
||||
$data = json_decode($response->json('choices.0.message.content') ?? '{}', true);
|
||||
|
||||
Recent Brent crude prices (USD/barrel):
|
||||
{$priceList}
|
||||
if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
|
||||
Log::error(self::class.': unexpected response format', ['data' => $data]);
|
||||
|
||||
Respond with direction (rising, falling, or flat), a confidence score (0–85),
|
||||
and a one-sentence reasoning.
|
||||
PROMPT;
|
||||
return null;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,16 +4,31 @@ namespace App\Services;
|
||||
|
||||
use App\Enums\FuelType;
|
||||
use App\Models\StationPriceCurrent;
|
||||
use App\Services\Prediction\Signals\BrandBehaviourSignal;
|
||||
use App\Services\Prediction\Signals\DayOfWeekSignal;
|
||||
use App\Services\Prediction\Signals\OilSignal;
|
||||
use App\Services\Prediction\Signals\RegionalMomentumSignal;
|
||||
use App\Services\Prediction\Signals\SignalContext;
|
||||
use App\Services\Prediction\Signals\StickinessSignal;
|
||||
use App\Services\Prediction\Signals\TrendSignal;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class NationalFuelPredictionService
|
||||
{
|
||||
private const float R_SQUARED_THRESHOLD = 0.5;
|
||||
|
||||
private const float SLOPE_THRESHOLD_PENCE = 0.3;
|
||||
|
||||
private const int PREDICTION_HORIZON_DAYS = 7;
|
||||
|
||||
public function __construct(
|
||||
private readonly TrendSignal $trendSignal,
|
||||
private readonly DayOfWeekSignal $dayOfWeekSignal,
|
||||
private readonly BrandBehaviourSignal $brandBehaviourSignal,
|
||||
private readonly StickinessSignal $stickinessSignal,
|
||||
private readonly RegionalMomentumSignal $regionalMomentumSignal,
|
||||
private readonly OilSignal $oilSignal,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* fuel_type: string,
|
||||
@@ -34,19 +49,19 @@ class NationalFuelPredictionService
|
||||
{
|
||||
$fuelType = FuelType::E10;
|
||||
$hasCoordinates = $lat !== null && $lng !== null;
|
||||
$context = new SignalContext($fuelType, $lat, $lng);
|
||||
|
||||
$currentAvg = $this->getCurrentAverage($fuelType, $lat, $lng);
|
||||
$trend = $this->computeTrendSignal($fuelType);
|
||||
$dayOfWeek = $this->computeDayOfWeekSignal($fuelType);
|
||||
$brandBehaviour = $this->computeBrandBehaviourSignal($fuelType);
|
||||
$stickiness = $this->computeStickinessSignal($fuelType);
|
||||
$trend = $this->trendSignal->compute($context);
|
||||
$dayOfWeek = $this->dayOfWeekSignal->compute($context);
|
||||
$brandBehaviour = $this->brandBehaviourSignal->compute($context);
|
||||
$stickiness = $this->stickinessSignal->compute($context);
|
||||
$oil = $this->oilSignal->compute($context);
|
||||
|
||||
$nationalMomentum = $this->disabledSignal('National momentum disabled for national predictions');
|
||||
$regionalMomentum = $hasCoordinates
|
||||
? $this->computeRegionalMomentumSignal($fuelType, $lat, $lng)
|
||||
: $this->disabledSignal('No coordinates provided for regional momentum analysis');
|
||||
$regionalMomentum = $this->regionalMomentumSignal->compute($context);
|
||||
|
||||
$signals = compact('trend', 'dayOfWeek', 'brandBehaviour', 'nationalMomentum', 'regionalMomentum', 'stickiness');
|
||||
$signals = compact('trend', 'dayOfWeek', 'brandBehaviour', 'nationalMomentum', 'regionalMomentum', 'stickiness', 'oil');
|
||||
|
||||
[$direction, $confidenceScore] = $this->aggregateSignals($signals, $hasCoordinates);
|
||||
|
||||
@@ -65,6 +80,8 @@ class NationalFuelPredictionService
|
||||
default => 'no_signal',
|
||||
};
|
||||
|
||||
$weeklySummary = $this->computeWeeklySummary($fuelType, $lat, $lng, $currentAvg, $slope);
|
||||
|
||||
return [
|
||||
'fuel_type' => $fuelType->value,
|
||||
'current_avg' => $currentAvg,
|
||||
@@ -73,10 +90,11 @@ class NationalFuelPredictionService
|
||||
'confidence_score' => $confidenceScore,
|
||||
'confidence_label' => $confidenceLabel,
|
||||
'action' => $action,
|
||||
'reasoning' => $this->buildReasoning($direction, $slope, $trend, $brandBehaviour),
|
||||
'reasoning' => $this->buildReasoning($direction, $slope, $trend, $brandBehaviour, $dayOfWeek),
|
||||
'prediction_horizon_days' => self::PREDICTION_HORIZON_DAYS,
|
||||
'region_key' => $hasCoordinates ? 'regional' : 'national',
|
||||
'methodology' => 'multi_signal_live_fallback',
|
||||
'weekly_summary' => $weeklySummary,
|
||||
'signals' => [
|
||||
'trend' => $trend,
|
||||
'day_of_week' => $dayOfWeek,
|
||||
@@ -84,6 +102,7 @@ class NationalFuelPredictionService
|
||||
'national_momentum' => $nationalMomentum,
|
||||
'regional_momentum' => $regionalMomentum,
|
||||
'price_stickiness' => $stickiness,
|
||||
'oil' => $oil,
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -91,10 +110,12 @@ class NationalFuelPredictionService
|
||||
private function getCurrentAverage(FuelType $fuelType, ?float $lat, ?float $lng): float
|
||||
{
|
||||
if ($lat !== null && $lng !== null) {
|
||||
[$radiusSql, $radiusBindings] = HaversineQuery::withinKm($lat, $lng, 50);
|
||||
|
||||
$avg = DB::table('station_prices_current')
|
||||
->join('stations', 'station_prices_current.station_id', '=', 'stations.node_id')
|
||||
->where('station_prices_current.fuel_type', $fuelType->value)
|
||||
->whereRaw('(6371 * acos(LEAST(1.0, cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?)) + sin(radians(?)) * sin(radians(lat))))) <= 50', [$lat, $lng, $lat])
|
||||
->whereRaw($radiusSql, $radiusBindings)
|
||||
->avg('station_prices_current.price_pence');
|
||||
|
||||
if ($avg !== null) {
|
||||
@@ -107,285 +128,6 @@ class NationalFuelPredictionService
|
||||
return $avg !== null ? round((float) $avg / 100, 1) : 0.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Linear regression on daily national average prices.
|
||||
* Tries 5-day lookback first; falls back to 14-day if R² < threshold.
|
||||
*
|
||||
* @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool, slope: float, r_squared: float}
|
||||
*/
|
||||
private function computeTrendSignal(FuelType $fuelType): array
|
||||
{
|
||||
foreach ([5, 14] as $lookbackDays) {
|
||||
$rows = DB::table('station_prices')
|
||||
->where('fuel_type', $fuelType->value)
|
||||
->where('price_effective_at', '>=', now()->subDays($lookbackDays))
|
||||
->selectRaw('DATE(price_effective_at) as day, AVG(price_pence) as avg_price')
|
||||
->groupBy('day')
|
||||
->orderBy('day')
|
||||
->get();
|
||||
|
||||
if ($rows->count() < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$regression = $this->linearRegression($rows->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all());
|
||||
|
||||
if ($regression['r_squared'] >= self::R_SQUARED_THRESHOLD) {
|
||||
$slope = $regression['slope'];
|
||||
$direction = match (true) {
|
||||
$slope >= self::SLOPE_THRESHOLD_PENCE => 'up',
|
||||
$slope <= -self::SLOPE_THRESHOLD_PENCE => 'down',
|
||||
default => 'stable',
|
||||
};
|
||||
$absSlope = abs($slope);
|
||||
$score = $direction === 'stable' ? 0.0 : min(1.0, $absSlope / 2.0) * ($slope > 0 ? 1 : -1);
|
||||
$projected = round($slope * $lookbackDays, 1);
|
||||
$detail = $direction === 'stable'
|
||||
? "Prices flat over {$lookbackDays} days (slope: {$slope}p/day, R²={$regression['r_squared']})"
|
||||
: sprintf(
|
||||
'%s at %sp/day over %d days (R²=%s, ~%s%sp in %dd)',
|
||||
$slope > 0 ? 'Rising' : 'Falling',
|
||||
abs(round($slope, 2)),
|
||||
$lookbackDays,
|
||||
round($regression['r_squared'], 2),
|
||||
$projected > 0 ? '+' : '',
|
||||
$projected,
|
||||
self::PREDICTION_HORIZON_DAYS,
|
||||
);
|
||||
|
||||
if ($lookbackDays === 5) {
|
||||
$detail .= ' [Adaptive lookback active]';
|
||||
}
|
||||
|
||||
return [
|
||||
'score' => $score,
|
||||
'confidence' => min(1.0, $regression['r_squared']),
|
||||
'direction' => $direction,
|
||||
'detail' => $detail,
|
||||
'data_points' => $rows->count(),
|
||||
'enabled' => true,
|
||||
'slope' => round($slope, 3),
|
||||
'r_squared' => round($regression['r_squared'], 3),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'score' => 0.0,
|
||||
'confidence' => 0.0,
|
||||
'direction' => 'stable',
|
||||
'detail' => 'Insufficient price history or noisy data (R² below threshold)',
|
||||
'data_points' => 0,
|
||||
'enabled' => false,
|
||||
'slope' => 0.0,
|
||||
'r_squared' => 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare today's average price against the per-weekday average over 90 days.
|
||||
* Requires 56+ days of history to activate.
|
||||
*
|
||||
* @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool}
|
||||
*/
|
||||
private function computeDayOfWeekSignal(FuelType $fuelType): array
|
||||
{
|
||||
$isSqlite = DB::connection()->getDriverName() === 'sqlite';
|
||||
$dowExpr = $isSqlite
|
||||
? "(CAST(strftime('%w', price_effective_at) AS INTEGER) + 1)"
|
||||
: 'DAYOFWEEK(price_effective_at)';
|
||||
|
||||
$rows = DB::table('station_prices')
|
||||
->where('fuel_type', $fuelType->value)
|
||||
->where('price_effective_at', '>=', now()->subDays(90))
|
||||
->selectRaw("{$dowExpr} as dow, DATE(price_effective_at) as day, AVG(price_pence) as avg_price")
|
||||
->groupBy('dow', 'day')
|
||||
->get();
|
||||
|
||||
$uniqueDays = $rows->pluck('day')->unique()->count();
|
||||
|
||||
if ($uniqueDays < 56) {
|
||||
return $this->disabledSignal("Insufficient history for day-of-week pattern ({$uniqueDays} days, need 56)");
|
||||
}
|
||||
|
||||
$dowAverages = $rows->groupBy('dow')->map(fn ($g) => $g->avg('avg_price'));
|
||||
$weekAvg = $dowAverages->avg();
|
||||
$todayDow = (int) now()->format('w') + 1; // PHP 0=Sun → MySQL 1=Sun
|
||||
$todayAvg = $dowAverages->get($todayDow, $weekAvg);
|
||||
$cheapestDow = $dowAverages->keys()->sortBy(fn ($k) => $dowAverages[$k])->first();
|
||||
$dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
$cheapestDayName = $dayNames[($cheapestDow - 1) % 7] ?? 'Unknown';
|
||||
$weekRange = round(($dowAverages->max() - $dowAverages->min()) / 100, 1);
|
||||
$tomorrowDelta = round(($dowAverages->get(($todayDow % 7) + 1, $weekAvg) - $todayAvg) / 100, 1);
|
||||
|
||||
$direction = match (true) {
|
||||
($todayAvg - $weekAvg) / 100 >= 1.5 => 'up',
|
||||
($weekAvg - $todayAvg) / 100 >= 1.5 => 'down',
|
||||
default => 'stable',
|
||||
};
|
||||
|
||||
$score = $direction === 'stable' ? 0.0 : ($direction === 'up' ? 1.0 : -1.0);
|
||||
|
||||
return [
|
||||
'score' => $score,
|
||||
'confidence' => min(1.0, $uniqueDays / 90),
|
||||
'direction' => $direction,
|
||||
'detail' => "Cheapest day: {$cheapestDayName}. Weekly range: {$weekRange}p. Tomorrow typically {$tomorrowDelta}p less than today.",
|
||||
'data_points' => $uniqueDays,
|
||||
'enabled' => true,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare supermarket vs non-supermarket 7-day price trend.
|
||||
* Detects divergence where one group has moved but the other hasn't yet.
|
||||
*
|
||||
* @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool}
|
||||
*/
|
||||
private function computeBrandBehaviourSignal(FuelType $fuelType): array
|
||||
{
|
||||
$rows = DB::table('station_prices')
|
||||
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
|
||||
->where('station_prices.fuel_type', $fuelType->value)
|
||||
->where('station_prices.price_effective_at', '>=', now()->subDays(7))
|
||||
->selectRaw('stations.is_supermarket, DATE(station_prices.price_effective_at) as day, AVG(station_prices.price_pence) as avg_price')
|
||||
->groupBy('stations.is_supermarket', 'day')
|
||||
->orderBy('day')
|
||||
->get();
|
||||
|
||||
$supermarket = $rows->where('is_supermarket', 1)->values();
|
||||
$major = $rows->where('is_supermarket', 0)->values();
|
||||
|
||||
if ($supermarket->count() < 2 || $major->count() < 2) {
|
||||
return $this->disabledSignal('Insufficient brand data for comparison');
|
||||
}
|
||||
|
||||
$supermarketSlope = $this->linearRegression($supermarket->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all())['slope'];
|
||||
$majorSlope = $this->linearRegression($major->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all())['slope'];
|
||||
|
||||
$divergence = round(abs($supermarketSlope - $majorSlope) * 7, 1);
|
||||
$supermarketChange = round($supermarketSlope * 7, 1);
|
||||
$majorChange = round($majorSlope * 7, 1);
|
||||
|
||||
if ($divergence < 1.0) {
|
||||
return [
|
||||
'score' => 0.0,
|
||||
'confidence' => 0.5,
|
||||
'direction' => 'stable',
|
||||
'detail' => 'Supermarkets and majors moving in sync.',
|
||||
'data_points' => $rows->count(),
|
||||
'enabled' => true,
|
||||
];
|
||||
}
|
||||
|
||||
$leaderChange = abs($supermarketChange) > abs($majorChange) ? $supermarketChange : $majorChange;
|
||||
$direction = $leaderChange > 0 ? 'up' : 'down';
|
||||
$leader = abs($supermarketChange) > abs($majorChange) ? 'Supermarkets' : 'Majors';
|
||||
$follower = $leader === 'Supermarkets' ? 'majors' : 'supermarkets';
|
||||
$leaderAbs = abs($leaderChange);
|
||||
$followerChange = $leader === 'Supermarkets' ? abs($majorChange) : abs($supermarketChange);
|
||||
|
||||
return [
|
||||
'score' => $direction === 'up' ? 1.0 : -1.0,
|
||||
'confidence' => min(1.0, $divergence / 5.0),
|
||||
'direction' => $direction,
|
||||
'detail' => "{$leader} ".($leaderChange > 0 ? 'rose' : 'fell')." {$leaderAbs}p vs {$follower} {$followerChange}p (divergence: {$divergence}p). Expect {$follower} to follow.",
|
||||
'data_points' => $rows->count(),
|
||||
'enabled' => true,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Average hold duration (days between price changes) as a confidence modifier.
|
||||
* Requires 30+ days of history. Returns a score between -0.1 and +0.1.
|
||||
*
|
||||
* @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool}
|
||||
*/
|
||||
private function computeStickinessSignal(FuelType $fuelType): array
|
||||
{
|
||||
$isSqlite = DB::connection()->getDriverName() === 'sqlite';
|
||||
$diffExpr = $isSqlite
|
||||
? 'CAST((julianday(MAX(price_effective_at)) - julianday(MIN(price_effective_at))) AS INTEGER)'
|
||||
: 'DATEDIFF(MAX(price_effective_at), MIN(price_effective_at))';
|
||||
|
||||
$rows = DB::table('station_prices')
|
||||
->where('fuel_type', $fuelType->value)
|
||||
->where('price_effective_at', '>=', now()->subDays(30))
|
||||
->selectRaw("station_id, COUNT(*) as changes, {$diffExpr} as span_days")
|
||||
->groupBy('station_id')
|
||||
->having('changes', '>', 1)
|
||||
->having('span_days', '>', 0)
|
||||
->get();
|
||||
|
||||
if ($rows->count() < 10) {
|
||||
return $this->disabledSignal('Insufficient stickiness data (need 10+ stations with price history)');
|
||||
}
|
||||
|
||||
$avgHoldDays = $rows->avg(fn ($r) => $r->span_days / ($r->changes - 1));
|
||||
$avgHoldDays = round((float) $avgHoldDays, 1);
|
||||
|
||||
$score = match (true) {
|
||||
$avgHoldDays < 2 => -0.1,
|
||||
$avgHoldDays > 5 => 0.1,
|
||||
default => 0.0,
|
||||
};
|
||||
|
||||
$detail = match (true) {
|
||||
$avgHoldDays < 2 => "Volatile prices (avg hold: {$avgHoldDays} days) — harder to predict.",
|
||||
$avgHoldDays > 5 => "Sticky prices (avg hold: {$avgHoldDays} days) — more predictable.",
|
||||
default => "Normal hold period (avg: {$avgHoldDays} days).",
|
||||
};
|
||||
|
||||
return [
|
||||
'score' => $score,
|
||||
'confidence' => min(1.0, $rows->count() / 200),
|
||||
'direction' => 'stable',
|
||||
'detail' => $detail,
|
||||
'data_points' => $rows->count(),
|
||||
'enabled' => true,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder for regional momentum signal (requires lat/lng).
|
||||
* Compares local station prices vs national average trend.
|
||||
*
|
||||
* @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool}
|
||||
*/
|
||||
private function computeRegionalMomentumSignal(FuelType $fuelType, float $lat, float $lng): array
|
||||
{
|
||||
// Regional momentum: compare trend of stations within 50km vs national trend
|
||||
$rows = DB::table('station_prices')
|
||||
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
|
||||
->where('station_prices.fuel_type', $fuelType->value)
|
||||
->where('station_prices.price_effective_at', '>=', now()->subDays(14))
|
||||
->whereRaw('(6371 * acos(CASE WHEN (cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?)) + sin(radians(?)) * sin(radians(lat))) > 1.0 THEN 1.0 ELSE (cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?)) + sin(radians(?)) * sin(radians(lat))) END)) <= 50', [$lat, $lng, $lat, $lat, $lng, $lat])
|
||||
->selectRaw('DATE(station_prices.price_effective_at) as day, AVG(station_prices.price_pence) as avg_price')
|
||||
->groupBy('day')
|
||||
->orderBy('day')
|
||||
->get();
|
||||
|
||||
if ($rows->count() < 3) {
|
||||
return $this->disabledSignal('Insufficient regional data');
|
||||
}
|
||||
|
||||
$regionalRegression = $this->linearRegression($rows->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all());
|
||||
$direction = match (true) {
|
||||
$regionalRegression['slope'] >= self::SLOPE_THRESHOLD_PENCE => 'up',
|
||||
$regionalRegression['slope'] <= -self::SLOPE_THRESHOLD_PENCE => 'down',
|
||||
default => 'stable',
|
||||
};
|
||||
|
||||
return [
|
||||
'score' => $direction === 'stable' ? 0.0 : ($direction === 'up' ? 0.7 : -0.7),
|
||||
'confidence' => min(1.0, $regionalRegression['r_squared']),
|
||||
'direction' => $direction,
|
||||
'detail' => 'Regional trend: '.round($regionalRegression['slope'], 2).'p/day (R²='.round($regionalRegression['r_squared'], 2).')',
|
||||
'data_points' => $rows->count(),
|
||||
'enabled' => true,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool} */
|
||||
private function disabledSignal(string $detail): array
|
||||
{
|
||||
@@ -400,46 +142,64 @@ class NationalFuelPredictionService
|
||||
}
|
||||
|
||||
/**
|
||||
* Weighted aggregate of enabled signals.
|
||||
* Returns [direction string, confidence score 0-100].
|
||||
* Aggregate enabled signals into a final direction + confidence score.
|
||||
*
|
||||
* @param array<string, array{score: float, confidence: float, enabled: bool}> $signals
|
||||
* Direction: weighted vote across signals that have a non-stable direction.
|
||||
* stable signals do NOT dilute the directional vote.
|
||||
*
|
||||
* Confidence: weighted average of enabled signals' own confidence values,
|
||||
* multiplied by an agreement coefficient (0..1) measuring how the signals
|
||||
* line up with the chosen direction.
|
||||
*
|
||||
* @param array<string, array{score: float, confidence: float, direction: string, enabled: bool}> $signals
|
||||
* @return array{0: string, 1: float}
|
||||
*/
|
||||
private function aggregateSignals(array $signals, bool $hasCoordinates = false): array
|
||||
{
|
||||
$weights = $hasCoordinates
|
||||
? [
|
||||
'regionalMomentum' => 0.50,
|
||||
'trend' => 0.20,
|
||||
'regionalMomentum' => 0.35,
|
||||
'oil' => 0.20,
|
||||
'trend' => 0.15,
|
||||
'dayOfWeek' => 0.15,
|
||||
'brandBehaviour' => 0.10,
|
||||
'stickiness' => 0.05,
|
||||
]
|
||||
: [
|
||||
'trend' => 0.45,
|
||||
'trend' => 0.30,
|
||||
'oil' => 0.25,
|
||||
'dayOfWeek' => 0.20,
|
||||
'brandBehaviour' => 0.25,
|
||||
'brandBehaviour' => 0.15,
|
||||
'stickiness' => 0.10,
|
||||
];
|
||||
|
||||
$weightedSum = 0.0;
|
||||
$totalWeight = 0.0;
|
||||
$directionalScoreSum = 0.0;
|
||||
$directionalWeightSum = 0.0;
|
||||
$confidenceWeightedSum = 0.0;
|
||||
$totalEnabledWeight = 0.0;
|
||||
|
||||
foreach ($weights as $key => $weight) {
|
||||
$signal = $signals[$key] ?? null;
|
||||
if ($signal && $signal['enabled']) {
|
||||
$weightedSum += $signal['score'] * $signal['confidence'] * $weight;
|
||||
$totalWeight += $weight;
|
||||
if (! $signal || ! $signal['enabled']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$totalEnabledWeight += $weight;
|
||||
$confidenceWeightedSum += $signal['confidence'] * $weight;
|
||||
|
||||
if ($signal['direction'] !== 'stable') {
|
||||
$directionalScoreSum += $signal['score'] * $signal['confidence'] * $weight;
|
||||
$directionalWeightSum += $weight;
|
||||
}
|
||||
}
|
||||
|
||||
if ($totalWeight < 0.01) {
|
||||
if ($totalEnabledWeight < 0.01) {
|
||||
return ['stable', 0.0];
|
||||
}
|
||||
|
||||
$normalised = $weightedSum / $totalWeight;
|
||||
$confidenceScore = round(min(100.0, abs($normalised) * 100), 1);
|
||||
$normalised = $directionalWeightSum > 0.01
|
||||
? $directionalScoreSum / $directionalWeightSum
|
||||
: 0.0;
|
||||
|
||||
$direction = match (true) {
|
||||
$normalised >= 0.1 => 'up',
|
||||
@@ -447,51 +207,185 @@ class NationalFuelPredictionService
|
||||
default => 'stable',
|
||||
};
|
||||
|
||||
$avgConfidence = $confidenceWeightedSum / $totalEnabledWeight;
|
||||
$agreement = $this->computeAgreement($signals, $weights, $direction);
|
||||
|
||||
$confidenceScore = round(min(100.0, $avgConfidence * $agreement * 100), 1);
|
||||
|
||||
return [$direction, $confidenceScore];
|
||||
}
|
||||
|
||||
/**
|
||||
* Least-squares linear regression.
|
||||
* x is the array index (day number), y is the price value.
|
||||
* How well the enabled 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
|
||||
*
|
||||
* @param float[] $values
|
||||
* @return array{slope: float, r_squared: float}
|
||||
* Range: 0 (full disagreement) → 1 (unanimous).
|
||||
*
|
||||
* @param array<string, array{confidence: float, direction: string, enabled: bool}> $signals
|
||||
* @param array<string, float> $weights
|
||||
*/
|
||||
private function linearRegression(array $values): array
|
||||
private function computeAgreement(array $signals, array $weights, string $finalDirection): float
|
||||
{
|
||||
$n = count($values);
|
||||
if ($n < 2) {
|
||||
return ['slope' => 0.0, 'r_squared' => 0.0];
|
||||
$finalDir = match ($finalDirection) {
|
||||
'up' => 1,
|
||||
'down' => -1,
|
||||
default => 0,
|
||||
};
|
||||
|
||||
$credit = 0.0;
|
||||
$maxCredit = 0.0;
|
||||
|
||||
foreach ($weights as $key => $weight) {
|
||||
$signal = $signals[$key] ?? null;
|
||||
if (! $signal || ! $signal['enabled']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$maxCredit += $signal['confidence'] * $weight;
|
||||
|
||||
$signalDir = match ($signal['direction']) {
|
||||
'up' => 1,
|
||||
'down' => -1,
|
||||
default => 0,
|
||||
};
|
||||
|
||||
if ($signalDir === $finalDir) {
|
||||
$credit += $signal['confidence'] * $weight;
|
||||
} elseif ($signalDir === 0 || $finalDir === 0) {
|
||||
$credit += 0.5 * $signal['confidence'] * $weight;
|
||||
}
|
||||
}
|
||||
|
||||
$xMean = ($n - 1) / 2.0;
|
||||
$yMean = array_sum($values) / $n;
|
||||
|
||||
$numerator = 0.0;
|
||||
$denominator = 0.0;
|
||||
|
||||
foreach ($values as $i => $y) {
|
||||
$x = $i - $xMean;
|
||||
$numerator += $x * ($y - $yMean);
|
||||
$denominator += $x * $x;
|
||||
}
|
||||
|
||||
$slope = $denominator > 0.0 ? $numerator / $denominator : 0.0;
|
||||
|
||||
$ssRes = 0.0;
|
||||
$ssTot = 0.0;
|
||||
foreach ($values as $i => $y) {
|
||||
$predicted = $yMean + $slope * ($i - $xMean);
|
||||
$ssRes += ($y - $predicted) ** 2;
|
||||
$ssTot += ($y - $yMean) ** 2;
|
||||
}
|
||||
|
||||
$rSquared = $ssTot > 0.0 ? max(0.0, 1.0 - ($ssRes / $ssTot)) : 0.0;
|
||||
|
||||
return ['slope' => $slope, 'r_squared' => $rSquared];
|
||||
return $maxCredit > 0.0 ? $credit / $maxCredit : 0.0;
|
||||
}
|
||||
|
||||
private function buildReasoning(string $direction, float $slope, array $trend, array $brandBehaviour): string
|
||||
/**
|
||||
* Yesterday / today / tomorrow snapshot + last-7-days series.
|
||||
* Regional (50km) when coordinates are given, with national fallback when
|
||||
* regional data is empty.
|
||||
*
|
||||
* @return array{
|
||||
* yesterday_avg: ?float,
|
||||
* today_avg: float,
|
||||
* tomorrow_estimated_avg: ?float,
|
||||
* yesterday_today_delta_pence: ?float,
|
||||
* last_7_days_series: array<int, array{date: string, avg: float}>,
|
||||
* last_7_days_change_pence: ?float,
|
||||
* cheapest_day: ?array{date: string, avg: float},
|
||||
* priciest_day: ?array{date: string, avg: float},
|
||||
* is_regional: bool
|
||||
* }
|
||||
*/
|
||||
private function computeWeeklySummary(FuelType $fuelType, ?float $lat, ?float $lng, float $todayAvg, float $slope): array
|
||||
{
|
||||
$yesterdayAvg = $this->getDailyAverage($fuelType, now()->subDay(), $lat, $lng);
|
||||
[$series, $usedRegional] = $this->getDailySeries($fuelType, 7, $lat, $lng);
|
||||
|
||||
$tomorrowEstimated = $todayAvg > 0.0 ? round($todayAvg + $slope, 1) : null;
|
||||
$yesterdayTodayDelta = $yesterdayAvg !== null ? round($todayAvg - $yesterdayAvg, 1) : null;
|
||||
|
||||
$cheapestDay = null;
|
||||
$priciestDay = null;
|
||||
$weekChange = null;
|
||||
|
||||
if (count($series) >= 2) {
|
||||
$byPrice = $series;
|
||||
usort($byPrice, fn ($a, $b) => $a['avg'] <=> $b['avg']);
|
||||
$cheapestDay = $byPrice[0];
|
||||
$priciestDay = $byPrice[count($byPrice) - 1];
|
||||
$weekChange = round(end($series)['avg'] - $series[0]['avg'], 1);
|
||||
}
|
||||
|
||||
return [
|
||||
'yesterday_avg' => $yesterdayAvg,
|
||||
'today_avg' => $todayAvg,
|
||||
'tomorrow_estimated_avg' => $tomorrowEstimated,
|
||||
'yesterday_today_delta_pence' => $yesterdayTodayDelta,
|
||||
'last_7_days_series' => $series,
|
||||
'last_7_days_change_pence' => $weekChange,
|
||||
'cheapest_day' => $cheapestDay,
|
||||
'priciest_day' => $priciestDay,
|
||||
'is_regional' => $usedRegional,
|
||||
];
|
||||
}
|
||||
|
||||
private function getDailyAverage(FuelType $fuelType, CarbonInterface $date, ?float $lat, ?float $lng): ?float
|
||||
{
|
||||
$dateString = $date->toDateString();
|
||||
|
||||
if ($lat !== null && $lng !== null) {
|
||||
[$radiusSql, $radiusBindings] = HaversineQuery::withinKm($lat, $lng, 50);
|
||||
|
||||
$regional = DB::table('station_prices')
|
||||
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
|
||||
->where('station_prices.fuel_type', $fuelType->value)
|
||||
->whereDate('station_prices.price_effective_at', $dateString)
|
||||
->whereRaw($radiusSql, $radiusBindings)
|
||||
->avg('station_prices.price_pence');
|
||||
|
||||
if ($regional !== null) {
|
||||
return round((float) $regional / 100, 1);
|
||||
}
|
||||
}
|
||||
|
||||
$national = DB::table('station_prices')
|
||||
->where('fuel_type', $fuelType->value)
|
||||
->whereDate('price_effective_at', $dateString)
|
||||
->avg('price_pence');
|
||||
|
||||
return $national !== null ? round((float) $national / 100, 1) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: array<int, array{date: string, avg: float}>, 1: bool}
|
||||
*/
|
||||
private function getDailySeries(FuelType $fuelType, int $days, ?float $lat, ?float $lng): array
|
||||
{
|
||||
$rows = collect();
|
||||
$usedRegional = false;
|
||||
|
||||
if ($lat !== null && $lng !== null) {
|
||||
[$radiusSql, $radiusBindings] = HaversineQuery::withinKm($lat, $lng, 50);
|
||||
|
||||
$rows = DB::table('station_prices')
|
||||
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
|
||||
->where('station_prices.fuel_type', $fuelType->value)
|
||||
->where('station_prices.price_effective_at', '>=', now()->subDays($days)->startOfDay())
|
||||
->whereRaw($radiusSql, $radiusBindings)
|
||||
->selectRaw('DATE(station_prices.price_effective_at) as day, AVG(station_prices.price_pence) as avg_price')
|
||||
->groupBy('day')
|
||||
->orderBy('day')
|
||||
->get();
|
||||
|
||||
$usedRegional = $rows->isNotEmpty();
|
||||
}
|
||||
|
||||
if ($rows->isEmpty()) {
|
||||
$rows = DB::table('station_prices')
|
||||
->where('fuel_type', $fuelType->value)
|
||||
->where('price_effective_at', '>=', now()->subDays($days)->startOfDay())
|
||||
->selectRaw('DATE(price_effective_at) as day, AVG(price_pence) as avg_price')
|
||||
->groupBy('day')
|
||||
->orderBy('day')
|
||||
->get();
|
||||
}
|
||||
|
||||
$series = $rows->map(fn ($r): array => [
|
||||
'date' => (string) $r->day,
|
||||
'avg' => round((float) $r->avg_price / 100, 1),
|
||||
])->values()->all();
|
||||
|
||||
return [$series, $usedRegional];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{enabled: bool, detail: string, direction: string} $trend
|
||||
* @param array{enabled: bool, detail: string, direction: string} $brandBehaviour
|
||||
* @param array{enabled: bool, detail: string, direction: string} $dayOfWeek
|
||||
*/
|
||||
private function buildReasoning(string $direction, float $slope, array $trend, array $brandBehaviour, array $dayOfWeek): string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
@@ -503,8 +397,16 @@ class NationalFuelPredictionService
|
||||
$parts[] = $brandBehaviour['detail'];
|
||||
}
|
||||
|
||||
if ($dayOfWeek['enabled']) {
|
||||
$parts[] = $dayOfWeek['detail'];
|
||||
}
|
||||
|
||||
if (empty($parts)) {
|
||||
return 'No clear pattern — fill up at the cheapest station near you now.';
|
||||
return match ($direction) {
|
||||
'up' => 'Mild upward signals — top up soon if you\'re nearby.',
|
||||
'down' => 'Mild downward signals — wait a day or two if your tank can hold.',
|
||||
default => 'No clear pattern — fill up at the cheapest station near you now.',
|
||||
};
|
||||
}
|
||||
|
||||
return implode(' ', $parts);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
61
app/Services/Prediction/Signals/AbstractSignal.php
Normal file
61
app/Services/Prediction/Signals/AbstractSignal.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Prediction\Signals;
|
||||
|
||||
abstract class AbstractSignal implements Signal
|
||||
{
|
||||
/** @return array{score: 0.0, confidence: 0.0, direction: 'stable', detail: string, data_points: 0, enabled: false} */
|
||||
protected function disabledSignal(string $detail): array
|
||||
{
|
||||
return [
|
||||
'score' => 0.0,
|
||||
'confidence' => 0.0,
|
||||
'direction' => 'stable',
|
||||
'detail' => $detail,
|
||||
'data_points' => 0,
|
||||
'enabled' => false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Least-squares linear regression. x = array index, y = value.
|
||||
*
|
||||
* @param float[] $values
|
||||
* @return array{slope: float, r_squared: float}
|
||||
*/
|
||||
protected function linearRegression(array $values): array
|
||||
{
|
||||
$n = count($values);
|
||||
|
||||
if ($n < 2) {
|
||||
return ['slope' => 0.0, 'r_squared' => 0.0];
|
||||
}
|
||||
|
||||
$xMean = ($n - 1) / 2.0;
|
||||
$yMean = array_sum($values) / $n;
|
||||
|
||||
$numerator = 0.0;
|
||||
$denominator = 0.0;
|
||||
|
||||
foreach ($values as $i => $y) {
|
||||
$x = $i - $xMean;
|
||||
$numerator += $x * ($y - $yMean);
|
||||
$denominator += $x * $x;
|
||||
}
|
||||
|
||||
$slope = $denominator > 0.0 ? $numerator / $denominator : 0.0;
|
||||
|
||||
$ssRes = 0.0;
|
||||
$ssTot = 0.0;
|
||||
|
||||
foreach ($values as $i => $y) {
|
||||
$predicted = $yMean + $slope * ($i - $xMean);
|
||||
$ssRes += ($y - $predicted) ** 2;
|
||||
$ssTot += ($y - $yMean) ** 2;
|
||||
}
|
||||
|
||||
$rSquared = $ssTot > 0.0 ? max(0.0, 1.0 - ($ssRes / $ssTot)) : 0.0;
|
||||
|
||||
return ['slope' => $slope, 'r_squared' => $rSquared];
|
||||
}
|
||||
}
|
||||
61
app/Services/Prediction/Signals/BrandBehaviourSignal.php
Normal file
61
app/Services/Prediction/Signals/BrandBehaviourSignal.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Prediction\Signals;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class BrandBehaviourSignal extends AbstractSignal
|
||||
{
|
||||
public function compute(SignalContext $context): array
|
||||
{
|
||||
$rows = DB::table('station_prices')
|
||||
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
|
||||
->where('station_prices.fuel_type', $context->fuelType->value)
|
||||
->where('station_prices.price_effective_at', '>=', now()->subDays(7))
|
||||
->selectRaw('stations.is_supermarket, DATE(station_prices.price_effective_at) as day, AVG(station_prices.price_pence) as avg_price')
|
||||
->groupBy('stations.is_supermarket', 'day')
|
||||
->orderBy('day')
|
||||
->get();
|
||||
|
||||
$supermarket = $rows->where('is_supermarket', 1)->values();
|
||||
$major = $rows->where('is_supermarket', 0)->values();
|
||||
|
||||
if ($supermarket->count() < 2 || $major->count() < 2) {
|
||||
return $this->disabledSignal('Insufficient brand data for comparison');
|
||||
}
|
||||
|
||||
$supermarketSlope = $this->linearRegression($supermarket->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all())['slope'];
|
||||
$majorSlope = $this->linearRegression($major->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all())['slope'];
|
||||
|
||||
$divergence = round(abs($supermarketSlope - $majorSlope) * 7, 1);
|
||||
$supermarketChange = round($supermarketSlope * 7, 1);
|
||||
$majorChange = round($majorSlope * 7, 1);
|
||||
|
||||
if ($divergence < 1.0) {
|
||||
return [
|
||||
'score' => 0.0,
|
||||
'confidence' => 0.5,
|
||||
'direction' => 'stable',
|
||||
'detail' => 'Supermarkets and majors moving in sync.',
|
||||
'data_points' => $rows->count(),
|
||||
'enabled' => true,
|
||||
];
|
||||
}
|
||||
|
||||
$leaderChange = abs($supermarketChange) > abs($majorChange) ? $supermarketChange : $majorChange;
|
||||
$direction = $leaderChange > 0 ? 'up' : 'down';
|
||||
$leader = abs($supermarketChange) > abs($majorChange) ? 'Supermarkets' : 'Majors';
|
||||
$follower = $leader === 'Supermarkets' ? 'majors' : 'supermarkets';
|
||||
$leaderAbs = abs($leaderChange);
|
||||
$followerChange = $leader === 'Supermarkets' ? abs($majorChange) : abs($supermarketChange);
|
||||
|
||||
return [
|
||||
'score' => $direction === 'up' ? 1.0 : -1.0,
|
||||
'confidence' => min(1.0, $divergence / 5.0),
|
||||
'direction' => $direction,
|
||||
'detail' => "{$leader} ".($leaderChange > 0 ? 'rose' : 'fell')." {$leaderAbs}p vs {$follower} {$followerChange}p (divergence: {$divergence}p). Expect {$follower} to follow.",
|
||||
'data_points' => $rows->count(),
|
||||
'enabled' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
80
app/Services/Prediction/Signals/DayOfWeekSignal.php
Normal file
80
app/Services/Prediction/Signals/DayOfWeekSignal.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Prediction\Signals;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class DayOfWeekSignal extends AbstractSignal
|
||||
{
|
||||
private const int MIN_DAYS = 21;
|
||||
|
||||
public function compute(SignalContext $context): array
|
||||
{
|
||||
$dowExpr = DbDialect::dayOfWeekExpr('price_effective_at');
|
||||
|
||||
$rows = DB::table('station_prices')
|
||||
->where('fuel_type', $context->fuelType->value)
|
||||
->where('price_effective_at', '>=', now()->subDays(90))
|
||||
->selectRaw("{$dowExpr} as dow, DATE(price_effective_at) as day, AVG(price_pence) as avg_price")
|
||||
->groupBy('dow', 'day')
|
||||
->get();
|
||||
|
||||
$uniqueDays = $rows->pluck('day')->unique()->count();
|
||||
|
||||
if ($uniqueDays < self::MIN_DAYS) {
|
||||
return $this->disabledSignal("Insufficient history for day-of-week pattern ({$uniqueDays} days, need ".self::MIN_DAYS.')');
|
||||
}
|
||||
|
||||
$dowAverages = $rows->groupBy('dow')->map(fn ($g) => $g->avg('avg_price'));
|
||||
$weekAvg = $dowAverages->avg();
|
||||
$todayDow = (int) now()->format('w') + 1; // PHP 0=Sun → MySQL 1=Sun
|
||||
$todayAvg = $dowAverages->get($todayDow, $weekAvg);
|
||||
$cheapestDow = $dowAverages->keys()->sortBy(fn ($k) => $dowAverages[$k])->first();
|
||||
$dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
$todayName = $dayNames[($todayDow - 1) % 7] ?? 'Today';
|
||||
$tomorrowName = $dayNames[$todayDow % 7] ?? 'Tomorrow';
|
||||
|
||||
$todayDeltaPence = round(($todayAvg - $weekAvg) / 100, 1);
|
||||
$tomorrowDeltaPence = round(($dowAverages->get(($todayDow % 7) + 1, $weekAvg) - $todayAvg) / 100, 1);
|
||||
|
||||
$direction = match (true) {
|
||||
($todayAvg - $weekAvg) / 100 >= 1.5 => 'up',
|
||||
($weekAvg - $todayAvg) / 100 >= 1.5 => 'down',
|
||||
default => 'stable',
|
||||
};
|
||||
|
||||
$score = $direction === 'stable' ? 0.0 : ($direction === 'up' ? 1.0 : -1.0);
|
||||
|
||||
$parts = [];
|
||||
$parts[] = abs($todayDeltaPence) < 0.1
|
||||
? "Today ({$todayName}) is typically in line with the weekly average."
|
||||
: sprintf(
|
||||
'Today (%s) is typically %sp %s the weekly average.',
|
||||
$todayName,
|
||||
number_format(abs($todayDeltaPence), 1),
|
||||
$todayDeltaPence > 0 ? 'above' : 'below',
|
||||
);
|
||||
|
||||
$parts[] = abs($tomorrowDeltaPence) < 0.1
|
||||
? "Tomorrow ({$tomorrowName}) is typically the same."
|
||||
: sprintf(
|
||||
'Tomorrow (%s) is typically %sp %s.',
|
||||
$tomorrowName,
|
||||
number_format(abs($tomorrowDeltaPence), 1),
|
||||
$tomorrowDeltaPence < 0 ? 'cheaper' : 'pricier',
|
||||
);
|
||||
|
||||
if ($cheapestDow === $todayDow) {
|
||||
$parts[] = 'Today is historically the cheapest day of the week.';
|
||||
}
|
||||
|
||||
return [
|
||||
'score' => $score,
|
||||
'confidence' => min(1.0, $uniqueDays / 90),
|
||||
'direction' => $direction,
|
||||
'detail' => implode(' ', $parts),
|
||||
'data_points' => $uniqueDays,
|
||||
'enabled' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
40
app/Services/Prediction/Signals/DbDialect.php
Normal file
40
app/Services/Prediction/Signals/DbDialect.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Prediction\Signals;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* SQL dialect helpers for the small set of MySQL/SQLite differences the
|
||||
* signal classes care about. Centralises the isSqlite ternaries that were
|
||||
* duplicated across DayOfWeekSignal and StickinessSignal.
|
||||
*/
|
||||
final class DbDialect
|
||||
{
|
||||
private static function isSqlite(): bool
|
||||
{
|
||||
return DB::connection()->getDriverName() === 'sqlite';
|
||||
}
|
||||
|
||||
/**
|
||||
* Day-of-week expression returning 1=Sun..7=Sat (MySQL DAYOFWEEK convention).
|
||||
* Targets a column on the queried table.
|
||||
*/
|
||||
public static function dayOfWeekExpr(string $column): string
|
||||
{
|
||||
return self::isSqlite()
|
||||
? "(CAST(strftime('%w', {$column}) AS INTEGER) + 1)"
|
||||
: "DAYOFWEEK({$column})";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole-day difference between MAX and MIN of a datetime column, suitable
|
||||
* for use in an aggregate selectRaw.
|
||||
*/
|
||||
public static function maxMinDayDiffExpr(string $column): string
|
||||
{
|
||||
return self::isSqlite()
|
||||
? "CAST((julianday(MAX({$column})) - julianday(MIN({$column}))) AS INTEGER)"
|
||||
: "DATEDIFF(MAX({$column}), MIN({$column}))";
|
||||
}
|
||||
}
|
||||
63
app/Services/Prediction/Signals/OilSignal.php
Normal file
63
app/Services/Prediction/Signals/OilSignal.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Prediction\Signals;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class OilSignal extends AbstractSignal
|
||||
{
|
||||
/**
|
||||
* Reads the most recent Brent crude prediction (LLM preferred, EWMA
|
||||
* fallback) covering today or later. Sourced from price_predictions,
|
||||
* which OilPriceService populates daily.
|
||||
*/
|
||||
public function compute(SignalContext $context): array
|
||||
{
|
||||
$prediction = null;
|
||||
|
||||
foreach (['llm_with_context', 'llm', 'ewma'] as $source) {
|
||||
$prediction = DB::table('price_predictions')
|
||||
->where('source', $source)
|
||||
->where('predicted_for', '>=', now()->toDateString())
|
||||
->orderByDesc('predicted_for')
|
||||
->orderByDesc('generated_at')
|
||||
->first();
|
||||
|
||||
if ($prediction !== null) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($prediction === null) {
|
||||
return $this->disabledSignal('No oil price prediction available');
|
||||
}
|
||||
|
||||
$direction = match ($prediction->direction) {
|
||||
'rising' => 'up',
|
||||
'falling' => 'down',
|
||||
default => 'stable',
|
||||
};
|
||||
|
||||
$score = match ($direction) {
|
||||
'up' => 1.0,
|
||||
'down' => -1.0,
|
||||
default => 0.0,
|
||||
};
|
||||
|
||||
$confidence = round(((float) $prediction->confidence) / 100, 2);
|
||||
|
||||
return [
|
||||
'score' => $score,
|
||||
'confidence' => $confidence,
|
||||
'direction' => $direction,
|
||||
'detail' => sprintf(
|
||||
'Brent crude %s (%s, %d%% confidence)',
|
||||
$prediction->direction,
|
||||
$prediction->source,
|
||||
(int) $prediction->confidence,
|
||||
),
|
||||
'data_points' => 1,
|
||||
'enabled' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
52
app/Services/Prediction/Signals/RegionalMomentumSignal.php
Normal file
52
app/Services/Prediction/Signals/RegionalMomentumSignal.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Prediction\Signals;
|
||||
|
||||
use App\Services\HaversineQuery;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class RegionalMomentumSignal extends AbstractSignal
|
||||
{
|
||||
private const float SLOPE_THRESHOLD_PENCE = 0.3;
|
||||
|
||||
private const float REGIONAL_RADIUS_KM = 50.0;
|
||||
|
||||
public function compute(SignalContext $context): array
|
||||
{
|
||||
if (! $context->hasCoordinates()) {
|
||||
return $this->disabledSignal('No coordinates provided for regional momentum analysis');
|
||||
}
|
||||
|
||||
[$radiusSql, $radiusBindings] = HaversineQuery::withinKm($context->lat, $context->lng, self::REGIONAL_RADIUS_KM);
|
||||
|
||||
$rows = DB::table('station_prices')
|
||||
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
|
||||
->where('station_prices.fuel_type', $context->fuelType->value)
|
||||
->where('station_prices.price_effective_at', '>=', now()->subDays(14))
|
||||
->whereRaw($radiusSql, $radiusBindings)
|
||||
->selectRaw('DATE(station_prices.price_effective_at) as day, AVG(station_prices.price_pence) as avg_price')
|
||||
->groupBy('day')
|
||||
->orderBy('day')
|
||||
->get();
|
||||
|
||||
if ($rows->count() < 3) {
|
||||
return $this->disabledSignal('Insufficient regional data');
|
||||
}
|
||||
|
||||
$regression = $this->linearRegression($rows->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all());
|
||||
$direction = match (true) {
|
||||
$regression['slope'] >= self::SLOPE_THRESHOLD_PENCE => 'up',
|
||||
$regression['slope'] <= -self::SLOPE_THRESHOLD_PENCE => 'down',
|
||||
default => 'stable',
|
||||
};
|
||||
|
||||
return [
|
||||
'score' => $direction === 'stable' ? 0.0 : ($direction === 'up' ? 0.7 : -0.7),
|
||||
'confidence' => min(1.0, $regression['r_squared']),
|
||||
'direction' => $direction,
|
||||
'detail' => 'Regional trend: '.round($regression['slope'], 2).'p/day (R²='.round($regression['r_squared'], 2).')',
|
||||
'data_points' => $rows->count(),
|
||||
'enabled' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
24
app/Services/Prediction/Signals/Signal.php
Normal file
24
app/Services/Prediction/Signals/Signal.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Prediction\Signals;
|
||||
|
||||
interface Signal
|
||||
{
|
||||
/**
|
||||
* Evaluate the signal against the given context.
|
||||
*
|
||||
* Returns the canonical signal payload. Implementations may add extra
|
||||
* keys beyond the base shape (e.g. trend adds slope + r_squared).
|
||||
*
|
||||
* @return array{
|
||||
* score: float,
|
||||
* confidence: float,
|
||||
* direction: string,
|
||||
* detail: string,
|
||||
* data_points: int,
|
||||
* enabled: bool,
|
||||
* ...
|
||||
* }
|
||||
*/
|
||||
public function compute(SignalContext $context): array;
|
||||
}
|
||||
24
app/Services/Prediction/Signals/SignalContext.php
Normal file
24
app/Services/Prediction/Signals/SignalContext.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Prediction\Signals;
|
||||
|
||||
use App\Enums\FuelType;
|
||||
|
||||
/**
|
||||
* Inputs required to evaluate a prediction signal. Individual signals may
|
||||
* ignore fields they don't need — for example OilSignal doesn't use fuelType,
|
||||
* RegionalMomentumSignal requires lat/lng to be non-null.
|
||||
*/
|
||||
final readonly class SignalContext
|
||||
{
|
||||
public function __construct(
|
||||
public FuelType $fuelType,
|
||||
public ?float $lat = null,
|
||||
public ?float $lng = null,
|
||||
) {}
|
||||
|
||||
public function hasCoordinates(): bool
|
||||
{
|
||||
return $this->lat !== null && $this->lng !== null;
|
||||
}
|
||||
}
|
||||
50
app/Services/Prediction/Signals/StickinessSignal.php
Normal file
50
app/Services/Prediction/Signals/StickinessSignal.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Prediction\Signals;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class StickinessSignal extends AbstractSignal
|
||||
{
|
||||
public function compute(SignalContext $context): array
|
||||
{
|
||||
$diffExpr = DbDialect::maxMinDayDiffExpr('price_effective_at');
|
||||
|
||||
$rows = DB::table('station_prices')
|
||||
->where('fuel_type', $context->fuelType->value)
|
||||
->where('price_effective_at', '>=', now()->subDays(30))
|
||||
->selectRaw("station_id, COUNT(*) as changes, {$diffExpr} as span_days")
|
||||
->groupBy('station_id')
|
||||
->having('changes', '>', 1)
|
||||
->having('span_days', '>', 0)
|
||||
->get();
|
||||
|
||||
if ($rows->count() < 10) {
|
||||
return $this->disabledSignal('Insufficient stickiness data (need 10+ stations with price history)');
|
||||
}
|
||||
|
||||
$avgHoldDays = $rows->avg(fn ($r) => $r->span_days / ($r->changes - 1));
|
||||
$avgHoldDays = round((float) $avgHoldDays, 1);
|
||||
|
||||
$score = match (true) {
|
||||
$avgHoldDays < 2 => -0.1,
|
||||
$avgHoldDays > 5 => 0.1,
|
||||
default => 0.0,
|
||||
};
|
||||
|
||||
$detail = match (true) {
|
||||
$avgHoldDays < 2 => "Volatile prices (avg hold: {$avgHoldDays} days) — harder to predict.",
|
||||
$avgHoldDays > 5 => "Sticky prices (avg hold: {$avgHoldDays} days) — more predictable.",
|
||||
default => "Normal hold period (avg: {$avgHoldDays} days).",
|
||||
};
|
||||
|
||||
return [
|
||||
'score' => $score,
|
||||
'confidence' => min(1.0, $rows->count() / 200),
|
||||
'direction' => 'stable',
|
||||
'detail' => $detail,
|
||||
'data_points' => $rows->count(),
|
||||
'enabled' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
86
app/Services/Prediction/Signals/TrendSignal.php
Normal file
86
app/Services/Prediction/Signals/TrendSignal.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Prediction\Signals;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class TrendSignal extends AbstractSignal
|
||||
{
|
||||
private const float R_SQUARED_THRESHOLD = 0.5;
|
||||
|
||||
private const float SLOPE_THRESHOLD_PENCE = 0.3;
|
||||
|
||||
private const float SLOPE_SATURATION_PENCE = 0.5;
|
||||
|
||||
private const int PREDICTION_HORIZON_DAYS = 7;
|
||||
|
||||
/** @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool, slope: float, r_squared: float} */
|
||||
public function compute(SignalContext $context): array
|
||||
{
|
||||
foreach ([5, 14] as $lookbackDays) {
|
||||
$rows = DB::table('station_prices')
|
||||
->where('fuel_type', $context->fuelType->value)
|
||||
->where('price_effective_at', '>=', now()->subDays($lookbackDays))
|
||||
->selectRaw('DATE(price_effective_at) as day, AVG(price_pence) as avg_price')
|
||||
->groupBy('day')
|
||||
->orderBy('day')
|
||||
->get();
|
||||
|
||||
if ($rows->count() < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$regression = $this->linearRegression($rows->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all());
|
||||
|
||||
if ($regression['r_squared'] >= self::R_SQUARED_THRESHOLD) {
|
||||
$slope = $regression['slope'];
|
||||
$direction = match (true) {
|
||||
$slope >= self::SLOPE_THRESHOLD_PENCE => 'up',
|
||||
$slope <= -self::SLOPE_THRESHOLD_PENCE => 'down',
|
||||
default => 'stable',
|
||||
};
|
||||
$absSlope = abs($slope);
|
||||
$score = $direction === 'stable' ? 0.0 : min(1.0, $absSlope / self::SLOPE_SATURATION_PENCE) * ($slope > 0 ? 1 : -1);
|
||||
$projected = round($slope * $lookbackDays, 1);
|
||||
$detail = $direction === 'stable'
|
||||
? "Prices flat over {$lookbackDays} days (slope: {$slope}p/day, R²={$regression['r_squared']})"
|
||||
: sprintf(
|
||||
'%s at %sp/day over %d days (R²=%s, ~%s%sp in %dd)',
|
||||
$slope > 0 ? 'Rising' : 'Falling',
|
||||
abs(round($slope, 2)),
|
||||
$lookbackDays,
|
||||
round($regression['r_squared'], 2),
|
||||
$projected > 0 ? '+' : '',
|
||||
$projected,
|
||||
self::PREDICTION_HORIZON_DAYS,
|
||||
);
|
||||
|
||||
if ($lookbackDays === 5) {
|
||||
$detail .= ' [Adaptive lookback active]';
|
||||
}
|
||||
|
||||
return [
|
||||
'score' => $score,
|
||||
'confidence' => min(1.0, $regression['r_squared']),
|
||||
'direction' => $direction,
|
||||
'detail' => $detail,
|
||||
'data_points' => $rows->count(),
|
||||
'enabled' => true,
|
||||
'slope' => round($slope, 3),
|
||||
'r_squared' => round($regression['r_squared'], 3),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'score' => 0.0,
|
||||
'confidence' => 0.0,
|
||||
'direction' => 'stable',
|
||||
'detail' => 'Insufficient price history or noisy data (R² below threshold)',
|
||||
'data_points' => 0,
|
||||
'enabled' => false,
|
||||
'slope' => 0.0,
|
||||
'r_squared' => 0.0,
|
||||
];
|
||||
}
|
||||
}
|
||||
16
app/Services/StationSearch/SearchCriteria.php
Normal file
16
app/Services/StationSearch/SearchCriteria.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\StationSearch;
|
||||
|
||||
use App\Enums\FuelType;
|
||||
|
||||
final readonly class SearchCriteria
|
||||
{
|
||||
public function __construct(
|
||||
public float $lat,
|
||||
public float $lng,
|
||||
public FuelType $fuelType,
|
||||
public float $radiusKm,
|
||||
public string $sort,
|
||||
) {}
|
||||
}
|
||||
21
app/Services/StationSearch/SearchResult.php
Normal file
21
app/Services/StationSearch/SearchResult.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\StationSearch;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
final readonly class SearchResult
|
||||
{
|
||||
/**
|
||||
* @param Collection<int, mixed> $stations Sorted station rows with _updated_at/_reliability/_classification cached
|
||||
* @param array{lowest: ?int, highest: ?int, avg: ?float} $pricesSummary
|
||||
* @param array{reliable: int, stale: int, outdated: int} $reliabilityCounts
|
||||
* @param array<string, mixed> $prediction
|
||||
*/
|
||||
public function __construct(
|
||||
public Collection $stations,
|
||||
public array $pricesSummary,
|
||||
public array $reliabilityCounts,
|
||||
public array $prediction,
|
||||
) {}
|
||||
}
|
||||
151
app/Services/StationSearch/StationSearchService.php
Normal file
151
app/Services/StationSearch/StationSearchService.php
Normal file
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\StationSearch;
|
||||
|
||||
use App\Enums\PriceClassification;
|
||||
use App\Enums\PriceReliability;
|
||||
use App\Models\Search;
|
||||
use App\Models\Station;
|
||||
use App\Models\User;
|
||||
use App\Services\HaversineQuery;
|
||||
use App\Services\NationalFuelPredictionService;
|
||||
use App\Services\PlanFeatures;
|
||||
use Illuminate\Database\Query\JoinClause;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
final class StationSearchService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NationalFuelPredictionService $predictionService,
|
||||
) {}
|
||||
|
||||
public function search(SearchCriteria $criteria, ?User $user, ?string $ipHash): SearchResult
|
||||
{
|
||||
$stations = $this->fetchAndSortStations($criteria);
|
||||
$prices = $stations->pluck('price_pence');
|
||||
|
||||
$this->logSearch($criteria, $stations->count(), $prices, $ipHash);
|
||||
|
||||
return new SearchResult(
|
||||
stations: $stations,
|
||||
pricesSummary: [
|
||||
'lowest' => $prices->min(),
|
||||
'highest' => $prices->max(),
|
||||
'avg' => $prices->isNotEmpty() ? round($prices->avg(), 2) : null,
|
||||
],
|
||||
reliabilityCounts: $this->countReliability($stations),
|
||||
prediction: $this->buildPrediction($user, $criteria),
|
||||
);
|
||||
}
|
||||
|
||||
/** @return Collection<int, mixed> */
|
||||
private function fetchAndSortStations(SearchCriteria $criteria): Collection
|
||||
{
|
||||
[$distanceSql, $distanceBindings] = HaversineQuery::distanceKm($criteria->lat, $criteria->lng);
|
||||
|
||||
$all = Station::query()
|
||||
->selectRaw(
|
||||
"stations.*, spc.price_pence, spc.fuel_type, spc.price_effective_at, {$distanceSql} AS distance_km",
|
||||
$distanceBindings,
|
||||
)
|
||||
->join('station_prices_current as spc', function (JoinClause $join) use ($criteria): void {
|
||||
$join->on('stations.node_id', '=', 'spc.station_id')
|
||||
->where('spc.fuel_type', '=', $criteria->fuelType->value);
|
||||
})
|
||||
->where('stations.temporary_closure', false)
|
||||
->where('stations.permanent_closure', false)
|
||||
->get();
|
||||
|
||||
// Compute reliability + classification once per row so the sort, the
|
||||
// count groupBy, and the StationResource render all read cached
|
||||
// values instead of re-invoking PriceReliability::fromUpdatedAt.
|
||||
$all->each(function ($s): void {
|
||||
$updatedAt = $s->price_effective_at ? Carbon::parse($s->price_effective_at) : null;
|
||||
$s->_updated_at = $updatedAt;
|
||||
$s->_reliability = PriceReliability::fromUpdatedAt($updatedAt);
|
||||
$s->_classification = PriceClassification::fromUpdatedAt($updatedAt);
|
||||
});
|
||||
|
||||
$filtered = $all->filter(fn ($s) => (float) $s->distance_km <= $criteria->radiusKm);
|
||||
|
||||
return $this->applySort($filtered, $criteria->sort);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, mixed> $filtered
|
||||
* @return Collection<int, mixed>
|
||||
*/
|
||||
private function applySort(Collection $filtered, string $sort): Collection
|
||||
{
|
||||
if ($sort === 'reliable') {
|
||||
return $filtered
|
||||
->sort(function ($a, $b) {
|
||||
return $a->_reliability->weight() <=> $b->_reliability->weight()
|
||||
?: ((int) $a->price_pence <=> (int) $b->price_pence)
|
||||
?: ((float) $a->distance_km <=> (float) $b->distance_km);
|
||||
})
|
||||
->values();
|
||||
}
|
||||
|
||||
return $filtered->sortBy(match ($sort) {
|
||||
'price' => fn ($s) => (int) $s->price_pence,
|
||||
'updated' => fn ($s) => $s->price_effective_at ? -strtotime($s->price_effective_at) : PHP_INT_MAX,
|
||||
default => fn ($s) => (float) $s->distance_km,
|
||||
})->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, mixed> $stations
|
||||
* @return array{reliable: int, stale: int, outdated: int}
|
||||
*/
|
||||
private function countReliability(Collection $stations): array
|
||||
{
|
||||
$counts = $stations->groupBy(fn ($s) => $s->_reliability->value)->map->count();
|
||||
|
||||
return [
|
||||
'reliable' => (int) $counts->get(PriceReliability::Reliable->value, 0),
|
||||
'stale' => (int) $counts->get(PriceReliability::Stale->value, 0),
|
||||
'outdated' => (int) $counts->get(PriceReliability::Outdated->value, 0),
|
||||
];
|
||||
}
|
||||
|
||||
/** @param Collection<int, mixed> $prices */
|
||||
private function logSearch(SearchCriteria $criteria, int $resultsCount, Collection $prices, ?string $ipHash): void
|
||||
{
|
||||
Search::create([
|
||||
'lat_bucket' => round($criteria->lat, 2),
|
||||
'lng_bucket' => round($criteria->lng, 2),
|
||||
'fuel_type' => $criteria->fuelType->value,
|
||||
'results_count' => $resultsCount,
|
||||
'lowest_pence' => $prices->min(),
|
||||
'highest_pence' => $prices->max(),
|
||||
'avg_pence' => $prices->isNotEmpty() ? round($prices->avg(), 2) : null,
|
||||
'searched_at' => now(),
|
||||
'ip_hash' => $ipHash ?? hash('sha256', ''),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Free/guest users get a stripped teaser; users with the ai_predictions
|
||||
* feature get the full multi-signal payload.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildPrediction(?User $user, SearchCriteria $criteria): array
|
||||
{
|
||||
$result = $this->predictionService->predict($criteria->lat, $criteria->lng);
|
||||
|
||||
$canSeeFull = $user !== null && PlanFeatures::for($user)->can('ai_predictions');
|
||||
|
||||
if (! $canSeeFull) {
|
||||
return [
|
||||
'fuel_type' => $result['fuel_type'],
|
||||
'predicted_direction' => $result['predicted_direction'],
|
||||
'tier_locked' => true,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,18 @@ return [
|
||||
'api_key' => env('FUELALERT_API_KEY'),
|
||||
],
|
||||
|
||||
'onesignal' => [
|
||||
'app_id' => env('ONESIGNAL_APP_ID'),
|
||||
'api_key' => env('ONESIGNAL_API_KEY'),
|
||||
],
|
||||
|
||||
'vonage' => [
|
||||
'key' => env('VONAGE_KEY'),
|
||||
'secret' => env('VONAGE_SECRET'),
|
||||
'whatsapp_from' => env('VONAGE_WHATSAPP_FROM'),
|
||||
'sms_from' => env('VONAGE_SMS_FROM', 'FuelAlert'),
|
||||
],
|
||||
|
||||
'stripe' => [
|
||||
'prices' => [
|
||||
'basic' => [
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('weekly_pump_prices', function (Blueprint $table) {
|
||||
$table->date('date')->primary()->comment('Week starting (Monday) per BEIS publication');
|
||||
$table->unsignedSmallInteger('ulsp_pence')->comment('Petrol pump price × 100');
|
||||
$table->unsignedSmallInteger('ulsd_pence')->comment('Diesel pump price × 100');
|
||||
$table->unsignedSmallInteger('ulsp_duty_pence')->comment('Petrol duty × 100');
|
||||
$table->unsignedSmallInteger('ulsd_duty_pence')->comment('Diesel duty × 100');
|
||||
$table->unsignedTinyInteger('ulsp_vat_pct')->comment('VAT %');
|
||||
$table->unsignedTinyInteger('ulsd_vat_pct')->comment('VAT %');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('weekly_pump_prices');
|
||||
}
|
||||
};
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
618
docs/superpowers/specs/2026-05-01-prediction-rebuild-design.md
Normal file
618
docs/superpowers/specs/2026-05-01-prediction-rebuild-design.md
Normal file
@@ -0,0 +1,618 @@
|
||||
# Prediction Rebuild — Design Spec
|
||||
|
||||
## Context
|
||||
|
||||
The current prediction service (`NationalFuelPredictionService` + six signal
|
||||
classes) produces output the user has repeatedly described as "doesn't make
|
||||
sense": headlines that contradict their own reasoning text, weights that
|
||||
nobody can defend a number on, and confidence values that aren't grounded in
|
||||
any track record. Two earlier docs (`.claude/rules/scoring.md`, `.claude/rules/prediction.md`)
|
||||
disagree on the weights of the same signals, which is itself evidence that
|
||||
the design has drifted.
|
||||
|
||||
This spec replaces the entire prediction stack from scratch around the
|
||||
historical data we actually have, with a model whose confidence values are
|
||||
calibrated against its own backtested track record.
|
||||
|
||||
Goals:
|
||||
- A "fill up now or wait?" call honest about uncertainty.
|
||||
- Confidence values calibrated against backtested residuals — "70%" actually
|
||||
means "in 7 of every 10 cases like this, the model called direction right".
|
||||
- Simple enough to debug a year from now.
|
||||
- Remove the six-signal aggregator entirely.
|
||||
- Recognise that pump prices, while *measured* weekly by BEIS, can *move* daily
|
||||
during oil shocks (Iran, OPEC surprise cuts, Hormuz disruption). The static
|
||||
weekly forecast must be backed by a daily news/event overlay so we can flag
|
||||
staleness in real time rather than pretend a Monday number is still valid on
|
||||
Thursday after a 6% Brent move.
|
||||
|
||||
---
|
||||
|
||||
## Inputs (audited 2026-05-01)
|
||||
|
||||
| Source | Status | Use in v1 |
|
||||
|---|---|---|
|
||||
| `weekly_pump_prices` | 435 weeks, all Mondays, 0 outliers, 1 duty change (Mar 2022, 57.95p → 52.95p), VAT stable at 20% | **Foundation** — train Layer 1 |
|
||||
| `station_prices_current` | ~7,550 stations × e10, ~7,620 × b7_standard | **Layer 2** — descriptive snapshot |
|
||||
| `stations` | 7,747 stations, 1,989 supermarkets, lat/lng | Layer 2 |
|
||||
| `station_prices` | 75 days of changes since 2026-01-16, sample mix uneven per day | Not modelled in v1, but **used by the volatility regime detector** as a churn indicator (% stations changing price / day vs 30-day baseline). |
|
||||
| `brent_prices` | 30 days only | **Backfilled in Phase 7** (8 years from FRED, single API call). Used as a Brent-move volatility trigger and as fuel for the daily LLM overlay. |
|
||||
|
||||
The Fuel Finder API has been confirmed empirically to have **no historical
|
||||
archive** — `effective-start-timestamp` is a station-level filter on current
|
||||
prices, not a time-window query. Per-station deep history can only accrue
|
||||
forward from the date polling started.
|
||||
|
||||
---
|
||||
|
||||
## Architecture — five thin layers
|
||||
|
||||
### Layer 1 — National weekly forecaster (predictive, calibrated)
|
||||
|
||||
Trained once weekly on `weekly_pump_prices`. Output:
|
||||
|
||||
- `direction ∈ {rising, falling, flat}`
|
||||
- `magnitude_pence` — predicted Δ price next week
|
||||
- `ridge_confidence` (0–100) — calibrated from backtested residuals, not
|
||||
from the model's raw output
|
||||
|
||||
This is the **quantitative baseline**. It updates only when the BEIS Monday
|
||||
publication arrives (so the *forecast itself* changes weekly), but its
|
||||
*displayed confidence* (Layer 3) is adjusted in real time by Layers 4 and 5.
|
||||
|
||||
`direction = flat` whenever `|magnitude_pence| < FLAT_THRESHOLD`. Phase 3
|
||||
picks `FLAT_THRESHOLD` from the backtest residual distribution; the
|
||||
starting value is **0.2p / litre**.
|
||||
|
||||
### Layer 2 — Local snapshot (descriptive, NOT predictive)
|
||||
|
||||
Pure SQL aggregates against `station_prices_current` + Haversine on
|
||||
`stations.lat/lng`. No ML, no history, no surprises:
|
||||
|
||||
- `local_avg_50km(fuel_type, lat, lng)`
|
||||
- `national_avg(fuel_type)`
|
||||
- `cheapest_within(km, fuel_type, lat, lng)`
|
||||
- `supermarket_avg_local`, `major_avg_local`, gap
|
||||
|
||||
Layer 2 never speaks about the future. It describes the present.
|
||||
|
||||
### Layer 3 — Verdict merger (rule-based gates, no multipliers)
|
||||
|
||||
Single user-facing verdict ∈ {`fill_now`, `wait`, `no_signal`}. The
|
||||
displayed confidence number is `ridge_confidence` itself, **untouched**.
|
||||
LLM agreement and volatility status are shown as separate **badges**, not
|
||||
blended into the number. Honesty over smoothing.
|
||||
|
||||
Gates evaluated in order, first match wins:
|
||||
|
||||
```
|
||||
1. direction == 'flat' → no_signal
|
||||
2. ridge_confidence < 40 → no_signal
|
||||
3. volatility_regime active → no_signal (badge: volatile)
|
||||
4. LLM disagrees AND ridge_confidence < 75 → no_signal (badge: conflicting)
|
||||
5. rising AND ridge_confidence >= 70 → fill_now
|
||||
6. falling AND ridge_confidence >= 70 → wait
|
||||
7. otherwise (40 <= conf < 70, no veto from 3 or 4) → dashboard-only
|
||||
```
|
||||
|
||||
Why gates, not multipliers:
|
||||
|
||||
- A multiplied confidence number is a black-box blend that the user can't
|
||||
audit. A 70% that used to be 90% before today's volatility hit looks
|
||||
identical to a 70% that's been calibrated all along.
|
||||
- Gates compose cleanly. Each rule has one job and is independently
|
||||
testable.
|
||||
- The verdict is binary anyway (notify / don't / silent). Smoothing
|
||||
confidence under the hood doesn't help that decision — it only obscures it.
|
||||
|
||||
Layer 2 affects **urgency wording only** ("fill up now, *especially* in
|
||||
your area at 2p above national"). It never changes the verdict. Neither
|
||||
does Layer 4 or Layer 5 — they can suppress (gate 3, 4) but never flip
|
||||
the direction.
|
||||
|
||||
### Layer 4 — Daily LLM news overlay (qualitative, news-aware)
|
||||
|
||||
**Single scheduled call at 07:00 UK.** Plus an event-driven refresh when
|
||||
Layer 5's volatility flag flips ON (with a 4-hour cooldown so the same
|
||||
event doesn't trigger repeatedly).
|
||||
|
||||
JSON in, JSON out. Calls Claude Haiku with web search enabled, asks for
|
||||
direction + confidence + cited events with URLs. Stored in a new
|
||||
`llm_overlays` table.
|
||||
|
||||
Layer 4 is **read-only with respect to the volatility flag**. It writes
|
||||
its result row; only Layer 5 mutates `volatility_regimes.active`.
|
||||
|
||||
LLM confidence is hard-capped at 75 in code (web-searched LLMs are
|
||||
systematically overconfident). Calls without `events_cited` are rejected.
|
||||
|
||||
### Layer 5 — Volatility regime detector (intra-week safety net)
|
||||
|
||||
Hourly cron. **Sole owner** of the `volatility_regimes.active` flag.
|
||||
Reads four signals, OR-combined:
|
||||
|
||||
1. Daily Brent move > 3% close-to-close (FRED `DCOILBRENTEU`, Phase 7).
|
||||
2. Most recent `llm_overlays.major_impact_event = true` AND at least one
|
||||
verified URL.
|
||||
3. `station_prices` daily churn rate > 1.5× its 30-day baseline.
|
||||
4. A `watched_events` row covering today (manually flagged geopolitical
|
||||
periods).
|
||||
|
||||
When the flag flips on:
|
||||
- An event-driven LLM refresh is queued (Layer 4) if last run was > 4h ago.
|
||||
- **Layer 3's gate 3 fires**: verdict forced to `no_signal` with the
|
||||
`volatile` badge.
|
||||
- The reasoning text appended: *"Volatility detected ({trigger}) — this
|
||||
forecast may be stale within days."*
|
||||
|
||||
When it flips off:
|
||||
- Verdict returns to whatever the gates produce on the unchanged
|
||||
`ridge_confidence` (no multiplier to reset — there are none).
|
||||
- Badge cleared.
|
||||
- Next morning's 07:00 LLM call still runs (it always runs); no extra
|
||||
refreshes are queued.
|
||||
|
||||
Layer 5 never changes Layer 1's *direction*. It only suppresses the
|
||||
verdict via gate 3.
|
||||
|
||||
---
|
||||
|
||||
## Methodology — Layer 1
|
||||
|
||||
### Target
|
||||
|
||||
```
|
||||
ΔULSP[t+1] = ULSP[t+1] − ULSP[t]
|
||||
```
|
||||
|
||||
We model the **change**, not the level. UK pump prices are non-stationary,
|
||||
so regressing on levels gives spurious R² and useless coefficients.
|
||||
Differencing makes the series stationary.
|
||||
|
||||
### Features (all stationary)
|
||||
|
||||
| Feature | Notes |
|
||||
|---|---|
|
||||
| `Δulsp_lag_0`, `Δulsp_lag_1`, `Δulsp_lag_3` | 1w / 2w / 4w momentum |
|
||||
| `Δulsd_lag_0` | Diesel cross-signal as a *change* |
|
||||
| `ulsp[t] − ma8[t]` | **Mean-reversion term** — gap between current price and 8-week MA. Single most useful feature for 1-week-ahead UK pump forecast. |
|
||||
| `week_of_year_sin`, `week_of_year_cos` | Cyclic seasonality encoding |
|
||||
| `is_pre_bank_holiday` | Boolean, within 7 days of UK bank holiday |
|
||||
|
||||
The level only enters as the deviation from MA-8 (itself stationary).
|
||||
That's the only way levels are allowed in.
|
||||
|
||||
**Duty change is NOT a feature.** With one event in 435 weeks, n=1 cannot
|
||||
fit a meaningful coefficient. Instead, duty-change-adjacent weeks (±4
|
||||
weeks of a known change) are handled in the **calibration override**
|
||||
(see below) — confidence is halved and the regime flag is surfaced in
|
||||
the reasoning text. A regime can be flagged. A coefficient cannot be
|
||||
trained from one observation.
|
||||
|
||||
### Model
|
||||
|
||||
Ridge regression. Boring on purpose:
|
||||
|
||||
- 435 weekly observations is too few to beat a well-specified linear model
|
||||
out-of-sample with gradient boosting or LSTM — those would just fit noise.
|
||||
- Interpretable coefficients are essential for the honesty layer
|
||||
(the reasoning text describes what the model used).
|
||||
|
||||
Upgrade to a non-linear model **only** if Phase 3 backtest demonstrates the
|
||||
linear model is missing real structure.
|
||||
|
||||
### Training and evaluation split
|
||||
|
||||
- Train on weeks 1–305 (~70%).
|
||||
- Evaluate on weeks 306–435 (~30%) with rolling-origin cross-validation
|
||||
(single-split would overfit hyperparameters to one window).
|
||||
|
||||
### Confidence calibration
|
||||
|
||||
Two-stage calibration:
|
||||
|
||||
1. **Magnitude binning** — bin predictions by predicted `|magnitude|` and
|
||||
record actual hit rate per bin. The published `confidence_score` reads
|
||||
from this lookup, not from the model's raw output.
|
||||
2. **Regime flag** — flag any forecast week within ±4 weeks of a known
|
||||
duty change. With only one duty change in 435 weeks, statistical
|
||||
stratification at n=1 is impossible. Instead:
|
||||
- For flagged weeks, halve the calibrated confidence manually.
|
||||
- Surface the flag in the reasoning text: *"Recent duty change —
|
||||
forecast accuracy is reduced for the next several weeks."*
|
||||
|
||||
This is the only place v1 accepts a hand-tuned guard, and it's there
|
||||
because the data can't tell us better.
|
||||
|
||||
---
|
||||
|
||||
## Methodology — Layer 2
|
||||
|
||||
Pure aggregates. No model.
|
||||
|
||||
```sql
|
||||
-- Local 50km average
|
||||
SELECT AVG(price_pence) FROM station_prices_current
|
||||
JOIN stations ON station_prices_current.station_id = stations.node_id
|
||||
WHERE fuel_type = ? AND <Haversine within 50km of (lat, lng)>;
|
||||
|
||||
-- National average
|
||||
SELECT AVG(price_pence) FROM station_prices_current WHERE fuel_type = ?;
|
||||
|
||||
-- Cheapest within 25km
|
||||
SELECT stations.*, station_prices_current.price_pence
|
||||
FROM station_prices_current
|
||||
JOIN stations ON station_prices_current.station_id = stations.node_id
|
||||
WHERE fuel_type = ? AND <Haversine within 25km>
|
||||
ORDER BY price_pence ASC LIMIT 5;
|
||||
|
||||
-- Supermarket vs major split, locally
|
||||
SELECT stations.is_supermarket, AVG(price_pence)
|
||||
FROM station_prices_current
|
||||
JOIN stations ON station_prices_current.station_id = stations.node_id
|
||||
WHERE fuel_type = ? AND <Haversine within 25km>
|
||||
GROUP BY stations.is_supermarket;
|
||||
```
|
||||
|
||||
Output is descriptive: "Your area is X p above national average right
|
||||
now", "Cheapest near you: {station} at {price}", "Supermarkets near you:
|
||||
{avg} vs majors: {avg}". **Never** predictive language.
|
||||
|
||||
---
|
||||
|
||||
## Methodology — Layer 3
|
||||
|
||||
Full gate ordering is in the Architecture section (Layer 3). Summary:
|
||||
|
||||
- Verdict via ordered rule gates, **not** multipliers.
|
||||
- `ridge_confidence` is displayed verbatim — never multiplied.
|
||||
- Volatility flag and LLM disagreement act as **suppressors with badges**
|
||||
(`volatile`, `conflicting`) but never flip direction.
|
||||
- `direction == 'flat'` always produces `no_signal`.
|
||||
- LLM disagreement only suppresses the verdict when `ridge_confidence < 75`.
|
||||
Above 75 the model's call is strong enough to stand even with a news-scan
|
||||
disagreement (the LLM is hard-capped at 75 confidence anyway, so it
|
||||
can't out-confidence the ridge model — only flag a tension).
|
||||
|
||||
Local position from Layer 2 modifies urgency wording only:
|
||||
|
||||
- If user's local average is materially above national (>2p), and Layer 1
|
||||
says "rising", urgency increased ("fill up now, *especially* in your area").
|
||||
- Layer 2 never flips Layer 1's direction.
|
||||
|
||||
---
|
||||
|
||||
## Methodology — Layer 4 (LLM news overlay)
|
||||
|
||||
Single scheduled call daily at 07:00 UK. Additional event-driven calls
|
||||
are queued by Layer 5 when the volatility flag flips ON, with a 4-hour
|
||||
cooldown enforced in code (skip the queue if the most recent
|
||||
`llm_overlays.ran_at` is within 4 hours).
|
||||
|
||||
**Brent input** (`brent_recent_14_days`) is optional — passed as `null`
|
||||
until Phase 7 backfills `brent_prices`. Phase 8 cannot ship before
|
||||
Phase 7 — explicit dependency.
|
||||
|
||||
### Request shape (JSON)
|
||||
|
||||
```json
|
||||
{
|
||||
"input": {
|
||||
"ulsp_recent_8_weeks": [...],
|
||||
"brent_recent_14_days": [...],
|
||||
"current_week_of_year": 18,
|
||||
"days_to_next_bank_holiday": 5,
|
||||
"duty_pence": 52.95,
|
||||
"ridge_model_says": {
|
||||
"direction": "down",
|
||||
"confidence": 68,
|
||||
"magnitude_pence": -0.4
|
||||
}
|
||||
},
|
||||
"ask": "Search recent news for oil-supply, OPEC, refinery, shipping, sanctions, geopolitical events affecting UK retail fuel prices over the next 1-2 weeks. Reply ONLY in the schema below."
|
||||
}
|
||||
```
|
||||
|
||||
### Response shape (JSON, enforced)
|
||||
|
||||
```json
|
||||
{
|
||||
"direction": "rising | falling | flat",
|
||||
"confidence": 0,
|
||||
"reasoning_short": "1-2 sentences",
|
||||
"events_cited": [
|
||||
{"headline": "...", "source": "...", "url": "...", "impact": "rising|falling|neutral"}
|
||||
],
|
||||
"agrees_with_ridge": true,
|
||||
"major_impact_event": false
|
||||
}
|
||||
```
|
||||
|
||||
### Code-level guards (not in the prompt)
|
||||
|
||||
1. **Cap `confidence` at 75.** Web-searched LLMs are systematically overconfident.
|
||||
2. **Reject the response if `events_cited` is empty.** Forces the LLM to
|
||||
ground its call in something checkable, not vibes.
|
||||
3. **Verify each `url` in `events_cited` is reachable** before storing.
|
||||
Catches hallucinated citations. Failed URLs blank the citation but
|
||||
don't reject the call (newer URLs sometimes 404 briefly).
|
||||
4. **Layer 4 does NOT mutate `volatility_regimes.active`.** It writes its
|
||||
row to `llm_overlays` (with `major_impact_event` + verified URLs) and
|
||||
that's it. Layer 5's hourly cron picks up the new row and decides
|
||||
whether to flip the flag.
|
||||
|
||||
### How Layer 3 uses it
|
||||
|
||||
- LLM agrees → no gating effect; `agrees` badge shown next to the verdict
|
||||
("News scan agrees, citing {event}").
|
||||
- LLM disagrees AND `ridge_confidence < 75` → **gate 4 fires**: verdict
|
||||
forced to `no_signal` with the `conflicting` badge.
|
||||
- LLM disagrees AND `ridge_confidence >= 75` → no suppression; the
|
||||
disagreement is shown as a badge but the model's strong call stands.
|
||||
- LLM neutral / flat → no gating effect.
|
||||
- Direction is never flipped by the LLM.
|
||||
|
||||
---
|
||||
|
||||
## Methodology — Layer 5 (volatility regime detector)
|
||||
|
||||
Hourly cron. **Sole owner** of `volatility_regimes.active`. Reads four
|
||||
signals, OR-combined:
|
||||
|
||||
1. **Brent move** — close-to-close daily Brent move > 3% on FRED
|
||||
`DCOILBRENTEU`. FRED publishes with a one-day lag (today's value is
|
||||
yesterday's settle), so the trigger reflects the most recent settled
|
||||
day. Sufficient for v1 — we don't have a real-time Brent feed.
|
||||
2. **LLM major-impact flag** — most recent `llm_overlays` row has
|
||||
`major_impact_event = true` AND at least one verified URL.
|
||||
3. **Station churn** — *gated until ≥180 days of stable polling.* The
|
||||
trigger fires when the last-24h % of stations updating price exceeds
|
||||
1.5× the 30-day rolling baseline. With only 75 days of uneven polling
|
||||
(Jan 16 → May 1) the baseline is meaningless — sample-mix variance
|
||||
would dominate any real shock signal. The trigger is implemented but
|
||||
disabled in code via a feature flag; flip it on once `station_prices`
|
||||
has 180+ continuous days.
|
||||
4. **Manual `watched_events`** — a row covering today. Lets you flag
|
||||
known geopolitical periods manually (e.g. "Iran tensions Apr–May 2026").
|
||||
|
||||
When the flag flips on:
|
||||
|
||||
- An event-driven Layer 4 LLM refresh is queued (skipped if the most
|
||||
recent `llm_overlays.ran_at` is within 4 hours — cooldown).
|
||||
- **Layer 3's gate 3 fires**: verdict forced to `no_signal` with the
|
||||
`volatile` badge for as long as the flag stays on.
|
||||
- Reasoning text appended: *"Volatility detected ({trigger label}) — this
|
||||
forecast may be stale within days."*
|
||||
|
||||
When it flips off:
|
||||
- Verdict returns to whatever the gates produce on the unchanged
|
||||
`ridge_confidence` (no multiplier reset needed — there are no multipliers).
|
||||
- Badge cleared.
|
||||
- The next morning's 07:00 LLM call still runs (always does); no extra
|
||||
refreshes are queued by Layer 5.
|
||||
|
||||
---
|
||||
|
||||
## Schema deltas
|
||||
|
||||
### Add
|
||||
|
||||
```
|
||||
weekly_forecasts
|
||||
id BIGINT PK
|
||||
forecast_for DATE — Monday the forecast covers
|
||||
model_version VARCHAR(32) — links back to backtests row
|
||||
direction ENUM('rising','falling','flat')
|
||||
magnitude_pence SMALLINT — predicted Δ × 100, signed
|
||||
ridge_confidence TINYINT UNSIGNED — 0..100, calibrated from backtested residuals. Displayed verbatim. Layer 3 gates may suppress the verdict but never modify this number.
|
||||
flagged_duty_change BOOLEAN — true if forecast is within ±4 weeks of a duty change (avoids collision with Layer 5's volatility_regimes)
|
||||
reasoning TEXT — generated from features actually used
|
||||
generated_at DATETIME
|
||||
UNIQUE (forecast_for, model_version)
|
||||
INDEX (forecast_for, generated_at DESC)
|
||||
|
||||
forecast_outcomes
|
||||
forecast_for DATE
|
||||
model_version VARCHAR(32)
|
||||
predicted_class ENUM('rising','falling','flat')
|
||||
actual_class ENUM('rising','falling','flat')
|
||||
correct BOOLEAN
|
||||
abs_error_pence SMALLINT UNSIGNED
|
||||
resolved_at DATETIME
|
||||
PRIMARY KEY (forecast_for, model_version)
|
||||
|
||||
backtests
|
||||
id BIGINT PK
|
||||
model_version VARCHAR(32) UNIQUE
|
||||
features_json JSON — feature spec
|
||||
train_start DATE
|
||||
train_end DATE
|
||||
eval_start DATE
|
||||
eval_end DATE
|
||||
directional_accuracy DECIMAL(5,2)
|
||||
mae_pence DECIMAL(5,2)
|
||||
calibration_table JSON — {bin_low..bin_high → empirical_hit_rate}
|
||||
leak_suspected BOOLEAN — secondary smell test: true if directional_accuracy > 75. Primary leak detection is structural (see Backtest section).
|
||||
ran_at DATETIME
|
||||
|
||||
llm_overlays
|
||||
id BIGINT PK
|
||||
ran_at DATETIME
|
||||
forecast_for_week DATE — which weekly forecast it overlays
|
||||
direction ENUM('rising','falling','flat')
|
||||
confidence TINYINT UNSIGNED — capped 75 in code
|
||||
reasoning TEXT
|
||||
events_json JSON — cited events with verified URLs
|
||||
agrees_with_ridge BOOLEAN
|
||||
major_impact_event BOOLEAN
|
||||
volatility_flag_on BOOLEAN — was the regime flag on at run time
|
||||
search_used BOOLEAN
|
||||
INDEX (forecast_for_week, ran_at)
|
||||
|
||||
volatility_regimes
|
||||
id BIGINT PK
|
||||
flipped_on_at DATETIME
|
||||
flipped_off_at DATETIME NULL
|
||||
trigger ENUM('brent_move','llm_event','station_churn','manual')
|
||||
trigger_detail TEXT — e.g. "Brent +4.2% close-to-close"
|
||||
active BOOLEAN
|
||||
|
||||
watched_events
|
||||
id BIGINT PK
|
||||
label VARCHAR(128)
|
||||
starts_at DATETIME
|
||||
ends_at DATETIME
|
||||
notes TEXT
|
||||
```
|
||||
|
||||
### Keep
|
||||
|
||||
- `weekly_pump_prices` — already loaded, source of truth
|
||||
- `stations`, `station_prices_current` — for Layer 2
|
||||
- `station_prices` — keep collecting forward, not modelled in v1
|
||||
|
||||
### Deprecate (delete after Layer 1 ships)
|
||||
|
||||
- `price_predictions` — old LLM/EWMA store, replaced by `weekly_forecasts`
|
||||
|
||||
The current six-signal aggregator (`NationalFuelPredictionService` and
|
||||
`app/Services/Prediction/Signals/*`) is **fully replaced**, not extended.
|
||||
Same JSON output keys (`predicted_direction`, `confidence_score`,
|
||||
`action`, `reasoning`) so the Vue frontend doesn't break — engine swapped,
|
||||
contract preserved.
|
||||
|
||||
---
|
||||
|
||||
## Implementation phases (each ships something working)
|
||||
|
||||
| Phase | Scope | Ships |
|
||||
|---|---|---|
|
||||
| **1. Backtest harness** | `BacktestRunner` service + `backtests` table. Takes a model class, train/eval split, returns directional accuracy + MAE + calibration curve. **Structural leak detection** built in (per-feature source-timestamp check vs target Monday); accuracy>75% smell test as secondary. | A way to *prove* any future model works before shipping it. |
|
||||
| **2. Naive baseline** | "Predict next week = this week" implemented as a model class. Run through harness. | A floor: any future model must beat this. |
|
||||
| **3. v1 ridge model** | Features above (incl. mean-reversion term), trained once, persisted with `model_version`. `WeeklyForecastService` runs it. Backtest must clear the acceptance gate. | First real forecast. Backtested numbers visible. |
|
||||
| **4. Live wiring** | Replace `NationalFuelPredictionService` internals with a thin adapter delegating to `WeeklyForecastService`. Same API shape, new engine. | Frontend keeps working, predictions now from the new model. |
|
||||
| **5. Local snapshot** | `LocalSnapshotService` — pure aggregates. Wire into `/api/stations` payload alongside the headline forecast. | "Your area" descriptive cards. |
|
||||
| **6. Honesty layer** | Reasoning generator describes *what the model used*: lag values, season, holiday flag. Shows backtest accuracy badge. Returns explicit "not enough data" when confidence < 40. Surfaces the duty-change-adjacent flag when set. | The "no BS" framing. |
|
||||
| **7. Brent backfill + daily refresh** | One FRED call (2018→today, ~2,150 daily rows). Daily refresh cron at **06:30 UK** (must complete before Phase 8's 07:00 LLM call — sequenced so the LLM has fresh Brent context). Used by Phase 9's volatility detector and as a feature option for future model iterations (only added to the ridge model if backtested lift is ≥3 percentage points directional accuracy). | Daily Brent in DB. Foundation for volatility + LLM context. |
|
||||
| **8. LLM news overlay** | `LlmOverlayService` — single scheduled call at **07:00 UK** (after Brent refresh). Plus event-driven calls when Layer 5 flips the volatility flag on, with 4h cooldown. JSON in / JSON out, web search enabled, results stored in `llm_overlays`. Feeds Layer 3's gate 4 (suppress when LLM disagrees AND ridge_confidence < 75) and the `agrees`/`conflicting` badges. URL-verification + empty-citation rejection enforced in code. **Depends on Phase 7.** | News-aware verdict suppression and badge on top of the calibrated ridge baseline. |
|
||||
| **9. Volatility regime detector** | `VolatilityRegimeService` — hourly cron, sole owner of `volatility_regimes.active`. OR-combines four triggers: Brent move > 3%, LLM `major_impact_event`, station churn > 1.5× baseline (**gated until ≥180 days of stable polling**), `watched_events` row covering today. Fires Layer 3's gate 3 (verdict → `no_signal` with `volatile` badge) and the event-driven Layer 4 refresh. | The intra-week safety net for oil shocks. |
|
||||
|
||||
---
|
||||
|
||||
## Backtest acceptance gates (Phase 3 → Phase 4)
|
||||
|
||||
| Backtest result | Action |
|
||||
|---|---|
|
||||
| < 60% directional accuracy | Features are wrong. Stay in Phase 3, don't ship. |
|
||||
| 60–62% | Marginal. One feature iteration, then re-evaluate. |
|
||||
| **62–68%** | **Ship.** Realistic target for UK weekly pump direction without Brent. |
|
||||
| 68–75% | Excellent. Ship and watch closely. |
|
||||
| > 75% | **Stop.** Run the structural leak detector. Almost certainly time leakage (e.g. using `t+1` info accidentally in `t` features). The accuracy threshold is a secondary smell test, not the primary detector. |
|
||||
| MAE > 1.0p / litre | Features are noisy. Refit before shipping. |
|
||||
| Target MAE | 0.4–0.7p / litre. |
|
||||
|
||||
### Structural leak detection (primary)
|
||||
|
||||
Built into the backtest harness. For every (training_week, feature_value)
|
||||
pair, the harness verifies the data source's effective timestamp is
|
||||
**strictly before** the target Monday. Any feature whose source timestamp
|
||||
is on or after the target week is treated as leakage and the backtest
|
||||
fails fast. This is independent of accuracy — it catches leakage even
|
||||
when it doesn't translate into suspiciously high accuracy.
|
||||
|
||||
The `> 75% accuracy` row is a secondary smell test for leakage modes the
|
||||
structural check missed (e.g. label leakage via a downstream computed
|
||||
column). Primary defence is the timestamp check. These numbers are
|
||||
encoded in the harness as assertions, not aspirations.
|
||||
|
||||
---
|
||||
|
||||
## Honesty rules — non-negotiables
|
||||
|
||||
1. Backtest accuracy is **published in the UI**. The model wears its track
|
||||
record on its sleeve.
|
||||
2. Below 40 confidence, the recommendation is `no_signal` and the reasoning
|
||||
says "we don't have enough signal to call it" — explicitly. No filler.
|
||||
3. When duty-change-adjacent weeks affect the forecast, surface the flag
|
||||
("forecast may be skewed by recent duty change").
|
||||
4. Reasoning text only references features the model actually used — no
|
||||
narrative invention. If the mean-reversion term drove the call, say so
|
||||
("Pump prices are 3.1p above their 8-week average, and prices typically
|
||||
pull back from that level"). If the seasonality term drove it, say so.
|
||||
5. `forecast_outcomes` is populated automatically when the next BEIS week
|
||||
lands. Hit rate over the trailing 13 weeks is shown next to the headline.
|
||||
6. When the **volatility regime flag** is on, the UI shows the `volatile`
|
||||
badge and the trigger (e.g. "Brent up 4.2% yesterday — forecast may be
|
||||
stale within days"). Verdict is suppressed visibly via gate 3, never
|
||||
silently.
|
||||
7. The LLM overlay is **shown separately** from the ridge model, never
|
||||
blended. "Model says down (68%); news scan agrees, citing {event}" —
|
||||
the `ridge_confidence` number stays calibrated and untouched, while
|
||||
LLM and volatility status are presented as their own badges.
|
||||
8. LLM citations with unreachable URLs are **dropped from the displayed
|
||||
reasoning** but kept in `llm_overlays.events_json` for audit. We never
|
||||
show a citation we haven't verified.
|
||||
|
||||
---
|
||||
|
||||
## What gets deleted at the end of Phase 4
|
||||
|
||||
- `app/Services/Prediction/Signals/*` (whole directory)
|
||||
- `NationalFuelPredictionService` internals (kept as a thin wrapper, then
|
||||
renamed when the frontend migration completes)
|
||||
- `price_predictions` table — replaced by `weekly_forecasts` (ridge) +
|
||||
`llm_overlays` (news layer)
|
||||
- `OilPriceService::generatePrediction()`, EWMA/LLM helpers — replaced by
|
||||
`LlmOverlayService` (Phase 8) which has a different contract
|
||||
- `OilPriceService::fetchBrentPrices()` — kept and **expanded** in Phase 7
|
||||
(backfill mode + daily refresh), not deleted
|
||||
- `.claude/rules/scoring.md` retired in favour of a fresh
|
||||
`.claude/rules/forecasting.md`
|
||||
- `.claude/rules/prediction.md` rewritten to match the new architecture
|
||||
|
||||
---
|
||||
|
||||
## Open decisions (to confirm before Phase 1)
|
||||
|
||||
- **Forecast cadence** — the *forecast itself* is weekly (matches BEIS
|
||||
publication). The *confidence and presentation* update daily via Layer 4
|
||||
(LLM) and Layer 5 (volatility regime). This split is deliberate — we
|
||||
refuse to fabricate intra-week movement, but we don't pretend a static
|
||||
Monday number is reliable on Thursday after a 6% Brent move.
|
||||
- **Scope** — drop the six-signal aggregator entirely, confirmed.
|
||||
- **API shape** — keep existing JSON output keys so Vue keeps working,
|
||||
with the engine swapped under the hood. The original `confidence_score`
|
||||
field maps to `ridge_confidence` (calibrated, untouched). Add new
|
||||
fields: `volatility` (`{active, trigger}`), `news_overlay`
|
||||
(`{direction, agreement, events}`), and `verdict_reason` (which gate
|
||||
fired, if any). The verdict itself goes in the existing `action` field.
|
||||
- **Brent** — promoted to Phase 7 (was "optional, conditional"). Needed
|
||||
for the volatility detector, regardless of whether it's used in the
|
||||
ridge model.
|
||||
- **LLM** — Anthropic Claude Haiku with web search. Single scheduled call
|
||||
at 07:00 UK (after the 06:30 Brent refresh). Plus event-driven refreshes
|
||||
when Layer 5 flips the volatility flag on, with a 4h cooldown. No fixed
|
||||
afternoon cron — by 13:00 UK, morning users have already made their
|
||||
fill-up decisions, so the value is too low to justify the extra noise.
|
||||
Hard confidence cap 75. Empty-citation rejection.
|
||||
|
||||
---
|
||||
|
||||
## Changelog (substantive design decisions)
|
||||
|
||||
| When | Change | Why |
|
||||
|---|---|---|
|
||||
| 2026-05-01 v1 | Initial spec — three layers, six-signal aggregator removed, ridge model on BEIS weekly data | Replace incoherent `NationalFuelPredictionService` |
|
||||
| 2026-05-01 v2 | Added Layer 4 (LLM news overlay) and Layer 5 (volatility regime detector). Pump prices can move daily during oil shocks; static weekly forecast must be backed by intra-week safety nets. | Iran/Hormuz-style shocks make a Monday-only confidence number stale by Wednesday |
|
||||
| 2026-05-01 v3 | **Verdict via rule gates, not multipliers.** `ridge_confidence` displayed verbatim. LLM and volatility presented as badges. `weeks_since_duty_change` removed from features (kept as calibration override only — n=1 can't fit a coefficient). Backtest gate floor lowered 65 → 62 (realistic without Brent). Structural leak detection (per-feature timestamp check) made primary; accuracy>75% demoted to secondary smell test. `weekly_forecasts` PK changed to `(forecast_for, model_version)` to preserve audit on retrain. `forecast_outcomes` made three-class. Layer 5 station-churn trigger gated until ≥180 days of stable polling. | Multipliers obscure calibration. Gates compose cleanly and stay auditable. |
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- Alquist, Kilian, Vigfusson (2013) — *Forecasting the Price of Oil* —
|
||||
the academic basis for "no-change baseline beats most structural models
|
||||
at <6m horizons" (which is why Phase 2 matters as a hard floor).
|
||||
- BEIS *Weekly road fuel prices* CSV — the 435-week training set.
|
||||
- `.claude/rules/scoring.md`, `.claude/rules/prediction.md` — the two
|
||||
inconsistent rule files this spec replaces.
|
||||
@@ -1,74 +1,41 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<!-- Gated overlay for free/guest users -->
|
||||
<div>
|
||||
<!-- Loading state -->
|
||||
<div
|
||||
v-if="!isPaidTier"
|
||||
class="absolute inset-0 z-10 rounded-2xl backdrop-blur-sm bg-white/60 flex flex-col items-center justify-center gap-3 text-center px-6"
|
||||
v-if="loading"
|
||||
class="p-6 bg-white rounded-2xl border border-zinc-300 animate-pulse space-y-2"
|
||||
>
|
||||
<iconify-icon class="text-accent text-3xl" icon="lucide:lock"></iconify-icon>
|
||||
<p class="font-bold text-zinc-800">Price predictions are available on paid plans</p>
|
||||
<div class="h-4 bg-zinc-200 rounded w-1/3"></div>
|
||||
<div class="h-6 bg-zinc-200 rounded w-2/3"></div>
|
||||
</div>
|
||||
|
||||
<!-- Free / guest: compact one-liner -->
|
||||
<div
|
||||
v-else-if="!isPaidTier"
|
||||
class="flex items-center gap-3 px-4 py-3 bg-white rounded-2xl border border-zinc-300"
|
||||
>
|
||||
<div :class="['shrink-0 w-10 h-10 rounded-full flex items-center justify-center', accentBg]">
|
||||
<iconify-icon :icon="genericIcon" class="text-xl text-white"></iconify-icon>
|
||||
</div>
|
||||
<p class="flex-1 text-sm text-zinc-800 font-medium leading-snug">
|
||||
{{ genericSentence }}
|
||||
</p>
|
||||
<a
|
||||
class="hidden sm:inline-flex shrink-0 text-sm font-bold text-accent hover:text-accent-content whitespace-nowrap"
|
||||
href="/pricing"
|
||||
class="px-6 py-2 bg-accent text-white rounded-full text-sm font-bold hover:bg-accent-content transition-colors"
|
||||
>
|
||||
Upgrade from £0.99/mo
|
||||
See full prediction →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Card content (blurred for free users, fully visible for paid) -->
|
||||
<div
|
||||
:class="['p-6 bg-white rounded-2xl border border-zinc-300 space-y-4', !isPaidTier && 'select-none pointer-events-none']"
|
||||
>
|
||||
<p class="text-xs font-bold uppercase tracking-widest text-zinc-500">Price Prediction</p>
|
||||
|
||||
<!-- Loading state -->
|
||||
<template v-if="loading">
|
||||
<div class="animate-pulse space-y-2">
|
||||
<div class="h-8 bg-zinc-300 rounded w-1/2"></div>
|
||||
<div class="h-4 bg-zinc-300 rounded w-3/4"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Loaded state -->
|
||||
<template v-else-if="prediction">
|
||||
<h3
|
||||
class="text-2xl font-black"
|
||||
:class="prediction.action === 'fill_now' ? 'text-mauve' : prediction.action === 'wait' ? 'text-teal' : 'text-tan'"
|
||||
>
|
||||
{{ actionLabel }}
|
||||
</h3>
|
||||
|
||||
<div class="w-full h-2 bg-zinc-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all"
|
||||
:class="prediction.action === 'fill_now' ? 'bg-mauve' : 'bg-teal'"
|
||||
:style="{ width: prediction.confidence_score + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-zinc-500 leading-relaxed">{{ prediction.reasoning }}</p>
|
||||
|
||||
<div class="flex items-center gap-4 text-xs text-zinc-500 font-medium">
|
||||
<span>Avg: {{ prediction.current_avg }}p</span>
|
||||
<span>Confidence: {{ prediction.confidence_label }}</span>
|
||||
<span v-if="prediction.predicted_change_pence">
|
||||
{{ prediction.predicted_change_pence > 0 ? '+' : '' }}{{ prediction.predicted_change_pence.toFixed(1) }}p expected
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Empty state (placeholder for gated view) -->
|
||||
<template v-else>
|
||||
<h3 class="text-2xl font-black text-mauve">Fill up now</h3>
|
||||
<div class="h-2 bg-zinc-200 rounded-full"><div class="h-full bg-mauve w-4/5 rounded-full"></div></div>
|
||||
<p class="text-sm text-zinc-500">Prices in your area are rising — best to fill up today.</p>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Paid: full prediction -->
|
||||
<PredictionFull v-else :prediction="prediction" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import PredictionFull from './PredictionFull.vue'
|
||||
|
||||
const props = defineProps({
|
||||
prediction: { type: Object, default: null },
|
||||
@@ -76,12 +43,23 @@ const props = defineProps({
|
||||
isPaidTier: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
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 direction = computed(() => props.prediction?.predicted_direction ?? 'stable')
|
||||
|
||||
const genericSentence = computed(() => ({
|
||||
up: 'UK fuel prices are trending upward this week.',
|
||||
down: 'UK fuel prices have been falling this week.',
|
||||
stable: 'UK fuel prices have been steady this week.',
|
||||
})[direction.value] ?? 'UK fuel prices have been steady this week.')
|
||||
|
||||
const genericIcon = computed(() => ({
|
||||
up: 'lucide:trending-up',
|
||||
down: 'lucide:trending-down',
|
||||
stable: 'lucide:minus',
|
||||
})[direction.value] ?? 'lucide:minus')
|
||||
|
||||
const accentBg = computed(() => ({
|
||||
up: 'bg-mauve',
|
||||
down: 'bg-teal',
|
||||
stable: 'bg-tan',
|
||||
})[direction.value] ?? 'bg-tan')
|
||||
</script>
|
||||
|
||||
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>
|
||||
@@ -57,7 +57,7 @@
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div v-if="expanded" class="border-t border-zinc-200 pt-3 space-y-3">
|
||||
<p v-if="brandLabel" class="text-[10px] font-black uppercase tracking-widest text-zinc-500">
|
||||
<p v-if="brandLabel" class="font-mono text-[10px] uppercase tracking-widest text-zinc-500 mb-1 truncate">
|
||||
{{ brandLabel }}
|
||||
</p>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -19,6 +19,22 @@ export function useAuth() {
|
||||
return ['basic', 'plus', 'pro'].includes(userTier.value)
|
||||
})
|
||||
|
||||
const subscriptionCancelled = computed(() => {
|
||||
return user.value?.subscription_cancelled ?? false
|
||||
})
|
||||
|
||||
const subscriptionCadence = computed(() => {
|
||||
return user.value?.subscription_cadence ?? null
|
||||
})
|
||||
|
||||
const subscribedAt = computed(() => {
|
||||
return user.value?.subscribed_at ?? null
|
||||
})
|
||||
|
||||
const subscriptionExpiresAt = computed(() => {
|
||||
return user.value?.subscription_expires_at ?? null
|
||||
})
|
||||
|
||||
async function fetchUser() {
|
||||
if (fetched.value) {
|
||||
return
|
||||
@@ -68,6 +84,10 @@ export function useAuth() {
|
||||
isAuthenticated,
|
||||
userTier,
|
||||
isPaidTier,
|
||||
subscriptionCancelled,
|
||||
subscriptionCadence,
|
||||
subscribedAt,
|
||||
subscriptionExpiresAt,
|
||||
fetchUser,
|
||||
clearUser,
|
||||
logout,
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import api from '../axios.js'
|
||||
|
||||
export function usePrediction() {
|
||||
const prediction = ref(null)
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
async function fetch({ lat, lng } = {}) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
prediction.value = null
|
||||
|
||||
const params = {}
|
||||
if (lat && lng) {
|
||||
params.lat = lat
|
||||
params.lng = lng
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.get('/prediction', { params })
|
||||
prediction.value = response.data
|
||||
} catch (err) {
|
||||
error.value = 'Unable to load prediction.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { prediction, loading, error, fetch }
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import api from '../axios.js'
|
||||
export function useStations() {
|
||||
const stations = ref([])
|
||||
const meta = ref(null)
|
||||
const prediction = ref(null)
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
@@ -12,6 +13,7 @@ export function useStations() {
|
||||
error.value = null
|
||||
stations.value = []
|
||||
meta.value = null
|
||||
prediction.value = null
|
||||
|
||||
const params = { fuel_type: fuelType, radius, sort }
|
||||
|
||||
@@ -26,6 +28,7 @@ export function useStations() {
|
||||
const response = await api.get('/stations', { params })
|
||||
stations.value = response.data.data
|
||||
meta.value = response.data.meta
|
||||
prediction.value = response.data.prediction ?? null
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.errors
|
||||
?? { general: ['Unable to load stations. Please try again.'] }
|
||||
@@ -37,9 +40,10 @@ export function useStations() {
|
||||
function reset() {
|
||||
stations.value = []
|
||||
meta.value = null
|
||||
prediction.value = null
|
||||
error.value = null
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
return { stations, meta, loading, error, search, reset }
|
||||
return { stations, meta, prediction, loading, error, search, reset }
|
||||
}
|
||||
|
||||
@@ -36,6 +36,13 @@
|
||||
<section v-if="searchAttempted" id="searchAttempted" class="px-6">
|
||||
<div class="max-w-7xl mx-auto space-y-6">
|
||||
|
||||
<!-- Prediction box (sits above filter results) -->
|
||||
<PredictionCard
|
||||
:is-paid-tier="showFullPrediction"
|
||||
:loading="loading"
|
||||
:prediction="prediction"
|
||||
/>
|
||||
|
||||
<!-- Post-search filter bar -->
|
||||
<PostSearchFilters
|
||||
v-model:brand-filter="brandFilter"
|
||||
@@ -74,6 +81,7 @@
|
||||
:radius-miles="radiusMiles"
|
||||
:stations="filteredStations"
|
||||
/>
|
||||
<UpsellBanner :station-count="liveStats.stationCount" />
|
||||
<StationList
|
||||
:current-sort="sort"
|
||||
:origin="searchOrigin"
|
||||
@@ -201,7 +209,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-6 max-w-5xl mx-auto">
|
||||
<!-- Free -->
|
||||
<div class="bg-white border border-zinc-300 p-8 rounded-3xl flex flex-col h-full">
|
||||
<div class="mb-8">
|
||||
@@ -229,7 +237,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<ul class="space-y-4 mb-8 flex-1">
|
||||
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Ad-free Experience</li>
|
||||
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Buy-or-Wait Score</li>
|
||||
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> 14-day Trend Data</li>
|
||||
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> 3 Daily Price Alerts</li>
|
||||
</ul>
|
||||
@@ -253,23 +261,6 @@
|
||||
</ul>
|
||||
<a :href="ctaHref('plus')" class="w-full py-3 px-4 bg-accent text-white rounded-xl text-center font-bold shadow-lg hover:bg-primary-dark transition-all">{{ ctaLabel('plus') }}</a>
|
||||
</div>
|
||||
|
||||
<!-- Pro -->
|
||||
<div class="bg-zinc-800 border border-zinc-800 p-8 rounded-3xl flex flex-col h-full text-white">
|
||||
<div class="mb-8">
|
||||
<h4 class="text-xl font-bold font-display mb-2">Pro</h4>
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-4xl font-black">{{ PRICES[cadence].pro }}</span>
|
||||
<span class="text-zinc-400 text-sm">{{ PRICE_SUFFIX[cadence] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="space-y-4 mb-8 flex-1">
|
||||
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:sparkles"></iconify-icon> AI Price Predictions</li>
|
||||
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Multi-Vehicle Fleet</li>
|
||||
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Exportable Price History</li>
|
||||
</ul>
|
||||
<a :href="ctaHref('pro')" class="w-full py-3 px-4 bg-white text-zinc-800 rounded-xl text-center font-bold hover:bg-zinc-100 transition-colors">{{ ctaLabel('pro') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -316,13 +307,12 @@
|
||||
</section>
|
||||
|
||||
<!-- CTA -->
|
||||
<section class="py-12 md:py-24 px-6 bg-accent text-white text-center">
|
||||
<section class="py-12 md:py-24 px-6 bg-accent text-white text-center" v-if="!isAuthenticated">
|
||||
<div class="max-w-3xl mx-auto space-y-8">
|
||||
<h2 class="text-4xl md:text-5xl font-black font-display leading-tight">Ready to outsmart the pumps?</h2>
|
||||
<p class="text-xl text-white/80">Sign up for free today and never pay over the odds for fuel again.</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<a class="bg-white text-accent px-10 py-4 rounded-xl text-lg font-black shadow-2xl hover:bg-zinc-100 transition-all" href="/register">Create Free Account</a>
|
||||
<a class="bg-transparent border-2 border-white/30 text-white px-10 py-4 rounded-xl text-lg font-bold hover:bg-white/10 transition-all" href="#">Watch Demo Video</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -394,7 +384,9 @@ import { useAuth } from '../composables/useAuth.js'
|
||||
import { useStations } from '../composables/useStations.js'
|
||||
import api from '../axios.js'
|
||||
import PostSearchFilters from '../components/PostSearchFilters.vue'
|
||||
import PredictionCard from '../components/PredictionCard.vue'
|
||||
import StationList from '../components/StationList.vue'
|
||||
import UpsellBanner from '../components/UpsellBanner.vue'
|
||||
|
||||
const LeafletMap = defineAsyncComponent(() => import('../components/LeafletMap.vue'))
|
||||
import LandingNav from '../components/landing/LandingNav.vue'
|
||||
@@ -453,14 +445,15 @@ const PRICES = {
|
||||
annual: { basic: '£9.90', plus: '£24.90', pro: '£39.90' },
|
||||
}
|
||||
const PRICE_SUFFIX = { monthly: '/mo', annual: '/yr' }
|
||||
const { stations, meta, loading, error, search, reset } = useStations()
|
||||
const { stations, meta, prediction, loading, error, search, reset } = useStations()
|
||||
const showFullPrediction = computed(() => Boolean(prediction.value) && !prediction.value.tier_locked)
|
||||
|
||||
watch(loading, (isLoading) => {
|
||||
if (!isLoading) return
|
||||
nextTick(() => {
|
||||
window.scrollBy({ top: 40, behavior: 'smooth' })
|
||||
})
|
||||
})
|
||||
// watch(loading, (isLoading) => {
|
||||
// if (!isLoading) return
|
||||
// nextTick(() => {
|
||||
// window.scrollBy({ top: 40, behavior: 'smooth' })
|
||||
// })
|
||||
// })
|
||||
|
||||
const searchOrigin = computed(() => {
|
||||
if (meta.value?.lat != null && meta.value?.lng != null) {
|
||||
|
||||
@@ -19,11 +19,39 @@
|
||||
</div>
|
||||
|
||||
<div class="p-6 bg-white rounded-2xl border border-zinc-300 space-y-2">
|
||||
<p class="text-sm font-bold uppercase tracking-widest text-zinc-500">Your plan</p>
|
||||
<p class="font-mono text-[10px] uppercase tracking-widest text-zinc-500 mb-1 truncate">Your plan</p>
|
||||
<p class="text-xl font-black text-zinc-800 capitalize">{{ userTier }}</p>
|
||||
<a v-if="userTier === 'free'" class="inline-block text-sm font-bold text-accent hover:underline" href="/pricing">
|
||||
Upgrade for alerts + predictions →
|
||||
</a>
|
||||
<dl v-if="isPaidTier" class="grid grid-cols-1 sm:grid-cols-3 gap-4 pt-3 mt-3 border-t border-zinc-200">
|
||||
<div v-if="subscribedAt">
|
||||
<dt class="font-mono text-[10px] uppercase tracking-widest text-zinc-500 mb-1 truncate">Subscribed</dt>
|
||||
<dd class="text-sm font-semibold text-zinc-800 mt-0.5">{{ formatDate(subscribedAt) }}</dd>
|
||||
</div>
|
||||
<div v-if="subscriptionCadence">
|
||||
<dt class="font-mono text-[10px] uppercase tracking-widest text-zinc-500 mb-1 truncate">Billed</dt>
|
||||
<dd class="text-sm font-semibold text-zinc-800 mt-0.5 capitalize">{{ subscriptionCadence }}</dd>
|
||||
</div>
|
||||
<div v-if="subscriptionExpiresAt">
|
||||
<dt class="font-mono text-[10px] uppercase tracking-widest text-zinc-500 mb-1 truncate">
|
||||
{{ subscriptionCancelled ? 'Ends on' : 'Renews on' }}
|
||||
</dt>
|
||||
<dd class="text-sm font-semibold text-zinc-800 mt-0.5">{{ formatDate(subscriptionExpiresAt) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div v-if="isPaidTier && !subscriptionCancelled" class="pt-3 mt-3 border-t border-zinc-200">
|
||||
<a
|
||||
class="inline-flex items-center gap-1.5 text-sm font-semibold text-mauve hover:text-zinc-800 transition-colors"
|
||||
href="/billing/portal"
|
||||
>
|
||||
<iconify-icon class="text-base" icon="lucide:circle-x"></iconify-icon>
|
||||
Cancel subscription
|
||||
</a>
|
||||
<p class="text-xs text-zinc-500 mt-1">
|
||||
You'll keep your features until the end of the billing period.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -32,7 +60,25 @@
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useAuth } from '../../composables/useAuth.js'
|
||||
|
||||
const { user, userTier } = useAuth()
|
||||
const {
|
||||
user,
|
||||
userTier,
|
||||
isPaidTier,
|
||||
subscriptionCancelled,
|
||||
subscriptionCadence,
|
||||
subscribedAt,
|
||||
subscriptionExpiresAt,
|
||||
} = useAuth()
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? '' : dateFormatter.format(date)
|
||||
}
|
||||
|
||||
const quickLinks = [
|
||||
{ to: '/dashboard/saved-stations', label: 'Saved Stations', icon: 'lucide:bookmark', description: 'Stations you\'ve bookmarked for quick access.' },
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div v-html="setupData.svg" class="[&_svg]:w-40 [&_svg]:h-40"></div>
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs font-bold text-zinc-500 uppercase tracking-widest">Or enter setup key manually</p>
|
||||
<p class="font-mono text-[10px] uppercase tracking-widest text-zinc-500 mb-1 truncate">Or enter setup key manually</p>
|
||||
<code class="text-xs bg-zinc-50 px-3 py-2 rounded-lg font-mono text-zinc-800 break-all block">{{ setupData.secretKey }}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
use App\Enums\FuelType;
|
||||
use App\Http\Controllers\Api\AuthController;
|
||||
use App\Http\Controllers\Api\PredictionController;
|
||||
use App\Http\Controllers\Api\StationController;
|
||||
use App\Http\Controllers\Api\StatsController;
|
||||
use App\Http\Controllers\Api\UserController;
|
||||
@@ -13,6 +12,7 @@ use Illuminate\Support\Facades\Route;
|
||||
// Public endpoints (no API key required)
|
||||
Route::post('/auth/register', [AuthController::class, 'register']);
|
||||
Route::post('/auth/login', [AuthController::class, 'login']);
|
||||
Route::get('/auth/me', [AuthController::class, 'me']);
|
||||
|
||||
Route::get('/fuel-types', function () {
|
||||
return Cache::remember('api:fuel-types', now()->addDay(), fn () => collect(FuelType::cases())
|
||||
@@ -26,12 +26,10 @@ Route::get('/stats/live', [StatsController::class, 'live']);
|
||||
Route::middleware(['throttle:60,1', VerifyApiKey::class])->group(function (): void {
|
||||
Route::get('/stations', [StationController::class, 'index']);
|
||||
Route::get('/stats/searches', [StatsController::class, 'searches']);
|
||||
Route::get('/prediction', [PredictionController::class, 'index']);
|
||||
});
|
||||
|
||||
// Sanctum-authenticated endpoints
|
||||
Route::middleware('auth:sanctum')->group(function (): void {
|
||||
Route::get('/auth/me', [AuthController::class, 'me']);
|
||||
Route::post('/auth/logout', [AuthController::class, 'logout']);
|
||||
|
||||
// User dashboard endpoints
|
||||
|
||||
@@ -33,6 +33,14 @@ Schedule::command('oil:predict --fetch')
|
||||
->onOneServer()
|
||||
->runInBackground();
|
||||
|
||||
// Move station_prices rows older than 12 months into station_prices_archive
|
||||
// once a month. Keeps the partitioned hot table bounded.
|
||||
Schedule::command('fuel:archive')
|
||||
->monthlyOn(1, '04:00')
|
||||
->withoutOverlapping()
|
||||
->onOneServer()
|
||||
->runInBackground();
|
||||
|
||||
// Scheduled WhatsApp updates — morning and evening
|
||||
Schedule::job(new SendScheduledWhatsAppJob('morning'))->dailyAt('07:30')->onOneServer();
|
||||
Schedule::job(new SendScheduledWhatsAppJob('evening'))->dailyAt('18:00')->onOneServer();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Plan;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
@@ -68,6 +69,175 @@ it('returns the authenticated user on /me', function () {
|
||||
->assertJsonPath('email', $user->email);
|
||||
});
|
||||
|
||||
it('does not leak sensitive or internal user fields on /me', function () {
|
||||
$user = User::factory()->create([
|
||||
'is_admin' => true,
|
||||
'stripe_id' => 'cus_secret',
|
||||
'pm_type' => 'visa',
|
||||
'pm_last_four' => '4242',
|
||||
'postcode' => 'SW1A 1AA',
|
||||
]);
|
||||
|
||||
$user->subscriptions()->create([
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_secret',
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_plus_monthly',
|
||||
'quantity' => 1,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user, 'sanctum')
|
||||
->getJson('/api/auth/me')
|
||||
->assertOk();
|
||||
|
||||
$payload = $response->json();
|
||||
|
||||
expect(array_keys($payload))->toEqualCanonicalizing([
|
||||
'name',
|
||||
'email',
|
||||
'two_factor_confirmed_at',
|
||||
'tier',
|
||||
'subscription_cancelled',
|
||||
'subscription_cadence',
|
||||
'subscribed_at',
|
||||
'subscription_expires_at',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports subscription_cancelled=false for a user with no subscription', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->getJson('/api/auth/me')
|
||||
->assertOk()
|
||||
->assertJsonPath('subscription_cancelled', false);
|
||||
});
|
||||
|
||||
it('reports subscription_cancelled=false for an active paid subscription', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$user->subscriptions()->create([
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_active',
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_plus_monthly',
|
||||
'quantity' => 1,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->getJson('/api/auth/me')
|
||||
->assertOk()
|
||||
->assertJsonPath('subscription_cancelled', false);
|
||||
});
|
||||
|
||||
it('reports subscription_cancelled=true once the subscription is set to end at period end', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$user->subscriptions()->create([
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_cancelling',
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_plus_monthly',
|
||||
'quantity' => 1,
|
||||
'ends_at' => now()->addDays(20),
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->getJson('/api/auth/me')
|
||||
->assertOk()
|
||||
->assertJsonPath('subscription_cancelled', true);
|
||||
});
|
||||
|
||||
it('exposes subscribed_at, cadence and renewal date for an active monthly subscription', function () {
|
||||
Plan::where('name', 'plus')->update([
|
||||
'stripe_price_id_monthly' => 'price_plus_monthly_test',
|
||||
'stripe_price_id_annual' => 'price_plus_annual_test',
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$subscribedAt = now()->subDays(10)->startOfSecond();
|
||||
$renewalAt = now()->addDays(20)->startOfSecond();
|
||||
|
||||
$user->subscriptions()->create([
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_monthly_active',
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_plus_monthly_test',
|
||||
'quantity' => 1,
|
||||
'current_period_end' => $renewalAt,
|
||||
'created_at' => $subscribedAt,
|
||||
'updated_at' => $subscribedAt,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user, 'sanctum')
|
||||
->getJson('/api/auth/me')
|
||||
->assertOk()
|
||||
->assertJsonPath('subscription_cancelled', false)
|
||||
->assertJsonPath('subscription_cadence', 'monthly');
|
||||
|
||||
expect($response->json('subscribed_at'))->toStartWith($subscribedAt->toDateString());
|
||||
expect($response->json('subscription_expires_at'))->toStartWith($renewalAt->toDateString());
|
||||
});
|
||||
|
||||
it('reports cadence as annual when the active price is the annual one', function () {
|
||||
Plan::where('name', 'pro')->update([
|
||||
'stripe_price_id_monthly' => 'price_pro_monthly_test',
|
||||
'stripe_price_id_annual' => 'price_pro_annual_test',
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$user->subscriptions()->create([
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_annual_active',
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_pro_annual_test',
|
||||
'quantity' => 1,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->getJson('/api/auth/me')
|
||||
->assertOk()
|
||||
->assertJsonPath('subscription_cadence', 'annual');
|
||||
});
|
||||
|
||||
it('uses ends_at as the expiry date when subscription is cancelled', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$endsAt = now()->addDays(15)->startOfSecond();
|
||||
$renewalAt = now()->addDays(30)->startOfSecond();
|
||||
|
||||
$user->subscriptions()->create([
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_cancelling_with_period',
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_plus_monthly',
|
||||
'quantity' => 1,
|
||||
'ends_at' => $endsAt,
|
||||
'current_period_end' => $renewalAt,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user, 'sanctum')
|
||||
->getJson('/api/auth/me')
|
||||
->assertOk()
|
||||
->assertJsonPath('subscription_cancelled', true);
|
||||
|
||||
expect($response->json('subscription_expires_at'))->toStartWith($endsAt->toDateString());
|
||||
});
|
||||
|
||||
it('returns null subscription metadata for users with no subscription', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->getJson('/api/auth/me')
|
||||
->assertOk()
|
||||
->assertJsonPath('subscription_cancelled', false)
|
||||
->assertJsonPath('subscription_cadence', null)
|
||||
->assertJsonPath('subscribed_at', null)
|
||||
->assertJsonPath('subscription_expires_at', null);
|
||||
});
|
||||
|
||||
it('logs out and revokes the token', function () {
|
||||
$user = User::factory()->create();
|
||||
$token = $user->createToken('api')->plainTextToken;
|
||||
@@ -80,6 +250,12 @@ it('logs out and revokes the token', function () {
|
||||
expect($user->tokens()->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('returns 401 on protected routes without a token', function () {
|
||||
$this->getJson('/api/auth/me')->assertUnauthorized();
|
||||
it('returns null on /me when unauthenticated', function () {
|
||||
$response = $this->getJson('/api/auth/me')->assertOk();
|
||||
|
||||
expect($response->getContent())->toBe('null');
|
||||
});
|
||||
|
||||
it('returns 401 on protected routes without a token', function () {
|
||||
$this->postJson('/api/auth/logout')->assertUnauthorized();
|
||||
});
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\FuelType;
|
||||
use App\Models\Station;
|
||||
use App\Models\StationPriceCurrent;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->withHeaders(['X-Api-Key' => config('app.api_secret_key')]);
|
||||
});
|
||||
|
||||
it('returns a prediction response', function () {
|
||||
$this->getJson('/api/prediction')
|
||||
->assertOk()
|
||||
->assertJsonStructure([
|
||||
'fuel_type', 'current_avg', 'predicted_direction', 'predicted_change_pence',
|
||||
'confidence_score', 'confidence_label', 'action', 'reasoning',
|
||||
'prediction_horizon_days', 'region_key', 'methodology',
|
||||
'signals' => [
|
||||
'trend' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'],
|
||||
'day_of_week' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'],
|
||||
'brand_behaviour' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'],
|
||||
'national_momentum' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'],
|
||||
'regional_momentum' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'],
|
||||
'price_stickiness' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'],
|
||||
],
|
||||
])
|
||||
->assertJsonPath('fuel_type', 'e10')
|
||||
->assertJsonPath('region_key', 'national');
|
||||
});
|
||||
|
||||
it('includes current average from live prices', function () {
|
||||
$station = Station::factory()->create();
|
||||
StationPriceCurrent::factory()->create([
|
||||
'station_id' => $station->node_id,
|
||||
'fuel_type' => FuelType::E10,
|
||||
'price_pence' => 14750,
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/prediction')->assertOk();
|
||||
|
||||
expect($response->json('current_avg'))->toBe(147.5);
|
||||
});
|
||||
|
||||
it('returns regional prediction when lat and lng are provided', function () {
|
||||
$this->getJson('/api/prediction?lat=52.5&lng=-0.2')
|
||||
->assertOk()
|
||||
->assertJsonPath('region_key', 'regional')
|
||||
->assertJsonPath('fuel_type', 'e10');
|
||||
});
|
||||
|
||||
it('returns national prediction without coordinates', function () {
|
||||
$this->getJson('/api/prediction')
|
||||
->assertOk()
|
||||
->assertJsonPath('region_key', 'national');
|
||||
});
|
||||
|
||||
it('returns 422 for invalid lat', function () {
|
||||
$this->getJson('/api/prediction?lat=999&lng=0')
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['lat']);
|
||||
});
|
||||
|
||||
it('returns 422 for invalid lng', function () {
|
||||
$this->getJson('/api/prediction?lat=51.5&lng=999')
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['lng']);
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\FuelType;
|
||||
use App\Filament\Resources\UserResource;
|
||||
use App\Models\Station;
|
||||
use App\Models\StationPriceCurrent;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
@@ -12,6 +14,15 @@ beforeEach(function () {
|
||||
$this->withHeaders(['X-Api-Key' => config('app.api_secret_key')]);
|
||||
});
|
||||
|
||||
function asPaidUserOnStations(string $tier = 'plus'): User
|
||||
{
|
||||
test()->artisan('db:seed', ['--class' => 'PlanSeeder']);
|
||||
$user = User::factory()->create();
|
||||
UserResource::applyTier($user, $tier, 'monthly');
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
it('returns stations near coordinates filtered by fuel type', function () {
|
||||
$station = Station::factory()->create(['lat' => 52.555064, 'lng' => -0.256119]);
|
||||
StationPriceCurrent::factory()->create([
|
||||
@@ -192,3 +203,37 @@ it('includes resolved lat and lng in meta when postcode is provided', function (
|
||||
->assertJsonPath('meta.lat', 51.5010)
|
||||
->assertJsonPath('meta.lng', -0.1415);
|
||||
});
|
||||
|
||||
it('embeds a tier-locked prediction teaser for guest requests', function () {
|
||||
Station::factory()->create(['lat' => 52.555, 'lng' => -0.256]);
|
||||
|
||||
$this->getJson('/api/stations?lat=52.555&lng=-0.256&fuel_type=e10&radius=10')
|
||||
->assertOk()
|
||||
->assertJsonPath('prediction.tier_locked', true)
|
||||
->assertJsonStructure(['prediction' => ['fuel_type', 'predicted_direction', 'tier_locked']])
|
||||
->assertJsonMissingPath('prediction.signals')
|
||||
->assertJsonMissingPath('prediction.weekly_summary');
|
||||
});
|
||||
|
||||
it('embeds a tier-locked teaser for free-tier authenticated users', function () {
|
||||
asPaidUserOnStations('free');
|
||||
$user = User::query()->latest('id')->first();
|
||||
Station::factory()->create(['lat' => 52.555, 'lng' => -0.256]);
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->getJson('/api/stations?lat=52.555&lng=-0.256&fuel_type=e10&radius=10')
|
||||
->assertOk()
|
||||
->assertJsonPath('prediction.tier_locked', true)
|
||||
->assertJsonMissingPath('prediction.signals');
|
||||
});
|
||||
|
||||
it('embeds the full prediction payload for plus users', function () {
|
||||
$user = asPaidUserOnStations('plus');
|
||||
Station::factory()->create(['lat' => 52.555, 'lng' => -0.256]);
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->getJson('/api/stations?lat=52.555&lng=-0.256&fuel_type=e10&radius=10')
|
||||
->assertOk()
|
||||
->assertJsonStructure(['prediction' => ['fuel_type', 'predicted_direction', 'confidence_score', 'reasoning', 'weekly_summary', 'signals']])
|
||||
->assertJsonMissingPath('prediction.tier_locked');
|
||||
});
|
||||
|
||||
69
tests/Feature/Commands/ArchiveOldPricesCommandTest.php
Normal file
69
tests/Feature/Commands/ArchiveOldPricesCommandTest.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Station;
|
||||
use App\Models\StationPrice;
|
||||
use App\Models\StationPriceArchive;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('moves prices older than 12 months to archive', function (): void {
|
||||
$station = Station::factory()->create();
|
||||
|
||||
StationPrice::factory()->create([
|
||||
'station_id' => $station->node_id,
|
||||
'price_effective_at' => now()->subMonths(13),
|
||||
'price_reported_at' => now()->subMonths(13),
|
||||
'recorded_at' => now()->subMonths(13),
|
||||
]);
|
||||
|
||||
StationPrice::factory()->create([
|
||||
'station_id' => $station->node_id,
|
||||
'price_effective_at' => now()->subMonths(6),
|
||||
'price_reported_at' => now()->subMonths(6),
|
||||
'recorded_at' => now()->subMonths(6),
|
||||
]);
|
||||
|
||||
$this->artisan('fuel:archive')->assertSuccessful();
|
||||
|
||||
expect(StationPrice::count())->toBe(1)
|
||||
->and(StationPriceArchive::count())->toBe(1);
|
||||
});
|
||||
|
||||
it('outputs no-op message when nothing qualifies', function (): void {
|
||||
$station = Station::factory()->create();
|
||||
|
||||
StationPrice::factory()->create([
|
||||
'station_id' => $station->node_id,
|
||||
'price_effective_at' => now()->subMonths(3),
|
||||
'price_reported_at' => now()->subMonths(3),
|
||||
'recorded_at' => now()->subMonths(3),
|
||||
]);
|
||||
|
||||
$this->artisan('fuel:archive')
|
||||
->expectsOutputToContain('No prices to archive.')
|
||||
->assertSuccessful();
|
||||
|
||||
expect(StationPrice::count())->toBe(1)
|
||||
->and(StationPriceArchive::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('preserves the row data when archiving', function (): void {
|
||||
$station = Station::factory()->create();
|
||||
|
||||
$original = StationPrice::factory()->create([
|
||||
'station_id' => $station->node_id,
|
||||
'price_pence' => 14523,
|
||||
'price_effective_at' => now()->subMonths(13),
|
||||
'price_reported_at' => now()->subMonths(13),
|
||||
'recorded_at' => now()->subMonths(13),
|
||||
]);
|
||||
|
||||
$this->artisan('fuel:archive')->assertSuccessful();
|
||||
|
||||
$archived = StationPriceArchive::first();
|
||||
|
||||
expect($archived)->not->toBeNull()
|
||||
->and($archived->station_id)->toBe($original->station_id)
|
||||
->and($archived->price_pence)->toBe(14523);
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Public\Fuel\Map;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('renders the map component', function () {
|
||||
Livewire::test(Map::class)
|
||||
->assertStatus(200);
|
||||
});
|
||||
|
||||
it('dispatches map-update browser event when stations-found is received', function () {
|
||||
Livewire::test(Map::class)
|
||||
->dispatch('stations-found',
|
||||
results: [['name' => 'BP Garage']],
|
||||
meta: ['lat' => 51.5, 'lng' => -0.1, 'count' => 1],
|
||||
radius: 5,
|
||||
prediction: null
|
||||
)
|
||||
->assertDispatched('map-update');
|
||||
});
|
||||
|
||||
it('passes radius in map-update payload', function () {
|
||||
Livewire::test(Map::class)
|
||||
->dispatch('stations-found',
|
||||
results: [],
|
||||
meta: ['lat' => 51.5, 'lng' => -0.1, 'count' => 0],
|
||||
radius: 10,
|
||||
prediction: null
|
||||
)
|
||||
->assertDispatched('map-update', fn ($event, $params) =>
|
||||
$params['radius'] === 10
|
||||
);
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Public\Fuel\Recommendation;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('renders nothing before stations-found fires', function () {
|
||||
Livewire::test(Recommendation::class)
|
||||
->assertStatus(200)
|
||||
->assertSet('prediction', null)
|
||||
->assertDontSee('Recommendation');
|
||||
});
|
||||
|
||||
it('shows recommendation card when stations-found includes a prediction', function () {
|
||||
$prediction = [
|
||||
'action' => 'fill_now',
|
||||
'confidence_score' => 80.0,
|
||||
'confidence_label' => 'high',
|
||||
'reasoning' => 'Prices are rising sharply.',
|
||||
'predicted_direction' => 'up',
|
||||
'predicted_change_pence' => 3.5,
|
||||
];
|
||||
|
||||
Livewire::test(Recommendation::class)
|
||||
->dispatch('stations-found', results: [], meta: [], prediction: $prediction, radius: 5)
|
||||
->assertSet('prediction', $prediction)
|
||||
->assertSee('Recommendation')
|
||||
->assertSee('Fill up now');
|
||||
});
|
||||
|
||||
it('shows nothing when stations-found has null prediction', function () {
|
||||
Livewire::test(Recommendation::class)
|
||||
->dispatch('stations-found', results: [], meta: [], prediction: null, radius: 5)
|
||||
->assertSet('prediction', null)
|
||||
->assertDontSee('Recommendation');
|
||||
});
|
||||
|
||||
it('clears previous prediction when new stations-found fires with null prediction', function () {
|
||||
$prediction = [
|
||||
'action' => 'fill_now',
|
||||
'confidence_score' => 80.0,
|
||||
'confidence_label' => 'high',
|
||||
'reasoning' => 'Prices rising.',
|
||||
'predicted_direction' => 'up',
|
||||
'predicted_change_pence' => 3.5,
|
||||
];
|
||||
|
||||
Livewire::test(Recommendation::class)
|
||||
->dispatch('stations-found', results: [], meta: [], prediction: $prediction, radius: 5)
|
||||
->assertSee('Recommendation')
|
||||
->dispatch('stations-found', results: [], meta: [], prediction: null, radius: 5)
|
||||
->assertDontSee('Recommendation');
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Public\Fuel\StationList;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('renders empty state before any search', function () {
|
||||
Livewire::test(StationList::class)
|
||||
->assertStatus(200)
|
||||
->assertSet('hasSearched', false)
|
||||
->assertDontSee('Stations Nearby');
|
||||
});
|
||||
|
||||
it('shows station cards after stations-found event', function () {
|
||||
$station = [
|
||||
'station_id' => 'abc123',
|
||||
'name' => 'BP Garage',
|
||||
'brand' => 'BP',
|
||||
'is_supermarket' => false,
|
||||
'address' => '1 High Street',
|
||||
'postcode' => 'SW1A 1AA',
|
||||
'lat' => 51.5074,
|
||||
'lng' => -0.1278,
|
||||
'distance_km' => 1.5,
|
||||
'fuel_type' => 'e10',
|
||||
'price_pence' => 14390,
|
||||
'price' => 143.9,
|
||||
'price_updated_at' => '2026-04-05T08:00:00.000Z',
|
||||
'price_classification' => 'current',
|
||||
'price_classification_label' => 'Current',
|
||||
];
|
||||
$meta = ['count' => 1, 'lowest_pence' => 14390, 'avg_pence' => 14390.0];
|
||||
|
||||
Livewire::test(StationList::class)
|
||||
->dispatch('stations-found', results: [$station], meta: $meta, prediction: null, radius: 5)
|
||||
->assertSet('hasSearched', true)
|
||||
->assertSee('Stations Nearby')
|
||||
->assertSee('BP Garage')
|
||||
->assertSee('1 Result');
|
||||
});
|
||||
|
||||
it('shows empty state message when stations-found has no results', function () {
|
||||
Livewire::test(StationList::class)
|
||||
->dispatch('stations-found', results: [], meta: ['count' => 0], prediction: null, radius: 5)
|
||||
->assertSet('hasSearched', true)
|
||||
->assertSee('No stations found');
|
||||
});
|
||||
|
||||
it('updates results when stations-found fires again', function () {
|
||||
$station = [
|
||||
'station_id' => 'abc123',
|
||||
'name' => 'BP Garage',
|
||||
'brand' => 'BP',
|
||||
'is_supermarket' => false,
|
||||
'address' => '1 High Street',
|
||||
'postcode' => 'SW1A 1AA',
|
||||
'lat' => 51.5074,
|
||||
'lng' => -0.1278,
|
||||
'distance_km' => 1.5,
|
||||
'fuel_type' => 'e10',
|
||||
'price_pence' => 14390,
|
||||
'price' => 143.9,
|
||||
'price_updated_at' => '2026-04-05T08:00:00.000Z',
|
||||
'price_classification' => 'current',
|
||||
'price_classification_label' => 'Current',
|
||||
];
|
||||
|
||||
Livewire::test(StationList::class)
|
||||
->dispatch('stations-found', results: [$station], meta: ['count' => 1], prediction: null, radius: 5)
|
||||
->assertSee('BP Garage')
|
||||
->dispatch('stations-found', results: [], meta: ['count' => 0], prediction: null, radius: 5)
|
||||
->assertDontSee('BP Garage');
|
||||
});
|
||||
@@ -1,9 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Public\FuelFinder;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('renders the fuel finder shell', function () {
|
||||
Livewire::test(FuelFinder::class)
|
||||
->assertStatus(200);
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Jobs\SendPaymentFailedReminderJob;
|
||||
use App\Listeners\HandleStripeWebhook;
|
||||
use App\Models\Subscription;
|
||||
use App\Models\User;
|
||||
use App\Models\UserNotificationPreference;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@@ -121,6 +122,63 @@ it('on invoice.payment_failed sets grace_period_until 5 days out and queues both
|
||||
Queue::assertPushed(SendPaymentFailedReminderJob::class, 2);
|
||||
});
|
||||
|
||||
it('persists current_period_start, current_period_end and stripe_data on subscription.updated', function (): void {
|
||||
$user = User::factory()->create(['stripe_id' => 'cus_period_1']);
|
||||
|
||||
$user->subscriptions()->create([
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_period_1',
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_plus_monthly',
|
||||
'quantity' => 1,
|
||||
]);
|
||||
|
||||
$start = 1714377600;
|
||||
$end = 1717056000;
|
||||
|
||||
(new HandleStripeWebhook)->handle(new WebhookReceived([
|
||||
'type' => 'customer.subscription.updated',
|
||||
'data' => ['object' => [
|
||||
'id' => 'sub_period_1',
|
||||
'customer' => 'cus_period_1',
|
||||
'current_period_start' => $start,
|
||||
'current_period_end' => $end,
|
||||
'status' => 'active',
|
||||
]],
|
||||
]));
|
||||
|
||||
$sub = Subscription::where('stripe_id', 'sub_period_1')->first();
|
||||
|
||||
expect($sub->current_period_start->timestamp)->toBe($start);
|
||||
expect($sub->current_period_end->timestamp)->toBe($end);
|
||||
expect($sub->stripe_data)->toMatchArray(['id' => 'sub_period_1', 'status' => 'active']);
|
||||
});
|
||||
|
||||
it('reads current_period_end from items.data[0] when not at the root (newer Stripe API)', function (): void {
|
||||
$user = User::factory()->create(['stripe_id' => 'cus_period_2']);
|
||||
|
||||
$user->subscriptions()->create([
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_period_2',
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_plus_monthly',
|
||||
'quantity' => 1,
|
||||
]);
|
||||
|
||||
$end = 1719648000;
|
||||
|
||||
(new HandleStripeWebhook)->handle(new WebhookReceived([
|
||||
'type' => 'customer.subscription.updated',
|
||||
'data' => ['object' => [
|
||||
'id' => 'sub_period_2',
|
||||
'customer' => 'cus_period_2',
|
||||
'items' => ['data' => [['current_period_end' => $end]]],
|
||||
]],
|
||||
]));
|
||||
|
||||
expect(Subscription::where('stripe_id', 'sub_period_2')->value('current_period_end')->timestamp)->toBe($end);
|
||||
});
|
||||
|
||||
it('repeat invoice.payment_failed within grace does not re-dispatch reminders', function (): void {
|
||||
Queue::fake();
|
||||
$existingGrace = now()->addDays(3)->startOfSecond();
|
||||
|
||||
@@ -7,7 +7,9 @@ use App\Models\NotificationLog;
|
||||
use App\Models\Plan;
|
||||
use App\Models\User;
|
||||
use App\Models\UserNotificationPreference;
|
||||
use App\Notifications\FuelPriceAlert;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
@@ -39,6 +41,39 @@ it('logs a sent entry for each allowed channel', function (): void {
|
||||
->and($log->fuel_type)->toBe(FuelType::E10->value);
|
||||
});
|
||||
|
||||
it('actually dispatches FuelPriceAlert with the allowed channels', function (): void {
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
UserNotificationPreference::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'channel' => 'email',
|
||||
'fuel_type' => FuelType::E10->value,
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
(new DispatchUserNotificationJob($user, 'price_threshold', FuelType::E10->value, price: 143.9))->handle();
|
||||
|
||||
Notification::assertSentTo($user, FuelPriceAlert::class, function (FuelPriceAlert $n) {
|
||||
return $n->triggerType === 'price_threshold'
|
||||
&& $n->fuelType === FuelType::E10->value
|
||||
&& $n->price === 143.9
|
||||
&& in_array('email', $n->channels, true);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not dispatch FuelPriceAlert when no channels are allowed', function (): void {
|
||||
Notification::fake();
|
||||
|
||||
// Free user with no preferences — channelsFor returns []
|
||||
$user = User::factory()->create();
|
||||
|
||||
(new DispatchUserNotificationJob($user, 'price_threshold', FuelType::E10->value))->handle();
|
||||
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
|
||||
// ─── DispatchUserNotificationJob — tier_restricted logging ───────────────────
|
||||
|
||||
it('logs tier_restricted for channels the user wants but their tier forbids', function (): void {
|
||||
@@ -69,11 +104,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 +140,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 +176,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 +201,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 +214,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 +233,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');
|
||||
|
||||
/*
|
||||
|
||||
@@ -3,10 +3,26 @@
|
||||
namespace Tests;
|
||||
|
||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Laravel\Fortify\Features;
|
||||
|
||||
abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// SQLite lacks GREATEST/LEAST scalar functions — register PHP-backed
|
||||
// shims so the haversine and other math expressions used in
|
||||
// production-style queries run identically in :memory: tests.
|
||||
// Idempotent: registering twice on the same PDO is harmless.
|
||||
if (DB::connection()->getDriverName() === 'sqlite') {
|
||||
$pdo = DB::connection()->getPdo();
|
||||
$pdo->sqliteCreateFunction('GREATEST', fn (...$args) => max($args), -1);
|
||||
$pdo->sqliteCreateFunction('LEAST', fn (...$args) => min($args), -1);
|
||||
}
|
||||
}
|
||||
|
||||
protected function skipUnlessFortifyHas(string $feature, ?string $message = null): void
|
||||
{
|
||||
if (! Features::enabled($feature)) {
|
||||
|
||||
@@ -38,11 +38,24 @@ it('fetches and stores brent prices from EIA', function (): void {
|
||||
->and(BrentPrice::find('2026-04-02')->price_usd)->toBe('73.80');
|
||||
});
|
||||
|
||||
it('throws when EIA returns a 500', function (): void {
|
||||
it('throws with HTTP status when EIA returns a 500', function (): void {
|
||||
Http::fake(['*eia.gov/*' => Http::response([], 500)]);
|
||||
|
||||
expect(fn () => $this->fetcher->fetchFromEia())
|
||||
->toThrow(BrentPriceFetchException::class, 'EIA returned HTTP 500');
|
||||
});
|
||||
|
||||
it('retries EIA on transient 500 and succeeds', function (): void {
|
||||
Http::fake([
|
||||
'*eia.gov/*' => Http::sequence()
|
||||
->push([], 500)
|
||||
->push(['response' => ['data' => [['period' => '2026-04-01', 'value' => '75.10']]]]),
|
||||
]);
|
||||
|
||||
$this->fetcher->fetchFromEia();
|
||||
})->throws(BrentPriceFetchException::class);
|
||||
|
||||
expect(BrentPrice::count())->toBe(1);
|
||||
});
|
||||
|
||||
it('throws when EIA returns empty data', function (): void {
|
||||
Http::fake(['*eia.gov/*' => Http::response(['response' => ['data' => []]])]);
|
||||
@@ -84,11 +97,24 @@ it('fetches and stores brent prices from FRED', function (): void {
|
||||
expect(BrentPrice::count())->toBe(2);
|
||||
});
|
||||
|
||||
it('throws when FRED fails', function (): void {
|
||||
it('throws with HTTP status when FRED returns a 500', function (): void {
|
||||
Http::fake(['*/fred/series/observations*' => Http::response([], 500)]);
|
||||
|
||||
expect(fn () => $this->fetcher->fetchFromFred())
|
||||
->toThrow(BrentPriceFetchException::class, 'FRED returned HTTP 500');
|
||||
});
|
||||
|
||||
it('retries FRED on transient 500 and succeeds', function (): void {
|
||||
Http::fake([
|
||||
'*/fred/series/observations*' => Http::sequence()
|
||||
->push([], 500)
|
||||
->push(['observations' => [['date' => '2026-04-01', 'value' => '75.10']]]),
|
||||
]);
|
||||
|
||||
$this->fetcher->fetchFromFred();
|
||||
})->throws(BrentPriceFetchException::class);
|
||||
|
||||
expect(BrentPrice::count())->toBe(1);
|
||||
});
|
||||
|
||||
it('filters out FRED missing value markers', function (): void {
|
||||
Http::fake([
|
||||
|
||||
@@ -61,7 +61,7 @@ it('returns null when fewer than 14 prices are available for EWMA', function ():
|
||||
expect($this->predictor->generateEwmaPrediction($prices))->toBeNull();
|
||||
});
|
||||
|
||||
it('stores both EWMA and LLM predictions when provider succeeds', function (): void {
|
||||
it('stores only the LLM prediction when the provider succeeds', function (): void {
|
||||
seedPrices(20);
|
||||
|
||||
$this->provider->shouldReceive('predict')->once()->andReturn(new PricePrediction([
|
||||
@@ -76,7 +76,8 @@ it('stores both EWMA and LLM predictions when provider succeeds', function (): v
|
||||
$prediction = $this->predictor->generatePrediction();
|
||||
|
||||
expect($prediction->source)->toBe(PredictionSource::LlmWithContext)
|
||||
->and(PricePrediction::count())->toBe(2);
|
||||
->and(PricePrediction::count())->toBe(1)
|
||||
->and(PricePrediction::where('source', PredictionSource::Ewma)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('falls back to EWMA when provider returns null', function (): void {
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Models\StationPrice;
|
||||
use App\Models\StationPriceCurrent;
|
||||
use App\Services\NationalFuelPredictionService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@@ -78,14 +79,96 @@ it('includes all required keys in response', function () {
|
||||
'fuel_type', 'current_avg', 'predicted_direction', 'predicted_change_pence',
|
||||
'confidence_score', 'confidence_label', 'action', 'reasoning',
|
||||
'prediction_horizon_days', 'region_key', 'methodology',
|
||||
'signals',
|
||||
'weekly_summary', 'signals',
|
||||
])
|
||||
->and($result['signals'])->toHaveKeys([
|
||||
'trend', 'day_of_week', 'brand_behaviour',
|
||||
'national_momentum', 'regional_momentum', 'price_stickiness',
|
||||
'national_momentum', 'regional_momentum', 'price_stickiness', 'oil',
|
||||
])
|
||||
->and($result['weekly_summary'])->toHaveKeys([
|
||||
'yesterday_avg', 'today_avg', 'tomorrow_estimated_avg',
|
||||
'yesterday_today_delta_pence', 'last_7_days_series',
|
||||
'last_7_days_change_pence', 'cheapest_day', 'priciest_day', 'is_regional',
|
||||
]);
|
||||
});
|
||||
|
||||
it('weekly_summary returns null prices and empty series when there is no data', function () {
|
||||
$result = app(NationalFuelPredictionService::class)->predict();
|
||||
$weekly = $result['weekly_summary'];
|
||||
|
||||
expect($weekly['yesterday_avg'])->toBeNull()
|
||||
->and($weekly['yesterday_today_delta_pence'])->toBeNull()
|
||||
->and($weekly['last_7_days_series'])->toBe([])
|
||||
->and($weekly['cheapest_day'])->toBeNull()
|
||||
->and($weekly['priciest_day'])->toBeNull()
|
||||
->and($weekly['is_regional'])->toBeFalse();
|
||||
});
|
||||
|
||||
it('weekly_summary populates yesterday avg, today avg and 7-day series from station_prices', function () {
|
||||
$station = Station::factory()->create();
|
||||
StationPriceCurrent::factory()->create([
|
||||
'station_id' => $station->node_id,
|
||||
'fuel_type' => FuelType::E10,
|
||||
'price_pence' => 14000,
|
||||
]);
|
||||
|
||||
for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) {
|
||||
StationPrice::factory()->create([
|
||||
'station_id' => $station->node_id,
|
||||
'fuel_type' => FuelType::E10,
|
||||
'price_pence' => 14000 + ($daysAgo * 50),
|
||||
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
|
||||
]);
|
||||
}
|
||||
|
||||
$result = app(NationalFuelPredictionService::class)->predict();
|
||||
$weekly = $result['weekly_summary'];
|
||||
|
||||
expect($weekly['yesterday_avg'])->toBe(140.5)
|
||||
->and($weekly['today_avg'])->toBe(140.0)
|
||||
->and($weekly['yesterday_today_delta_pence'])->toBe(-0.5)
|
||||
->and(count($weekly['last_7_days_series']))->toBe(7)
|
||||
->and($weekly['cheapest_day']['avg'])->toBe(140.0)
|
||||
->and($weekly['priciest_day']['avg'])->toBe(143.0);
|
||||
});
|
||||
|
||||
it('weekly_summary falls back from regional to national when regional data is empty', function () {
|
||||
$station = Station::factory()->create(['lat' => 51.5, 'lng' => -0.1]);
|
||||
|
||||
for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) {
|
||||
StationPrice::factory()->create([
|
||||
'station_id' => $station->node_id,
|
||||
'fuel_type' => FuelType::E10,
|
||||
'price_pence' => 14000,
|
||||
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
|
||||
]);
|
||||
}
|
||||
|
||||
// Coordinates 600+ km away from any station — no regional data available.
|
||||
$result = app(NationalFuelPredictionService::class)->predict(58.0, -3.0);
|
||||
$weekly = $result['weekly_summary'];
|
||||
|
||||
expect($weekly['is_regional'])->toBeFalse()
|
||||
->and(count($weekly['last_7_days_series']))->toBe(7);
|
||||
});
|
||||
|
||||
it('weekly_summary marks is_regional true when stations exist within 50km of coordinates', function () {
|
||||
$station = Station::factory()->create(['lat' => 51.5, 'lng' => -0.1]);
|
||||
|
||||
for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) {
|
||||
StationPrice::factory()->create([
|
||||
'station_id' => $station->node_id,
|
||||
'fuel_type' => FuelType::E10,
|
||||
'price_pence' => 14000,
|
||||
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
|
||||
]);
|
||||
}
|
||||
|
||||
$result = app(NationalFuelPredictionService::class)->predict(51.5074, -0.1278);
|
||||
|
||||
expect($result['weekly_summary']['is_regional'])->toBeTrue();
|
||||
});
|
||||
|
||||
it('always returns e10 as fuel_type', function () {
|
||||
$result = app(NationalFuelPredictionService::class)->predict();
|
||||
|
||||
@@ -146,3 +229,166 @@ it('disables trend signal when r_squared is below 0.5', function () {
|
||||
// Trend signal may be disabled if both 5-day and 14-day lookbacks fail R² threshold
|
||||
expect($result['signals']['trend']['data_points'])->toBeInt();
|
||||
});
|
||||
|
||||
it('oil signal is disabled when no price_predictions row covers today or later', function () {
|
||||
$result = app(NationalFuelPredictionService::class)->predict();
|
||||
|
||||
expect($result['signals']['oil']['enabled'])->toBeFalse();
|
||||
});
|
||||
|
||||
it('oil signal picks up an llm prediction over an ewma one for the same date', function () {
|
||||
DB::table('price_predictions')->insert([
|
||||
[
|
||||
'predicted_for' => now()->toDateString(),
|
||||
'source' => 'ewma',
|
||||
'direction' => 'flat',
|
||||
'confidence' => 60,
|
||||
'reasoning' => null,
|
||||
'generated_at' => now()->subHour(),
|
||||
],
|
||||
[
|
||||
'predicted_for' => now()->toDateString(),
|
||||
'source' => 'llm',
|
||||
'direction' => 'rising',
|
||||
'confidence' => 75,
|
||||
'reasoning' => 'OPEC cut',
|
||||
'generated_at' => now(),
|
||||
],
|
||||
]);
|
||||
|
||||
$oil = app(NationalFuelPredictionService::class)->predict()['signals']['oil'];
|
||||
|
||||
expect($oil['enabled'])->toBeTrue()
|
||||
->and($oil['direction'])->toBe('up')
|
||||
->and($oil['score'])->toBe(1.0)
|
||||
->and($oil['confidence'])->toBe(0.75);
|
||||
});
|
||||
|
||||
it('oil signal prefers llm_with_context over plain llm', function () {
|
||||
DB::table('price_predictions')->insert([
|
||||
[
|
||||
'predicted_for' => now()->toDateString(),
|
||||
'source' => 'llm',
|
||||
'direction' => 'falling',
|
||||
'confidence' => 70,
|
||||
'reasoning' => 'baseline',
|
||||
'generated_at' => now(),
|
||||
],
|
||||
[
|
||||
'predicted_for' => now()->toDateString(),
|
||||
'source' => 'llm_with_context',
|
||||
'direction' => 'rising',
|
||||
'confidence' => 82,
|
||||
'reasoning' => 'with context',
|
||||
'generated_at' => now(),
|
||||
],
|
||||
]);
|
||||
|
||||
$oil = app(NationalFuelPredictionService::class)->predict()['signals']['oil'];
|
||||
|
||||
expect($oil['direction'])->toBe('up')
|
||||
->and($oil['confidence'])->toBe(0.82);
|
||||
});
|
||||
|
||||
it('confidence reaches "high" when trend and oil agree strongly', function () {
|
||||
$station = Station::factory()->create();
|
||||
|
||||
// Strong falling trend over 7 days, ~1p/day
|
||||
for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) {
|
||||
StationPrice::factory()->create([
|
||||
'station_id' => $station->node_id,
|
||||
'fuel_type' => FuelType::E10,
|
||||
'price_pence' => 15000 - ((6 - $daysAgo) * 100),
|
||||
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
|
||||
]);
|
||||
}
|
||||
|
||||
DB::table('price_predictions')->insert([
|
||||
'predicted_for' => now()->toDateString(),
|
||||
'source' => 'llm',
|
||||
'direction' => 'falling',
|
||||
'confidence' => 80,
|
||||
'reasoning' => 'agree',
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
|
||||
$result = app(NationalFuelPredictionService::class)->predict();
|
||||
|
||||
expect($result['predicted_direction'])->toBe('down')
|
||||
->and($result['confidence_score'])->toBeGreaterThanOrEqual(70)
|
||||
->and($result['confidence_label'])->toBe('high');
|
||||
});
|
||||
|
||||
it('confidence drops when trend and oil disagree', function () {
|
||||
$station = Station::factory()->create();
|
||||
|
||||
// Strong falling trend
|
||||
for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) {
|
||||
StationPrice::factory()->create([
|
||||
'station_id' => $station->node_id,
|
||||
'fuel_type' => FuelType::E10,
|
||||
'price_pence' => 15000 - ((6 - $daysAgo) * 100),
|
||||
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
|
||||
]);
|
||||
}
|
||||
|
||||
// Oil disagrees: rising
|
||||
DB::table('price_predictions')->insert([
|
||||
'predicted_for' => now()->toDateString(),
|
||||
'source' => 'llm',
|
||||
'direction' => 'rising',
|
||||
'confidence' => 80,
|
||||
'reasoning' => 'opec',
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
|
||||
$agree = app(NationalFuelPredictionService::class)->predict();
|
||||
|
||||
// Replace oil with one that agrees instead — confidence should be higher
|
||||
DB::table('price_predictions')->update([
|
||||
'direction' => 'falling',
|
||||
]);
|
||||
|
||||
$disagreeReplaced = app(NationalFuelPredictionService::class)->predict();
|
||||
|
||||
expect($agree['confidence_score'])->toBeLessThan($disagreeReplaced['confidence_score']);
|
||||
});
|
||||
|
||||
it('day-of-week signal activates at 21 days of history (no longer 56)', function () {
|
||||
$station = Station::factory()->create();
|
||||
|
||||
for ($daysAgo = 25; $daysAgo >= 0; $daysAgo--) {
|
||||
StationPrice::factory()->create([
|
||||
'station_id' => $station->node_id,
|
||||
'fuel_type' => FuelType::E10,
|
||||
'price_pence' => 14000 + ($daysAgo % 7) * 50,
|
||||
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
|
||||
]);
|
||||
}
|
||||
|
||||
$result = app(NationalFuelPredictionService::class)->predict();
|
||||
|
||||
expect($result['signals']['day_of_week']['enabled'])->toBeTrue();
|
||||
});
|
||||
|
||||
it('reasoning fallback for the wait action does not say "fill up"', function () {
|
||||
// No data → trend disabled, brand disabled, oil disabled.
|
||||
// Force a "down" direction by injecting an oil prediction that points down with low confidence.
|
||||
DB::table('price_predictions')->insert([
|
||||
'predicted_for' => now()->toDateString(),
|
||||
'source' => 'ewma',
|
||||
'direction' => 'falling',
|
||||
'confidence' => 50,
|
||||
'reasoning' => null,
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
|
||||
$result = app(NationalFuelPredictionService::class)->predict();
|
||||
|
||||
if ($result['action'] === 'wait') {
|
||||
expect($result['reasoning'])->not->toContain('fill up at the cheapest');
|
||||
} else {
|
||||
// If thresholds keep this at no_signal, still verify action-aware fallback exists
|
||||
expect($result['reasoning'])->toBeString();
|
||||
}
|
||||
});
|
||||
|
||||
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