11 Commits

Author SHA1 Message Date
Ovidiu U
8fe3461adf Update legal pages with ICO registration, query logging, push notifications, and automated recommendations disclosure
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
- Add ICO registration reference (00014395133) to privacy policy, terms of service, and refund policy
- Document search/query logging (hashed IP, location, fuel type, result count) with 24-month retention under legitimate interests
- Add push notification data section (OneSignal endpoints, encryption keys, preferences) to privacy policy
- Add new section on automated recommendations explaining fill-up timing algorithm is informational only without legal effects
- Clarify IP address collection context: security/abuse/fraud only, not individual profiling
- Update retention periods: security logs 12 months, query logs 24 months, push subscriptions until unsubscribe
- Expand data processor descriptions (OneSignal push data, Umami cookieless commitment)
- Add commercial use restrictions to terms: no scraping, mirroring, or republishing compiled data/rankings without permission
- Clarify downstream data aggregator role and upstream data feed limitations
- Add prohibition on using service while operating motor vehicle
- Remove annual billing references (monthly only)
- Add VAT status notice and downgrade-to-free-tier behavior on cancellation
- Add data controller contact details to privacy policy footer
2026-06-10 13:15:43 +01:00
Ovidiu U
ad2230728c Disable Daily and Smart pricing CTAs until features ship
Replace the basic/plus checkout links on /pricing with disabled
"Coming soon" buttons, gated by a COMING_SOON list so each tier's CTA
goes live again by removing it from that list. Free is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:15:16 +01:00
Ovidiu U
5bd909d227 Show site footer on dashboard pages and trim footer links
Render the shared SiteFooter in DashboardLayout so it appears on every
dashboard page (overview, saved stations, preferences, settings) from a
single source. Content area grows to pin the footer to the bottom on
short pages.

Trim the footer Product column to real anchors and drop the dead-link
Resources column.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 12:40:51 +01:00
Ovidiu U
a841a7c900 Add dedicated /pricing page and lock launch tiers to Free/Daily/Smart
Consolidate pricing onto a single source. Pro is deferred from launch
(left dormant: no Stripe price, no card), so the offered set is 3 tiers.

- Extract the pricing grid and footer into shared components
  (PricingGrid.vue, landing/SiteFooter.vue); add a /pricing route
  rendering Pricing.vue; remove the pricing section from Home
- Repoint every upgrade link to the /pricing route (LandingNav and
  SiteFooter via RouterLink, UpsellBanner CTA) — no more #pricing anchors
- Bump Smart (plus) SMS daily limit 1 -> 3 (PlanSeeder + PlanFactory),
  update PlanFeaturesTest assertion
- Rewrite /pricing card bullets to match real entitlements (drop
  unbuilt promises: multi-location tracking, 14-day trend, supermarket anchor)
- Fix stale "1/day" SMS references in notifications.md, tiers.md, docs/tiers.md
- Delete unused resources/views/components/pricing-card.blade.php

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 12:22:19 +01:00
Ovidiu U
adf0b93a45 Coarsen GPS coordinates to 3 dp before API/URL and deduplicate runSearch triggers
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
- HeroSearch: round lat/lng to 3 decimal places (~111m precision) before emit to prevent exact location leakage in shareable URLs and server logs
- Home: move duplicate-search guard into runSearch (single choke point) instead of watcher, eliminating race between route.query sync and direct onSearch call
- Add inline documentation referencing .claude/rules/frontend.md privacy guidance
2026-06-10 11:08:55 +01:00
Ovidiu U
1ae2f1396d Update legal pages with contact details, location handling, and alert channels
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
- Expand privacy policy with detailed location-use explanations: search-only, shareable links, and saved location
2026-06-10 10:55:54 +01:00
Ovidiu U
df4ebdb7c6 Remove Livewire docs, add Vue SPA frontend docs, document log-masking runbook
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
- Delete `.claude/rules/livewire.md` (Livewire is vestigial – only starter-kit auth screens remain)
- Add `.claude/rules/frontend.md` documenting Vue 3 SPA stack, router, composables, search-URL mirroring, and Leaflet integration
- Add `docs/ops/nginx-log-masking.md` runbook for redacting lat/lng from Nginx access/error logs and PHP-FPM on IONOS VPS
- Update `CLAUDE.md` and `architecture.md` to reflect Vue SPA as primary frontend, REST API as backend contract, and Livewire's reduced role
- Note stale service names in legacy domain docs (`scoring.md`, `prediction.md`, `notifications.md`) now refactored into `Services/Forecasting/`, `Services/StationSearch/`, and `PlanFeatures`
2026-06-10 10:55:34 +01:00
Ovidiu U
7a8bd5c86a Remove placeholders and finalize contact details for launch
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
- Replace all `[PLACEHOLDER]` entries across legal pages with actual values
- Update privacy policy with ICO registration status and service provider details (Ionos, Vonage, OneSignal)
- Finalize contact email as `hello@fuel-alert.co.uk` throughout legal pages and footer
- Update legal layout to use full landing nav with auth/guest actions
- Add Umami analytics script to main app layout
- Remove "Fleet" nav link from landing navigation
- Add feature test to enforce no placeholders and correct contact domain remain in legal pages
2026-06-10 10:07:02 +01:00
Ovidiu U
ecd45588e9 Add legal policy pages and shared layout component
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
- Add Cookie Policy view documenting essential cookies (session, CSRF, remember_me, fa_location) and cookieless Umami analytics
- Add Privacy Policy view covering UK GDPR compliance, data categories, lawful bases, processors, retention, and user rights
- Add Refund & Cancellation Policy view explaining 14-day cooling-off period under Consumer Contracts Regulations 2013 and express-consent flow
- Add Terms of Service view defining account rules, subscription billing, and governing law
- Create shared legal layout component with FuelAlert header, footer with cross-links, and consistent typography
- Add feature tests covering all four legal pages and their cross-links
- All policies include placeholders for ICO registration number, email, and hosting/email providers pending production config
2026-05-14 17:43:53 +01:00
Ovidiu U
598ef04645 Rebrand from "Fuel Price" to "Fuel Alert" and update project metadata
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
- Rename project in package.json, composer.json, and .env.example
- Update app name, URLs, and session domains to fuel-alert
- Comment out testimonials section in Home.vue
- Revise footer copy: remove "
2026-05-14 14:30:02 +01:00
Ovidiu U
07e0789044 fix(forecasting): persist LLM overlay under Tier-1 ITPM via two-call architecture
The daily forecast:llm-overlay command was being skipped because the previous
single-conversation flow consumed more than Tier-1's 50,000 input-tokens-per-
minute Anthropic bucket. The web_search tool auto-caches its results (~55k
tokens) and requires `encrypted_content` intact when those blocks are resent,
so the prior retry-on-missing-citations path either 429'd or 400'd on the
second call.

LlmOverlayService now runs two independent API calls. Phase 1 invokes the
web_search tool and we discard the transcript after harvesting the URLs +
titles from the returned web_search_tool_result blocks. Phase 2 is a fresh
conversation containing the forecast context and the harvested headlines as
plain text, with a forced submit_overlay tool call. events_cited is now
optional in the tool schema — Haiku's flaky compliance no longer matters
because citations come from the search results, not the model's transcription.
Model-tagged events (with directional impact) merge with harvested-only
entries (impact: 'neutral'), deduped by URL.

Between phases the service reads anthropic-ratelimit-input-tokens-remaining /
…-reset from Phase 1's headers and sleeps proportionally — only long enough
for the SUBMIT_TOKEN_BUDGET worth of refill, not for the full bucket reset,
capped at 65 seconds.

ApiLogger now captures usage.input_tokens, usage.output_tokens,
cache_read_input_tokens, cache_creation_input_tokens, plus the rate-limit
remaining/reset headers on every Anthropic response. New nullable columns on
api_logs make rate-limit diagnostics directly queryable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:22:42 +01:00
42 changed files with 3093 additions and 1113 deletions

View File

@@ -1,57 +1,118 @@
# Architecture # Architecture
## Shape: Vue SPA ⇄ REST API ⇄ fat Laravel Services
The frontend is a **Vue 3 single-page app** (`resources/js/`). It talks to the
backend exclusively over a **REST API** (`routes/api.php`
`app/Http/Controllers/Api/`). Controllers stay thin; all business logic lives in
**Service classes** (`app/Services/`). Blade is reduced to a one-line SPA shell,
server-rendered legal pages, transactional emails, and the Filament admin panel.
```
Browser
└── Vue SPA (resources/js, mounted on #app via app.blade.php)
└── axios (resources/js/axios.js — baseURL '/api', cookie + XSRF auth)
└── REST API (routes/api.php → Http/Controllers/Api/*)
└── Services (app/Services/*) ← all business logic
└── Models / Jobs / Events / Notifications
```
## Core principle: fat Services, thin everything else ## Core principle: fat Services, thin everything else
All business logic lives in Service classes. Controllers, Livewire components, All business logic lives in Service classes. API controllers, console commands,
and console commands are thin orchestrators — they call Services and return results. jobs, listeners, and Filament resources are thin orchestrators — they call
This keeps the app API-extractable later without a rewrite. Services and return results. Never put domain logic in a controller or a Vue
component.
## Directory structure ## Directory structure (actual)
``` ```
app/ app/
├── Console/Commands/ # Scheduler commands (PollFuelPrices, PredictOilPrices, RunScoringEngine) ├── Console/Commands/ # Scheduler: PollFuelPrices, FetchOilPrices, BackfillOilPrices,
├── Http/Controllers/ # Minimal — auth + Stripe webhook only │ # RunLlmOverlay, EvaluateVolatilityRegime, ResolveForecastOutcomes,
├── Livewire/ # Classic two-file Livewire components # ArchiveOldPricesCommand, ImportBeisFuelPrices, ImportPostcodes
├── Models/ # Eloquent models ├── Http/
├── Notifications/ # Laravel Notification classes (multi-channel) │ ├── Controllers/Api/ # AuthController, StationController, StatsController, UserController
├── Services/ # ALL business logic lives here │ ├── Controllers/ # BillingController (Stripe Checkout/Portal redirects)
│ ├── FuelPriceService.php # Fuel Finder API polling + storage │ ├── Middleware/ # VerifyApiKey (API-key gate), RequiresFeature (tier gate)
│ ├── AlertScoringService.php # Fill-up timing recommendation engine │ ├── Requests/ # Form requests
── StationTaggingService.php # Supermarket brand detection ── Resources/Api/ # StationResource (API output shaping)
│ ├── NotificationDispatchService.php # Tier-aware notification routing ├── Actions/Fortify/ # Fortify auth actions (CreateNewUser, …)
│ ├── SubscriptionService.php # Cashier/tier helpers ├── Services/ # ALL business logic — see below
│ ├── PostcodeService.php # Resolves postcodes/outcodes/place names → lat/lng ├── Models/ # Eloquent models
│ ├── OilPriceService.php # FRED fetch + EWMA/LLM Brent crude prediction ├── Notifications/ # FuelPriceAlert + custom Channels (OneSignal, Vonage WA/SMS)
│ ├── LocationResult.php # DTO returned by PostcodeService ├── Jobs/ # DispatchUserNotificationJob, PollFuelPricesJob,
└── ApiLogger.php # Wraps all outbound HTTP calls, logs to api_logs # SendScheduledWhatsAppJob, SendPaymentFailedReminderJob
── Jobs/ # Queued jobs (dispatch notifications per user) ── Events/ # PricesUpdatedEvent
├── Listeners/ # HandleStripeWebhook (Cashier WebhookReceived)
├── Filament/ # Admin panel (Resources, Widgets) + Providers/Filament
├── Enums/ # FuelType, PlanTier, …
└── Livewire/Actions/ # Logout only — Livewire is otherwise vestigial
resources/views/ app/Services/
├── livewire/ # Livewire Blade templates ├── FuelPriceService.php # Fuel Finder API polling + storage
── emails/ # Mailable templates ── StationTaggingService.php # Supermarket brand detection
├── PostcodeService.php # postcode/outcode/place → lat/lng (+ LocationResult DTO)
├── PlanFeatures.php # Single source of tier-entitlement truth (see tiers.md)
├── HaversineQuery.php # Distance SQL helper
├── ApiLogger.php # Wraps outbound HTTP, logs to api_logs
├── StationSearch/ # StationSearchService (+ SearchCriteria, SearchResult DTOs)
├── BrentPriceSources/ # FRED + EIA Brent sources (+ BrentPriceFetcher)
└── Forecasting/ # Weekly forecast subsystem — WeeklyForecastService,
# LlmOverlayService, BacktestRunner, OutcomeResolver,
# VolatilityRegimeService, feature/leak tooling, …
resources/
├── js/ # Vue 3 SPA — see .claude/rules/frontend.md
└── views/
├── app.blade.php # SPA shell (mounts #app)
├── legal/ # Server-rendered legal pages
├── emails/ # Mailable templates
└── livewire/auth/ # Starter-kit Volt auth screens (left untouched)
routes/ routes/
├── web.php # All web routes (Livewire pages) ├── web.php # SPA shell + catch-all, legal pages, billing redirects, server logout
└── api.php # Empty for now — API added later if needed └── api.php # REST API consumed by the SPA (see below)
``` ```
> **Stale service names elsewhere.** Domain rule files (`scoring.md`,
> `prediction.md`, `notifications.md`) still name services that have since been
> refactored away — e.g. `AlertScoringService`, `OilPriceService`,
> `NationalFuelPredictionService`, `NotificationDispatchService`,
> `SubscriptionService`. Those concerns now live under `Services/Forecasting/`,
> `Services/StationSearch/`, and `PlanFeatures`. Always verify a service name
> against `app/Services/` before trusting it from those files.
## Service class conventions ## Service class conventions
- Constructor injection only — no facade usage inside Services - Constructor injection only — no facade usage inside Services
- Services are bound in AppServiceProvider if they need interfaces
- Each Service has one responsibility — do not merge concerns - Each Service has one responsibility — do not merge concerns
- Return typed DTOs or Eloquent collections — never raw arrays from Services - Return typed DTOs or Eloquent collections — never raw arrays from Services
- Services never dispatch jobs directly — that's the controller/command's job - Services never dispatch jobs directly — that's the controller/command/listener's job
## No API yet ## The REST API
`routes/api.php` stays empty for v1. Do not create API controllers or Sanctum `routes/api.php` is the SPA's backend. Three access tiers:
token auth. The app is Livewire-only until subscriber count justifies a native app.
When the API is added later, it will reuse the same Service classes.
## Livewire components (classic only) - **Public** (no auth): `POST /api/auth/register`, `POST /api/auth/login`,
`GET /api/auth/me`, `GET /api/fuel-types`, `GET /api/stats/live`
- **API-key** (`VerifyApiKey` + `throttle:60,1`): `GET /api/stations`,
`GET /api/stats/searches`
- **Sanctum** (`auth:sanctum`): `POST /api/auth/logout`, `/api/user/*`
(preferences, saved stations, profile, password, delete account)
Use two-file classic Livewire components. Do NOT use Volt single-file syntax. Conventions:
Volt files from the starter kit (auth screens) are left as-is — do not convert them.
New components go in `app/Livewire/` with corresponding Blade in `resources/views/livewire/`. - Shape responses with API Resources (`app/Http/Resources/Api/`), never raw arrays.
- The fuel **prediction is embedded in the `/api/stations` response** under the
`prediction` key — there is no standalone prediction endpoint (see `prediction.md`).
- Tier-gate routes with the `feature` middleware (`RequiresFeature`); never
inline entitlement checks (see `tiers.md`).
- Auth is cookie/session via Sanctum SPA mode (axios sends XSRF + credentials);
the API-key gate protects the expensive station search from scraping.
## Frontend
The Vue SPA is documented in `.claude/rules/frontend.md`. Do **not** build new
Livewire components — Livewire/Flux remain only for the starter-kit auth screens
and the `Logout` action.

74
.claude/rules/frontend.md Normal file
View File

@@ -0,0 +1,74 @@
# Frontend — Vue 3 SPA
The entire user-facing app (landing, station search, dashboard, settings) is a
**Vue 3 single-page application** under `resources/js/`. It is served from the
Blade shell `resources/views/app.blade.php` via the SPA catch-all in
`routes/web.php` and consumes the REST API (`routes/api.php`). There is **no
Livewire in the product UI** — do not add Livewire / Volt / Alpine components for
new features. Build Vue.
## Stack
- Vue 3.5 with `<script setup>` (Composition API), Vue Router 4 (`createWebHistory`)
- Vite 8 (`npm run dev` / `npm run build`), `@vitejs/plugin-vue`
- Tailwind CSS v4 (`@tailwindcss/vite`)
- Leaflet for maps, iconify-icon + Lucide for icons, axios for HTTP
## Layout
```
resources/js/
├── app.js # Entry — createApp(App).use(router).mount('#app')
├── App.vue # Root — <RouterView/>, fetches the user on mount
├── axios.js # Configured axios instance (baseURL '/api', XSRF + credentials)
├── router/index.js # Routes + requiresAuth guard (redirects to /login)
├── views/
│ ├── Home.vue # Landing + station search (mirrors state to the URL query)
│ └── dashboard/ # Overview, SavedStations, Preferences, settings/{Profile,Security,Appearance}
├── components/ # LeafletMap, StationCard, StationList, PredictionCard/Full,
│ # PostSearchFilters, UpsellBanner, landing/*
├── composables/ # useAuth, useStations, useSavedStations
└── constants/ # fuelTypes.js — shared fuel-type source of truth (front + back)
```
## Conventions
- **Composition API + `<script setup>` only.** No Options API.
- **All server state goes through composables** that call the configured `api`
axios instance (`axios.js`) — never `fetch()` or a bare `axios` call inside a
component.
- **Auth is cookie/session (Sanctum SPA mode).** axios is configured with
`withCredentials` + `withXSRFToken`; don't add bearer-token handling.
- Route guards live in `router/index.js` (`meta.requiresAuth`). Hard navigations
(`/login`, `/logout`) use `window.location.href`, not the router.
- Keep business logic in the API / Services. Vue components render and orchestrate.
## Search & the shareable URL
`Home.vue` is the search surface. Search params (postcode **or** lat/lng,
`fuel_type`, `radius`, `sort`) are mirrored into the **URL query** via
`router.push` (`queryFromParams`) so searches are shareable and bookmarkable, and
restored on load (`paramsFromQuery`). Because the URL is shareable, **any
coordinates written to it must be coarsened** — a precise GPS pair from "Use my
location" (`HeroSearch.vue` → browser Geolocation) would otherwise broadcast the
sharer's exact position to everyone they send the link to.
## Maps (Leaflet)
`components/LeafletMap.vue` wraps Leaflet directly (Leaflet is a plain JS library,
not a Vue plugin). Station and user markers and the Google-Maps directions links
are built from `station.lat` / `station.lng`; the user marker comes from the
browser Geolocation API.
## Prediction
The fuel prediction ships **inside** the `/api/stations` response under the
`prediction` key and is rendered by `PredictionCard.vue` / `PredictionFull.vue`
(`useStations` reads `response.data.prediction`). See `prediction.md` for the
payload shape and the tier gate.
---
paths:
- "resources/js/**/*.vue"
- "resources/js/**/*.js"
---

View File

@@ -1,61 +0,0 @@
# Livewire Components
## Classic two-file components only
Do NOT use Volt single-file syntax for new components.
Volt files created by the Livewire starter kit (auth screens) are left untouched.
## Component locations
```
app/Livewire/
├── Dashboard/
│ ├── FuelRecommendation.php # Main fill-up/wait card
│ ├── NearbyStations.php # Map + station list
│ └── PriceHistory.php # 14-day trend chart
├── Account/
│ ├── NotificationSettings.php # Channel prefs + WhatsApp OTP
│ ├── SubscriptionManager.php # Upgrade/downgrade UI
│ └── FuelPreferences.php # Fuel type, postcode
└── Public/
└── PriceWatchdog.php # Public-facing local price watchdog
```
## Component conventions
- Component properties that are shown in the view must be `public`
- Use `#[Computed]` attribute for derived values — not re-computed on every render
- Validate with `#[Validate]` attribute on properties, not in separate rules array
- Never put Service instantiation in the component — inject via method parameter or mount()
- Dispatch browser events with `$this->dispatch()` not `$this->emit()` (Livewire 3 syntax)
- Use `wire:loading` on all buttons that trigger server actions
## Alpine.js usage
Alpine.js handles local UI state only: dropdowns, modals, tab switching, copy-to-clipboard.
Do not replicate server state in Alpine — use Livewire for anything that needs PHP.
Alpine components stay inline in Blade (`x-data="{}"`), not in separate JS files unless reused 3+ times.
## Map (Leaflet.js)
Leaflet is a plain JS drop-in, not a Livewire component.
Station data is fetched from a dedicated Livewire endpoint and passed to Leaflet via Alpine:
```blade
<div
x-data="stationMap(@entangle('stations'))"
id="map"
style="height: 400px"
></div>
```
Map initialisation lives in `resources/js/maps/station-map.js`.
## Page routing
Livewire full-page components are mounted in `routes/web.php` using `Route::get()->component()`.
No separate view files for pages — the Livewire component IS the page.
---
paths:
- "app/Livewire/**/*.php"
- "resources/views/livewire/**/*.blade.php"
---

View File

