Skip to content

Commit 962a495

Browse files
committed
fix: wrong prepaid badges + add maintenance back pane
1 parent e753e7a commit 962a495

10 files changed

Lines changed: 890 additions & 49 deletions

File tree

CLAUDE.md

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
55
## Development Commands
66

77
### Environment Setup
8+
89
The project supports two development environments:
910

1011
**Nix / devenv (primary)**`.envrc` uses `direnv` + `use flake`. With direnv allowed, the
1112
toolchain (PHP 8.3, Node) is provided automatically and a `sail` alias is exported.
1213

1314
**Laravel Sail (Docker)** — the classic path:
15+
1416
```bash
1517
# Initial setup
1618
cp .env.example .env # or: mv .env.example .env
@@ -31,6 +33,7 @@ npm run build
3133
> (and the `DB_*` host vars) if you run against the Sail database.
3234
3335
### Development Commands
36+
3437
```bash
3538
# Frontend development
3639
npm run dev # Start Vite dev server with hot reload
@@ -54,6 +57,7 @@ npm run build # Build production assets
5457
```
5558

5659
### Event State Management
60+
5761
```bash
5862
# Useful for development/testing - sets up an event in a given state.
5963
# Accepted states: pre-order | order | event-order | closed (open = legacy alias)
@@ -62,6 +66,7 @@ php artisan event:state closed # Orders closed
6266
```
6367

6468
### Other Useful Artisan Commands
69+
6570
```bash
6671
php artisan badges:print # Print all unprinted badges
6772
php artisan badges:unprint # Revert badges to unprinted
@@ -79,6 +84,7 @@ php artisan tse:update-state # Fiskaly TSE state management (also t
7984
## Architecture Overview
8085

