Compare commits
18 Commits
8d74ac74cd
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f0f20659d | |||
| 98dacef556 | |||
| da6d51b7b2 | |||
| 0e55e36cea | |||
| da9cd9bbe0 | |||
| 41c9454334 | |||
| fa908cdcaf | |||
| 894352b43f | |||
| 1aeb57f130 | |||
| 6be47aa35a | |||
| 79f7f50706 | |||
| 54b35ee087 | |||
| dfffdd343b | |||
| a9124ccb8d | |||
| 5c215b4647 | |||
| 532f2ddf99 | |||
| c3a6f6cc6e | |||
| 2fdfc0040c |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+10
-3
@@ -53,9 +53,16 @@ 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=
|
||||
|
||||
@@ -25,6 +25,18 @@ jobs:
|
||||
--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
|
||||
@@ -56,12 +68,21 @@ jobs:
|
||||
- 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
|
||||
env:
|
||||
DB_CONNECTION: pgsql
|
||||
DB_HOST: 127.0.0.1
|
||||
DB_PORT: 5432
|
||||
DB_DATABASE: testing
|
||||
DB_USERNAME: root
|
||||
DB_PASSWORD: ''
|
||||
run: php artisan test --compact
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
+32
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
+40
@@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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,12 +27,75 @@ class CreateBookingAction
|
||||
{
|
||||
$this->bookingService->validateSelections($data->selections);
|
||||
|
||||
return DB::transaction(function () use ($data) {
|
||||
$route = EvRoute::findOrFail($data->evRouteId);
|
||||
$isRoundTrip = $data->returnEvRouteId !== null;
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
// 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]);
|
||||
|
||||
// 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);
|
||||
|
||||
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),
|
||||
$data->selections,
|
||||
$selections,
|
||||
);
|
||||
|
||||
$totalPrice = array_reduce(
|
||||
@@ -44,11 +108,13 @@ class CreateBookingAction
|
||||
'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,
|
||||
'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,
|
||||
@@ -57,17 +123,12 @@ class CreateBookingAction
|
||||
'dropoff_lng' => $data->dropoffLng,
|
||||
'price' => $totalPrice,
|
||||
'status' => BookingStatus::PendingPayment,
|
||||
'is_round_trip' => $data->isRoundTrip,
|
||||
'return_travel_date' => $data->returnTravelDate,
|
||||
'created_by_channel' => $data->createdByChannel,
|
||||
]);
|
||||
|
||||
$booking->vehicleOptions()->createMany($lines);
|
||||
|
||||
BookingCreated::dispatch($booking);
|
||||
|
||||
return $booking;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+1
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
@@ -46,6 +51,17 @@ class BookingController extends Controller
|
||||
}
|
||||
|
||||
$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();
|
||||
@@ -83,6 +99,18 @@ 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
|
||||
@@ -98,6 +126,7 @@ 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: $channel,
|
||||
@@ -110,8 +139,10 @@ class BookingController extends Controller
|
||||
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)))
|
||||
|
||||
@@ -34,14 +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'],
|
||||
// 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'],
|
||||
'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}";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,6 +206,33 @@ test('created_by_channel is taken from the Device-Type header', function (string
|
||||
'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']]);
|
||||
|
||||
@@ -221,3 +248,122 @@ test('a Device-Type header cannot spoof the agent or admin channel', function (s
|
||||
'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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@ 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;
|
||||
@@ -59,6 +61,7 @@ test('a FastAPI JWT booking is stored against the verified openid, ignoring a sp
|
||||
|
||||
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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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']);
|
||||
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
+42
@@ -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>
|
||||
@@ -43,6 +43,10 @@ class InitiatePaymentAction
|
||||
throw PaymentInitiationNotAllowedException::notPendingPayment($booking);
|
||||
}
|
||||
|
||||
if ($booking->is_return_leg) {
|
||||
throw PaymentInitiationNotAllowedException::isReturnLeg($booking);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($booking, $method) {
|
||||
$booking = Booking::whereKey($booking->id)->lockForUpdate()->first();
|
||||
|
||||
@@ -62,11 +66,12 @@ class InitiatePaymentAction
|
||||
}
|
||||
|
||||
$merchantOrderId = $this->merchantOrderId($booking);
|
||||
$amount = $this->amount($booking);
|
||||
|
||||
$result = $this->paymentService->initiate(new PaymentRequestData(
|
||||
bookingId: $booking->id,
|
||||
merchantOrderId: $merchantOrderId,
|
||||
amount: (string) $booking->price,
|
||||
amount: $amount,
|
||||
currency: self::CURRENCY,
|
||||
method: $method,
|
||||
notifyUrl: $this->notifyUrl($booking, $method),
|
||||
@@ -76,7 +81,7 @@ class InitiatePaymentAction
|
||||
'booking_id' => $booking->id,
|
||||
'gateway' => $method,
|
||||
'status' => $result->status,
|
||||
'amount' => $booking->price,
|
||||
'amount' => $amount,
|
||||
'currency' => self::CURRENCY,
|
||||
'gateway_transaction_id' => $result->gatewayTransactionId ?? $merchantOrderId,
|
||||
'gateway_payload' => $result->gatewayPayload,
|
||||
@@ -85,6 +90,20 @@ class InitiatePaymentAction
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* A booking can have more than one payment attempt (retry after
|
||||
* failure), so the merchant order id must be unique per attempt, not
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -114,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),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -239,7 +245,7 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
|
||||
'timestamp' => (string) now()->timestamp,
|
||||
'method' => 'kbz.payment.precreate',
|
||||
'notify_url' => $data->notifyUrl ?? $this->notifyUrl,
|
||||
'nonce_str' => $this->nonceStr(),
|
||||
'nonce_str' => uniqid(),
|
||||
'version' => '1.0',
|
||||
'biz_content' => [
|
||||
'appid' => $this->appId,
|
||||
@@ -266,7 +272,7 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
|
||||
$params = [
|
||||
'timestamp' => (string) now()->timestamp,
|
||||
'method' => 'kbz.payment.queryorder',
|
||||
'nonce_str' => $this->nonceStr(),
|
||||
'nonce_str' => uniqid(),
|
||||
'version' => '1.0',
|
||||
'biz_content' => [
|
||||
'appid' => $this->appId,
|
||||
@@ -303,7 +309,7 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
|
||||
$params = [
|
||||
'timestamp' => (string) now()->timestamp,
|
||||
'method' => 'kbz.payment.refund',
|
||||
'nonce_str' => $this->nonceStr(),
|
||||
'nonce_str' => uniqid(),
|
||||
'version' => '1.0',
|
||||
'biz_content' => [
|
||||
'appid' => $this->appId,
|
||||
@@ -329,18 +335,6 @@ class KbzMiniAppGateway implements PaymentGatewayInterface
|
||||
return now()->format('YmdHi').strtoupper(Str::random(8));
|
||||
}
|
||||
|
||||
/**
|
||||
* KBZ requires `nonce_str` to be a plain alphanumeric string of at most
|
||||
* 32 characters — no hyphens or other special characters (confirmed
|
||||
* against KBZ's "Query Order" field spec). `Str::uuid()` violates both
|
||||
* constraints (36 chars, hyphenated), which silently broke `precreate`
|
||||
* downstream even though the request's own signature still validated.
|
||||
*/
|
||||
private function nonceStr(): string
|
||||
{
|
||||
return strtoupper(Str::random(32));
|
||||
}
|
||||
|
||||
/**
|
||||
* mTLS options for the refund call — KBZ requires a client cert/key +
|
||||
* CA bundle on `kbz.payment.refund` specifically (domain.md §6).
|
||||
@@ -369,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()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ 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
|
||||
@@ -21,11 +22,20 @@ class PaymentController extends Controller
|
||||
$openid = $request->attributes->get('fastapi_openid');
|
||||
|
||||
if ($openid === null) {
|
||||
Gate::authorize('create', Booking::class);
|
||||
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);
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,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);
|
||||
});
|
||||
|
||||
@@ -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,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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
+29
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
+31
@@ -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);
|
||||
});
|
||||
}
|
||||
};
|
||||
+31
@@ -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);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -4,7 +4,7 @@ use Illuminate\Support\Facades\Route;
|
||||
use Modules\Routing\Http\Controllers\EvRouteController;
|
||||
|
||||
Route::prefix('api/v1')->middleware(['api', 'api.auth', 'throttle:api-read'])->group(function () {
|
||||
Route::get('/routes', [EvRouteController::class, 'index'])->name('routing.routes.index');
|
||||
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');
|
||||
|
||||
@@ -6,6 +6,8 @@ use Filament\Forms\Components\Repeater;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
|
||||
@@ -15,6 +17,10 @@ class EvRouteForm
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Route')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
Select::make('ev_company_id')
|
||||
->label('EV Company')
|
||||
->relationship('company', 'name')
|
||||
@@ -43,16 +49,25 @@ class EvRouteForm
|
||||
->multiple()
|
||||
->searchable()
|
||||
->preload(),
|
||||
Toggle::make('is_round_trip')
|
||||
->required()
|
||||
->default(false),
|
||||
]),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make('Options')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
Toggle::make('is_active')
|
||||
->required()
|
||||
->default(false)
|
||||
->helperText('Every non-blocked vehicle option must have a price above 0 before a route can be activated.'),
|
||||
]),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make('Pricing')
|
||||
->schema([
|
||||
Repeater::make('pricing')
|
||||
->relationship()
|
||||
->label('Pricing')
|
||||
->hiddenLabel()
|
||||
->schema([
|
||||
Select::make('vehicle_option')
|
||||
->options(array_combine(
|
||||
@@ -82,7 +97,8 @@ class EvRouteForm
|
||||
)
|
||||
->addable(false)
|
||||
->deletable(false)
|
||||
->reorderable(false)
|
||||
->reorderable(false),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ namespace Modules\Routing\Filament\Resources\EvRoutes\Tables;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Support\Enums\Width;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Filters\TernaryFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Modules\Routing\Models\EvRoute;
|
||||
@@ -40,8 +42,6 @@ class EvRoutesTable
|
||||
.($pricing->is_blocked ? 'Blocked' : number_format($pricing->price, 0)))
|
||||
->all())
|
||||
->listWithLineBreaks(),
|
||||
IconColumn::make('is_round_trip')
|
||||
->boolean(),
|
||||
IconColumn::make('is_active')
|
||||
->boolean(),
|
||||
TextColumn::make('created_at')
|
||||
@@ -50,9 +50,25 @@ class EvRoutesTable
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('ev_company_id')
|
||||
->label('Company')
|
||||
->relationship('company', 'name')
|
||||
->searchable()
|
||||
->preload(),
|
||||
SelectFilter::make('from_destination_id')
|
||||
->label('From')
|
||||
->relationship('fromDestination', 'name')
|
||||
->searchable()
|
||||
->preload(),
|
||||
SelectFilter::make('to_destination_id')
|
||||
->label('To')
|
||||
->relationship('toDestination', 'name')
|
||||
->searchable()
|
||||
->preload(),
|
||||
TernaryFilter::make('is_active'),
|
||||
TernaryFilter::make('is_round_trip'),
|
||||
])
|
||||
->filtersFormColumns(2)
|
||||
->filtersFormWidth(Width::Large)
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
])
|
||||
|
||||
@@ -2,10 +2,16 @@
|
||||
|
||||
namespace Modules\Routing\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Modules\Catalog\Models\EvCompany;
|
||||
use Modules\Routing\Http\Requests\SearchRoutesRequest;
|
||||
use Modules\Routing\Http\Resources\EvRouteResource;
|
||||
use Modules\Routing\Http\Resources\RoutePricingResource;
|
||||
use Modules\Routing\Http\Resources\RouteTimeSlotResource;
|
||||
@@ -22,26 +28,111 @@ class EvRouteController extends Controller
|
||||
|
||||
private const CACHE_TTL_MINUTES = 5;
|
||||
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
/**
|
||||
* POST, not GET: round_trip=true returns two independent result sets
|
||||
* (routes + return_routes) in one response, which doesn't fit a plain
|
||||
* GET-with-query-params search shape as cleanly (domain.md §2b).
|
||||
*/
|
||||
public function search(SearchRoutesRequest $request): JsonResponse
|
||||
{
|
||||
$filters = $request->only(['company', 'from', 'to', 'date']);
|
||||
$page = $request->integer('page', 1);
|
||||
$filters = $request->only(['company', 'from', 'to', 'date', 'time_slot']);
|
||||
$isRoundTrip = $request->boolean('round_trip');
|
||||
|
||||
$routes = Cache::tags(self::CACHE_TAG)->remember(
|
||||
'routes:index:'.md5(json_encode($filters + ['page' => $page])),
|
||||
// Separate page params: routes and return_routes almost always have
|
||||
// different totals, so paging one must never slice the other at the
|
||||
// same offset (e.g. return_routes with only 3 rows would come back
|
||||
// empty on page=2 while routes still has real data there).
|
||||
$routes = $this->searchRoutes($filters, fromKey: 'from', toKey: 'to', pageName: 'page', page: $request->integer('page', 1));
|
||||
|
||||
$returnRoutes = $isRoundTrip
|
||||
? $this->searchRoutes($filters, fromKey: 'to', toKey: 'from', pageName: 'return_page', page: $request->integer('return_page', 1))
|
||||
: new LengthAwarePaginator([], 0, 15);
|
||||
|
||||
return response()->json([
|
||||
'routes' => EvRouteResource::collection($routes)->response()->getData(true),
|
||||
'return_routes' => EvRouteResource::collection($returnRoutes)->response()->getData(true),
|
||||
// The company/time_slot options actually available for this
|
||||
// from->to pair — computed from from/to alone, ignoring any
|
||||
// company/time_slot already applied, so the client can offer
|
||||
// switching between them rather than guessing a static list.
|
||||
'filters' => $this->filterOptions($filters['from'] ?? null, $filters['to'] ?? null),
|
||||
'return_filters' => $isRoundTrip
|
||||
? $this->filterOptions($filters['to'] ?? null, $filters['from'] ?? null)
|
||||
: ['companies' => [], 'time_slots' => []],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{companies: array<int, array<string, mixed>>, time_slots: array<int, array<string, mixed>>}
|
||||
*/
|
||||
private function filterOptions(mixed $from, mixed $to): array
|
||||
{
|
||||
if (blank($from) || blank($to)) {
|
||||
return ['companies' => [], 'time_slots' => []];
|
||||
}
|
||||
|
||||
$cacheKey = "routes:filter-options:{$from}:{$to}";
|
||||
|
||||
return Cache::tags(self::CACHE_TAG)->remember(
|
||||
$cacheKey,
|
||||
now()->addMinutes(self::CACHE_TTL_MINUTES),
|
||||
function () use ($from, $to) {
|
||||
$routes = EvRoute::query()
|
||||
->where('is_active', true)
|
||||
->where('from_destination_id', $from)
|
||||
->where('to_destination_id', $to)
|
||||
->with(['company', 'timeSlots' => fn (BelongsToMany $query) => $query->wherePivot('is_active', true)])
|
||||
->get();
|
||||
|
||||
$companies = $routes->pluck('company')->filter()->unique('id')->sortBy('name')->values();
|
||||
$timeSlots = $routes->flatMap(fn (EvRoute $route) => $route->timeSlots)->unique('id')->sortBy('time')->values();
|
||||
|
||||
return [
|
||||
// Facet purposes only — not the full EvCompanyResource
|
||||
// (no slug/description/contact/logo needed just to
|
||||
// populate a filter option).
|
||||
'companies' => $companies->map(fn (EvCompany $company) => [
|
||||
'id' => $company->id,
|
||||
'name' => $company->name,
|
||||
'mm_name' => $company->mm_name,
|
||||
])->all(),
|
||||
'time_slots' => $timeSlots->map(fn ($slot) => [
|
||||
'id' => $slot->id,
|
||||
'label' => $slot->label,
|
||||
'time' => $slot->time?->format('H:i'),
|
||||
])->all(),
|
||||
];
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters Keyed by 'from'/'to' regardless of
|
||||
* $fromKey/$toKey — swapped for the return leg of a round trip.
|
||||
*/
|
||||
private function searchRoutes(array $filters, string $fromKey, string $toKey, string $pageName, int $page): LengthAwarePaginator
|
||||
{
|
||||
$cacheKey = 'routes:search:'.md5(json_encode($filters + ['fromKey' => $fromKey, 'page' => $page]));
|
||||
|
||||
return Cache::tags(self::CACHE_TAG)->remember(
|
||||
$cacheKey,
|
||||
now()->addMinutes(self::CACHE_TTL_MINUTES),
|
||||
fn () => EvRoute::query()
|
||||
->where('is_active', true)
|
||||
->when($request->filled('company'), fn ($query) => $query->where('ev_company_id', $request->integer('company')))
|
||||
->when($request->filled('from'), fn ($query) => $query->where('from_destination_id', $request->integer('from')))
|
||||
->when($request->filled('to'), fn ($query) => $query->where('to_destination_id', $request->integer('to')))
|
||||
->when(filled($filters['company'] ?? null), fn (Builder $query) => $query->where('ev_company_id', $filters['company']))
|
||||
->when(filled($filters[$fromKey] ?? null), fn (Builder $query) => $query->where('from_destination_id', $filters[$fromKey]))
|
||||
->when(filled($filters[$toKey] ?? null), fn (Builder $query) => $query->where('to_destination_id', $filters[$toKey]))
|
||||
->when(filled($filters['time_slot'] ?? null), fn (Builder $query) => $query->whereHas(
|
||||
'timeSlots',
|
||||
fn (Builder $timeSlotQuery) => $timeSlotQuery
|
||||
->where('departure_time_slots.time', Carbon::createFromFormat('H:i', $filters['time_slot'])->format('H:i:s'))
|
||||
->where('ev_route_time_slots.is_active', true),
|
||||
))
|
||||
// `date` is accepted for forward-compatibility with future per-date capacity
|
||||
// checks (domain.md §7), but v1 has no route-level calendar to filter against.
|
||||
->with(self::EAGER_LOADS)
|
||||
->paginate(),
|
||||
->paginate(perPage: 15, pageName: $pageName, page: $page),
|
||||
);
|
||||
|
||||
return EvRouteResource::collection($routes);
|
||||
}
|
||||
|
||||
public function show(EvRoute $route): EvRouteResource
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Routing\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* Shape validation only for the routes search endpoint. round_trip=true
|
||||
* requires both from and to — "return" only means something for a specific
|
||||
* origin/destination pair, not an unfiltered route list.
|
||||
*/
|
||||
class SearchRoutesRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'company' => ['nullable', 'integer', 'exists:ev_companies,id'],
|
||||
'from' => ['nullable', 'integer', 'exists:destinations,id', 'required_if:round_trip,true'],
|
||||
'to' => ['nullable', 'integer', 'exists:destinations,id', 'different:from', 'required_if:round_trip,true'],
|
||||
'date' => ['nullable', 'date'],
|
||||
// The catalog's shared time value (e.g. "06:00"), not a
|
||||
// DepartureTimeSlot id — matches how customers think about
|
||||
// departure times (domain.md §1).
|
||||
'time_slot' => ['nullable', 'date_format:H:i'],
|
||||
'round_trip' => ['sometimes', 'boolean'],
|
||||
// Independent page cursors — routes and return_routes almost
|
||||
// always have different totals, so they can't share one `page`
|
||||
// without one side silently paginating the other's offset.
|
||||
'page' => ['nullable', 'integer', 'min:1'],
|
||||
'return_page' => ['nullable', 'integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ class EvRouteResource extends JsonResource
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'is_round_trip' => $this->is_round_trip,
|
||||
'is_active' => $this->is_active,
|
||||
'company' => new EvCompanyResource($this->whenLoaded('company')),
|
||||
'from_destination' => new DestinationResource($this->whenLoaded('fromDestination')),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Modules\Routing\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -41,7 +42,6 @@ class EvRoute extends Model
|
||||
'ev_company_id',
|
||||
'from_destination_id',
|
||||
'to_destination_id',
|
||||
'is_round_trip',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
@@ -51,7 +51,6 @@ class EvRoute extends Model
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_round_trip' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
@@ -91,4 +90,23 @@ class EvRoute extends Model
|
||||
{
|
||||
return $this->hasMany(RoutePricing::class, 'ev_route_id');
|
||||
}
|
||||
|
||||
public function name(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn (): string => $this->fromDestination->name.' → '.$this->toDestination->name,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this route is the exact reverse direction of $other (from
|
||||
* and to swapped) — used to validate a booking's `return_ev_route_id`
|
||||
* is genuinely the return leg of its outbound route, not an unrelated
|
||||
* pair (domain.md §2b).
|
||||
*/
|
||||
public function isReverseOf(EvRoute $other): bool
|
||||
{
|
||||
return $this->from_destination_id === $other->to_destination_id
|
||||
&& $this->to_destination_id === $other->from_destination_id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,39 @@ test('can list ev routes', function () {
|
||||
->assertCanSeeTableRecords($routes);
|
||||
});
|
||||
|
||||
test('can filter ev routes by company, from, and to', function () {
|
||||
$companyA = EvCompany::factory()->create();
|
||||
$companyB = EvCompany::factory()->create();
|
||||
$yangon = Destination::factory()->create();
|
||||
$mandalay = Destination::factory()->create();
|
||||
$bagan = Destination::factory()->create();
|
||||
|
||||
$matching = EvRoute::factory()->create([
|
||||
'ev_company_id' => $companyA->id,
|
||||
'from_destination_id' => $yangon->id,
|
||||
'to_destination_id' => $mandalay->id,
|
||||
]);
|
||||
|
||||
$wrongCompany = EvRoute::factory()->create([
|
||||
'ev_company_id' => $companyB->id,
|
||||
'from_destination_id' => $yangon->id,
|
||||
'to_destination_id' => $mandalay->id,
|
||||
]);
|
||||
|
||||
$wrongDestination = EvRoute::factory()->create([
|
||||
'ev_company_id' => $companyA->id,
|
||||
'from_destination_id' => $yangon->id,
|
||||
'to_destination_id' => $bagan->id,
|
||||
]);
|
||||
|
||||
Livewire::test(ListEvRoutes::class)
|
||||
->filterTable('ev_company_id', $companyA->id)
|
||||
->filterTable('from_destination_id', $yangon->id)
|
||||
->filterTable('to_destination_id', $mandalay->id)
|
||||
->assertCanSeeTableRecords([$matching])
|
||||
->assertCanNotSeeTableRecords([$wrongCompany, $wrongDestination]);
|
||||
});
|
||||
|
||||
test('list shows each vehicle option price stacked, and blocked options instead of a price', function () {
|
||||
$route = EvRoute::factory()->create();
|
||||
|
||||
@@ -63,7 +96,6 @@ test('creating a route also creates all three vehicle option pricing rows, defau
|
||||
'ev_company_id' => $company->id,
|
||||
'from_destination_id' => $from->id,
|
||||
'to_destination_id' => $to->id,
|
||||
'is_round_trip' => false,
|
||||
'is_active' => false,
|
||||
'pricing' => pricingPayload(),
|
||||
])
|
||||
|
||||
@@ -25,14 +25,26 @@ test('an ev route belongs to a company and two destinations', function () {
|
||||
->and($route->toDestination->is($to))->toBeTrue();
|
||||
});
|
||||
|
||||
test('is_round_trip and is_active cast to boolean', function () {
|
||||
test('is_active casts to boolean', function () {
|
||||
$route = EvRoute::factory()->create([
|
||||
'is_round_trip' => 1,
|
||||
'is_active' => 0,
|
||||
]);
|
||||
|
||||
expect($route->is_round_trip)->toBeTrue()
|
||||
->and($route->is_active)->toBeFalse();
|
||||
expect($route->is_active)->toBeFalse();
|
||||
});
|
||||
|
||||
test('isReverseOf detects a route with from/to swapped', function () {
|
||||
$a = Destination::factory()->create();
|
||||
$b = Destination::factory()->create();
|
||||
|
||||
$outbound = EvRoute::factory()->create(['from_destination_id' => $a->id, 'to_destination_id' => $b->id]);
|
||||
$return = EvRoute::factory()->create(['from_destination_id' => $b->id, 'to_destination_id' => $a->id]);
|
||||
$unrelated = EvRoute::factory()->create();
|
||||
|
||||
expect($return->isReverseOf($outbound))->toBeTrue()
|
||||
->and($outbound->isReverseOf($return))->toBeTrue()
|
||||
->and($unrelated->isReverseOf($outbound))->toBeFalse()
|
||||
->and($outbound->isReverseOf($outbound))->toBeFalse();
|
||||
});
|
||||
|
||||
test('a route can be attached to time slots via the pivot, carrying its own is_active flag', function () {
|
||||
|
||||
@@ -41,42 +41,42 @@ test('saving an ev route invalidates the routes cache tag', function () {
|
||||
$route = EvRoute::factory()->create(['is_active' => true]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson('/api/v1/routes')
|
||||
->assertJsonCount(1, 'data');
|
||||
->postJson('/api/v1/routes/search')
|
||||
->assertJsonCount(1, 'routes.data');
|
||||
|
||||
// Bypass Eloquent so the change wouldn't be visible without invalidation.
|
||||
EvRoute::query()->where('id', $route->id)->update(['is_active' => false]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson('/api/v1/routes')
|
||||
->assertJsonCount(1, 'data');
|
||||
->postJson('/api/v1/routes/search')
|
||||
->assertJsonCount(1, 'routes.data');
|
||||
|
||||
$route->refresh()->save();
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson('/api/v1/routes')
|
||||
->assertJsonCount(0, 'data');
|
||||
->postJson('/api/v1/routes/search')
|
||||
->assertJsonCount(0, 'routes.data');
|
||||
});
|
||||
|
||||
test('deleting an ev route invalidates the routes cache tag', function () {
|
||||
$route = EvRoute::factory()->create(['is_active' => true]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson('/api/v1/routes')
|
||||
->assertJsonCount(1, 'data');
|
||||
->postJson('/api/v1/routes/search')
|
||||
->assertJsonCount(1, 'routes.data');
|
||||
|
||||
$route->delete();
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson('/api/v1/routes')
|
||||
->assertJsonCount(0, 'data');
|
||||
->postJson('/api/v1/routes/search')
|
||||
->assertJsonCount(0, 'routes.data');
|
||||
});
|
||||
|
||||
test('flushing the routes cache tag does not affect other cached data', function () {
|
||||
Cache::put('unrelated-key', 'still here', now()->addMinutes(5));
|
||||
|
||||
$route = EvRoute::factory()->create(['is_active' => true]);
|
||||
$route->update(['is_round_trip' => true]);
|
||||
$route->update(['is_active' => false]);
|
||||
|
||||
expect(Cache::get('unrelated-key'))->toBe('still here');
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ beforeEach(function () {
|
||||
$this->token = User::factory()->create()->createToken('test-token')->plainTextToken;
|
||||
});
|
||||
|
||||
test('lists active routes with nested company, destinations, time slots and pricing', function () {
|
||||
test('searches active routes with nested company, destinations, time slots and pricing', function () {
|
||||
$route = EvRoute::factory()->create(['is_active' => true]);
|
||||
EvRoute::factory()->create(['is_active' => false]);
|
||||
|
||||
@@ -26,17 +26,18 @@ test('lists active routes with nested company, destinations, time slots and pric
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson('/api/v1/routes')
|
||||
->postJson('/api/v1/routes/search')
|
||||
->assertSuccessful()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.id', $route->id)
|
||||
->assertJsonPath('data.0.company.id', $route->ev_company_id)
|
||||
->assertJsonPath('data.0.from_destination.id', $route->from_destination_id)
|
||||
->assertJsonPath('data.0.to_destination.id', $route->to_destination_id)
|
||||
->assertJsonPath('data.0.time_slots.0.id', $slot->id)
|
||||
->assertJsonPath('data.0.time_slots.0.is_active', true)
|
||||
->assertJsonPath('data.0.pricing.0.vehicle_option', 'front_seat')
|
||||
->assertJsonPath('data.0.pricing.0.price', '12000.00');
|
||||
->assertJsonCount(1, 'routes.data')
|
||||
->assertJsonPath('routes.data.0.id', $route->id)
|
||||
->assertJsonPath('routes.data.0.company.id', $route->ev_company_id)
|
||||
->assertJsonPath('routes.data.0.from_destination.id', $route->from_destination_id)
|
||||
->assertJsonPath('routes.data.0.to_destination.id', $route->to_destination_id)
|
||||
->assertJsonPath('routes.data.0.time_slots.0.id', $slot->id)
|
||||
->assertJsonPath('routes.data.0.time_slots.0.is_active', true)
|
||||
->assertJsonPath('routes.data.0.pricing.0.vehicle_option', 'front_seat')
|
||||
->assertJsonPath('routes.data.0.pricing.0.price', '12000.00')
|
||||
->assertJsonCount(0, 'return_routes.data');
|
||||
});
|
||||
|
||||
test('filters routes by company, from, and to', function () {
|
||||
@@ -68,24 +69,211 @@ test('filters routes by company, from, and to', function () {
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson('/api/v1/routes?'.http_build_query([
|
||||
->postJson('/api/v1/routes/search', [
|
||||
'company' => $companyA->id,
|
||||
'from' => $yangon->id,
|
||||
'to' => $mandalay->id,
|
||||
]))
|
||||
])
|
||||
->assertSuccessful()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.id', $matching->id);
|
||||
->assertJsonCount(1, 'routes.data')
|
||||
->assertJsonPath('routes.data.0.id', $matching->id);
|
||||
});
|
||||
|
||||
test('filters routes by time_slot', function () {
|
||||
$morning = DepartureTimeSlot::factory()->create(['time' => '06:00']);
|
||||
$evening = DepartureTimeSlot::factory()->create(['time' => '18:00']);
|
||||
|
||||
$morningRoute = EvRoute::factory()->create(['is_active' => true]);
|
||||
$morningRoute->timeSlots()->attach($morning->id, ['is_active' => true]);
|
||||
|
||||
$eveningRoute = EvRoute::factory()->create(['is_active' => true]);
|
||||
$eveningRoute->timeSlots()->attach($evening->id, ['is_active' => true]);
|
||||
|
||||
// Attached but inactive on this route — must not match.
|
||||
$inactivePivotRoute = EvRoute::factory()->create(['is_active' => true]);
|
||||
$inactivePivotRoute->timeSlots()->attach($morning->id, ['is_active' => false]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/routes/search', ['time_slot' => '06:00'])
|
||||
->assertSuccessful()
|
||||
->assertJsonCount(1, 'routes.data')
|
||||
->assertJsonPath('routes.data.0.id', $morningRoute->id);
|
||||
});
|
||||
|
||||
test('round trip: returns both routes and return_routes, swapped from/to', function () {
|
||||
$company = EvCompany::factory()->create();
|
||||
$yangon = Destination::factory()->create();
|
||||
$mandalay = Destination::factory()->create();
|
||||
|
||||
$outbound = EvRoute::factory()->create([
|
||||
'ev_company_id' => $company->id,
|
||||
'from_destination_id' => $yangon->id,
|
||||
'to_destination_id' => $mandalay->id,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$return = EvRoute::factory()->create([
|
||||
'ev_company_id' => $company->id,
|
||||
'from_destination_id' => $mandalay->id,
|
||||
'to_destination_id' => $yangon->id,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/routes/search', [
|
||||
'round_trip' => true,
|
||||
'from' => $yangon->id,
|
||||
'to' => $mandalay->id,
|
||||
])
|
||||
->assertSuccessful()
|
||||
->assertJsonCount(1, 'routes.data')
|
||||
->assertJsonPath('routes.data.0.id', $outbound->id)
|
||||
->assertJsonCount(1, 'return_routes.data')
|
||||
->assertJsonPath('return_routes.data.0.id', $return->id);
|
||||
});
|
||||
|
||||
test('response includes the distinct companies and time_slots actually available for the from-to pair', function () {
|
||||
$yangon = Destination::factory()->create();
|
||||
$mandalay = Destination::factory()->create();
|
||||
|
||||
$companyA = EvCompany::factory()->create(['name' => 'Alpha EV']);
|
||||
$companyB = EvCompany::factory()->create(['name' => 'Beta EV']);
|
||||
$morning = DepartureTimeSlot::factory()->create(['time' => '06:00']);
|
||||
$evening = DepartureTimeSlot::factory()->create(['time' => '18:00']);
|
||||
$inactiveSlot = DepartureTimeSlot::factory()->create(['time' => '12:00']);
|
||||
|
||||
$routeA = EvRoute::factory()->create([
|
||||
'ev_company_id' => $companyA->id,
|
||||
'from_destination_id' => $yangon->id,
|
||||
'to_destination_id' => $mandalay->id,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$routeA->timeSlots()->attach([$morning->id => ['is_active' => true], $inactiveSlot->id => ['is_active' => false]]);
|
||||
|
||||
$routeB = EvRoute::factory()->create([
|
||||
'ev_company_id' => $companyB->id,
|
||||
'from_destination_id' => $yangon->id,
|
||||
'to_destination_id' => $mandalay->id,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$routeB->timeSlots()->attach($evening->id, ['is_active' => true]);
|
||||
|
||||
// Unrelated pair — must not leak into the facets.
|
||||
$bagan = Destination::factory()->create();
|
||||
$unrelated = EvRoute::factory()->create([
|
||||
'from_destination_id' => $yangon->id,
|
||||
'to_destination_id' => $bagan->id,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$unrelated->timeSlots()->attach(DepartureTimeSlot::factory()->create(['time' => '09:00'])->id, ['is_active' => true]);
|
||||
|
||||
// Applying a company filter narrows `routes.data` but must not narrow
|
||||
// the facets themselves — facets always reflect the full from-to pair.
|
||||
$response = $this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/routes/search', [
|
||||
'from' => $yangon->id,
|
||||
'to' => $mandalay->id,
|
||||
'company' => $companyA->id,
|
||||
])
|
||||
->assertSuccessful()
|
||||
->assertJsonCount(1, 'routes.data')
|
||||
->json();
|
||||
|
||||
expect(collect($response['filters']['companies'])->pluck('id')->sort()->values()->all())
|
||||
->toBe([$companyA->id, $companyB->id]);
|
||||
// Facet shape is trimmed to id/name/mm_name — not the full company resource.
|
||||
expect(array_keys($response['filters']['companies'][0]))->toBe(['id', 'name', 'mm_name']);
|
||||
expect(collect($response['filters']['time_slots'])->pluck('time')->all())
|
||||
->toBe(['06:00', '18:00']); // sorted by time, inactive pivot and unrelated pair excluded
|
||||
});
|
||||
|
||||
test('filter options are empty when from/to are not both given', function () {
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/routes/search')
|
||||
->assertSuccessful()
|
||||
->assertJson(['filters' => ['companies' => [], 'time_slots' => []]]);
|
||||
});
|
||||
|
||||
test('round trip: return_filters reflect the swapped to-from pair', function () {
|
||||
$yangon = Destination::factory()->create();
|
||||
$mandalay = Destination::factory()->create();
|
||||
$returnCompany = EvCompany::factory()->create();
|
||||
|
||||
EvRoute::factory()->create([
|
||||
'from_destination_id' => $yangon->id,
|
||||
'to_destination_id' => $mandalay->id,
|
||||
'is_active' => true,
|
||||
]);
|
||||
EvRoute::factory()->create([
|
||||
'ev_company_id' => $returnCompany->id,
|
||||
'from_destination_id' => $mandalay->id,
|
||||
'to_destination_id' => $yangon->id,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$response = $this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/routes/search', [
|
||||
'round_trip' => true,
|
||||
'from' => $yangon->id,
|
||||
'to' => $mandalay->id,
|
||||
])
|
||||
->assertSuccessful()
|
||||
->json();
|
||||
|
||||
expect(collect($response['return_filters']['companies'])->pluck('id')->all())
|
||||
->toBe([$returnCompany->id]);
|
||||
});
|
||||
|
||||
test('round trip: routes and return_routes paginate independently via page and return_page', function () {
|
||||
$company = EvCompany::factory()->create();
|
||||
$yangon = Destination::factory()->create();
|
||||
$mandalay = Destination::factory()->create();
|
||||
|
||||
// 20 outbound routes (2 pages of 15), only 3 return routes (1 page).
|
||||
EvRoute::factory()->count(20)->create([
|
||||
'ev_company_id' => $company->id,
|
||||
'from_destination_id' => $yangon->id,
|
||||
'to_destination_id' => $mandalay->id,
|
||||
'is_active' => true,
|
||||
]);
|
||||
EvRoute::factory()->count(3)->create([
|
||||
'ev_company_id' => $company->id,
|
||||
'from_destination_id' => $mandalay->id,
|
||||
'to_destination_id' => $yangon->id,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
// page=2 must give the 2nd page of routes (5 remaining), while
|
||||
// return_routes — with no return_page given — must still return its own
|
||||
// full page 1 (all 3), not an empty slice at offset 2.
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/routes/search', [
|
||||
'round_trip' => true,
|
||||
'from' => $yangon->id,
|
||||
'to' => $mandalay->id,
|
||||
'page' => 2,
|
||||
])
|
||||
->assertSuccessful()
|
||||
->assertJsonCount(5, 'routes.data')
|
||||
->assertJsonPath('routes.meta.current_page', 2)
|
||||
->assertJsonCount(3, 'return_routes.data')
|
||||
->assertJsonPath('return_routes.meta.current_page', 1);
|
||||
});
|
||||
|
||||
test('round_trip without from and to is rejected', function () {
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/routes/search', ['round_trip' => true])
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors(['from', 'to']);
|
||||
});
|
||||
|
||||
test('paginates routes', function () {
|
||||
EvRoute::factory()->count(20)->create(['is_active' => true]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson('/api/v1/routes')
|
||||
->postJson('/api/v1/routes/search')
|
||||
->assertSuccessful()
|
||||
->assertJsonCount(15, 'data')
|
||||
->assertJsonPath('meta.total', 20);
|
||||
->assertJsonCount(15, 'routes.data')
|
||||
->assertJsonPath('routes.meta.total', 20);
|
||||
});
|
||||
|
||||
test('shows a single active route', function () {
|
||||
@@ -148,7 +336,7 @@ test('lists a route\'s time slots with the pivot active flag', function () {
|
||||
test('routes endpoints reject unauthenticated requests', function () {
|
||||
$route = EvRoute::factory()->create(['is_active' => true]);
|
||||
|
||||
$this->getJson('/api/v1/routes')->assertUnauthorized();
|
||||
$this->postJson('/api/v1/routes/search')->assertUnauthorized();
|
||||
$this->getJson("/api/v1/routes/{$route->id}")->assertUnauthorized();
|
||||
$this->getJson("/api/v1/routes/{$route->id}/pricing")->assertUnauthorized();
|
||||
$this->getJson("/api/v1/routes/{$route->id}/time-slots")->assertUnauthorized();
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Shared\Sms;
|
||||
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Thin wrapper around the sms_poh gateway (the only provider configured
|
||||
* today, config('services.sms')). No-ops when SMS is disabled so callers
|
||||
* (queued listeners) can call send() unconditionally in every environment.
|
||||
*/
|
||||
class SmsService
|
||||
{
|
||||
private readonly bool $enabled;
|
||||
|
||||
private readonly ?string $server;
|
||||
|
||||
private readonly ?string $token;
|
||||
|
||||
private readonly ?string $sender;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $config
|
||||
*/
|
||||
public function __construct(?array $config = null)
|
||||
{
|
||||
$config ??= (array) config('services.sms');
|
||||
$providerConfig = (array) ($config['sms_poh'] ?? []);
|
||||
|
||||
$this->enabled = (bool) ($config['enabled'] ?? false);
|
||||
$this->server = $providerConfig['server'] ?? null;
|
||||
$this->token = $providerConfig['token'] ?? null;
|
||||
$this->sender = $providerConfig['sender'] ?? null;
|
||||
}
|
||||
|
||||
public function send(string $to, string $message, ?string $from = null): bool
|
||||
{
|
||||
if (! $this->enabled || $this->server === null || $this->token === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::withToken($this->token)
|
||||
->post($this->server, [
|
||||
'to' => $to,
|
||||
'message' => $message,
|
||||
'from' => $from ?? $this->sender,
|
||||
]);
|
||||
|
||||
Log::notice('Send SMS Response : '.$to.' '.$response->body());
|
||||
|
||||
return $response->successful();
|
||||
} catch (ConnectionException $exception) {
|
||||
Log::error('Send SMS Error : '.$to.' '.$exception->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Modules\Shared\Sms\SmsService;
|
||||
|
||||
$config = [
|
||||
'enabled' => true,
|
||||
'sms_poh' => [
|
||||
'server' => 'https://sms.test/send',
|
||||
'token' => 'test-token',
|
||||
'sender' => 'FamousLY4',
|
||||
],
|
||||
];
|
||||
|
||||
test('send posts to the configured server with a bearer token and returns true on success', function () use ($config) {
|
||||
Http::fake(['sms.test/*' => Http::response(['status' => 'ok'])]);
|
||||
|
||||
$result = (new SmsService($config))->send('+959111222333', 'Your driver is here.');
|
||||
|
||||
expect($result)->toBeTrue();
|
||||
Http::assertSent(function ($request) {
|
||||
return $request->url() === 'https://sms.test/send'
|
||||
&& $request->hasHeader('Authorization', 'Bearer test-token')
|
||||
&& $request['to'] === '+959111222333'
|
||||
&& $request['message'] === 'Your driver is here.'
|
||||
&& $request['from'] === 'FamousLY4';
|
||||
});
|
||||
});
|
||||
|
||||
test('send returns false and does not call the gateway when disabled', function () use ($config) {
|
||||
Http::fake();
|
||||
$config['enabled'] = false;
|
||||
|
||||
$result = (new SmsService($config))->send('+959111222333', 'Your driver is here.');
|
||||
|
||||
expect($result)->toBeFalse();
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('send returns false on a non-successful gateway response', function () use ($config) {
|
||||
Http::fake(['sms.test/*' => Http::response(['error' => 'invalid'], 422)]);
|
||||
|
||||
$result = (new SmsService($config))->send('+959111222333', 'Your driver is here.');
|
||||
|
||||
expect($result)->toBeFalse();
|
||||
});
|
||||
|
||||
test('send uses an explicit from over the configured sender', function () use ($config) {
|
||||
Http::fake(['sms.test/*' => Http::response(['status' => 'ok'])]);
|
||||
|
||||
(new SmsService($config))->send('+959111222333', 'Hello', 'OtherSender');
|
||||
|
||||
Http::assertSent(fn ($request) => $request['from'] === 'OtherSender');
|
||||
});
|
||||
@@ -5,6 +5,7 @@ namespace App\Providers;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
@@ -23,6 +24,16 @@ class AppServiceProvider extends ServiceProvider
|
||||
public function boot(): void
|
||||
{
|
||||
$this->configureRateLimiting();
|
||||
|
||||
// Belt-and-suspenders alongside bootstrap/app.php's trustProxies():
|
||||
// that already makes url()/asset() respect the proxy's
|
||||
// X-Forwarded-Proto, but if that header is ever missing or a proxy
|
||||
// is misconfigured, this still forces https:// asset/route URLs on
|
||||
// any environment whose APP_URL is itself https — so a plain-http
|
||||
// request never causes a mixed-content-blocked asset again.
|
||||
if (str(config('app.url'))->startsWith('https://')) {
|
||||
URL::forceScheme('https');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,6 +25,7 @@ use Modules\Booking\BookingPlugin;
|
||||
use Modules\Catalog\CatalogPlugin;
|
||||
use Modules\Identity\IdentityPlugin;
|
||||
use Modules\Payment\PaymentPlugin;
|
||||
use Modules\Reporting\ReportingPlugin;
|
||||
use Modules\Routing\RoutingPlugin;
|
||||
|
||||
class AdminPanelProvider extends PanelProvider
|
||||
@@ -47,6 +48,7 @@ class AdminPanelProvider extends PanelProvider
|
||||
NavigationGroup::make()->label('Catalog'),
|
||||
NavigationGroup::make()->label('Routing'),
|
||||
NavigationGroup::make()->label('Operations'),
|
||||
NavigationGroup::make()->label('Reports'),
|
||||
])
|
||||
->plugins([
|
||||
CatalogPlugin::make(),
|
||||
@@ -54,6 +56,7 @@ class AdminPanelProvider extends PanelProvider
|
||||
BookingPlugin::make(),
|
||||
PaymentPlugin::make(),
|
||||
IdentityPlugin::make(),
|
||||
ReportingPlugin::make(),
|
||||
// T6.5 — ops convenience for browsing storage/logs/*.log
|
||||
// in-browser; distinct from the structured, per-model audit
|
||||
// trail (AuditLogResource, T6.2). No extra permission gate:
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"nightwatch": false,
|
||||
"sail": false,
|
||||
"skills": [
|
||||
"infer-conventions",
|
||||
"laravel-best-practices",
|
||||
"pest-testing",
|
||||
"tailwindcss-development"
|
||||
|
||||
@@ -11,6 +11,7 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Modules\Identity\Http\Middleware\AuthenticateSanctumOrFastApiJwt;
|
||||
use Modules\Identity\Http\Middleware\EnsureFastApiAgent;
|
||||
use Symfony\Component\HttpFoundation\Request as SymfonyRequest;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
@@ -24,6 +25,22 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
// Staging/production sit behind a reverse proxy/load balancer that
|
||||
// terminates SSL — without this, Laravel never sees the original
|
||||
// request as HTTPS, so it generates http:// asset URLs, which
|
||||
// browsers then block as mixed content on the https:// page (e.g.
|
||||
// Filament's file-upload.js failing to load, breaking that field's
|
||||
// JS-enhanced dropzone). Trusting '*' is the standard Laravel
|
||||
// pattern when the proxy's IP isn't fixed/known in advance.
|
||||
$middleware->trustProxies(
|
||||
at: '*',
|
||||
headers: SymfonyRequest::HEADER_X_FORWARDED_FOR
|
||||
| SymfonyRequest::HEADER_X_FORWARDED_HOST
|
||||
| SymfonyRequest::HEADER_X_FORWARDED_PORT
|
||||
| SymfonyRequest::HEADER_X_FORWARDED_PROTO
|
||||
| SymfonyRequest::HEADER_X_FORWARDED_AWS_ELB,
|
||||
);
|
||||
|
||||
$middleware->alias([
|
||||
'fastapi.agent' => EnsureFastApiAgent::class,
|
||||
'api.auth' => AuthenticateSanctumOrFastApiJwt::class,
|
||||
|
||||
Regular → Executable
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user