@@ -6,7 +6,7 @@
|------------|--------|-------|------------|----------|-----------| |------------|--------|-------|------------|----------|-----------|
| Free | £0 | ✓ weekly digest | ✗ | ✗ | ✗ | | Free | £0 | ✓ weekly digest | ✗ | ✗ | ✗ |
| Basic | £0.99 | ✓ daily | ✓ daily | ✓ daily | ✗ | | Basic | £0.99 | ✓ daily | ✓ daily | ✓ daily | ✗ |
| Plus | £2.49 | ✓ | ✓ | ✓ | ✓ max 1/day triggered | | Plus | £2.49 | ✓ | ✓ | ✓ | ✓ max 3/day triggered |
| Pro | £3.99 | ✓ | ✓ | ✓ | ✓ max 3/day triggered | | Pro | £3.99 | ✓ | ✓ | ✓ | ✓ max 3/day triggered |
## NotificationDispatchService ## NotificationDispatchService
@@ -41,8 +41,8 @@ Subject line / message copy adapts based on `recommendation` and `confidence`.
- Same Vonage client, SMS channel - Same Vonage client, SMS channel
- Triggered only (not daily) — fires when signal strength ≥ 2 AND price event warrants it - Triggered only (not daily) — fires when signal strength ≥ 2 AND price event warrants it
- Plus: max 8 SMS/month (enforced via alerts table count) - Plus (Smart): max 3 SMS/day (enforced via notification_log count)
- Pro: max 30 SMS/month - Pro: deferred from launch — see `tiers.md`
- Cost: ~3.5p per message UK - Cost: ~3.5p per message UK
## Email ## Email

View File

@@ -50,7 +50,7 @@ All six are available to all tiers. The restriction is quantity only:
| email | weekly digest | daily | ✓ triggered | ✓ triggered | | email | weekly digest | daily | ✓ triggered | ✓ triggered |
| push | ✗ | ✓ daily | ✓ triggered | ✓ triggered | | push | ✗ | ✓ daily | ✓ triggered | ✓ triggered |
| whatsapp | ✗ | ✓ daily | ✓ triggered | ✓ triggered | | whatsapp | ✗ | ✓ daily | ✓ triggered | ✓ triggered |
| sms | ✗ | ✗ | ✓ max 1/day | ✓ max 3/day | | sms | ✗ | ✗ | ✓ max 3/day | ✓ max 3/day |
WhatsApp also supports scheduled updates (morning + evening) independent of WhatsApp also supports scheduled updates (morning + evening) independent of
price triggers — available to any tier that has WhatsApp enabled. price triggers — available to any tier that has WhatsApp enabled.

View File

@@ -1,8 +1,8 @@
APP_NAME="Fuel Finder" APP_NAME="Fuel Alert"
APP_ENV=local APP_ENV=local
APP_KEY= APP_KEY=
APP_DEBUG=true APP_DEBUG=true
APP_URL=http://fuel-price.test APP_URL=http://fuel-alert.test
APP_LOCALE=en APP_LOCALE=en
APP_FALLBACK_LOCALE=en APP_FALLBACK_LOCALE=en
@@ -31,7 +31,7 @@ SESSION_DRIVER=database
SESSION_LIFETIME=120 SESSION_LIFETIME=120
SESSION_ENCRYPT=false SESSION_ENCRYPT=false
SESSION_PATH=/ SESSION_PATH=/
SESSION_DOMAIN=.fuel-price.test SESSION_DOMAIN=.fuel-alert.test
BROADCAST_CONNECTION=log BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local FILESYSTEM_DISK=local
@@ -97,4 +97,4 @@ STRIPE_PRICE_PLUS_ANNUAL=price_1TM3pXJuhjW3IKHlfQenHsf1
STRIPE_PRICE_PRO_MONTHLY= STRIPE_PRICE_PRO_MONTHLY=
STRIPE_PRICE_PRO_ANNUAL= STRIPE_PRICE_PRO_ANNUAL=
SANCTUM_STATEFUL_DOMAINS=fuel-price.test SANCTUM_STATEFUL_DOMAINS=fuel-alert.test

View File

@@ -1,8 +1,16 @@
# Fuel Price — Claude Code Instructions # Fuel Alert — Claude Code Instructions
UK fuel price intelligence app. Subscribers receive fill-up timing recommendations UK fuel price intelligence app. Subscribers receive fill-up timing recommendations
based on local price trends. Built solo by a PHP/Laravel developer. based on local price trends. Built solo by a PHP/Laravel developer.
> **Stack reality check (read first).** The frontend is a **Vue 3 SPA**
> (`resources/js/`, see `frontend.md`), not Livewire. Station data is served by a
> **REST API** under `routes/api.php` (see `architecture.md`). Some domain rule
> files (`scoring.md`, `prediction.md`, `notifications.md`) still name services
> that were since refactored into `Services/Forecasting/`,
> `Services/StationSearch/`, and `PlanFeatures` — verify names against
> `app/Services/`. When docs and code disagree, the code wins.
## Destructive DB operations — HARD STOP ## Destructive DB operations — HARD STOP
**Never run** the following commands. If one of them is the right step, stop, tell the user the exact command, and ask them to run it themselves: **Never run** the following commands. If one of them is the right step, stop, tell the user the exact command, and ask them to run it themselves:
@@ -21,9 +29,23 @@ A user saying "trust me", "do the refactor", "clean up the mess", or "I want it
- **Product**: "Fill up now or wait?" — local fuel price trend scoring for UK drivers - **Product**: "Fill up now or wait?" — local fuel price trend scoring for UK drivers
- **Monetisation**: £0/mo free, £0.99/mo Basic, £2.49/mo Plus, £3.99/mo Pro - **Monetisation**: £0/mo free, £0.99/mo Basic, £2.49/mo Plus, £3.99/mo Pro
- **Stack**: Laravel 11 + Livewire 3 (Volt disabled — use classic components) + Alpine.js + Tailwind CSS - **Backend**: Laravel 13 + PHP 8.4. MySQL (Eloquent, migrations only — no raw DDL)
- **Database**: MySQL — Eloquent ORM, migrations only (no raw DDL) - **Frontend**: **Vue 3 SPA** — Vue 3.5 + Vue Router 4, Vite 8, Tailwind CSS v4.
- **Payments**: Stripe via Laravel Cashier Entry `resources/js/app.js` mounts `App.vue`; views/components under
`resources/js/`. Served from the Blade shell `resources/views/app.blade.php`
via the SPA catch-all in `web.php`. Maps: Leaflet
(`components/LeafletMap.vue`). Icons: iconify-icon + Lucide. HTTP: axios
(`resources/js/axios.js`).
- **API**: REST API in `routes/api.php``app/Http/Controllers/Api/*`
(Auth, Station, Stats, User). Public station data is gated by an API key
(`VerifyApiKey` middleware); user/dashboard endpoints use Sanctum. The SPA is
the primary consumer.
- **Auth**: Laravel Fortify (backend auth) + Sanctum (API tokens)
- **Admin**: Filament v5 panel
- **Livewire**: v4 / Flux v2 are installed but **vestigial** — only starter-kit
Volt auth screens (`resources/views/livewire/auth`) and `app/Livewire/Actions`
remain. Do **not** build new Livewire components; build Vue.
- **Payments**: Stripe via Laravel Cashier (v16)
- **Notifications**: Laravel Notification channels — email, WhatsApp (Vonage), SMS (Vonage), push (OneSignal) - **Notifications**: Laravel Notification channels — email, WhatsApp (Vonage), SMS (Vonage), push (OneSignal)
- **Queue**: Laravel queues with Redis driver (notifications and polling jobs) - **Queue**: Laravel queues with Redis driver (notifications and polling jobs)
- **Scheduler**: Laravel scheduler for Fuel Finder API polling and scoring - **Scheduler**: Laravel scheduler for Fuel Finder API polling and scoring
@@ -36,7 +58,8 @@ php artisan queue:work # Process notification jobs
php artisan schedule:run # Run scheduled commands (cron every minute) php artisan schedule:run # Run scheduled commands (cron every minute)
php artisan migrate # Run migrations php artisan migrate # Run migrations
php artisan test # Run Pest test suite php artisan test # Run Pest test suite
npm run dev # Vite asset watcher npm run dev # Vite dev server (Vue SPA + HMR)
npm run build # Production build — run if SPA changes don't show up
``` ```
## Imports ## Imports
@@ -48,7 +71,7 @@ npm run dev # Vite asset watcher
@.claude/rules/prediction.md @.claude/rules/prediction.md
@.claude/rules/payments.md @.claude/rules/payments.md
@.claude/rules/tiers.md @.claude/rules/tiers.md
@.claude/rules/livewire.md @.claude/rules/frontend.md
@.claude/rules/api-data.md @.claude/rules/api-data.md
@.claude/rules/testing.md @.claude/rules/testing.md
@.claude/rules/code-style.md @.claude/rules/code-style.md

View File

@@ -7,7 +7,21 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
#[Fillable(['service', 'method', 'url', 'status_code', 'duration_ms', 'error', 'response_body'])] #[Fillable([
'service',
'method',
'url',
'status_code',
'duration_ms',
'error',
'response_body',
'input_tokens',
'output_tokens',
'cache_read_tokens',
'cache_write_tokens',
'ratelimit_remaining',
'ratelimit_reset_at',
])]
class ApiLog extends Model class ApiLog extends Model
{ {
/** @use HasFactory<ApiLogFactory> */ /** @use HasFactory<ApiLogFactory> */
@@ -19,6 +33,7 @@ class ApiLog extends Model
{ {
return [ return [
'created_at' => 'datetime', 'created_at' => 'datetime',
'ratelimit_reset_at' => 'datetime',
]; ];
} }
} }

View File

@@ -34,10 +34,12 @@ class ApiLogger
$statusCode = null; $statusCode = null;
$error = null; $error = null;
$responseBody = null; $responseBody = null;
$usage = [];
try { try {
$response = $request(); $response = $request();
$statusCode = $response->status(); $statusCode = $response->status();
$usage = $this->extractUsage($response);
if ($response->failed()) { if ($response->failed()) {
$body = $response->body(); $body = $response->body();
@@ -53,6 +55,7 @@ class ApiLogger
// doesn't. Pull the body when it's available. // doesn't. Pull the body when it's available.
if ($e instanceof RequestException) { if ($e instanceof RequestException) {
$responseBody = $this->truncate($e->response->body()); $responseBody = $this->truncate($e->response->body());
$usage = $this->extractUsage($e->response);
} }
throw $e; throw $e;
@@ -65,6 +68,7 @@ class ApiLogger
'duration_ms' => (int) round((microtime(true) - $start) * 1000), 'duration_ms' => (int) round((microtime(true) - $start) * 1000),
'error' => $error, 'error' => $error,
'response_body' => $responseBody, 'response_body' => $responseBody,
...$usage,
]); ]);
} }
} }
@@ -75,4 +79,39 @@ class ApiLogger
? substr($body, 0, self::RESPONSE_BODY_CAP) ? substr($body, 0, self::RESPONSE_BODY_CAP)
: $body; : $body;
} }
/**
* Pull token-usage and rate-limit telemetry from a provider response.
*
* Today only Anthropic exposes both. Other providers return mostly
* NULLs callers don't need to know which is which.
*
* @return array<string, int|string|null>
*/
private function extractUsage(?Response $response): array
{
if ($response === null) {
return [];
}
$usage = $response->json('usage');
$tokens = is_array($usage) ? $usage : [];
$reset = $response->header('anthropic-ratelimit-input-tokens-reset');
$remaining = $response->header('anthropic-ratelimit-input-tokens-remaining');
return [
'input_tokens' => $this->intOrNull($tokens['input_tokens'] ?? null),
'output_tokens' => $this->intOrNull($tokens['output_tokens'] ?? null),
'cache_read_tokens' => $this->intOrNull($tokens['cache_read_input_tokens'] ?? null),
'cache_write_tokens' => $this->intOrNull($tokens['cache_creation_input_tokens'] ?? null),
'ratelimit_remaining' => $this->intOrNull($remaining !== '' ? $remaining : null),
'ratelimit_reset_at' => $reset !== '' ? $reset : null,
];
}
private function intOrNull(mixed $value): ?int
{
return is_numeric($value) ? (int) $value : null;
}
} }

View File

