Compare commits

..

17 Commits

Author SHA1 Message Date
Ovidiu U
28061541d4 refactor: remove auto-scroll on stations loading
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
Disabled the watch hook that scrolled the viewport when station results
were loading. The 40px nudge disrupted user focus and was not needed
for usability.
2026-04-30 09:01:11 +01:00
Ovidiu U
895d55439b refactor: extract StationSearchService
Audit item #8. StationController::index was ~100 lines doing the
haversine join, in-PHP filter/sort/group, search-row write, and
prediction call — well past the "thin orchestrator" line in
.claude/rules/architecture.md.

- App\Services\StationSearch\SearchCriteria — DTO (lat/lng/fuelType/
  radiusKm/sort)
- App\Services\StationSearch\SearchResult — DTO (stations, prices
  summary, reliability counts, prediction payload)
- App\Services\StationSearch\StationSearchService::search(criteria,
  ?user, ?ipHash) — owns the haversine query, the per-row reliability
  memoisation, sort, count, search-row logging, and the tier-gated
  prediction.

The controller now resolves coordinates, builds a SearchCriteria, calls
the service, and shapes the JSON response. Down from 154 → 71 lines.
Public API contract unchanged — all 15 StationController tests pass
without modification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 08:20:23 +01:00
Ovidiu U
aff6dd1e0f refactor: SendScheduledWhatsAppJob — drop redundant filtering
Audit item #12. The fan-out job ran an upfront Plan query plus a
per-user tier-name comparison before checking canSendNow('whatsapp').
Both are already covered by canSendNow → canUseChannel + daily-limit
count, so the parent was duplicating filtering work that the child
DispatchUserNotificationJob would do anyway via channelsFor().

Now the parent does only the cheap pre-check (canSendNow) before
dispatching the per-user child job. Iteration uses chunkById(500) to
make the memory bound explicit. Each user remains its own queueable
unit — independent retry, no shared failure mode across the cohort.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:21:30 +01:00
Ovidiu U
06f5f2035f refactor: extract iterateBatches helper in FuelPriceService
Audit item #9. pollPrices() and refreshStations() shared the same
do/while batch loop with token + try/catch + Log::error + clean-exit
detection but different bodies. Extracted to a private iterateBatches()
that takes the endpoint, optional since timestamp, and a process
callable. Returns [total_processed, completedCleanly] so pollPrices
can still gate its incremental-poll cache update on a clean exhaustion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:21:21 +01:00
Ovidiu U
69eb524e07 fix: ImportPostcodes streams into staging, swaps on success
Audit item #13. The command truncated postcodes + outcodes immediately
and then chunk-inserted from the CSV. A mid-stream failure (network
drive disconnect, malformed row) left the production tables empty
until a successful re-run.

Now streams into a postcodes_staging table created via Schema::create
(works on both MySQL and SQLite) and only swaps into the live tables
once the full CSV has been consumed. A try/finally ensures the file
handle and staging table are cleaned up on any path. Live data is now
preserved on partial failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:21:15 +01:00
Ovidiu U
b4ef1177b2 refactor: PollFuelPricesJob calls service directly
Audit item #11. The job was a one-line Artisan::call wrapper around
fuel:poll --full. Routing through Artisan adds output buffering and
swallows typed exceptions before they reach the queue's failed-job
handler. Now injects FuelPriceService and replicates the --full path
(refreshStations + pollPrices + PricesUpdatedEvent) directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:21:05 +01:00
Ovidiu U
8e29980dfe perf: memoize PriceReliability + PriceClassification per row
Audit item #10. PriceReliability::fromUpdatedAt was being invoked
~10× per station per /api/stations response — twice in the sort
comparator (once for $a, once for $b), once in the count groupBy, and
once per resource render. PriceClassification::fromUpdatedAt was
called twice inside the resource (value + label).

