Created migration for storing UK weekly pump prices from BEIS publications.
Table uses Monday date as primary key and stores petrol/diesel pump prices,
duty, and VAT rates as integer pence or percentage values.
- Made `/api/auth/me` public and return explicit allowlist (name, email,
two_factor_confirmed_at, tier, subscription fields) instead of spreading
`$user->toArray()` which leaked is_admin, stripe_id, pm_type, pm_last_four,
postcode. Returns `null` when unauthenticated rather than 401.
- Moved `/auth/logout` to remain behind auth:sanctum gate.
- Added 3×200ms retry with exponential backoff to EiaBrentPriceSource and
FredBrentPriceSource on ConnectionException or 5xx responses. Timeout
raised from 10s to 30s.
- Both sources now throw typed BrentPriceFetchException on exhausted retries
instead of silently returning null + logging. Updated tests to assert
exception message includes HTTP status or "connection failed".
Documented explicit prohibition of `migrate:fresh`, `migrate:reset`,
`db:wipe`, and raw DROP/TRUNCATE operations in CLAUDE.md. Prose rule
clarifies that user phrases like "trust me" or "do the refactor" are
not authorisation for schema rebuilds — architectural decision is
separate from operational step.
Added matching deny patterns to `.claude/settings.json` to block
direct inv
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.
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
The features JSON column required defensive fallback stubs in three
places (Plan::resolveForUser, PlanFeatures::__construct, PlanSeeder)
and silently swallowed misspelled keys. Typed columns give Eloquent
type-safe reads, simplify the Filament form (no more dotted JSON
paths), and let resolveForUser fail loud when the free row is
missing.
PlanFeatures public API is unchanged so consumers (jobs, middleware)
need no rewrites — one missed JSON read in SendScheduledWhatsAppJob
was caught and converted to a typed where() query.
tests/Pest.php seeds PlanSeeder in beforeEach so any feature test
that resolves a plan finds the free row, mirroring production where
plans always exist.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Consolidate prediction functionality by merging /api/prediction endpoint into /api/stations response. Move prediction logic from PredictionController into StationController, returning prediction data alongside station results. Replace usePrediction composable with unified useStations that returns {stations, meta, prediction}. Remove PredictionRequest, related tests, and unused Vue components (FuelFinderTest, MapTest, RecommendationTest, StationListTest). Add PredictionFull component and UpsellBanner. Extend NationalFuelPredictionService to include weekly_summary (7-day series, yesterday/today averages, cheapest/priciest days) and oil signal from price_predictions table. Update Home.vue to consume prediction from stations response. Add Plan::resolveCadenceForUser helper and configure Cashier to use custom Subscription model.
Add a prediction box above filter results on the homepage.
Server returns the full payload only when PlanFeatures::can(
'ai_predictions') — currently plus and pro. Other tiers and
guests get a trimmed {fuel_type, predicted_direction,
tier_locked: true} response so the gate is enforced server-side.
Frontend renders a compact one-liner with the national trend
direction for trimmed responses, full card for unlocked.
Hide the Pro plan card from the pricing section (pro plan
disabled in DB pending real Stripe price ids), and only show
the bottom signup CTA when the visitor is a guest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
env() returns an empty string (not null) when a STRIPE_PRICE_*
var is set but blank, so the ?? fallback never fired and the
synthetic subscription was created with stripe_price = '' —
which then resolved back to free in Plan::resolveForUser.
Switch to ?: so empty strings also fall back to the synthetic
price_admin_{tier}_{cadence} id, and backfill the matching Plan
row's stripe_price_id_{cadence} when empty so resolution succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Show an amber banner to logged-in users whose grace_period_until is set,
linking to the Stripe Customer Portal to update their card.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captures the agreed design for Stripe webhook handling, 5-day grace
period with branded day-3/day-5 reminders, and Stripe Customer Portal
as the single subscription-management surface. Updates payments rules
to match and ignores .worktrees/ for isolated implementation work.
Self-hosted UK postcode lookup: ONS Postcode Directory loaded into
local postcodes/outcodes tables; postcodes.io retained as fallback
for place names and unknown postcodes, with successful fallback
results persisted back to the local tables.