@@ -6,7 +6,9 @@ use App\Models\BrentPrice;
use App\Models\LlmOverlay; use App\Models\LlmOverlay;
use App\Models\VolatilityRegime; use App\Models\VolatilityRegime;
use App\Services\ApiLogger; use App\Services\ApiLogger;
use Carbon\CarbonImmutable;
use Carbon\CarbonInterface; use Carbon\CarbonInterface;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -15,9 +17,21 @@ use Throwable;
/** /**
* Layer 4 daily news-aware overlay on the calibrated ridge forecast. * Layer 4 daily news-aware overlay on the calibrated ridge forecast.
* *
* Calls Anthropic Haiku with the web_search tool, then forces a * Runs as two independent Anthropic API calls:
* submit_overlay tool call to get structured output. Cites events with * Phase 1 web_search tool only; we capture the URLs/titles from
* URLs; URLs are verified before storing. Empty citations rejection. * the returned web_search_tool_result blocks.
* Phase 2 fresh conversation containing those URLs+titles as plain
* text plus a forced submit_overlay tool call.
*
* Phase 1's transcript is never sent back to Phase 2. Anthropic's
* web_search auto-caches the encrypted page text (~55k tokens per
* search) and requires it intact when web_search_tool_result blocks
* are resent. Threading it through to Phase 2 either blows the Tier-1
* 50k ITPM bucket or 400s if we try to strip it. Two clean calls keep
* Phase 2 around 3k input tokens.
*
* Citations are harvested directly from Phase 1's web_search_tool_result
* blocks Haiku is unreliable about populating `events_cited` itself.
* *
* Read-only with respect to the volatility flag Layer 4 writes its * Read-only with respect to the volatility flag Layer 4 writes its
* `llm_overlays` row; Layer 5's hourly cron picks it up and decides * `llm_overlays` row; Layer 5's hourly cron picks it up and decides
@@ -31,6 +45,15 @@ final class LlmOverlayService
private const int COOLDOWN_HOURS = 4; private const int COOLDOWN_HOURS = 4;
private const int MAX_SEARCH_TURNS = 2;
/**
* Approximate input-token cost of Phase 2 (system + tool schema +
* forecast context + harvested URL list). If Phase 1 leaves
* remaining ITPM below this, wait for the bucket to refill.
*/
private const int SUBMIT_TOKEN_BUDGET = 4_000;
public function __construct( public function __construct(
private readonly ApiLogger $apiLogger, private readonly ApiLogger $apiLogger,
private readonly WeeklyForecastService $weeklyForecast, private readonly WeeklyForecastService $weeklyForecast,
@@ -55,19 +78,24 @@ final class LlmOverlayService
$forecast = $this->weeklyForecast->currentForecast(); $forecast = $this->weeklyForecast->currentForecast();
$context = $this->buildContext($forecast); $context = $this->buildContext($forecast);
$rawResult = $this->callAnthropic($context); $callResult = $this->callAnthropic($context);
if ($rawResult === null) { if ($callResult === null) {
return null; return null;
} }
$verifiedEvents = $this->verifyCitedUrls($rawResult['events_cited'] ?? []); $rawResult = $callResult['raw'];
$harvested = $callResult['harvested'];
$mergedEvents = $this->mergeEvents($rawResult['events_cited'] ?? [], $harvested);
$verifiedEvents = $this->verifyCitedUrls($mergedEvents);
if ($verifiedEvents === []) { if ($verifiedEvents === []) {
Log::warning('LlmOverlayService: no verified citations, rejecting overlay', [ Log::warning('LlmOverlayService: no verified citations, rejecting overlay', [
'events_cited_count' => count($rawResult['events_cited'] ?? []), 'model_events' => $rawResult['events_cited'] ?? null,
'harvested_urls' => array_column($harvested, 'url'),
'direction' => $rawResult['direction'] ?? null, 'direction' => $rawResult['direction'] ?? null,
'confidence' => $rawResult['confidence'] ?? null, 'confidence' => $rawResult['confidence'] ?? null,
'reasoning_short' => $rawResult['reasoning_short'] ?? null, 'reasoning_short' => $rawResult['reasoning_short'] ?? null,
'raw_result' => $rawResult,
]); ]);
return null; return null;
@@ -131,70 +159,44 @@ final class LlmOverlayService
]; ];
} }
/** @return array<string, mixed>|null */ /**
* Two independent API calls:
*
* Phase 1 runs the web_search tool, captures the assistant's
* returned `web_search_tool_result` blocks, then
* discards the transcript.
*
* Phase 2 issues a brand-new conversation with the harvested
* URLs/titles flattened into a plain-text user message
* and forces a `submit_overlay` tool call.
*
* Why not one stitched conversation: Anthropic auto-caches web_search
* results into ITPM (≈55k tokens for a 1-search call) and requires
* `encrypted_content` intact when those blocks are sent back.
* Resending the Phase 1 transcript to Phase 2 either rate-limits us
* (29k+ tokens twice exceeds the Tier-1 50k ITPM bucket) or 400s
* if we strip the encrypted blob. A fresh Phase 2 sends ~3k tokens
* total small enough to fit in the recovered bucket after a
* short adaptive sleep.
*
* @return array{raw: array<string, mixed>, harvested: array<int, array{url: string, title: string}>}|null
*/
private function callAnthropic(array $context): ?array private function callAnthropic(array $context): ?array
{ {
$messages = [['role' => 'user', 'content' => $this->prompt($context)]];
try { try {
// Phase 1: web search loop. Append the assistant turn after every $phase1 = $this->runWebSearch($context);
// successful response, then decide whether to keep looping — if ($phase1 === null) {
// this guarantees the messages array stays well-formed regardless
// of whether we exit via `break` or by exhausting iterations.
for ($i = 0, $response = null; $i < 5; $i++) {
$response = $this->apiLogger->send('anthropic', 'POST', self::URL, fn () => Http::timeout(45)
->withHeaders($this->headers())
->post(self::URL, [
'model' => config('services.anthropic.model', 'claude-haiku-4-5-20251001'),
'max_tokens' => 1024,
'tools' => [['type' => 'web_search_20250305', 'name' => 'web_search']],
'messages' => $messages,
]));
if (! $response->successful()) {
Log::error('LlmOverlayService: search request failed', ['status' => $response->status()]);
return null;
}
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
if ($response->json('stop_reason') !== 'pause_turn') {
break;
}
}
$messages[] = ['role' => 'user', 'content' => 'Now submit your overlay using the submit_overlay tool. Cite at least one event with a URL.'];
// Phase 2: forced structured output
$submitResponse = $this->apiLogger->send('anthropic', 'POST', self::URL, fn () => Http::timeout(20)
->withHeaders($this->headers())
->post(self::URL, [
'model' => config('services.anthropic.model', 'claude-haiku-4-5-20251001'),
'max_tokens' => 512,
'tools' => [$this->submitOverlayTool()],
'tool_choice' => ['type' => 'tool', 'name' => 'submit_overlay'],
'messages' => $messages,
]));
if (! $submitResponse->successful()) {
Log::error('LlmOverlayService: submit request failed', ['status' => $submitResponse->status()]);
return null; return null;
} }
$submitContent = $submitResponse->json('content') ?? []; $this->waitForRateLimitIfNeeded($phase1['response']);
$rawResult = $this->extractToolInput($submitContent);
// Haiku sometimes calls submit_overlay without `events_cited` even $rawResult = $this->runSubmit($context, $phase1['harvested']);
// though the schema marks it required. Confirmed in laravel.log on if ($rawResult === null) {
// 2026-05-12: tool_use input had only direction/confidence/reasoning. return null;
// Retry once with an explicit tool_result error.
if ($this->citationsMissing($rawResult)) {
$rawResult = $this->retrySubmitWithCitationError($messages, $submitContent) ?? $rawResult;
} }
return $rawResult; return ['raw' => $rawResult, 'harvested' => $phase1['harvested']];
} catch (Throwable $e) { } catch (Throwable $e) {
Log::error('LlmOverlayService: callAnthropic failed', ['error' => $e->getMessage()]); Log::error('LlmOverlayService: callAnthropic failed', ['error' => $e->getMessage()]);
@@ -202,6 +204,239 @@ final class LlmOverlayService
} }
} }
/**
* Phase 1: ask the model to search for news and capture the
* web_search_tool_result blocks. Returns the harvested citations
* and the final response (whose rate-limit headers tell us when
* the ITPM bucket will be replenished for Phase 2).
*
* @return array{harvested: array<int, array{url: string, title: string}>, response: Response}|null
*/
private function runWebSearch(array $context): ?array
{
$messages = [['role' => 'user', 'content' => $this->searchUserMessage($context)]];
$response = null;
for ($i = 0; $i < self::MAX_SEARCH_TURNS; $i++) {
$response = $this->apiLogger->send('anthropic', 'POST', self::URL, fn () => Http::timeout(45)
->withHeaders($this->headers())
->post(self::URL, [
'model' => $this->model(),
'max_tokens' => 1024,
'system' => $this->searchSystem(),
'tools' => [['type' => 'web_search_20250305', 'name' => 'web_search']],
'messages' => $messages,
]));
if (! $response->successful()) {
Log::error('LlmOverlayService: search request failed', [
'status' => $response->status(),
'body' => substr($response->body(), 0, 500),
]);
return null;
}
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
if ($response->json('stop_reason') !== 'pause_turn') {
break;
}
}
if ($response === null) {
return null;
}
return [
'harvested' => $this->harvestSearchResults($messages),
'response' => $response,
];
}
/**
* Phase 2: fresh API call no Phase 1 transcript with the
* harvested citations as plain text and a forced submit_overlay
* tool call.
*
* @param array<int, array{url: string, title: string}> $harvested
* @return array<string, mixed>|null
*/
private function runSubmit(array $context, array $harvested): ?array
{
$response = $this->apiLogger->send('anthropic', 'POST', self::URL, fn () => Http::timeout(20)
->withHeaders($this->headers())
->post(self::URL, [
'model' => $this->model(),
'max_tokens' => 512,
'system' => $this->submitSystem(),
'tools' => [$this->submitOverlayTool()],
'tool_choice' => ['type' => 'tool', 'name' => 'submit_overlay'],
'messages' => [['role' => 'user', 'content' => $this->submitUserMessage($context, $harvested)]],
]));
if (! $response->successful()) {
Log::error('LlmOverlayService: submit request failed', [
'status' => $response->status(),
'body' => substr($response->body(), 0, 500),
]);
return null;
}
$rawResult = $this->extractToolInput($response->json('content') ?? []);
if ($rawResult === null) {
Log::warning('LlmOverlayService: submit response missing tool_use block');
return null;
}
return $rawResult;
}
/**
* Anthropic's web_search burns ≈55k input tokens (mostly auto-cached
* search results) on Phase 1. At Tier 1's 50k ITPM the bucket can
* be at zero immediately afterwards. Read the rate-limit headers
* and sleep until the bucket has refilled enough for Phase 2.
* Capped at 65s so the daily cron never hangs longer than a minute.
*/
private function waitForRateLimitIfNeeded(Response $response): void
{
$remaining = (int) $response->header('anthropic-ratelimit-input-tokens-remaining');
if ($response->header('anthropic-ratelimit-input-tokens-remaining') === ''
|| $remaining >= self::SUBMIT_TOKEN_BUDGET) {
return;
}
$resetAt = $response->header('anthropic-ratelimit-input-tokens-reset');
$bucketSize = (int) $response->header('anthropic-ratelimit-input-tokens-limit');
if ($resetAt === '' || $bucketSize <= 0) {
return;
}
try {
$secondsUntilFullReset = max(0, CarbonImmutable::parse($resetAt)->getTimestamp() - now()->getTimestamp());
} catch (Throwable) {
return;
}
// Anthropic's bucket refills linearly. We don't need to wait for
// the full reset — only enough for SUBMIT_TOKEN_BUDGET tokens to
// become available. Sleep proportionally + a small safety margin,
// hard-capped at 65s.
$tokensNeeded = self::SUBMIT_TOKEN_BUDGET - $remaining;
$proportional = (int) ceil(($tokensNeeded / $bucketSize) * $secondsUntilFullReset);
$waitSeconds = max(1, min(65, $proportional + 2));
Log::info('LlmOverlayService: waiting for ITPM bucket refill before submit', [
'remaining' => $remaining,
'wait_seconds' => $waitSeconds,
'full_reset_in' => $secondsUntilFullReset,
]);
sleep($waitSeconds);
}
/**
* Walk every assistant turn and extract `{url, title}` from each
* `web_search_tool_result` block. Anthropic's web_search returns
* these blocks directly they are the authoritative citation
* source, not anything the model transcribes back to us.
*
* @param array<int, array<string, mixed>> $messages
* @return array<int, array{url: string, title: string}>
*/
private function harvestSearchResults(array $messages): array
{
$byUrl = [];
foreach ($messages as $message) {
if (($message['role'] ?? null) !== 'assistant') {
continue;
}
$content = $message['content'] ?? [];
if (! is_array($content)) {
continue;
}
foreach ($content as $block) {
if (! is_array($block) || ($block['type'] ?? null) !== 'web_search_tool_result') {
continue;
}
$results = $block['content'] ?? [];
if (! is_array($results)) {
continue;
}
foreach ($results as $result) {
if (! is_array($result) || ($result['type'] ?? null) !== 'web_search_result') {
continue;
}
$url = (string) ($result['url'] ?? '');
if ($url === '' || isset($byUrl[$url])) {
continue;
}
$byUrl[$url] = ['url' => $url, 'title' => (string) ($result['title'] ?? '')];
}
}
}
return array_values($byUrl);
}
/**
* Merge model-provided events_cited with citations harvested from
* `web_search_tool_result`. Model entries (which include `impact`
* tagging) take precedence on URL collision; harvested-only entries
* default to `impact: 'neutral'`.
*
* @param array<int, mixed> $modelEvents
* @param array<int, array{url: string, title: string}> $harvested
* @return array<int, array<string, mixed>>
*/
private function mergeEvents(array $modelEvents, array $harvested): array
{
$byUrl = [];
foreach ($modelEvents as $event) {
if (! is_array($event)) {
continue;
}
$url = (string) ($event['url'] ?? '');
if ($url === '') {
continue;
}
$byUrl[$url] = [
'headline' => (string) ($event['headline'] ?? ''),
'source' => (string) ($event['source'] ?? ''),
'url' => $url,
'impact' => in_array($event['impact'] ?? null, ['rising', 'falling', 'neutral'], true)
? $event['impact']
: 'neutral',
];
}
foreach ($harvested as $result) {
$url = $result['url'];
if (isset($byUrl[$url])) {
continue;
}
$byUrl[$url] = [
'headline' => $result['title'],
'source' => $this->domainOf($url),
'url' => $url,
'impact' => 'neutral',
];
}
return array_values($byUrl);
}
private function domainOf(string $url): string
{
$host = parse_url($url, PHP_URL_HOST);
return is_string($host) ? preg_replace('/^www\./', '', $host) : '';
}
private function verificationUserAgent(): string private function verificationUserAgent(): string
{ {
$appUrl = rtrim((string) config('app.url'), '/'); $appUrl = rtrim((string) config('app.url'), '/');
@@ -320,37 +555,61 @@ final class LlmOverlayService
return config('services.anthropic.api_key'); return config('services.anthropic.api_key');
} }
private function prompt(array $context): string private function model(): string
{ {
$json = json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); return config('services.anthropic.model', 'claude-haiku-4-5-20251001');
}
return <<<PROMPT
You are providing a daily news-aware overlay for a UK weekly pump-price forecast.
The calibrated ridge model has already produced a directional call from price history.
Your job is to search recent oil/fuel news and decide whether to AGREE or DISAGREE
and most importantly, surface any major-impact event that the ridge model can't see
from price history alone.
private function searchSystem(): string
{
return <<<'PROMPT'
You are researching news that may affect this week's UK pump-price forecast.
Search recent news (last 48 hours) for: Search recent news (last 48 hours) for:
- OPEC+ production decisions or unexpected announcements - OPEC+ production decisions or unexpected announcements
- Geopolitical events affecting oil supply (sanctions, conflict, shipping disruption) - Geopolitical events affecting oil supply (sanctions, conflict, shipping disruption)
- Major refinery outages or pipeline incidents - Major refinery outages or pipeline incidents
- US/EU inventory reports that materially moved Brent - US/EU inventory reports that materially moved Brent
Return only the search results you will be asked to summarise separately.
Context for this week:
$json
After searching, you will be asked to submit_overlay with direction, confidence
(capped at $this->confidenceCap), short reasoning, cited events with URLs,
agrees_with_ridge, and major_impact_event.
Citing events with REAL URLs is mandatory. An empty citation array will be
rejected and the overlay discarded.
PROMPT; PROMPT;
} }
private string $confidenceCap = '75'; private function searchUserMessage(array $context): string
{
$json = json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
return "Use web_search to find oil/fuel news from the last 48 hours that could move UK pump prices this week.\n\nContext for this week:\n\n".$json;
}
private function submitSystem(): string
{
$cap = self::CONFIDENCE_CAP;
return <<<PROMPT
You are providing a news-aware directional overlay for a UK weekly pump-price forecast.
Decide whether to AGREE or DISAGREE with the ridge model based on the news headlines
provided in the user message. Cap confidence at $cap.
Include events_cited (with impact tags) for any specific headline that drove your
reasoning; you may leave events_cited empty if the news is unremarkable.
PROMPT;
}
/**
* @param array<int, array{url: string, title: string}> $harvested
*/
private function submitUserMessage(array $context, array $harvested): string
{
$contextJson = json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if ($harvested === []) {
$headlines = '(none — no relevant news found)';
} else {
$headlines = collect($harvested)
->map(fn (array $r): string => '- '.$r['title'].' — '.$r['url'])
->implode("\n");
}
return "Context for this week:\n\n".$contextJson."\n\nNews headlines found:\n".$headlines."\n\nNow call submit_overlay with your decision.";
}
/** @return array<string, mixed> */ /** @return array<string, mixed> */
private function submitOverlayTool(): array private function submitOverlayTool(): array
@@ -366,7 +625,7 @@ final class LlmOverlayService
'reasoning_short' => ['type' => 'string', 'description' => '12 sentences.'], 'reasoning_short' => ['type' => 'string', 'description' => '12 sentences.'],
'events_cited' => [ 'events_cited' => [
'type' => 'array', 'type' => 'array',
'minItems' => 1, 'description' => 'Optional. Events that drove your reasoning, with directional impact. Citations are otherwise harvested from web_search_tool_result.',
'items' => [ 'items' => [
'type' => 'object', 'type' => 'object',
'properties' => [ 'properties' => [
@@ -381,7 +640,7 @@ final class LlmOverlayService
'agrees_with_ridge' => ['type' => 'boolean'], 'agrees_with_ridge' => ['type' => 'boolean'],
'major_impact_event' => ['type' => 'boolean'], 'major_impact_event' => ['type' => 'boolean'],
], ],
'required' => ['direction', 'confidence', 'reasoning_short', 'events_cited', 'agrees_with_ridge', 'major_impact_event'], 'required' => ['direction', 'confidence', 'reasoning_short', 'agrees_with_ridge', 'major_impact_event'],
], ],
]; ];
} }
@@ -396,57 +655,4 @@ final class LlmOverlayService
return $block['input'] ?? null; return $block['input'] ?? null;
} }
/** @param array<string, mixed>|null $rawResult */
private function citationsMissing(?array $rawResult): bool
{
return $rawResult === null
|| ! isset($rawResult['events_cited'])
|| ! is_array($rawResult['events_cited'])
|| $rawResult['events_cited'] === [];
}
/**
* @param array<int, mixed> $messages
* @param array<int, mixed> $failedSubmitContent
* @return array<string, mixed>|null
*/
private function retrySubmitWithCitationError(array $messages, array $failedSubmitContent): ?array
{
$toolUseId = collect($failedSubmitContent)->firstWhere('type', 'tool_use')['id'] ?? null;
if ($toolUseId === null) {
Log::warning('LlmOverlayService: cannot retry — no tool_use id in failed submit');
return null;
}
Log::info('LlmOverlayService: retrying submit with citation error', ['tool_use_id' => $toolUseId]);
$messages[] = ['role' => 'assistant', 'content' => $failedSubmitContent];
$messages[] = ['role' => 'user', 'content' => [[
'type' => 'tool_result',
'tool_use_id' => $toolUseId,
'content' => 'events_cited was missing or empty. Resubmit submit_overlay with at least one event from your earlier web search results, including its real URL, headline, source, and impact.',
'is_error' => true,
]]];
$retryResponse = $this->apiLogger->send('anthropic', 'POST', self::URL, fn () => Http::timeout(20)
->withHeaders($this->headers())
->post(self::URL, [
'model' => config('services.anthropic.model', 'claude-haiku-4-5-20251001'),
'max_tokens' => 512,
'tools' => [$this->submitOverlayTool()],
'tool_choice' => ['type' => 'tool', 'name' => 'submit_overlay'],
'messages' => $messages,
]));
if (! $retryResponse->successful()) {
Log::error('LlmOverlayService: retry submit failed', ['status' => $retryResponse->status()]);
return null;
}
return $this->extractToolInput($retryResponse->json('content') ?? []);
}
} }

View File