The controller now computes the parsed datetime + reliability +
classification once per row and stashes them on the row. Sort,
groupBy, and StationResource read the cached values; the resource
keeps a fresh-compute fallback for callers that bypass the controller.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:20:59 +01:00
Ovidiu U
4ce5066596 refactor: persist EWMA only on LLM failure, dedup EWMA helper
Audit items #7 and #5.

#7 — BrentPricePredictor::generatePrediction previously wrote both an
EWMA row and an LLM row to price_predictions on every run. The
downstream OilSignal already prefers llm_with_context > llm > ewma, so
the EWMA row was dead weight 95% of the time. Now we try LLM first; if
it returns null (no API key, parse failure, etc.) we compute and persist
EWMA as a real fallback. This also avoids redundant work on the success
path.

Updated the "stores both" test to "stores only LLM" — asserts no EWMA
row is written when the provider succeeds.

#5 — BrentPricePredictor and AnthropicPredictionProvider both had
byte-identical computeEwma() methods with identical EWMA_ALPHA = 0.3
constants. Extracted to App\Services\Ewma::compute() and dropped both
private methods + their alpha constants.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:04:41 +01:00
Ovidiu U
c46b017b51 chore: audit nits — PlanFeatures, test boot, EIA log, static method
Audit items #15, #16, #20, #22.

#15 — AuthController::me and UserResource form/table now read tier via
PlanFeatures::for($user)->tier() instead of Plan::resolveForUser($user)
->name. Tiers.md: PlanFeatures is the single entitlement gate.

#16 — Moved SQLite GREATEST/LEAST PHP-backed function registration from
AppServiceProvider::boot to tests/TestCase::setUp. Production app boot no
longer checks the DB driver name.

#20 — FetchOilPrices: added Log::warning on EIA fallback and Log::error
on both-providers-failed so primary-source reliability can be trended
beyond the cron output buffer.

#22 — FuelPriceService::flattenEnabledFlags is now an instance method,
matching the rest of the class. No external callers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:00:09 +01:00
Ovidiu U
7f64c42a23 refactor: extract DbDialect helper, inline ProfileValidationRules trait
Audit items #17 and #21.

#17 — DayOfWeekSignal and StickinessSignal each had their own
isSqlite ternary picking between SQLite (strftime/julianday) and
MySQL (DAYOFWEEK/DATEDIFF) date expressions. Centralised in
App\Services\Prediction\Signals\DbDialect.