8186
### Core System
87+
8288
This is a **Laravel 11 + Inertia.js + Vue 3** application for managing fursuit badge
8389
registration at the Eurofurence convention. It covers the full lifecycle from badge creation
8490
through on-site pickup, with integrated payment processing (wallet + SumUp), German fiscal
@@ -93,6 +99,7 @@ determine when badges can be ordered. Event state is computed dynamically (see
9399

94100
**Badge Lifecycle**: Badges use Spatie Model States with two parallel state machines
95101
(in `app/Models/Badge/`):
102+
96103
- Payment states (`State_Payment/`): `Unpaid``Paid`
97104
- Fulfillment states (`State_Fulfillment/`): `Pending``Processing``ReadyForPickup``PickedUp`
98105
(a `Printed` state also exists; transitions live in the `Transitions/` subfolders, and POS
@@ -109,6 +116,7 @@ wraps this with its own models, services, and states.
109116
### Application Structure
110117

111118
**Multi-Interface Design**:
119+
112120
- `/` — Public fursuit badge registration interface (Vue/Inertia)
113121
- `/admin` — Filament admin panel for staff
114122
- `/pos` — Point-of-sale system for on-site operations (machine + staff PIN auth)
@@ -120,6 +128,7 @@ wraps this with its own models, services, and states.
120128
cert/signing), `catch-em-all.php`, `gallery.php`, `api.php`, `channels.php`, `console.php`.
121129

122130
**Key Directories**:
131+
123132
- `app/Models/Badge/` — Badge model with the two state machines
124133
- `app/Models/Fursuit/` — Fursuit management with approval workflow
125134
- `app/Models/FCEA/` — Catch-Em-All catch/log/ranking models
@@ -136,11 +145,13 @@ cert/signing), `catch-em-all.php`, `gallery.php`, `api.php`, `channels.php`, `co
136145
`CatchEmAll/`, `FCEA/`, `Gallery/`, `Statistics/`, `Auth/`
137146

138147
### State Management Pattern
148+
139149
The system heavily uses **Spatie Model States** for complex entity lifecycles (Badges,
140150
Fursuits, Checkouts). When working with these entities, always consider the current state and
141151
the available transitions rather than mutating state properties directly.
142152

143153
### Event-Driven Architecture
154+
144155
- Badge creation triggers notifications
145156
- State transitions are logged via `spatie/laravel-activitylog`
146157
- Background jobs handle printing, ranking updates, and receipt generation
@@ -149,44 +160,51 @@ the available transitions rather than mutating state properties directly.
149160
(every 15 min), and stuck-print-job checks (every 3 min)
150161

151162
### Badge Generation System
163+
152164
- Badges are rendered as PDFs using custom badge classes in `app/Badges/`
153165
- Each badge type (e.g. `EF28_Badge`, `EF29_Badge`) extends `BadgeBase_V1` (in `Bases/`) and
154166
defines positioning/fonts; reusable field/layout helpers live in `app/Badges/Components/`
155167
- PDF generation uses `mpdf/mpdf`; images are processed with Intervention/Imagine and stored on S3
156168
- QR codes are generated for the Catch-Em-All game integration
157169

158170
### Printing System
171+
159172
- On-site printing is driven through **QZ Tray** (browser-to-printer bridge); the POS exposes
160173
certificate and signing endpoints (`pos-auth.php`) for QZ
161174
- Print jobs and printer state are modeled in `app/Domain/Printing/` with their own enums
162175
(`PrintJobStatusEnum`, `PrinterStatusEnum`, etc.) and queued jobs (`PrintBadgeJob`, `BatchPrintJob`)
163176
- See `PRINTING_SYSTEM.md` and the `PRINTING_SYSTEM_IMPROVEMENTS*.md` docs for design notes
164177

165178
### Fiscal Compliance (German market)
179+
166180
- SumUp card readers handle on-site card payments (`SumUpReader` model)
167181
- A Fiskaly **TSE** (Technical Security System) signs transactions; managed via the `tse:*`
168182
commands. See `TSE.md`
169183
- DSFinV-K exports are produced by `dsfin:generate-direct-export`. See `DSFinV_K_2_4.pdf`
170184

171185
### Database Design Notes
186+
172187
- Default connection is SQLite (`.env.example`); Sail provisions MySQL
173188
- Events use computed state (no `state` column) based on date comparisons
174189
- Badges have `custom_id` for human-readable identification
175190
- Soft deletes are used throughout for audit trails
176191
- Activity logging tracks all important changes
177192

178193
### Testing Approach
194+
179195
- Uses **Pest PHP** testing framework (`tests/Feature`, `tests/Unit`)
180196
- Feature tests cover critical user journeys (badge flow, checkout, printing, notifications,
181197
event order state)
182198
- Database factory patterns for test data creation
183199
- Event state can be manipulated via the `event:state` command for testing
184200

185201
### Observability
202+
186203
- **Sentry** (`sentry/sentry-laravel`) for error tracking
187204
- **Clockwork** (`itsgoingd/clockwork`) for local request profiling/debugging
188205

189206
### Development Environment
207+
190208
- **Nix / devenv** (`.envrc` + flake) or **Laravel Sail** for Docker-based development
191209
- **Laravel Octane** (Swoole) is available as the production app server
192210
- **Laravel Reverb** provides WebSockets; the frontend uses `laravel-echo`
@@ -196,30 +214,39 @@ the available transitions rather than mutating state properties directly.
196214

197215
### Domain Gotchas
198216

199-
**Prepaid badges: "can create" vs. "free badges left" are two different things.** There are two
200-
intentionally different prepaid calculations — do **not** merge their values:
201-
- `App\Policies\BadgePolicy::create()` uses `prepaid_badges − ordered` (raw allowance) to decide
202-
whether a user may create a badge **at all** (the badge may end up **paid**). A prepaid
203-
allowance bypasses the closed order-window restriction.
204-
- `App\Models\User::getPrepaidBadgesLeft()` subtracts an additional `1` after `order_starts_at`
205-
(the included free badge is "no longer honored" / becomes paid) and answers **how many free
206-
badges remain**; it drives badge **pricing** in `BadgeController@store`.
207-
208-
Because of this, a user can have `getPrepaidBadgesLeft() == 0` yet still be allowed to order an
209-
additional **paid** badge. The public Welcome page (`Welcome.vue`) therefore gates its
210-
create/customize button on the authoritative `canCreate` (`Gate::allows('create', Badge::class)`)
211-
passed by `WelcomeController`, not on `prepaidBadgesLeft`. `PrepaidBadgePriceConsistencyTest`
212-
locks in that paid additional badges stay orderable once the free allowance is used.
213-
See `docs/bugfix-01-result.md` and `docs/handoff.md`.
217+
**Prepaid badges: "can create" vs. "free badges left" are two different things.** Two related
218+
prepaid calculations — do **not** merge their values:
219+
220+
- `App\Policies\BadgePolicy::create()` uses `prepaid_badges − ordered` to decide whether a user
221+
may create a badge **at all** (the badge may end up **paid**). A prepaid allowance bypasses the
222+
closed order-window restriction.
223+
- `App\Models\User::getPrepaidBadgesLeft()` = `prepaid_badges − orderedMainBadges` (only main
224+
badges count; spare copies — `extra_copy_of != null` — are always separately paid and never
225+
consume the allowance). It answers **how many free badges remain** and drives badge **pricing**
226+
in `BadgeController@store`.
227+
228+
The **full** `prepaid_badges` entitlement is honored as free. (Until bugfix-03 this method also
229+
deducted an extra `1` after `order_starts_at` — "the included badge is no longer honored" — which
230+
wrongly **charged** the user's last prepaid badge; that `−1` is gone. See
231+
`docs/bugfix-03-fix.md`.)
232+
233+
A user with `getPrepaidBadgesLeft() == 0` can still order an additional **paid** badge while the
234+
order window is open. The public Welcome page (`Welcome.vue`) therefore gates its create/customize
235+
button on the authoritative `canCreate` (`Gate::allows('create', Badge::class)`) passed by
236+
`WelcomeController`, not on `prepaidBadgesLeft`. `PrepaidBadgePriceConsistencyTest` locks in the
237+
pricing; `FreeBadgeRepairService` (admin → Maintenance → DB Service) repairs already-wrongly-charged
238+
badges. See `docs/bugfix-01-result.md`, `docs/bugfix-03-fix.md`, and `docs/handoff.md`.
214239

215240
### Migrations must be idempotent
241+
216242
Migrations run as an ArgoCD PreSync **Job** (`php artisan migrate --force`) against MySQL, and
217243
**MySQL DDL is not transactional** — a migration that fails partway leaves its applied steps in
218244
place but is never recorded, so the next run hits "Duplicate column / key / table" and blocks every
219245
later migration (this caused a dev outage; see `docs/bugfix-02-*.md`).
220246

221247
Every migration must therefore be safe to re-run. Guard each operation with
222248
`App\Support\Migrations\SchemaGuard`:
249+
223250
- `Schema::create('t', …)` → wrap in `if (SchemaGuard::missingTable('t')) { … }` (and use
224251
`Schema::dropIfExists` in `down()`).
225252
- add/drop column → `SchemaGuard::missingColumn(...)` / `SchemaGuard::hasColumn(...)`.
@@ -232,6 +259,7 @@ Every migration must therefore be safe to re-run. Guard each operation with
232259
`tests/Feature/MigrationIdempotencyTest.php` locks in the helper's behaviour.
233260

234261
### Additional Documentation
262+
235263
The repo root contains focused design docs worth consulting: `CATCH.md` (Catch-Em-All game),
236264
`PRINTING_SYSTEM*.md` (printing), `TSE.md` + `zebra.md` (fiscal/printer hardware),
237265
`openapi.yml` (API spec), and `README.md` (setup). Bugfix write-ups live in `docs/`.

app/Filament/Pages/DbService.php

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<?php
2+
3+
namespace App\Filament\Pages;
4+
5+
use App\Models\Event;
6+
use App\Services\FreeBadgeRepairService;
7+
use Filament\Notifications\Notification;
8+
use Filament\Pages\Page;
9+
10+
class DbService extends Page
11+
{
12+
protected static ?string $navigationIcon = 'heroicon-o-wrench-screwdriver';
13+
14+
protected static ?string $navigationGroup = 'Maintenance';
15+
16+
protected static ?string $navigationLabel = 'DB Service';
17+
18+
protected static ?string $title = 'DB Service';
19+
20+
protected static string $view = 'filament.pages.db-service';
21+
22+
/** Preview report for the "Fix free badges" action (null until generated). */
23+
public ?array $freeBadgeReport = null;
24+
25+
/** Result summary after the fix has been applied (null until applied). */
26+
public ?array $freeBadgeResult = null;
27+
28+
public bool $reviewingFreeBadges = false;
29+
30+
/**
31+
* Restrict the whole Maintenance group + this page to admins. The panel itself also admits
32+
* reviewers (User::canAccessPanel), so this extra gate is required.
33+
*/
34+
public static function canAccess(): bool
35+
{
36+
return (bool) (auth()->user()?->is_admin);
37+
}
38+
39+
public static function shouldRegisterNavigation(): bool
40+
{
41+
return (bool) (auth()->user()?->is_admin);
42+
}
43+
44+
/**
45+
* Step 1 — build a non-mutating preview of the badges that would be fixed.
46+
*/
47+
public function previewFreeBadgeFix(): void
48+
{
49+
$this->freeBadgeResult = null;
50+
$this->freeBadgeReport = app(FreeBadgeRepairService::class)->preview(Event::getActiveEvent());
51+
$this->reviewingFreeBadges = true;
52+
53+
if ($this->freeBadgeReport['affected_badge_count'] === 0) {
54+
Notification::make()
55+
->title('Nothing to fix')
56+
->body('No wrongly-charged prepaid badges were found for the current event.')
57+
->success()
58+
->send();
59+
}
60+
}
61+
62+
/**
63+
* Step 2 — apply the fix after the admin confirms the preview.
64+
*/
65+
public function applyFreeBadgeFix(): void
66+
{
67+
$result = app(FreeBadgeRepairService::class)->repair(Event::getActiveEvent(), auth()->user());
68+
69+
$this->freeBadgeResult = $result;
70+
$this->freeBadgeReport = null;
71+
$this->reviewingFreeBadges = false;
72+
73+
if ($result['success']) {
74+
Notification::make()
75+
->title('Fix applied')
76+
->body("Converted {$result['fixed_badge_count']} badge(s) for {$result['fixed_user_count']} user(s) to free.")
77+
->success()
78+
->send();
79+
} else {
80+
Notification::make()
81+
->title('Fix failed')
82+
->body($result['error'] ?? 'Unknown error.')
83+
->danger()
84+
->send();
85+
}
86+
}
87+
88+
public function cancelFreeBadgeFix(): void
89+
{
90+
$this->reviewingFreeBadges = false;
91+
$this->freeBadgeReport = null;
92+
}
93+
94+
public function resetFreeBadgeFix(): void
95+
{
96+
$this->freeBadgeReport = null;
97+
$this->freeBadgeResult = null;
98+
$this->reviewingFreeBadges = false;
99+
}
100+
101+
/**
102+
* Best-effort displayable image URL, used by the Blade view.
103+
*/
104+
public function imageUrl(?string $path): ?string
105+
{
106+
return app(FreeBadgeRepairService::class)->imageUrl($path);
107+
}
108+
109+
public function formatEuro(int $cents): string
110+
{
111+
return ''.number_format($cents / 100, 2);
112+
}
113+
}

app/Models/User.php

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -104,17 +104,18 @@ public function getPrepaidBadgesLeft($eventId = null): int
104104
return 0;
105105
}
106106

107+
// The user's full prepaid entitlement is honored as free badges. (Historically this
108+
// deducted an extra 1 after order_starts_at — "the included badge is no longer honored" —
109+
// which wrongly charged the user's last prepaid badge. See docs/bugfix-03-fix.md.)
107110
$prepaidBadges = $eventUser->prepaid_badges;
108111

109-
// After order start date, deduct 1 free badge (no longer honored)
110-
if ($event->order_starts_at && now() > $event->order_starts_at) {
111-
$prepaidBadges = max(0, $prepaidBadges - 1);
112-
}
113-
112+
// Only main badges consume the prepaid allowance; spare copies are always separately
113+
// paid (extra_copy_of !== null) and must not eat into the free entitlement.
114114
$orderedBadges = $this->badges()
115115
->whereHas('fursuit.event', function ($query) use ($event) {
116116
$query->where('id', $event->id);
117117
})
118+
->whereNull('extra_copy_of')
118119
->count();
119120

120121
return max(0, $prepaidBadges - $orderedBadges);

0 commit comments

Comments
 (0)