Compare commits

..

20 Commits

Author SHA1 Message Date
Nyan Lin Paing 4f0f20659d Return EvCompany logo as a full URL in the API resource
PHP Tests / php-tests (push) Has been cancelled
EvCompanyResource returned the raw disk-relative path stored by
Filament's FileUpload (e.g. "logos/xxx.png"), not something API
consumers can render directly.

- EvCompany::logoUrl() builds an absolute URL from the configured
  filesystem disk, guarding against a disk (e.g. s3) that already
  returns an absolute URL so it isn't double-prefixed.
- EvCompanyResource now exposes that as 'logo' instead of the raw path.
2026-08-23 23:59:13 +07:00
Nyan Lin Paing 98dacef556 Fix asset URLs generated as http:// behind staging's reverse proxy
PHP Tests / php-tests (push) Has been cancelled
Staging terminates SSL at a reverse proxy in front of the app, but
Laravel had no trustProxies() configured, so it never saw the request
as HTTPS and generated http:// asset URLs on the https:// page.
Browsers block that as mixed content, which silently broke every
JS-enhanced Filament field (FileUpload, Textarea, etc.) — e.g. the
Ev Company logo field falling back to a bare native file input.

- bootstrap/app.php: trust the proxy via X-Forwarded-* headers.
- AppServiceProvider: force the https scheme when APP_URL is https,
  as a fallback in case the forwarded header is ever missing.
2026-08-23 23:19:44 +07:00
Nyan Lin Paing da6d51b7b2 chmod storage and bootstrap/cache to 775
PHP Tests / php-tests (push) Has been cancelled
2026-08-23 23:02:41 +07:00
Nyan Lin Paing 0e55e36cea Add Bookings & Revenue reporting module
PHP Tests / php-tests (push) Has been cancelled
New modules/reporting Filament page: filterable bookings table (travel
date range, status, route, channel) with CSV/Excel export. Report
columns include booking ref, route, passenger name/count, price,
best-payment status/amount, and driver info, plus a TOTAL row summing
passenger count, price, and payment amount in both export formats.

- BookingsRevenueExport backs both CSV and XLSX via maatwebsite/excel
  ^4.0 (the only version compatible with PHP 8.5; 3.1.x caps
  phpoffice/phpspreadsheet below 8.5).
- CSV export writes a UTF-8 BOM so non-Latin passenger names (Burmese)
  open correctly in Excel.
- New view_reports permission (super_admin/admin/support) gates the
  page; new indexes on bookings.travel_date/status/created_by_channel
  and payments.completed_at support the report's filters.
2026-08-23 22:32:49 +07:00
Nyan Lin Paing da9cd9bbe0 add sms sending feat 2026-08-23 20:44:52 +07:00
Nyan Lin Paing 41c9454334 modify booking response data 2026-08-23 14:51:10 +07:00
Nyan Lin Paing fa908cdcaf add notes/remark and refactor round-trip
PHP Tests / php-tests (push) Has been cancelled
2026-08-22 21:43:41 +07:00
Nyan Lin Paing 894352b43f fix ci/cd testing failure
PHP Tests / php-tests (push) Successful in 4m42s
2026-08-20 20:49:28 +07:00
Nyan Lin Paing 1aeb57f130 Update .env.example
PHP Tests / php-tests (push) Failing after 4m0s
2026-08-20 20:23:48 +07:00
Nyan Lin Paing 6be47aa35a Update tests.yml
PHP Tests / php-tests (push) Failing after 4m18s
2026-08-20 00:27:47 +07:00
Nyan Lin Paing 79f7f50706 Update tests.yml
PHP Tests / php-tests (push) Failing after 2m51s
2026-08-20 00:20:08 +07:00
Nyan Lin Paing 54b35ee087 Update tests.yml
PHP Tests / php-tests (push) Has been cancelled
2026-08-20 00:08:33 +07:00
Nyan Lin Paing dfffdd343b fix ci/cd test pipeline
PHP Tests / php-tests (push) Has been cancelled
2026-08-20 00:01:28 +07:00
Nyan Lin Paing a9124ccb8d Update BookingInfolist.php
PHP Tests / php-tests (push) Failing after 3m21s
2026-08-19 23:06:45 +07:00
Nyan Lin Paing 5c215b4647 add admin noti email feat
PHP Tests / php-tests (push) Failing after 3m20s
2026-08-19 21:43:00 +07:00
Nyan Lin Paing 532f2ddf99 fix kbz payment success payload
PHP Tests / php-tests (push) Failing after 3m6s
2026-08-18 11:54:34 +07:00
Nyan Lin Paing c3a6f6cc6e composer update
PHP Tests / php-tests (push) Failing after 4m12s
2026-08-17 23:24:31 +07:00
Nyan Lin Paing 2fdfc0040c fix composer.json
PHP Tests / php-tests (push) Failing after 6m39s
2026-08-17 22:30:26 +07:00
Nyan Lin Paing 8d74ac74cd fix dashboard and apis
PHP Tests / php-tests (push) Failing after 9m1s
2026-08-16 23:50:39 +07:00
Nyan Lin Paing 60413bdebf Phase 7 dashboard widgets, Gitea CI, and branded landing page (T7.1)
- BookingsTodayWidget, RecentBookingsTableWidget (Booking module) and
  RevenueChartWidget, PaymentFailureRateWidget (Payment module) dashboard
  widgets, auto-registered via each plugin's existing discoverWidgets().
- .gitea/workflows/tests.yml: Postgres-backed Pest run on push/PR.
- Landing page (resources/views/welcome.blade.php) now shows the Famous
  Linnyone4 EV logo with a single admin login link, and the Filament admin
  panel uses the same logo as its brand logo.