#21 — ProfileValidationRules was a trait with one consumer
(CreateNewUser); inlined the rules into the action and deleted the
trait. Also dropped PasswordValidationRules::currentPasswordRules()
which was unused. PasswordValidationRules trait stays (two consumers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:00:01 +01:00
Ovidiu U
4d9df1ee19 chore: add OneSignal and Vonage env keys to .env.example
Follow-up to the FuelPriceAlert notification wiring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:48:21 +01:00
Ovidiu U
5369b4a5a0 feat: build FuelPriceAlert notification with multi-channel adapters
The DispatchUserNotificationJob has been logging sent=true for every
allowed channel without actually sending anything — a TODO marker covered
by the existing test suite, which only asserted log rows. The downstream
"missed today" widget read those rows and reported falsely. This commit
makes the telemetry truthful by wiring the real send.

- App\Notifications\FuelPriceAlert — Notification class with via() that
  returns the per-tier-filtered channel list passed in by the dispatcher.
  Implements toMail / toOneSignal / toVonageWhatsApp / toVonageSms.
  ShouldQueue on the 'notifications' queue.
- App\Notifications\Channels\OneSignalChannel — raw HTTP to OneSignal
  REST API, gated on services.onesignal.{app_id,api_key} + user
  push_token. Logs every call to api_logs via ApiLogger.
- App\Notifications\Channels\VonageWhatsAppChannel — raw HTTP to Vonage
  Messages API, gated on whatsapp_verified_at + whatsapp_number.
- App\Notifications\Channels\VonageSmsChannel — raw HTTP to Vonage SMS
  API, gated on whatsapp_number.
- DispatchUserNotificationJob now calls $user->notify(new
  FuelPriceAlert(...)) before logging.
- New tests: assert the notification IS dispatched with the right
  channels, and that nothing is dispatched when no channels are allowed.

Channels gracefully no-op when their credentials are unset (logging at
info level), so existing tests without a Notification::fake() still
pass — the channels just early-return on missing config.

No new composer dependencies — Vonage SDK avoided in favour of raw HTTP
through the existing ApiLogger pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:48:10 +01:00
Ovidiu U
27c82ef103 refactor: extract 6 prediction signals into Signal classes
The 803-line NationalFuelPredictionService had six private compute*Signal
methods, a private linearRegression helper, and a private disabledSignal
shape factory all crammed together. Each signal is now an independently
testable class.

- App\Services\Prediction\Signals\Signal — interface
- App\Services\Prediction\Signals\SignalContext — input value object
  (FuelType + optional lat/lng + hasCoordinates() helper)
- App\Services\Prediction\Signals\AbstractSignal — shared
  disabledSignal() and linearRegression() helpers
- TrendSignal, DayOfWeekSignal, BrandBehaviourSignal, StickinessSignal,
  RegionalMomentumSignal, OilSignal — one class each, extending
  AbstractSignal

NationalFuelPredictionService receives the 6 signal classes via constructor
injection and orchestrates them. The lat/lng null-guard for regional
momentum now lives inside RegionalMomentumSignal::compute() so the
coordinator no longer branches on coordinate presence.

Aggregation, weekly summary, and reasoning helpers stay in the service
for now — they are coupled to the public predict() output shape and are
candidates for a follow-up extraction once a stable API is locked in.

Service: 803 → 414 lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:43:28 +01:00
Ovidiu U
e39618f5df refactor: extract AbstractLlmPredictionProvider for shared boilerplate
Anthropic, Gemini, and OpenAi providers each repeated: API-key gate,
chronological price-list building, response validation
(direction/confidence/reasoning), TrendDirection::tryFrom, confidence
cap at 85, and the top-level try/catch + Log::error.

Now in AbstractLlmPredictionProvider:
- LLM_MAX_CONFIDENCE constant
- buildPriceList(Collection) helper
- buildPrediction(input, ?source) — handles direction validation,
  confidence cap, model construction
- defaultPrompt(priceList) — shared by Gemini and OpenAi
- Default predict() flow (apiKey + callProvider + buildPrediction +
  try/catch). Gemini and OpenAi only implement apiKey() and
  callProvider(). Anthropic overrides predict() because of its
  multi-phase web-search + forced-tool flow but reuses the helpers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:35:57 +01:00
Ovidiu U
00d0f7c8ec refactor: extract HaversineQuery helper, fix LL bind quirk
The 5 haversine SQL fragments duplicated across StationController and
NationalFuelPredictionService disagreed on float-clamping (LEAST only,
GREATEST/LEAST, vs. CASE WHEN). Centralised in App\Services\HaversineQuery
with the safe GREATEST(-1.0, LEAST(1.0, …)) form everywhere.

withinKm() embeds the radius as a numeric literal (sprintf %F) because
PDO + SQLite binds float parameters as strings by default, which breaks
numeric comparison against the haversine expression — a NULL filter would
silently match all rows. Coordinates remain bound positionally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:33:07 +01:00
Ovidiu U
48af2083f3 feat: add fuel:archive command and monthly scheduler entry
Completes Tasks 12 + 13 from docs/superpowers/plans/2026-04-03-fuel-api-ingestion.md.

- ArchiveOldPricesCommand moves station_prices rows older than 12 months
  into station_prices_archive in chunks of 1000, wrapping each chunk's
  insert + delete in a DB::transaction so a partial failure can't
  duplicate rows.
- StationPriceArchive: add $table = 'station_prices_archive'; without it
  Eloquent infers 'station_price_archives' and the insert would fail.
- routes/console.php: register fuel:archive monthly on the 1st at 04:00
  UTC, alongside the other fuel/oil scheduler entries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:33:05 +01:00
Ovidiu U
783297694c fix: model audit cleanups (primaryKey, fuel_type cast, cadence cache)
- StationPriceCurrent: $primaryKey was null; set to 'station_id' + keyType
  string so Eloquent has a sensible default for save() / find() paths.
- UserNotificationPreference: add FuelType enum cast on fuel_type so it
  hydrates as an enum like every other price model.
- Plan::resolveCadenceForUser: cache for 1h under the same plans tag as
  resolveForUser; HandleStripeWebhook busts both keys on subscription
  events.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:32:55 +01:00
53 changed files with 1891 additions and 1120 deletions

View File

@@ -72,6 +72,14 @@ ANTHROPIC_API_KEY=
ANTHROPIC_MODEL=claude-haiku-4-5
FRED_API_KEY=
ONESIGNAL_APP_ID=
ONESIGNAL_API_KEY=
VONAGE_KEY=
VONAGE_SECRET=
VONAGE_WHATSAPP_FROM=
VONAGE_SMS_FROM=FuelAlert
API_SECRET_KEY=
EIA_API_KEY=

View File

@@ -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();

View File

@@ -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'];
}
}

