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