@@ -1,9 +1,9 @@
{ {
"$schema": "https://getcomposer.org/schema.json", "$schema": "https://getcomposer.org/schema.json",
"name": "laravel/livewire-starter-kit", "name": "fuel-alert/fuel-alert",
"type": "project", "type": "project",
"description": "The official Laravel starter kit for Livewire.", "description": "Fuel Alert — UK fuel price intelligence app.",
"keywords": ["laravel", "framework"], "keywords": ["laravel", "fuel", "alerts"],
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.4", "php": "^8.4",

View File

@@ -80,7 +80,7 @@ class PlanFactory extends Factory
'whatsapp_daily_limit' => 5, 'whatsapp_daily_limit' => 5,
'whatsapp_scheduled_updates' => 2, 'whatsapp_scheduled_updates' => 2,
'sms_enabled' => true, 'sms_enabled' => true,
'sms_daily_limit' => 1, 'sms_daily_limit' => 3,
'ai_predictions' => true, 'ai_predictions' => true,
'price_threshold' => true, 'price_threshold' => true,
'score_alerts' => true, 'score_alerts' => true,

View File

@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Capture token usage and rate-limit headers from token-metering
* providers (today: Anthropic). These columns let us see the
* cumulative input-tokens-per-minute trajectory directly in
* api_logs rather than inferring it from request counts.
*/
public function up(): void
{
Schema::table('api_logs', function (Blueprint $table) {
$table->unsignedInteger('input_tokens')->nullable()->after('response_body')
->comment('Input tokens billed (Anthropic usage.input_tokens). NULL for providers that do not report usage.');
$table->unsignedInteger('output_tokens')->nullable()->after('input_tokens')
->comment('Output tokens billed (Anthropic usage.output_tokens).');
$table->unsignedInteger('cache_read_tokens')->nullable()->after('output_tokens')
->comment('Cache-hit tokens (Anthropic usage.cache_read_input_tokens). Do not count toward ITPM on most models.');
$table->unsignedInteger('cache_write_tokens')->nullable()->after('cache_read_tokens')
->comment('Cache-write tokens (Anthropic usage.cache_creation_input_tokens). Count toward ITPM.');
$table->unsignedInteger('ratelimit_remaining')->nullable()->after('cache_write_tokens')
->comment('Provider-reported input-tokens remaining in the rolling window (anthropic-ratelimit-input-tokens-remaining).');
$table->dateTime('ratelimit_reset_at')->nullable()->after('ratelimit_remaining')
->comment('When the input-tokens bucket will be fully replenished (anthropic-ratelimit-input-tokens-reset, RFC 3339).');
});
}
public function down(): void
{
Schema::table('api_logs', function (Blueprint $table) {
$table->dropColumn([
'input_tokens',
'output_tokens',
'cache_read_tokens',
'cache_write_tokens',
'ratelimit_remaining',
'ratelimit_reset_at',
]);
});
}
};

View File

@@ -57,7 +57,7 @@ class PlanSeeder extends Seeder
'whatsapp_daily_limit' => 5, 'whatsapp_daily_limit' => 5,
'whatsapp_scheduled_updates' => 2, 'whatsapp_scheduled_updates' => 2,
'sms_enabled' => true, 'sms_enabled' => true,
'sms_daily_limit' => 1, 'sms_daily_limit' => 3,
'ai_predictions' => true, 'ai_predictions' => true,
'price_threshold' => true, 'price_threshold' => true,
'score_alerts' => true, 'score_alerts' => true,

View File

@@ -1,6 +1,6 @@
# FuelAlert API Reference # FuelAlert API Reference
Base URL: `https://fuel-price.test/api` Base URL: `https://fuel-alert.test/api`
All endpoints return JSON. All endpoints require an `X-Api-Key` header. All endpoints return JSON. All endpoints require an `X-Api-Key` header.
**Authentication:** **Authentication:**

View File

@@ -0,0 +1,214 @@
# Runbook — Mask lat/lng coordinates in server logs (IONOS VPS)
**Goal:** stop precise search coordinates (`lat`/`lng`) from being written to server
logs, while keeping GET-based shareable search URLs and full debugging value (IP,
status, path, fuel_type all stay readable).
**Applies to:** the production Nginx + PHP-FPM box on IONOS. None of this lives in the
app repo — it is server config you apply over SSH. The app already stores only a
~1km-rounded location bucket + a SHA-256 IP hash in the `searches` table; this runbook
covers the *raw* coordinates that transit the server in the URL.
## Scope — what this does and does NOT cover
| Sink | Covered here? |
|---|---|
| Nginx **access** log | ✅ Step 1 (request line + referer) |
| Nginx **error** log | ✅ Step 2 (scrub, since `log_format` can't touch it) |
| **PHP-FPM** access log | ✅ Step 3 (check / disable) |
| Laravel app logs | ✅ Nothing to do — stock config logs no URL, no error tracker installed |
| **IONOS edge** (CDN / WAF / managed LB) | ❌ Cannot be redacted from your side — see "Honest limit" |
> If you ever add Sentry/Flare/Bugsnag later, they capture the full request URL by
> default — you'd need a `before_send` scrubber there too.
---
## Step 1 — Mask the Nginx access log
### 1a. Create `/etc/nginx/conf.d/fuel-alert-log-masking.conf`
```nginx
# Redact lat/lng from the access log — request line AND referer.
# Every other field and query param is left intact.
# --- request line (e.g. GET /api/stations?lat=..&lng=.. HTTP/1.1) ---
map $request $fa_req_1 {
default $request;
"~^(?<rqa>.*[?&])lat=[^& ]*(?<rqb>.*)$" "${rqa}lat=***${rqb}";
}
map $fa_req_1 $fa_request_masked {
default $fa_req_1;
"~^(?<rqc>.*[?&])lng=[^& ]*(?<rqd>.*)$" "${rqc}lng=***${rqd}";
}
# --- referer header (e.g. https://fuel-alert.co.uk/?lat=..&lng=..) ---
map $http_referer $fa_ref_1 {
default $http_referer;
"~^(?<rfa>.*[?&])lat=[^&]*(?<rfb>.*)$" "${rfa}lat=***${rfb}";
}
map $fa_ref_1 $fa_referer_masked {
default $fa_ref_1;
"~^(?<rfc>.*[?&])lng=[^&]*(?<rfd>.*)$" "${rfc}lng=***${rfd}";
}
# A copy of the standard "combined" format, but using the masked values.
log_format fuelalert_masked
'$remote_addr - $remote_user [$time_local] '
'"$fa_request_masked" $status $body_bytes_sent '
'"$fa_referer_masked" "$http_user_agent"';
```
The `map` blocks must sit in the `http {}` context. Files in `/etc/nginx/conf.d/` are
included there by default — if your setup doesn't include `conf.d/*.conf`, paste the
block inside `http {}` in `/etc/nginx/nginx.conf` instead.
### 1b. Point the site at the masked format
Find your site's server block and its current `access_log` line:
```bash
sudo nginx -T | grep -nE "server_name|access_log|root" | grep -i fuel
ls /etc/nginx/sites-enabled/ # the site file is usually here
```
Inside that `server { … }` block, set:
```nginx
access_log /var/log/nginx/fuel-alert.access.log fuelalert_masked;
```
(Keep the path the same as whatever it currently is; only the trailing format name
`fuelalert_masked` is the change.)
---
## Step 2 — Scrub the Nginx error log
Error-log lines are generated internally by Nginx and **do not pass through
`log_format`**, so they can't be masked at write time. They include `request:` and
`referrer:` fields that carry the coordinates. Two parts:
### 2a. Reduce how often request lines are written
In the same `server { … }` block (or globally), lower the level so routine
warn/error/info entries that embed the request line are dropped:
```nginx
error_log /var/log/nginx/fuel-alert.error.log crit;
```
### 2b. Scrub whatever still gets written
Create `/usr/local/bin/scrub-fuel-logs.sh`:
```bash
#!/bin/sh
# Redact lat/lng values in the Nginx error log. GNU sed (Linux). Verified portable expr.
sed -E -i 's/([?&])(lat|lng)=[^ &"]*/\1\2=***/g' /var/log/nginx/fuel-alert.error.log
```
```bash
sudo chmod +x /usr/local/bin/scrub-fuel-logs.sh
```
Run it every minute via a systemd timer.
`/etc/systemd/system/scrub-fuel-logs.service`:
```ini
[Unit]
Description=Scrub lat/lng from Nginx error log
[Service]
Type=oneshot
ExecStart=/usr/local/bin/scrub-fuel-logs.sh
```
`/etc/systemd/system/scrub-fuel-logs.timer`:
```ini
[Unit]
Description=Run lat/lng scrub every minute
[Timer]
OnBootSec=1min
OnUnitActiveSec=1min
AccuracySec=10s
[Install]
WantedBy=timers.target
```
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now scrub-fuel-logs.timer
```
> **Trade-off:** a coordinate from an *errored* request can sit on disk for up to ~60s
> before the scrub runs. Error entries are low-volume, so the surface is small but not
> zero. For a zero-window setup (nothing un-redacted ever hits disk) you'd route
> `error_log` to syslog and scrub at the syslog layer, or tail a RAM-backed raw file
> with Vector and write only the redacted copy — more setup; ask if you want it.
---
## Step 3 — Check PHP-FPM
```bash
grep -nE '^\s*(access\.log|access\.format)' /etc/php/*/fpm/pool.d/*.conf
```
- **No output / commented out** → FPM isn't logging requests. Nothing to do.
- **`access.log` is enabled** → either comment it out, or remove the `%r`, `%q`, `%Q`
tokens from `access.format`, then `sudo systemctl reload php*-fpm`.
(FPM slowlog and error log record the PHP script/backtrace, not the query string —
no action needed there.)
---
## Step 4 — Apply, test, verify
```bash
# 1. Validate config BEFORE reloading — catches typos safely.
sudo nginx -t
# If this FAILS, do NOT reload. Fix the error first.
# 2. Reload Nginx (zero downtime).
sudo systemctl reload nginx
# 3. Trigger a real search in the app, then inspect the freshest lines:
sudo tail -n 5 /var/log/nginx/fuel-alert.access.log
sudo tail -n 5 /var/log/nginx/fuel-alert.error.log
# Expect: lat=***&lng=*** — IP, status, path, fuel_type all still present.
```
---
## Honest limit — IONOS edge
Masking only reaches logs **you** control (your Nginx, FPM, app). It cannot touch
anything IONOS runs in front of the box:
- **Plain IONOS VPS / Cloud Server (root server):** there is no HTTP-layer edge — your
Nginx is the first HTTP hop, and IONOS network logging is L3/L4 (IPs/ports/bytes, no
URLs). In this case this runbook covers everything that exists. ✅
- **Managed hosting / Deploy Now / CDN / managed WAF or LB:** those terminate HTTPS and
log full URLs with coordinates, on their retention, and you **cannot** redact them.
Levers: ask IONOS what they log + for how long, or stop putting coords in the URL.
**The only way coords never reach *any* HTTP log (yours or theirs) is to keep them out
of the URL.** To preserve shareable links without coords in the URL, use **opaque share
tokens**: store the search server-side, share `/s/<token>`, resolve token → coords on
the server. Coords then appear in zero logs and live in one DB row you control
(set its precision + expiry). This is an app change, tracked separately if wanted.
---
## Rollback
1. In the site `server {}` block, restore the original `access_log` (drop the
`fuelalert_masked` name) and `error_log` lines.
2. `sudo rm /etc/nginx/conf.d/fuel-alert-log-masking.conf`
3. `sudo nginx -t && sudo systemctl reload nginx`
4. `sudo systemctl disable --now scrub-fuel-logs.timer` (optional).

View File

@@ -0,0 +1,403 @@
# FuelAlert — Legal Pages Spec
## Context for Claude Code
You are generating four legal pages for **FuelAlert**, a UK consumer subscription web/mobile app that shows real-time UK fuel prices and forecasts.
### Reference comparable (structural check, NOT for copying)
A near-identical business already exists and has competent legal pages: **Fuel Finder UK** at https://www.fuel-finder.uk, operated by Scott Benson as a UK sole trader. Same business model, same data sources, same jurisdiction.
**Use it ONLY as a structural sanity check.** Do not copy any text. Their content is copyrighted and their specific processors / retention periods / business decisions are not FuelAlert's.
What Fuel Finder UK does well that this spec already incorporates:
- Explicit sole trader disclosure naming the operator
- Named ICO registration number in the Privacy Policy
- Specific named processors (Stripe, hosting provider, email provider, analytics, mapping services) with what data flows to each
- Strong price-accuracy disclaimer in Terms ("prices may not reflect real-time changes, always verify at the pump")
- "AS IS" / "AS AVAILABLE" basis with liability limitations
- ICO complaint route clearly stated
- Account deletion flow that cancels paid subscription first
What FuelAlert must do BETTER than Fuel Finder UK:
- **Standalone Refund & Cancellation Policy page** — Fuel Finder UK appears to bundle this into Terms. Stripe specifically asks for a refund policy; keep it as a separate page at `/legal/refund`.
- **Express-consent checkbox at subscription checkout** for the 14-day cooling-off period under Consumer Contracts Regulations 2013 (see Refund Policy section below). This is the single most important implementation detail in the entire spec.
If you find yourself generating content that overlaps verbatim with Fuel Finder UK or any other site, stop and rewrite in your own words. Legal pages are copyrighted works.
**Business operator (data controller):**
- Name: Ovidiu Ungureanu
- Trading as: FuelAlert
- Status: Sole trader (not a limited company — do not use "Ltd", "Limited", or any corporate form)
- Location: Peterborough, United Kingdom
- Contact email: [hello@fuelalert.co.uk]
- ICO registration number: [PLACEHOLDER: ZAxxxxxxx — Ovidiu to register at ico.org.uk before launch, fee ~£40/yr]
**Service:**
- UK fuel price comparison and forecasting
- Free tier + two paid subscription tiers: Daily (£0.99/mo) and Smart (£2.49/mo); annual billing also offered
- Data sources: UK government Fuel Finder / Pump Watch open data, ONS Postcode Directory (OGL/Crown Copyright)
- Processes: email address, postcode/location data, account credentials, payment data (via Stripe), usage telemetry
**Jurisdiction:**
- England and Wales
- Consumer law: Consumer Rights Act 2015, Consumer Contracts (Information, Cancellation and Additional Charges) Regulations 2013 ("CCRs")
- Data protection: UK GDPR + Data Protection Act 2018, PECR for cookies/marketing
- Digital markets: Digital Markets, Competition and Consumers Act 2024 ("DMCC")
**Tech stack & integration:**
- Laravel + Blade for server-rendered pages (NOT Vue components — these must render in raw HTML for Stripe reviewers and SEO)
- Route prefix: `/legal/*`
- Layout: extend the existing site layout but with a readable prose container (max-width ~720px, generous line-height)
- Each page has a `last_updated` date in front matter — set to today
- All pages must be linkable from the footer and accessible without JavaScript
### ⚠️ CRITICAL: Server-rendered Blade only, NOT Vue
The rest of this application is a Vue SPA. **That is irrelevant for these pages.** The legal pages must be server-rendered Blade templates.
Rules:
- Files live at `resources/views/legal/{name}.blade.php` — never `.vue`
- Body content must be plain HTML with Blade directives (`@extends`, `@section`, `{{ }}`)
- No Vue components anywhere in the page body — no custom tags like `<legal-privacy-page>`, no `<div id="app">`, no Vue mounting points
- No client-side rendering for the legal content itself — Alpine.js is acceptable only for the cookie banner show/hide behaviour, nothing else
- Pages must work fully with JavaScript disabled
**Verification test (run after generation):**
```bash
curl -s https://[host]/legal/privacy | grep -i "data controller"
curl -s https://[host]/legal/terms | grep -i "subscription"
curl -s https://[host]/legal/refund | grep -i "14-day"
curl -s https://[host]/legal/cookies | grep -i "essential"
```
Each command must return matching lines from the raw HTML response. If any returns empty, the page has been built as a client-rendered component and must be redone as Blade.
If you find yourself reaching for `<script>` tags, Vue single-file components, or any pattern that requires the browser to execute JS before content appears — stop and use plain Blade instead.
---
## Pages to generate
1. `/legal/privacy` — Privacy Policy
2. `/legal/terms` — Terms of Service
3. `/legal/refund` — Refund & Cancellation Policy (can be a section of Terms, but a separate page is cleaner for Stripe review)
4. `/legal/cookies` — Cookie Policy (paired with a real consent banner — see separate section)
---
## 1. Privacy Policy (`/legal/privacy`)
### Required sections, in order:
**1. Who we are**
- Identify Ovidiu Ungureanu as data controller, trading as FuelAlert
- Sole trader status, Peterborough address (use generic "Peterborough, UK" unless a registered business address is provided)
- ICO registration number
- Contact email for privacy queries
**2. What data we collect**
Break into clear subsections:
- *Account data*: email, hashed password, account creation date
- *Location data*: postcodes entered, optional precise GPS if granted, derived location for nearby-station queries
- *Payment data*: handled by Stripe; FuelAlert does NOT store card numbers. Stripe customer ID and subscription metadata only.
- *Usage data*: features used, queries made, price alerts configured (for service delivery and improvement)
- *Technical data*: IP address, browser type, device type (for security and analytics)
- *Marketing preferences*: only if user opts in
**3. Lawful basis for processing**
For each category, state the UK GDPR Article 6 basis:
- Account + service delivery: **contract** (Art. 6(1)(b))
- Payment processing: **contract**
- Security/fraud prevention: **legitimate interests** (Art. 6(1)(f))
- Analytics/product improvement: **legitimate interests**, with opt-out via cookie banner
- Marketing emails: **consent** (Art. 6(1)(a))
**4. How we use your data**
Bullet list, plain English. Tie back to the lawful basis for each use.
**5. Who we share data with (processors)**
Named list — be specific:
- **Stripe** (payment processing) — refer to Stripe's privacy policy
- **[Hosting provider, e.g. Hetzner]** (infrastructure) — EU-based, note location
- **[Email provider, e.g. Fastmail / Postmark]** (transactional email)
- **[Analytics, e.g. Plausible]** — if used; if self-hosted, say so
- State that we do NOT sell data to third parties
**6. International transfers**
- State whether any processors are outside the UK/EEA (Stripe has US operations)
- Reference appropriate safeguards: Standard Contractual Clauses (SCCs) and UK International Data Transfer Addendum
**7. How long we keep data**
- Active account data: while account is active + 12 months after closure
- Payment records: 6 years (HMRC requirement for sole traders)
- Marketing data: until consent is withdrawn
- Logs/analytics: max 24 months
**8. Your rights under UK GDPR**
List all eight rights with one-line explanations:
- Right of access
- Right to rectification
- Right to erasure ("right to be forgotten")
- Right to restrict processing
- Right to data portability
- Right to object
- Rights related to automated decision-making (state we do NOT make solely automated decisions with legal effect)
- Right to withdraw consent
Explain how to exercise rights (email to the contact address, response within one month).
**9. Cookies**
Brief mention with link to the dedicated Cookie Policy.
**10. Security**
Plain-English summary: HTTPS, hashed passwords, encrypted database fields where appropriate, access controls, regular updates. Don't overpromise ("bank-grade" etc. — meaningless and creates liability).
**11. Children**
Service is not directed at under-16s; we do not knowingly collect data from children.
**12. Complaints**
Right to complain to the ICO: ico.org.uk, 0303 123 1113. Encourage contacting FuelAlert first.
**13. Changes to this policy**
We will notify users of material changes by email; non-material changes shown by updated "Last updated" date.
**14. Contact**
Email address for privacy queries.
### Tone & style
- Plain English, Hemingway readability score ~grade 8
- No legalese where avoidable
- Use "we" / "you"
- Short paragraphs, lots of headings, scannable
---
## 2. Terms of Service (`/legal/terms`)
### Required sections, in order:
**1. About these terms**
- Who FuelAlert is (sole trader disclosure)
- These terms form a contract between you and Ovidiu Ungureanu trading as FuelAlert
- By using the service you agree to these terms
- Governing law: England and Wales
**2. The service**
- What FuelAlert does
- Free tier and paid tier descriptions
- We may add, remove, or change features with reasonable notice
**3. Your account**
- Eligibility: 18+, UK resident, accurate information required
- One account per person
- You're responsible for keeping login credentials secure
- We may suspend accounts for breach of these terms
**4. Subscriptions, billing and payment**
This is the section Stripe cares most about. Cover:
- Prices (link to /pricing) — currently £0.99/mo Daily, £2.49/mo Smart, with annual discount
- Billing cycle: monthly or annual, charged in advance
- **Auto-renewal**: subscriptions auto-renew at the end of each period at the then-current price, until cancelled
- Payment method: card via Stripe; you authorise FuelAlert (via Stripe) to charge your method on each renewal
- Failed payments: we'll retry and notify you; persistent failure suspends paid features
- Price changes: 30 days' notice by email before any price increase takes effect on renewal; you may cancel before the increase
- VAT: prices include UK VAT where applicable (sole traders below the VAT threshold do not charge VAT — clarify FuelAlert's current status)
**5. Cancellation and refunds**
Cross-reference the Refund Policy. Summarise:
- You can cancel at any time from your account settings
- Cancellation stops the next renewal; you keep access until the end of the current billing period
- 14-day right to cancel for new subscribers (see Refund Policy for the express-consent mechanism that affects this)
**6. Acceptable use**
You agree not to:
- Scrape, reverse-engineer, or bulk-extract data from the service
- Resell or redistribute price data
- Use the service for any unlawful purpose
- Attempt to compromise security
- Create automated queries beyond normal personal use
**7. Accuracy of price data — IMPORTANT**
This is your liability shield. Say clearly:
- Prices are sourced from official UK government data feeds (Pump Watch / Fuel Finder) and refreshed periodically
- We make reasonable efforts to display accurate prices but **cannot guarantee** prices are correct at the moment you arrive at a station
- Stations may change prices between data feed updates
- FuelAlert is not liable for losses arising from inaccurate price data (wasted journey, fuel cost differences, etc.)
- Always confirm the price at the pump before fuelling
**8. Forecasts and predictions**
- Forecasts are informational only and not financial advice
- Past trends do not guarantee future prices
- We do not warrant the accuracy of any forecast
**9. Intellectual property**
- The FuelAlert name, logo, software, and original content are owned by Ovidiu Ungureanu
- Underlying fuel price data is owned by the respective retailers and published under government open data schemes; ONS Postcode Directory data is © Crown Copyright, used under Open Government Licence v3.0
- You get a limited, non-exclusive, revocable licence to use the service for personal, non-commercial purposes (or commercial fleet use if on a Fleet plan)
**10. Third-party services**
We use Stripe for payments. Your use of Stripe is also subject to Stripe's terms.
**11. Limitation of liability**
- We don't exclude liability for death/personal injury caused by negligence, fraud, or anything that can't be excluded under UK consumer law
- We exclude liability for indirect, consequential, or business losses
- For consumers using the paid service, our total liability in any 12-month period is capped at the amount you paid in subscription fees in that period
- We don't accept liability for issues caused by third-party services we depend on (Stripe outage, data feed outage, etc.)
**12. Termination**
- You can stop using the service and close your account at any time
- We can terminate access for serious breach of these terms, with notice where reasonable
- On termination, paid sections 411 of these terms survive
**13. Changes to these terms**
We may update these terms; material changes notified by email at least 14 days before they take effect.
**14. Disputes**
- Try to resolve directly by contacting us first
- These terms are governed by the laws of England and Wales; courts of England and Wales have non-exclusive jurisdiction
- Consumers retain the right to bring proceedings in their country of residence
- Reference to alternative dispute resolution if applicable
**15. Contact**
Email address.
---
## 3. Refund & Cancellation Policy (`/legal/refund`)
This page is required by Stripe specifically and by UK consumer law. Keep it short and clear.
### Required sections:
**1. Your 14-day right to cancel (cooling-off period)**
- Under the Consumer Contracts Regulations 2013, you have 14 days from the date you subscribe to cancel without giving a reason
- This applies to **new subscribers only**; not subsequent renewals
**2. Express consent to start the service immediately — CRITICAL CLAUSE**
- When you subscribe, you can choose to start using the paid features immediately
- By doing so, you expressly acknowledge that you **lose your right to cancel** once the service has been fully supplied (i.e. once you've used the paid features within the 14-day window)
- If you cancel before using the paid features, you receive a full refund
- If you cancel within 14 days having used some paid features, we may reduce the refund proportionally to reflect usage
**Implementation note for the signup flow:** the express consent must be an unticked checkbox at checkout, with explicit text. Do not pre-tick it. Suggested wording for the checkbox label:
> "I want my subscription to start immediately. I understand that by using paid features within the 14-day cooling-off period, I will lose my right to cancel under the Consumer Contracts Regulations 2013."
**3. How to cancel**
- From your account: Settings → Subscription → Cancel
- Or by emailing the contact address
- Cancellation is effective at the end of the current billing period unless you exercise the 14-day right above
**4. Refunds outside the 14-day period**
- After 14 days, subscription fees are non-refundable for the remainder of the period you paid for
- You keep access until the end of the period
- We may issue discretionary refunds in cases of service failure or where required by law
**5. Annual subscriptions**
- Same 14-day right applies
- After 14 days, annual fees are non-refundable; no pro-rata refund for unused months
**6. Failed payments and involuntary cancellation**
- If your payment fails, we'll retry and notify you
- Paid features suspend after [N] failed retries
- Your account is not deleted; you can resume by updating payment
**7. How long refunds take**
- Refunds are issued to the original payment method within 510 business days of approval
**8. Contact**
Email address for refund requests.
---
## 4. Cookie Policy (`/legal/cookies`)
### Required sections:
**1. What cookies are**
One short paragraph.
**2. Cookies we use**
Table format with columns: Name | Purpose | Duration | Type (Essential / Analytics / Marketing).
Categories to include:
- **Essential**: session, CSRF, authentication, cookie-consent preference itself — these do not require consent under PECR
- **Analytics**: only if you use them; name them specifically (Plausible is cookie-less in many configs, GA4 uses several)
- **Marketing**: only if you run any — likely none at launch
--- ignore for now. need to decide what tool to use for this
**3. Your choices**
- You can accept, decline, or customise non-essential cookies via the consent banner
- You can change your choice at any time via "Cookie Settings" in the footer
- Most browsers also let you block cookies — link to ICO guidance on managing cookies
**4. Changes to this policy**
Standard wording.
**5. Contact**
Email.
---
## Cookie consent banner (separate component)
Implement as a Blade component, server-rendered, that:
- Appears on first visit
- Has three buttons: **Accept all**, **Reject all**, **Customise**
- "Customise" expands to checkboxes per category (Essential — locked on; Analytics; Marketing if applicable)
- Stores choice in a first-party cookie (`fa_cookie_consent`) for 12 months
- **Blocks non-essential scripts from loading until consent is given** — this is the critical bit. Use a tag manager pattern or conditional Blade includes; do NOT load Google Analytics, etc. before consent.
- Persistent "Cookie Settings" link in the footer reopens the customise panel
PECR / ICO require equal prominence for accept and reject. Don't make "Reject" smaller or harder to find.
---
## Implementation notes for Claude Code
1. **Create Laravel routes** in `routes/web.php`:
```php
Route::view('/legal/privacy', 'legal.privacy')->name('legal.privacy');
Route::view('/legal/terms', 'legal.terms')->name('legal.terms');
Route::view('/legal/refund', 'legal.refund')->name('legal.refund');
Route::view('/legal/cookies', 'legal.cookies')->name('legal.cookies');
```
2. **Create Blade layout** `resources/views/layouts/legal.blade.php` extending the main site layout, with a narrow prose container.
3. **Each page** at `resources/views/legal/{name}.blade.php` should:
- Set page title
- Set meta description (short summary, ~150 chars)
- Render the content in semantic HTML (h1, h2, h3, ul, ol, table where appropriate)
- Show "Last updated: [date]" at top
- Use Blade `@section` for content
4. **Update footer** to link to all four legal pages with `route()` helpers. Replace any existing "Cookie Settings" link with a button that opens the consent banner customise panel.
5. **Cookie banner component** at `resources/views/components/cookie-banner.blade.php` — include in main layout, with Alpine.js or vanilla JS for show/hide logic. Must be functional with JavaScript disabled (degrade to "you can change cookie settings in your browser" message).
6. **Do NOT** auto-generate the ICO registration number or contact email. Leave clear `[PLACEHOLDER: ...]` markers throughout for Ovidiu to fill in before deployment.
7. **Do NOT** include claims that aren't true yet — if uncertain, use a `[PLACEHOLDER: verify before launch]` marker rather than guessing.
8. **At the top of each generated file**, add a comment:
```
{{-- DRAFT: Generated [date]. Review by UK-qualified solicitor recommended before launch. --}}
```
---
## Pre-launch checklist for Ovidiu (not for Claude Code)
- [ ] Register with the ICO (ico.org.uk) and obtain registration number — required by law for processing personal data commercially
- [ ] Replace all `[PLACEHOLDER: ...]` markers
- [ ] Confirm hosting provider and processors named in the Privacy Policy are accurate
- [ ] Test the cookie banner: does it block non-essential scripts before consent?
- [ ] Test the signup flow: is the express-consent checkbox unticked by default?
- [ ] Test account cancellation: does it work end-to-end?
- [ ] Have a UK-qualified solicitor review (or use Termly/iubenda as a sanity check)
- [ ] Set `last_updated` dates to the actual go-live date
- [ ] Submit to Stripe with the live URL

View File

@@ -12,7 +12,7 @@ decision — which channels a user can receive, how often, and what features the
|-------|--------|---------------|-----------|-----------|------------|----------------|-----------------|--------------|------------| |-------|--------|---------------|-----------|-----------|------------|----------------|-----------------|--------------|------------|
| free | £0 | weekly digest | — | — | — | — | — | — | 1 | | free | £0 | weekly digest | — | — | — | — | — | — | 1 |
| basic | £0.99 | daily | daily | daily | — | — | ✓ | ✓ | 1 | | basic | £0.99 | daily | daily | daily | — | — | ✓ | ✓ | 1 |
| plus | £2.49 | triggered | triggered | triggered | max 1/day | ✓ | ✓ | ✓ | 1 | | plus | £2.49 | triggered | triggered | triggered | max 3/day | ✓ | ✓ | ✓ | 1 |
| pro | £3.99 | triggered | triggered | triggered | max 3/day | ✓ | ✓ | ✓ | unlimited | | pro | £3.99 | triggered | triggered | triggered | max 3/day | ✓ | ✓ | ✓ | unlimited |
Tiers are stored in the `plans` table. The `features` JSON column defines every limit and flag. Tiers are stored in the `plans` table. The `features` JSON column defines every limit and flag.

862
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,10 +11,11 @@
"@vitejs/plugin-vue": "^6.0.5", "@vitejs/plugin-vue": "^6.0.5",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"axios": "^1.15.0", "axios": "^1.15.0",
"concurrently": "^9.0.1", "concurrently": "^10.0.3",
"iconify-icon": "^3.0.2", "iconify-icon": "^3.0.2",
"laravel-vite-plugin": "^3.0.0", "laravel-vite-plugin": "^3.0.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"shell-quote": "^1.8.4",
"tailwindcss": "^4.0.7", "tailwindcss": "^4.0.7",
"vite": "^8.0.0", "vite": "^8.0.0",
"vue": "^3.5.32", "vue": "^3.5.32",

View File

@@ -0,0 +1,135 @@
<template>
<!-- Pricing -->
<section id="pricing" class="py-4 md:py-24 px-6 bg-zinc-50">
<div class="max-w-7xl mx-auto">
<div class="text-center mb-16">
<h2 class="text-4xl md:text-5xl font-black font-display text-zinc-800 mb-4">Pricing for every driver</h2>
<p class="text-zinc-500 text-lg mb-8">Save hundreds for less than the cost of a coffee.</p>
<div class="inline-flex items-center gap-1 p-1 bg-white border border-zinc-300 rounded-full">
<button
:class="cadence === 'monthly' ? 'bg-accent text-white' : 'text-zinc-500'"
class="px-5 py-2 rounded-full text-sm font-bold transition-colors"
type="button"
@click="cadence = 'monthly'"
>
Monthly
</button>
<button
:class="cadence === 'annual' ? 'bg-accent text-white' : 'text-zinc-500'"
class="px-5 py-2 rounded-full text-sm font-bold transition-colors"
type="button"
@click="cadence = 'annual'"
>
Annual <span class="text-[10px] opacity-80">(save 17%)</span>
</button>
</div>
</div>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-6 max-w-5xl mx-auto">
<!-- Free -->
<div class="bg-white border border-zinc-300 p-8 rounded-3xl flex flex-col h-full">
<div class="mb-8">
<h4 class="text-xl font-bold font-display mb-2">Free</h4>
<div class="flex items-baseline gap-1">
<span class="text-4xl font-black">£0</span>
<span class="text-zinc-500 text-sm">/mo</span>
</div>
</div>
<ul class="space-y-4 mb-8 flex-1">
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Local station price search</li>
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Buy-or-wait verdict</li>
<li class="text-sm flex gap-2 text-zinc-500"><iconify-icon class="text-zinc-300" icon="lucide:x"></iconify-icon> No price alerts</li>
</ul>
<a :href="ctaHref('free')" class="w-full py-3 px-4 border border-zinc-300 rounded-xl text-center font-bold hover:bg-zinc-100 transition-colors">{{ ctaLabel('free') }}</a>
</div>
<!-- Daily (backend: basic) -->
<div class="bg-white border border-zinc-300 p-8 rounded-3xl flex flex-col h-full">
<div class="mb-8">
<h4 class="text-xl font-bold font-display mb-2">Daily</h4>
<div class="flex items-baseline gap-1">
<span class="text-4xl font-black">{{ PRICES[cadence].basic }}</span>
<span class="text-zinc-500 text-sm">{{ PRICE_SUFFIX[cadence] }}</span>
</div>
</div>
<ul class="space-y-4 mb-8 flex-1">
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Buy-or-wait score + reason</li>
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Daily email, push &amp; WhatsApp</li>
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Price-drop &amp; score alerts</li>
</ul>
<a v-if="!isComingSoon('basic')" :href="ctaHref('basic')" class="w-full py-3 px-4 border border-zinc-300 rounded-xl text-center font-bold hover:bg-zinc-100 transition-colors">{{ ctaLabel('basic') }}</a>
<button v-else type="button" disabled class="w-full py-3 px-4 border border-zinc-200 bg-zinc-50 rounded-xl text-center font-bold text-zinc-400 cursor-not-allowed">Coming soon</button>
</div>
<!-- Smart (backend: plus) -->
<div class="bg-white border-2 border-accent p-8 rounded-3xl flex flex-col h-full relative">
<div class="absolute -top-4 left-1/2 -translate-x-1/2 bg-accent text-white px-4 py-1 rounded-full text-[10px] font-black uppercase tracking-widest whitespace-nowrap">Most pick this</div>
<div class="mb-8">
<h4 class="text-xl font-bold font-display mb-2">Smart</h4>
<div class="flex items-baseline gap-1">
<span class="text-4xl font-black text-accent">{{ PRICES[cadence].plus }}</span>
<span class="text-zinc-500 text-sm">{{ PRICE_SUFFIX[cadence] }}</span>
</div>
</div>
<ul class="space-y-4 mb-8 flex-1">
<li class="text-sm flex gap-2 font-bold"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> AI fuel-price prediction</li>
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Real-time email, push &amp; WhatsApp</li>
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> SMS alerts (up to 3/day)</li>
</ul>
<a v-if="!isComingSoon('plus')" :href="ctaHref('plus')" class="w-full py-3 px-4 bg-accent text-white rounded-xl text-center font-bold shadow-lg hover:bg-primary-dark transition-all">{{ ctaLabel('plus') }}</a>
<button v-else type="button" disabled class="w-full py-3 px-4 bg-zinc-100 rounded-xl text-center font-bold text-zinc-400 cursor-not-allowed">Coming soon</button>
</div>
</div>
</div>
</section>
</template>
<script setup>
import { ref } from 'vue'
import { useAuth } from '../composables/useAuth.js'
const { isAuthenticated, userTier } = useAuth()
const cadence = ref('monthly')
const PRICES = {
monthly: { basic: '£0.99', plus: '£2.49', pro: '£3.99' },
annual: { basic: '£9.90', plus: '£24.90', pro: '£39.90' },
}
const PRICE_SUFFIX = { monthly: '/mo', annual: '/yr' }
// Paid tiers whose alerting features aren't fully shipped yet — their CTAs are
// disabled until then. Remove a tier from this list to make its button live.
const COMING_SOON = ['basic', 'plus']
function isComingSoon(tier) {
return COMING_SOON.includes(tier)
}
function ctaHref(tier) {
if (tier === 'free') {
return isAuthenticated.value ? '/dashboard' : '/register'
}
if (!isAuthenticated.value) {
return '/register?tier=' + tier + '&cadence=' + cadence.value
}
if (userTier.value === tier) {
return '/billing/portal'
}
return '/billing/checkout/' + tier + '/' + cadence.value
}
function ctaLabel(tier) {
if (tier === 'free') {
return isAuthenticated.value ? 'Go to dashboard' : 'Start free'
}
if (isAuthenticated.value && userTier.value === tier) {
return 'Manage subscription'
}
return {
basic: 'Choose Daily',
plus: 'Choose Smart',
pro: 'Choose Pro',
}[tier]
}
</script>

View File

@@ -47,6 +47,6 @@ const stationCountLabel = computed(() => {
return new Intl.NumberFormat('en-GB').format(props.stationCount) return new Intl.NumberFormat('en-GB').format(props.stationCount)
}) })
const ctaHref = computed(() => isAuthenticated.value ? '#pricing' : '/register?tier=plus&cadence=monthly') const ctaHref = computed(() => isAuthenticated.value ? '/pricing' : '/register?tier=plus&cadence=monthly')
const ctaLabel = computed(() => isAuthenticated.value ? 'See plans' : 'Start saving') const ctaLabel = computed(() => isAuthenticated.value ? 'See plans' : 'Start saving')
</script> </script>

View File

@@ -58,6 +58,18 @@ const props = defineProps({
const emit = defineEmits(['search']) const emit = defineEmits(['search'])
// Coarsen GPS coordinates to ~111 m (3 dp) before they leave the browser.
// "Use my location" coords flow into the shareable URL, the /api/stations
// request, and server/access logs — full precision would broadcast the user's
// exact position to anyone they share the resulting link with. 3 dp is ample
// for a radius station search. See .claude/rules/frontend.md.
const COORDINATE_DECIMALS = 3
function coarsenCoordinate(value) {
const factor = 10 ** COORDINATE_DECIMALS
return Math.round(value * factor) / factor
}
const postcode = ref('') const postcode = ref('')
const locating = ref(false) const locating = ref(false)
@@ -88,8 +100,8 @@ function useMyLocation() {
postcode.value = '' postcode.value = ''
emit('search', { emit('search', {
postcode: null, postcode: null,
lat: coords.latitude, lat: coarsenCoordinate(coords.latitude),
lng: coords.longitude, lng: coarsenCoordinate(coords.longitude),
fuelType: props.fuelType, fuelType: props.fuelType,
radius: props.radius, radius: props.radius,
sort: props.sort, sort: props.sort,

View File

@@ -11,8 +11,7 @@
<div class="hidden lg:flex items-center gap-8 font-mono text-[11px] uppercase tracking-widest text-zinc-600"> <div class="hidden lg:flex items-center gap-8 font-mono text-[11px] uppercase tracking-widest text-zinc-600">
<a class="hover:text-accent transition-colors" href="#how-it-works">How it works</a> <a class="hover:text-accent transition-colors" href="#how-it-works">How it works</a>
<a class="hover:text-accent transition-colors" href="#features">Why it works</a> <a class="hover:text-accent transition-colors" href="#features">Why it works</a>
<a class="hover:text-accent transition-colors" href="#pricing">Pricing</a> <RouterLink class="hover:text-accent transition-colors" to="/pricing">Pricing</RouterLink>
<a class="hover:text-accent transition-colors" href="#fleet">Fleet</a>
</div> </div>
<div class="flex items-center gap-3 md:gap-5"> <div class="flex items-center gap-3 md:gap-5">

View File

@@ -0,0 +1,55 @@
<template>
<!-- Footer -->
<footer class="bg-zinc-50 border-t border-zinc-300 pt-16 pb-8 px-6">
<div class="max-w-7xl mx-auto grid grid-cols-2 md:grid-cols-4 gap-12 mb-12">
<div class="col-span-2 md:col-span-1 space-y-4">
<RouterLink class="flex items-center gap-2" to="/">
<div class="w-8 h-8 rounded bg-accent flex items-center justify-center">
<iconify-icon class="text-white" icon="lucide:fuel"></iconify-icon>
</div>
<span class="text-xl font-black font-display tracking-tighter text-accent">FuelAlert</span>
</RouterLink>
<p class="text-sm text-zinc-500 leading-relaxed">
Helping UK drivers save money at the pump. Real-time data, smarter choices.
</p>
<p class="text-sm text-zinc-500">
Questions? <a class="text-accent hover:underline" href="mailto:hello@fuel-alert.co.uk">hello@fuel-alert.co.uk</a>
</p>
<div class="flex gap-4">
<iconify-icon class="text-2xl text-zinc-500 hover:text-accent cursor-pointer transition-colors" icon="mdi:twitter"></iconify-icon>
<iconify-icon class="text-2xl text-zinc-500 hover:text-accent cursor-pointer transition-colors" icon="mdi:facebook"></iconify-icon>
<iconify-icon class="text-2xl text-zinc-500 hover:text-accent cursor-pointer transition-colors" icon="mdi:instagram"></iconify-icon>
</div>
</div>
<div class="space-y-4">
<h5 class="font-black text-xs text-zinc-800 tracking-widest">Product</h5>
<ul class="space-y-2 text-sm text-zinc-500">
<li><RouterLink class="hover:text-accent transition-colors" to="/pricing">Pricing</RouterLink></li>
<li><a class="hover:text-accent transition-colors" href="#how-it-works">How it works</a></li>
<li><a class="hover:text-accent transition-colors" href="#features">Why it works</a></li>
</ul>
</div>
<div class="space-y-4">
<h5 class="font-black text-xs text-zinc-800 tracking-widest">Legal</h5>
<ul class="space-y-2 text-sm text-zinc-500">
<li><a class="hover:text-accent transition-colors" href="/legal/privacy">Privacy Policy</a></li>
<li><a class="hover:text-accent transition-colors" href="/legal/terms">Terms of Service</a></li>
<li><a class="hover:text-accent transition-colors" href="/legal/refund">Refund &amp; Cancellation</a></li>
<li><a class="hover:text-accent transition-colors" href="/legal/cookies">Cookie Policy</a></li>
</ul>
</div>
</div>
<div class="max-w-7xl mx-auto pt-8 border-t border-zinc-300 flex flex-col md:flex-row justify-between items-center gap-4 text-[10px] tracking-widest text-zinc-500">
<p>© 2026 FuelAlert UK. FuelAlert is a trading name of Ovidiu Ungureanu, sole trader, based in Peterborough, UK.</p>
<p>Data provided by official UK retail price transparency schemes.</p>
<p>Postcode data from <a class="underline hover:text-accent" href="https://geoportal.statistics.gov.uk/datasets/ons::onspd-online-latest-centroids-1/about" rel="noopener" target="_blank">ONS Postcode Directory</a>: contains OS data © Crown copyright &amp; database right, Royal Mail data © Royal Mail copyright &amp; database right, and National Statistics data © Crown copyright &amp; database right.</p>
</div>
</footer>
</template>
<script setup>
import { RouterLink } from 'vue-router'
</script>

View File

@@ -22,7 +22,7 @@ const stats = computed(() => [
value: props.stationCount ? props.stationCount.toLocaleString('en-GB') : '11,482', value: props.stationCount ? props.stationCount.toLocaleString('en-GB') : '11,482',
label: 'Stations tracked', label: 'Stations tracked',
}, },
{ value: 273', label: 'Median saving / yr' }, { value: 8.40', label: 'Avg saving per tank*' },
{ value: '84%', label: 'Forecast accuracy' }, { value: '84%', label: 'Forecast accuracy' },
]) ])
</script> </script>

View File

@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue' import Home from '../views/Home.vue'
import { useAuth } from '../composables/useAuth.js' import { useAuth } from '../composables/useAuth.js'
const Pricing = () => import('../views/Pricing.vue')
const DashboardLayout = () => import('../views/dashboard/DashboardLayout.vue') const DashboardLayout = () => import('../views/dashboard/DashboardLayout.vue')
const Overview = () => import('../views/dashboard/Overview.vue') const Overview = () => import('../views/dashboard/Overview.vue')
const SavedStations = () => import('../views/dashboard/SavedStations.vue') const SavedStations = () => import('../views/dashboard/SavedStations.vue')
@@ -13,6 +14,7 @@ const Appearance = () => import('../views/dashboard/settings/Appearance.vue')
const routes = [ const routes = [
{ path: '/', component: Home, name: 'home' }, { path: '/', component: Home, name: 'home' },
{ path: '/pricing', component: Pricing, name: 'pricing' },
{ {
path: '/logout', path: '/logout',
name: 'logout', name: 'logout',

View File

@@ -225,90 +225,8 @@
</div> </div>
</section> </section>
<!-- Pricing -->
<section id="pricing" class="py-4 md:py-24 px-6 bg-zinc-50">
<div class="max-w-7xl mx-auto">
<div class="text-center mb-16">
<h2 class="text-4xl md:text-5xl font-black font-display text-zinc-800 mb-4">Pricing for every driver</h2>
<p class="text-zinc-500 text-lg mb-8">Save hundreds for less than the cost of a coffee.</p>
<div class="inline-flex items-center gap-1 p-1 bg-white border border-zinc-300 rounded-full">
<button
:class="cadence === 'monthly' ? 'bg-accent text-white' : 'text-zinc-500'"
class="px-5 py-2 rounded-full text-sm font-bold transition-colors"
type="button"
@click="cadence = 'monthly'"
>
Monthly
</button>
<button
:class="cadence === 'annual' ? 'bg-accent text-white' : 'text-zinc-500'"
class="px-5 py-2 rounded-full text-sm font-bold transition-colors"
type="button"
@click="cadence = 'annual'"
>
Annual <span class="text-[10px] opacity-80">(save 17%)</span>
</button>
</div>
</div>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-6 max-w-5xl mx-auto">
<!-- Free -->
<div class="bg-white border border-zinc-300 p-8 rounded-3xl flex flex-col h-full">
<div class="mb-8">
<h4 class="text-xl font-bold font-display mb-2">Free</h4>
<div class="flex items-baseline gap-1">
<span class="text-4xl font-black">£0</span>
<span class="text-zinc-500 text-sm">/mo</span>
</div>
</div>
<ul class="space-y-4 mb-8 flex-1">
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Basic Search</li>
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Daily Updates</li>
<li class="text-sm flex gap-2 text-zinc-500"><iconify-icon class="text-zinc-300" icon="lucide:x"></iconify-icon> No Alerts</li>
</ul>
<a :href="ctaHref('free')" class="w-full py-3 px-4 border border-zinc-300 rounded-xl text-center font-bold hover:bg-zinc-100 transition-colors">{{ ctaLabel('free') }}</a>
</div>
<!-- Daily (backend: basic) -->
<div class="bg-white border border-zinc-300 p-8 rounded-3xl flex flex-col h-full">
<div class="mb-8">
<h4 class="text-xl font-bold font-display mb-2">Daily</h4>
<div class="flex items-baseline gap-1">
<span class="text-4xl font-black">{{ PRICES[cadence].basic }}</span>
<span class="text-zinc-500 text-sm">{{ PRICE_SUFFIX[cadence] }}</span>
</div>
</div>
<ul class="space-y-4 mb-8 flex-1">
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Buy-or-Wait Score</li>
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> 14-day Trend Data</li>
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> 3 Daily Price Alerts</li>
</ul>
<a :href="ctaHref('basic')" class="w-full py-3 px-4 border border-zinc-300 rounded-xl text-center font-bold hover:bg-zinc-100 transition-colors">{{ ctaLabel('basic') }}</a>
</div>
<!-- Smart (backend: plus) -->
<div class="bg-white border-2 border-accent p-8 rounded-3xl flex flex-col h-full relative">
<div class="absolute -top-4 left-1/2 -translate-x-1/2 bg-accent text-white px-4 py-1 rounded-full text-[10px] font-black uppercase tracking-widest whitespace-nowrap">Most pick this</div>
<div class="mb-8">
<h4 class="text-xl font-bold font-display mb-2">Smart</h4>
<div class="flex items-baseline gap-1">
<span class="text-4xl font-black text-accent">{{ PRICES[cadence].plus }}</span>
<span class="text-zinc-500 text-sm">{{ PRICE_SUFFIX[cadence] }}</span>
</div>
</div>
<ul class="space-y-4 mb-8 flex-1">
<li class="text-sm flex gap-2 font-bold"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Supermarket Anchor</li>
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Priority Price Alerts</li>
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Multi-location tracking</li>
</ul>
<a :href="ctaHref('plus')" class="w-full py-3 px-4 bg-accent text-white rounded-xl text-center font-bold shadow-lg hover:bg-primary-dark transition-all">{{ ctaLabel('plus') }}</a>
</div>
</div>
</div>
</section>
<!-- Testimonials --> <!-- Testimonials -->
<section class="py-12 md:py-24 px-6"> <!-- <section class="py-12 md:py-24 px-6">
<div class="max-w-7xl mx-auto"> <div class="max-w-7xl mx-auto">
<div class="flex flex-col md:flex-row gap-12 items-center"> <div class="flex flex-col md:flex-row gap-12 items-center">
<div class="md:w-1/3"> <div class="md:w-1/3">
@@ -346,7 +264,7 @@
</div> </div>
</div> </div>
</div> </div>
</section> </section>-->
<!-- CTA --> <!-- CTA -->
<section class="py-12 md:py-24 px-6 bg-accent text-white text-center" v-if="!isAuthenticated"> <section class="py-12 md:py-24 px-6 bg-accent text-white text-center" v-if="!isAuthenticated">
@@ -360,68 +278,14 @@
</section> </section>
<!-- Footer --> <!-- Footer -->
<footer class="bg-zinc-50 border-t border-zinc-300 pt-16 pb-8 px-6"> <SiteFooter />
<div class="max-w-7xl mx-auto grid grid-cols-2 md:grid-cols-4 gap-12 mb-12">
<div class="col-span-2 md:col-span-1 space-y-4">
<RouterLink class="flex items-center gap-2" to="/">
<div class="w-8 h-8 rounded bg-accent flex items-center justify-center">
<iconify-icon class="text-white" icon="lucide:fuel"></iconify-icon>
</div>
<span class="text-xl font-black font-display tracking-tighter text-accent">FuelAlert</span>
</RouterLink>
<p class="text-sm text-zinc-500 leading-relaxed">
Helping UK drivers save money at the pump since 2021. Real-time data, smarter choices.
</p>
<div class="flex gap-4">
<iconify-icon class="text-2xl text-zinc-500 hover:text-accent cursor-pointer transition-colors" icon="mdi:twitter"></iconify-icon>
<iconify-icon class="text-2xl text-zinc-500 hover:text-accent cursor-pointer transition-colors" icon="mdi:facebook"></iconify-icon>
<iconify-icon class="text-2xl text-zinc-500 hover:text-accent cursor-pointer transition-colors" icon="mdi:instagram"></iconify-icon>
</div>
</div>
<div class="space-y-4">
<h5 class="font-black text-xs text-zinc-800 tracking-widest">Product</h5>
<ul class="space-y-2 text-sm text-zinc-500">
<li><a class="hover:text-accent transition-colors" href="#pricing">Pricing</a></li>
<li><a class="hover:text-accent transition-colors" href="#features">Features</a></li>
<li><a class="hover:text-accent transition-colors" href="#">FuelAlert Pro</a></li>
<li><a class="hover:text-accent transition-colors" href="#">Enterprise API</a></li>
</ul>
</div>
<div class="space-y-4">
<h5 class="font-black text-xs text-zinc-800 tracking-widest">Resources</h5>
<ul class="space-y-2 text-sm text-zinc-500">
<li><a class="hover:text-accent transition-colors" href="#">Market Insights</a></li>
<li><a class="hover:text-accent transition-colors" href="#">How We Track</a></li>
<li><a class="hover:text-accent transition-colors" href="#">Help Center</a></li>
<li><a class="hover:text-accent transition-colors" href="#">Driver Safety</a></li>
</ul>
</div>
<div class="space-y-4">
<h5 class="font-black text-xs text-zinc-800 tracking-widest">Legal</h5>
<ul class="space-y-2 text-sm text-zinc-500">
<li><a class="hover:text-accent transition-colors" href="#">Privacy Policy</a></li>
<li><a class="hover:text-accent transition-colors" href="#">Terms of Service</a></li>
<li><a class="hover:text-accent transition-colors" href="#">Cookie Settings</a></li>
</ul>
</div>
</div>
<div class="max-w-7xl mx-auto pt-8 border-t border-zinc-300 flex flex-col md:flex-row justify-between items-center gap-4 text-[10px] tracking-widest text-zinc-500">
<p>© 2024 FuelAlert UK Limited. All Rights Reserved.</p>
<p>Data provided by official UK retail price transparency schemes.</p>
<p>Postcode data from <a class="underline hover:text-accent" href="https://geoportal.statistics.gov.uk/datasets/ons::onspd-online-latest-centroids-1/about" rel="noopener" target="_blank">ONS Postcode Directory</a>: contains OS data © Crown copyright &amp; database right, Royal Mail data © Royal Mail copyright &amp; database right, and National Statistics data © Crown copyright &amp; database right.</p>
</div>
</footer>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, watch, onMounted, nextTick, defineAsyncComponent } from 'vue' import { ref, computed, watch, onMounted, nextTick, defineAsyncComponent } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useAuth } from '../composables/useAuth.js' import { useAuth } from '../composables/useAuth.js'
import { useStations } from '../composables/useStations.js' import { useStations } from '../composables/useStations.js'
import api from '../axios.js' import api from '../axios.js'
@@ -437,8 +301,9 @@ import LiveTicker from '../components/landing/LiveTicker.vue'
import VerdictCard from '../components/landing/VerdictCard.vue' import VerdictCard from '../components/landing/VerdictCard.vue'
import HeroSearch from '../components/landing/HeroSearch.vue' import HeroSearch from '../components/landing/HeroSearch.vue'
import StatsRow from '../components/landing/StatsRow.vue' import StatsRow from '../components/landing/StatsRow.vue'
import SiteFooter from '../components/landing/SiteFooter.vue'
const { isAuthenticated, userTier } = useAuth() const { isAuthenticated } = useAuth()
const liveStats = ref({ stationCount: null, latestPriceAt: null }) const liveStats = ref({ stationCount: null, latestPriceAt: null })
@@ -454,40 +319,6 @@ onMounted(async () => {
} }
}) })
const cadence = ref('monthly')
function ctaHref(tier) {
if (tier === 'free') {
return isAuthenticated.value ? '/dashboard' : '/register'
}
if (!isAuthenticated.value) {
return '/register?tier=' + tier + '&cadence=' + cadence.value
}
if (userTier.value === tier) {
return '/billing/portal'
}
return '/billing/checkout/' + tier + '/' + cadence.value
}
function ctaLabel(tier) {
if (tier === 'free') {
return isAuthenticated.value ? 'Go to dashboard' : 'Start free'
}
if (isAuthenticated.value && userTier.value === tier) {
return 'Manage subscription'
}
return {
basic: 'Choose Daily',
plus: 'Choose Smart',
pro: 'Choose Pro',
}[tier]
}
const PRICES = {
monthly: { basic: '£0.99', plus: '£2.49', pro: '£3.99' },
annual: { basic: '£9.90', plus: '£24.90', pro: '£39.90' },
}
const PRICE_SUFFIX = { monthly: '/mo', annual: '/yr' }
const { stations, meta, prediction, loading, error, search, reset } = useStations() const { stations, meta, prediction, loading, error, search, reset } = useStations()
const showFullPrediction = computed(() => Boolean(prediction.value) && !prediction.value.tier_locked) const showFullPrediction = computed(() => Boolean(prediction.value) && !prediction.value.tier_locked)
@@ -621,6 +452,14 @@ function queryFromParams(params) {
} }
async function runSearch(params) { async function runSearch(params) {
// Single dedup choke point. A user search calls onSearch, which both pushes to
// the URL (synchronously firing the route.query watcher → runSearch) and then
// calls runSearch directly — two triggers for one intent. Guarding here, where
// both paths funnel through, collapses them into one request for a given query.
if (lastParams.value
&& JSON.stringify(queryFromParams(lastParams.value)) === JSON.stringify(queryFromParams(params))) {
return
}
lastParams.value = params lastParams.value = params
sort.value = params.sort ?? sort.value sort.value = params.sort ?? sort.value
radiusMiles.value = params.radius ?? radiusMiles.value radiusMiles.value = params.radius ?? radiusMiles.value
@@ -642,9 +481,6 @@ watch(() => route.query, (query) => {
reset() reset()
return return
} }
const sameAsLast = lastParams.value
&& JSON.stringify(queryFromParams(lastParams.value)) === JSON.stringify(queryFromParams(params))
if (sameAsLast) return
runSearch(params) runSearch(params)
}, { immediate: true }) }, { immediate: true })
</script> </script>

View File

@@ -0,0 +1,15 @@
<template>
<div class="min-h-screen bg-zinc-50">
<LandingNav />
<main class="pt-20 md:pt-24">
<PricingGrid />
</main>
<SiteFooter />
</div>
</template>
<script setup>
import LandingNav from '../components/landing/LandingNav.vue'
import PricingGrid from '../components/PricingGrid.vue'
import SiteFooter from '../components/landing/SiteFooter.vue'
</script>

View File

@@ -64,7 +64,7 @@
</div> </div>
</nav> </nav>
<div class="flex pt-20 max-w-7xl mx-auto w-full px-6 py-8 gap-8"> <div class="flex flex-1 pt-20 max-w-7xl mx-auto w-full px-6 py-8 gap-8">
<!-- Sidebar --> <!-- Sidebar -->
<aside class="w-56 flex-shrink-0 hidden md:block"> <aside class="w-56 flex-shrink-0 hidden md:block">
<nav class="space-y-1"> <nav class="space-y-1">
@@ -88,6 +88,8 @@
<RouterView /> <RouterView />
</main> </main>
</div> </div>
<SiteFooter />
</div> </div>
</template> </template>
@@ -95,6 +97,7 @@
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router' import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router'
import { useAuth } from '../../composables/useAuth.js' import { useAuth } from '../../composables/useAuth.js'
import SiteFooter from '../../components/landing/SiteFooter.vue'
const { user } = useAuth() const { user } = useAuth()
const route = useRoute() const route = useRoute()

View File

@@ -6,6 +6,12 @@
<meta name="csrf-token" content="{{ csrf_token() }}"> <meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ __('Welcome') }} - {{ config('app.name', 'Laravel') }}</title> <title>{{ __('Welcome') }} - {{ config('app.name', 'Laravel') }}</title>
<meta name="description" content="Live UK fuel prices across 11,000+ stations. See whether to fill up today or wait, based on local trends."> <meta name="description" content="Live UK fuel prices across 11,000+ stations. See whether to fill up today or wait, based on local trends.">
<script
defer src="https://umami.local.uovidiu.com/script.js"
data-website-id="26b2df00-e3fc-4c8c-97d1-75d99daa4545"
data-do-not-track="true"
data-domains="fuel-alert.co.uk,www.fuel-alert.co.uk"
></script>
<script> <script>
window['FUEL_TYPES'] = @json( window['FUEL_TYPES'] = @json(
collect(App\Enums\FuelType::cases()) collect(App\Enums\FuelType::cases())

View File

@@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ ($title ?? $heading ?? 'Legal').' - '.config('app.name') }}</title>
@isset($metaDescription)
<meta name="description" content="{{ $metaDescription }}">
@endisset
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=inter:400,500,600&family=manrope:600,700,800,900&display=swap" rel="stylesheet" />
{{-- CSS only no @fluxAppearance: it applies Flux's `.dark` class from the OS
preference, which overrides --color-accent to white. Legal pages render no
Flux components, so they stay light like the rest of the public site. --}}
@vite(['resources/css/app.css'])
<script src="https://code.iconify.design/iconify-icon/1.0.7/iconify-icon.min.js" defer></script>
</head>
<body class="min-h-screen bg-[#f5ede5] text-zinc-900 antialiased">
<nav class="fixed top-0 w-full z-50 bg-zinc-50/90 backdrop-blur-sm border-b border-zinc-300 px-6 py-4 md:px-12">
<div class="max-w-7xl mx-auto flex items-center justify-between gap-6">
<a class="flex items-center gap-3 shrink-0" href="/">
<div class="w-9 h-9 md:w-10 md:h-10 rounded-lg bg-accent flex items-center justify-center shadow-md">
<iconify-icon class="text-white text-xl" icon="lucide:fuel"></iconify-icon>
</div>
<span class="text-xl md:text-2xl font-black font-display tracking-tighter text-accent">FuelAlert</span>
</a>
<div class="hidden lg:flex items-center gap-8 font-mono text-[11px] uppercase tracking-widest text-zinc-600">
<a class="hover:text-accent transition-colors" href="/#how-it-works">How it works</a>
<a class="hover:text-accent transition-colors" href="/#features">Why it works</a>
<a class="hover:text-accent transition-colors" href="/#pricing">Pricing</a>
</div>
<div class="flex items-center gap-3 md:gap-5">
@auth
<a class="bg-accent text-white px-5 py-2 rounded-full text-sm font-bold shadow-md hover:bg-primary-dark transition-all" href="/dashboard">
Dashboard
</a>
@else
<a class="text-sm font-semibold text-zinc-600 hover:text-zinc-900 transition-colors" href="/login">Login</a>
<a class="hidden sm:inline-flex bg-accent text-white px-5 py-2 rounded-full text-sm font-bold shadow-md hover:bg-primary-dark transition-all" href="/register">
Get started
</a>
@endauth
</div>
</div>
</nav>
<main class="mx-auto max-w-3xl px-6 pt-28 pb-12 md:pt-32">
<article class="space-y-6 leading-relaxed text-zinc-800">
<header class="space-y-3 border-b border-zinc-300 pb-6">
<h1 class="font-display text-3xl font-black tracking-tight text-zinc-900 md:text-4xl">
{{ $heading }}
</h1>
@isset($lastUpdated)
<p class="text-sm text-zinc-500">Last updated: {{ $lastUpdated }}</p>
@endisset
</header>
{{ $slot }}
</article>
</main>
<footer class="mt-16 border-t border-zinc-300 bg-white/50">
<div class="mx-auto max-w-3xl px-6 py-10 text-sm text-zinc-600">
<nav class="flex flex-wrap gap-x-6 gap-y-2">
<a class="hover:text-accent" href="{{ route('legal.privacy') }}">Privacy Policy</a>
<a class="hover:text-accent" href="{{ route('legal.terms') }}">Terms of Service</a>
<a class="hover:text-accent" href="{{ route('legal.refund') }}">Refund &amp; Cancellation</a>
<a class="hover:text-accent" href="{{ route('legal.cookies') }}">Cookie Policy</a>
</nav>
<p class="mt-6 text-xs text-zinc-500">
&copy; {{ date('Y') }} FuelAlert. A trading name of Ovidiu Ungureanu, sole trader, Peterborough, United Kingdom.
</p>
</div>
</footer>
</body>
</html>

View File

@@ -1,55 +0,0 @@
@props([
'name',
'price',
'buttonText',
'perks' => [],
'featured' => false,
'dark' => false,
])
@php
$cardClass = match(true) {
$dark => 'bg-primary border border-primary text-white',
$featured => 'bg-white border-2 border-primary',
default => 'bg-white border border-zinc-300',
};
$buttonClass = $dark
? 'bg-white text-primary hover:bg-zinc-100'
: 'bg-primary text-white hover:bg-primary-dark';
@endphp
<div {{ $attributes->merge(['class' => "p-8 rounded-3xl flex flex-col relative $cardClass"]) }}>
@if ($featured)
<div class="absolute -top-4 left-1/2 -translate-x-1/2 bg-primary text-white px-4 py-1 rounded-full text-[10px] font-black uppercase tracking-widest whitespace-nowrap">
Most Popular
</div>
@endif
<div class="flex items-baseline justify-between mb-6">
<h4 class="text-xl font-bold">{{ $name }}</h4>
<div class="flex items-baseline gap-1">
<span @class(['text-3xl font-black', 'text-primary' => $featured])>{{ $price }}</span>
<span @class(['text-sm', 'text-zinc-500' => !$dark, 'text-zinc-400' => $dark])>/mo</span>
</div>
</div>
<ul class="space-y-2 mb-6 flex-1">
@foreach ($perks as $perk)
<li @class(['text-sm flex gap-2 items-center', 'text-zinc-500' => !$perk['included'] && !$dark])>
@if ($perk['included'])
<iconify-icon icon="lucide:check" class="text-primary shrink-0"></iconify-icon>
@else
<iconify-icon icon="lucide:x" class="text-zinc-300 shrink-0"></iconify-icon>
@endif
{{ $perk['text'] }}
</li>
@endforeach
</ul>
<a href="{{ route('register') }}" class="w-full py-2.5 px-6 {{ $buttonClass }} rounded-full text-sm text-center font-bold shadow-lg transition-all hover:scale-105 active:scale-95">
{{ $buttonText }}
</a>
</div>

View File

@@ -0,0 +1,106 @@
{{-- DRAFT: Generated {{ date('Y-m-d') }}. Review by UK-qualified solicitor recommended before launch. --}}
<x-layouts.legal
title="Cookie Policy"
heading="Cookie Policy"
lastUpdated="{{ now()->format('j F Y') }}"
metaDescription="The cookies and similar technologies FuelAlert uses, and how to manage them.">
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">1. What cookies are</h2>
<p>
Cookies are small text files placed on your device by websites you visit. They allow a
site to remember things between visits (for example, that you're signed in) and to
measure how the site is used. This policy explains how FuelAlert uses cookies and
similar technologies, and how you can manage them.
</p>
</section>
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">2. Cookies we use</h2>
<p>
FuelAlert uses only <strong>essential</strong> cookies &mdash; cookies that are strictly
necessary to deliver the service you've asked for. Under the Privacy and Electronic
Communications Regulations (PECR), these do not require your consent, but we list them
here for transparency.
</p>
<p>
For aggregated usage metrics we run our own self-hosted instance of
<strong>Umami Analytics</strong>, which is <strong>cookieless</strong> &mdash; it does
not set any cookies, does not use device fingerprinting, and does not track you across
sites. It does not store information that identifies you as an individual, so no consent
is required.
</p>
<div class="overflow-x-auto">
<table class="w-full border-collapse text-left text-sm">
<thead class="bg-zinc-100">
<tr>
<th class="border border-zinc-300 px-3 py-2 font-semibold">Name</th>
<th class="border border-zinc-300 px-3 py-2 font-semibold">Purpose</th>
<th class="border border-zinc-300 px-3 py-2 font-semibold">Duration</th>
<th class="border border-zinc-300 px-3 py-2 font-semibold">Type</th>
</tr>
</thead>
<tbody>
<tr>
<td class="border border-zinc-300 px-3 py-2 font-mono text-xs">fuel_alert_session</td>
<td class="border border-zinc-300 px-3 py-2">Keeps you signed in and maintains your session state.</td>
<td class="border border-zinc-300 px-3 py-2">Session</td>
<td class="border border-zinc-300 px-3 py-2">Essential</td>
</tr>
<tr>
<td class="border border-zinc-300 px-3 py-2 font-mono text-xs">XSRF-TOKEN</td>
<td class="border border-zinc-300 px-3 py-2">Protects against cross-site request forgery attacks on forms and account actions.</td>
<td class="border border-zinc-300 px-3 py-2">Session</td>
<td class="border border-zinc-300 px-3 py-2">Essential</td>
</tr>
<tr>
<td class="border border-zinc-300 px-3 py-2 font-mono text-xs">remember_web_*</td>
<td class="border border-zinc-300 px-3 py-2">"Remember me" &mdash; keeps you signed in across browser restarts if you tick the box at login.</td>
<td class="border border-zinc-300 px-3 py-2">30 days</td>
<td class="border border-zinc-300 px-3 py-2">Essential</td>
</tr>
</tbody>
</table>
</div>
<p class="text-sm text-zinc-600">
If we add a non-essential cookie in future (for example, a marketing or advertising
tool), we will add it to the table above and request your consent before it loads.
</p>
</section>
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">3. Your choices</h2>
<p>
Because we currently use only essential cookies, there is nothing to opt in or out of
on FuelAlert at this time. If we introduce non-essential cookies in future (for example,
analytics or marketing), we will ask for your consent first and give you a way to accept,
reject, or customise your choice. We will not set non-essential cookies before you have
given consent.
</p>
<p>
All major browsers also let you view, block, or delete cookies. The ICO publishes
guidance on managing cookies in your browser:
<a class="text-accent underline" href="https://ico.org.uk/your-data-matters/online/cookies/" target="_blank" rel="noopener">ico.org.uk &middot; managing cookies</a>.
Note that blocking essential cookies will prevent you from signing in.
</p>
</section>
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">4. Changes to this policy</h2>
<p>
We may update this policy if we add new cookies, change our providers, or in response to
legal or guidance changes. Material changes will be highlighted by an updated
"Last updated" date at the top of this page.
</p>
</section>
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">5. Contact</h2>
<p>
Questions about cookies? Email
<a href="mailto:hello@fuel-alert.co.uk" class="text-accent underline">hello@fuel-alert.co.uk</a>.
</p>
</section>
</x-layouts.legal>

View File

@@ -0,0 +1,282 @@
{{-- DRAFT: Generated {{ date('Y-m-d') }}. Review by UK-qualified solicitor recommended before launch. --}}
<x-layouts.legal
title="Privacy Policy"
heading="Privacy Policy"
lastUpdated="{{ now()->format('j F Y') }}"
metaDescription="How FuelAlert collects, uses and protects your personal data under UK GDPR.">
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">1. Who we are</h2>
<p>
FuelAlert is a trading name of <strong>Ovidiu Ungureanu</strong>, a sole trader based in
Peterborough, United Kingdom. For the purposes of UK data protection law, Ovidiu Ungureanu
is the <strong>data controller</strong> for personal data collected through this service.
</p>
<p>
Ovidiu Ungureanu is registered with the UK Information Commissioner's Office (ICO) as a
data controller. <strong>ICO registration reference: 00014395133.</strong>
</p>
<p>
If you have any questions about this policy or how we handle your personal data, contact us at
<a href="mailto:hello@fuel-alert.co.uk" class="text-accent underline">hello@fuel-alert.co.uk</a>.
</p>
</section>
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">2. What data we collect</h2>
<h3 class="font-semibold text-zinc-900">Account data</h3>
<p>Your email address, a hashed password, and the date you created your account.</p>
<h3 class="font-semibold text-zinc-900">Contact data for alerts</h3>
<p>
If you opt in to WhatsApp or SMS alerts, your mobile phone number. We collect it only to
send the alerts you have requested, and only after you verify the number through a
one-time passcode (OTP) sent to that number.
</p>
<h3 class="font-semibold text-zinc-900">Location data</h3>
<p>
We use location only to show you fuel prices near you, and only when you ask us to. We
never track your location in the background. Location reaches us in the following ways:
</p>
<ul class="list-disc space-y-1 pl-6">
<li>
<strong>Searching nearby (everyone).</strong> When you use "find prices near me",
your browser asks your permission to share your device location. We use the
coordinates to find nearby stations. We do not store your precise coordinates. For
anonymous usage statistics (for example, "stations checked this week") we record
searches only at approximately 1&nbsp;km precision, together with a one-way hashed
version of your IP address that cannot be reversed to identify you.
</li>
<li>
<strong>Shareable search links.</strong> Search results can be shared or bookmarked
as a web link. To make this work, your filters and an approximate location are
included in the link's web address. Location in links is rounded to roughly
street-level precision rather than your exact position. Anyone you share a link with
can see the approximate location it contains.
</li>
<li>
<strong>Saved location (registered users).</strong> If you provide a postcode, we
convert it to approximate coordinates and store this against your account so we can
show your local prices without you re-entering it. You can change or remove it in
your account settings, and it is deleted when you delete your account.
</li>
</ul>
<h3 class="font-semibold text-zinc-900">Search and query logs</h3>
<p>
When you search for stations or prices, we log the approximate search location, fuel
type selected, result count, timestamp, a one-way hashed IP address, and basic device
information (browser type, device type). We use these logs for abuse prevention,
troubleshooting, and aggregate service statistics. We do not use them to build a profile
of your individual behaviour. Logs are retained for a maximum of 24 months.
</p>
<h3 class="font-semibold text-zinc-900">Payment data</h3>
<p>
Payment card details are collected and processed by <strong>Stripe</strong>, our payment
processor. FuelAlert does not see, store, or otherwise have access to your card numbers.
We retain only your Stripe customer ID and subscription metadata (plan, billing cycle,
renewal date).
</p>
<h3 class="font-semibold text-zinc-900">Push notification data</h3>
<p>
If you opt in to push notifications via OneSignal, we store your push subscription
endpoint (a browser-specific URL), the encryption keys needed for secure message
delivery, and your notification preferences. This data is retained until you unsubscribe,
revoke browser permission, or your subscription becomes stale.
</p>
<h3 class="font-semibold text-zinc-900">Usage data</h3>
<p>
Features you use and alerts you configure used to deliver the service and improve it.
</p>
<h3 class="font-semibold text-zinc-900">Technical data</h3>
<p>
IP address, browser type and version, device type, and operating system. IP address is
collected alongside account actions and searches for security, abuse prevention, and
fraud detection (lawful basis: legitimate interests, Art. 6(1)(f)). We do not use IP
addresses to identify you as an individual in any other context.
</p>
<h3 class="font-semibold text-zinc-900">Marketing preferences</h3>
<p>Only collected if you opt in to marketing communications.</p>
</section>
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">3. Lawful basis for processing</h2>
<p>We process your personal data under the following bases of UK GDPR Article 6:</p>
<ul class="list-disc space-y-1 pl-6">
<li><strong>Account creation and service delivery</strong> &mdash; contract (Art. 6(1)(b)).</li>
<li><strong>Sending the alerts you configure, including by email, WhatsApp, SMS or push</strong> &mdash; contract (Art. 6(1)(b)).</li>
<li><strong>Finding stations near you on request (device location)</strong> &mdash; consent (Art. 6(1)(a)), given through your browser's location permission and withdrawable at any time.</li>
<li><strong>Storing your saved location as a registered user</strong> &mdash; contract (Art. 6(1)(b)).</li>
<li><strong>Payment processing</strong> &mdash; contract (Art. 6(1)(b)).</li>
<li><strong>Security, abuse prevention, and fraud detection (including IP address logging)</strong> &mdash; legitimate interests (Art. 6(1)(f)).</li>
<li><strong>Search and query logging for aggregate statistics and troubleshooting</strong> &mdash; legitimate interests (Art. 6(1)(f)).</li>
<li><strong>Aggregated, non-identifying analytics and product improvement</strong> &mdash; legitimate interests (Art. 6(1)(f)).</li>
<li><strong>Marketing emails</strong> &mdash; consent (Art. 6(1)(a)). You can withdraw consent at any time.</li>
</ul>
</section>
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">4. How we use your data</h2>
<ul class="list-disc space-y-1 pl-6">
<li>To create and operate your account (contract).</li>
<li>To deliver fuel price information and the alerts you have configured (contract).</li>
<li>To find fuel stations near you when you request it (consent).</li>
<li>To process subscription payments via Stripe (contract).</li>
<li>To keep our service secure and prevent abuse (legitimate interests).</li>
<li>To understand which features are used and improve the product, using aggregated, non-identifying data (legitimate interests).</li>
<li>To respond to your support enquiries (contract / legitimate interests).</li>
<li>To send marketing emails if you have opted in (consent).</li>
</ul>
</section>
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">5. Automated recommendations</h2>
<p>
FuelAlert generates fill-up timing recommendations (for example, "fill up now" or "wait")
using an algorithm that analyses local price trends, historical patterns, and market
signals. These recommendations are <strong>informational only</strong> and are produced
automatically without human review. They do not have legal or similarly significant
effects on you, and we do not use them to make decisions that affect your rights or
interests in any material way.
</p>
</section>
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">6. Who we share data with</h2>
<p>We use the following processors to deliver the service. We do not sell your data to anyone.</p>
<ul class="list-disc space-y-2 pl-6">
<li>
<strong>Stripe</strong> &mdash; payment processing. Card details, billing address,
and subscription events flow to Stripe. See
<a class="text-accent underline" href="https://stripe.com/privacy" target="_blank" rel="noopener">Stripe's privacy policy</a>.
</li>
<li><strong>Ionos</strong> &mdash; infrastructure where our application and database run, and the mail servers (SMTP) that send account, billing and alert emails on our behalf.</li>
<li>
<strong>Umami Analytics</strong> &mdash; we run our own self-hosted Umami instance to
collect aggregated, cookieless usage metrics (pages viewed, referrer, country, device
type). It does not store data that identifies you as an individual, and no analytics
data is shared with third parties. We periodically review our analytics setup to
confirm it remains cookieless; if this changes we will update our Cookie Policy and
request consent before setting any non-essential cookies.
</li>
<li><strong>Vonage</strong> &mdash; delivers WhatsApp and SMS alerts if you opt in to those channels. Your phone number is shared only to send messages you have requested. See <a class="text-accent underline" href="https://www.vonage.co.uk/legal/privacy-policy/" target="_blank" rel="noopener">Vonage's privacy policy</a>.</li>
<li><strong>OneSignal</strong> &mdash; delivers web push notifications if you opt in to push alerts. Push subscription data (endpoint, encryption keys, device type) is processed by OneSignal on our behalf. See <a class="text-accent underline" href="https://onesignal.com/privacy_policy" target="_blank" rel="noopener">OneSignal's privacy policy</a>.</li>
</ul>
</section>
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">7. International transfers</h2>
<p>
Some of our processors &mdash; including Stripe, Vonage and OneSignal &mdash; operate
outside the UK and EEA, including in the United States. Where personal data is transferred
internationally, we rely on appropriate safeguards under UK GDPR: the UK International Data
Transfer Addendum to the EU Standard Contractual Clauses, the UK Extension to the EU-US
Data Privacy Framework, or an equivalent mechanism, depending on the processor.
</p>
</section>
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">8. How long we keep data</h2>
<ul class="list-disc space-y-1 pl-6">
<li><strong>Active account data:</strong> for as long as your account is open, plus 12 months after closure.</li>
<li><strong>Saved location (registered users):</strong> while your account is active; deleted when you delete your account.</li>
<li><strong>Alert and notification preferences:</strong> while your account is active; deleted when you close your account or remove the preference.</li>
<li><strong>Push notification subscriptions:</strong> until you unsubscribe, revoke browser permission, or the subscription becomes stale.</li>
<li><strong>Payment records:</strong> 6 years, to meet HMRC requirements for self-employed traders.</li>
<li><strong>Marketing data:</strong> until you withdraw consent.</li>
<li><strong>Security and fraud logs (including IP records):</strong> a maximum of 12 months.</li>
<li><strong>Search and query logs:</strong> a maximum of 24 months.</li>
<li><strong>Aggregated analytics:</strong> retained indefinitely in anonymised, non-identifiable form only.</li>
</ul>
</section>
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">9. Your rights under UK GDPR</h2>
<p>You have the following rights in relation to your personal data:</p>
<ul class="list-disc space-y-1 pl-6">
<li><strong>Right of access</strong> &mdash; ask for a copy of the data we hold about you.</li>
<li><strong>Right to rectification</strong> &mdash; ask us to correct inaccurate data.</li>
<li><strong>Right to erasure</strong> ("right to be forgotten") &mdash; ask us to delete your data.</li>
<li><strong>Right to restrict processing</strong> &mdash; ask us to pause processing in certain circumstances.</li>
<li><strong>Right to data portability</strong> &mdash; receive your data in a machine-readable format.</li>
<li><strong>Right to object</strong> &mdash; object to processing based on legitimate interests.</li>
<li><strong>Rights related to automated decision-making</strong> &mdash; our fill-up timing recommendations are generated algorithmically but are informational only and do not have legal or similarly significant effects on you.</li>
<li><strong>Right to withdraw consent</strong> &mdash; where we rely on consent (for example, device location or marketing).</li>
</ul>
<p>
To exercise any of these rights, email
<a href="mailto:hello@fuel-alert.co.uk" class="text-accent underline">hello@fuel-alert.co.uk</a>.
We will respond within one month.
</p>
</section>
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">10. Cookies</h2>
<p>
We use only a small number of essential cookies to operate the service, and self-hosted,
cookieless analytics. Full details are in our
<a class="text-accent underline" href="{{ route('legal.cookies') }}">Cookie Policy</a>.
</p>
</section>
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">11. Security</h2>
<p>
All traffic between your device and our service is encrypted with HTTPS. Passwords are
stored as one-way hashes &mdash; we never see your plaintext password. Sensitive fields in
our database are protected by access controls, and our infrastructure receives regular
security updates. No system is ever 100% secure; if a breach occurs that affects you, we
will notify you and the ICO as required by law.
</p>
</section>
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">12. Children</h2>
<p>
FuelAlert is not directed at children. We do not knowingly collect data from anyone under
16. If you believe a child has provided us with personal data, contact us and we will
delete it.
</p>
</section>
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">13. Complaints</h2>
<p>
We hope you'll contact us first if you have a complaint, so we can try to put it right.
You also have the right to lodge a complaint with the UK Information Commissioner's Office
at any time.
</p>
<p>
ICO website: <a class="text-accent underline" href="https://ico.org.uk" target="_blank" rel="noopener">ico.org.uk</a>
&middot; ICO helpline: 0303 123 1113.
</p>
</section>
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">14. Changes to this policy</h2>
<p>
We may update this policy from time to time. If we make material changes we will notify
registered users by email. Non-material changes will be shown by an updated "Last updated"
date at the top of this page.
</p>
</section>
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">15. Contact</h2>
<p>
For any privacy or data protection queries, email
<a href="mailto:hello@fuel-alert.co.uk" class="text-accent underline">hello@fuel-alert.co.uk</a>.
</p>
<p class="text-sm text-zinc-600">
Data controller: Ovidiu Ungureanu trading as FuelAlert, Peterborough, United Kingdom.
ICO registration reference: 00014395133.
</p>
</section>
</x-layouts.legal>

View File

@@ -0,0 +1,110 @@
{{-- DRAFT: Generated {{ date('Y-m-d') }}. Review by UK-qualified solicitor recommended before launch. --}}
<x-layouts.legal
title="Refund & Cancellation Policy"
heading="Refund & Cancellation Policy"
lastUpdated="{{ now()->format('j F Y') }}"
metaDescription="Your right to cancel a FuelAlert subscription, including the 14-day cooling-off period under UK law.">
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">1. Your 14-day right to cancel</h2>
<p>
Under the <strong>Consumer Contracts (Information, Cancellation and Additional Charges)
Regulations 2013</strong>, you have <strong>14 days</strong> from the date you subscribe
to a paid plan to cancel without giving a reason. This is sometimes called the
"cooling-off period".
</p>
<p>
This 14-day right applies to <strong>new subscribers only</strong>. It does not apply to
subsequent automatic renewals of an existing subscription.
</p>
</section>
<section class="space-y-3 rounded-lg border-l-4 border-accent bg-white/70 p-6">
<h2 class="font-display text-2xl font-bold text-zinc-900">2. Express consent to start the service immediately</h2>
<p>
When you subscribe, we ask you to choose whether the paid features should be available
to you immediately. If you tick the consent box and start using paid features within the
14-day window, you expressly acknowledge that:
</p>
<ul class="list-disc space-y-1 pl-6">
<li>The service is being supplied to you straight away;</li>
<li>
<strong>You will lose your right to cancel under the Consumer Contracts Regulations
2013 once the service has been fully supplied</strong> (i.e. once you have used the
paid features during the cooling-off period).
</li>
</ul>
<p>
If you do <strong>not</strong> tick the express-consent box, your subscription is still
created but paid features remain inactive until the cooling-off period ends, or until
you change your mind and confirm consent.
</p>
<p>
If you cancel within the 14-day window <strong>before</strong> using any paid features,
you receive a <strong>full refund</strong>. If you cancel within the window after using
some paid features, we may reduce the refund proportionally to reflect usage, as
permitted by the Regulations.
</p>
</section>
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">3. How to cancel</h2>
<p>You can cancel a subscription in either of these ways:</p>
<ul class="list-disc space-y-1 pl-6">
<li>From your account: <strong>Settings &rarr; Subscription &rarr; Cancel</strong>.</li>
<li>By emailing <a href="mailto:hello@fuel-alert.co.uk" class="text-accent underline">hello@fuel-alert.co.uk</a> from the address on your account.</li>
</ul>
<p>
Unless you are exercising the 14-day right above, cancellation takes effect at the end
of the current billing period. You keep access to paid features until that date.
</p>
</section>
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">4. Refunds outside the 14-day period</h2>
<p>
Outside the 14-day cooling-off window, subscription fees are <strong>non-refundable</strong>
for the remainder of the period you have paid for. You keep access to paid features
until the end of that period; the subscription simply does not renew.
</p>
<p>
We may issue discretionary refunds where there has been a service failure on our side or
where required by law.
</p>
</section>
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">5. Annual subscriptions</h2>
<p>
The 14-day cooling-off right applies to annual subscriptions in the same way as monthly
subscriptions. After the 14 days, annual fees are non-refundable; we do not issue
pro-rata refunds for unused months of an annual plan.
</p>
</section>
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">6. Failed payments and involuntary cancellation</h2>
<p>
If a renewal payment fails, we and Stripe will retry the payment over a short period and
email you. Paid features are suspended after the final unsuccessful retry. Your account
itself is <strong>not deleted</strong>; you can resume by updating your payment method.
</p>
</section>
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">7. How long refunds take</h2>
<p>
Approved refunds are issued to the original payment method via Stripe and typically
arrive in your account within 5&ndash;10 business days, depending on your bank or card
provider.
</p>
</section>
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">8. Contact</h2>
<p>
For refund or cancellation queries, email
<a href="mailto:hello@fuel-alert.co.uk" class="text-accent underline">hello@fuel-alert.co.uk</a>.
</p>
</section>
</x-layouts.legal>

View File

@@ -0,0 +1,248 @@
{{-- DRAFT: Generated {{ date('Y-m-d') }}. Review by UK-qualified solicitor recommended before launch. --}}
<x-layouts.legal
title="Terms of Service"
heading="Terms of Service"
lastUpdated="{{ now()->format('j F Y') }}"
metaDescription="The terms that govern your use of FuelAlert's subscription service.">
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">1. About these terms</h2>
<p>
FuelAlert is a trading name of <strong>Ovidiu Ungureanu</strong>, a sole trader based in
Peterborough, United Kingdom ("we", "us", "our"). These terms form a legally binding
contract between you and Ovidiu Ungureanu trading as FuelAlert.
ICO registration reference: 00014395133.
</p>
<p>
By creating an account or using the service, you confirm that you have read, understood
and accepted these terms. If you do not accept them, please do not use the service.
</p>
<p>These terms are governed by the laws of England and Wales.</p>
</section>
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">2. The service</h2>
<p>
FuelAlert provides UK fuel price comparison and fill-up timing recommendations. We act
as a downstream consumer of publicly available UK government fuel price data feeds
(including the UK Fuel Finder / Pump Watch transparency scheme) and surface that data
through a web app, alerts, and forecasts. We do not control the prices submitted by fuel
retailers to those upstream schemes and are not responsible for errors or delays in
that data.
</p>
<p>
We offer a free tier and one or more paid subscription plans. The current list of plans
and prices is available on our <a class="text-accent underline" href="/#pricing">pricing page</a>.
</p>
<p>
We may add, remove, or change features over time. Where changes materially reduce the
paid service, we will give you reasonable notice and, where appropriate, a way to cancel.
</p>
</section>
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">3. Your account</h2>
<ul class="list-disc space-y-1 pl-6">
<li>You must be at least 18 years old and resident in the United Kingdom to create an account.</li>
<li>The information you provide must be accurate and kept up to date.</li>
<li>One account per person. You are responsible for keeping your login credentials secure.</li>
<li>You are responsible for activity that takes place under your account.</li>
<li>We may suspend or close accounts where these terms are seriously or repeatedly breached.</li>
</ul>
</section>
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">4. Subscriptions, billing and payment</h2>
<p>
Paid plans are billed monthly in advance. The current price for each plan is shown on
the <a class="text-accent underline" href="/#pricing">pricing page</a> at the time you subscribe.
</p>
<p>
<strong>Auto-renewal.</strong> Subscriptions renew automatically at the end of each
billing period at the then-current price, unless you cancel before the renewal date. By
subscribing you authorise FuelAlert &mdash; through our payment processor Stripe &mdash;
to charge your nominated payment method at each renewal.
</p>
<p>
<strong>Failed payments.</strong> If a payment fails, we and Stripe will retry the
payment over the following days. We will email you when this happens. Persistent failure
will cause your paid features to be suspended; your account itself is not deleted.
</p>
<p>
<strong>Price changes.</strong> If we change the price of your plan, we will give you at
least 30 days' notice by email before the new price takes effect on your next renewal.
You may cancel before the change takes effect.
</p>
<p>
<strong>VAT.</strong> FuelAlert is currently below the UK VAT registration threshold and is
not VAT-registered, so no VAT is charged on your subscription. The price shown is the total
amount you pay. If our VAT status changes, we will update these terms and notify you before
any price change takes effect.
</p>
<p>
<strong>Downgrade on cancellation.</strong> When a paid subscription ends or is cancelled,
your account reverts to the free tier. Paid alert channels (WhatsApp, SMS) are deactivated,
but your alert settings are retained and will reactivate if you resubscribe.
</p>
</section>
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">5. Cancellation and refunds</h2>
<p>
You can cancel your subscription at any time from your account settings. Cancellation
stops the next renewal; you keep access to paid features until the end of the current
billing period.
</p>
<p>
New subscribers have a <strong>14-day right to cancel</strong> under the Consumer
Contracts Regulations 2013. Important details &mdash; including the express-consent
mechanism that affects this right &mdash; are set out in our
<a class="text-accent underline" href="{{ route('legal.refund') }}">Refund &amp; Cancellation Policy</a>.
</p>
</section>
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">6. Acceptable use</h2>
<p>You agree not to:</p>
<ul class="list-disc space-y-1 pl-6">
<li>Scrape, reverse-engineer, or bulk-extract data from the service without our written permission.</li>
<li>Mirror, republish, or systematically reproduce our compiled price data, station rankings, scoring outputs, or any other value-added data derived from the service.</li>
<li>Use the service or its outputs for commercial exploitation, resale, or competitor monitoring without our written consent.</li>
<li>Resell or redistribute fuel price data taken from FuelAlert.</li>
<li>Use the service for any unlawful purpose.</li>
<li>Attempt to circumvent or compromise our security measures.</li>
<li>Use automated tools to make queries beyond what a single human user would reasonably make.</li>
<li>Use the service while operating a motor vehicle. You must not interact with the service while a vehicle is in motion. Compliance with the Road Traffic Act 1988, the Highway Code, and all applicable road traffic laws is your sole responsibility.</li>
</ul>
</section>
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">7. Accuracy of price data</h2>
<p>
Fuel prices shown on FuelAlert are sourced from official UK government data feeds
(including the Pump Watch / Fuel Finder transparency schemes) and refreshed
periodically. FuelAlert acts as a downstream consumer of those feeds and does not
control the data submitted by fuel retailers to the central aggregator. Stations can
change prices at any time, and there is usually a delay between a forecourt change
and the feed update.
</p>
<p>
We make reasonable efforts to display accurate prices but <strong>we cannot guarantee
that the price shown will match the price at the pump</strong> when you arrive.
<strong>Always confirm the price at the pump before fuelling.</strong>
</p>
<p>
We are not liable for any loss arising from inaccurate, delayed, or missing price data,
including the cost of a wasted journey or any difference between the price shown and the
price charged.
</p>
</section>
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">8. Forecasts and recommendations</h2>
<p>
FuelAlert may show forecasts and recommendations (e.g. "fill up now" or "wait"). These
are generated algorithmically based on local price trends, historical patterns, and
market signals. They are <strong>informational only</strong>, are not financial advice,
and should not be relied upon as a guarantee of future prices. Past trends do not
guarantee future prices. We do not warrant the accuracy of any forecast or recommendation.
</p>
</section>
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">9. Intellectual property</h2>
<p>
The FuelAlert name, logo, software, scoring algorithms, and original content are owned
by Ovidiu Ungureanu. You receive a limited, non-exclusive, revocable licence to use the
service for personal, non-commercial purposes.
</p>
<p>
Underlying fuel price data is owned by the respective fuel retailers and published under
UK government open data schemes. Postcode and geographic data is sourced from the ONS
Postcode Directory, &copy; Crown Copyright, used under the Open Government Licence v3.0.
</p>
</section>
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">10. Third-party services</h2>
<p>
We use <strong>Stripe</strong> to process payments. Your use of Stripe is also subject
to Stripe's own terms and privacy policy. We may use other third-party processors to
run the service; these are named in our
<a class="text-accent underline" href="{{ route('legal.privacy') }}">Privacy Policy</a>.
</p>
</section>
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">11. Limitation of liability</h2>
<p>
Nothing in these terms excludes or limits our liability for death or personal injury
caused by our negligence, fraud or fraudulent misrepresentation, or any other liability
that cannot be excluded under UK consumer law. Your statutory rights as a consumer are
not affected.
</p>
<p>Subject to the paragraph above:</p>
<ul class="list-disc space-y-1 pl-6">
<li>We exclude liability for indirect, consequential, or business losses.</li>
<li>
For paying subscribers, our total liability to you in any 12-month period is capped
at the total amount you paid in subscription fees during that period.
</li>
<li>
We do not accept liability for issues caused by third-party services we rely on,
including but not limited to outages or errors at our payment processor, hosting
provider, or upstream data sources.
</li>
<li>
We are not responsible for the accuracy, completeness, or timeliness of data
submitted by fuel retailers to the UK Fuel Finder scheme or any other upstream
source we consume as a downstream aggregator.
</li>
</ul>
</section>
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">12. Termination</h2>
<p>
You may stop using the service and close your account at any time. We may terminate or
suspend access for serious breach of these terms, with reasonable notice where the
breach is capable of being put right.
</p>
<p>
Sections that by their nature should survive termination (including sections 7 to 11)
will continue to apply after your account is closed.
</p>
</section>
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">13. Changes to these terms</h2>
<p>
We may update these terms. Material changes will be notified to registered users by
email at least 14 days before they take effect. Continued use of the service after the
change date means you accept the new terms.
</p>
</section>
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">14. Disputes</h2>
<p>
Please contact us first if you have a complaint &mdash; we will try to resolve it
directly. These terms are governed by the laws of England and Wales, and the courts of
England and Wales have non-exclusive jurisdiction over any dispute. If you live
elsewhere in the United Kingdom, you keep the right to bring proceedings in the courts
of your country of residence.
</p>
</section>
<section class="space-y-3">
<h2 class="font-display text-2xl font-bold text-zinc-900">15. Contact</h2>
<p>
For questions about these terms, email
<a href="mailto:hello@fuel-alert.co.uk" class="text-accent underline">hello@fuel-alert.co.uk</a>.
</p>
<p class="text-sm text-zinc-600">
Ovidiu Ungureanu trading as FuelAlert, Peterborough, United Kingdom.
ICO registration reference: 00014395133.
</p>
</section>
</x-layouts.legal>

View File

@@ -24,5 +24,13 @@ Route::middleware(['auth'])->prefix('billing')->name('billing.')->group(function
Route::get('/cancel', [BillingController::class, 'cancel'])->name('cancel'); Route::get('/cancel', [BillingController::class, 'cancel'])->name('cancel');
}); });
// Server-rendered legal pages — must be registered before the SPA catch-all
Route::prefix('legal')->name('legal.')->group(function () {
Route::view('/privacy', 'legal.privacy')->name('privacy');
Route::view('/terms', 'legal.terms')->name('terms');
Route::view('/refund', 'legal.refund')->name('refund');
Route::view('/cookies', 'legal.cookies')->name('cookies');
});
// SPA catch-all — must be last // SPA catch-all — must be last
Route::get('/{any?}', fn () => view('app'))->where('any', '.*')->name('home'); Route::get('/{any?}', fn () => view('app'))->where('any', '.*')->name('home');

View File

@@ -0,0 +1,68 @@
<?php
it('serves the privacy policy with required content', function (): void {
$response = $this->get('/legal/privacy');
$response->assertStatus(200);
$response->assertSeeText('Privacy Policy');
$response->assertSeeText('data controller');
$response->assertSeeText('UK GDPR');
$response->assertSeeText('Last updated:');
});
it('serves the terms of service with required content', function (): void {
$response = $this->get('/legal/terms');
$response->assertStatus(200);
$response->assertSeeText('Terms of Service');
$response->assertSeeText('subscription');
$response->assertSeeText('England and Wales');
});
it('serves the refund policy with cooling-off content', function (): void {
$response = $this->get('/legal/refund');
$response->assertStatus(200);
$response->assertSeeText('Refund');
$response->assertSeeText('14-day');
$response->assertSeeText('Consumer Contracts');
});
it('serves the cookie policy with essential-cookie content', function (): void {
$response = $this->get('/legal/cookies');
$response->assertStatus(200);
$response->assertSeeText('Cookie Policy');
$response->assertSeeText('essential');
});
it('does not render the SPA mount point on legal pages', function (string $path): void {
$response = $this->get($path);
$response->assertStatus(200);
$response->assertDontSee('<div id="app"></div>', false);
})->with([
'/legal/privacy',
'/legal/terms',
'/legal/refund',
'/legal/cookies',
]);
it('cross-links between legal pages', function (): void {
$this->get('/legal/privacy')->assertSee(route('legal.cookies'), false);
$this->get('/legal/terms')->assertSee(route('legal.refund'), false);
});
it('is launch-ready: no placeholders and the correct contact domain', function (string $path): void {
$response = $this->get($path);
$response->assertStatus(200);
$response->assertDontSee('[PLACEHOLDER', false);
$response->assertDontSee('hello@fuelalert.co.uk', false);
$response->assertSee('hello@fuel-alert.co.uk', false);
})->with([
'/legal/privacy',
'/legal/terms',
'/legal/refund',
'/legal/cookies',
]);

View File

@@ -53,7 +53,7 @@ it('canSendNow returns false when tier does not allow the channel', function ():
}); });
it('canSendNow returns false when daily limit is reached', function (): void { it('canSendNow returns false when daily limit is reached', function (): void {
$plan = Plan::where('name', 'plus')->first(); // sms_daily_limit = 1 $plan = Plan::where('name', 'plus')->first(); // sms_daily_limit = 3
$user = User::factory()->create(); $user = User::factory()->create();
UserNotificationPreference::factory()->create([ UserNotificationPreference::factory()->create([
@@ -70,7 +70,7 @@ it('canSendNow returns false when daily limit is reached', function (): void {
'created_at' => now(), 'created_at' => now(),
]); ]);
expect($plan->sms_daily_limit)->toBe(1); expect($plan->sms_daily_limit)->toBe(3);
$sentCount = NotificationLog::where('user_id', $user->id) $sentCount = NotificationLog::where('user_id', $user->id)
->where('channel', 'sms') ->where('channel', 'sms')

View File

@@ -119,3 +119,57 @@ it('captures response_body when an HTTP RequestException is thrown', function ()
expect(ApiLog::first()->response_body)->toBe('upstream details'); expect(ApiLog::first()->response_body)->toBe('upstream details');
}); });
it('captures Anthropic usage tokens from a successful response', function (): void {
Http::fake(['https://api.anthropic.com/v1/messages' => Http::response([
'content' => [],
'usage' => [
'input_tokens' => 1234,
'output_tokens' => 56,
'cache_creation_input_tokens' => 8000,
'cache_read_input_tokens' => 12000,
],
])]);
$this->apiLogger->send('anthropic', 'POST', 'https://api.anthropic.com/v1/messages',
fn () => Http::post('https://api.anthropic.com/v1/messages'));
$log = ApiLog::first();
expect($log->input_tokens)->toBe(1234)
->and($log->output_tokens)->toBe(56)
->and($log->cache_write_tokens)->toBe(8000)
->and($log->cache_read_tokens)->toBe(12000);
});
it('captures rate-limit headers from any provider response', function (): void {
Http::fake(['https://api.anthropic.com/v1/messages' => Http::response(
['content' => [], 'usage' => ['input_tokens' => 100, 'output_tokens' => 10]],
200,
[
'anthropic-ratelimit-input-tokens-remaining' => '38000',
'anthropic-ratelimit-input-tokens-reset' => '2026-05-14T12:41:00Z',
],
)]);
$this->apiLogger->send('anthropic', 'POST', 'https://api.anthropic.com/v1/messages',
fn () => Http::post('https://api.anthropic.com/v1/messages'));
$log = ApiLog::first();
expect($log->ratelimit_remaining)->toBe(38000)
->and($log->ratelimit_reset_at?->toIso8601String())->toBe('2026-05-14T12:41:00+00:00');
});
it('leaves token columns null for services without usage data', function (): void {
Http::fake(['https://example.com/x' => Http::response(['ok' => true])]);
$this->apiLogger->send('test_service', 'GET', 'https://example.com/x',
fn () => Http::get('https://example.com/x'));
$log = ApiLog::first();
expect($log->input_tokens)->toBeNull()
->and($log->output_tokens)->toBeNull()
->and($log->cache_read_tokens)->toBeNull()
->and($log->cache_write_tokens)->toBeNull()
->and($log->ratelimit_remaining)->toBeNull()
->and($log->ratelimit_reset_at)->toBeNull();
});

View File

@@ -18,32 +18,63 @@ beforeEach(function (): void {
Config::set('services.anthropic.api_key', 'test-key'); Config::set('services.anthropic.api_key', 'test-key');
}); });
function fakeAnthropicWithOverlay(string $direction, int $confidence, array $events, bool $major = false): void /**
* Anthropic-shaped Phase 1 assistant turn that includes a real
* web_search_tool_result block (the source of truth for harvested
* citations).
*
* @param array<int, array{url: string, title: string}> $results
* @return array<string, mixed>
*/
function fakeSearchResultsTurn(array $results): array
{ {
Http::fake([ $content = [['type' => 'text', 'text' => 'Searching...']];
'*api.anthropic.com/*' => Http::sequence() foreach ($results as $idx => $r) {
->push([ $content[] = [
'stop_reason' => 'end_turn', 'type' => 'server_tool_use',
'content' => [['type' => 'text', 'text' => 'Search summary.']], 'id' => 'srvtoolu_'.$idx,
]) 'name' => 'web_search',
->push([ 'input' => ['query' => 'oil news'],
'stop_reason' => 'tool_use', ];
'content' => [[ $content[] = [
'type' => 'tool_use', 'type' => 'web_search_tool_result',
'name' => 'submit_overlay', 'tool_use_id' => 'srvtoolu_'.$idx,
'input' => [ 'content' => [[
'direction' => $direction, 'type' => 'web_search_result',
'confidence' => $confidence, 'url' => $r['url'],
'reasoning_short' => 'Test reasoning.', 'title' => $r['title'],
'events_cited' => $events, 'encrypted_content' => str_repeat('LONG_PAGE_TEXT_', 200),
'agrees_with_ridge' => true, 'page_age' => '1 day ago',
'major_impact_event' => $major, ]],
], ];
]], }
]),
// URL HEAD verification probes — accept everything by default return ['stop_reason' => 'end_turn', 'content' => $content];
'*' => Http::response('', 200), }
]);
/** @param array<int, array<string, mixed>> $events */
function fakeSubmitTurn(string $direction, int $confidence, array $events, bool $major = false): array
{
$input = [
'direction' => $direction,
'confidence' => $confidence,
'reasoning_short' => 'Test reasoning.',
'agrees_with_ridge' => true,
'major_impact_event' => $major,
];
if ($events !== []) {
$input['events_cited'] = $events;
}
return [
'stop_reason' => 'tool_use',
'content' => [[
'type' => 'tool_use',
'id' => 'toolu_submit',
'name' => 'submit_overlay',
'input' => $input,
]],
];
} }
it('skips when ANTHROPIC_API_KEY is not set', function (): void { it('skips when ANTHROPIC_API_KEY is not set', function (): void {
@@ -54,8 +85,13 @@ it('skips when ANTHROPIC_API_KEY is not set', function (): void {
expect($service->run())->toBeNull(); expect($service->run())->toBeNull();
}); });
it('rejects the overlay when no events are cited', function (): void { it('rejects only when neither web search nor model cited anything', function (): void {
fakeAnthropicWithOverlay('rising', 60, []); Http::fake([
'*api.anthropic.com/*' => Http::sequence()
->push(['stop_reason' => 'end_turn', 'content' => [['type' => 'text', 'text' => 'no results']]])
->push(fakeSubmitTurn('rising', 60, [])),
'*' => Http::response('', 200),
]);
$service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class)); $service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class));
@@ -66,30 +102,13 @@ it('rejects the overlay when no events are cited', function (): void {
it('verifies a URL via GET fallback when HEAD returns 405', function (): void { it('verifies a URL via GET fallback when HEAD returns 405', function (): void {
Http::fake([ Http::fake([
'*api.anthropic.com/*' => Http::sequence() '*api.anthropic.com/*' => Http::sequence()
->push([ ->push(['stop_reason' => 'end_turn', 'content' => [['type' => 'text', 'text' => 'ok']]])
'stop_reason' => 'end_turn', ->push(fakeSubmitTurn('rising', 60, [
'content' => [['type' => 'text', 'text' => 'ok']], ['headline' => 'OPEC', 'source' => 'Reuters', 'url' => 'https://reuters.com/x', 'impact' => 'rising'],
]) ])),
->push([
'stop_reason' => 'tool_use',
'content' => [[
'type' => 'tool_use',
'name' => 'submit_overlay',
'input' => [
'direction' => 'rising',
'confidence' => 60,
'reasoning_short' => 'Hostile-to-HEAD source.',
'events_cited' => [
['headline' => 'OPEC', 'source' => 'Reuters', 'url' => 'https://reuters.com/x', 'impact' => 'rising'],
],
'agrees_with_ridge' => true,
'major_impact_event' => false,
],
]],
]),
'reuters.com/*' => Http::sequence() 'reuters.com/*' => Http::sequence()
->push('', 405) // HEAD → 405 Method Not Allowed ->push('', 405)
->push('partial-body', 200), // GET fallback succeeds ->push('partial-body', 200),
]); ]);
$service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class)); $service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class));
@@ -99,65 +118,13 @@ it('verifies a URL via GET fallback when HEAD returns 405', function (): void {
->and($row->events_json)->toHaveCount(1); ->and($row->events_json)->toHaveCount(1);
}); });
it('rejects the overlay when both HEAD and GET fail', function (): void {
Http::fake([
'*api.anthropic.com/*' => Http::sequence()
->push([
'stop_reason' => 'end_turn',
'content' => [['type' => 'text', 'text' => 'ok']],
])
->push([
'stop_reason' => 'tool_use',
'content' => [[
'type' => 'tool_use',
'name' => 'submit_overlay',
'input' => [
'direction' => 'rising',
'confidence' => 60,
'reasoning_short' => 'Truly dead URL.',
'events_cited' => [
['headline' => 'X', 'source' => 'Reuters', 'url' => 'https://example.com/dead', 'impact' => 'rising'],
],
'agrees_with_ridge' => true,
'major_impact_event' => false,
],
]],
]),
'example.com/*' => Http::sequence()
->push('', 404) // HEAD → 404
->push('', 404), // GET → still 404
]);
$service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class));
expect($service->run())->toBeNull()
->and(LlmOverlay::query()->count())->toBe(0);
});
it('rejects the overlay when every cited URL is unreachable', function (): void { it('rejects the overlay when every cited URL is unreachable', function (): void {
Http::fake([ Http::fake([
'*api.anthropic.com/*' => Http::sequence() '*api.anthropic.com/*' => Http::sequence()
->push([ ->push(['stop_reason' => 'end_turn', 'content' => [['type' => 'text', 'text' => 'ok']]])
'stop_reason' => 'end_turn', ->push(fakeSubmitTurn('rising', 60, [
'content' => [['type' => 'text', 'text' => 'ok']], ['headline' => 'X', 'source' => 'Reuters', 'url' => 'https://example.com/dead', 'impact' => 'rising'],
]) ])),
->push([
'stop_reason' => 'tool_use',
'content' => [[
'type' => 'tool_use',
'name' => 'submit_overlay',
'input' => [
'direction' => 'rising',
'confidence' => 60,
'reasoning_short' => 'Test.',
'events_cited' => [
['headline' => 'X', 'source' => 'Reuters', 'url' => 'https://example.com/dead', 'impact' => 'rising'],
],
'agrees_with_ridge' => true,
'major_impact_event' => false,
],
]],
]),
'example.com/*' => Http::response('', 404), 'example.com/*' => Http::response('', 404),
]); ]);
@@ -168,14 +135,14 @@ it('rejects the overlay when every cited URL is unreachable', function (): void
}); });
it('persists an overlay row with verified citations and capped confidence', function (): void { it('persists an overlay row with verified citations and capped confidence', function (): void {
fakeAnthropicWithOverlay( Http::fake([
direction: 'rising', '*api.anthropic.com/*' => Http::sequence()
confidence: 95, // above cap → expect capped to 75 ->push(['stop_reason' => 'end_turn', 'content' => [['type' => 'text', 'text' => 'ok']]])
events: [ ->push(fakeSubmitTurn('rising', 95, [
['headline' => 'OPEC cuts output', 'source' => 'Reuters', 'url' => 'https://reuters.com/opec', 'impact' => 'rising'], ['headline' => 'OPEC cuts output', 'source' => 'Reuters', 'url' => 'https://reuters.com/opec', 'impact' => 'rising'],
], ], major: true)),
major: true, '*' => Http::response('', 200),
); ]);
$service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class)); $service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class));
@@ -183,51 +150,20 @@ it('persists an overlay row with verified citations and capped confidence', func
expect($row)->not->toBeNull() expect($row)->not->toBeNull()
->and($row->direction)->toBe('rising') ->and($row->direction)->toBe('rising')
->and($row->confidence)->toBe(75) // capped ->and($row->confidence)->toBe(75)
->and($row->major_impact_event)->toBeTrue() ->and($row->major_impact_event)->toBeTrue()
->and($row->search_used)->toBeTrue() ->and($row->search_used)->toBeTrue()
->and($row->events_json)->toHaveCount(1); ->and($row->events_json)->toHaveCount(1);
}); });
it('retries the submit when the model omits events_cited', function (): void { it('harvests citations from web_search_tool_result when the model omits events_cited', function (): void {
Http::fake([ Http::fake([
'*api.anthropic.com/*' => Http::sequence() '*api.anthropic.com/*' => Http::sequence()
->push([ ->push(fakeSearchResultsTurn([
'stop_reason' => 'end_turn', ['url' => 'https://reuters.com/opec', 'title' => 'OPEC cuts output'],
'content' => [['type' => 'text', 'text' => 'Search done.']], ['url' => 'https://bloomberg.com/iran', 'title' => 'Iran tensions'],
]) ]))
->push([ ->push(fakeSubmitTurn('rising', 70, [])),
'stop_reason' => 'tool_use',
'content' => [[
'type' => 'tool_use',
'id' => 'toolu_first',
'name' => 'submit_overlay',
'input' => [
'direction' => 'rising',
'confidence' => 70,
'reasoning_short' => 'Forgot citations.',
// events_cited omitted entirely — the bug we are guarding against
],
]],
])
->push([
'stop_reason' => 'tool_use',
'content' => [[
'type' => 'tool_use',
'id' => 'toolu_retry',
'name' => 'submit_overlay',
'input' => [
'direction' => 'rising',
'confidence' => 70,
'reasoning_short' => 'With citations now.',
'events_cited' => [
['headline' => 'OPEC', 'source' => 'Reuters', 'url' => 'https://reuters.com/opec', 'impact' => 'rising'],
],
'agrees_with_ridge' => true,
'major_impact_event' => false,
],
]],
]),
'*' => Http::response('', 200), '*' => Http::response('', 200),
]); ]);
@@ -236,42 +172,79 @@ it('retries the submit when the model omits events_cited', function (): void {
$row = $service->run(); $row = $service->run();
expect($row)->not->toBeNull() expect($row)->not->toBeNull()
->and($row->events_json)->toHaveCount(1) ->and($row->events_json)->toHaveCount(2)
->and(LlmOverlay::query()->count())->toBe(1); ->and(collect($row->events_json)->pluck('url')->all())
->toEqualCanonicalizing(['https://reuters.com/opec', 'https://bloomberg.com/iran'])
->and(collect($row->events_json)->pluck('impact')->unique()->all())
->toBe(['neutral']);
}); });
it('rejects when the retry also omits events_cited', function (): void { it('merges model events_cited with harvested URLs deduped by URL', function (): void {
Http::fake([ Http::fake([
'*api.anthropic.com/*' => Http::sequence() '*api.anthropic.com/*' => Http::sequence()
->push([ ->push(fakeSearchResultsTurn([
'stop_reason' => 'end_turn', ['url' => 'https://reuters.com/opec', 'title' => 'OPEC cuts output'],
'content' => [['type' => 'text', 'text' => 'Search done.']], ['url' => 'https://bloomberg.com/iran', 'title' => 'Iran tensions'],
]) ]))
->push([ ->push(fakeSubmitTurn('rising', 70, [
'stop_reason' => 'tool_use', ['headline' => 'OPEC slashes output', 'source' => 'Reuters', 'url' => 'https://reuters.com/opec', 'impact' => 'rising'],
'content' => [[ ['headline' => 'Refinery fire', 'source' => 'CNBC', 'url' => 'https://cnbc.com/refinery', 'impact' => 'rising'],
'type' => 'tool_use', ])),
'id' => 'toolu_first',
'name' => 'submit_overlay',
'input' => ['direction' => 'rising', 'confidence' => 70, 'reasoning_short' => 'No cites.'],
]],
])
->push([
'stop_reason' => 'tool_use',
'content' => [[
'type' => 'tool_use',
'id' => 'toolu_retry',
'name' => 'submit_overlay',
'input' => ['direction' => 'rising', 'confidence' => 70, 'reasoning_short' => 'Still none.'],
]],
]),
'*' => Http::response('', 200), '*' => Http::response('', 200),
]); ]);
$service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class)); $service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class));
expect($service->run())->toBeNull() $row = $service->run();
->and(LlmOverlay::query()->count())->toBe(0);
expect($row)->not->toBeNull()
->and(collect($row->events_json)->pluck('url')->all())
->toEqualCanonicalizing([
'https://reuters.com/opec',
'https://bloomberg.com/iran',
'https://cnbc.com/refinery',
]);
$opec = collect($row->events_json)->firstWhere('url', 'https://reuters.com/opec');
expect($opec['impact'])->toBe('rising')
->and($opec['headline'])->toBe('OPEC slashes output');
$bloomberg = collect($row->events_json)->firstWhere('url', 'https://bloomberg.com/iran');
expect($bloomberg['impact'])->toBe('neutral');
});
it('does not resend Phase 1 web_search_tool_result blocks on the submit call', function (): void {
Http::fake([
'*api.anthropic.com/*' => Http::sequence()
->push(fakeSearchResultsTurn([
['url' => 'https://reuters.com/opec', 'title' => 'OPEC cuts output'],
]))
->push(fakeSubmitTurn('rising', 70, [
['headline' => 'OPEC', 'source' => 'Reuters', 'url' => 'https://reuters.com/opec', 'impact' => 'rising'],
])),
'*' => Http::response('', 200),
]);
$service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class));
$service->run();
$anthropicRequests = collect(Http::recorded())
->filter(fn (array $pair): bool => str_contains($pair[0]->url(), 'api.anthropic.com'))
->values();
expect($anthropicRequests)->toHaveCount(2);
$submitBody = $anthropicRequests[1][0]->data();
$messagesJson = json_encode($submitBody['messages'], JSON_UNESCAPED_SLASHES);
expect($submitBody['messages'])->toHaveCount(1)
->and($submitBody['messages'][0]['role'])->toBe('user');
expect($messagesJson)->not->toContain('web_search_tool_result')
->and($messagesJson)->not->toContain('LONG_PAGE_TEXT_')
->and($messagesJson)->not->toContain('server_tool_use')
->and($messagesJson)->toContain('https://reuters.com/opec');
}); });
it('honors the 4-hour cooldown for event-driven calls', function (): void { it('honors the 4-hour cooldown for event-driven calls', function (): void {
@@ -291,14 +264,19 @@ it('honors the 4-hour cooldown for event-driven calls', function (): void {
'updated_at' => now(), 'updated_at' => now(),
]); ]);
fakeAnthropicWithOverlay('falling', 40, [ Http::fake([
['headline' => 'A', 'source' => 'X', 'url' => 'https://reuters.com/a', 'impact' => 'falling'], '*api.anthropic.com/*' => Http::sequence()
->push(['stop_reason' => 'end_turn', 'content' => [['type' => 'text', 'text' => 'ok']]])
->push(fakeSubmitTurn('falling', 40, [
['headline' => 'A', 'source' => 'X', 'url' => 'https://reuters.com/a', 'impact' => 'falling'],
])),
'*' => Http::response('', 200),
]); ]);
$service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class)); $service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class));
expect($service->run(eventDriven: true))->toBeNull() // <4h since prior expect($service->run(eventDriven: true))->toBeNull()
->and(LlmOverlay::query()->count())->toBe(1); // no new row inserted ->and(LlmOverlay::query()->count())->toBe(1);
Carbon::setTestNow(); Carbon::setTestNow();
}); });
@@ -320,8 +298,13 @@ it('always runs (ignores cooldown) when not event-driven', function (): void {
'updated_at' => now(), 'updated_at' => now(),
]); ]);
fakeAnthropicWithOverlay('falling', 40, [ Http::fake([
['headline' => 'A', 'source' => 'X', 'url' => 'https://reuters.com/a', 'impact' => 'falling'], '*api.anthropic.com/*' => Http::sequence()
->push(['stop_reason' => 'end_turn', 'content' => [['type' => 'text', 'text' => 'ok']]])
->push(fakeSubmitTurn('falling', 40, [
['headline' => 'A', 'source' => 'X', 'url' => 'https://reuters.com/a', 'impact' => 'falling'],
])),
'*' => Http::response('', 200),
]); ]);
$service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class)); $service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class));