View File

@@ -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),
];
}
}

View 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;
}
}

View File

@@ -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;

View File

@@ -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.');

View File

@@ -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',

View File

@@ -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;
@@ -70,7 +71,7 @@ class AuthController extends Controller
return response()->json(array_merge(
$user->toArray(),
[
'tier' => Plan::resolveForUser($user)->name,
'tier' => PlanFeatures::for($user)->tier(),
'subscription_cancelled' => $subscription?->canceled() ?? false,
'subscription_cadence' => Plan::resolveCadenceForUser($user),
'subscribed_at' => $subscription?->created_at?->toIso8601String(),

View File

@@ -2,29 +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\Models\User;
use App\Services\NationalFuelPredictionService;
use App\Services\PlanFeatures;
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,
private readonly NationalFuelPredictionService $predictionService,
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());
@@ -33,119 +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');
}
$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),
],
],
'prediction' => $this->predictionFor($request->user(), $lat, $lng),
]);
}
/**
* Returns the prediction payload for embedding in the search response.
* 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 predictionFor(?User $user, float $lat, float $lng): array
{
$result = $this->predictionService->predict($lat, $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 [$location->lat, $location->lng];
}
return $result;
return [(float) $request->input('lat'), (float) $request->input('lng')];
}
}

View File

@@ -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(),
];

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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,36 +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)
->where('whatsapp_scheduled_updates', '>', 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');
});
}
}

View File

@@ -129,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}");
}
}

View File

@@ -77,33 +77,41 @@ class Plan extends Model
*/
public static function resolveCadenceForUser(User $user): ?string
{
if (! method_exists($user, 'subscriptions')) {
return null;
}
$cache = Cache::supportsTags() ? Cache::tags(['plans']) : Cache::store();
$priceId = $user->subscriptions()->active()->value('stripe_price');
return $cache->remember(
"plan_cadence_for_user_{$user->id}",
3600,
function () use ($user): ?string {
if (! method_exists($user, 'subscriptions')) {
return null;
}
if ($priceId === null) {
return null;
}
$priceId = $user->subscriptions()->active()->value('stripe_price');
$plan = static::where('stripe_price_id_monthly', $priceId)
->orWhere('stripe_price_id_annual', $priceId)
->first();
if ($priceId === null) {
return null;
}
if ($plan === null) {
return null;
}
$plan = static::where('stripe_price_id_monthly', $priceId)
->orWhere('stripe_price_id_annual', $priceId)
->first();
if ($plan->stripe_price_id_monthly === $priceId) {
return 'monthly';
}
if ($plan === null) {
return null;
}
if ($plan->stripe_price_id_annual === $priceId) {
return 'annual';
}
if ($plan->stripe_price_id_monthly === $priceId) {
return 'monthly';
}
return null;
if ($plan->stripe_price_id_annual === $priceId) {
return 'annual';
}
return null;
}
);
}
protected static function booted(): void