2026-08-10 00:15:19 +07:00
135 changed files with 6923 additions and 2135 deletions
+104
View File
@@ -0,0 +1,104 @@
---
name: infer-conventions
description: "Use this skill to analyze how a Laravel application is actually written and record its conventions as shared rules. Trigger when the user wants to detect, infer, document, or standardize project conventions or coding style, set up or grow `.ai/rules`, resolve mixed or conflicting patterns (e.g. \"are we using Form Requests or inline validation?\"), or onboard agents and teammates to \"how we do things here\". Covers: a systematic sweep of ~49 Laravel convention dimensions (validation, models, architecture, testing, frontend, database, console), open-ended house-pattern discovery, conflict reporting, and recording rules scoped to the right paths via the Boost `record-rule` MCP tool. Do not use for one-off code review, enforcing formatting a linter already handles, or editing `.ai/rules` files by hand."
license: MIT
metadata:
author: laravel
---
# Infer Conventions
Learn how this application writes Laravel, then record what you learn as durable, path-scoped rules other agents will read. You are documenting reality, not improving it.
## Ground Rules (read before you start)
- Consistency first. The codebase's majority style is the convention. Never judge it, never propose a "better" pattern, never record what the code should do. If the app validates inline everywhere, that is the rule, even if Form Requests would be nicer.
- Skip what an active tool produces, keep what a tool would fight. Inspect the project's Pint and Rector configuration first; a Rector transformation is tooling-owned only when its package and relevant rule or set are installed and enabled. Active tools may rewrite code toward one canonical form: `$casts` to `casts()`, `$fillable` to attributes, magic accessors to the `Attribute` class, pipe-string rules to arrays, `$signature` to `#[Signature]`, named migrations to anonymous, and many more. When the app already sits at an active tool's target form, the tool owns it, so record nothing. But when the app deliberately holds a form an active tool would refactor away, such as legacy `getXxxAttribute()` accessors the `Attribute` class would replace, no tool can reproduce that choice and an agent defaults the other way. That against-the-grain hold is exactly what to record.
- Record decisions, not defaults. A consistent pattern earns a rule only when it reflects a choice: the app took one valid option where the framework or common practice offered others, or the pattern would surprise a competent agent. Framework defaults steer nothing, so skip them: anonymous migrations, `$signature` commands, `ShouldQueue` jobs, `casts()` on Laravel 11+, named routes, Rule objects in `app/Rules`, and `Mail::fake()` or `Bus::fake()` to isolate framework services. A real fork is not enough on its own. Weigh the side the app took, and record only the side an agent would not reach for by itself: inline closures everywhere, legacy accessors, a bespoke query layer. Watch for the false fork too. "No Mockery" next to facade fakes is not a choice against Mockery, because they double different things. The test for every candidate: without this rule, would the next agent plausibly write it differently? Only "yes" earns a rule.
- Architecture choices are the gold. Record presence and deliberate absence. The structural pattern the app commits to is the highest-signal convention and the one no tool can decide: Action classes and how they are invoked (`handle` / `execute` / `__invoke`), service objects, dedicated query objects exposing `builder()`, DTOs (spatie/laravel-data vs readonly classes), Form Request validation vs inline, an events and listeners spine vs direct calls, and domain or module folders. Also record a consistent non-pattern, such as "query Eloquent directly in controllers, no repository layer", so the next agent matches the app's altitude instead of over-engineering.
- Never duplicate `.ai/rules`. Read `.ai/rules/index.md` and the area files before the sweep. A dimension already covered there is marked done and skipped.
- Evidence or silence. A convention needs at least 3 consistent examples and no meaningful rival to become a candidate. Every Step 1 verdict applies this bar.
- The recorded rule states the convention, nothing else. One or two imperative lines: this project does X, so do X here. Keep detection evidence out. No counts, ratios, current usage, file lists, or example paths, because that is proof for the confirm step, not part of the rule. One short syntax fragment at most, and point to `search-docs` for API details.
## Process
Each step ends on a checkable completion criterion. Do not advance until it holds.
Fan out when you can. The sweep is embarrassingly parallel. If your environment can spawn subagents (a Task, dispatch, or equivalent tool), do Step 0 yourself, then hand each checklist group (A to J) and the architecture map to its own subagent. Each subagent runs the greps, reads a few representative files, and returns structured verdicts (dimension, verdict, evidence, proposed glob / title / note). You aggregate, dedupe, then run Steps 3 to 5. It is far faster on a real app. No subagents available? Run the steps in sequence, with the same bar and the same output.
### Step 0: Orient
Read `composer.json` (installed packages tell you which checklist groups apply), the `pint.json` / PHPStan / Rector config, `.ai/rules/index.md` if present, and most important, map the `app/` tree. List every directory under `app/` (and any `Modules/`, `src/`, `packages/`, or domain root). Every folder beyond Laravel's default skeleton (`Http`, `Models`, `Providers`, `Console`, `Exceptions`) is a structural pattern the app committed to and a high-value rule waiting to be written: `Actions`, `Services`, `Data` or DTOs, `Queries`, `Repositories`, `ViewModels`, `Pipelines`, `Support`, `Enums`, `Contracts`, `Observers`, or `Domain` and module roots. Note each one. You will confirm how it is used in Step 2.
This app ships a frontend stack, so the frontend checklist group applies. Sweep it.
Done when: you have the applicable checklist groups, the dimensions already recorded in `.ai/rules`, and a list of every non-default `app/` directory mapped to the pattern it represents.
### Step 1: Predefined sweep
Open `references/checklist.md` and work every applicable dimension using its search hints. Give each exactly one verdict:
- Pattern. Clears the bar, rival under ~20% of sites, and reflects a real choice (passes the decisions-not-defaults test). A recording candidate. Cite 2 to 3 example files.
- Conflict. Both styles present in meaningful numbers. Report the split with counts and example files. Never record a preferred winner while the code remains mixed, even in yolo, because that would describe an aspiration rather than reality. Record only if the user identifies a stable path or context boundary that explains both styles; otherwise defer until the code is reconciled.
- Default. Consistent, but a framework or common-practice default the agent already writes unprompted. Skip it as a no-op, not a convention.
- No signal. Under the bar: feature unused, or too few examples. Skip silently (one summary line at most).
- Tooling-owned or Already-recorded. Skip per the ground rules.
Done when: every applicable dimension carries exactly one of those verdicts.
### Step 2: Open-ended pass
First, close out the architecture map from Step 0. For every non-default `app/` directory you listed, confirm how the pattern is used and apply the same evidence and decisions-not-defaults tests as Step 1. Generator-standard or sparsely used directories such as `Rules`, `Observers`, `Mail`, and `Notifications` are signals to inspect, not automatic conventions. Make genuine structural patterns candidates: Action classes invoked via `handle` / `execute` / `__invoke`, Services constructor-injected, `Queries` objects exposing `builder(): Builder`, DTOs as readonly classes or spatie/laravel-data, module or domain folders as the unit of organization. Scope each qualifying pattern to its own directory glob. Also record a consistent deliberate absence, such as "no repository layer, controllers query Eloquent directly", so the next agent matches the app's altitude.
Then find what else makes this codebase itself: base or abstract classes most code extends, traits used everywhere, tenancy or authorization scoping woven through queries, naming schemes, and custom helpers. Same evidence bar, cite files. Record every genuine structural pattern, and cap the other house findings at ~5 so the pass stays high-signal.
Done when: every non-default `app/` directory from Step 0 has a verdict, and the pass has produced its cited house findings (or concluded there are none).
### Step 3: Confirm
Present every candidate in one batch. Per item: dimension, verdict, evidence (counts and files), and the exact proposed `glob` or `globs` / `title` / `note`. Conflicts are presented as questions about an existing context boundary or deferred cleanup, not as a choice of future style.
Default mode is confirm: record only what the user approves. Switch to yolo only when the invocation said so ("yolo", "don't ask", "just record them"), then record all pattern candidates without asking. Conflicts still go to the user in yolo.
Done when: every candidate is approved, rejected, or (conflicts) decided.
### Step 4: Record
Make one `record-rule` call for each glob an approved convention applies to. Choose the most specific globs that cover the cited evidence from the mapping table below; if a convention spans models and migrations, record it under both domains so agents discover it from either path. The `note` is the bare convention: strip every trace of detection (see the ground rule). If `record-rule` is unavailable (rules disabled), report the full rule text so the user can enable `BOOST_RULES_ENABLED` or add it by hand.
Record this:
> Accessors and mutators: use the legacy magic-method style (`getXxxAttribute()` / `setXxxAttribute()`), not the `Attribute` class. Match it in models.
Not this:
> Accessors/mutators use the legacy magic-method style; the `Attribute`-class style is not used anywhere (13 legacy, 0 Attribute-class), e.g. `app/Models/Post.php`. Match the legacy style in existing models.
Done when: every approved item has a successful tool response, and any failure is reported with its rule text.
### Step 5: Summarize
List recorded rules (file and title), conflicts the user deferred, notable no-signals, and remind the user to commit `.ai/rules` so their team and agents share the conventions.
## Glob mapping
Attach each rule to the most specific path that covers its evidence. Never a lazy `app/**` when a subtree fits. Match the glob to where the code actually lives, which is not the same in a default skeleton and in a modular or DDD layout. Use the Step 0 `app/` map to pick the real path.
Examples:
- Models: `app/Models/**` in a default app, or `app/Modules/Blog/Models/**` / `src/Domain/Blog/**` in a modular one.
- Controllers, routing, validation, responses: `app/Http/**`, or `app/Modules/*/Http/**` when each module owns its HTTP layer.
- Actions, Services, DTOs: `app/Actions/**`, `app/Services/**`, `app/Data/**`, or the module path the app actually uses.
- Tests: `tests/**`.
- Migrations and database: `database/migrations/**`.
- Truly app-wide (rare, e.g. auth retrieval): `app/**`.
`record-rule` takes one glob. When a convention genuinely spans two domains (e.g. UUID keys touch models and migrations), call it once per domain with the same title and note; mentioning another path in the note does not make the rule discoverable there.
## Edge cases
- Rules disabled or `record-rule` missing: detection is read-only, so Steps 0 to 3 still run, and recording falls back to the manual path in Step 4.
- Tiny or fresh app: most dimensions land on no-signal. Say so honestly ("not enough code to infer conventions yet") and record nothing.
- Huge app: each dimension is a bounded grep plus a handful of file reads. Sample representative files, do not read everything.
- Re-runs: reading `.ai/rules` in Step 0 makes re-runs incremental, so only new or undecided dimensions surface.
- Non-standard layout (modules, DDD): the open-ended pass catches the layout itself as convention #1. Adapt the globs in the mapping table to the observed paths.
@@ -0,0 +1,139 @@
# Detection Checklist
Every dimension here is a genuine fork: Laravel offers two or more valid approaches, the app's choice changes what the next agent writes, and no active project tool can pick for you. Left out on purpose: pure formatting (Pint owns it), any form an installed and enabled Rector rule rewrites to one canonical shape (`$casts` to `casts()`, `$fillable` to attributes, pipe-string rules to arrays, named to anonymous migrations, `$signature` to `#[Signature]`), and framework defaults any agent writes unprompted (`ShouldQueue` jobs, relation return types, `HasFactory`).
Each item gives the fork, then a hint (a grep or dir to spot which side the app takes). Hints are only a start. Read the matched files, never record on a raw count. Apply the ground rules to every verdict: a consistent choice that is a default or a tool's target form is not a pattern. Rows tagged (architecture) are the highest-signal, so record presence and deliberate absence.
---
## A. Validation & HTTP input
1. Validation entry point: inline `$request->validate()` vs Form Request classes vs `Validator::make()`.
- Hint: `ls app/Http/Requests`; grep `->validate(` / `Validator::make(` in `app/Http/Controllers`.
2. Custom rule location: invokable rule objects in `app/Rules` vs inline closures vs `Validator::extend()` in a provider. Rule objects are the default `make:rule` path, so record only if the app leans on closures or `Validator::extend` instead. "No rule objects" alone is just no-signal.
- Hint: `ls app/Rules`; grep `Validator::extend` in `app/Providers`.
3. Typed input retrieval: typed getters (`$request->string()`, `->integer()`, `->enum()`, `->date()`) vs raw `$request->input()` / dynamic properties.
- Hint: grep `->string(` / `->integer(` / `->enum(` vs `->input(` in `app/Http`.
4. Custom messages/attributes: `lang/*/validation.php` vs Form Request `messages()` / `attributes()` methods.
- Hint: `ls lang`; grep `function messages`, `function attributes` in `app/Http/Requests`.
## B. Controllers & routing
5. Controller shape: invokable single-action (`__invoke`) vs resource controllers vs plain multi-method.
- Hint: grep `__invoke` in controllers; `Route::resource` / `apiResource` vs verb routes.
6. Business-logic location (architecture): fat controllers vs delegated to Actions / Services / Jobs.
- Hint: read a few controller methods; `ls app/Actions app/Services`.
7. Route handler style: closures in `routes/*.php` vs controller classes.
- Hint: count `function ()` vs `::class` in `routes/web.php`, `routes/api.php`.
8. Middleware assignment: route/group `->middleware()` vs controller `HasMiddleware::middleware()` vs `#[Middleware]` attribute.
- Hint: grep `implements HasMiddleware`, `#[Middleware(` in controllers vs `->middleware(` in routes.
9. Route model binding: implicit (type-hinted models) vs explicit `Route::bind` vs manual `findOrFail`.
- Hint: typed model params in signatures vs `findOrFail(` in controllers; grep `Route::bind`.
10. Rate limiting: named `RateLimiter::for()` + `throttle:name` vs inline `throttle:60,1`.
- Hint: grep `RateLimiter::for` in providers vs `throttle:` in route files.
## C. Authorization
11. Authorization home: Gates (`Gate::define`) vs Policy classes in `app/Policies`.
- Hint: `ls app/Policies`; grep `Gate::define` in `app/Providers`.
12. Authorization call site: `$this->authorize()` / `Gate::authorize()` vs `$user->can()` vs `can` middleware vs `#[Authorize]` vs `@can` in Blade.
- Hint: grep `authorize(`, `->can(`, `middleware('can:`, `#[Authorize(`, `@can(`.
## D. Eloquent & models
13. Mass assignment: `$fillable` allow-list vs `$guarded` block-list.
- Hint: grep `protected $fillable` / `protected $guarded` in `app/Models`.
14. Accessors/mutators: modern `Attribute` class vs legacy `getXxxAttribute()` / `setXxxAttribute()`. Record a legacy hold, it goes against the tool's grain.
- Hint: grep `: Attribute` / `Attribute::make` vs `function get[A-Z].*Attribute` in `app/Models`.
15. Primary keys: auto-increment vs `HasUuids` vs `HasUlids`.
- Hint: grep `HasUuids` / `HasUlids` in `app/Models`; migration `id()` vs `uuid('id')`.
16. Custom casts: dedicated `CastsAttributes` classes (`app/Casts`) vs inline `Attribute` vs built-in cast strings.
- Hint: `ls app/Casts`; grep `Cast::class`, `AsStringable::class` in models.
17. Data/query layer (architecture): Eloquent directly in controllers vs repositories vs dedicated query objects (e.g. classes exposing `builder(): Builder`).
- Hint: `ls app/Repositories app/Queries`; see where non-trivial queries are built.
18. Query scopes: local `scope`/`#[Scope]` methods vs dedicated builder classes.
- Hint: grep `function scope` / `#[Scope]` in models; `ls app/*/Builders`.
19. Model events: observers (`app/Observers`, `#[ObservedBy]`) vs `booted()` closures vs event classes.
- Hint: `ls app/Observers`; grep `booted`, `::observe`, `#[ObservedBy]`.
20. Eager-load posture: explicit per-query `->with()` vs model-level `$with` defaults. Treat `preventLazyLoading()` separately as a development guard because it can complement either posture.
- Hint: grep `protected $with`, `->with(`, and separately `preventLazyLoading` in `app/`.
## E. Architecture & organization
21. Action/Service structure (architecture): Action classes (invoked via `handle` / `execute` / `__invoke`) vs service objects vs neither. Cross-check the Step 0 `app/` map: any `Actions`/`Services`/`Pipelines`/`Jobs`-as-actions folder is this pattern, so record how it is invoked.
- Hint: `ls app/` (the whole tree, not just `Actions`/`Services`); grep the invocation method in the folder you find.
22. DTOs (architecture): spatie/laravel-data vs plain readonly classes vs arrays everywhere.
- Hint: `ls app/Data`; grep `extends Data`, `readonly class` in `app/`.
23. Dependency acquisition: constructor/method injection vs `app()` / `resolve()` / `App::make()` service location.
- Hint: grep `app(` / `resolve(` / `::make(` in `app/` vs promoted constructor deps.
24. Decoupling: events + listeners vs direct service calls.
- Hint: `ls app/Events app/Listeners`; grep `event(`, `::dispatch(`.
25. Helper vs facade idiom: global helpers (`config()`, `auth()`, `response()`) vs facades (`Config::`, `Auth::`, `Response::`).
- Hint: ratio of `config(` vs `Config::` (etc.) across `app/`.
26. Namespace layout (architecture): default `app/` skeleton vs domain/module folders (`app/Domain/**`, modules).
- Hint: `ls app/`, look for `Domain/`, `Modules/`, bounded-context folders.
27. Enums: backed vs pure; case naming; where they live.
- Hint: `ls app/Enums`; grep `enum .*: string`, `enum .*: int`.
## F. Frontend & views
This app ships a frontend stack, so the items below apply.
28. Frontend stack: Blade+Livewire vs Inertia (Vue/React/Svelte) vs Blade-only / API + separate SPA.
- Hint: `composer.json` + `package.json`; `ls resources/js/pages`, `resources/views`.
29. Blade composition: class `<x-*>` components vs anonymous components (`@props`) vs `@include` partials.
- Hint: `ls app/View/Components`; grep `<x-`, `@include` in `resources/views`.
30. Livewire component format: Volt functional/class components, native Livewire 4 single-file (SFC), multi-file (MFC), view-based, or class-based components. Evaluate full-page vs nested separately because it is an independent usage choice.
- Hint: check the installed Livewire major and `livewire/volt`; inspect `app/Livewire`, `resources/views/livewire`, and Livewire 4 component/page directories for `@volt`, SFC, MFC, view-based, and class-based formats.
32. Localization: short keys (`lang/*/*.php` + `__('messages.welcome')`) vs JSON string keys (`lang/*.json` + `__('Full sentence')`).
- Hint: `ls lang`; grep dotted `__('` vs sentence keys.
## G. Database & migrations
33. Foreign keys: `foreignId()->constrained()` vs `foreignIdFor(Model::class)` vs manual `foreign()->references()->on()`.
- Hint: grep `foreignId(`, `foreignIdFor(`, `->foreign(` in `database/migrations`.
34. `down()` methods: real reverse logic vs omitted / one-way migrations.
- Hint: grep `function down` vs the migration count.
35. Enum storage: DB `enum()` column vs `string()` + PHP-enum cast on the model.
- Hint: grep `->enum(` in migrations vs string columns cast to enums.
36. Transactions: `DB::transaction(fn ...)` closure vs manual `beginTransaction` / `commit` / `rollBack`.
- Hint: grep `DB::transaction`, `beginTransaction` in `app/`.
37. Idempotent writes: `upsert` / `updateOrCreate` / `firstOrCreate` vs find-then-save.
- Hint: grep `upsert(`, `updateOrCreate(`, `firstOrCreate(` in `app/`.
## H. Testing
38. Framework: Pest (`it()` / `test()` / `expect()`) vs PHPUnit classes.
- Hint: `ls tests/Pest.php`; grep `it(` / `test(` vs `extends TestCase`.
39. DB reset: `RefreshDatabase` vs `DatabaseTruncation` vs `DatabaseMigrations`.
- Hint: grep those trait names in `tests/`.
40. Fixtures: compare how equivalent test-owned records are created, such as factories vs manual inserts. Track seeders separately for shared reference data because `$this->seed()` commonly and legitimately coexists with factories.
- Hint: grep `::factory(` and direct inserts in `tests/`; separately inspect `$this->seed(` calls and what those seeders provide.
41. Collaborator isolation: how the app doubles its own classes, Mockery `mock()` / `spy()` vs real integration. Ignore facade fakes like `Mail::fake()` here, they isolate framework services by default and are not a fork against Mockery.
- Hint: grep `->mock(`, `->spy(`, `Mockery::` in `tests/`.
42. Endpoint assertions: array `assertJson([...])` / `assertJsonFragment` vs fluent `AssertableJson`.
- Hint: grep `AssertableJson`, `assertJsonFragment` in `tests/`.
## I. Responses & API resources
43. Response shape: API Resource classes vs `response()->json()` vs returning models/arrays directly.
- Hint: `ls app/Http/Resources`; grep `JsonResource`, `->json(` in controllers.
44. Resource relationship inclusion: `whenLoaded()` guards vs unconditional relationship access. Do not count ordinary scalar attributes as rivals to conditional relationships, and evaluate general `when()` fields separately.
- Hint: compare relationship fields using `whenLoaded(` with unconditional relationship property access in `app/Http/Resources`.
45. Pagination contracts: within comparable endpoint categories, length-aware `paginate()` vs `simplePaginate()` vs `cursorPaginate()`. These have different totals, navigation, ordering, and performance contracts, so record only a stable path-scoped API policy, never a project-wide majority.
- Hint: grep those in `app/`, then group matches by endpoint type and client contract before comparing them.
46. Web redirects/URLs: `route('name')` vs `url('/path')` vs `action([...])`.
- Hint: grep `route('`, `url('/`, `action([` in `app/Http` and views.
## J. Strings, collections & dates
47. Iteration idiom: `collect()->map()->filter()` pipelines vs `array_map` / `foreach`.
- Hint: grep `collect(`, `->map(` vs `array_map`, `foreach` density in `app/`.
48. String API: fluent `Str::of()->...` (Stringable) vs static `Str::` vs native (`trim`, `strtoupper`).
- Hint: grep `Str::of(` vs `Str::` vs native string funcs.
49. Dates: compare equivalent construction call styles (`now()` / `today()` helpers vs `Carbon::`) separately from the application's mutable/immutable date policy. `Date::use(CarbonImmutable::class)` can make helpers return immutable dates, so those signals are complementary rather than conflicting.
- Hint: grep `now(` and `Carbon::` for call style; separately inspect `CarbonImmutable` and `Date::use` for mutability policy.
---
Genuine forks only. Every row survived the "no tool can decide this, and it isn't the default" filter. Give each applicable dimension exactly one verdict: pattern, conflict, default, no-signal, tooling-owned, or already-recorded. The rows tagged (architecture) are where the highest-value rules come from.
@@ -30,7 +30,8 @@ $articles = Article::whereHas('user', function ($q) {
Correct:
```php
public function scopeActive(Builder $query): Builder
#[Scope]
protected function active(Builder $query): Builder
{
return $query->where('verified', true)->whereNotNull('activated_at');
}
@@ -58,7 +59,8 @@ class PublishedScope implements Scope
Correct (local scope you opt into):
```php
public function scopePublished(Builder $query): Builder
#[Scope]
protected function published(Builder $query): Builder
{
return $query->where('published', true);
}
@@ -90,7 +90,7 @@ Correct:
## CSRF Protection
Include `@csrf` in all POST/PUT/DELETE Blade forms. In Inertia apps, the `@csrf` directive is automatically applied.
Include `@csrf` in all POST/PUT/DELETE Blade forms. Inertia doesn't use `@csrf`; its HTTP client sends the `XSRF-TOKEN` cookie back as the `X-XSRF-TOKEN` header, which Laravel accepts in place of the `_token` field.
Incorrect:
```blade
+104
View File
@@ -0,0 +1,104 @@
---
name: infer-conventions
description: "Use this skill to analyze how a Laravel application is actually written and record its conventions as shared rules. Trigger when the user wants to detect, infer, document, or standardize project conventions or coding style, set up or grow `.ai/rules`, resolve mixed or conflicting patterns (e.g. \"are we using Form Requests or inline validation?\"), or onboard agents and teammates to \"how we do things here\". Covers: a systematic sweep of ~49 Laravel convention dimensions (validation, models, architecture, testing, frontend, database, console), open-ended house-pattern discovery, conflict reporting, and recording rules scoped to the right paths via the Boost `record-rule` MCP tool. Do not use for one-off code review, enforcing formatting a linter already handles, or editing `.ai/rules` files by hand."
license: MIT
metadata:
author: laravel
---
# Infer Conventions
Learn how this application writes Laravel, then record what you learn as durable, path-scoped rules other agents will read. You are documenting reality, not improving it.
## Ground Rules (read before you start)
- Consistency first. The codebase's majority style is the convention. Never judge it, never propose a "better" pattern, never record what the code should do. If the app validates inline everywhere, that is the rule, even if Form Requests would be nicer.
- Skip what an active tool produces, keep what a tool would fight. Inspect the project's Pint and Rector configuration first; a Rector transformation is tooling-owned only when its package and relevant rule or set are installed and enabled. Active tools may rewrite code toward one canonical form: `$casts` to `casts()`, `$fillable` to attributes, magic accessors to the `Attribute` class, pipe-string rules to arrays, `$signature` to `#[Signature]`, named migrations to anonymous, and many more. When the app already sits at an active tool's target form, the tool owns it, so record nothing. But when the app deliberately holds a form an active tool would refactor away, such as legacy `getXxxAttribute()` accessors the `Attribute` class would replace, no tool can reproduce that choice and an agent defaults the other way. That against-the-grain hold is exactly what to record.
- Record decisions, not defaults. A consistent pattern earns a rule only when it reflects a choice: the app took one valid option where the framework or common practice offered others, or the pattern would surprise a competent agent. Framework defaults steer nothing, so skip them: anonymous migrations, `$signature` commands, `ShouldQueue` jobs, `casts()` on Laravel 11+, named routes, Rule objects in `app/Rules`, and `Mail::fake()` or `Bus::fake()` to isolate framework services. A real fork is not enough on its own. Weigh the side the app took, and record only the side an agent would not reach for by itself: inline closures everywhere, legacy accessors, a bespoke query layer. Watch for the false fork too. "No Mockery" next to facade fakes is not a choice against Mockery, because they double different things. The test for every candidate: without this rule, would the next agent plausibly write it differently? Only "yes" earns a rule.
- Architecture choices are the gold. Record presence and deliberate absence. The structural pattern the app commits to is the highest-signal convention and the one no tool can decide: Action classes and how they are invoked (`handle` / `execute` / `__invoke`), service objects, dedicated query objects exposing `builder()`, DTOs (spatie/laravel-data vs readonly classes), Form Request validation vs inline, an events and listeners spine vs direct calls, and domain or module folders. Also record a consistent non-pattern, such as "query Eloquent directly in controllers, no repository layer", so the next agent matches the app's altitude instead of over-engineering.
- Never duplicate `.ai/rules`. Read `.ai/rules/index.md` and the area files before the sweep. A dimension already covered there is marked done and skipped.
- Evidence or silence. A convention needs at least 3 consistent examples and no meaningful rival to become a candidate. Every Step 1 verdict applies this bar.
- The recorded rule states the convention, nothing else. One or two imperative lines: this project does X, so do X here. Keep detection evidence out. No counts, ratios, current usage, file lists, or example paths, because that is proof for the confirm step, not part of the rule. One short syntax fragment at most, and point to `search-docs` for API details.
## Process
Each step ends on a checkable completion criterion. Do not advance until it holds.
Fan out when you can. The sweep is embarrassingly parallel. If your environment can spawn subagents (a Task, dispatch, or equivalent tool), do Step 0 yourself, then hand each checklist group (A to J) and the architecture map to its own subagent. Each subagent runs the greps, reads a few representative files, and returns structured verdicts (dimension, verdict, evidence, proposed glob / title / note). You aggregate, dedupe, then run Steps 3 to 5. It is far faster on a real app. No subagents available? Run the steps in sequence, with the same bar and the same output.
### Step 0: Orient
Read `composer.json` (installed packages tell you which checklist groups apply), the `pint.json` / PHPStan / Rector config, `.ai/rules/index.md` if present, and most important, map the `app/` tree. List every directory under `app/` (and any `Modules/`, `src/`, `packages/`, or domain root). Every folder beyond Laravel's default skeleton (`Http`, `Models`, `Providers`, `Console`, `Exceptions`) is a structural pattern the app committed to and a high-value rule waiting to be written: `Actions`, `Services`, `Data` or DTOs, `Queries`, `Repositories`, `ViewModels`, `Pipelines`, `Support`, `Enums`, `Contracts`, `Observers`, or `Domain` and module roots. Note each one. You will confirm how it is used in Step 2.
This app ships a frontend stack, so the frontend checklist group applies. Sweep it.
Done when: you have the applicable checklist groups, the dimensions already recorded in `.ai/rules`, and a list of every non-default `app/` directory mapped to the pattern it represents.
### Step 1: Predefined sweep
Open `references/checklist.md` and work every applicable dimension using its search hints. Give each exactly one verdict:
- Pattern. Clears the bar, rival under ~20% of sites, and reflects a real choice (passes the decisions-not-defaults test). A recording candidate. Cite 2 to 3 example files.
- Conflict. Both styles present in meaningful numbers. Report the split with counts and example files. Never record a preferred winner while the code remains mixed, even in yolo, because that would describe an aspiration rather than reality. Record only if the user identifies a stable path or context boundary that explains both styles; otherwise defer until the code is reconciled.
- Default. Consistent, but a framework or common-practice default the agent already writes unprompted. Skip it as a no-op, not a convention.
- No signal. Under the bar: feature unused, or too few examples. Skip silently (one summary line at most).
- Tooling-owned or Already-recorded. Skip per the ground rules.
Done when: every applicable dimension carries exactly one of those verdicts.
### Step 2: Open-ended pass
First, close out the architecture map from Step 0. For every non-default `app/` directory you listed, confirm how the pattern is used and apply the same evidence and decisions-not-defaults tests as Step 1. Generator-standard or sparsely used directories such as `Rules`, `Observers`, `Mail`, and `Notifications` are signals to inspect, not automatic conventions. Make genuine structural patterns candidates: Action classes invoked via `handle` / `execute` / `__invoke`, Services constructor-injected, `Queries` objects exposing `builder(): Builder`, DTOs as readonly classes or spatie/laravel-data, module or domain folders as the unit of organization. Scope each qualifying pattern to its own directory glob. Also record a consistent deliberate absence, such as "no repository layer, controllers query Eloquent directly", so the next agent matches the app's altitude.
Then find what else makes this codebase itself: base or abstract classes most code extends, traits used everywhere, tenancy or authorization scoping woven through queries, naming schemes, and custom helpers. Same evidence bar, cite files. Record every genuine structural pattern, and cap the other house findings at ~5 so the pass stays high-signal.
Done when: every non-default `app/` directory from Step 0 has a verdict, and the pass has produced its cited house findings (or concluded there are none).
### Step 3: Confirm
Present every candidate in one batch. Per item: dimension, verdict, evidence (counts and files), and the exact proposed `glob` or `globs` / `title` / `note`. Conflicts are presented as questions about an existing context boundary or deferred cleanup, not as a choice of future style.
Default mode is confirm: record only what the user approves. Switch to yolo only when the invocation said so ("yolo", "don't ask", "just record them"), then record all pattern candidates without asking. Conflicts still go to the user in yolo.
Done when: every candidate is approved, rejected, or (conflicts) decided.
### Step 4: Record
Make one `record-rule` call for each glob an approved convention applies to. Choose the most specific globs that cover the cited evidence from the mapping table below; if a convention spans models and migrations, record it under both domains so agents discover it from either path. The `note` is the bare convention: strip every trace of detection (see the ground rule). If `record-rule` is unavailable (rules disabled), report the full rule text so the user can enable `BOOST_RULES_ENABLED` or add it by hand.
Record this:
> Accessors and mutators: use the legacy magic-method style (`getXxxAttribute()` / `setXxxAttribute()`), not the `Attribute` class. Match it in models.
Not this:
> Accessors/mutators use the legacy magic-method style; the `Attribute`-class style is not used anywhere (13 legacy, 0 Attribute-class), e.g. `app/Models/Post.php`. Match the legacy style in existing models.
Done when: every approved item has a successful tool response, and any failure is reported with its rule text.
### Step 5: Summarize
List recorded rules (file and title), conflicts the user deferred, notable no-signals, and remind the user to commit `.ai/rules` so their team and agents share the conventions.
## Glob mapping
Attach each rule to the most specific path that covers its evidence. Never a lazy `app/**` when a subtree fits. Match the glob to where the code actually lives, which is not the same in a default skeleton and in a modular or DDD layout. Use the Step 0 `app/` map to pick the real path.
Examples:
- Models: `app/Models/**` in a default app, or `app/Modules/Blog/Models/**` / `src/Domain/Blog/**` in a modular one.
- Controllers, routing, validation, responses: `app/Http/**`, or `app/Modules/*/Http/**` when each module owns its HTTP layer.
- Actions, Services, DTOs: `app/Actions/**`, `app/Services/**`, `app/Data/**`, or the module path the app actually uses.
- Tests: `tests/**`.
- Migrations and database: `database/migrations/**`.
- Truly app-wide (rare, e.g. auth retrieval): `app/**`.
`record-rule` takes one glob. When a convention genuinely spans two domains (e.g. UUID keys touch models and migrations), call it once per domain with the same title and note; mentioning another path in the note does not make the rule discoverable there.
## Edge cases
- Rules disabled or `record-rule` missing: detection is read-only, so Steps 0 to 3 still run, and recording falls back to the manual path in Step 4.
- Tiny or fresh app: most dimensions land on no-signal. Say so honestly ("not enough code to infer conventions yet") and record nothing.
- Huge app: each dimension is a bounded grep plus a handful of file reads. Sample representative files, do not read everything.
- Re-runs: reading `.ai/rules` in Step 0 makes re-runs incremental, so only new or undecided dimensions surface.
- Non-standard layout (modules, DDD): the open-ended pass catches the layout itself as convention #1. Adapt the globs in the mapping table to the observed paths.
@@ -0,0 +1,139 @@
# Detection Checklist
Every dimension here is a genuine fork: Laravel offers two or more valid approaches, the app's choice changes what the next agent writes, and no active project tool can pick for you. Left out on purpose: pure formatting (Pint owns it), any form an installed and enabled Rector rule rewrites to one canonical shape (`$casts` to `casts()`, `$fillable` to attributes, pipe-string rules to arrays, named to anonymous migrations, `$signature` to `#[Signature]`), and framework defaults any agent writes unprompted (`ShouldQueue` jobs, relation return types, `HasFactory`).
Each item gives the fork, then a hint (a grep or dir to spot which side the app takes). Hints are only a start. Read the matched files, never record on a raw count. Apply the ground rules to every verdict: a consistent choice that is a default or a tool's target form is not a pattern. Rows tagged (architecture) are the highest-signal, so record presence and deliberate absence.
---
## A. Validation & HTTP input
1. Validation entry point: inline `$request->validate()` vs Form Request classes vs `Validator::make()`.
- Hint: `ls app/Http/Requests`; grep `->validate(` / `Validator::make(` in `app/Http/Controllers`.
2. Custom rule location: invokable rule objects in `app/Rules` vs inline closures vs `Validator::extend()` in a provider. Rule objects are the default `make:rule` path, so record only if the app leans on closures or `Validator::extend` instead. "No rule objects" alone is just no-signal.
- Hint: `ls app/Rules`; grep `Validator::extend` in `app/Providers`.
3. Typed input retrieval: typed getters (`$request->string()`, `->integer()`, `->enum()`, `->date()`) vs raw `$request->input()` / dynamic properties.
- Hint: grep `->string(` / `->integer(` / `->enum(` vs `->input(` in `app/Http`.
4. Custom messages/attributes: `lang/*/validation.php` vs Form Request `messages()` / `attributes()` methods.
- Hint: `ls lang`; grep `function messages`, `function attributes` in `app/Http/Requests`.
## B. Controllers & routing
5. Controller shape: invokable single-action (`__invoke`) vs resource controllers vs plain multi-method.
- Hint: grep `__invoke` in controllers; `Route::resource` / `apiResource` vs verb routes.
6. Business-logic location (architecture): fat controllers vs delegated to Actions / Services / Jobs.
- Hint: read a few controller methods; `ls app/Actions app/Services`.
7. Route handler style: closures in `routes/*.php` vs controller classes.
- Hint: count `function ()` vs `::class` in `routes/web.php`, `routes/api.php`.
8. Middleware assignment: route/group `->middleware()` vs controller `HasMiddleware::middleware()` vs `#[Middleware]` attribute.
- Hint: grep `implements HasMiddleware`, `#[Middleware(` in controllers vs `->middleware(` in routes.
9. Route model binding: implicit (type-hinted models) vs explicit `Route::bind` vs manual `findOrFail`.
- Hint: typed model params in signatures vs `findOrFail(` in controllers; grep `Route::bind`.
10. Rate limiting: named `RateLimiter::for()` + `throttle:name` vs inline `throttle:60,1`.
- Hint: grep `RateLimiter::for` in providers vs `throttle:` in route files.
## C. Authorization
11. Authorization home: Gates (`Gate::define`) vs Policy classes in `app/Policies`.
- Hint: `ls app/Policies`; grep `Gate::define` in `app/Providers`.
12. Authorization call site: `$this->authorize()` / `Gate::authorize()` vs `$user->can()` vs `can` middleware vs `#[Authorize]` vs `@can` in Blade.
- Hint: grep `authorize(`, `->can(`, `middleware('can:`, `#[Authorize(`, `@can(`.
## D. Eloquent & models
13. Mass assignment: `$fillable` allow-list vs `$guarded` block-list.
- Hint: grep `protected $fillable` / `protected $guarded` in `app/Models`.
14. Accessors/mutators: modern `Attribute` class vs legacy `getXxxAttribute()` / `setXxxAttribute()`. Record a legacy hold, it goes against the tool's grain.
- Hint: grep `: Attribute` / `Attribute::make` vs `function get[A-Z].*Attribute` in `app/Models`.
15. Primary keys: auto-increment vs `HasUuids` vs `HasUlids`.
- Hint: grep `HasUuids` / `HasUlids` in `app/Models`; migration `id()` vs `uuid('id')`.
16. Custom casts: dedicated `CastsAttributes` classes (`app/Casts`) vs inline `Attribute` vs built-in cast strings.
- Hint: `ls app/Casts`; grep `Cast::class`, `AsStringable::class` in models.
17. Data/query layer (architecture): Eloquent directly in controllers vs repositories vs dedicated query objects (e.g. classes exposing `builder(): Builder`).
- Hint: `ls app/Repositories app/Queries`; see where non-trivial queries are built.
18. Query scopes: local `scope`/`#[Scope]` methods vs dedicated builder classes.
- Hint: grep `function scope` / `#[Scope]` in models; `ls app/*/Builders`.
19. Model events: observers (`app/Observers`, `#[ObservedBy]`) vs `booted()` closures vs event classes.
- Hint: `ls app/Observers`; grep `booted`, `::observe`, `#[ObservedBy]`.
20. Eager-load posture: explicit per-query `->with()` vs model-level `$with` defaults. Treat `preventLazyLoading()` separately as a development guard because it can complement either posture.
- Hint: grep `protected $with`, `->with(`, and separately `preventLazyLoading` in `app/`.
## E. Architecture & organization
21. Action/Service structure (architecture): Action classes (invoked via `handle` / `execute` / `__invoke`) vs service objects vs neither. Cross-check the Step 0 `app/` map: any `Actions`/`Services`/`Pipelines`/`Jobs`-as-actions folder is this pattern, so record how it is invoked.
- Hint: `ls app/` (the whole tree, not just `Actions`/`Services`); grep the invocation method in the folder you find.
22. DTOs (architecture): spatie/laravel-data vs plain readonly classes vs arrays everywhere.
- Hint: `ls app/Data`; grep `extends Data`, `readonly class` in `app/`.
23. Dependency acquisition: constructor/method injection vs `app()` / `resolve()` / `App::make()` service location.
- Hint: grep `app(` / `resolve(` / `::make(` in `app/` vs promoted constructor deps.
24. Decoupling: events + listeners vs direct service calls.
- Hint: `ls app/Events app/Listeners`; grep `event(`, `::dispatch(`.
25. Helper vs facade idiom: global helpers (`config()`, `auth()`, `response()`) vs facades (`Config::`, `Auth::`, `Response::`).
- Hint: ratio of `config(` vs `Config::` (etc.) across `app/`.
26. Namespace layout (architecture): default `app/` skeleton vs domain/module folders (`app/Domain/**`, modules).
- Hint: `ls app/`, look for `Domain/`, `Modules/`, bounded-context folders.
27. Enums: backed vs pure; case naming; where they live.
- Hint: `ls app/Enums`; grep `enum .*: string`, `enum .*: int`.
## F. Frontend & views
This app ships a frontend stack, so the items below apply.
28. Frontend stack: Blade+Livewire vs Inertia (Vue/React/Svelte) vs Blade-only / API + separate SPA.
- Hint: `composer.json` + `package.json`; `ls resources/js/pages`, `resources/views`.
29. Blade composition: class `<x-*>` components vs anonymous components (`@props`) vs `@include` partials.
- Hint: `ls app/View/Components`; grep `<x-`, `@include` in `resources/views`.
30. Livewire component format: Volt functional/class components, native Livewire 4 single-file (SFC), multi-file (MFC), view-based, or class-based components. Evaluate full-page vs nested separately because it is an independent usage choice.
- Hint: check the installed Livewire major and `livewire/volt`; inspect `app/Livewire`, `resources/views/livewire`, and Livewire 4 component/page directories for `@volt`, SFC, MFC, view-based, and class-based formats.
32. Localization: short keys (`lang/*/*.php` + `__('messages.welcome')`) vs JSON string keys (`lang/*.json` + `__('Full sentence')`).
- Hint: `ls lang`; grep dotted `__('` vs sentence keys.
## G. Database & migrations
33. Foreign keys: `foreignId()->constrained()` vs `foreignIdFor(Model::class)` vs manual `foreign()->references()->on()`.
- Hint: grep `foreignId(`, `foreignIdFor(`, `->foreign(` in `database/migrations`.
34. `down()` methods: real reverse logic vs omitted / one-way migrations.
- Hint: grep `function down` vs the migration count.
35. Enum storage: DB `enum()` column vs `string()` + PHP-enum cast on the model.
- Hint: grep `->enum(` in migrations vs string columns cast to enums.
36. Transactions: `DB::transaction(fn ...)` closure vs manual `beginTransaction` / `commit` / `rollBack`.
- Hint: grep `DB::transaction`, `beginTransaction` in `app/`.
37. Idempotent writes: `upsert` / `updateOrCreate` / `firstOrCreate` vs find-then-save.
- Hint: grep `upsert(`, `updateOrCreate(`, `firstOrCreate(` in `app/`.
## H. Testing
38. Framework: Pest (`it()` / `test()` / `expect()`) vs PHPUnit classes.
- Hint: `ls tests/Pest.php`; grep `it(` / `test(` vs `extends TestCase`.
39. DB reset: `RefreshDatabase` vs `DatabaseTruncation` vs `DatabaseMigrations`.
- Hint: grep those trait names in `tests/`.
40. Fixtures: compare how equivalent test-owned records are created, such as factories vs manual inserts. Track seeders separately for shared reference data because `$this->seed()` commonly and legitimately coexists with factories.
- Hint: grep `::factory(` and direct inserts in `tests/`; separately inspect `$this->seed(` calls and what those seeders provide.
41. Collaborator isolation: how the app doubles its own classes, Mockery `mock()` / `spy()` vs real integration. Ignore facade fakes like `Mail::fake()` here, they isolate framework services by default and are not a fork against Mockery.
- Hint: grep `->mock(`, `->spy(`, `Mockery::` in `tests/`.
42. Endpoint assertions: array `assertJson([...])` / `assertJsonFragment` vs fluent `AssertableJson`.
- Hint: grep `AssertableJson`, `assertJsonFragment` in `tests/`.
## I. Responses & API resources
43. Response shape: API Resource classes vs `response()->json()` vs returning models/arrays directly.
- Hint: `ls app/Http/Resources`; grep `JsonResource`, `->json(` in controllers.
44. Resource relationship inclusion: `whenLoaded()` guards vs unconditional relationship access. Do not count ordinary scalar attributes as rivals to conditional relationships, and evaluate general `when()` fields separately.
- Hint: compare relationship fields using `whenLoaded(` with unconditional relationship property access in `app/Http/Resources`.
45. Pagination contracts: within comparable endpoint categories, length-aware `paginate()` vs `simplePaginate()` vs `cursorPaginate()`. These have different totals, navigation, ordering, and performance contracts, so record only a stable path-scoped API policy, never a project-wide majority.
- Hint: grep those in `app/`, then group matches by endpoint type and client contract before comparing them.
46. Web redirects/URLs: `route('name')` vs `url('/path')` vs `action([...])`.
- Hint: grep `route('`, `url('/`, `action([` in `app/Http` and views.
## J. Strings, collections & dates
47. Iteration idiom: `collect()->map()->filter()` pipelines vs `array_map` / `foreach`.
- Hint: grep `collect(`, `->map(` vs `array_map`, `foreach` density in `app/`.
48. String API: fluent `Str::of()->...` (Stringable) vs static `Str::` vs native (`trim`, `strtoupper`).
- Hint: grep `Str::of(` vs `Str::` vs native string funcs.
49. Dates: compare equivalent construction call styles (`now()` / `today()` helpers vs `Carbon::`) separately from the application's mutable/immutable date policy. `Date::use(CarbonImmutable::class)` can make helpers return immutable dates, so those signals are complementary rather than conflicting.
- Hint: grep `now(` and `Carbon::` for call style; separately inspect `CarbonImmutable` and `Date::use` for mutability policy.
---
Genuine forks only. Every row survived the "no tool can decide this, and it isn't the default" filter. Give each applicable dimension exactly one verdict: pattern, conflict, default, no-signal, tooling-owned, or already-recorded. The rows tagged (architecture) are where the highest-value rules come from.
@@ -30,7 +30,8 @@ $articles = Article::whereHas('user', function ($q) {
Correct:
```php
public function scopeActive(Builder $query): Builder
#[Scope]
protected function active(Builder $query): Builder
{
return $query->where('verified', true)->whereNotNull('activated_at');
}
@@ -58,7 +59,8 @@ class PublishedScope implements Scope
Correct (local scope you opt into):
```php
public function scopePublished(Builder $query): Builder
#[Scope]
protected function published(Builder $query): Builder
{
return $query->where('published', true);
}
@@ -90,7 +90,7 @@ Correct:
## CSRF Protection
Include `@csrf` in all POST/PUT/DELETE Blade forms. In Inertia apps, the `@csrf` directive is automatically applied.
Include `@csrf` in all POST/PUT/DELETE Blade forms. Inertia doesn't use `@csrf`; its HTTP client sends the `XSRF-TOKEN` cookie back as the `X-XSRF-TOKEN` header, which Laravel accepts in place of the `_token` field.
Incorrect:
```blade
+104
View File
@@ -0,0 +1,104 @@
---
name: infer-conventions
description: "Use this skill to analyze how a Laravel application is actually written and record its conventions as shared rules. Trigger when the user wants to detect, infer, document, or standardize project conventions or coding style, set up or grow `.ai/rules`, resolve mixed or conflicting patterns (e.g. \"are we using Form Requests or inline validation?\"), or onboard agents and teammates to \"how we do things here\". Covers: a systematic sweep of ~49 Laravel convention dimensions (validation, models, architecture, testing, frontend, database, console), open-ended house-pattern discovery, conflict reporting, and recording rules scoped to the right paths via the Boost `record-rule` MCP tool. Do not use for one-off code review, enforcing formatting a linter already handles, or editing `.ai/rules` files by hand."
license: MIT
metadata:
author: laravel
---
# Infer Conventions
Learn how this application writes Laravel, then record what you learn as durable, path-scoped rules other agents will read. You are documenting reality, not improving it.
## Ground Rules (read before you start)
- Consistency first. The codebase's majority style is the convention. Never judge it, never propose a "better" pattern, never record what the code should do. If the app validates inline everywhere, that is the rule, even if Form Requests would be nicer.
- Skip what an active tool produces, keep what a tool would fight. Inspect the project's Pint and Rector configuration first; a Rector transformation is tooling-owned only when its package and relevant rule or set are installed and enabled. Active tools may rewrite code toward one canonical form: `$casts` to `casts()`, `$fillable` to attributes, magic accessors to the `Attribute` class, pipe-string rules to arrays, `$signature` to `#[Signature]`, named migrations to anonymous, and many more. When the app already sits at an active tool's target form, the tool owns it, so record nothing. But when the app deliberately holds a form an active tool would refactor away, such as legacy `getXxxAttribute()` accessors the `Attribute` class would replace, no tool can reproduce that choice and an agent defaults the other way. That against-the-grain hold is exactly what to record.
- Record decisions, not defaults. A consistent pattern earns a rule only when it reflects a choice: the app took one valid option where the framework or common practice offered others, or the pattern would surprise a competent agent. Framework defaults steer nothing, so skip them: anonymous migrations, `$signature` commands, `ShouldQueue` jobs, `casts()` on Laravel 11+, named routes, Rule objects in `app/Rules`, and `Mail::fake()` or `Bus::fake()` to isolate framework services. A real fork is not enough on its own. Weigh the side the app took, and record only the side an agent would not reach for by itself: inline closures everywhere, legacy accessors, a bespoke query layer. Watch for the false fork too. "No Mockery" next to facade fakes is not a choice against Mockery, because they double different things. The test for every candidate: without this rule, would the next agent plausibly write it differently? Only "yes" earns a rule.
- Architecture choices are the gold. Record presence and deliberate absence. The structural pattern the app commits to is the highest-signal convention and the one no tool can decide: Action classes and how they are invoked (`handle` / `execute` / `__invoke`), service objects, dedicated query objects exposing `builder()`, DTOs (spatie/laravel-data vs readonly classes), Form Request validation vs inline, an events and listeners spine vs direct calls, and domain or module folders. Also record a consistent non-pattern, such as "query Eloquent directly in controllers, no repository layer", so the next agent matches the app's altitude instead of over-engineering.
- Never duplicate `.ai/rules`. Read `.ai/rules/index.md` and the area files before the sweep. A dimension already covered there is marked done and skipped.
- Evidence or silence. A convention needs at least 3 consistent examples and no meaningful rival to become a candidate. Every Step 1 verdict applies this bar.
- The recorded rule states the convention, nothing else. One or two imperative lines: this project does X, so do X here. Keep detection evidence out. No counts, ratios, current usage, file lists, or example paths, because that is proof for the confirm step, not part of the rule. One short syntax fragment at most, and point to `search-docs` for API details.
## Process
Each step ends on a checkable completion criterion. Do not advance until it holds.
Fan out when you can. The sweep is embarrassingly parallel. If your environment can spawn subagents (a Task, dispatch, or equivalent tool), do Step 0 yourself, then hand each checklist group (A to J) and the architecture map to its own subagent. Each subagent runs the greps, reads a few representative files, and returns structured verdicts (dimension, verdict, evidence, proposed glob / title / note). You aggregate, dedupe, then run Steps 3 to 5. It is far faster on a real app. No subagents available? Run the steps in sequence, with the same bar and the same output.
### Step 0: Orient
Read `composer.json` (installed packages tell you which checklist groups apply), the `pint.json` / PHPStan / Rector config, `.ai/rules/index.md` if present, and most important, map the `app/` tree. List every directory under `app/` (and any `Modules/`, `src/`, `packages/`, or domain root). Every folder beyond Laravel's default skeleton (`Http`, `Models`, `Providers`, `Console`, `Exceptions`) is a structural pattern the app committed to and a high-value rule waiting to be written: `Actions`, `Services`, `Data` or DTOs, `Queries`, `Repositories`, `ViewModels`, `Pipelines`, `Support`, `Enums`, `Contracts`, `Observers`, or `Domain` and module roots. Note each one. You will confirm how it is used in Step 2.
This app ships a frontend stack, so the frontend checklist group applies. Sweep it.
Done when: you have the applicable checklist groups, the dimensions already recorded in `.ai/rules`, and a list of every non-default `app/` directory mapped to the pattern it represents.
### Step 1: Predefined sweep
Open `references/checklist.md` and work every applicable dimension using its search hints. Give each exactly one verdict:
- Pattern. Clears the bar, rival under ~20% of sites, and reflects a real choice (passes the decisions-not-defaults test). A recording candidate. Cite 2 to 3 example files.
- Conflict. Both styles present in meaningful numbers. Report the split with counts and example files. Never record a preferred winner while the code remains mixed, even in yolo, because that would describe an aspiration rather than reality. Record only if the user identifies a stable path or context boundary that explains both styles; otherwise defer until the code is reconciled.
- Default. Consistent, but a framework or common-practice default the agent already writes unprompted. Skip it as a no-op, not a convention.
- No signal. Under the bar: feature unused, or too few examples. Skip silently (one summary line at most).
- Tooling-owned or Already-recorded. Skip per the ground rules.
Done when: every applicable dimension carries exactly one of those verdicts.
### Step 2: Open-ended pass
First, close out the architecture map from Step 0. For every non-default `app/` directory you listed, confirm how the pattern is used and apply the same evidence and decisions-not-defaults tests as Step 1. Generator-standard or sparsely used directories such as `Rules`, `Observers`, `Mail`, and `Notifications` are signals to inspect, not automatic conventions. Make genuine structural patterns candidates: Action classes invoked via `handle` / `execute` / `__invoke`, Services constructor-injected, `Queries` objects exposing `builder(): Builder`, DTOs as readonly classes or spatie/laravel-data, module or domain folders as the unit of organization. Scope each qualifying pattern to its own directory glob. Also record a consistent deliberate absence, such as "no repository layer, controllers query Eloquent directly", so the next agent matches the app's altitude.
Then find what else makes this codebase itself: base or abstract classes most code extends, traits used everywhere, tenancy or authorization scoping woven through queries, naming schemes, and custom helpers. Same evidence bar, cite files. Record every genuine structural pattern, and cap the other house findings at ~5 so the pass stays high-signal.
Done when: every non-default `app/` directory from Step 0 has a verdict, and the pass has produced its cited house findings (or concluded there are none).
### Step 3: Confirm
Present every candidate in one batch. Per item: dimension, verdict, evidence (counts and files), and the exact proposed `glob` or `globs` / `title` / `note`. Conflicts are presented as questions about an existing context boundary or deferred cleanup, not as a choice of future style.
Default mode is confirm: record only what the user approves. Switch to yolo only when the invocation said so ("yolo", "don't ask", "just record them"), then record all pattern candidates without asking. Conflicts still go to the user in yolo.
Done when: every candidate is approved, rejected, or (conflicts) decided.
### Step 4: Record
Make one `record-rule` call for each glob an approved convention applies to. Choose the most specific globs that cover the cited evidence from the mapping table below; if a convention spans models and migrations, record it under both domains so agents discover it from either path. The `note` is the bare convention: strip every trace of detection (see the ground rule). If `record-rule` is unavailable (rules disabled), report the full rule text so the user can enable `BOOST_RULES_ENABLED` or add it by hand.
Record this:
> Accessors and mutators: use the legacy magic-method style (`getXxxAttribute()` / `setXxxAttribute()`), not the `Attribute` class. Match it in models.
Not this:
> Accessors/mutators use the legacy magic-method style; the `Attribute`-class style is not used anywhere (13 legacy, 0 Attribute-class), e.g. `app/Models/Post.php`. Match the legacy style in existing models.
Done when: every approved item has a successful tool response, and any failure is reported with its rule text.
### Step 5: Summarize
List recorded rules (file and title), conflicts the user deferred, notable no-signals, and remind the user to commit `.ai/rules` so their team and agents share the conventions.
## Glob mapping
Attach each rule to the most specific path that covers its evidence. Never a lazy `app/**` when a subtree fits. Match the glob to where the code actually lives, which is not the same in a default skeleton and in a modular or DDD layout. Use the Step 0 `app/` map to pick the real path.
Examples:
- Models: `app/Models/**` in a default app, or `app/Modules/Blog/Models/**` / `src/Domain/Blog/**` in a modular one.
- Controllers, routing, validation, responses: `app/Http/**`, or `app/Modules/*/Http/**` when each module owns its HTTP layer.
- Actions, Services, DTOs: `app/Actions/**`, `app/Services/**`, `app/Data/**`, or the module path the app actually uses.
- Tests: `tests/**`.
- Migrations and database: `database/migrations/**`.
- Truly app-wide (rare, e.g. auth retrieval): `app/**`.
`record-rule` takes one glob. When a convention genuinely spans two domains (e.g. UUID keys touch models and migrations), call it once per domain with the same title and note; mentioning another path in the note does not make the rule discoverable there.
## Edge cases
- Rules disabled or `record-rule` missing: detection is read-only, so Steps 0 to 3 still run, and recording falls back to the manual path in Step 4.
- Tiny or fresh app: most dimensions land on no-signal. Say so honestly ("not enough code to infer conventions yet") and record nothing.
- Huge app: each dimension is a bounded grep plus a handful of file reads. Sample representative files, do not read everything.
- Re-runs: reading `.ai/rules` in Step 0 makes re-runs incremental, so only new or undecided dimensions surface.
- Non-standard layout (modules, DDD): the open-ended pass catches the layout itself as convention #1. Adapt the globs in the mapping table to the observed paths.
@@ -0,0 +1,139 @@
# Detection Checklist
Every dimension here is a genuine fork: Laravel offers two or more valid approaches, the app's choice changes what the next agent writes, and no active project tool can pick for you. Left out on purpose: pure formatting (Pint owns it), any form an installed and enabled Rector rule rewrites to one canonical shape (`$casts` to `casts()`, `$fillable` to attributes, pipe-string rules to arrays, named to anonymous migrations, `$signature` to `#[Signature]`), and framework defaults any agent writes unprompted (`ShouldQueue` jobs, relation return types, `HasFactory`).
Each item gives the fork, then a hint (a grep or dir to spot which side the app takes). Hints are only a start. Read the matched files, never record on a raw count. Apply the ground rules to every verdict: a consistent choice that is a default or a tool's target form is not a pattern. Rows tagged (architecture) are the highest-signal, so record presence and deliberate absence.
---
## A. Validation & HTTP input
1. Validation entry point: inline `$request->validate()` vs Form Request classes vs `Validator::make()`.
- Hint: `ls app/Http/Requests`; grep `->validate(` / `Validator::make(` in `app/Http/Controllers`.
2. Custom rule location: invokable rule objects in `app/Rules` vs inline closures vs `Validator::extend()` in a provider. Rule objects are the default `make:rule` path, so record only if the app leans on closures or `Validator::extend` instead. "No rule objects" alone is just no-signal.
- Hint: `ls app/Rules`; grep `Validator::extend` in `app/Providers`.
3. Typed input retrieval: typed getters (`$request->string()`, `->integer()`, `->enum()`, `->date()`) vs raw `$request->input()` / dynamic properties.
- Hint: grep `->string(` / `->integer(` / `->enum(` vs `->input(` in `app/Http`.
4. Custom messages/attributes: `lang/*/validation.php` vs Form Request `messages()` / `attributes()` methods.
- Hint: `ls lang`; grep `function messages`, `function attributes` in `app/Http/Requests`.
## B. Controllers & routing
5. Controller shape: invokable single-action (`__invoke`) vs resource controllers vs plain multi-method.
- Hint: grep `__invoke` in controllers; `Route::resource` / `apiResource` vs verb routes.
6. Business-logic location (architecture): fat controllers vs delegated to Actions / Services / Jobs.
- Hint: read a few controller methods; `ls app/Actions app/Services`.
7. Route handler style: closures in `routes/*.php` vs controller classes.
- Hint: count `function ()` vs `::class` in `routes/web.php`, `routes/api.php`.
8. Middleware assignment: route/group `->middleware()` vs controller `HasMiddleware::middleware()` vs `#[Middleware]` attribute.
- Hint: grep `implements HasMiddleware`, `#[Middleware(` in controllers vs `->middleware(` in routes.
9. Route model binding: implicit (type-hinted models) vs explicit `Route::bind` vs manual `findOrFail`.
- Hint: typed model params in signatures vs `findOrFail(` in controllers; grep `Route::bind`.
10. Rate limiting: named `RateLimiter::for()` + `throttle:name` vs inline `throttle:60,1`.
- Hint: grep `RateLimiter::for` in providers vs `throttle:` in route files.
## C. Authorization
11. Authorization home: Gates (`Gate::define`) vs Policy classes in `app/Policies`.
- Hint: `ls app/Policies`; grep `Gate::define` in `app/Providers`.
12. Authorization call site: `$this->authorize()` / `Gate::authorize()` vs `$user->can()` vs `can` middleware vs `#[Authorize]` vs `@can` in Blade.
- Hint: grep `authorize(`, `->can(`, `middleware('can:`, `#[Authorize(`, `@can(`.
## D. Eloquent & models
13. Mass assignment: `$fillable` allow-list vs `$guarded` block-list.
- Hint: grep `protected $fillable` / `protected $guarded` in `app/Models`.
14. Accessors/mutators: modern `Attribute` class vs legacy `getXxxAttribute()` / `setXxxAttribute()`. Record a legacy hold, it goes against the tool's grain.
- Hint: grep `: Attribute` / `Attribute::make` vs `function get[A-Z].*Attribute` in `app/Models`.
15. Primary keys: auto-increment vs `HasUuids` vs `HasUlids`.
- Hint: grep `HasUuids` / `HasUlids` in `app/Models`; migration `id()` vs `uuid('id')`.
16. Custom casts: dedicated `CastsAttributes` classes (`app/Casts`) vs inline `Attribute` vs built-in cast strings.
- Hint: `ls app/Casts`; grep `Cast::class`, `AsStringable::class` in models.
17. Data/query layer (architecture): Eloquent directly in controllers vs repositories vs dedicated query objects (e.g. classes exposing `builder(): Builder`).
- Hint: `ls app/Repositories app/Queries`; see where non-trivial queries are built.
18. Query scopes: local `scope`/`#[Scope]` methods vs dedicated builder classes.
- Hint: grep `function scope` / `#[Scope]` in models; `ls app/*/Builders`.
19. Model events: observers (`app/Observers`, `#[ObservedBy]`) vs `booted()` closures vs event classes.
- Hint: `ls app/Observers`; grep `booted`, `::observe`, `#[ObservedBy]`.
20. Eager-load posture: explicit per-query `->with()` vs model-level `$with` defaults. Treat `preventLazyLoading()` separately as a development guard because it can complement either posture.
- Hint: grep `protected $with`, `->with(`, and separately `preventLazyLoading` in `app/`.
## E. Architecture & organization
21. Action/Service structure (architecture): Action classes (invoked via `handle` / `execute` / `__invoke`) vs service objects vs neither. Cross-check the Step 0 `app/` map: any `Actions`/`Services`/`Pipelines`/`Jobs`-as-actions folder is this pattern, so record how it is invoked.
- Hint: `ls app/` (the whole tree, not just `Actions`/`Services`); grep the invocation method in the folder you find.
22. DTOs (architecture): spatie/laravel-data vs plain readonly classes vs arrays everywhere.
- Hint: `ls app/Data`; grep `extends Data`, `readonly class` in `app/`.
23. Dependency acquisition: constructor/method injection vs `app()` / `resolve()` / `App::make()` service location.
- Hint: grep `app(` / `resolve(` / `::make(` in `app/` vs promoted constructor deps.
24. Decoupling: events + listeners vs direct service calls.
- Hint: `ls app/Events app/Listeners`; grep `event(`, `::dispatch(`.
25. Helper vs facade idiom: global helpers (`config()`, `auth()`, `response()`) vs facades (`Config::`, `Auth::`, `Response::`).
- Hint: ratio of `config(` vs `Config::` (etc.) across `app/`.
26. Namespace layout (architecture): default `app/` skeleton vs domain/module folders (`app/Domain/**`, modules).
- Hint: `ls app/`, look for `Domain/`, `Modules/`, bounded-context folders.
27. Enums: backed vs pure; case naming; where they live.
- Hint: `ls app/Enums`; grep `enum .*: string`, `enum .*: int`.
## F. Frontend & views
This app ships a frontend stack, so the items below apply.
28. Frontend stack: Blade+Livewire vs Inertia (Vue/React/Svelte) vs Blade-only / API + separate SPA.
- Hint: `composer.json` + `package.json`; `ls resources/js/pages`, `resources/views`.
29. Blade composition: class `<x-*>` components vs anonymous components (`@props`) vs `@include` partials.
- Hint: `ls app/View/Components`; grep `<x-`, `@include` in `resources/views`.
30. Livewire component format: Volt functional/class components, native Livewire 4 single-file (SFC), multi-file (MFC), view-based, or class-based components. Evaluate full-page vs nested separately because it is an independent usage choice.
- Hint: check the installed Livewire major and `livewire/volt`; inspect `app/Livewire`, `resources/views/livewire`, and Livewire 4 component/page directories for `@volt`, SFC, MFC, view-based, and class-based formats.
32. Localization: short keys (`lang/*/*.php` + `__('messages.welcome')`) vs JSON string keys (`lang/*.json` + `__('Full sentence')`).
- Hint: `ls lang`; grep dotted `__('` vs sentence keys.
## G. Database & migrations
33. Foreign keys: `foreignId()->constrained()` vs `foreignIdFor(Model::class)` vs manual `foreign()->references()->on()`.
- Hint: grep `foreignId(`, `foreignIdFor(`, `->foreign(` in `database/migrations`.
34. `down()` methods: real reverse logic vs omitted / one-way migrations.
- Hint: grep `function down` vs the migration count.
35. Enum storage: DB `enum()` column vs `string()` + PHP-enum cast on the model.
- Hint: grep `->enum(` in migrations vs string columns cast to enums.
36. Transactions: `DB::transaction(fn ...)` closure vs manual `beginTransaction` / `commit` / `rollBack`.
- Hint: grep `DB::transaction`, `beginTransaction` in `app/`.
37. Idempotent writes: `upsert` / `updateOrCreate` / `firstOrCreate` vs find-then-save.
- Hint: grep `upsert(`, `updateOrCreate(`, `firstOrCreate(` in `app/`.
## H. Testing
38. Framework: Pest (`it()` / `test()` / `expect()`) vs PHPUnit classes.
- Hint: `ls tests/Pest.php`; grep `it(` / `test(` vs `extends TestCase`.
39. DB reset: `RefreshDatabase` vs `DatabaseTruncation` vs `DatabaseMigrations`.
- Hint: grep those trait names in `tests/`.
40. Fixtures: compare how equivalent test-owned records are created, such as factories vs manual inserts. Track seeders separately for shared reference data because `$this->seed()` commonly and legitimately coexists with factories.
- Hint: grep `::factory(` and direct inserts in `tests/`; separately inspect `$this->seed(` calls and what those seeders provide.
41. Collaborator isolation: how the app doubles its own classes, Mockery `mock()` / `spy()` vs real integration. Ignore facade fakes like `Mail::fake()` here, they isolate framework services by default and are not a fork against Mockery.
- Hint: grep `->mock(`, `->spy(`, `Mockery::` in `tests/`.
42. Endpoint assertions: array `assertJson([...])` / `assertJsonFragment` vs fluent `AssertableJson`.
- Hint: grep `AssertableJson`, `assertJsonFragment` in `tests/`.
## I. Responses & API resources
43. Response shape: API Resource classes vs `response()->json()` vs returning models/arrays directly.
- Hint: `ls app/Http/Resources`; grep `JsonResource`, `->json(` in controllers.
44. Resource relationship inclusion: `whenLoaded()` guards vs unconditional relationship access. Do not count ordinary scalar attributes as rivals to conditional relationships, and evaluate general `when()` fields separately.
- Hint: compare relationship fields using `whenLoaded(` with unconditional relationship property access in `app/Http/Resources`.
45. Pagination contracts: within comparable endpoint categories, length-aware `paginate()` vs `simplePaginate()` vs `cursorPaginate()`. These have different totals, navigation, ordering, and performance contracts, so record only a stable path-scoped API policy, never a project-wide majority.
- Hint: grep those in `app/`, then group matches by endpoint type and client contract before comparing them.
46. Web redirects/URLs: `route('name')` vs `url('/path')` vs `action([...])`.
- Hint: grep `route('`, `url('/`, `action([` in `app/Http` and views.
## J. Strings, collections & dates
47. Iteration idiom: `collect()->map()->filter()` pipelines vs `array_map` / `foreach`.
- Hint: grep `collect(`, `->map(` vs `array_map`, `foreach` density in `app/`.
48. String API: fluent `Str::of()->...` (Stringable) vs static `Str::` vs native (`trim`, `strtoupper`).
- Hint: grep `Str::of(` vs `Str::` vs native string funcs.
49. Dates: compare equivalent construction call styles (`now()` / `today()` helpers vs `Carbon::`) separately from the application's mutable/immutable date policy. `Date::use(CarbonImmutable::class)` can make helpers return immutable dates, so those signals are complementary rather than conflicting.
- Hint: grep `now(` and `Carbon::` for call style; separately inspect `CarbonImmutable` and `Date::use` for mutability policy.
---
Genuine forks only. Every row survived the "no tool can decide this, and it isn't the default" filter. Give each applicable dimension exactly one verdict: pattern, conflict, default, no-signal, tooling-owned, or already-recorded. The rows tagged (architecture) are where the highest-value rules come from.
@@ -30,7 +30,8 @@ $articles = Article::whereHas('user', function ($q) {
Correct:
```php
public function scopeActive(Builder $query): Builder
#[Scope]
protected function active(Builder $query): Builder
{
return $query->where('verified', true)->whereNotNull('activated_at');
}
@@ -58,7 +59,8 @@ class PublishedScope implements Scope
Correct (local scope you opt into):
```php
public function scopePublished(Builder $query): Builder
#[Scope]
protected function published(Builder $query): Builder
{
return $query->where('published', true);
}
@@ -90,7 +90,7 @@ Correct:
## CSRF Protection
Include `@csrf` in all POST/PUT/DELETE Blade forms. In Inertia apps, the `@csrf` directive is automatically applied.
Include `@csrf` in all POST/PUT/DELETE Blade forms. Inertia doesn't use `@csrf`; its HTTP client sends the `XSRF-TOKEN` cookie back as the `X-XSRF-TOKEN` header, which Laravel accepts in place of the `_token` field.
Incorrect:
```blade
+16 -3
View File
@@ -53,14 +53,24 @@ REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
BOOKING_BACK_SEAT_ENABLED=
BOOKING_WHOLE_VEHICLE_ENABLED=
BOOKING_FRONT_SEAT_MAX_PER_BOOKING=
BOOKING_BACK_SEAT_ENABLED=true
BOOKING_WHOLE_VEHICLE_ENABLED=true
BOOKING_FRONT_SEAT_MAX_PER_BOOKING=1
BOOKING_ADMIN_EMAILS="example@gmail.com"
SMS_ENABLED=false
SMS_SERVER=
SMS_TOKEN=
SMS_SENDER=
KBZ_APP_ID=
KBZ_MERCHANT_CODE=
KBZ_MERCHANT_KEY=
KBZ_BASE_URL=
KBZ_CREATE_ORDER_URL=
KBZ_QUERY_ORDER_URL=
KBZ_REFUND_ORDER_URL=
KBZ_NOTIFY_URL=
KBZ_CERT_PATH=
KBZ_CERT_KEY_PATH=
@@ -83,3 +93,6 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
FASTAPI_AGENT_JWT_SECRET=
FASTAPI_AGENT_JWT_ALGORITHM=HS256
+88
View File
@@ -0,0 +1,88 @@
name: PHP Tests
on:
push:
branches: ['**']
pull_request:
jobs:
php-tests:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:18-alpine
env:
POSTGRES_DB: testing
POSTGRES_USER: root
POSTGRES_PASSWORD: ''
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DB_CONNECTION: pgsql
DB_HOST: postgres
DB_PORT: 5432
DB_DATABASE: testing
DB_USERNAME: root
DB_PASSWORD: ''
CACHE_STORE: array
CACHE_DRIVER: array
SESSION_DRIVER: array
QUEUE_CONNECTION: sync
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.5'
extensions: mbstring, bcmath, intl, gd, zip, pdo, pdo_pgsql, redis, pcntl
coverage: none
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Copy .env
run: cp .env.example .env
- name: Install Composer dependencies
run: composer install --no-interaction --prefer-dist --no-progress
- name: Install npm dependencies
run: npm ci
- name: Build frontend assets
run: npm run build
- name: Generate app key
run: php artisan key:generate
- name: Install postgresql-client
run: |
apt-get update
apt-get install -y postgresql-client
- name: Wait for Postgres
timeout-minutes: 1
run: |
until pg_isready -h postgres -p 5432 -U root; do
echo "Waiting for postgres..."
sleep 2
done
- name: Run migrations
run: php artisan migrate --force
- name: Run tests
run: php artisan test --compact
+9 -15
View File
@@ -7,22 +7,11 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
This application is a Laravel application running on PHP 8.5. You are an expert with the Laravel ecosystem. Always use the APIs that match the installed major version of each package — do not assume a version.
- php - 8.5
- filament/filament (FILAMENT) - v4
- laravel/framework (LARAVEL) - v13
- laravel/prompts (PROMPTS) - v0
- laravel/sanctum (SANCTUM) - v4
- livewire/livewire (LIVEWIRE) - v3
- laravel/boost (BOOST) - v2
- laravel/mcp (MCP) - v0
- laravel/pail (PAIL) - v1
- laravel/pint (PINT) - v1
- laravel/sail (SAIL) - v1
- pestphp/pest (PEST) - v4
- phpunit/phpunit (PHPUNIT) - v12
- tailwindcss (TAILWINDCSS) - v4
Before relying on a package's API, confirm its installed version:
- PHP packages: run `composer show --direct` to list direct dependencies with versions, or `composer show <vendor/package>` for a single package.
- JS packages: check `package.json` for the installed versions.
## Skills Activation
@@ -81,6 +70,11 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
3. Combine words and phrases for mixed queries: `middleware "rate limit"`.
4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`.
## Project Rules
- This project contains committed, area-grouped rules in `.ai/rules` when that directory exists (settled decisions, non-obvious traps, standing constraints). Framework and package guidelines that only apply to specific paths (testing, frontend, components) also live there, under `.ai/rules/boost` — this is not just recorded decisions, it is load-bearing guidance you have not seen inline. Before you enter plan mode or create/edit any file, you MUST first: open @.ai/rules/index.md (it maps file globs to rule files), read every rule file whose globs cover the path(s) in scope, and run `grep -rin 'keyword' .ai/rules` to catch what a path match alone misses. Do not write code until you have read and are following every matching rule. If `.ai/rules` does not exist, continue without it.
- Record durable rules with `record-rule` so the next agent or teammate inherits them instead of working them out again. Pass a `glob` (e.g. `app/Http/Controllers/**`), a short `title`, and a few-line `note`. Always use `record-rule`, never your native memory or notes tool — native memory is personal and session-scoped; only `.ai/rules` is shared with the team and persists in the repo.
## Artisan
- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
+9 -15
View File
@@ -7,22 +7,11 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
This application is a Laravel application running on PHP 8.5. You are an expert with the Laravel ecosystem. Always use the APIs that match the installed major version of each package — do not assume a version.
- php - 8.5
- filament/filament (FILAMENT) - v4
- laravel/framework (LARAVEL) - v13
- laravel/prompts (PROMPTS) - v0
- laravel/sanctum (SANCTUM) - v4
- livewire/livewire (LIVEWIRE) - v3
- laravel/boost (BOOST) - v2
- laravel/mcp (MCP) - v0
- laravel/pail (PAIL) - v1
- laravel/pint (PINT) - v1
- laravel/sail (SAIL) - v1
- pestphp/pest (PEST) - v4
- phpunit/phpunit (PHPUNIT) - v12
- tailwindcss (TAILWINDCSS) - v4
Before relying on a package's API, confirm its installed version:
- PHP packages: run `composer show --direct` to list direct dependencies with versions, or `composer show <vendor/package>` for a single package.
- JS packages: check `package.json` for the installed versions.
## Skills Activation
@@ -81,6 +70,11 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
3. Combine words and phrases for mixed queries: `middleware "rate limit"`.
4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`.
## Project Rules
- This project contains committed, area-grouped rules in `.ai/rules` when that directory exists (settled decisions, non-obvious traps, standing constraints). Framework and package guidelines that only apply to specific paths (testing, frontend, components) also live there, under `.ai/rules/boost` — this is not just recorded decisions, it is load-bearing guidance you have not seen inline. Before you enter plan mode or create/edit any file, you MUST first: open @.ai/rules/index.md (it maps file globs to rule files), read every rule file whose globs cover the path(s) in scope, and run `grep -rin 'keyword' .ai/rules` to catch what a path match alone misses. Do not write code until you have read and are following every matching rule. If `.ai/rules` does not exist, continue without it.
- Record durable rules with `record-rule` so the next agent or teammate inherits them instead of working them out again. Pass a `glob` (e.g. `app/Http/Controllers/**`), a short `title`, and a few-line `note`. Always use `record-rule`, never your native memory or notes tool — native memory is personal and session-scoped; only `.ai/rules` is shared with the team and persists in the repo.
## Artisan
- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
@@ -29,6 +29,8 @@ class BookingFactory extends Factory
'user_id' => null,
'openid' => null,
'ev_route_id' => EvRoute::factory(),
'linked_booking_id' => null,
'is_return_leg' => false,
'departure_time_slot_id' => DepartureTimeSlot::factory(),
'travel_date' => now()->addDay()->toDateString(),
'passenger_name' => $this->faker->name(),
@@ -41,8 +43,6 @@ class BookingFactory extends Factory
'dropoff_lng' => null,
'price' => $this->faker->randomFloat(2, 5000, 50000),
'status' => BookingStatus::PendingPayment,
'is_round_trip' => false,
'return_travel_date' => null,
'created_by_channel' => BookingChannel::MiniApp,
'driver_name' => null,
'driver_phone' => null,
@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 'notes' customer-supplied, submitted via the booking create API
* endpoint (StoreBookingRequest). 'remark' staff-only, set from the
* admin panel (SetRemarkTableAction); never exposed on the customer
* BookingResource. Both nullable, free text.
*/
public function up(): void
{
Schema::table('bookings', function (Blueprint $table) {
$table->text('notes')->nullable();
$table->text('remark')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('bookings', function (Blueprint $table) {
$table->dropColumn(['notes', 'remark']);
});
}
};
@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Round trip is redesigned as two linked one-way Booking rows (outbound
* + return) rather than a flag + a lone return date on a single row
* the return leg needs its own route/time-slot/price/driver-vehicle
* assignment, since it may run with a different vehicle than the
* outbound leg (domain.md §2b). `is_round_trip` becomes a computed
* accessor on the model (`linked_booking_id !== null`), so the column
* is dropped rather than kept redundant.
*/
public function up(): void
{
Schema::table('bookings', function (Blueprint $table) {
$table->dropColumn(['is_round_trip', 'return_travel_date']);
$table->foreignId('linked_booking_id')->nullable()->after('ev_route_id')
->constrained('bookings')->nullOnDelete();
$table->boolean('is_return_leg')->default(false)->after('linked_booking_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('bookings', function (Blueprint $table) {
$table->dropConstrainedForeignId('linked_booking_id');
$table->dropColumn('is_return_leg');
$table->boolean('is_round_trip')->default(false);
$table->date('return_travel_date')->nullable();
});
}
};
@@ -3,7 +3,7 @@
use Illuminate\Support\Facades\Route;
use Modules\Booking\Http\Controllers\BookingController;
Route::prefix('api/v1')->middleware(['api', 'auth:sanctum', 'throttle:api-write'])->group(function () {
Route::prefix('api/v1')->middleware(['api', 'api.auth', 'throttle:api-write'])->group(function () {
Route::get('/bookings', [BookingController::class, 'index'])->name('booking.bookings.index');
Route::get('/bookings/{booking:booking_ref}', [BookingController::class, 'show'])->name('booking.bookings.show');
Route::post('/bookings', [BookingController::class, 'store'])->name('booking.bookings.store');
@@ -4,6 +4,7 @@ namespace Modules\Booking\Actions;
use Modules\Booking\Data\AssignDriverData;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Events\DriverAssigned;
use Modules\Booking\Exceptions\DriverAssignmentNotAllowedException;
use Modules\Booking\Models\Booking;
@@ -21,6 +22,12 @@ class AssignDriverAction
throw DriverAssignmentNotAllowedException::notConfirmed($booking);
}
if ($booking->travel_date->lt(today())) {
throw DriverAssignmentNotAllowedException::travelDateInPast($booking);
}
$isFirstAssignment = $booking->driver_name === null;
$booking->update([
'driver_name' => $data->driverName,
'driver_phone' => $data->driverPhone,
@@ -28,6 +35,13 @@ class AssignDriverAction
'car_model' => $data->carModel,
]);
// Guards against a double-submit of the same form resulting in two
// identical SMS notifications to the passenger — a genuine
// reassignment always changes at least one of these columns.
if ($booking->wasChanged(['driver_name', 'driver_phone', 'car_plate_number', 'car_model'])) {
DriverAssigned::dispatch($booking, $isFirstAssignment);
}
return $booking;
}
}
@@ -7,6 +7,7 @@ use Modules\Booking\Data\CreateBookingData;
use Modules\Booking\Data\VehicleSelectionData;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Events\BookingCreated;
use Modules\Booking\Exceptions\InvalidReturnRouteException;
use Modules\Booking\Models\Booking;
use Modules\Booking\Services\BookingRefGenerator;
use Modules\Booking\Services\BookingService;
@@ -26,50 +27,110 @@ class CreateBookingAction
{
$this->bookingService->validateSelections($data->selections);
return DB::transaction(function () use ($data) {
$route = EvRoute::findOrFail($data->evRouteId);
$isRoundTrip = $data->returnEvRouteId !== null;
$lines = array_map(
fn (VehicleSelectionData $selection) => $this->priceSelection($route, $selection),
$data->selections,
if ($isRoundTrip) {
$this->bookingService->validateSelections($data->returnSelections);
}
return DB::transaction(function () use ($data, $isRoundTrip) {
$outboundRoute = EvRoute::findOrFail($data->evRouteId);
$outboundBooking = $this->createLeg(
data: $data,
route: $outboundRoute,
selections: $data->selections,
travelDate: $data->travelDate,
timeSlotId: $data->departureTimeSlotId,
isReturnLeg: false,
);
$totalPrice = array_reduce(
$lines,
fn (string $carry, array $line) => bcadd($carry, $line['line_total'], 2),
'0.00',
if (! $isRoundTrip) {
BookingCreated::dispatch($outboundBooking);
return $outboundBooking;
}
$returnRoute = EvRoute::findOrFail($data->returnEvRouteId);
if (! $returnRoute->isReverseOf($outboundRoute)) {
throw InvalidReturnRouteException::notReverseOfOutbound($returnRoute, $outboundRoute);
}
$returnBooking = $this->createLeg(
data: $data,
route: $returnRoute,
selections: $data->returnSelections,
travelDate: $data->returnTravelDate,
timeSlotId: $data->returnDepartureTimeSlotId,
isReturnLeg: true,
);
$booking = Booking::create([
'booking_ref' => $this->bookingRefGenerator->generate(),
'user_id' => $data->userId,
'openid' => $data->openid,
'ev_route_id' => $data->evRouteId,
'departure_time_slot_id' => $data->departureTimeSlotId,
'travel_date' => $data->travelDate,
'passenger_name' => $data->passengerName,
'passenger_phone' => $data->passengerPhone,
'pickup_address' => $data->pickupAddress,
'pickup_lat' => $data->pickupLat,
'pickup_lng' => $data->pickupLng,
'dropoff_address' => $data->dropoffAddress,
'dropoff_lat' => $data->dropoffLat,
'dropoff_lng' => $data->dropoffLng,
'price' => $totalPrice,
'status' => BookingStatus::PendingPayment,
'is_round_trip' => $data->isRoundTrip,
'return_travel_date' => $data->returnTravelDate,
'created_by_channel' => $data->createdByChannel,
]);
// Linked bidirectionally after both rows exist — a single
// `linked_booking_id` FK can't be set on either row at create
// time since the other side doesn't have an id yet.
$returnBooking->update(['linked_booking_id' => $outboundBooking->id]);
$outboundBooking->update(['linked_booking_id' => $returnBooking->id]);
$booking->vehicleOptions()->createMany($lines);
// No registered listeners on BookingCreated today, so firing it
// twice per round-trip creation has no side effects — flagged
// here for whoever adds the first listener.
BookingCreated::dispatch($outboundBooking);
BookingCreated::dispatch($returnBooking);
BookingCreated::dispatch($booking);
return $booking;
return $outboundBooking->refresh();
});
}
/**
* @param list<VehicleSelectionData> $selections
*/
private function createLeg(
CreateBookingData $data,
EvRoute $route,
array $selections,
string $travelDate,
int $timeSlotId,
bool $isReturnLeg,
): Booking {
$lines = array_map(
fn (VehicleSelectionData $selection) => $this->priceSelection($route, $selection),
$selections,
);
$totalPrice = array_reduce(
$lines,
fn (string $carry, array $line) => bcadd($carry, $line['line_total'], 2),
'0.00',
);
$booking = Booking::create([
'booking_ref' => $this->bookingRefGenerator->generate(),
'user_id' => $data->userId,
'openid' => $data->openid,
'ev_route_id' => $route->id,
'is_return_leg' => $isReturnLeg,
'departure_time_slot_id' => $timeSlotId,
'travel_date' => $travelDate,
'passenger_name' => $data->passengerName,
'passenger_phone' => $data->passengerPhone,
'notes' => $data->notes,
'pickup_address' => $data->pickupAddress,
'pickup_lat' => $data->pickupLat,
'pickup_lng' => $data->pickupLng,
'dropoff_address' => $data->dropoffAddress,
'dropoff_lat' => $data->dropoffLat,
'dropoff_lng' => $data->dropoffLng,
'price' => $totalPrice,
'status' => BookingStatus::PendingPayment,
'created_by_channel' => $data->createdByChannel,
]);
$booking->vehicleOptions()->createMany($lines);
return $booking;
}
/**
* @return array{vehicle_option: VehicleOption, passenger_count: int, unit_price: string, line_total: string}
*/
@@ -0,0 +1,21 @@
<?php
namespace Modules\Booking\Actions;
use Modules\Booking\Models\Booking;
/**
* Staff-only internal note, set from the admin panel
* (SetRemarkTableAction). No status restriction staff can annotate a
* booking at any point in its lifecycle. Never exposed on the customer
* BookingResource.
*/
class SetRemarkAction
{
public function handle(Booking $booking, ?string $remark): Booking
{
$booking->update(['remark' => $remark]);
return $booking;
}
}
@@ -9,6 +9,9 @@ readonly class CreateBookingData
/**
* @param list<VehicleSelectionData> $selections One or more Vehicle Option
* selections (e.g. front_seat + back_seat) domain.md §2.
* @param list<VehicleSelectionData>|null $returnSelections Same shape as $selections,
* priced independently against $returnEvRouteId. Presence of
* $returnEvRouteId is the round-trip signal (domain.md §2b).
*/
public function __construct(
public int $evRouteId,
@@ -22,11 +25,14 @@ readonly class CreateBookingData
public BookingChannel $createdByChannel,
public ?int $userId = null,
public ?string $openid = null,
public ?string $notes = null,
public ?float $pickupLat = null,
public ?float $pickupLng = null,
public ?float $dropoffLat = null,
public ?float $dropoffLng = null,
public bool $isRoundTrip = false,
public ?int $returnEvRouteId = null,
public ?int $returnDepartureTimeSlotId = null,
public ?string $returnTravelDate = null,
public ?array $returnSelections = null,
) {}
}
@@ -7,10 +7,29 @@ namespace Modules\Booking\Enums;
*/
enum BookingChannel: string
{
case MiniApp = 'mini_app';
case MiniApp = 'kbz_miniapp';
case Android = 'android';
case Ios = 'ios';
case Web = 'web';
case Agent = 'agent';
case Admin = 'admin';
/**
* Resolve the client's channel from its `Device-Type` header, defaulting
* to MiniApp when the header is missing or unrecognized. Agent/Admin are
* deliberately excluded from what a header can select those two are
* derived from how the request authenticated (FastAPI JWT, Filament),
* never a client-supplied value, so a customer can't spoof one via the
* header.
*/
public static function fromDeviceTypeHeader(?string $deviceType): self
{
$channel = self::tryFrom((string) $deviceType);
if ($channel === null || in_array($channel, [self::Agent, self::Admin], true)) {
return self::MiniApp;
}
return $channel;
}
}
@@ -0,0 +1,20 @@
<?php
namespace Modules\Booking\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Modules\Booking\Models\Booking;
/**
* Fired whenever AssignDriverAction sets or updates a booking's
* driver/vehicle details covers both the first assignment and any later
* reassignment, since both go through the same action. $isFirstAssignment
* lets listeners (e.g. the SMS notification) word the message differently
* for "driver assigned" vs "driver info updated".
*/
class DriverAssigned
{
use Dispatchable;
public function __construct(public Booking $booking, public bool $isFirstAssignment) {}
}
@@ -16,6 +16,13 @@ class DriverAssignmentNotAllowedException extends RuntimeException
);
}
public static function travelDateInPast(Booking $booking): self
{
return new self(
"Booking [{$booking->booking_ref}] cannot have a driver assigned because its travel date [{$booking->travel_date->toDateString()}] is in the past."
);
}
public function render(Request $request): ?JsonResponse
{
if ($request->expectsJson()) {
@@ -0,0 +1,32 @@
<?php
namespace Modules\Booking\Exceptions;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\Routing\Models\EvRoute;
use RuntimeException;
class InvalidReturnRouteException extends RuntimeException
{
public static function notReverseOfOutbound(EvRoute $returnRoute, EvRoute $outboundRoute): self
{
return new self(
"Return route [{$returnRoute->id}] is not the reverse of outbound route [{$outboundRoute->id}] — ".
'from/to destinations must be swapped.'
);
}
/**
* A rejected return route is a client input problem, not a server
* error surface it as 422, matching InvalidVehicleSelectionException.
*/
public function render(Request $request): ?JsonResponse
{
if ($request->expectsJson()) {
return response()->json(['message' => $this->getMessage()], 422);
}
return null;
}
}
@@ -25,6 +25,7 @@ class AssignDriverTableAction
->icon(Heroicon::OutlinedTruck)
->color('primary')
->visible(fn (Booking $record): bool => $record->status === BookingStatus::Confirmed
&& $record->travel_date->gte(today())
&& (auth()->user()?->can('manage_bookings') ?? false))
->schema([
TextInput::make('driver_name')->required(),
@@ -0,0 +1,40 @@
<?php
namespace Modules\Booking\Filament\Resources\Bookings\Actions;
use Filament\Actions\Action;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Support\Icons\Heroicon;
use Modules\Booking\Actions\SetRemarkAction;
use Modules\Booking\Models\Booking;
/**
* Shared between BookingsTable (row action) and ViewBooking (header action)
* so both surfaces stay in sync one definition, not two.
*/
class SetRemarkTableAction
{
public static function make(): Action
{
return Action::make('setRemark')
->label('Remark')
->icon(Heroicon::OutlinedPencilSquare)
->color('gray')
->visible(fn (): bool => auth()->user()?->can('manage_bookings') ?? false)
->schema([
Textarea::make('remark')->maxLength(1000),
])
->fillForm(fn (Booking $record): array => [
'remark' => $record->remark,
])
->action(function (array $data, Booking $record, SetRemarkAction $setRemarkAction) {
$setRemarkAction->handle($record, $data['remark'] ?: null);
Notification::make()
->title('Remark saved')
->success()
->send();
});
}
}
@@ -5,6 +5,7 @@ namespace Modules\Booking\Filament\Resources\Bookings\Pages;
use Filament\Resources\Pages\ViewRecord;
use Modules\Booking\Filament\Resources\Bookings\Actions\AssignDriverTableAction;
use Modules\Booking\Filament\Resources\Bookings\Actions\CancelBookingTableAction;
use Modules\Booking\Filament\Resources\Bookings\Actions\SetRemarkTableAction;
use Modules\Booking\Filament\Resources\Bookings\BookingResource;
class ViewBooking extends ViewRecord
@@ -15,6 +16,7 @@ class ViewBooking extends ViewRecord
{
return [
AssignDriverTableAction::make(),
SetRemarkTableAction::make(),
CancelBookingTableAction::make(),
];
}
@@ -8,6 +8,7 @@ use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Filament\Resources\Bookings\BookingResource;
use Modules\Payment\Enums\PaymentStatus;
class BookingInfolist
@@ -32,7 +33,8 @@ class BookingInfolist
TextEntry::make('created_by_channel')->badge(),
TextEntry::make('created_at')->dateTime(),
]),
]),
])
->columnSpanFull(),
Section::make('Trip')
->schema([
Grid::make(3)
@@ -43,10 +45,17 @@ class BookingInfolist
TextEntry::make('timeSlot.label')->label('Time Slot'),
TextEntry::make('travel_date')->date(),
TextEntry::make('is_round_trip')->label('Round Trip')->badge(),
TextEntry::make('return_travel_date')->date()
TextEntry::make('is_return_leg')->label('Leg')->badge()
->formatStateUsing(fn (bool $state) => $state ? 'Return' : 'Outbound')
->visible(fn ($record) => $record->is_round_trip),
TextEntry::make('linkedBooking.booking_ref')->label('Linked Leg')
->visible(fn ($record) => $record->is_round_trip)
->url(fn ($record) => $record->linked_booking_id
? BookingResource::getUrl('view', ['record' => $record->linked_booking_id])
: null),
]),
]),
])
->columnSpanFull(),
Section::make('Vehicle Options')
->schema([
RepeatableEntry::make('vehicleOptions')
@@ -61,15 +70,29 @@ class BookingInfolist
]),
]),
TextEntry::make('price')->label('Total Price')->numeric(2),
]),
])
->columnSpanFull(),
Section::make('Passenger')
->schema([
Grid::make(2)
->schema([
TextEntry::make('passenger_name'),
TextEntry::make('passenger_phone'),
TextEntry::make('notes')
->label('Customer Notes')
->placeholder('—')
->columnSpanFull(),
]),
]),
])
->columnSpanFull(),
Section::make('Staff Remark')
->description('Internal only — never shown to the customer. Set via the Remark action.')
->schema([
TextEntry::make('remark')
->label('')
->placeholder('No remark yet.'),
])
->columnSpanFull(),
Section::make('Pickup & Dropoff')
->schema([
Grid::make(2)
@@ -81,7 +104,8 @@ class BookingInfolist
TextEntry::make('pickup_lng')->label('Pickup Lng')->placeholder('—'),
TextEntry::make('dropoff_lng')->label('Dropoff Lng')->placeholder('—'),
]),
]),
])
->columnSpanFull(),
Section::make('Driver & Vehicle')
->description('Filled in by staff once the booking is confirmed — see the Assign Driver action.')
->schema([
@@ -92,18 +116,20 @@ class BookingInfolist
TextEntry::make('car_plate_number')->label('Car Plate')->placeholder('Not yet assigned'),
TextEntry::make('car_model')->label('Car Model')->placeholder('—'),
]),
]),
])
->columnSpanFull(),
// A booking can have more than one payment attempt if an
// earlier one failed and the customer retried (domain.md §1)
// — full detail (gateway response, refunds) lives on the
// Payment/Refund Filament resources (T5.13), this is just a
// quick-glance summary from the booking side.
Section::make('Payments')
->columnSpanFull()
->schema([
RepeatableEntry::make('payments')
->label('')
->schema([
Grid::make(6)
Grid::make(8)
->schema([
TextEntry::make('gateway')->badge(),
TextEntry::make('status')
@@ -116,6 +142,7 @@ class BookingInfolist
TextEntry::make('amount')->numeric(2),
TextEntry::make('currency'),
TextEntry::make('gateway_transaction_id')->label('Gateway Txn ID')->placeholder('—'),
TextEntry::make('gateway_payload.mm_order_id')->label('Transaction ID')->placeholder('—')->columnSpan(2),
TextEntry::make('completed_at')->dateTime()->placeholder('—'),
]),
])
@@ -4,6 +4,8 @@ namespace Modules\Booking\Filament\Resources\Bookings\Tables;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Toggle;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
@@ -15,6 +17,7 @@ use Modules\Booking\Filament\Resources\Bookings\Actions\AssignDriverTableAction;
use Modules\Booking\Filament\Resources\Bookings\Actions\CancelBookingTableAction;
use Modules\Booking\Filament\Resources\Bookings\Actions\DeleteBookingTableAction;
use Modules\Booking\Filament\Resources\Bookings\Actions\RestoreBookingTableAction;
use Modules\Booking\Filament\Resources\Bookings\Actions\SetRemarkTableAction;
use Modules\Booking\Models\Booking;
use Modules\Catalog\Models\EvCompany;
use Modules\Routing\Models\EvRoute;
@@ -54,6 +57,10 @@ class BookingsTable
->sortable(),
TextColumn::make('timeSlot.label')
->label('Time Slot'),
IconColumn::make('is_round_trip')
->label('Round Trip')
->boolean()
->toggleable(),
TextColumn::make('vehicleOptions')
->label('Vehicle Options')
->state(fn (Booking $record) => $record->vehicleOptions
@@ -79,6 +86,16 @@ class BookingsTable
->join(' • ') ?: null)
->searchable(['driver_name', 'driver_phone', 'car_plate_number', 'car_model'])
->toggleable(),
TextColumn::make('notes')
->label('Customer Notes')
->placeholder('—')
->limit(50)
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('remark')
->label('Staff Remark')
->placeholder('—')
->limit(50)
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('created_at')
->dateTime()
->sortable()
@@ -112,6 +129,15 @@ class BookingsTable
$data['value'] ?? null,
fn (Builder $q, $companyId) => $q->whereHas('route', fn (Builder $rq) => $rq->where('ev_company_id', $companyId)),
)),
// is_round_trip is a computed accessor (linked_booking_id
// !== null), not a DB column — TernaryFilter builds a raw
// where() on it, which breaks now that the column is gone.
Filter::make('is_round_trip')
->schema([Toggle::make('is_round_trip')])
->query(fn (Builder $query, array $data) => $query->when(
$data['is_round_trip'] ?? null,
fn (Builder $q) => $q->whereNotNull('linked_booking_id'),
)),
// Deleted bookings are soft-deleted, not hard-removed
// (domain.md; T7.x follow-up) — this is the only place they
// become visible again, off by default.
@@ -120,6 +146,7 @@ class BookingsTable
->recordActions([
ViewAction::make(),
AssignDriverTableAction::make(),
SetRemarkTableAction::make(),
CancelBookingTableAction::make(),
DeleteBookingTableAction::make(),
RestoreBookingTableAction::make(),
@@ -0,0 +1,36 @@
<?php
namespace Modules\Booking\Filament\Widgets;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Models\Booking;
/**
* Dispatch-facing snapshot of today's trips "today" means travel_date, not
* created_at, since this is what staff care about when assigning
* drivers/vehicles (domain.md §5a), not how many bookings were made today.
*/
class BookingsTodayWidget extends BaseWidget
{
protected function getStats(): array
{
$today = Booking::query()->whereDate('travel_date', today());
$confirmedToday = (clone $today)->where('status', BookingStatus::Confirmed)->count();
$pendingToday = (clone $today)->where('status', BookingStatus::PendingPayment)->count();
return [
Stat::make('Trips Today', (clone $today)->count())
->description('Bookings scheduled for today')
->color('primary'),
Stat::make('Confirmed', $confirmedToday)
->description('Paid & ready for driver assignment')
->color('success'),
Stat::make('Awaiting Payment', $pendingToday)
->description('Still pending_payment')
->color($pendingToday > 0 ? 'warning' : 'gray'),
];
}
}
@@ -0,0 +1,52 @@
<?php
namespace Modules\Booking\Filament\Widgets;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Filament\Widgets\TableWidget as BaseWidget;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Models\Booking;
class RecentBookingsTableWidget extends BaseWidget
{
protected static ?int $sort = 2;
protected int|string|array $columnSpan = 'full';
public function table(Table $table): Table
{
return $table
->heading('Recent Bookings')
->query(
Booking::query()
->with(['route.fromDestination', 'route.toDestination'])
->latest('created_at')
->limit(10),
)
->columns([
TextColumn::make('booking_ref')
->label('Ref'),
TextColumn::make('status')
->badge()
->color(fn (BookingStatus $state) => match ($state) {
BookingStatus::PendingPayment => 'warning',
BookingStatus::Confirmed => 'success',
BookingStatus::Cancelled => 'gray',
BookingStatus::Expired => 'danger',
}),
TextColumn::make('route.fromDestination.name')
->label('From'),
TextColumn::make('route.toDestination.name')
->label('To'),
TextColumn::make('travel_date')
->date(),
TextColumn::make('price')
->numeric(2),
TextColumn::make('created_at')
->dateTime()
->since(),
])
->paginated(false);
}
}
@@ -15,6 +15,7 @@ use Modules\Booking\Enums\BookingChannel;
use Modules\Booking\Http\Requests\StoreBookingRequest;
use Modules\Booking\Http\Resources\BookingResource;
use Modules\Booking\Models\Booking;
use Modules\Payment\Enums\PaymentStatus;
use Modules\Shared\Enums\VehicleOption;
class BookingController extends Controller
@@ -22,7 +23,11 @@ class BookingController extends Controller
/**
* @var list<string>
*/
private const EAGER_LOADS = ['route', 'timeSlot', 'vehicleOptions'];
private const EAGER_LOADS = [
'route', 'timeSlot', 'vehicleOptions',
'linkedBooking.route.company', 'linkedBooking.route.fromDestination', 'linkedBooking.route.toDestination',
'linkedBooking.timeSlot', 'linkedBooking.vehicleOptions',
];
public function __construct(
private CreateBookingAction $createBookingAction,
@@ -31,10 +36,32 @@ class BookingController extends Controller
public function index(Request $request): AnonymousResourceCollection
{
Gate::authorize('viewAny', Booking::class);
$openid = $request->attributes->get('fastapi_openid');
$bookings = Booking::query()
->where('user_id', $request->user()->id)
$query = Booking::query();
if ($openid !== null) {
// FastAPI agent (JWT auth, no Laravel user) — scoped to the
// verified token's own openid, never a client-supplied value,
// so one agent session can't list another customer's bookings.
$query->where('openid', $openid);
} else {
Gate::authorize('viewAny', Booking::class);
$query->where('user_id', $request->user()->id);
}
$bookings = $query
// Only bookings that actually have a completed payment — a
// pending_payment booking never had money move, so it's noise
// in a booking list, not a real reservation to show.
->whereHas('payments', fn ($paymentQuery) => $paymentQuery->where('status', PaymentStatus::Completed))
// A round trip is two Booking rows (outbound + return leg,
// linked via linked_booking_id — domain.md §2b), but it should
// still surface once here, not as two separate list entries.
// The outbound row's `linked_booking` already carries the
// return leg's full detail (including vehicle_options).
->where('is_return_leg', false)
->when($request->filled('booking_ref'), fn ($q) => $q->where('booking_ref', 'ilike', '%'.$request->string('booking_ref').'%'))
->with(self::EAGER_LOADS)
->latest()
->paginate();
@@ -42,9 +69,15 @@ class BookingController extends Controller
return BookingResource::collection($bookings);
}
public function show(Booking $booking): BookingResource
public function show(Request $request, Booking $booking): BookingResource
{
Gate::authorize('view', $booking);
$openid = $request->attributes->get('fastapi_openid');
if ($openid !== null) {
abort_if($booking->openid !== $openid, 404);
} else {
Gate::authorize('view', $booking);
}
return new BookingResource($booking->load(self::EAGER_LOADS));
}
@@ -52,6 +85,11 @@ class BookingController extends Controller
public function store(StoreBookingRequest $request): JsonResponse
{
$validated = $request->validated();
$openid = $request->attributes->get('fastapi_openid');
if ($openid === null) {
Gate::authorize('create', Booking::class);
}
$selections = array_map(
fn (array $selection) => new VehicleSelectionData(
@@ -61,6 +99,26 @@ class BookingController extends Controller
$validated['selections'],
);
$isRoundTrip = $validated['is_round_trip'] ?? false;
$returnSelections = $isRoundTrip
? array_map(
fn (array $selection) => new VehicleSelectionData(
vehicleOption: VehicleOption::from($selection['vehicle_option']),
passengerCount: $selection['passenger_count'],
),
$validated['return_selections'],
)
: null;
// The agent's own auth path always wins over anything a header could
// claim; customer channels come from Device-Type, not a
// client-supplied body field (BookingChannel::fromDeviceTypeHeader
// already refuses to hand back Agent/Admin from a header value).
$channel = $openid !== null
? BookingChannel::Agent
: BookingChannel::fromDeviceTypeHeader($request->header('Device-Type'));
$booking = $this->createBookingAction->handle(new CreateBookingData(
evRouteId: $validated['ev_route_id'],
departureTimeSlotId: $validated['departure_time_slot_id'],
@@ -68,19 +126,23 @@ class BookingController extends Controller
selections: $selections,
passengerName: $validated['passenger_name'],
passengerPhone: $validated['passenger_phone'],
notes: $validated['notes'] ?? null,
pickupAddress: $validated['pickup_address'],
dropoffAddress: $validated['dropoff_address'],
createdByChannel: isset($validated['created_by_channel'])
? BookingChannel::from($validated['created_by_channel'])
: BookingChannel::MiniApp,
createdByChannel: $channel,
// A verified FastAPI JWT's own openid always wins over a
// client-supplied one — a request can never claim a different
// customer's identity than its own token proves.
userId: $request->user()?->id,
openid: $validated['openid'] ?? null,
openid: $openid ?? $validated['openid'] ?? null,
pickupLat: $validated['pickup_lat'] ?? null,
pickupLng: $validated['pickup_lng'] ?? null,
dropoffLat: $validated['dropoff_lat'] ?? null,
dropoffLng: $validated['dropoff_lng'] ?? null,
isRoundTrip: $validated['is_round_trip'] ?? false,
returnEvRouteId: $isRoundTrip ? $validated['return_ev_route_id'] : null,
returnDepartureTimeSlotId: $isRoundTrip ? $validated['return_departure_time_slot_id'] : null,
returnTravelDate: $validated['return_travel_date'] ?? null,
returnSelections: $returnSelections,
));
return (new BookingResource($booking->load(self::EAGER_LOADS)))
@@ -4,7 +4,6 @@ namespace Modules\Booking\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Modules\Booking\Enums\BookingChannel;
use Modules\Shared\Enums\VehicleOption;
/**
@@ -35,17 +34,24 @@ class StoreBookingRequest extends FormRequest
'selections.*.passenger_count' => ['required', 'integer', 'min:1'],
'passenger_name' => ['required', 'string', 'max:255'],
'passenger_phone' => ['required', 'string', 'max:50'],
'notes' => ['nullable', 'string', 'max:1000'],
'pickup_address' => ['required', 'string', 'max:500'],
'pickup_lat' => ['nullable', 'numeric', 'between:-90,90'],
'pickup_lng' => ['nullable', 'numeric', 'between:-180,180'],
'dropoff_address' => ['required', 'string', 'max:500'],
'dropoff_lat' => ['nullable', 'numeric', 'between:-90,90'],
'dropoff_lng' => ['nullable', 'numeric', 'between:-180,180'],
'openid' => ['nullable', 'string', 'max:255'],
// Round trip = a second, independently-priced leg on its own
// route/time-slot/date — the return route must already exist as
// a catalog EvRoute and is validated server-side as the true
// reverse of ev_route_id (EvRoute::isReverseOf, domain.md §2b).
'is_round_trip' => ['sometimes', 'boolean'],
'return_travel_date' => ['nullable', 'date', 'required_if:is_round_trip,true'],
// Admin-created bookings go through the Filament resource (T4.7), not this API.
'created_by_channel' => ['sometimes', Rule::enum(BookingChannel::class)->except(BookingChannel::Admin)],
'return_ev_route_id' => ['required_if:is_round_trip,true', 'integer', 'exists:ev_routes,id'],
'return_departure_time_slot_id' => ['required_if:is_round_trip,true', 'integer', 'exists:departure_time_slots,id'],
'return_travel_date' => ['required_if:is_round_trip,true', 'date', 'after_or_equal:travel_date'],
'return_selections' => ['required_if:is_round_trip,true', 'array', 'min:1'],
'return_selections.*.vehicle_option' => ['required_if:is_round_trip,true', Rule::enum(VehicleOption::class)],
'return_selections.*.passenger_count' => ['required_if:is_round_trip,true', 'integer', 'min:1'],
];
}
}
@@ -20,9 +20,10 @@ class BookingResource extends JsonResource
'status' => $this->status,
'travel_date' => $this->travel_date?->toDateString(),
'is_round_trip' => $this->is_round_trip,
'return_travel_date' => $this->return_travel_date?->toDateString(),
'is_return_leg' => $this->is_return_leg,
'passenger_name' => $this->passenger_name,
'passenger_phone' => $this->passenger_phone,
'notes' => $this->notes,
'pickup_address' => $this->pickup_address,
'pickup_lat' => $this->pickup_lat,
'pickup_lng' => $this->pickup_lng,
@@ -30,6 +31,15 @@ class BookingResource extends JsonResource
'dropoff_lat' => $this->dropoff_lat,
'dropoff_lng' => $this->dropoff_lng,
'price' => $this->price,
// This leg's own price, same value CancelBookingAction/
// RefundBookingAction use for this specific leg. total_price is
// the round-trip total (this leg + linked leg) — computed here,
// not left to the client to sum, since it must always match what
// InitiatePaymentAction actually charges (bcadd, same as there).
// Equal to `price` for a plain one-way booking.
'total_price' => $this->relationLoaded('linkedBooking') && $this->linkedBooking !== null
? bcadd((string) $this->price, (string) $this->linkedBooking->price, 2)
: $this->price,
'created_by_channel' => $this->created_by_channel,
// Only ever populated once status is confirmed — see AssignDriverAction.
'driver_name' => $this->driver_name,
@@ -53,6 +63,44 @@ class BookingResource extends JsonResource
'label' => $this->timeSlot->label,
'time' => $this->timeSlot->time?->format('H:i'),
]),
// Hand-built, not a nested BookingResource — the linked leg's
// own linked_booking points right back here, so nesting the
// full resource would recurse forever (domain.md §2b).
'linked_booking' => $this->whenLoaded('linkedBooking', fn () => [
'id' => $this->linkedBooking->id,
'booking_ref' => $this->linkedBooking->booking_ref,
'status' => $this->linkedBooking->status,
'travel_date' => $this->linkedBooking->travel_date?->toDateString(),
'is_return_leg' => $this->linkedBooking->is_return_leg,
'route' => $this->linkedBooking->relationLoaded('route') ? [
'id' => $this->linkedBooking->route->id,
'ev_company_id' => $this->linkedBooking->route->ev_company_id,
'from_destination_id' => $this->linkedBooking->route->from_destination_id,
'to_destination_id' => $this->linkedBooking->route->to_destination_id,
] : null,
'time_slot' => $this->linkedBooking->relationLoaded('timeSlot') ? [
'id' => $this->linkedBooking->timeSlot->id,
'label' => $this->linkedBooking->timeSlot->label,
'time' => $this->linkedBooking->timeSlot->time?->format('H:i'),
] : null,
'vehicle_options' => $this->linkedBooking->relationLoaded('vehicleOptions')
? $this->linkedBooking->vehicleOptions->map(fn ($selection) => [
'vehicle_option' => $selection->vehicle_option,
'passenger_count' => $selection->passenger_count,
'unit_price' => $selection->unit_price,
'line_total' => $selection->line_total,
])
: null,
// Each leg gets its own independent driver/vehicle
// assignment — the return leg is never guaranteed the same
// car as the outbound leg (domain.md §2b). Only ever
// populated once that leg's own status is confirmed — see
// AssignDriverAction.
'driver_name' => $this->linkedBooking->driver_name,
'driver_phone' => $this->linkedBooking->driver_phone,
'car_plate_number' => $this->linkedBooking->car_plate_number,
'car_model' => $this->linkedBooking->car_model,
]),
'created_at' => $this->created_at,
];
}
@@ -0,0 +1,50 @@
<?php
namespace Modules\Booking\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Modules\Booking\Events\DriverAssigned;
use Modules\Shared\Sms\SmsService;
/**
* Notifies the passenger of their driver/car details whenever a driver is
* assigned or reassigned (domain.md driver/vehicle assignment). Queued
* since it's an outbound HTTP call to the SMS gateway.
*/
class SendDriverAssignedSms implements ShouldQueue
{
public function __construct(private readonly SmsService $smsService) {}
public function handle(DriverAssigned $event): void
{
$booking = $event->booking;
$this->smsService->send($booking->passenger_phone, $this->message($event));
}
private function message(DriverAssigned $event): string
{
$booking = $event->booking;
$vehicle = trim($booking->car_model !== null
? "{$booking->car_plate_number} ({$booking->car_model})"
: $booking->car_plate_number);
$route = $booking->route->fromDestination->name.' - '.$booking->route->toDestination->name;
$mmRoute = $booking->route->fromDestination->mm_name.' - '.$booking->route->toDestination->mm_name;
$appName = 'BNF Express - '.config('app.name');
$supportPhone = config('app.support_phone');
$supportEmail = config('app.support_email');
$contact = "Help: {$supportPhone} / {$supportEmail}\nအကူအညီလိုအပ်ပါက ဆက်သွယ်ရန်: {$supportPhone} / {$supportEmail}";
if ($event->isFirstAssignment) {
$en = "Your driver has been assigned for booking {$booking->booking_ref} ({$route}). Driver: {$booking->driver_name}, {$booking->driver_phone}. Vehicle: {$vehicle}.";
$mm = "ဘွတ်ကင် {$booking->booking_ref} ({$mmRoute}) အတွက် ယာဉ်မောင်း သတ်မှတ်ပြီးပါပြီ။ ယာဉ်မောင်း - {$booking->driver_name}, {$booking->driver_phone}။ ယာဉ် - {$vehicle}";
} else {
$en = "Driver info updated for booking {$booking->booking_ref} ({$route}). Driver: {$booking->driver_name}, {$booking->driver_phone}. Vehicle: {$vehicle}.";
$mm = "ဘွတ်ကင် {$booking->booking_ref} ({$mmRoute}) ၏ ယာဉ်မောင်းအချက်အလက်ကို ပြင်ဆင်ထားပါသည်။ ယာဉ်မောင်း - {$booking->driver_name}, {$booking->driver_phone}။ ယာဉ် - {$vehicle}";
}
return "{$appName}\n{$en}\n{$mm}\n{$contact}";
}
}
+29 -4
View File
@@ -3,6 +3,7 @@
namespace Modules\Booking\Models;
use App\Models\User;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -43,10 +44,14 @@ class Booking extends Model
'user_id',
'openid',
'ev_route_id',
'linked_booking_id',
'is_return_leg',
'departure_time_slot_id',
'travel_date',
'passenger_name',
'passenger_phone',
'notes',
'remark',
'pickup_address',
'pickup_lat',
'pickup_lng',
@@ -55,8 +60,6 @@ class Booking extends Model
'dropoff_lng',
'price',
'status',
'is_round_trip',
'return_travel_date',
'created_by_channel',
'driver_name',
'driver_phone',
@@ -77,8 +80,7 @@ class Booking extends Model
'dropoff_lng' => 'decimal:7',
'price' => 'decimal:2',
'status' => BookingStatus::class,
'is_round_trip' => 'boolean',
'return_travel_date' => 'date',
'is_return_leg' => 'boolean',
'created_by_channel' => BookingChannel::class,
];
}
@@ -93,6 +95,16 @@ class Booking extends Model
return $this->belongsTo(EvRoute::class, 'ev_route_id');
}
/**
* The other leg of a round trip (outbound <-> return), linked
* bidirectionally by CreateBookingAction. Null for a plain one-way
* booking see the `isRoundTrip()` accessor (domain.md §2b).
*/
public function linkedBooking(): BelongsTo
{
return $this->belongsTo(Booking::class, 'linked_booking_id');
}
public function timeSlot(): BelongsTo
{
return $this->belongsTo(DepartureTimeSlot::class, 'departure_time_slot_id');
@@ -107,4 +119,17 @@ class Booking extends Model
{
return $this->hasMany(Payment::class);
}
/**
* True when this booking has a linked leg i.e. it's one half of a
* round trip. Computed, not stored: presence of `linked_booking_id` is
* the single source of truth, so it can't drift out of sync the way a
* separate flag column could (domain.md §2b).
*/
public function isRoundTrip(): Attribute
{
return Attribute::make(
get: fn (): bool => $this->linked_booking_id !== null,
);
}
}
@@ -3,7 +3,10 @@
namespace Modules\Booking\Providers;
use Illuminate\Contracts\Auth\Access\Gate;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Modules\Booking\Events\DriverAssigned;
use Modules\Booking\Listeners\SendDriverAssignedSms;
use Modules\Booking\Policies\BookingPolicy;
class BookingServiceProvider extends ServiceProvider
@@ -13,5 +16,7 @@ class BookingServiceProvider extends ServiceProvider
public function boot(Gate $gate): void
{
$gate->policy('Modules\Booking\Models\Booking', BookingPolicy::class);
// Event::listen(DriverAssigned::class, SendDriverAssignedSms::class);
}
}
@@ -1,6 +1,7 @@
<?php
use App\Models\User;
use Modules\Booking\Enums\BookingChannel;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Models\Booking;
use Modules\Catalog\Models\DepartureTimeSlot;
@@ -176,3 +177,193 @@ test('shape validation rejects an empty selections array', function () {
->assertStatus(422)
->assertJsonValidationErrors(['selections']);
});
test('created_by_channel defaults to kbz_miniapp when no Device-Type header is sent', function () {
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
$this->withHeader('Authorization', "Bearer {$this->token}")
->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
]))
->assertCreated()
->assertJsonPath('data.created_by_channel', BookingChannel::MiniApp->value);
});
test('created_by_channel is taken from the Device-Type header', function (string $deviceType, BookingChannel $expected) {
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
$this->withHeader('Authorization', "Bearer {$this->token}")
->withHeader('Device-Type', $deviceType)
->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
]))
->assertCreated()
->assertJsonPath('data.created_by_channel', $expected->value);
})->with([
'android' => ['android', BookingChannel::Android],
'ios' => ['ios', BookingChannel::Ios],
'web' => ['web', BookingChannel::Web],
'kbz_miniapp' => ['kbz_miniapp', BookingChannel::MiniApp],
]);
test('customer-supplied notes are stored and returned', function () {
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
$payload = bookingPayload($route, $timeSlot, [
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
]);
$payload['notes'] = 'Please call before arriving.';
$this->withHeader('Authorization', "Bearer {$this->token}")
->postJson('/api/v1/bookings', $payload)
->assertCreated()
->assertJsonPath('data.notes', 'Please call before arriving.');
expect(Booking::first()->notes)->toBe('Please call before arriving.');
});
test('notes is optional and defaults to null', function () {
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
$this->withHeader('Authorization', "Bearer {$this->token}")
->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
]))
->assertCreated()
->assertJsonPath('data.notes', null);
});
test('a Device-Type header cannot spoof the agent or admin channel', function (string $deviceType) {
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
$this->withHeader('Authorization', "Bearer {$this->token}")
->withHeader('Device-Type', $deviceType)
->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
]))
->assertCreated()
->assertJsonPath('data.created_by_channel', BookingChannel::MiniApp->value);
})->with([
'agent' => ['agent'],
'admin' => ['admin'],
'unrecognized value' => ['smart-fridge'],
]);
/**
* Same company as $outbound, from/to swapped the true reverse route.
*
* @param array<int, array{0: VehicleOption, 1: string}> $pricedOptions
*/
function reverseRouteAndSlot(EvRoute $outbound, array $pricedOptions): array
{
$route = EvRoute::factory()->create([
'ev_company_id' => $outbound->ev_company_id,
'from_destination_id' => $outbound->to_destination_id,
'to_destination_id' => $outbound->from_destination_id,
'is_active' => true,
]);
$timeSlot = DepartureTimeSlot::factory()->create();
$route->timeSlots()->attach($timeSlot->id, ['is_active' => true]);
foreach ($pricedOptions as [$vehicleOption, $price]) {
RoutePricing::factory()->create([
'ev_route_id' => $route->id,
'vehicle_option' => $vehicleOption,
'price' => $price,
]);
}
return [$route, $timeSlot];
}
test('round trip: creates two linked bookings, each priced against its own route', function () {
config(['booking.back_seat_enabled' => true]);
[$outboundRoute, $outboundSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '9000.00']]);
[$returnRoute, $returnSlot] = reverseRouteAndSlot($outboundRoute, [[VehicleOption::BackSeat, '11000.00']]);
$payload = bookingPayload($outboundRoute, $outboundSlot, [
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
]);
$payload['is_round_trip'] = true;
$payload['return_ev_route_id'] = $returnRoute->id;
$payload['return_departure_time_slot_id'] = $returnSlot->id;
$payload['return_travel_date'] = now()->addDays(3)->toDateString();
$payload['return_selections'] = [['vehicle_option' => 'back_seat', 'passenger_count' => 1]];
$this->withHeader('Authorization', "Bearer {$this->token}")
->postJson('/api/v1/bookings', $payload)
->assertCreated()
->assertJsonPath('data.is_round_trip', true)
->assertJsonPath('data.is_return_leg', false)
->assertJsonPath('data.price', '9000.00')
->assertJsonPath('data.linked_booking.is_return_leg', true)
->assertJsonPath('data.linked_booking.route.id', $returnRoute->id)
->assertJsonPath('data.linked_booking.vehicle_options.0.vehicle_option', 'back_seat')
->assertJsonPath('data.linked_booking.vehicle_options.0.unit_price', '11000.00');
expect(Booking::count())->toBe(2);
$return = Booking::where('is_return_leg', true)->firstOrFail();
expect($return->price)->toEqual('11000.00')
->and($return->ev_route_id)->toBe($returnRoute->id);
});
test('round trip: a return route that is not the reverse of the outbound route surfaces as 422', function () {
config(['booking.back_seat_enabled' => true]);
[$outboundRoute, $outboundSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '9000.00']]);
[$unrelatedRoute, $unrelatedSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '9000.00']]);
$payload = bookingPayload($outboundRoute, $outboundSlot, [
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
]);
$payload['is_round_trip'] = true;
$payload['return_ev_route_id'] = $unrelatedRoute->id;
$payload['return_departure_time_slot_id'] = $unrelatedSlot->id;
$payload['return_travel_date'] = now()->addDays(3)->toDateString();
$payload['return_selections'] = [['vehicle_option' => 'back_seat', 'passenger_count' => 1]];
$this->withHeader('Authorization', "Bearer {$this->token}")
->postJson('/api/v1/bookings', $payload)
->assertStatus(422);
expect(Booking::count())->toBe(0);
});
test('round trip: return fields are required when is_round_trip is true', function () {
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
$payload = bookingPayload($route, $timeSlot, [
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
]);
$payload['is_round_trip'] = true;
$this->withHeader('Authorization', "Bearer {$this->token}")
->postJson('/api/v1/bookings', $payload)
->assertStatus(422)
->assertJsonValidationErrors([
'return_ev_route_id', 'return_departure_time_slot_id', 'return_travel_date', 'return_selections',
]);
});
test('round trip: return_travel_date before travel_date is rejected', function () {
config(['booking.back_seat_enabled' => true]);
[$outboundRoute, $outboundSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '9000.00']]);
[$returnRoute, $returnSlot] = reverseRouteAndSlot($outboundRoute, [[VehicleOption::BackSeat, '9000.00']]);
$payload = bookingPayload($outboundRoute, $outboundSlot, [
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
]);
$payload['is_round_trip'] = true;
$payload['return_ev_route_id'] = $returnRoute->id;
$payload['return_departure_time_slot_id'] = $returnSlot->id;
$payload['return_travel_date'] = now()->toDateString(); // before travel_date (addDay())
$payload['return_selections'] = [['vehicle_option' => 'back_seat', 'passenger_count' => 1]];
$this->withHeader('Authorization', "Bearer {$this->token}")
->postJson('/api/v1/bookings', $payload)
->assertStatus(422)
->assertJsonValidationErrors(['return_travel_date']);
});
@@ -1,7 +1,10 @@
<?php
use App\Models\User;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Models\Booking;
use Modules\Payment\Enums\PaymentMethod;
use Modules\Payment\Models\Payment;
use Spatie\Permission\Models\Permission;
beforeEach(function () {
@@ -11,10 +14,27 @@ beforeEach(function () {
$this->token = $this->owner->createToken('test-token')->plainTextToken;
});
/**
* Index only ever shows bookings with a completed payment give the
* booking a completed Payment row so it's not silently excluded.
*/
function paidBooking(array $attributes = []): Booking
{
$booking = Booking::factory()->create($attributes);
Payment::factory()->completed()->create([
'booking_id' => $booking->id,
'gateway' => PaymentMethod::KbzMiniApp,
'amount' => $booking->price,
]);
return $booking;
}
test('index lists only the authenticated user\'s own bookings, latest first', function () {
$mine = Booking::factory()->create(['user_id' => $this->owner->id, 'created_at' => now()->subMinute()]);
$mineNewer = Booking::factory()->create(['user_id' => $this->owner->id]);
Booking::factory()->create(['user_id' => User::factory()->create()->id]);
$mine = paidBooking(['user_id' => $this->owner->id, 'created_at' => now()->subMinute()]);
$mineNewer = paidBooking(['user_id' => $this->owner->id]);
paidBooking(['user_id' => User::factory()->create()->id]);
$this->withHeader('Authorization', "Bearer {$this->token}")
->getJson('/api/v1/bookings')
@@ -24,6 +44,115 @@ test('index lists only the authenticated user\'s own bookings, latest first', fu
->assertJsonPath('data.1.id', $mine->id);
});
test('index excludes bookings with no completed payment', function () {
// pending_payment, never paid.
Booking::factory()->create(['user_id' => $this->owner->id]);
// Has a payment attempt, but it failed — still not "complete".
$failedPayment = Booking::factory()->create(['user_id' => $this->owner->id]);
Payment::factory()->failed()->create(['booking_id' => $failedPayment->id, 'gateway' => PaymentMethod::KbzMiniApp]);
$paid = paidBooking(['user_id' => $this->owner->id]);
$this->withHeader('Authorization', "Bearer {$this->token}")
->getJson('/api/v1/bookings')
->assertSuccessful()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.id', $paid->id);
});
test('index surfaces a round trip once, not as two separate rows, with a combined total_price', function () {
$outbound = paidBooking(['user_id' => $this->owner->id, 'price' => '9000.00']);
$return = Booking::factory()->create([
'user_id' => $this->owner->id,
'price' => '11000.00',
'is_return_leg' => true,
'linked_booking_id' => $outbound->id,
]);
$outbound->update(['linked_booking_id' => $return->id]);
$this->withHeader('Authorization', "Bearer {$this->token}")
->getJson('/api/v1/bookings')
->assertSuccessful()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.id', $outbound->id)
->assertJsonPath('data.0.price', '9000.00')
->assertJsonPath('data.0.total_price', '20000.00')
->assertJsonPath('data.0.linked_booking.id', $return->id);
});
test('linked_booking carries the return leg\'s own driver/vehicle assignment, independent of the outbound leg\'s', function () {
$outbound = paidBooking([
'user_id' => $this->owner->id,
'status' => BookingStatus::Confirmed,
'driver_name' => 'U Aung',
'driver_phone' => '+959111222333',
'car_plate_number' => 'YGN-1234',
'car_model' => 'Tesla Model Y',
]);
$return = Booking::factory()->create([
'user_id' => $this->owner->id,
'is_return_leg' => true,
'linked_booking_id' => $outbound->id,
'status' => BookingStatus::Confirmed,
'driver_name' => 'Daw Hla',
'driver_phone' => '+959444555666',
'car_plate_number' => 'MDY-5678',
'car_model' => null,
]);
$outbound->update(['linked_booking_id' => $return->id]);
$this->withHeader('Authorization', "Bearer {$this->token}")
->getJson("/api/v1/bookings/{$outbound->booking_ref}")
->assertSuccessful()
->assertJsonPath('data.driver_name', 'U Aung')
->assertJsonPath('data.car_plate_number', 'YGN-1234')
->assertJsonPath('data.linked_booking.driver_name', 'Daw Hla')
->assertJsonPath('data.linked_booking.driver_phone', '+959444555666')
->assertJsonPath('data.linked_booking.car_plate_number', 'MDY-5678')
->assertJsonPath('data.linked_booking.car_model', null);
});
test('total_price equals price for a plain one-way booking, on both index and show', function () {
$booking = paidBooking(['user_id' => $this->owner->id, 'price' => '15000.00']);
$this->withHeader('Authorization', "Bearer {$this->token}")
->getJson('/api/v1/bookings')
->assertJsonPath('data.0.total_price', '15000.00');
$this->withHeader('Authorization', "Bearer {$this->token}")
->getJson("/api/v1/bookings/{$booking->booking_ref}")
->assertJsonPath('data.total_price', '15000.00');
});
test('show returns the combined total_price for a round trip', function () {
$outbound = Booking::factory()->create(['user_id' => $this->owner->id, 'price' => '9000.00']);
$return = Booking::factory()->create([
'user_id' => $this->owner->id,
'price' => '11000.00',
'is_return_leg' => true,
'linked_booking_id' => $outbound->id,
]);
$outbound->update(['linked_booking_id' => $return->id]);
$this->withHeader('Authorization', "Bearer {$this->token}")
->getJson("/api/v1/bookings/{$outbound->booking_ref}")
->assertSuccessful()
->assertJsonPath('data.price', '9000.00')
->assertJsonPath('data.total_price', '20000.00');
});
test('index filters by booking_ref, partial and case-insensitive', function () {
$match = paidBooking(['user_id' => $this->owner->id, 'booking_ref' => 'EVB-FINDME1']);
paidBooking(['user_id' => $this->owner->id, 'booking_ref' => 'EVB-OTHER01']);
$this->withHeader('Authorization', "Bearer {$this->token}")
->getJson('/api/v1/bookings?booking_ref=findme')
->assertSuccessful()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.id', $match->id);
});
test('index rejects unauthenticated requests', function () {
$this->getJson('/api/v1/bookings')->assertUnauthorized();
});
@@ -149,6 +149,15 @@ test('the assign driver action is visible for a confirmed booking and hidden oth
->assertTableActionHidden('assignDriver', $pending);
});
test('the assign driver action is hidden once the travel date has passed', function () {
$past = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'travel_date' => today()->subDay()]);
$today = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'travel_date' => today()]);
Livewire::test(ListBookings::class)
->assertTableActionHidden('assignDriver', $past)
->assertTableActionVisible('assignDriver', $today);
});
test('the assign driver action is hidden from a user without manage_bookings', function () {
$viewer = User::factory()->create()->givePermissionTo('view_bookings');
$this->actingAs($viewer);
@@ -325,6 +334,45 @@ test('restoring a deleted booking brings it back', function () {
expect(Booking::find($booking->id)->trashed())->toBeFalse();
});
test('the remark action is visible for a user with manage_bookings', function () {
$booking = Booking::factory()->create();
Livewire::test(ListBookings::class)
->assertTableActionVisible('setRemark', $booking);
});
test('the remark action is hidden from a user without manage_bookings', function () {
$viewer = User::factory()->create()->givePermissionTo('view_bookings');
$this->actingAs($viewer);
$booking = Booking::factory()->create();
Livewire::test(ListBookings::class)
->assertTableActionHidden('setRemark', $booking);
});
test('calling the remark action sets the staff remark on a booking', function () {
$booking = Booking::factory()->create();
Livewire::test(ListBookings::class)
->callTableAction('setRemark', $booking, data: [
'remark' => 'Passenger requested a child seat.',
])
->assertNotified();
expect($booking->refresh()->remark)->toBe('Passenger requested a child seat.');
});
test('the remark form is pre-filled with the booking\'s existing remark', function () {
$booking = Booking::factory()->create(['remark' => 'Existing remark.']);
Livewire::test(ListBookings::class)
->mountTableAction('setRemark', $booking)
->assertTableActionDataSet([
'remark' => 'Existing remark.',
]);
});
test('the restore action is hidden from a user without manage_bookings', function () {
$stranger = User::factory()->create();
$booking = Booking::factory()->create();
@@ -0,0 +1,22 @@
<?php
use App\Models\User;
use Livewire\Livewire;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Filament\Widgets\BookingsTodayWidget;
use Modules\Booking\Models\Booking;
test('it counts todays trips by status, ignoring other days', function () {
$this->actingAs(User::factory()->create());
Booking::factory()->create(['travel_date' => today(), 'status' => BookingStatus::Confirmed]);
Booking::factory()->create(['travel_date' => today(), 'status' => BookingStatus::PendingPayment]);
Booking::factory()->create(['travel_date' => today()->addDay(), 'status' => BookingStatus::Confirmed]);
Livewire::test(BookingsTodayWidget::class)
->assertOk()
->assertSee('Trips Today')
->assertSee('2')
->assertSee('Confirmed')
->assertSee('Awaiting Payment');
});
@@ -7,9 +7,11 @@ use Modules\Booking\Data\VehicleSelectionData;
use Modules\Booking\Enums\BookingChannel;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Events\BookingCreated;
use Modules\Booking\Exceptions\InvalidReturnRouteException;
use Modules\Booking\Exceptions\InvalidVehicleSelectionException;
use Modules\Booking\Models\Booking;
use Modules\Catalog\Models\DepartureTimeSlot;
use Modules\Routing\Exceptions\RoutePricingNotFoundException;
use Modules\Routing\Models\EvRoute;
use Modules\Routing\Models\RoutePricing;
use Modules\Shared\Enums\VehicleOption;
@@ -33,7 +35,7 @@ function makeBookableRoute(array $pricedOptions): array
return [$route, $timeSlot];
}
function bookingData(EvRoute $route, DepartureTimeSlot $timeSlot, array $selections): CreateBookingData
function bookingData(EvRoute $route, DepartureTimeSlot $timeSlot, array $selections, array $roundTrip = []): CreateBookingData
{
return new CreateBookingData(
evRouteId: $route->id,
@@ -46,9 +48,38 @@ function bookingData(EvRoute $route, DepartureTimeSlot $timeSlot, array $selecti
dropoffAddress: '456 Dropoff Ave',
createdByChannel: BookingChannel::MiniApp,
openid: 'mini-app-openid-123',
returnEvRouteId: $roundTrip['route']->id ?? null,
returnDepartureTimeSlotId: $roundTrip['timeSlot']->id ?? null,
returnTravelDate: $roundTrip['travelDate'] ?? (isset($roundTrip['route']) ? now()->addDays(3)->toDateString() : null),
returnSelections: $roundTrip['selections'] ?? null,
);
}
/**
* Same company as $outbound, from/to swapped the true reverse route.
*
* @param array<int, array{0: VehicleOption, 1: string}> $pricedOptions
*/
function makeReverseRoute(EvRoute $outbound, array $pricedOptions): array
{
$route = EvRoute::factory()->create([
'ev_company_id' => $outbound->ev_company_id,
'from_destination_id' => $outbound->to_destination_id,
'to_destination_id' => $outbound->from_destination_id,
]);
$timeSlot = DepartureTimeSlot::factory()->create();
foreach ($pricedOptions as [$vehicleOption, $price]) {
RoutePricing::factory()->create([
'ev_route_id' => $route->id,
'vehicle_option' => $vehicleOption,
'price' => $price,
]);
}
return [$route, $timeSlot];
}
test('it persists a pending_payment booking with the price snapshotted from PricingService', function () {
config(['booking.back_seat_enabled' => true]);
@@ -150,3 +181,144 @@ test('each booking created gets a unique, sequential booking_ref', function () {
expect($first->booking_ref)->toBe('EVB-AAAAA1')
->and($second->booking_ref)->toBe('EVB-AAAAA2');
});
test('a plain one-way booking has no linked leg', function () {
config(['booking.back_seat_enabled' => true]);
[$route, $timeSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
$booking = app(CreateBookingAction::class)->handle(
bookingData($route, $timeSlot, [new VehicleSelectionData(VehicleOption::BackSeat)])
);
expect($booking->linked_booking_id)->toBeNull()
->and($booking->is_round_trip)->toBeFalse()
->and($booking->is_return_leg)->toBeFalse()
->and(Booking::count())->toBe(1);
});
test('a round trip creates two bookings linked bidirectionally, each priced independently', function () {
config(['booking.back_seat_enabled' => true]);
[$outboundRoute, $outboundSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
[$returnRoute, $returnSlot] = makeReverseRoute($outboundRoute, [[VehicleOption::BackSeat, '11000.00']]);
$outbound = app(CreateBookingAction::class)->handle(bookingData(
$outboundRoute,
$outboundSlot,
[new VehicleSelectionData(VehicleOption::BackSeat)],
roundTrip: [
'route' => $returnRoute,
'timeSlot' => $returnSlot,
'selections' => [new VehicleSelectionData(VehicleOption::BackSeat)],
],
));
expect(Booking::count())->toBe(2)
->and($outbound->is_return_leg)->toBeFalse()
->and($outbound->is_round_trip)->toBeTrue()
->and($outbound->price)->toEqual('9000.00');
$return = $outbound->linkedBooking;
expect($return)->not->toBeNull()
->and($return->is_return_leg)->toBeTrue()
->and($return->is_round_trip)->toBeTrue()
->and($return->linked_booking_id)->toBe($outbound->id)
->and($return->ev_route_id)->toBe($returnRoute->id)
->and($return->departure_time_slot_id)->toBe($returnSlot->id)
->and($return->price)->toEqual('11000.00');
});
test('a round trip dispatches BookingCreated for both legs', function () {
Event::fake([BookingCreated::class]);
config(['booking.back_seat_enabled' => true]);
[$outboundRoute, $outboundSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
[$returnRoute, $returnSlot] = makeReverseRoute($outboundRoute, [[VehicleOption::BackSeat, '9000.00']]);
$outbound = app(CreateBookingAction::class)->handle(bookingData(
$outboundRoute,
$outboundSlot,
[new VehicleSelectionData(VehicleOption::BackSeat)],
roundTrip: [
'route' => $returnRoute,
'timeSlot' => $returnSlot,
'selections' => [new VehicleSelectionData(VehicleOption::BackSeat)],
],
));
Event::assertDispatched(BookingCreated::class, 2);
Event::assertDispatched(BookingCreated::class, fn (BookingCreated $event) => $event->booking->is($outbound));
Event::assertDispatched(BookingCreated::class, fn (BookingCreated $event) => $event->booking->is($outbound->linkedBooking));
});
test('it rejects a return route that is not the reverse of the outbound route', function () {
config(['booking.back_seat_enabled' => true]);
[$outboundRoute, $outboundSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
// Unrelated route — not from/to swapped.
[$unrelatedRoute, $unrelatedSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
expect(fn () => app(CreateBookingAction::class)->handle(bookingData(
$outboundRoute,
$outboundSlot,
[new VehicleSelectionData(VehicleOption::BackSeat)],
roundTrip: [
'route' => $unrelatedRoute,
'timeSlot' => $unrelatedSlot,
'selections' => [new VehicleSelectionData(VehicleOption::BackSeat)],
],
)))->toThrow(InvalidReturnRouteException::class);
// The whole transaction rolls back — no orphan outbound-only booking.
expect(Booking::count())->toBe(0);
});
test('return leg selections are validated independently of the outbound leg', function () {
config(['booking.back_seat_enabled' => true]);
[$outboundRoute, $outboundSlot] = makeBookableRoute([[VehicleOption::FrontSeat, '12000.00']]);
[$returnRoute, $returnSlot] = makeReverseRoute($outboundRoute, [[VehicleOption::FrontSeat, '12000.00']]);
expect(fn () => app(CreateBookingAction::class)->handle(bookingData(
$outboundRoute,
$outboundSlot,
[new VehicleSelectionData(VehicleOption::FrontSeat, 1)],
roundTrip: [
'route' => $returnRoute,
'timeSlot' => $returnSlot,
// Front seat max per booking is 1 — this should fail validation
// for the return leg even though the outbound leg is valid.
'selections' => [new VehicleSelectionData(VehicleOption::FrontSeat, 2)],
],
)))->toThrow(InvalidVehicleSelectionException::class);
expect(Booking::count())->toBe(0);
});
test('a failed return-leg price lookup rolls back the outbound leg too', function () {
config(['booking.back_seat_enabled' => true]);
[$outboundRoute, $outboundSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
// Return route exists (true reverse) but has no pricing rows at all.
$returnRoute = EvRoute::factory()->create([
'ev_company_id' => $outboundRoute->ev_company_id,
'from_destination_id' => $outboundRoute->to_destination_id,
'to_destination_id' => $outboundRoute->from_destination_id,
]);
$returnSlot = DepartureTimeSlot::factory()->create();
expect(fn () => app(CreateBookingAction::class)->handle(bookingData(
$outboundRoute,
$outboundSlot,
[new VehicleSelectionData(VehicleOption::BackSeat)],
roundTrip: [
'route' => $returnRoute,
'timeSlot' => $returnSlot,
'selections' => [new VehicleSelectionData(VehicleOption::BackSeat)],
],
)))->toThrow(RoutePricingNotFoundException::class);
expect(Booking::count())->toBe(0);
});
@@ -0,0 +1,89 @@
<?php
use Firebase\JWT\JWT;
use Modules\Booking\Enums\BookingChannel;
use Modules\Booking\Models\Booking;
use Modules\Catalog\Models\DepartureTimeSlot;
use Modules\Payment\Enums\PaymentMethod;
use Modules\Payment\Models\Payment;
use Modules\Routing\Models\EvRoute;
use Modules\Routing\Models\RoutePricing;
use Modules\Shared\Enums\VehicleOption;
beforeEach(function () {
config(['services.fastapi_agent.jwt_secret' => 'test-fastapi-agent-secret-0123456789ABCDEF']);
config(['services.fastapi_agent.jwt_algorithm' => 'HS256']);
});
function fastApiAgentToken(string $openid): string
{
return JWT::encode([
'sub' => $openid,
'iat' => time(),
'exp' => time() + 3600,
], 'test-fastapi-agent-secret-0123456789ABCDEF', 'HS256');
}
test('a FastAPI JWT booking is stored against the verified openid, ignoring a spoofed body value', function () {
config(['booking.back_seat_enabled' => true]);
$route = EvRoute::factory()->create(['is_active' => true]);
$timeSlot = DepartureTimeSlot::factory()->create();
$route->timeSlots()->attach($timeSlot->id, ['is_active' => true]);
RoutePricing::factory()->create([
'ev_route_id' => $route->id,
'vehicle_option' => VehicleOption::BackSeat,
'price' => '15000.00',
]);
$token = fastApiAgentToken('real-customer-openid');
$this->withHeader('Authorization', "Bearer {$token}")
->withHeader('Device-Type', 'android') // the agent's own channel always wins, ignored here.
->postJson('/api/v1/bookings', [
'ev_route_id' => $route->id,
'departure_time_slot_id' => $timeSlot->id,
'travel_date' => now()->addDay()->toDateString(),
'selections' => [['vehicle_option' => 'back_seat', 'passenger_count' => 1]],
'passenger_name' => 'Jane Doe',
'passenger_phone' => '+959123456789',
'pickup_address' => '123 Pickup St',
'dropoff_address' => '456 Dropoff Ave',
'openid' => 'spoofed-openid',
])
->assertCreated();
$booking = Booking::sole();
expect($booking->openid)->toBe('real-customer-openid')
->and($booking->user_id)->toBeNull()
->and($booking->created_by_channel)->toBe(BookingChannel::Agent);
});
test('a FastAPI JWT can list and show only its own openid\'s bookings', function () {
$mine = Booking::factory()->create(['openid' => 'agent-openid-mine']);
Payment::factory()->completed()->create(['booking_id' => $mine->id, 'gateway' => PaymentMethod::KbzMiniApp]);
Booking::factory()->create(['openid' => 'agent-openid-someone-else']);
$token = fastApiAgentToken('agent-openid-mine');
$this->withHeader('Authorization', "Bearer {$token}")
->getJson('/api/v1/bookings')
->assertSuccessful()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.id', $mine->id);
$this->withHeader('Authorization', "Bearer {$token}")
->getJson("/api/v1/bookings/{$mine->booking_ref}")
->assertSuccessful()
->assertJsonPath('data.id', $mine->id);
});
test('a FastAPI JWT gets a 404 for a booking belonging to a different openid', function () {
$someoneElses = Booking::factory()->create(['openid' => 'agent-openid-someone-else']);
$token = fastApiAgentToken('agent-openid-mine');
$this->withHeader('Authorization', "Bearer {$token}")
->getJson("/api/v1/bookings/{$someoneElses->booking_ref}")
->assertNotFound();
});
@@ -0,0 +1,17 @@
<?php
use App\Models\User;
use Livewire\Livewire;
use Modules\Booking\Filament\Widgets\RecentBookingsTableWidget;
use Modules\Booking\Models\Booking;
test('it lists the most recently created bookings', function () {
$this->actingAs(User::factory()->create());
$older = Booking::factory()->create(['created_at' => now()->subDay()]);
$newer = Booking::factory()->create(['created_at' => now()]);
Livewire::test(RecentBookingsTableWidget::class)
->assertOk()
->assertCanSeeTableRecords([$newer, $older]);
});
@@ -0,0 +1,75 @@
<?php
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Events\DriverAssigned;
use Modules\Booking\Listeners\SendDriverAssignedSms;
use Modules\Booking\Models\Booking;
use Modules\Catalog\Models\Destination;
use Modules\Routing\Models\EvRoute;
use Modules\Shared\Sms\SmsService;
beforeEach(function () {
config([
'app.name' => 'FamousLY4 EV',
'app.support_phone' => '+959123456789',
'app.support_email' => 'support@famousLY4.test',
]);
});
test('a first driver assignment texts the passenger with an "assigned" message including the route', function () {
$booking = Booking::factory()->create([
'status' => BookingStatus::Confirmed,
'passenger_phone' => '+959999888777',
'driver_name' => 'U Aung',
'driver_phone' => '+959111222333',
'car_plate_number' => 'YGN-1234',
'car_model' => 'Tesla Model Y',
'ev_route_id' => EvRoute::factory()->create([
'from_destination_id' => Destination::factory()->create(['name' => 'Yangon'])->id,
'to_destination_id' => Destination::factory()->create(['name' => 'Mandalay'])->id,
])->id,
]);
$sms = Mockery::mock(SmsService::class);
$sms->shouldReceive('send')
->once()
->with('+959999888777', Mockery::on(fn (string $message) => str_contains($message, 'assigned')
&& str_contains($message, 'U Aung')
&& str_contains($message, 'YGN-1234')
&& str_contains($message, 'Yangon - Mandalay')
&& str_contains($message, config('app.name'))
&& str_contains($message, config('app.support_phone'))
&& str_contains($message, config('app.support_email'))
&& str_contains($message, 'ယာဉ်မောင်း')
&& str_contains($message, 'အကူအညီလိုအပ်ပါက ဆက်သွယ်ရန်')));
(new SendDriverAssignedSms($sms))->handle(new DriverAssigned($booking, isFirstAssignment: true));
});
test('a driver reassignment texts the passenger with an "updated" message including the route', function () {
$booking = Booking::factory()->create([
'status' => BookingStatus::Confirmed,
'passenger_phone' => '+959999888777',
'driver_name' => 'Daw Hla',
'driver_phone' => '+959444555666',
'car_plate_number' => 'YGN-5678',
'ev_route_id' => EvRoute::factory()->create([
'from_destination_id' => Destination::factory()->create(['name' => 'Yangon'])->id,
'to_destination_id' => Destination::factory()->create(['name' => 'Mandalay'])->id,
])->id,
]);
$sms = Mockery::mock(SmsService::class);
$sms->shouldReceive('send')
->once()
->with('+959999888777', Mockery::on(fn (string $message) => str_contains($message, 'updated')
&& str_contains($message, 'Daw Hla')
&& str_contains($message, 'Yangon - Mandalay')
&& str_contains($message, config('app.name'))
&& str_contains($message, config('app.support_phone'))
&& str_contains($message, config('app.support_email'))
&& str_contains($message, 'ယာဉ်မောင်း')
&& str_contains($message, 'အကူအညီလိုအပ်ပါက ဆက်သွယ်ရန်')));
(new SendDriverAssignedSms($sms))->handle(new DriverAssigned($booking, isFirstAssignment: false));
});
@@ -1,8 +1,10 @@
<?php
use Illuminate\Support\Facades\Event;
use Modules\Booking\Actions\AssignDriverAction;
use Modules\Booking\Data\AssignDriverData;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Events\DriverAssigned;
use Modules\Booking\Exceptions\DriverAssignmentNotAllowedException;
use Modules\Booking\Models\Booking;
@@ -23,6 +25,57 @@ test('it assigns driver and car details to a confirmed booking', function () {
->and($booking->refresh()->driver_name)->toBe('U Aung');
});
test('it dispatches DriverAssigned with isFirstAssignment true for a booking with no prior driver', function () {
Event::fake([DriverAssigned::class]);
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
(new AssignDriverAction)->handle($booking, new AssignDriverData(
driverName: 'U Aung',
driverPhone: '+959111222333',
carPlateNumber: 'YGN-1234',
));
Event::assertDispatched(DriverAssigned::class, fn (DriverAssigned $event) => $event->booking->is($booking) && $event->isFirstAssignment === true);
});
test('it dispatches DriverAssigned with isFirstAssignment false when reassigning', function () {
Event::fake([DriverAssigned::class]);
$booking = Booking::factory()->create([
'status' => BookingStatus::Confirmed,
'driver_name' => 'U Aung',
'driver_phone' => '+959111222333',
'car_plate_number' => 'YGN-1234',
]);
(new AssignDriverAction)->handle($booking, new AssignDriverData(
driverName: 'Daw Hla',
driverPhone: '+959444555666',
carPlateNumber: 'YGN-5678',
));
Event::assertDispatched(DriverAssigned::class, fn (DriverAssigned $event) => $event->isFirstAssignment === false);
});
test('it does not dispatch DriverAssigned again when resubmitted with identical driver/car details', function () {
Event::fake([DriverAssigned::class]);
$booking = Booking::factory()->create([
'status' => BookingStatus::Confirmed,
'driver_name' => 'U Aung',
'driver_phone' => '+959111222333',
'car_plate_number' => 'YGN-1234',
'car_model' => 'Tesla Model Y',
]);
(new AssignDriverAction)->handle($booking, new AssignDriverData(
driverName: 'U Aung',
driverPhone: '+959111222333',
carPlateNumber: 'YGN-1234',
carModel: 'Tesla Model Y',
));
Event::assertNotDispatched(DriverAssigned::class);
});
test('car_model is optional', function () {
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
@@ -47,6 +100,36 @@ test('it guards against assigning a driver to a pending_payment booking', functi
expect($booking->refresh()->driver_name)->toBeNull();
});
test('it guards against assigning a driver when the travel date has already passed', function () {
$booking = Booking::factory()->create([
'status' => BookingStatus::Confirmed,
'travel_date' => today()->subDay(),
]);
expect(fn () => (new AssignDriverAction)->handle($booking, new AssignDriverData(
driverName: 'U Aung',
driverPhone: '+959111222333',
carPlateNumber: 'YGN-1234',
)))->toThrow(DriverAssignmentNotAllowedException::class);
expect($booking->refresh()->driver_name)->toBeNull();
});
test('it allows assigning a driver when the travel date is today', function () {
$booking = Booking::factory()->create([
'status' => BookingStatus::Confirmed,
'travel_date' => today(),
]);
$updated = (new AssignDriverAction)->handle($booking, new AssignDriverData(
driverName: 'U Aung',
driverPhone: '+959111222333',
carPlateNumber: 'YGN-1234',
));
expect($updated->driver_name)->toBe('U Aung');
});
test('it guards against assigning a driver to a cancelled booking', function () {
$booking = Booking::factory()->create(['status' => BookingStatus::Cancelled]);
@@ -74,3 +157,25 @@ test('reassigning a different driver on a still-confirmed booking overwrites the
expect($booking->refresh()->driver_name)->toBe('Daw Hla')
->and($booking->car_plate_number)->toBe('YGN-5678');
});
test('a round trip: assigning a driver to the outbound leg does not touch the linked return leg', function () {
$outbound = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
$return = Booking::factory()->create([
'status' => BookingStatus::Confirmed,
'is_return_leg' => true,
'linked_booking_id' => $outbound->id,
]);
$outbound->update(['linked_booking_id' => $return->id]);
(new AssignDriverAction)->handle($outbound, new AssignDriverData(
driverName: 'U Aung',
driverPhone: '+959111222333',
carPlateNumber: 'YGN-1234',
));
// Each leg has its own independent driver/vehicle slot — the return leg
// can get a completely different (or no-yet-assigned) vehicle, per the
// "next available vehicle" business rule (domain.md §2b).
expect($outbound->refresh()->driver_name)->toBe('U Aung')
->and($return->refresh()->driver_name)->toBeNull();
});
@@ -23,7 +23,9 @@ class EvCompanyFactory extends Factory
'slug' => fake()->unique()->slug(),
'description' => fake()->sentence(),
'mm_description' => null,
'contact' => fake()->phoneNumber(),
// fake()->phoneNumber() occasionally emits formats (e.g. extensions like "x1234")
// that fail the form's ->tel() regex validation, making the test flaky.
'contact' => fake()->numerify('+959#########'),
'address' => fake()->address(),
'logo' => null,
'is_active' => true,
@@ -4,7 +4,7 @@ use Illuminate\Support\Facades\Route;
use Modules\Catalog\Http\Controllers\DestinationController;
use Modules\Catalog\Http\Controllers\EvCompanyController;
Route::prefix('api/v1')->middleware(['api', 'auth:sanctum', 'throttle:api-read'])->group(function () {
Route::prefix('api/v1')->middleware(['api', 'api.auth', 'throttle:api-read'])->group(function () {
Route::get('/companies', [EvCompanyController::class, 'index'])->name('catalog.companies.index');
Route::get('/destinations', [DestinationController::class, 'index'])->name('catalog.destinations.index');
});
@@ -2,6 +2,7 @@
namespace Modules\Catalog\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Routing\Controller;
use Modules\Catalog\Http\Resources\DestinationResource;
@@ -9,10 +10,23 @@ use Modules\Catalog\Models\Destination;
class DestinationController extends Controller
{
public function index(): AnonymousResourceCollection
public function index(Request $request): AnonymousResourceCollection
{
$terms = array_filter(array_map(
trim(...),
explode(',', (string) $request->string('search')),
));
return DestinationResource::collection(
Destination::query()->where('is_active', true)->get()
Destination::query()
->where('is_active', true)
->when($terms !== [], fn ($query) => $query->where(function ($query) use ($terms) {
foreach ($terms as $term) {
$query->orWhere('name', 'ilike', "%{$term}%")
->orWhere('mm_name', 'ilike', "%{$term}%");
}
}))
->paginate()
);
}
}
@@ -12,7 +12,7 @@ class EvCompanyController extends Controller
public function index(): AnonymousResourceCollection
{
return EvCompanyResource::collection(
EvCompany::query()->where('is_active', true)->get()
EvCompany::query()->where('is_active', true)->paginate()
);
}
}
@@ -23,7 +23,7 @@ class EvCompanyResource extends JsonResource
'mm_description' => $this->mm_description,
'contact' => $this->contact,
'address' => $this->address,
'logo' => $this->logo,
'logo' => $this->logo_url,
];
}
}
@@ -2,8 +2,10 @@
namespace Modules\Catalog\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Modules\Catalog\Database\Factories\EvCompanyFactory;
use Spatie\Activitylog\Models\Concerns\LogsActivity;
@@ -72,4 +74,26 @@ class EvCompany extends Model
'is_active' => 'boolean',
];
}
/**
* `logo` is stored as the disk-relative path Filament's FileUpload
* writes (e.g. "logos/xxx.png"), not a URL API consumers need a full
* absolute URL to render it directly. Guards against the disk itself
* already returning an absolute URL (e.g. an s3 disk), so this stays
* correct if the storage disk ever changes from local.
*/
public function logoUrl(): Attribute
{
return Attribute::make(
get: function (): ?string {
if (blank($this->logo)) {
return null;
}
$url = Storage::disk(config('filesystems.default'))->url($this->logo);
return str($url)->startsWith(['http://', 'https://']) ? $url : url($url);
},
);
}
}
@@ -1,6 +1,7 @@
<?php
use App\Models\User;
use Illuminate\Support\Facades\Storage;
use Modules\Catalog\Models\Destination;
use Modules\Catalog\Models\EvCompany;
@@ -19,6 +20,24 @@ test('lists active ev companies', function () {
->assertJsonFragment(['id' => $active->id]);
});
test('returns the company logo as a full absolute url', function () {
$company = EvCompany::factory()->create(['is_active' => true, 'logo' => 'logos/example.png']);
$this->withHeader('Authorization', "Bearer {$this->token}")
->getJson('/api/v1/companies')
->assertSuccessful()
->assertJsonFragment(['logo' => url(Storage::disk(config('filesystems.default'))->url($company->logo))]);
});
test('returns a null logo when the company has none', function () {
EvCompany::factory()->create(['is_active' => true, 'logo' => null]);
$this->withHeader('Authorization', "Bearer {$this->token}")
->getJson('/api/v1/companies')
->assertSuccessful()
->assertJsonFragment(['logo' => null]);
});
test('lists active destinations', function () {
$active = Destination::factory()->create(['is_active' => true]);
Destination::factory()->create(['is_active' => false]);
@@ -30,6 +49,36 @@ test('lists active destinations', function () {
->assertJsonFragment(['id' => $active->id]);
});
test('paginates companies and destinations', function () {
EvCompany::factory()->count(20)->create(['is_active' => true]);
Destination::factory()->count(20)->create(['is_active' => true]);
$this->withHeader('Authorization', "Bearer {$this->token}")
->getJson('/api/v1/companies')
->assertSuccessful()
->assertJsonCount(15, 'data')
->assertJsonPath('meta.total', 20);
$this->withHeader('Authorization', "Bearer {$this->token}")
->getJson('/api/v1/destinations')
->assertSuccessful()
->assertJsonCount(15, 'data')
->assertJsonPath('meta.total', 20);
});
test('searches destinations by comma-separated terms', function () {
$yangon = Destination::factory()->create(['is_active' => true, 'name' => 'Yangon']);
$mandalay = Destination::factory()->create(['is_active' => true, 'name' => 'Mandalay']);
Destination::factory()->create(['is_active' => true, 'name' => 'Bagan']);
$this->withHeader('Authorization', "Bearer {$this->token}")
->getJson('/api/v1/destinations?search=yangon,mandalay')
->assertSuccessful()
->assertJsonCount(2, 'data')
->assertJsonFragment(['id' => $yangon->id])
->assertJsonFragment(['id' => $mandalay->id]);
});
test('companies endpoint rejects unauthenticated requests', function () {
$this->getJson('/api/v1/companies')->assertUnauthorized();
});
@@ -25,6 +25,7 @@ class RolePermissionSeeder extends Seeder
'manage_roles',
'view_customers',
'manage_settings',
'view_reports',
];
/**
@@ -44,6 +45,7 @@ class RolePermissionSeeder extends Seeder
'manage_roles',
'view_customers',
'manage_settings',
'view_reports',
],
'admin' => [
'manage_catalog',
@@ -56,6 +58,7 @@ class RolePermissionSeeder extends Seeder
'view_audit_log',
'view_customers',
'manage_settings',
'view_reports',
],
'support' => [
'view_bookings',
@@ -4,6 +4,7 @@ namespace Modules\Identity\Filament\Pages;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
@@ -12,6 +13,7 @@ use Filament\Schemas\Components\Actions;
use Filament\Schemas\Components\Form;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Illuminate\Support\Facades\Artisan;
@@ -61,6 +63,11 @@ class ManageAppSettings extends Page
'back_seat_enabled' => (bool) config('booking.back_seat_enabled'),
'whole_vehicle_enabled' => (bool) config('booking.whole_vehicle_enabled'),
'front_seat_max_per_booking' => config('booking.front_seat_max_per_booking'),
'booking_admin_emails' => config('booking.admin_emails'),
'sms_enabled' => (bool) config('services.sms.enabled'),
'sms_server' => config('services.sms.sms_poh.server'),
'sms_token' => config('services.sms.sms_poh.token'),
'sms_sender' => config('services.sms.sms_poh.sender'),
]);
}
@@ -111,7 +118,34 @@ class ManageAppSettings extends Page
->minValue(1)
->required()
->helperText('Max Front Seats a single booking may request.'),
TagsInput::make('booking_admin_emails')
->label('Admin Emails')
->required()
->helperText('Notified on booking events. Press enter after each address.'),
]),
Tab::make('SMS')
->schema([
Toggle::make('sms_enabled')
->label('SMS Enabled')
->live()
->helperText('Whether driver/car SMS notifications are sent at all.'),
TextInput::make('sms_server')
->label('SMS Server URL')
->url()
->maxLength(255)
->required(fn (Get $get): bool => (bool) $get('sms_enabled')),
TextInput::make('sms_token')
->label('SMS Token')
->password()
->revealable()
->maxLength(255)
->required(fn (Get $get): bool => (bool) $get('sms_enabled')),
TextInput::make('sms_sender')
->label('SMS Sender')
->maxLength(255)
->helperText('Default sender name/number for outgoing SMS.'),
])
->columns(2),
]),
])
->livewireSubmitHandler('save')
@@ -139,6 +173,11 @@ class ManageAppSettings extends Page
'BOOKING_BACK_SEAT_ENABLED' => (bool) $state['back_seat_enabled'],
'BOOKING_WHOLE_VEHICLE_ENABLED' => (bool) $state['whole_vehicle_enabled'],
'BOOKING_FRONT_SEAT_MAX_PER_BOOKING' => (int) $state['front_seat_max_per_booking'],
'BOOKING_ADMIN_EMAILS' => implode(',', $state['booking_admin_emails'] ?? []),
'SMS_ENABLED' => (bool) $state['sms_enabled'],
'SMS_SERVER' => $state['sms_server'],
'SMS_TOKEN' => $state['sms_token'],
'SMS_SENDER' => $state['sms_sender'],
]);
Artisan::call('config:clear');
@@ -0,0 +1,59 @@
<?php
namespace Modules\Identity\Http\Middleware;
use Closure;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
/**
* Accepts either of two bearer schemes on the same routes:
*
* - A Sanctum personal access token, for real database users (mini app,
* mobile, web, admin) resolved exactly as `auth:sanctum` would.
* - A self-signed JWT minted by the FastAPI AI agent, carrying the real
* end-customer's identity in its `sub` claim. No Laravel `User` is
* created or attached for this path the verified claim is stashed as
* the `fastapi_openid` request attribute for controllers to scope by
* (domain.md §8; the agent has no database identity of its own).
*
* Payment/refund routes deliberately keep plain `auth:sanctum` instead of
* this middleware, so a JWT-authenticated request can never reach them.
*/
class AuthenticateSanctumOrFastApiJwt implements AuthenticatesRequests
{
public function handle(Request $request, Closure $next): Response
{
if (Auth::guard('sanctum')->check()) {
Auth::shouldUse('sanctum');
return $next($request);
}
if ($token = $request->bearerToken()) {
try {
$payload = JWT::decode($token, new Key(
config('services.fastapi_agent.jwt_secret'),
config('services.fastapi_agent.jwt_algorithm'),
));
$request->attributes->set('fastapi_openid', $payload->sub);
return $next($request);
} catch (Throwable $e) {
// Expired/malformed/wrong-signature tokens are routine auth
// failures, not application errors — log at debug level
// only, never report() to the error tracker.
Log::debug('FastAPI agent JWT rejected.', ['reason' => $e->getMessage()]);
}
}
abort(401, 'Unauthenticated.');
}
}
@@ -6,6 +6,7 @@ use Modules\Booking\Models\Booking;
use Modules\Identity\Enums\TokenAbility;
use Modules\Payment\Enums\PaymentMethod;
use Modules\Payment\Models\Payment;
use Modules\Routing\Models\EvRoute;
/**
* T6.4 full policy + agent-ability audit (domain.md §8). The FastAPI
@@ -63,17 +64,34 @@ test('catalog writes have no customer-facing route at all', function () {
});
test('routing/pricing writes have no customer-facing route at all', function () {
// Only a read-only search endpoint exists for EvRoute — no create/update/
// delete route was ever registered, and the search endpoint itself
// never creates records regardless of payload (it's POST because
// round_trip returns two result sets, not because it writes anything).
// {route} only has a GET (show) handler registered, so PUT/DELETE hit
// that same URI pattern and are rejected as 405 (method not allowed).
$this->withHeader('Authorization', "Bearer {$this->agentToken}")
->postJson('/api/v1/routes', ['ev_company_id' => 1])
->putJson('/api/v1/routes/1', ['ev_company_id' => 1])
->assertStatus(405);
$this->withHeader('Authorization', "Bearer {$this->agentToken}")
->deleteJson('/api/v1/routes/1')
->assertStatus(405);
$this->withHeader('Authorization', "Bearer {$this->agentToken}")
->postJson('/api/v1/routes/search', ['ev_company_id' => 1])
->assertSuccessful();
expect(EvRoute::count())->toBe(0);
});
test('the agent token can still read routes and create/read bookings', function () {
$this->withHeader('Authorization', "Bearer {$this->agentToken}")
->getJson('/api/v1/routes')
->postJson('/api/v1/routes/search')
->assertSuccessful();
$booking = Booking::factory()->create(['user_id' => $this->agent->id]);
Payment::factory()->completed()->create(['booking_id' => $booking->id, 'gateway' => PaymentMethod::KbzMiniApp]);
$this->withHeader('Authorization', "Bearer {$this->agentToken}")
->getJson('/api/v1/bookings')
@@ -28,6 +28,37 @@ test('a booking status transition is recorded in the audit log', function () {
expect($activity->attribute_changes->get('attributes'))->toMatchArray(['status' => BookingStatus::Confirmed->value]);
});
test('assigning a driver is recorded in the audit log with who and when', function () {
$dispatcher = User::factory()->create();
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
$this->actingAs($dispatcher);
$booking->update([
'driver_name' => 'U Aung',
'driver_phone' => '+959111222333',
'car_plate_number' => 'YGN-1234',
'car_model' => 'Tesla Model Y',
]);
$activity = Activity::where('subject_type', Booking::class)
->where('subject_id', $booking->id)
->where('log_name', 'booking')
->latest('id')
->first();
expect($activity)->not->toBeNull();
expect($activity->attribute_changes->get('attributes'))->toMatchArray([
'driver_name' => 'U Aung',
'driver_phone' => '+959111222333',
'car_plate_number' => 'YGN-1234',
'car_model' => 'Tesla Model Y',
]);
expect($activity->causer_type)->toBe(User::class);
expect($activity->causer_id)->toBe($dispatcher->id);
expect($activity->created_at)->not->toBeNull();
});
test('a catalog CRUD write is recorded in the audit log', function () {
$company = EvCompany::factory()->create(['name' => 'Original Name']);
@@ -0,0 +1,73 @@
<?php
use App\Models\User;
use Firebase\JWT\JWT;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
beforeEach(function () {
config(['services.fastapi_agent.jwt_secret' => 'test-fastapi-agent-secret-0123456789ABCDEF']);
config(['services.fastapi_agent.jwt_algorithm' => 'HS256']);
Route::middleware(['api.auth'])
->get('/__test/sanctum-or-fastapi-jwt', fn (Request $request) => response()->json([
'openid' => $request->attributes->get('fastapi_openid'),
'user_id' => $request->user()?->id,
]));
});
function fastApiJwt(array $overrides = []): string
{
$payload = array_merge([
'sub' => 'mini-app-openid-123',
'iat' => time(),
'exp' => time() + 3600,
], $overrides);
return JWT::encode($payload, 'test-fastapi-agent-secret-0123456789ABCDEF', 'HS256');
}
test('a valid Sanctum token authenticates as a real user, no openid attribute set', function () {
$user = User::factory()->create();
$token = $user->createToken('test-token')->plainTextToken;
$this->withHeader('Authorization', "Bearer {$token}")
->getJson('/__test/sanctum-or-fastapi-jwt')
->assertSuccessful()
->assertJson(['openid' => null, 'user_id' => $user->id]);
});
test('a valid FastAPI JWT authenticates with the verified openid claim and no user', function () {
$token = fastApiJwt(['sub' => 'agent-openid-456']);
$this->withHeader('Authorization', "Bearer {$token}")
->getJson('/__test/sanctum-or-fastapi-jwt')
->assertSuccessful()
->assertJson(['openid' => 'agent-openid-456', 'user_id' => null]);
});
test('an expired FastAPI JWT is rejected', function () {
$token = fastApiJwt(['exp' => time() - 60]);
$this->withHeader('Authorization', "Bearer {$token}")
->getJson('/__test/sanctum-or-fastapi-jwt')
->assertUnauthorized();
});
test('a FastAPI JWT signed with the wrong secret is rejected', function () {
$token = JWT::encode(['sub' => 'agent-openid-456', 'exp' => time() + 3600], 'wrong-secret-0123456789ABCDEFGHIJKLMNOP', 'HS256');
$this->withHeader('Authorization', "Bearer {$token}")
->getJson('/__test/sanctum-or-fastapi-jwt')
->assertUnauthorized();
});
test('a malformed bearer token is rejected', function () {
$this->withHeader('Authorization', 'Bearer not-a-real-token')
->getJson('/__test/sanctum-or-fastapi-jwt')
->assertUnauthorized();
});
test('a request with no Authorization header is rejected', function () {
$this->getJson('/__test/sanctum-or-fastapi-jwt')->assertUnauthorized();
});
@@ -43,6 +43,11 @@ test('a super_admin can view and save app settings, writing them to .env', funct
'back_seat_enabled' => false,
'whole_vehicle_enabled' => true,
'front_seat_max_per_booking' => 2,
'booking_admin_emails' => ['ops@evbooking.test', 'dispatch@evbooking.test'],
'sms_enabled' => true,
'sms_server' => 'https://sms.example.test/send',
'sms_token' => 'secret-token',
'sms_sender' => 'EVBooking',
])
->call('save')
->assertHasNoFormErrors();
@@ -56,7 +61,23 @@ test('a super_admin can view and save app settings, writing them to .env', funct
->toContain('APP_CURRENCY=MMK')
->toContain('BOOKING_BACK_SEAT_ENABLED=false')
->toContain('BOOKING_WHOLE_VEHICLE_ENABLED=true')
->toContain('BOOKING_FRONT_SEAT_MAX_PER_BOOKING=2');
->toContain('BOOKING_FRONT_SEAT_MAX_PER_BOOKING=2')
->toContain('BOOKING_ADMIN_EMAILS=ops@evbooking.test,dispatch@evbooking.test')
->toContain('SMS_ENABLED=true')
->toContain('SMS_SERVER=https://sms.example.test/send')
->toContain('SMS_TOKEN=secret-token')
->toContain('SMS_SENDER=EVBooking');
});
test('sms server and token are required once sms is enabled', function () {
$superAdmin = User::factory()->create();
$superAdmin->assignRole('super_admin');
$this->actingAs($superAdmin);
Livewire::test(ManageAppSettings::class)
->fillForm(['sms_enabled' => true, 'sms_server' => '', 'sms_token' => ''])
->call('save')
->assertHasFormErrors(['sms_server', 'sms_token']);
});
test('front seat max per booking must be at least 1', function () {
@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* A round trip's Payment is combined on the primary (outbound) leg
* (domain.md §2b), so `refund->payment->booking` is no longer reliable
* for identifying which leg a refund actually cancels a refund
* against the return leg still hangs off the primary's Payment.
* `booking_id` records the actual leg RefundBookingAction was asked to
* refund, so MarkBookingRefunded flips the right booking to cancelled.
*/
public function up(): void
{
Schema::table('refunds', function (Blueprint $table) {
$table->foreignId('booking_id')->nullable()->after('payment_id')
->constrained('bookings')->nullOnDelete();
});
// Backfill existing rows from their Payment's booking — correct for
// every pre-existing refund, since round trip didn't exist yet.
DB::statement(
'update refunds set booking_id = payments.booking_id '.
'from payments where payments.id = refunds.payment_id'
);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('refunds', function (Blueprint $table) {
$table->dropConstrainedForeignId('booking_id');
});
}
};
@@ -0,0 +1,30 @@
<x-mail::message>
# New EV Booking ( {{ $booking->ref_no }} )
A new EV booking was created at {{ $booking->created_at }}.
**Booking Summary**
- **Route:** {{ $booking->route->name ?? 'N/A' }}
- **Seat Options:** {{ $booking->vehicleOptions->map(fn($b) => "{$b->passenger_count} x {$b->vehicle_option->value}")->implode(', ') }}
- **Pickup Location:** {{ $booking->pickup_address ?? 'N/A' }}
- **Dropoff Location:** {{ $booking->dropoff_address ?? 'N/A' }}
- **Travel Date:** {{ $booking->travel_date ?? 'N/A' }}
- **Total Price:** {{ $booking->price ?? 'N/A' }}
{{-- @if(!empty($booking->notes))
**Notes:**<br>
{{ $booking->notes }}
@endif --}}
Payment Method: {{ ($payment->gateway ?? 'N/A') }}
<br>
Contact Info: {{ ($booking->passenger_name ?? '') }} {{ $booking->passenger_phone ?? '' }}
<br>
Channel: {{ $booking->created_by_channel ?? '-' }}
<x-mail::button :url="$url">
View Booking
</x-mail::button>
Auto generated from<br>
{{ config('app.name') }}
</x-mail::message>
@@ -5,7 +5,7 @@ use Modules\Payment\Http\Controllers\PaymentController;
use Modules\Payment\Http\Controllers\PaymentWebhookController;
use Modules\Payment\Http\Controllers\RefundController;
Route::prefix('api/v1')->middleware(['api', 'auth:sanctum', 'throttle:api-write'])->group(function () {
Route::prefix('api/v1')->middleware(['api', 'api.auth', 'throttle:api-write'])->group(function () {
Route::post('/payments/{booking:booking_ref}/initiate', [PaymentController::class, 'initiate'])->name('payment.payments.initiate');
Route::post('/bookings/{booking:booking_ref}/refund', [RefundController::class, 'refund'])->name('payment.bookings.refund');
});
@@ -8,6 +8,7 @@ use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Models\Booking;
use Modules\Payment\Data\PaymentRequestData;
use Modules\Payment\Enums\PaymentMethod;
use Modules\Payment\Enums\PaymentStatus;
use Modules\Payment\Exceptions\PaymentInitiationNotAllowedException;
use Modules\Payment\Models\Payment;
use Modules\Payment\Services\PaymentService;
@@ -19,6 +20,13 @@ use Modules\Payment\Services\PaymentService;
*
* Booking status only ever flips to `confirmed` once the gateway confirms
* success via the webhook/verify path (T5.9/T5.10) never here.
*
* Idempotent per booking: KBZ's precreate rejects a second call tied to an
* order that's still in flight, so a repeat call (double-tap on "Pay", the
* customer re-opening the payment screen) must not blindly precreate again.
* If the latest attempt is still `pending`, it's re-verified against the
* gateway (via ConfirmPaymentAction, the same logic the webhook path uses)
* and reused instead of starting a new one.
*/
class InitiatePaymentAction
{
@@ -26,6 +34,7 @@ class InitiatePaymentAction
public function __construct(
private PaymentService $paymentService,
private ConfirmPaymentAction $confirmPayment,
) {}
public function handle(Booking $booking, PaymentMethod $method = PaymentMethod::KbzMiniApp): Payment
@@ -34,27 +43,65 @@ class InitiatePaymentAction
throw PaymentInitiationNotAllowedException::notPendingPayment($booking);
}
$merchantOrderId = $this->merchantOrderId($booking);
if ($booking->is_return_leg) {
throw PaymentInitiationNotAllowedException::isReturnLeg($booking);
}
$result = $this->paymentService->initiate(new PaymentRequestData(
bookingId: $booking->id,
merchantOrderId: $merchantOrderId,
amount: (string) $booking->price,
currency: self::CURRENCY,
method: $method,
notifyUrl: $this->notifyUrl($booking, $method),
));
return DB::transaction(function () use ($booking, $method) {
$booking = Booking::whereKey($booking->id)->lockForUpdate()->first();
return DB::transaction(fn () => Payment::create([
'booking_id' => $booking->id,
'gateway' => $method,
'status' => $result->status,
'amount' => $booking->price,
'currency' => self::CURRENCY,
'gateway_transaction_id' => $result->gatewayTransactionId ?? $merchantOrderId,
'gateway_payload' => $result->gatewayPayload,
'initiated_at' => now(),
]));
$latest = $booking->payments()->latest('id')->first();
if ($latest !== null) {
// No-op for an already-terminal payment (ConfirmPaymentAction
// only re-verifies `pending` ones), so this is cheap even for
// a Failed/Completed latest attempt — and it guards against a
// narrow race where a webhook already completed the payment
// but the queued booking-status listener hasn't run yet.
$reverified = $this->confirmPayment->handle($latest->gateway, $latest->gateway_transaction_id);
if ($reverified !== null && $reverified->status !== PaymentStatus::Failed) {
return $reverified;
}
}
$merchantOrderId = $this->merchantOrderId($booking);
$amount = $this->amount($booking);
$result = $this->paymentService->initiate(new PaymentRequestData(
bookingId: $booking->id,
merchantOrderId: $merchantOrderId,
amount: $amount,
currency: self::CURRENCY,
method: $method,
notifyUrl: $this->notifyUrl($booking, $method),
));
return Payment::create([
'booking_id' => $booking->id,
'gateway' => $method,
'status' => $result->status,
'amount' => $amount,
'currency' => self::CURRENCY,
'gateway_transaction_id' => $result->gatewayTransactionId ?? $merchantOrderId,
'gateway_payload' => $result->gatewayPayload,
'initiated_at' => now(),
]);
});
}
/**
* A round trip's payment is combined on the outbound leg covers both
* legs' price, since the return leg never gets its own Payment
* (domain.md §2b). A plain one-way booking just pays its own price.
*/
private function amount(Booking $booking): string
{
if ($booking->linked_booking_id === null) {
return (string) $booking->price;
}
return bcadd((string) $booking->price, (string) $booking->linkedBooking->price, 2);
}
/**
@@ -35,7 +35,14 @@ class RefundBookingAction
throw RefundNotAllowedException::notConfirmed($booking);
}
$payment = $booking->payments()->where('status', PaymentStatus::Completed->value)->latest()->first();
// Round trip: payment is combined on the outbound leg, so a return
// leg has no Payment of its own — refund against its linked leg's
// Payment instead (domain.md §2b). The Confirmed check above still
// applies to $booking itself, not the payment holder, so each leg
// remains independently cancellable/refundable.
$paymentBooking = $booking->is_return_leg ? ($booking->linkedBooking ?? $booking) : $booking;
$payment = $paymentBooking->payments()->where('status', PaymentStatus::Completed->value)->latest()->first();
if ($payment === null) {
throw RefundNotAllowedException::noCompletedPayment($booking);
@@ -45,9 +52,10 @@ class RefundBookingAction
$result = $this->paymentService->refund($payment->gateway, $payment->gateway_transaction_id, $amount, $reason);
$refund = DB::transaction(function () use ($payment, $amount, $reason, $result, $requestedBy) {
$refund = DB::transaction(function () use ($booking, $payment, $amount, $reason, $result, $requestedBy) {
$refund = Refund::create([
'payment_id' => $payment->id,
'booking_id' => $booking->id,
'status' => $result->status,
'amount' => $amount,
'reason' => $reason,
@@ -16,6 +16,18 @@ class PaymentInitiationNotAllowedException extends RuntimeException
);
}
/**
* A round trip's payment is combined on the outbound leg the return
* leg is marked paid when the outbound leg's payment succeeds
* (MarkBookingPaid), never via its own Payment (domain.md §2b).
*/
public static function isReturnLeg(Booking $booking): self
{
return new self(
"Booking [{$booking->booking_ref}] is a round trip's return leg — initiate payment on its linked outbound booking instead."
);
}
public function render(Request $request): ?JsonResponse
{
if ($request->expectsJson()) {
@@ -0,0 +1,42 @@
<?php
namespace Modules\Payment\Filament\Widgets;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Modules\Payment\Enums\PaymentStatus;
use Modules\Payment\Models\Payment;
/**
* Failure rate over the trailing 30 days, among payments that reached a
* terminal state (completed or failed) pending attempts are excluded
* since they haven't resolved either way yet.
*/
class PaymentFailureRateWidget extends BaseWidget
{
protected static ?int $sort = 3;
protected function getStats(): array
{
$since = today()->subDays(30);
$completed = Payment::query()
->where('status', PaymentStatus::Completed)
->where('initiated_at', '>=', $since)
->count();
$failed = Payment::query()
->where('status', PaymentStatus::Failed)
->where('initiated_at', '>=', $since)
->count();
$resolved = $completed + $failed;
$rate = $resolved > 0 ? round(($failed / $resolved) * 100, 1) : 0.0;
return [
Stat::make('Payment Failure Rate', $rate.'%')
->description("{$failed} failed of {$resolved} resolved (last 30 days)")
->color($rate >= 20 ? 'danger' : ($rate > 0 ? 'warning' : 'success')),
];
}
}
@@ -0,0 +1,71 @@
<?php
namespace Modules\Payment\Filament\Widgets;
use Filament\Widgets\ChartWidget;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Modules\Payment\Enums\PaymentStatus;
use Modules\Payment\Models\Payment;
/**
* 30-day revenue trend from completed payments. Cached per day (T7.1)
* refreshes at most once every 5 minutes since this aggregates a whole
* month of rows on every dashboard load otherwise.
*/
class RevenueChartWidget extends ChartWidget
{
protected static ?int $sort = 1;
protected ?string $heading = 'Revenue (Last 30 Days)';
protected function getData(): array
{
$days = Cache::tags('payments')->remember(
'dashboard:revenue-chart:'.today()->toDateString(),
now()->addMinutes(5),
fn () => $this->revenueByDay(),
);
return [
'datasets' => [
[
'label' => 'Revenue',
'data' => $days->pluck('total')->all(),
'fill' => true,
],
],
'labels' => $days->pluck('label')->all(),
];
}
protected function getType(): string
{
return 'line';
}
/**
* @return Collection<int, array{label: string, total: float}>
*/
private function revenueByDay(): Collection
{
$start = today()->subDays(29);
$totals = Payment::query()
->where('status', PaymentStatus::Completed)
->whereDate('completed_at', '>=', $start)
->selectRaw('DATE(completed_at) as day, SUM(amount) as total')
->groupBy('day')
->pluck('total', 'day');
return collect(range(0, 29))
->map(function (int $offset) use ($start, $totals) {
$date = $start->copy()->addDays($offset);
return [
'label' => $date->format('M j'),
'total' => (float) ($totals[$date->toDateString()] ?? 0),
];
});
}
}
@@ -32,6 +32,12 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
private readonly string $baseUrl;
private readonly string $createOrderUrl;
private readonly string $queryOrderUrl;
private readonly string $refundOrderUrl;
private readonly ?string $notifyUrl;
private readonly ?string $certPath;
@@ -53,6 +59,11 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
$this->merchantCode = (string) ($config['merchant_code'] ?? '');
$this->merchantKey = (string) ($config['merchant_key'] ?? '');
$this->baseUrl = (string) ($config['base_url'] ?? '');
// Falls back to base_url for gateways/environments that haven't
// configured per-operation endpoints yet.
$this->createOrderUrl = (string) ($config['create_order_url'] ?? $this->baseUrl);
$this->queryOrderUrl = (string) ($config['query_order_url'] ?? $this->baseUrl);
$this->refundOrderUrl = (string) ($config['refund_order_url'] ?? $this->baseUrl);
$this->notifyUrl = $config['notify_url'] ?? null;
$this->certPath = $config['cert_path'] ?? null;
$this->certKeyPath = $config['cert_key_path'] ?? null;
@@ -63,10 +74,18 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
public function initiate(PaymentRequestData $data): PaymentResultData
{
$params = $this->buildPrecreateParams($data);
logger($params);
try {
$response = Http::asJson()->post($this->baseUrl, ['Request' => $params]);
$response = Http::post($this->createOrderUrl, ['Request' => $params]);
logger($response);
} catch (ConnectionException $exception) {
\Log::error('KBZ Mini App precreate connection error: '.$exception->getMessage(), [
'merchant_order_id' => $data->merchantOrderId,
'amount' => $data->amount,
'currency' => $data->currency,
]);
return new PaymentResultData(
status: PaymentStatus::Failed,
gatewayTransactionId: null,
@@ -79,6 +98,14 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
$body = $response->json('Response', []);
if (! $response->successful() || ($body['result'] ?? null) !== 'SUCCESS') {
\Log::error('KBZ Mini App precreate failed: '.($body['msg'] ?? 'Unknown error'), [
'merchant_order_id' => $data->merchantOrderId,
'amount' => $data->amount,
'currency' => $data->currency,
'http_status' => $response->status(),
'raw_body' => $response->body(),
]);
return new PaymentResultData(
status: PaymentStatus::Failed,
gatewayTransactionId: $body['prepay_id'] ?? null,
@@ -87,13 +114,19 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
);
}
$orderInfo = $this->createOrderInfo($body['prepay_id'] ?? '');
return new PaymentResultData(
// KBZ's queryorder/refund calls both key off our own merch_order_id,
// not their prepay_id — so that's what gets stored/passed forward as
// the gateway transaction id (prepay_id still lives in the payload).
status: PaymentStatus::Pending,
gatewayTransactionId: $data->merchantOrderId,
gatewayPayload: $body,
gatewayPayload: [
'prepayId' => $body['prepay_id'] ?? null,
'orderInfo' => KbzSignature::joinKeyVal($orderInfo),
'signature' => KbzSignature::sign($orderInfo, $this->merchantKey),
],
);
}
@@ -102,8 +135,12 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
$params = $this->buildQueryOrderParams($gatewayTransactionId);
try {
$response = Http::asJson()->post($this->baseUrl, ['Request' => $params]);
$response = Http::asJson()->post($this->queryOrderUrl, ['Request' => $params]);
} catch (ConnectionException $exception) {
\Log::error('KBZ Mini App verify connection error: '.$exception->getMessage(), [
'gateway_transaction_id' => $gatewayTransactionId,
]);
return new PaymentResultData(
status: PaymentStatus::Failed,
gatewayTransactionId: $gatewayTransactionId,
@@ -130,7 +167,7 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
try {
$response = Http::asJson()
->withOptions($this->mtlsOptions())
->post($this->baseUrl, ['Request' => $params]);
->post($this->refundOrderUrl, ['Request' => $params]);
} catch (ConnectionException $exception) {
return new RefundResultData(
status: RefundStatus::Failed,
@@ -208,7 +245,7 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
'timestamp' => (string) now()->timestamp,
'method' => 'kbz.payment.precreate',
'notify_url' => $data->notifyUrl ?? $this->notifyUrl,
'nonce_str' => (string) Str::uuid(),
'nonce_str' => uniqid(),
'version' => '1.0',
'biz_content' => [
'appid' => $this->appId,
@@ -235,7 +272,7 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
$params = [
'timestamp' => (string) now()->timestamp,
'method' => 'kbz.payment.queryorder',
'nonce_str' => (string) Str::uuid(),
'nonce_str' => uniqid(),
'version' => '1.0',
'biz_content' => [
'appid' => $this->appId,
@@ -272,7 +309,7 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
$params = [
'timestamp' => (string) now()->timestamp,
'method' => 'kbz.payment.refund',
'nonce_str' => (string) Str::uuid(),
'nonce_str' => uniqid(),
'version' => '1.0',
'biz_content' => [
'appid' => $this->appId,
@@ -326,4 +363,15 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
return $options;
}
public function createOrderInfo($prepayId): array
{
return [
'appid' => $this->appId,
'merch_code' => $this->merchantCode,
'nonce_str' => uniqid(),
'prepay_id' => $prepayId,
'timestamp' => (string)time()
];
}
}
@@ -3,10 +3,12 @@
namespace Modules\Payment\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Gate;
use Modules\Booking\Models\Booking;
use Modules\Payment\Actions\InitiatePaymentAction;
use Modules\Payment\Enums\PaymentStatus;
use Modules\Payment\Http\Resources\PaymentResource;
class PaymentController extends Controller
@@ -15,12 +17,25 @@ class PaymentController extends Controller
private InitiatePaymentAction $initiatePaymentAction,
) {}
public function initiate(Booking $booking): JsonResponse
public function initiate(Request $request, Booking $booking): JsonResponse
{
Gate::authorize('pay', $booking);
$openid = $request->attributes->get('fastapi_openid');
if ($openid === null) {
Gate::authorize('pay', $booking);
}
$payment = $this->initiatePaymentAction->handle($booking);
if ($payment->status === PaymentStatus::Failed) {
return response()->json([
'message' => 'Payment initiation failed',
'errors' => [
'payment' => ['Payment initiation failed'],
],
], 422);
}
return (new PaymentResource($payment))
->response()
->setStatusCode(201);
@@ -18,10 +18,14 @@ class RefundController extends Controller
public function refund(RefundBookingRequest $request, Booking $booking): JsonResponse
{
Gate::authorize('refund', $booking);
$validated = $request->validated();
$openid = $request->attributes->get('fastapi_openid');
if ($openid === null) {
Gate::authorize('refund', $booking);
}
$refund = $this->refundBookingAction->handle(
$booking,
(string) $validated['amount'],
@@ -29,5 +29,14 @@ class MarkBookingPaid implements ShouldQueue
if ($booking->status === BookingStatus::PendingPayment) {
$booking->update(['status' => BookingStatus::Confirmed]);
}
// Round trip: payment is combined on the outbound leg, so its
// success also confirms the linked return leg — the return leg
// never gets its own Payment (domain.md §2b).
$linkedBooking = $booking->linkedBooking;
if ($linkedBooking !== null && $linkedBooking->status === BookingStatus::PendingPayment) {
$linkedBooking->update(['status' => BookingStatus::Confirmed]);
}
}
}
@@ -16,7 +16,11 @@ class MarkBookingRefunded implements ShouldQueue
{
public function handle(RefundProcessed $event): void
{
$booking = $event->refund->payment->booking;
// The leg actually refunded — not payment->booking, since a round
// trip's return leg refunds against the primary leg's shared
// Payment (domain.md §2b). Falls back to payment->booking for
// pre-redesign rows where booking_id wasn't yet recorded.
$booking = $event->refund->booking ?? $event->refund->payment->booking;
// Booking uses SoftDeletes — normally unreachable here (a confirmed
// booking is never deletable, BookingPolicy::delete), but this
@@ -0,0 +1,61 @@
<?php
namespace Modules\Payment\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
use Modules\Booking\Filament\Resources\Bookings\BookingResource;
use Modules\Booking\Models\Booking;
use Modules\Payment\Models\Payment;
class NewBookingAlert extends Mailable
{
use Queueable, SerializesModels;
public Booking $booking;
public string $url;
/**
* Create a new message instance.
*/
public function __construct(public Payment $payment)
{
$this->booking = $payment->booking;
$this->url = BookingResource::getUrl('view', ['record' => $this->booking]);
}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
return new Envelope(
subject: 'FamousLY4 New Booking Alert',
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
markdown: 'payment::mails.new-booking-alert',
);
}
/**
* Get the attachments for the message.
*
* @return array<int, Attachment>
*/
public function attachments(): array
{
return [];
}
}
+12
View File
@@ -6,6 +6,7 @@ use App\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Modules\Booking\Models\Booking;
use Modules\Payment\Database\Factories\RefundFactory;
use Modules\Payment\Enums\RefundStatus;
use Spatie\Activitylog\Models\Concerns\LogsActivity;
@@ -38,6 +39,7 @@ class Refund extends Model
*/
protected $fillable = [
'payment_id',
'booking_id',
'status',
'amount',
'reason',
@@ -67,6 +69,16 @@ class Refund extends Model
return $this->belongsTo(Payment::class);
}
/**
* The leg actually being refunded/cancelled not necessarily
* payment->booking, since a round trip's return leg refunds against the
* primary leg's shared Payment (domain.md §2b).
*/
public function booking(): BelongsTo
{
return $this->belongsTo(Booking::class);
}
public function requestedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'requested_by');
@@ -0,0 +1,35 @@
<?php
namespace Modules\Payment\Observers;
use Illuminate\Support\Facades\Mail;
use Modules\Payment\Enums\PaymentStatus;
use Modules\Payment\Mail\NewBookingAlert;
use Modules\Payment\Models\Payment;
class PaymentObserver
{
public function created(Payment $payment): void
{
if ($payment->status === PaymentStatus::Failed) return;
try {
$admin_emails = config('booking.admin_emails');
Mail::bcc($admin_emails)->queue(new NewBookingAlert($payment));
} catch (\Exception $e) {
// Log the exception or handle it as needed
\Log::error('Failed to send NewBookingAlert email: ' . $e->getMessage());
}
}
public function updated(Payment $payment): void
{
// Handle the event when a payment is updated for a booking
}
public function deleted(Payment $payment): void
{
// Handle the event when a payment is deleted for a booking
}
}
@@ -2,15 +2,12 @@
namespace Modules\Payment\Providers;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Modules\Payment\Enums\PaymentMethod;
use Modules\Payment\Events\PaymentCompleted;
use Modules\Payment\Events\RefundProcessed;
use Modules\Payment\Factories\PaymentGatewayFactory;
use Modules\Payment\Gateways\KbzMiniAppGateway;
use Modules\Payment\Listeners\MarkBookingPaid;
use Modules\Payment\Listeners\MarkBookingRefunded;
use Modules\Payment\Models\Payment;
use Modules\Payment\Observers\PaymentObserver;
class PaymentServiceProvider extends ServiceProvider
{
@@ -26,7 +23,11 @@ class PaymentServiceProvider extends ServiceProvider
public function boot(): void
{
Event::listen(PaymentCompleted::class, MarkBookingPaid::class);
Event::listen(RefundProcessed::class, MarkBookingRefunded::class);
// MarkBookingPaid/MarkBookingRefunded are auto-discovered by
// internachi/modular's EventsPlugin (any Listeners/*.php with a
// handle(SomeEvent $event) signature) — registering them here too
// used to double-dispatch both listeners (see DriverAssigned's
// BookingServiceProvider for the same fix).
Payment::observe(PaymentObserver::class);
}
}
@@ -21,9 +21,16 @@ class FakeInitiatePaymentGateway implements PaymentGatewayInterface
{
public static ?PaymentRequestData $lastRequest = null;
public static int $initiateCalls = 0;
public static int $verifyCalls = 0;
public static PaymentStatus $verifyStatus = PaymentStatus::Pending;
public function initiate(PaymentRequestData $data): PaymentResultData
{
self::$lastRequest = $data;
self::$initiateCalls++;
return new PaymentResultData(
status: PaymentStatus::Pending,
@@ -34,7 +41,13 @@ class FakeInitiatePaymentGateway implements PaymentGatewayInterface
public function verify(string $gatewayTransactionId): PaymentResultData
{
throw new RuntimeException('not needed for this test');
self::$verifyCalls++;
return new PaymentResultData(
status: self::$verifyStatus,
gatewayTransactionId: $gatewayTransactionId,
gatewayPayload: ['trade_status' => self::$verifyStatus->value],
);
}
public function refund(string $gatewayTransactionId, string $amount, string $reason): RefundResultData
@@ -53,6 +66,11 @@ beforeEach(function () {
app(PaymentGatewayFactory::class)->register(PaymentMethod::KbzMiniApp, FakeInitiatePaymentGateway::class);
FakeInitiatePaymentGateway::$lastRequest = null;
FakeInitiatePaymentGateway::$initiateCalls = 0;
FakeInitiatePaymentGateway::$verifyCalls = 0;
FakeInitiatePaymentGateway::$verifyStatus = PaymentStatus::Pending;
$this->owner = User::factory()->create();
$this->token = $this->owner->createToken('test-token')->plainTextToken;
});
@@ -79,6 +97,44 @@ test('the owner can initiate payment for their own pending_payment booking', fun
->and($payment->gateway_transaction_id)->toBe("{$booking->booking_ref}-1");
});
test('a repeat call while the previous attempt is still pending reuses it instead of precreating again', function () {
$booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::PendingPayment]);
FakeInitiatePaymentGateway::$verifyStatus = PaymentStatus::Pending;
$this->withHeader('Authorization', "Bearer {$this->token}")
->postJson("/api/v1/payments/{$booking->booking_ref}/initiate")
->assertCreated();
$firstPaymentId = Payment::where('booking_id', $booking->id)->sole()->id;
$this->withHeader('Authorization', "Bearer {$this->token}")
->postJson("/api/v1/payments/{$booking->booking_ref}/initiate")
->assertCreated()
->assertJsonPath('data.id', $firstPaymentId);
expect(Payment::where('booking_id', $booking->id)->count())->toBe(1)
->and(FakeInitiatePaymentGateway::$initiateCalls)->toBe(1)
->and(FakeInitiatePaymentGateway::$verifyCalls)->toBe(1);
});
test('a repeat call reused attempt found completed on re-verify is returned without precreating again', function () {
$booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::PendingPayment]);
$this->withHeader('Authorization', "Bearer {$this->token}")
->postJson("/api/v1/payments/{$booking->booking_ref}/initiate")
->assertCreated();
FakeInitiatePaymentGateway::$verifyStatus = PaymentStatus::Completed;
$this->withHeader('Authorization', "Bearer {$this->token}")
->postJson("/api/v1/payments/{$booking->booking_ref}/initiate")
->assertCreated()
->assertJsonPath('data.status', PaymentStatus::Completed->value);
expect(Payment::where('booking_id', $booking->id)->count())->toBe(1)
->and(FakeInitiatePaymentGateway::$initiateCalls)->toBe(1);
});
test('a retried payment attempt gets a unique merchant order id', function () {
$booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::PendingPayment]);
Payment::factory()->failed()->create(['booking_id' => $booking->id]);
@@ -135,3 +191,44 @@ test('404s for a booking that does not exist', function () {
->postJson('/api/v1/payments/EVB-DOES-NOT-EXIST/initiate')
->assertNotFound();
});
test('round trip: initiating payment on the primary leg charges the combined total of both legs', function () {
$outbound = Booking::factory()->create([
'user_id' => $this->owner->id,
'status' => BookingStatus::PendingPayment,
'price' => 9000,
]);
$return = Booking::factory()->create([
'status' => BookingStatus::PendingPayment,
'price' => 11000,
'is_return_leg' => true,
'linked_booking_id' => $outbound->id,
]);
$outbound->update(['linked_booking_id' => $return->id]);
$this->withHeader('Authorization', "Bearer {$this->token}")
->postJson("/api/v1/payments/{$outbound->booking_ref}/initiate")
->assertCreated();
$payment = Payment::where('booking_id', $outbound->id)->sole();
expect((float) $payment->amount)->toBe(20000.0)
->and(Payment::where('booking_id', $return->id)->count())->toBe(0);
});
test('round trip: initiating payment on the return leg directly surfaces as 422', function () {
$outbound = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::PendingPayment]);
$return = Booking::factory()->create([
'user_id' => $this->owner->id,
'status' => BookingStatus::PendingPayment,
'is_return_leg' => true,
'linked_booking_id' => $outbound->id,
]);
$outbound->update(['linked_booking_id' => $return->id]);
$this->withHeader('Authorization', "Bearer {$this->token}")
->postJson("/api/v1/payments/{$return->booking_ref}/initiate")
->assertStatus(422);
expect(Payment::where('booking_id', $return->id)->count())->toBe(0);
});
@@ -49,10 +49,10 @@ test('initiate returns a pending PaymentResultData on a successful precreate', f
Http::fake(['kbz.test/*' => Http::response(['Response' => ['result' => 'SUCCESS', 'prepay_id' => 'PREPAY123']])]);
$result = (new KbzMiniAppGateway($config))->initiate($paymentRequest);
logger()->info('Payment initiation result', ['result' => $result]);
expect($result->status)->toBe(PaymentStatus::Pending)
->and($result->gatewayTransactionId)->toBe('EVB-FIXTURE-001')
->and($result->gatewayPayload)->toBe(['result' => 'SUCCESS', 'prepay_id' => 'PREPAY123']);
->and($result->gatewayPayload['prepayId'])->toBe('PREPAY123');
});
test('initiate returns a failed PaymentResultData when KBZ rejects the request', function () use ($config, $paymentRequest) {
@@ -33,3 +33,20 @@ test('does not crash if the booking was soft-deleted before this queued listener
expect(fn () => (new MarkBookingPaid)->handle(new PaymentCompleted($payment->fresh())))
->not->toThrow(Throwable::class);
});
test('a round trip: paying the primary leg also confirms its linked return leg', function () {
$outbound = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
$return = Booking::factory()->create([
'status' => BookingStatus::PendingPayment,
'is_return_leg' => true,
'linked_booking_id' => $outbound->id,
]);
$outbound->update(['linked_booking_id' => $return->id]);
$payment = Payment::factory()->completed()->create(['booking_id' => $outbound->id]);
(new MarkBookingPaid)->handle(new PaymentCompleted($payment));
expect($outbound->refresh()->status)->toBe(BookingStatus::Confirmed)
->and($return->refresh()->status)->toBe(BookingStatus::Confirmed);
});
@@ -0,0 +1,22 @@
<?php
use App\Models\User;
use Livewire\Livewire;
use Modules\Payment\Filament\Widgets\PaymentFailureRateWidget;
use Modules\Payment\Models\Payment;
test('it computes the failure rate among resolved payments in the last 30 days', function () {
$this->actingAs(User::factory()->create());
Payment::factory()->completed()->create(['initiated_at' => today()]);
Payment::factory()->completed()->create(['initiated_at' => today()]);
Payment::factory()->completed()->create(['initiated_at' => today()]);
Payment::factory()->failed()->create(['initiated_at' => today()]);
Payment::factory()->create(['initiated_at' => today()]); // pending, excluded from resolved total
Payment::factory()->failed()->create(['initiated_at' => today()->subDays(40)]); // outside window
Livewire::test(PaymentFailureRateWidget::class)
->assertOk()
->assertSee('25%')
->assertSee('1 failed of 4 resolved (last 30 days)');
});
@@ -152,3 +152,57 @@ test('a failed gateway refund is persisted as failed, leaves the booking untouch
Event::assertNotDispatched(RefundProcessed::class);
});
/**
* Round trip: payment is combined on the outbound ("primary") leg the
* return leg has no Payment of its own (domain.md §2b).
*/
function confirmedRoundTripWithCombinedPayment(string $outboundPrice, string $returnPrice): array
{
$outbound = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'price' => $outboundPrice]);
$return = Booking::factory()->create([
'status' => BookingStatus::Confirmed,
'price' => $returnPrice,
'is_return_leg' => true,
'linked_booking_id' => $outbound->id,
]);
$outbound->update(['linked_booking_id' => $return->id]);
$combined = bcadd($outboundPrice, $returnPrice, 2);
Payment::factory()->completed()->create([
'booking_id' => $outbound->id,
'gateway' => PaymentMethod::KbzMiniApp,
'amount' => $combined,
'gateway_transaction_id' => 'EVB-ROUNDTRIP-REFUND-1',
]);
return [$outbound->fresh(), $return->fresh()];
}
test('refunding a return leg draws a partial refund against the primary leg\'s combined payment', function () {
[$outbound, $return] = confirmedRoundTripWithCombinedPayment('9000.00', '11000.00');
$refund = app(RefundBookingAction::class)->handle($return, '11000', 'return leg cancelled');
expect($refund->status)->toBe(RefundStatus::Completed)
->and($refund->payment_id)->toBe($outbound->payments()->first()->id)
->and($return->refresh()->status)->toBe(BookingStatus::Cancelled)
->and($outbound->refresh()->status)->toBe(BookingStatus::Confirmed);
});
test('each leg of a round trip can be cancelled/refunded independently without exceeding the combined payment', function () {
[$outbound, $return] = confirmedRoundTripWithCombinedPayment('9000.00', '11000.00');
app(RefundBookingAction::class)->handle($return, '11000', 'return leg cancelled');
$second = app(RefundBookingAction::class)->handle($outbound, '9000', 'outbound leg cancelled too');
expect($second->status)->toBe(RefundStatus::Completed)
->and($outbound->refresh()->status)->toBe(BookingStatus::Cancelled)
->and($return->refresh()->status)->toBe(BookingStatus::Cancelled);
// Cumulative refunds (20000) exactly match the combined payment total —
// a third refund attempt on either leg must now fail.
expect(fn () => app(RefundBookingAction::class)->handle($outbound, '1', 'over the limit'))
->toThrow(RefundNotAllowedException::class);
});
@@ -0,0 +1,19 @@
<?php
use Modules\Payment\Filament\Widgets\RevenueChartWidget;
use Modules\Payment\Models\Payment;
test('it sums completed payment amounts per day over the last 30 days', function () {
Payment::factory()->completed()->create(['amount' => 10000, 'completed_at' => today()]);
Payment::factory()->completed()->create(['amount' => 5000, 'completed_at' => today()]);
Payment::factory()->create(['amount' => 99999, 'completed_at' => null]); // pending, excluded
Payment::factory()->completed()->create(['amount' => 77777, 'completed_at' => today()->subDays(40)]); // outside window
$widget = new RevenueChartWidget;
$getData = (new ReflectionMethod($widget, 'getData'));
$getData->setAccessible(true);
$data = $getData->invoke($widget);
expect($data['labels'])->toHaveCount(30)
->and(array_sum($data['datasets'][0]['data']))->toBe(15000.0);
});
+26
View File
@@ -0,0 +1,26 @@
{
"name": "modules/reporting",
"description": "",
"type": "library",
"version": "1.0",
"license": "proprietary",
"require": {
"maatwebsite/excel": "^4.0"
},
"autoload": {
"psr-4": {
"Modules\\Reporting\\": "src/",
"Modules\\Reporting\\Tests\\": "tests/",
"Modules\\Reporting\\Database\\Factories\\": "database/factories/",
"Modules\\Reporting\\Database\\Seeders\\": "database/seeders/"
}
},
"minimum-stability": "stable",
"extra": {
"laravel": {
"providers": [
"Modules\\Reporting\\Providers\\ReportingServiceProvider"
]
}
}
}
@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Supports the Bookings & Revenue report's filters travel_date/status/
* created_by_channel on bookings and completed_at on payments had no
* standalone index before this (only openid and the composite
* [ev_route_id, travel_date, departure_time_slot_id] existed).
*/
public function up(): void
{
Schema::table('bookings', function (Blueprint $table) {
$table->index('travel_date');
$table->index('status');
$table->index('created_by_channel');
});
Schema::table('payments', function (Blueprint $table) {
$table->index('completed_at');
});
}
public function down(): void
{
Schema::table('bookings', function (Blueprint $table) {
$table->dropIndex(['travel_date']);
$table->dropIndex(['status']);
$table->dropIndex(['created_by_channel']);
});
Schema::table('payments', function (Blueprint $table) {
$table->dropIndex(['completed_at']);
});
}
};
@@ -0,0 +1,5 @@
<x-filament-panels::page>
{{ $this->filtersForm }}
{{ $this->table }}
</x-filament-panels::page>
@@ -0,0 +1,134 @@
<?php
namespace Modules\Reporting\Exports;
use Illuminate\Database\Eloquent\Builder;
use Maatwebsite\Excel\Concerns\FromQuery;
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
use Maatwebsite\Excel\Concerns\WithCustomCsvSettings;
use Maatwebsite\Excel\Concerns\WithEvents;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithMapping;
use Maatwebsite\Excel\Events\AfterSheet;
use Modules\Booking\Models\Booking;
use Modules\Payment\Enums\PaymentStatus;
use Modules\Payment\Models\Payment;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
/**
* One row per Booking, with its "best" Payment (completed, else most recent)
* joined on, plus a bold TOTAL row summing passenger count, price, and
* payment amount. Backs both the CSV and Excel exports of the Bookings &
* Revenue report Excel::download() picks the writer, this class supplies
* the columns once for both formats.
*/
class BookingsRevenueExport implements FromQuery, ShouldAutoSize, WithCustomCsvSettings, WithEvents, WithHeadings, WithMapping
{
public function __construct(private readonly Builder $query) {}
public function query(): Builder
{
return $this->query;
}
/**
* @return array<int, string>
*/
public function headings(): array
{
return [
'Booking Ref', 'Travel Date', 'Route', 'Channel', 'Status',
'Passenger Name', 'Passenger Count', 'Price', 'Payment Status',
'Payment Amount', 'Driver Name', 'Driver Phone', 'Car Plate',
'Car Model', 'Vehicle Options',
];
}
/**
* @return array<int, mixed>
*/
public function map($booking): array
{
/** @var Booking $booking */
$payment = $this->bestPayment($booking);
return [
$booking->booking_ref,
$booking->travel_date?->toDateString(),
$booking->route ? $booking->route->name : '',
$booking->created_by_channel?->value,
$booking->status->value,
$booking->passenger_name,
$booking->vehicleOptions->sum('passenger_count'),
(float) $booking->price,
$payment?->status?->value ?? '',
$payment ? (float) $payment->amount : null,
$booking->driver_name,
$booking->driver_phone,
$booking->car_plate_number,
$booking->car_model,
$booking->vehicleOptions
->map(fn ($v) => str($v->vehicle_option->value)->headline().' x'.$v->passenger_count)
->implode('; '),
];
}
/**
* Appends a bold TOTAL row (passenger count, price, payment amount)
* below the last data row. Re-fetches the already-filtered query rather
* than accumulating during map() FromQuery streams rows in chunks, so
* there's no single point with the full result set to total as it's
* written; report-sized result sets make a second fetch cheap enough to
* trade for keeping the chunked write untouched.
*
* @return array<string, callable>
*/
public function registerEvents(): array
{
return [
AfterSheet::class => function (AfterSheet $event): void {
$bookings = (clone $this->query)->get();
$totalPassengers = $bookings->sum(fn (Booking $b) => $b->vehicleOptions->sum('passenger_count'));
$totalPrice = $bookings->sum('price');
$totalPaid = $bookings->sum(fn (Booking $b) => $this->bestPayment($b)?->amount ?? 0);
$worksheet = $event->getDelegate();
$row = $worksheet->getHighestRow() + 1;
$lastColumn = Coordinate::stringFromColumnIndex(count($this->headings()));
$worksheet->setCellValue("A{$row}", 'TOTAL');
$worksheet->setCellValue($this->columnFor('Passenger Count').$row, $totalPassengers);
$worksheet->setCellValue($this->columnFor('Price').$row, $totalPrice);
$worksheet->setCellValue($this->columnFor('Payment Amount').$row, $totalPaid);
$worksheet->getStyle("A{$row}:{$lastColumn}{$row}")->getFont()->setBold(true);
},
];
}
private function columnFor(string $heading): string
{
return Coordinate::stringFromColumnIndex(array_search($heading, $this->headings(), true) + 1);
}
private function bestPayment(Booking $booking): ?Payment
{
return $booking->payments->sortByDesc(
fn (Payment $p) => $p->status === PaymentStatus::Completed ? 1 : 0
)->first();
}
/**
* Excel's CSV import guesses encoding from the system locale unless a
* UTF-8 BOM is present, so passenger names/routes containing Burmese
* (or other non-Latin) text open correctly instead of as mojibake.
*
* @return array<string, mixed>
*/
public function getCsvSettings(): array
{
return [
'use_bom' => true,
];
}
}
@@ -0,0 +1,195 @@
<?php
namespace Modules\Reporting\Filament\Pages;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Pages\Page;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\Summarizers\Sum;
use Filament\Tables\Columns\Summarizers\Summarizer;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Maatwebsite\Excel\Excel as ExcelFormat;
use Maatwebsite\Excel\Facades\Excel;
use Modules\Booking\Enums\BookingChannel;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Models\Booking;
use Modules\Payment\Enums\PaymentStatus;
use Modules\Payment\Models\Payment;
use Modules\Reporting\Exports\BookingsRevenueExport;
use Modules\Routing\Models\EvRoute;
use UnitEnum;
class BookingsRevenueReport extends Page implements HasTable
{
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedDocumentChartBar;
protected static string|UnitEnum|null $navigationGroup = 'Reports';
protected static ?string $navigationLabel = 'Bookings & Revenue';
protected static ?string $title = 'Bookings & Revenue Report';
protected string $view = 'reporting::filament.pages.bookings-revenue-report';
/**
* @var array<string, mixed>|null
*/
public ?array $filters = [];
public static function canAccess(): bool
{
return auth()->user()?->can('view_reports') ?? false;
}
public function mount(): void
{
$this->filtersForm->fill();
}
public function filtersForm(Schema $schema): Schema
{
return $schema
->components([
DatePicker::make('travel_date_from')
->label('Travel date from')
->live(),
DatePicker::make('travel_date_to')
->label('Travel date to')
->afterOrEqual('travel_date_from')
->live(),
Select::make('status')
->label('Status')
->options(BookingStatus::class)
->native(false)
->placeholder('All statuses')
->live(),
Select::make('ev_route_id')
->label('Route')
->options(fn () => EvRoute::with(['fromDestination', 'toDestination'])->get()
->mapWithKeys(fn (EvRoute $route) => [$route->id => $route->name]))
->searchable()
->placeholder('All routes')
->live(),
Select::make('created_by_channel')
->label('Channel')
->options(BookingChannel::class)
->native(false)
->placeholder('All channels')
->live(),
])
->columns(3)
->statePath('filters');
}
public function table(Table $table): Table
{
return $table
->query(fn (): Builder => $this->reportQuery())
->columns([
TextColumn::make('booking_ref')
->label('Ref')
->sortable(),
TextColumn::make('travel_date')
->date()
->sortable(),
TextColumn::make('route.name')
->label('Route'),
TextColumn::make('created_by_channel')
->badge(),
TextColumn::make('status')
->badge(),
TextColumn::make('passenger_name')
->label('Passenger'),
TextColumn::make('passenger_count')
->label('Pax')
->state(fn (Booking $record) => $record->vehicleOptions->sum('passenger_count'))
->summarize(Summarizer::make()
->label('Total')
->using(fn (QueryBuilder $query) => Booking::query()
->with('vehicleOptions')
->whereIn('id', (clone $query)->pluck('id'))
->get()
->sum(fn (Booking $b) => $b->vehicleOptions->sum('passenger_count')))),
TextColumn::make('price')
->numeric(2)
->sortable()
->summarize(Sum::make()->label('Total')),
TextColumn::make('payment_status')
->label('Payment')
->state(fn (Booking $record) => $this->bestPayment($record)?->status?->value ?? '—'),
TextColumn::make('payment_amount')
->label('Paid')
->state(fn (Booking $record) => $this->bestPayment($record)?->amount)
->summarize(Summarizer::make()
->label('Total')
->using(fn (QueryBuilder $query) => Booking::query()
->with('payments')
->whereIn('id', (clone $query)->pluck('id'))
->get()
->sum(fn (Booking $b) => $this->bestPayment($b)?->amount ?? 0))),
TextColumn::make('driver_name')
->label('Driver')
->placeholder('—'),
])
->defaultSort('travel_date', 'desc')
->paginated([25, 50, 100]);
}
public function reportQuery(): Builder
{
$data = $this->filters ?? [];
return Booking::query()
->with(['route.fromDestination', 'route.toDestination', 'payments', 'vehicleOptions'])
->when($data['travel_date_from'] ?? null, fn (Builder $q, $d) => $q->whereDate('travel_date', '>=', $d))
->when($data['travel_date_to'] ?? null, fn (Builder $q, $d) => $q->whereDate('travel_date', '<=', $d))
->when($data['status'] ?? null, fn (Builder $q, $s) => $q->where('status', $s))
->when($data['ev_route_id'] ?? null, fn (Builder $q, $id) => $q->where('ev_route_id', $id))
->when($data['created_by_channel'] ?? null, fn (Builder $q, $c) => $q->where('created_by_channel', $c))
// A unique tie-breaker after travel_date — required for FromQuery's
// chunked export to paginate deterministically (see its docblock);
// the table's own defaultSort() applies on top of this for display.
->orderBy('travel_date', 'desc')
->orderBy('id');
}
protected function bestPayment(Booking $record): ?Payment
{
return $record->payments->sortByDesc(
fn (Payment $p) => $p->status === PaymentStatus::Completed ? 1 : 0
)->first();
}
protected function getHeaderActions(): array
{
return [
Action::make('exportCsv')
->label('Export CSV')
->icon(Heroicon::OutlinedArrowDownTray)
->action(fn () => Excel::download(
new BookingsRevenueExport($this->reportQuery()),
'bookings-revenue-'.now()->format('Y-m-d').'.csv',
ExcelFormat::CSV,
)),
Action::make('exportXlsx')
->label('Export Excel')
->icon(Heroicon::OutlinedArrowDownTray)
->action(fn () => Excel::download(
new BookingsRevenueExport($this->reportQuery()),
'bookings-revenue-'.now()->format('Y-m-d').'.xlsx',
ExcelFormat::XLSX,
)),
];
}
}
@@ -0,0 +1,12 @@
<?php
namespace Modules\Reporting\Providers;
use Illuminate\Support\ServiceProvider;
class ReportingServiceProvider extends ServiceProvider
{
public function register(): void {}
public function boot(): void {}
}
@@ -0,0 +1,29 @@
<?php
namespace Modules\Reporting;
use Filament\Contracts\Plugin;
use Filament\Panel;
class ReportingPlugin implements Plugin
{
public function getId(): string
{
return 'reporting';
}
public function register(Panel $panel): void
{
$panel->discoverPages(
in: __DIR__.'/Filament/Pages',
for: 'Modules\Reporting\Filament\Pages',
);
}
public function boot(Panel $panel): void {}
public static function make(): static
{
return app(static::class);
}
}
@@ -0,0 +1,89 @@
<?php
use App\Models\User;
use Livewire\Livewire;
use Maatwebsite\Excel\Excel as ExcelFormat;
use Maatwebsite\Excel\Facades\Excel;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Models\Booking;
use Modules\Reporting\Exports\BookingsRevenueExport;
use Modules\Reporting\Filament\Pages\BookingsRevenueReport;
use PhpOffice\PhpSpreadsheet\IOFactory;
use Spatie\Permission\Models\Permission;
beforeEach(function () {
Permission::findOrCreate('view_reports', 'web');
$this->admin = User::factory()->create()->givePermissionTo(['view_reports']);
$this->actingAs($this->admin);
});
test('it renders for a user with view_reports', function () {
Livewire::test(BookingsRevenueReport::class)->assertOk();
});
test('a user without view_reports cannot access it', function () {
$this->actingAs(User::factory()->create());
expect(BookingsRevenueReport::canAccess())->toBeFalse();
});
test('it narrows results by travel date range and status', function () {
$inRange = Booking::factory()->create(['travel_date' => today(), 'status' => BookingStatus::Confirmed]);
$outOfRange = Booking::factory()->create(['travel_date' => today()->addMonths(2), 'status' => BookingStatus::Confirmed]);
$wrongStatus = Booking::factory()->create(['travel_date' => today(), 'status' => BookingStatus::Cancelled]);
Livewire::test(BookingsRevenueReport::class)
->fillForm([
'travel_date_from' => today()->toDateString(),
'travel_date_to' => today()->toDateString(),
'status' => BookingStatus::Confirmed->value,
], 'filtersForm')
->assertCanSeeTableRecords([$inRange])
->assertCanNotSeeTableRecords([$outOfRange, $wrongStatus]);
});
test('exporting csv triggers a download', function () {
Excel::fake();
Booking::factory()->create();
Livewire::test(BookingsRevenueReport::class)->callAction('exportCsv');
Excel::assertDownloaded('bookings-revenue-'.now()->format('Y-m-d').'.csv');
});
test('exporting excel triggers a download', function () {
Excel::fake();
Booking::factory()->create();
Livewire::test(BookingsRevenueReport::class)->callAction('exportXlsx');
Excel::assertDownloaded('bookings-revenue-'.now()->format('Y-m-d').'.xlsx');
});
test('the export includes passenger name/count columns and a total row', function () {
$a = Booking::factory()->create(['passenger_name' => 'Jane Doe', 'price' => 10000]);
$a->vehicleOptions()->create(['vehicle_option' => 'back_seat', 'passenger_count' => 2, 'unit_price' => 5000, 'line_total' => 10000]);
$b = Booking::factory()->create(['passenger_name' => 'John Roe', 'price' => 15000]);
$b->vehicleOptions()->create(['vehicle_option' => 'back_seat', 'passenger_count' => 3, 'unit_price' => 5000, 'line_total' => 15000]);
$export = new BookingsRevenueExport(Booking::query()->with(['route.fromDestination', 'route.toDestination', 'payments', 'vehicleOptions']));
$path = storage_path('app/test-bookings-revenue.xlsx');
file_put_contents($path, Excel::raw($export, ExcelFormat::XLSX));
$sheet = IOFactory::load($path)->getActiveSheet();
unlink($path);
expect($sheet->getCell('F1')->getValue())->toBe('Passenger Name')
->and($sheet->getCell('G1')->getValue())->toBe('Passenger Count')
->and([$sheet->getCell('F2')->getValue(), $sheet->getCell('F3')->getValue()])->toContain('Jane Doe', 'John Roe');
$totalRow = $sheet->getHighestRow();
expect($sheet->getCell("A{$totalRow}")->getValue())->toBe('TOTAL')
->and((int) $sheet->getCell("G{$totalRow}")->getValue())->toBe(5) // 2 + 3 passengers
->and((float) $sheet->getCell("H{$totalRow}")->getValue())->toBe(25000.0); // 10000 + 15000 price
});
@@ -23,7 +23,6 @@ class EvRouteFactory extends Factory
'ev_company_id' => EvCompany::factory(),
'from_destination_id' => Destination::factory(),
'to_destination_id' => Destination::factory(),
'is_round_trip' => false,
'is_active' => true,
];
}
@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Staff-curated flag for surfacing a route as "popular" on the customer
* side filterable via the routes index endpoint's `popular` param.
*/
public function up(): void
{
Schema::table('ev_routes', function (Blueprint $table) {
$table->boolean('is_popular')->default(false)->after('is_active');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('ev_routes', function (Blueprint $table) {
$table->dropColumn('is_popular');
});
}
};
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Round trip is no longer a flag on the route a round-trip booking now
* explicitly supplies a `return_ev_route_id`, validated server-side as
* the true reverse of the outbound route (`EvRoute::isReverseOf`), so
* this flag has no remaining purpose (domain.md §2b).
*/
public function up(): void
{
Schema::table('ev_routes', function (Blueprint $table) {
$table->dropColumn('is_round_trip');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('ev_routes', function (Blueprint $table) {
$table->boolean('is_round_trip')->default(false);
});
}
};
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* "Popular routes" is being reworked from scratch to match the
* client's actual logic (details TBD) the blunt is_popular flag
* shipped in 2026_08_20_010000 didn't align with it, so it's removed
* rather than kept around unused.
*/
public function up(): void
{
Schema::table('ev_routes', function (Blueprint $table) {
$table->dropColumn('is_popular');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('ev_routes', function (Blueprint $table) {
$table->boolean('is_popular')->default(false);
});
}
};
@@ -3,8 +3,8 @@
use Illuminate\Support\Facades\Route;
use Modules\Routing\Http\Controllers\EvRouteController;
Route::prefix('api/v1')->middleware(['api', 'auth:sanctum', 'throttle:api-read'])->group(function () {
Route::get('/routes', [EvRouteController::class, 'index'])->name('routing.routes.index');
Route::prefix('api/v1')->middleware(['api', 'api.auth', 'throttle:api-read'])->group(function () {
Route::post('/routes/search', [EvRouteController::class, 'search'])->name('routing.routes.search');
Route::get('/routes/{route}', [EvRouteController::class, 'show'])->name('routing.routes.show');
Route::get('/routes/{route}/pricing', [EvRouteController::class, 'pricing'])->name('routing.routes.pricing');
Route::get('/routes/{route}/time-slots', [EvRouteController::class, 'timeSlots'])->name('routing.routes.time-slots');

Some files were not shown because too many files have changed in this diff Show More