Add comprehensive project documentation and architecture guidelines
Establishes core rules and conventions for the FuelAlert Laravel application: architecture patterns (fat services, thin controllers), database schema with partitioned station_prices table, multi-tier notification system with Vonage and OneSignal, 4-signal scoring engine for fuel price recommendations, Stripe subscription tiers, Livewire classic component structure, and Pest testing standards.
This commit is contained in:
33
CLAUDE.local.md
Normal file
33
CLAUDE.local.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Local overrides (gitignored — personal only)
|
||||||
|
|
||||||
|
## Local environment
|
||||||
|
|
||||||
|
- PhpStorm with SFTP sync to Proxmox LXC (CT 150, 192.168.1.150)
|
||||||
|
- MySQL on IONOS VPS: 192.168.x.x (update with actual)
|
||||||
|
- Local dev URL: http://fuel-alert.test (Valet) or http://localhost:8000
|
||||||
|
|
||||||
|
## Test credentials (local only)
|
||||||
|
|
||||||
|
- Stripe test publishable key: pk_test_...
|
||||||
|
- Fuel Finder sandbox credentials (if available): see 1Password
|
||||||
|
- Vonage test account: see 1Password
|
||||||
|
- OneSignal test app: see 1Password
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
- Production: IONOS VPS behind Traefik (same setup as uovidiu.com portfolio)
|
||||||
|
- Deploy: git push → SSH → composer install --no-dev → php artisan migrate --force → php artisan queue:restart
|
||||||
|
- Redis: Docker container on IONOS VPS
|
||||||
|
|
||||||
|
## Useful local commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Manually trigger fuel price poll
|
||||||
|
php artisan app:poll-fuel-prices
|
||||||
|
|
||||||
|
# Run scoring for a specific user
|
||||||
|
php artisan app:score-user {user_id}
|
||||||
|
|
||||||
|
# Clear scored results cache
|
||||||
|
php artisan cache:forget scoring_results
|
||||||
|
```
|
||||||
38
CLAUDE.md
Normal file
38
CLAUDE.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# FuelAlert — Claude Code Instructions
|
||||||
|
|
||||||
|
UK fuel price intelligence app. Subscribers receive fill-up timing recommendations
|
||||||
|
based on local price trends. Built solo by a PHP/Laravel developer.
|
||||||
|
|
||||||
|
## Project overview
|
||||||
|
|
||||||
|
- **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
|
||||||
|
- **Stack**: Laravel 11 + Livewire 3 (Volt disabled — use classic components) + Alpine.js + Tailwind CSS
|
||||||
|
- **Database**: MySQL — Eloquent ORM, migrations only (no raw DDL)
|
||||||
|
- **Payments**: Stripe via Laravel Cashier
|
||||||
|
- **Notifications**: Laravel Notification channels — email, WhatsApp (Vonage), SMS (Vonage), push (OneSignal)
|
||||||
|
- **Queue**: Laravel queues with Redis driver (notifications and polling jobs)
|
||||||
|
- **Scheduler**: Laravel scheduler for Fuel Finder API polling and scoring
|
||||||
|
|
||||||
|
## Key commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan serve # Local dev server
|
||||||
|
php artisan queue:work # Process notification jobs
|
||||||
|
php artisan schedule:run # Run scheduled commands (cron every minute)
|
||||||
|
php artisan migrate # Run migrations
|
||||||
|
php artisan test # Run Pest test suite
|
||||||
|
npm run dev # Vite asset watcher
|
||||||
|
```
|
||||||
|
|
||||||
|
## Imports
|
||||||
|
|
||||||
|
@.claude/rules/architecture.md
|
||||||
|
@.claude/rules/database.md
|
||||||
|
@.claude/rules/notifications.md
|
||||||
|
@.claude/rules/scoring.md
|
||||||
|
@.claude/rules/payments.md
|
||||||
|
@.claude/rules/livewire.md
|
||||||
|
@.claude/rules/api-data.md
|
||||||
|
@.claude/rules/testing.md
|
||||||
|
@.claude/rules/code-style.md
|
||||||
68
api-data.md
Normal file
68
api-data.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# External API & Data Sources
|
||||||
|
|
||||||
|
## UK Fuel Finder API (gov.uk) — PRIMARY SOURCE
|
||||||
|
|
||||||
|
- Base URL: `https://api.fuel-finder.service.gov.uk/`
|
||||||
|
- Auth: OAuth 2.0 client credentials (client_id + client_secret → Bearer token)
|
||||||
|
- Token stored in cache with TTL matching expiry minus 60 seconds
|
||||||
|
- Returns: all UK station prices + station metadata
|
||||||
|
- Update frequency: stations report within 30 minutes of price change
|
||||||
|
- Our polling interval: every 15 minutes via scheduler
|
||||||
|
|
||||||
|
### FuelPriceService responsibilities
|
||||||
|
1. Fetch OAuth token (cache it)
|
||||||
|
2. GET all station prices
|
||||||
|
3. Upsert `stations` table with metadata
|
||||||
|
4. Insert new rows into `station_prices` only when price has changed for that station+fuel combo
|
||||||
|
5. Call StationTaggingService to set `is_supermarket` and `brand`
|
||||||
|
6. Dispatch `PricesUpdatedEvent` for downstream processing
|
||||||
|
|
||||||
|
### Deduplication
|
||||||
|
Only insert a new `station_prices` row if price differs from the most recent stored price
|
||||||
|
for that `(station_id, fuel_type)` combination. Avoids row explosion on unchanged prices.
|
||||||
|
|
||||||
|
### Credentials in .env
|
||||||
|
```
|
||||||
|
FUEL_FINDER_CLIENT_ID=
|
||||||
|
FUEL_FINDER_CLIENT_SECRET=
|
||||||
|
FUEL_FINDER_BASE_URL=https://api.fuel-finder.service.gov.uk
|
||||||
|
```
|
||||||
|
|
||||||
|
## Postcodes.io — postcode → lat/lng
|
||||||
|
|
||||||
|
- URL: `https://api.postcodes.io/postcodes/{postcode}`
|
||||||
|
- Free, no API key required
|
||||||
|
- Called once on user registration / when postcode changes
|
||||||
|
- Store resolved `lat` + `lng` on `users` table
|
||||||
|
- Cache postcode lookups for 30 days (postcodes rarely change coordinates)
|
||||||
|
|
||||||
|
## FRED API (St. Louis Fed) — Brent crude direction
|
||||||
|
|
||||||
|
- Series: `DCOILBRENTEU` (daily Brent spot price)
|
||||||
|
- URL: `https://api.stlouisfed.org/fred/series/observations?series_id=DCOILBRENTEU&api_key={key}&sort_order=desc&limit=10&file_type=json`
|
||||||
|
- Free API key required — stored as `FRED_API_KEY` in .env
|
||||||
|
- Fetched once daily via scheduler at 7am
|
||||||
|
- Stored in `brent_prices` table: `(date DATE, price_usd DECIMAL(8,2))`
|
||||||
|
- Only the 5-day trend direction is used by the scoring engine
|
||||||
|
|
||||||
|
## OneSignal — push notifications
|
||||||
|
|
||||||
|
- REST API: `https://oapi.onesignal.com/notifications`
|
||||||
|
- App ID + REST API key stored in .env as `ONESIGNAL_APP_ID`, `ONESIGNAL_API_KEY`
|
||||||
|
- Target by `player_id` (stored in `users.push_token`)
|
||||||
|
- No official Laravel package needed — use Laravel HTTP client (`Http::post(...)`)
|
||||||
|
- Free plan: 10,000 subscribers — sufficient for v1
|
||||||
|
|
||||||
|
## Vonage — WhatsApp + SMS
|
||||||
|
|
||||||
|
- Package: `vonage/client-core` via Composer
|
||||||
|
- Credentials: `VONAGE_KEY`, `VONAGE_SECRET`, `VONAGE_WHATSAPP_FROM` in .env
|
||||||
|
- WhatsApp: Messages API, utility template category (pre-approved)
|
||||||
|
- SMS: SMS API, alphanumeric sender ID "FuelAlert"
|
||||||
|
- All Vonage calls go through NotificationDispatchService — never call Vonage directly from components
|
||||||
|
|
||||||
|
## HTTP client
|
||||||
|
|
||||||
|
Use Laravel's built-in `Http` facade for all external API calls.
|
||||||
|
Always set a timeout: `Http::timeout(10)->get(...)`.
|
||||||
|
Wrap in try/catch — log failures, never let a failed API call crash the scheduler.
|
||||||
53
architecture.md
Normal file
53
architecture.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
## Core principle: fat Services, thin everything else
|
||||||
|
|
||||||
|
All business logic lives in Service classes. Controllers, Livewire components,
|
||||||
|
and console commands are thin orchestrators — they call Services and return results.
|
||||||
|
This keeps the app API-extractable later without a rewrite.
|
||||||
|
|
||||||
|
## Directory structure
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── Console/Commands/ # Scheduler commands (PollFuelPrices, RunScoringEngine)
|
||||||
|
├── Http/Controllers/ # Minimal — auth + Stripe webhook only
|
||||||
|
├── Livewire/ # Classic two-file Livewire components
|
||||||
|
├── Models/ # Eloquent models
|
||||||
|
├── Notifications/ # Laravel Notification classes (multi-channel)
|
||||||
|
├── Services/ # ALL business logic lives here
|
||||||
|
│ ├── FuelPriceService.php # Fuel Finder API polling + storage
|
||||||
|
│ ├── AlertScoringService.php # Fill-up timing recommendation engine
|
||||||
|
│ ├── StationTaggingService.php # Supermarket brand detection
|
||||||
|
│ ├── NotificationDispatchService.php # Tier-aware notification routing
|
||||||
|
│ └── SubscriptionService.php # Cashier/tier helpers
|
||||||
|
└── Jobs/ # Queued jobs (dispatch notifications per user)
|
||||||
|
|
||||||
|
resources/views/
|
||||||
|
├── livewire/ # Livewire Blade templates
|
||||||
|
└── emails/ # Mailable templates
|
||||||
|
|
||||||
|
routes/
|
||||||
|
├── web.php # All web routes (Livewire pages)
|
||||||
|
└── api.php # Empty for now — API added later if needed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Service class conventions
|
||||||
|
|
||||||
|
- 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
|
||||||
|
- Return typed DTOs or Eloquent collections — never raw arrays from Services
|
||||||
|
- Services never dispatch jobs directly — that's the controller/command's job
|
||||||
|
|
||||||
|
## No API yet
|
||||||
|
|
||||||
|
`routes/api.php` stays empty for v1. Do not create API controllers or Sanctum
|
||||||
|
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)
|
||||||
|
|
||||||
|
Use two-file classic Livewire components. Do NOT use Volt single-file syntax.
|
||||||
|
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/`.
|
||||||
47
code-style.md
Normal file
47
code-style.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Code Style
|
||||||
|
|
||||||
|
## PHP
|
||||||
|
|
||||||
|
- PHP 8.2+ features: constructor property promotion, readonly properties, enums, match expressions
|
||||||
|
- Type declarations on all method parameters and return types — no untyped signatures
|
||||||
|
- Named arguments for clarity on calls with 3+ parameters
|
||||||
|
- Enums over string constants: `FuelType::E10`, `Recommendation::Wait`, `NotificationChannel::WhatsApp`
|
||||||
|
- `final` classes for Services and Jobs — they are not designed for extension
|
||||||
|
|
||||||
|
## Laravel conventions
|
||||||
|
|
||||||
|
- Route model binding over manual `findOrFail()` in controllers
|
||||||
|
- Form Requests for any validated input
|
||||||
|
- `config()` helper over `env()` outside of config files — never call `env()` in app code
|
||||||
|
- Events + Listeners for side effects (e.g. `PricesUpdatedEvent` triggers scoring)
|
||||||
|
- Do not use `DB::transaction()` manually — wrap in Service methods where needed
|
||||||
|
|
||||||
|
## Naming
|
||||||
|
|
||||||
|
- Services: `{Noun}Service` — e.g. `AlertScoringService`
|
||||||
|
- Jobs: `{Verb}{Noun}Job` — e.g. `SendFuelAlertJob`, `PollFuelPricesJob`
|
||||||
|
- Events: `{Noun}{PastTense}Event` — e.g. `PricesUpdatedEvent`
|
||||||
|
- Livewire components: `{Context}/{Feature}` — e.g. `Dashboard/FuelRecommendation`
|
||||||
|
- Migrations: descriptive, e.g. `create_station_prices_table`, `add_whatsapp_fields_to_users_table`
|
||||||
|
|
||||||
|
## Formatting
|
||||||
|
|
||||||
|
- 4-space indentation (PSR-12)
|
||||||
|
- Max line length: 120 characters
|
||||||
|
- Run `./vendor/bin/pint` before committing (Laravel Pint, default ruleset)
|
||||||
|
- No commented-out code committed — use git to recover old code
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Never store raw passwords — Bcrypt via Laravel's Hash facade only
|
||||||
|
- Validate and sanitise all user input — postcodes, phone numbers, fuel type selections
|
||||||
|
- Phone numbers: strip non-numeric characters, enforce UK format (07xxx or +447xxx)
|
||||||
|
- OTP codes: 6 digits, expire in 10 minutes, single use only
|
||||||
|
- All Stripe webhook payloads verified via Cashier's built-in signature check
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
- Local: Laravel Valet or `php artisan serve`
|
||||||
|
- Production: IONOS VPS, Nginx + PHP-FPM, MySQL, Redis
|
||||||
|
- Never commit `.env` — `.env.example` must stay up to date with all required keys
|
||||||
|
- Queue driver: `redis` in production, `sync` in testing only
|
||||||
98
database.md
Normal file
98
database.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# Database
|
||||||
|
|
||||||
|
## Engine and conventions
|
||||||
|
|
||||||
|
- MySQL with InnoDB. All schema changes via Laravel migrations only — no raw DDL.
|
||||||
|
- All models use Eloquent. No raw DB:: queries except for complex aggregations.
|
||||||
|
- Table names: plural snake_case. Column names: snake_case.
|
||||||
|
- Always define `$fillable` or `$guarded` on models — never leave both empty.
|
||||||
|
- Use `->comment()` on migration columns for non-obvious fields.
|
||||||
|
|
||||||
|
## Core tables
|
||||||
|
|
||||||
|
### users
|
||||||
|
Standard Laravel users table + additions:
|
||||||
|
- `postcode` VARCHAR(8) — user's home postcode, used for nearby station lookup
|
||||||
|
- `lat` / `lng` DECIMAL(10,7) — resolved from postcode via postcodes.io on registration
|
||||||
|
- `whatsapp_number` VARCHAR(20) NULLABLE — verified mobile, set after OTP flow
|
||||||
|
- `whatsapp_verified_at` TIMESTAMP NULLABLE
|
||||||
|
- `push_token` VARCHAR(255) NULLABLE — OneSignal player ID
|
||||||
|
|
||||||
|
### subscriptions
|
||||||
|
Managed by Laravel Cashier — do not hand-edit this table.
|
||||||
|
Tier is read via `$user->subscribed('basic')`, `->subscribed('plus')`, `->subscribed('pro')`.
|
||||||
|
|
||||||
|
### station_prices (HIGH VOLUME — partitioned)
|
||||||
|
```
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT
|
||||||
|
station_id VARCHAR(64) — Fuel Finder API station identifier
|
||||||
|
fuel_type ENUM('e10','e5','diesel','super_diesel','b10','hvo')
|
||||||
|
price_pence SMALLINT UNSIGNED — e.g. 14523 = 145.23p (store as integer × 100)
|
||||||
|
is_supermarket TINYINT(1) DEFAULT 0
|
||||||
|
brand VARCHAR(64) NULLABLE
|
||||||
|
recorded_at DATETIME
|
||||||
|
|
||||||
|
INDEX (station_id, fuel_type, recorded_at)
|
||||||
|
INDEX (recorded_at)
|
||||||
|
PARTITION BY RANGE (YEAR(recorded_at) * 100 + MONTH(recorded_at))
|
||||||
|
```
|
||||||
|
Partition monthly. Use `recorded_at` not `created_at` — it reflects actual price time.
|
||||||
|
Never store floats for prices — always pence as SMALLINT (price × 100).
|
||||||
|
|
||||||
|
### stations (reference/cache)
|
||||||
|
```
|
||||||
|
station_id VARCHAR(64) PRIMARY KEY — from Fuel Finder API
|
||||||
|
name VARCHAR(128)
|
||||||
|
brand VARCHAR(64) NULLABLE
|
||||||
|
lat DECIMAL(10,7)
|
||||||
|
lng DECIMAL(10,7)
|
||||||
|
postcode VARCHAR(8)
|
||||||
|
is_supermarket TINYINT(1) DEFAULT 0
|
||||||
|
amenities JSON NULLABLE
|
||||||
|
last_seen_at DATETIME
|
||||||
|
```
|
||||||
|
Refreshed on each Fuel Finder poll. `is_supermarket` set by StationTaggingService.
|
||||||
|
|
||||||
|
### alerts (sent notification log)
|
||||||
|
```
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT
|
||||||
|
user_id BIGINT UNSIGNED FK users.id
|
||||||
|
alert_type ENUM('fill_up_now','wait','watchdog','price_drop')
|
||||||
|
channel ENUM('email','whatsapp','sms','push')
|
||||||
|
signal_strength TINYINT — 1=weak, 2=medium, 3=strong
|
||||||
|
payload JSON — recommendation data snapshot
|
||||||
|
sent_at DATETIME
|
||||||
|
INDEX (user_id, sent_at)
|
||||||
|
```
|
||||||
|
|
||||||
|
### scoring_results (daily per-user cache)
|
||||||
|
```
|
||||||
|
user_id BIGINT UNSIGNED FK users.id
|
||||||
|
scored_at DATE
|
||||||
|
recommendation ENUM('fill_up','wait','no_signal')
|
||||||
|
confidence TINYINT — 0–100
|
||||||
|
signals JSON — breakdown of each signal used
|
||||||
|
local_avg_pence SMALLINT UNSIGNED
|
||||||
|
trend_direction ENUM('falling','rising','flat')
|
||||||
|
expires_at DATETIME
|
||||||
|
PRIMARY KEY (user_id, scored_at)
|
||||||
|
```
|
||||||
|
|
||||||
|
### otp_verifications
|
||||||
|
```
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT
|
||||||
|
user_id BIGINT UNSIGNED FK users.id
|
||||||
|
channel ENUM('whatsapp','sms')
|
||||||
|
phone_number VARCHAR(20)
|
||||||
|
code CHAR(6)
|
||||||
|
expires_at DATETIME
|
||||||
|
used_at DATETIME NULLABLE
|
||||||
|
INDEX (user_id, expires_at)
|
||||||
|
```
|
||||||
|
OTP codes expire after 10 minutes. Mark `used_at` on success — never delete rows.
|
||||||
|
|
||||||
|
## Supermarket brands (StationTaggingService)
|
||||||
|
|
||||||
|
Match station `name` (case-insensitive) against:
|
||||||
|
`['tesco', 'asda', 'morrisons', 'sainsbury', 'aldi', 'lidl', 'costco']`
|
||||||
|
Set `is_supermarket = 1` and normalise `brand` on match.
|
||||||
61
livewire.md
Normal file
61
livewire.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# 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"
|
||||||
|
---
|
||||||
68
notifications.md
Normal file
68
notifications.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# Notifications
|
||||||
|
|
||||||
|
## Tier-to-channel mapping
|
||||||
|
|
||||||
|
| Tier | Price | Email | Push (PWA) | WhatsApp | SMS |
|
||||||
|
|------------|--------|-------|------------|----------|-----------|
|
||||||
|
| Free | £0 | ✓ weekly digest | ✗ | ✗ | ✗ |
|
||||||
|
| Basic | £0.99 | ✓ daily | ✓ daily | ✓ daily | ✗ |
|
||||||
|
| Plus | £2.49 | ✓ | ✓ | ✓ | ✓ max 1/day triggered |
|
||||||
|
| Pro | £3.99 | ✓ | ✓ | ✓ | ✓ max 3/day triggered |
|
||||||
|
|
||||||
|
## NotificationDispatchService
|
||||||
|
|
||||||
|
Reads user tier via Cashier, returns allowed channels:
|
||||||
|
```php
|
||||||
|
public function channelsFor(User $user): array
|
||||||
|
// Returns e.g. ['mail', 'vonage-whatsapp'] for Basic tier
|
||||||
|
// Returns ['mail', 'vonage-whatsapp', 'vonage-sms'] for Plus+
|
||||||
|
```
|
||||||
|
|
||||||
|
Never fire SMS unless confidence ≥ 70 AND user is Plus/Pro.
|
||||||
|
Never fire more than 1 notification per user per day unless they are Pro tier.
|
||||||
|
Log every sent notification to `alerts` table.
|
||||||
|
|
||||||
|
## Laravel Notification class
|
||||||
|
|
||||||
|
One `FuelPriceAlert` notification class with `via()` returning channels based on tier.
|
||||||
|
Subject line / message copy adapts based on `recommendation` and `confidence`.
|
||||||
|
|
||||||
|
## WhatsApp (Vonage)
|
||||||
|
|
||||||
|
- Provider: Vonage Messages API (WhatsApp channel)
|
||||||
|
- PHP package: `vonage/client`
|
||||||
|
- Template: utility category (one-way alert) — pre-approved template required by Meta
|
||||||
|
- Opt-in: phone number + OTP flow (see otp_verifications table)
|
||||||
|
- User must have `whatsapp_verified_at` set — never send to unverified numbers
|
||||||
|
- Cost: ~0.3–0.5p per message (utility rate)
|
||||||
|
- Message format: keep under 160 chars. Include station name, price, and recommendation reason.
|
||||||
|
|
||||||
|
## SMS (Vonage — Plus/Pro only)
|
||||||
|
|
||||||
|
- Same Vonage client, SMS channel
|
||||||
|
- Triggered only (not daily) — fires when signal strength ≥ 2 AND price event warrants it
|
||||||
|
- Plus: max 8 SMS/month (enforced via alerts table count)
|
||||||
|
- Pro: max 30 SMS/month
|
||||||
|
- Cost: ~3.5p per message UK
|
||||||
|
|
||||||
|
## Email
|
||||||
|
|
||||||
|
- Driver: Mailgun (configured in .env)
|
||||||
|
- Free tier: weekly digest every Monday 8am
|
||||||
|
- Basic+: daily summary if recommendation is not `no_signal`
|
||||||
|
- Use Mailable classes, not raw Mail::send
|
||||||
|
- Templates in `resources/views/emails/`
|
||||||
|
|
||||||
|
## Push notifications (OneSignal)
|
||||||
|
|
||||||
|
- Free plan: up to 10,000 subscribers
|
||||||
|
- Player ID stored in `users.push_token`
|
||||||
|
- Use OneSignal REST API — no official Laravel package needed, simple HTTP call
|
||||||
|
- PWA service worker setup required (separate task)
|
||||||
|
- Basic+: daily push alongside email
|
||||||
|
|
||||||
|
## Queue
|
||||||
|
|
||||||
|
All notifications are dispatched as queued jobs — never send synchronously.
|
||||||
|
Use `FuelAlertNotificationJob` dispatched per user.
|
||||||
|
Queue: `notifications` (separate from default queue for priority control).
|
||||||
54
payments.md
Normal file
54
payments.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Payments & Subscriptions
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
Laravel Cashier (Stripe). Never implement custom billing logic — use Cashier methods.
|
||||||
|
|
||||||
|
## Stripe products
|
||||||
|
|
||||||
|
Three recurring subscription products (monthly):
|
||||||
|
- `basic` — £0.99/mo
|
||||||
|
- `plus` — £2.49/mo
|
||||||
|
- `pro` — £3.99/mo
|
||||||
|
|
||||||
|
Price IDs stored in `config/services.php` under `stripe.prices.*`, loaded from .env:
|
||||||
|
```
|
||||||
|
STRIPE_PRICES_BASIC=price_xxx
|
||||||
|
STRIPE_PRICES_PLUS=price_xxx
|
||||||
|
STRIPE_PRICES_PRO=price_xxx
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tier helpers (SubscriptionService)
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function tier(User $user): string
|
||||||
|
// Returns 'free' | 'basic' | 'plus' | 'pro'
|
||||||
|
|
||||||
|
public function canReceiveSms(User $user): bool
|
||||||
|
// true if tier is plus or pro
|
||||||
|
|
||||||
|
public function smsRemainingThisMonth(User $user): int
|
||||||
|
// checks alerts table count for current month
|
||||||
|
```
|
||||||
|
|
||||||
|
Never check tier inline in components or notification classes — always use SubscriptionService.
|
||||||
|
|
||||||
|
## Cashier conventions
|
||||||
|
|
||||||
|
- Billable model: `User` (add `use Billable` trait)
|
||||||
|
- Webhook route: `POST /stripe/webhook` — handled by Cashier automatically
|
||||||
|
- Webhook secret in `.env` as `STRIPE_WEBHOOK_SECRET`
|
||||||
|
- Always handle `customer.subscription.deleted` to downgrade user to free tier
|
||||||
|
- Trial: none for v1
|
||||||
|
|
||||||
|
## Upgrade / downgrade flow
|
||||||
|
|
||||||
|
- User upgrades in account settings Livewire component
|
||||||
|
- Swap plan with `$user->subscription()->swap($newPriceId)`
|
||||||
|
- Cashier handles proration automatically
|
||||||
|
- On downgrade to free: cancel subscription, remove WhatsApp/SMS notification preference
|
||||||
|
|
||||||
|
## Stripe test mode
|
||||||
|
|
||||||
|
Use Stripe test keys in local `.env`. Never commit real Stripe keys.
|
||||||
|
Test cards: 4242424242424242 (success), 4000000000000002 (decline).
|
||||||
76
scoring.md
Normal file
76
scoring.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Scoring Engine (AlertScoringService)
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Produces a "fill up now or wait?" recommendation per user based on their local
|
||||||
|
station history. Output is one of: `fill_up`, `wait`, `no_signal`.
|
||||||
|
Never guess — stay silent (no_signal) when signals conflict or data is insufficient.
|
||||||
|
|
||||||
|
## The 4 signals (in priority order)
|
||||||
|
|
||||||
|
### Signal 1 — Local price trend (HIGHEST WEIGHT)
|
||||||
|
- Query `station_prices` for user's nearest 5 stations (within 5km of user lat/lng)
|
||||||
|
- Use last 14 days of history for `e10` (or user's preferred fuel type)
|
||||||
|
- Calculate 3-day rolling average vs 7-day rolling average
|
||||||
|
- **Falling**: 3-day avg < 7-day avg by ≥ 0.5p → positive wait signal
|
||||||
|
- **Rising**: 3-day avg > 7-day avg by ≥ 0.5p → fill_up signal
|
||||||
|
- **Flat**: difference < 0.5p → neutral, no signal
|
||||||
|
- Weight: 40 points max
|
||||||
|
|
||||||
|
### Signal 2 — Supermarket anchor effect (HIGH WEIGHT)
|
||||||
|
- Find nearest supermarket station (is_supermarket = 1) within 10km
|
||||||
|
- Check if supermarket cut price in last 48 hours (> 1p drop)
|
||||||
|
- Check if nearest non-supermarket stations have NOT yet followed
|
||||||
|
- If supermarket cut AND independents haven't moved → strong wait signal
|
||||||
|
- Weight: 35 points max
|
||||||
|
|
||||||
|
### Signal 3 — Day-of-week pattern (MEDIUM WEIGHT — needs 8+ weeks data)
|
||||||
|
- Per station: average price by day-of-week over last 90 days
|
||||||
|
- Only activate if station has 56+ days of history
|
||||||
|
- If today is statistically 1.5p+ cheaper than weekly average → mild fill_up
|
||||||
|
- If today is statistically 1.5p+ more expensive → mild wait
|
||||||
|
- Weight: 15 points max
|
||||||
|
|
||||||
|
### Signal 4 — Brent crude direction (LOW WEIGHT)
|
||||||
|
- Fetched daily from FRED API, stored in a simple `brent_prices` table
|
||||||
|
- 5-day trend: rising ≥ 3% → mild fill_up pressure; falling ≥ 3% → mild wait
|
||||||
|
- Weight: 10 points max
|
||||||
|
|
||||||
|
## Confidence thresholds
|
||||||
|
|
||||||
|
- Score 70–100: strong signal → fire recommendation + notification
|
||||||
|
- Score 40–69: weak signal → show in dashboard only, no push/SMS/WhatsApp
|
||||||
|
- Score 0–39: no_signal → stay silent entirely
|
||||||
|
|
||||||
|
**Only send notifications when confidence ≥ 70.** Never spam.
|
||||||
|
|
||||||
|
## Output (stored in scoring_results)
|
||||||
|
|
||||||
|
```php
|
||||||
|
[
|
||||||
|
'recommendation' => 'wait', // fill_up | wait | no_signal
|
||||||
|
'confidence' => 78, // 0-100
|
||||||
|
'signals' => [
|
||||||
|
'trend' => ['direction' => 'falling', 'points' => 32],
|
||||||
|
'supermarket' => ['triggered' => true, 'points' => 35],
|
||||||
|
'day_pattern' => ['triggered' => false, 'points' => 0],
|
||||||
|
'brent' => ['direction' => 'flat', 'points' => 0],
|
||||||
|
],
|
||||||
|
'local_avg_pence' => 14380, // 143.80p
|
||||||
|
'trend_delta' => -2.3, // pence change over 7 days
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Human-readable reason strings
|
||||||
|
|
||||||
|
Always generate a plain-English reason for the recommendation:
|
||||||
|
- "Prices near you have been falling for 6 days. Tesco {station} cut 3p yesterday — independents usually follow within 48 hours."
|
||||||
|
- "Prices are rising in your area — filling up today avoids paying more later."
|
||||||
|
- "No clear pattern this week — fill up at the cheapest station near you now."
|
||||||
|
|
||||||
|
Reason strings are stored in `scoring_results.signals` JSON and shown in the UI and notifications.
|
||||||
|
|
||||||
|
## Accuracy self-tracking
|
||||||
|
|
||||||
|
After 3 days, check if `wait` recommendation was correct (prices did fall further).
|
||||||
|
Store outcome in `scoring_results` for future display: "This signal has been right X% of the time in your area."
|
||||||
48
testing.md
Normal file
48
testing.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Testing
|
||||||
|
|
||||||
|
## Framework
|
||||||
|
|
||||||
|
Pest PHP. Never use PHPUnit test classes directly — always Pest syntax.
|
||||||
|
|
||||||
|
## Test structure
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/
|
||||||
|
├── Feature/
|
||||||
|
│ ├── Scoring/AlertScoringServiceTest.php
|
||||||
|
│ ├── Notifications/NotificationDispatchTest.php
|
||||||
|
│ ├── Payments/SubscriptionTest.php
|
||||||
|
│ └── Livewire/DashboardTest.php
|
||||||
|
└── Unit/
|
||||||
|
├── Services/FuelPriceServiceTest.php
|
||||||
|
└── Services/StationTaggingServiceTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- Use `RefreshDatabase` trait on Feature tests
|
||||||
|
- Factory-first: all test data via Eloquent factories — never raw DB inserts
|
||||||
|
- Mock external APIs (Fuel Finder, Vonage, OneSignal, FRED) — never hit live APIs in tests
|
||||||
|
- Use `Http::fake()` for all outbound HTTP in tests
|
||||||
|
- Stripe: use Cashier's built-in test helpers, never hit Stripe API in tests
|
||||||
|
|
||||||
|
## What to test
|
||||||
|
|
||||||
|
- AlertScoringService: all signal combinations, confidence thresholds, no_signal cases
|
||||||
|
- NotificationDispatchService: correct channels returned per tier
|
||||||
|
- SubscriptionService: tier detection, SMS limit counting
|
||||||
|
- Livewire components: use `Livewire::test()` for interaction testing
|
||||||
|
- Stripe webhooks: test `customer.subscription.deleted` downgrades user correctly
|
||||||
|
|
||||||
|
## Running tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan test # Full suite
|
||||||
|
php artisan test --filter=Scoring # Single test class
|
||||||
|
php artisan test --parallel # Parallel (faster)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "tests/**/*.php"
|
||||||
|
---
|
||||||
Reference in New Issue
Block a user