View File

@@ -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',
];
}

View File

@@ -19,7 +19,9 @@ class StationPriceCurrent extends Model
public $timestamps = false;
protected $primaryKey = null;
protected $primaryKey = 'station_id';
protected $keyType = 'string';
public $incrementing = false;

View File

@@ -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,
];
}
}

View 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()]);
}
}
}

View 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()]);
}
}
}

View 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()]);
}
}
}

View 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();
}
}

View File

@@ -59,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()

View File

@@ -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;

25
app/Services/Ewma.php Normal file
View 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);
}
}

View File

@@ -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 [];

View 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]];
}
}

View 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 35 days.
Recent Brent crude prices (USD/barrel):
{$priceList}
Respond with direction (rising, falling, or flat), a confidence score (085),
and a one-sentence reasoning.
PROMPT;
}
}

View File

@@ -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 35 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 35 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;
}
}

View File

@@ -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 35 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 (085),
and a one-sentence reasoning.
PROMPT;
return null;
}
return $data;
}
}

View File

@@ -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 35 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 (085),
and a one-sentence reasoning.
PROMPT;
return null;
}
return $data;
}
}

View File

@@ -4,23 +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;
/** Slope (pence/day) at which trend score saturates to ±1.0. */
private const float SLOPE_SATURATION_PENCE = 0.5;
/** Minimum unique days of history for the day-of-week signal to activate. */
private const int DAY_OF_WEEK_MIN_DAYS = 21;
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,
@@ -41,18 +49,17 @@ 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);
$oil = $this->computeOilSignal();
$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', 'oil');
@@ -103,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) {
@@ -119,367 +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 < 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 / 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,
];
}
/**
* 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 < self::DAY_OF_WEEK_MIN_DAYS) {
return $this->disabledSignal("Insufficient history for day-of-week pattern ({$uniqueDays} days, need ".self::DAY_OF_WEEK_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,
];
}
/**
* 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,
];
}
/**
* Reads the most recent Brent crude prediction (LLM preferred, EWMA fallback)
* covering today or later. Sourced from price_predictions, which OilPriceService
* populates daily.
*
* @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool}
*/
private function computeOilSignal(): 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,
];
}
/** @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool} */
private function disabledSignal(string $detail): array
{
@@ -668,11 +316,13 @@ class NationalFuelPredictionService
$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('(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.price_pence');
if ($regional !== null) {
@@ -697,11 +347,13 @@ class NationalFuelPredictionService
$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('(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)
->selectRaw('DATE(station_prices.price_effective_at) as day, AVG(station_prices.price_pence) as avg_price')
->groupBy('day')
->orderBy('day')
@@ -728,47 +380,6 @@ class NationalFuelPredictionService
return [$series, $usedRegional];
}
/**
* Least-squares linear regression.
* x is the array index (day number), y is the price value.
*
* @param float[] $values
* @return array{slope: float, r_squared: float}
*/
private 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];
}
/**
* @param array{enabled: bool, detail: string, direction: string} $trend
* @param array{enabled: bool, detail: string, direction: string} $brandBehaviour

View 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];
}
}

View 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,
];
}
}

View 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,
];
}
}

View 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}))";
}
}

View 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,
];
}
}

View 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,
];
}
}

View 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;
}

View 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;
}
}

View 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,
];
}
}

View 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,
];
}
}

View 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,
) {}
}

View 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,
) {}
}

View 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;
}
}

View File

@@ -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' => [

View File

@@ -448,12 +448,12 @@ const PRICE_SUFFIX = { monthly: '/mo', annual: '/yr' }
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) {

View File

@@ -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();

View 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);
});

View File

@@ -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 {

View File

@@ -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)) {

View File

@@ -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 {