Compare commits
30 Commits
bf5d2c676a
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f0f20659d | |||
| 98dacef556 | |||
| da6d51b7b2 | |||
| 0e55e36cea | |||
| da9cd9bbe0 | |||
| 41c9454334 | |||
| fa908cdcaf | |||
| 894352b43f | |||
| 1aeb57f130 | |||
| 6be47aa35a | |||
| 79f7f50706 | |||
| 54b35ee087 | |||
| dfffdd343b | |||
| a9124ccb8d | |||
| 5c215b4647 | |||
| 532f2ddf99 | |||
| c3a6f6cc6e | |||
| 2fdfc0040c | |||
| 8d74ac74cd | |||
| 60413bdebf | |||
| d528cf16ec | |||
| fd3a195453 | |||
| 46f9b8d5a3 | |||
| e2e7902307 | |||
| d19a14a45e | |||
| 4737838021 | |||
| e0bcc5f81a | |||
| 5b68f4fa38 | |||
| 4da9ecfe7d | |||
| 7872105f2f |
@@ -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
|
||||
|
||||
+24
-4
@@ -8,6 +8,11 @@ APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
APP_TIMEZONE=Asia/Yangon
|
||||
APP_CURRENCY=MMK
|
||||
SUPPORT_EMAIL=
|
||||
SUPPORT_PHONE=
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
# APP_MAINTENANCE_STORE=database
|
||||
|
||||
@@ -15,7 +20,8 @@ APP_MAINTENANCE_DRIVER=file
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_CHANNEL=daily
|
||||
FILAMENT_LOG_VIEWER_DRIVER=daily
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
@@ -47,13 +53,24 @@ REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
BOOKING_BACK_SEAT_ENABLED=
|
||||
BOOKING_WHOLE_VEHICLE_ENABLED=
|
||||
BOOKING_FRONT_SEAT_MAX_PER_BOOKING=
|
||||
BOOKING_BACK_SEAT_ENABLED=true
|
||||
BOOKING_WHOLE_VEHICLE_ENABLED=true
|
||||
BOOKING_FRONT_SEAT_MAX_PER_BOOKING=1
|
||||
|
||||
BOOKING_ADMIN_EMAILS="example@gmail.com"
|
||||
|
||||
SMS_ENABLED=false
|
||||
SMS_SERVER=
|
||||
SMS_TOKEN=
|
||||
SMS_SENDER=
|
||||
|
||||
KBZ_APP_ID=
|
||||
KBZ_MERCHANT_CODE=
|
||||
KBZ_MERCHANT_KEY=
|
||||
KBZ_BASE_URL=
|
||||
KBZ_CREATE_ORDER_URL=
|
||||
KBZ_QUERY_ORDER_URL=
|
||||
KBZ_REFUND_ORDER_URL=
|
||||
KBZ_NOTIFY_URL=
|
||||
KBZ_CERT_PATH=
|
||||
KBZ_CERT_KEY_PATH=
|
||||
@@ -76,3 +93,6 @@ AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
FASTAPI_AGENT_JWT_SECRET=
|
||||
FASTAPI_AGENT_JWT_ALGORITHM=HS256
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
name: PHP Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
php-tests:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18-alpine
|
||||
env:
|
||||
POSTGRES_DB: testing
|
||||
POSTGRES_USER: root
|
||||
POSTGRES_PASSWORD: ''
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
env:
|
||||
DB_CONNECTION: pgsql
|
||||
DB_HOST: postgres
|
||||
DB_PORT: 5432
|
||||
DB_DATABASE: testing
|
||||
DB_USERNAME: root
|
||||
DB_PASSWORD: ''
|
||||
CACHE_STORE: array
|
||||
CACHE_DRIVER: array
|
||||
SESSION_DRIVER: array
|
||||
QUEUE_CONNECTION: sync
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.5'
|
||||
extensions: mbstring, bcmath, intl, gd, zip, pdo, pdo_pgsql, redis, pcntl
|
||||
coverage: none
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Copy .env
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Install Composer dependencies
|
||||
run: composer install --no-interaction --prefer-dist --no-progress
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build frontend assets
|
||||
run: npm run build
|
||||
|
||||
- name: Generate app key
|
||||
run: php artisan key:generate
|
||||
|
||||
- name: Install postgresql-client
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y postgresql-client
|
||||
|
||||
- name: Wait for Postgres
|
||||
timeout-minutes: 1
|
||||
run: |
|
||||
until pg_isready -h postgres -p 5432 -U root; do
|
||||
echo "Waiting for postgres..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
- name: Run migrations
|
||||
run: php artisan migrate --force
|
||||
|
||||
- name: Run tests
|
||||
run: php artisan test --compact
|
||||
@@ -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.
|
||||
@@ -110,6 +104,13 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
|
||||
|
||||
- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications.
|
||||
|
||||
=== tests rules ===
|
||||
|
||||
# Test Enforcement
|
||||
|
||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
# Do Things the Laravel Way
|
||||
|
||||
@@ -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.
|
||||
@@ -110,6 +104,13 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
|
||||
|
||||
- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications.
|
||||
|
||||
=== tests rules ===
|
||||
|
||||
# Test Enforcement
|
||||
|
||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
# Do Things the Laravel Way
|
||||
@@ -163,3 +164,13 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
|
||||
- This application runs inside Docker via Laravel Sail. Use `./vendor/bin/sail artisan ...` instead of `php artisan ...`, and `./vendor/bin/sail composer ...` instead of `composer ...`.
|
||||
- For binaries not wrapped by Sail's own commands (e.g. Pint), run them inside the container: `./vendor/bin/sail exec laravel.test vendor/bin/pint --dirty --format agent`.
|
||||
- Check containers are up first with `./vendor/bin/sail ps` before running commands; start them with `./vendor/bin/sail up -d` if they aren't.
|
||||
|
||||
## Database / Migrations
|
||||
|
||||
- **Never run `php artisan migrate:fresh`, `migrate:refresh`, `migrate:reset`, or `db:wipe` against the dev database unless the user explicitly asks for it in that turn.** These drop/recreate all tables and destroy dev data. Use `php artisan migrate` (apply pending) and `php artisan migrate:rollback` (undo the last batch) instead for normal migration work.
|
||||
- Dev data loss happened once (2026-08-08 ~22:05 local) from exactly this kind of command — do not repeat it.
|
||||
|
||||
## Architecture / ERD Diagram
|
||||
|
||||
- The canonical tldraw board for this project's architecture and ERD lives at `/home/marcspecta/Documents/EV Booking System Architecture.tldraw` (outside the repo — not committed). Use this path when opening/updating the board with the tldraw-offline skill/agent.
|
||||
- Do not copy or save this file into the project directory; a stray copy there was previously deleted.
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Database\Factories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\Booking\Enums\BookingChannel;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Catalog\Models\DepartureTimeSlot;
|
||||
use Modules\Routing\Models\EvRoute;
|
||||
|
||||
/**
|
||||
* @extends Factory<Booking>
|
||||
*/
|
||||
class BookingFactory extends Factory
|
||||
{
|
||||
protected $model = Booking::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'booking_ref' => 'EVB-'.strtoupper(Str::random(6)),
|
||||
'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(),
|
||||
'passenger_phone' => $this->faker->phoneNumber(),
|
||||
'pickup_address' => $this->faker->address(),
|
||||
'pickup_lat' => null,
|
||||
'pickup_lng' => null,
|
||||
'dropoff_address' => $this->faker->address(),
|
||||
'dropoff_lat' => null,
|
||||
'dropoff_lng' => null,
|
||||
'price' => $this->faker->randomFloat(2, 5000, 50000),
|
||||
'status' => BookingStatus::PendingPayment,
|
||||
'created_by_channel' => BookingChannel::MiniApp,
|
||||
'driver_name' => null,
|
||||
'driver_phone' => null,
|
||||
'car_plate_number' => null,
|
||||
'car_model' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Database\Factories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Booking\Models\BookingVehicleOption;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
|
||||
/**
|
||||
* @extends Factory<BookingVehicleOption>
|
||||
*/
|
||||
class BookingVehicleOptionFactory extends Factory
|
||||
{
|
||||
protected $model = BookingVehicleOption::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'booking_id' => Booking::factory(),
|
||||
'vehicle_option' => VehicleOption::BackSeat,
|
||||
'passenger_count' => 1,
|
||||
'unit_price' => 9000.00,
|
||||
'line_total' => 9000.00,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('bookings', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('booking_ref')->unique();
|
||||
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->string('openid')->nullable()->index();
|
||||
$table->foreignId('ev_route_id')->constrained('ev_routes')->cascadeOnDelete();
|
||||
$table->foreignId('departure_time_slot_id')->constrained('departure_time_slots')->cascadeOnDelete();
|
||||
$table->date('travel_date');
|
||||
$table->string('passenger_name');
|
||||
$table->string('passenger_phone');
|
||||
$table->string('pickup_address');
|
||||
$table->decimal('pickup_lat', 10, 7)->nullable();
|
||||
$table->decimal('pickup_lng', 10, 7)->nullable();
|
||||
$table->string('dropoff_address');
|
||||
$table->decimal('dropoff_lat', 10, 7)->nullable();
|
||||
$table->decimal('dropoff_lng', 10, 7)->nullable();
|
||||
// Total across all booking_vehicle_options lines — see that table for the
|
||||
// per-vehicle-option breakdown (a booking can mix e.g. front_seat + back_seat).
|
||||
$table->decimal('price', 10, 2);
|
||||
$table->string('status')->default('pending_payment');
|
||||
$table->boolean('is_round_trip')->default(false);
|
||||
$table->date('return_travel_date')->nullable();
|
||||
$table->string('created_by_channel');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['ev_route_id', 'travel_date', 'departure_time_slot_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('bookings');
|
||||
}
|
||||
};
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* One row per Vehicle Option selected on a booking — a booking can mix
|
||||
* e.g. front_seat + back_seat in one go (domain.md §2). The unique
|
||||
* constraint keeps each option to a single line per booking (no
|
||||
* duplicate front_seat rows); BookingService still enforces the
|
||||
* business rules (front-seat max, disabled options, whole-vehicle
|
||||
* exclusivity) on top of this shape.
|
||||
*
|
||||
* Also the natural source table for the deferred real-capacity-check
|
||||
* phase (domain.md §7): summing passenger_count per vehicle_option for
|
||||
* a route/date/time-slot is exactly what that future check needs.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('booking_vehicle_options', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('booking_id')->constrained('bookings')->cascadeOnDelete();
|
||||
$table->string('vehicle_option');
|
||||
$table->unsignedInteger('passenger_count')->default(1);
|
||||
// Snapshotted at booking time, same as bookings.price — never re-read
|
||||
// from route_pricing later (domain.md §3).
|
||||
$table->decimal('unit_price', 10, 2);
|
||||
$table->decimal('line_total', 10, 2);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['booking_id', 'vehicle_option']);
|
||||
$table->index('vehicle_option');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('booking_vehicle_options');
|
||||
}
|
||||
};
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Driver/vehicle details, filled in by admin staff once a booking is
|
||||
* confirmed (paid) and dispatch assigns who's actually doing the trip.
|
||||
* Nullable — unknown until assignment happens, and never required for
|
||||
* pending_payment/cancelled/expired bookings.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('bookings', function (Blueprint $table) {
|
||||
$table->string('driver_name')->nullable();
|
||||
$table->string('driver_phone')->nullable();
|
||||
$table->string('car_plate_number')->nullable();
|
||||
$table->string('car_model')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('bookings', function (Blueprint $table) {
|
||||
$table->dropColumn(['driver_name', 'driver_phone', 'car_plate_number', 'car_model']);
|
||||
});
|
||||
}
|
||||
};
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* Soft deletes only — a booking is never hard-removed. Admin staff may
|
||||
* delete a cancelled/expired booking (BookingResource, gated by
|
||||
* manage_bookings + BookingPolicy::delete), but the row stays
|
||||
* recoverable and its Payment/Refund history stays intact.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('bookings', function (Blueprint $table) {
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('bookings', function (Blueprint $table) {
|
||||
$table->dropSoftDeletes();
|
||||
});
|
||||
}
|
||||
};
|
||||
+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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1 +1,11 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Booking\Http\Controllers\BookingController;
|
||||
|
||||
Route::prefix('api/v1')->middleware(['api', 'api.auth', 'throttle:api-write'])->group(function () {
|
||||
Route::get('/bookings', [BookingController::class, 'index'])->name('booking.bookings.index');
|
||||
Route::get('/bookings/{booking:booking_ref}', [BookingController::class, 'show'])->name('booking.bookings.show');
|
||||
Route::post('/bookings', [BookingController::class, 'store'])->name('booking.bookings.store');
|
||||
Route::post('/bookings/{booking:booking_ref}/cancel', [BookingController::class, 'cancel'])->name('booking.bookings.cancel');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* Driver/vehicle details only make sense once a booking is confirmed
|
||||
* (paid) — dispatch assigns who's actually doing the trip at that point,
|
||||
* not before. Re-running this (e.g. reassigning a different driver) is
|
||||
* allowed as long as the booking is still confirmed.
|
||||
*/
|
||||
class AssignDriverAction
|
||||
{
|
||||
public function handle(Booking $booking, AssignDriverData $data): Booking
|
||||
{
|
||||
if ($booking->status !== BookingStatus::Confirmed) {
|
||||
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,
|
||||
'car_plate_number' => $data->carPlateNumber,
|
||||
'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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Actions;
|
||||
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Exceptions\BookingCannotBeCancelledException;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Payment\Actions\RefundBookingAction;
|
||||
|
||||
/**
|
||||
* A pending_payment booking has no money moved yet, so it cancels directly.
|
||||
* A confirmed (paid) booking is cancelled by refunding it in full first —
|
||||
* delegates to RefundBookingAction (Payment module); the booking only
|
||||
* actually flips to cancelled once that refund succeeds, via
|
||||
* RefundProcessed/MarkBookingRefunded, not here (domain.md §5). Any other
|
||||
* status (already cancelled/expired) is rejected outright.
|
||||
*/
|
||||
class CancelBookingAction
|
||||
{
|
||||
private const CANCELLATION_REFUND_REASON = 'Booking cancellation';
|
||||
|
||||
public function __construct(
|
||||
private RefundBookingAction $refundBookingAction,
|
||||
) {}
|
||||
|
||||
public function handle(Booking $booking, ?int $requestedBy = null): Booking
|
||||
{
|
||||
if ($booking->status === BookingStatus::Confirmed) {
|
||||
$this->refundBookingAction->handle(
|
||||
$booking,
|
||||
(string) $booking->price,
|
||||
self::CANCELLATION_REFUND_REASON,
|
||||
$requestedBy,
|
||||
);
|
||||
|
||||
return $booking->refresh();
|
||||
}
|
||||
|
||||
if ($booking->status !== BookingStatus::PendingPayment) {
|
||||
throw BookingCannotBeCancelledException::notPendingPayment($booking);
|
||||
}
|
||||
|
||||
$booking->update(['status' => BookingStatus::Cancelled]);
|
||||
|
||||
return $booking;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Actions;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
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;
|
||||
use Modules\Routing\Models\EvRoute;
|
||||
use Modules\Routing\Services\PricingService;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
|
||||
class CreateBookingAction
|
||||
{
|
||||
public function __construct(
|
||||
private BookingService $bookingService,
|
||||
private PricingService $pricingService,
|
||||
private BookingRefGenerator $bookingRefGenerator,
|
||||
) {}
|
||||
|
||||
public function handle(CreateBookingData $data): Booking
|
||||
{
|
||||
$this->bookingService->validateSelections($data->selections);
|
||||
|
||||
$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),
|
||||
$selections,
|
||||
);
|
||||
|
||||
$totalPrice = array_reduce(
|
||||
$lines,
|
||||
fn (string $carry, array $line) => bcadd($carry, $line['line_total'], 2),
|
||||
'0.00',
|
||||
);
|
||||
|
||||
$booking = Booking::create([
|
||||
'booking_ref' => $this->bookingRefGenerator->generate(),
|
||||
'user_id' => $data->userId,
|
||||
'openid' => $data->openid,
|
||||
'ev_route_id' => $route->id,
|
||||
'is_return_leg' => $isReturnLeg,
|
||||
'departure_time_slot_id' => $timeSlotId,
|
||||
'travel_date' => $travelDate,
|
||||
'passenger_name' => $data->passengerName,
|
||||
'passenger_phone' => $data->passengerPhone,
|
||||
'notes' => $data->notes,
|
||||
'pickup_address' => $data->pickupAddress,
|
||||
'pickup_lat' => $data->pickupLat,
|
||||
'pickup_lng' => $data->pickupLng,
|
||||
'dropoff_address' => $data->dropoffAddress,
|
||||
'dropoff_lat' => $data->dropoffLat,
|
||||
'dropoff_lng' => $data->dropoffLng,
|
||||
'price' => $totalPrice,
|
||||
'status' => BookingStatus::PendingPayment,
|
||||
'created_by_channel' => $data->createdByChannel,
|
||||
]);
|
||||
|
||||
$booking->vehicleOptions()->createMany($lines);
|
||||
|
||||
return $booking;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{vehicle_option: VehicleOption, passenger_count: int, unit_price: string, line_total: string}
|
||||
*/
|
||||
private function priceSelection(EvRoute $route, VehicleSelectionData $selection): array
|
||||
{
|
||||
$quote = $this->pricingService->quote($route, $selection->vehicleOption);
|
||||
|
||||
return [
|
||||
'vehicle_option' => $selection->vehicleOption,
|
||||
'passenger_count' => $selection->passengerCount,
|
||||
'unit_price' => $quote->price,
|
||||
'line_total' => bcmul($quote->price, (string) $selection->passengerCount, 2),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking;
|
||||
|
||||
use Filament\Contracts\Plugin;
|
||||
use Filament\Panel;
|
||||
|
||||
class BookingPlugin implements Plugin
|
||||
{
|
||||
public function getId(): string
|
||||
{
|
||||
return 'booking';
|
||||
}
|
||||
|
||||
public function register(Panel $panel): void
|
||||
{
|
||||
$panel
|
||||
->discoverResources(
|
||||
in: __DIR__.'/Filament/Resources',
|
||||
for: 'Modules\Booking\Filament\Resources',
|
||||
)
|
||||
->discoverPages(
|
||||
in: __DIR__.'/Filament/Pages',
|
||||
for: 'Modules\Booking\Filament\Pages',
|
||||
)
|
||||
->discoverWidgets(
|
||||
in: __DIR__.'/Filament/Widgets',
|
||||
for: 'Modules\Booking\Filament\Widgets',
|
||||
);
|
||||
}
|
||||
|
||||
public function boot(Panel $panel): void {}
|
||||
|
||||
public static function make(): static
|
||||
{
|
||||
return app(static::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Data;
|
||||
|
||||
readonly class AssignDriverData
|
||||
{
|
||||
public function __construct(
|
||||
public string $driverName,
|
||||
public string $driverPhone,
|
||||
public string $carPlateNumber,
|
||||
public ?string $carModel = null,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Data;
|
||||
|
||||
use Modules\Booking\Enums\BookingChannel;
|
||||
|
||||
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,
|
||||
public int $departureTimeSlotId,
|
||||
public string $travelDate,
|
||||
public array $selections,
|
||||
public string $passengerName,
|
||||
public string $passengerPhone,
|
||||
public string $pickupAddress,
|
||||
public string $dropoffAddress,
|
||||
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 ?int $returnEvRouteId = null,
|
||||
public ?int $returnDepartureTimeSlotId = null,
|
||||
public ?string $returnTravelDate = null,
|
||||
public ?array $returnSelections = null,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Data;
|
||||
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
|
||||
readonly class VehicleSelectionData
|
||||
{
|
||||
public function __construct(
|
||||
public VehicleOption $vehicleOption,
|
||||
public int $passengerCount = 1,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Enums;
|
||||
|
||||
/**
|
||||
* Which external actor created the booking (domain.md §8).
|
||||
*/
|
||||
enum BookingChannel: string
|
||||
{
|
||||
case MiniApp = 'kbz_miniapp';
|
||||
case Android = 'android';
|
||||
case Ios = 'ios';
|
||||
case Web = 'web';
|
||||
case Agent = 'agent';
|
||||
case Admin = 'admin';
|
||||
|
||||
/**
|
||||
* Resolve the client's channel from its `Device-Type` header, defaulting
|
||||
* to MiniApp when the header is missing or unrecognized. Agent/Admin are
|
||||
* deliberately excluded from what a header can select — those two are
|
||||
* derived from how the request authenticated (FastAPI JWT, Filament),
|
||||
* never a client-supplied value, so a customer can't spoof one via the
|
||||
* header.
|
||||
*/
|
||||
public static function fromDeviceTypeHeader(?string $deviceType): self
|
||||
{
|
||||
$channel = self::tryFrom((string) $deviceType);
|
||||
|
||||
if ($channel === null || in_array($channel, [self::Agent, self::Admin], true)) {
|
||||
return self::MiniApp;
|
||||
}
|
||||
|
||||
return $channel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Enums;
|
||||
|
||||
enum BookingStatus: string
|
||||
{
|
||||
case PendingPayment = 'pending_payment';
|
||||
case Confirmed = 'confirmed';
|
||||
case Cancelled = 'cancelled';
|
||||
case Expired = 'expired';
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Modules\Booking\Models\Booking;
|
||||
|
||||
class BookingCreated
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(public Booking $booking) {}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Exceptions;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use RuntimeException;
|
||||
|
||||
class BookingCannotBeCancelledException extends RuntimeException
|
||||
{
|
||||
/**
|
||||
* Confirmed bookings no longer reach this — CancelBookingAction (T5.12)
|
||||
* refunds them instead. This is only for statuses that can't be
|
||||
* cancelled at all (already cancelled/expired).
|
||||
*/
|
||||
public static function notPendingPayment(Booking $booking): self
|
||||
{
|
||||
return new self(
|
||||
"Booking [{$booking->booking_ref}] cannot be cancelled because its status is [{$booking->status->value}]."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A rejected cancel attempt is a client input problem, not a server
|
||||
* error — surface it as 422 rather than the default 500.
|
||||
*/
|
||||
public function render(Request $request): ?JsonResponse
|
||||
{
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['message' => $this->getMessage()], 422);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Exceptions;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use RuntimeException;
|
||||
|
||||
class DriverAssignmentNotAllowedException extends RuntimeException
|
||||
{
|
||||
public static function notConfirmed(Booking $booking): self
|
||||
{
|
||||
return new self(
|
||||
"Booking [{$booking->booking_ref}] cannot have a driver assigned because its status is [{$booking->status->value}], not confirmed."
|
||||
);
|
||||
}
|
||||
|
||||
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()) {
|
||||
return response()->json(['message' => $this->getMessage()], 422);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Exceptions;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
use RuntimeException;
|
||||
|
||||
class InvalidVehicleSelectionException extends RuntimeException
|
||||
{
|
||||
public static function frontSeatLimitExceeded(int $requested, int $max): self
|
||||
{
|
||||
return new self("Front seat request [{$requested}] exceeds the max of [{$max}] per booking.");
|
||||
}
|
||||
|
||||
public static function optionDisabled(VehicleOption $vehicleOption): self
|
||||
{
|
||||
return new self("Vehicle option [{$vehicleOption->value}] is not currently available for booking.");
|
||||
}
|
||||
|
||||
public static function duplicateOption(VehicleOption $vehicleOption): self
|
||||
{
|
||||
return new self("Vehicle option [{$vehicleOption->value}] was selected more than once — combine it into a single selection.");
|
||||
}
|
||||
|
||||
public static function wholeVehicleCannotBeCombined(): self
|
||||
{
|
||||
return new self('Whole Vehicle cannot be combined with other vehicle options in the same booking.');
|
||||
}
|
||||
|
||||
/**
|
||||
* A rejected vehicle selection is a client input problem, not a server
|
||||
* error — surface it as 422 rather than the default 500. A broader JSON
|
||||
* error envelope for all of api/* is Phase 6 (T6.3); this keeps the
|
||||
* mapping local until that lands.
|
||||
*/
|
||||
public function render(Request $request): ?JsonResponse
|
||||
{
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['message' => $this->getMessage()], 422);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Filament\Resources\Bookings\Actions;
|
||||
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Modules\Booking\Actions\AssignDriverAction;
|
||||
use Modules\Booking\Data\AssignDriverData;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Exceptions\DriverAssignmentNotAllowedException;
|
||||
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 AssignDriverTableAction
|
||||
{
|
||||
public static function make(): Action
|
||||
{
|
||||
return Action::make('assignDriver')
|
||||
->label('Assign Driver')
|
||||
->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(),
|
||||
TextInput::make('driver_phone')->required(),
|
||||
TextInput::make('car_plate_number')->required(),
|
||||
TextInput::make('car_model'),
|
||||
])
|
||||
->fillForm(fn (Booking $record): array => [
|
||||
'driver_name' => $record->driver_name,
|
||||
'driver_phone' => $record->driver_phone,
|
||||
'car_plate_number' => $record->car_plate_number,
|
||||
'car_model' => $record->car_model,
|
||||
])
|
||||
->action(function (array $data, Booking $record, AssignDriverAction $assignDriverAction) {
|
||||
try {
|
||||
$assignDriverAction->handle($record, new AssignDriverData(
|
||||
driverName: $data['driver_name'],
|
||||
driverPhone: $data['driver_phone'],
|
||||
carPlateNumber: $data['car_plate_number'],
|
||||
carModel: $data['car_model'] ?: null,
|
||||
));
|
||||
|
||||
Notification::make()
|
||||
->title('Driver assigned')
|
||||
->success()
|
||||
->send();
|
||||
} catch (DriverAssignmentNotAllowedException $exception) {
|
||||
Notification::make()
|
||||
->title('Cannot assign driver')
|
||||
->body($exception->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Filament\Resources\Bookings\Actions;
|
||||
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Modules\Booking\Actions\CancelBookingAction;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Exceptions\BookingCannotBeCancelledException;
|
||||
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 CancelBookingTableAction
|
||||
{
|
||||
public static function make(): Action
|
||||
{
|
||||
return Action::make('cancel')
|
||||
->label('Cancel')
|
||||
->icon(Heroicon::OutlinedXCircle)
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Booking $record): bool => Gate::allows('cancel', $record))
|
||||
->disabled(fn (Booking $record): bool => $record->status !== BookingStatus::PendingPayment)
|
||||
->action(function (Booking $record, CancelBookingAction $cancelBookingAction) {
|
||||
try {
|
||||
$cancelBookingAction->handle($record);
|
||||
|
||||
Notification::make()
|
||||
->title('Booking cancelled')
|
||||
->success()
|
||||
->send();
|
||||
} catch (BookingCannotBeCancelledException $exception) {
|
||||
Notification::make()
|
||||
->title('Cannot cancel booking')
|
||||
->body($exception->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Filament\Resources\Bookings\Actions;
|
||||
|
||||
use Filament\Actions\DeleteAction;
|
||||
|
||||
/**
|
||||
* Soft-delete only (Booking uses SoftDeletes). `authorize('delete')` ties
|
||||
* both the visible/hidden state AND the actual delete call itself to
|
||||
* BookingPolicy::delete (manage_bookings + terminal status) — unlike
|
||||
* visible()/disabled(), which are UI-only, authorize() is enforced when the
|
||||
* action runs (Filament\Actions\Concerns\CanBeAuthorized). A booking that
|
||||
* isn't cancelled/expired never shows this button at all, rather than a
|
||||
* dead disabled one.
|
||||
*/
|
||||
class DeleteBookingTableAction
|
||||
{
|
||||
public static function make(): DeleteAction
|
||||
{
|
||||
return DeleteAction::make()
|
||||
->authorize('delete');
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Filament\Resources\Bookings\Actions;
|
||||
|
||||
use Filament\Actions\RestoreAction;
|
||||
|
||||
/**
|
||||
* Pairs with DeleteBookingTableAction — RestoreAction is already visible
|
||||
* only for trashed records out of the box; authorize('restore') layers
|
||||
* BookingPolicy::restore (manage_bookings) on top, enforced at call time
|
||||
* as well as driving visibility (Filament\Actions\Concerns\CanBeAuthorized).
|
||||
*/
|
||||
class RestoreBookingTableAction
|
||||
{
|
||||
public static function make(): RestoreAction
|
||||
{
|
||||
return RestoreAction::make()
|
||||
->authorize('restore');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Filament\Resources\Bookings;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Pages\ListBookings;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Pages\ViewBooking;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Schemas\BookingInfolist;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Tables\BookingsTable;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use UnitEnum;
|
||||
|
||||
/**
|
||||
* Read-mostly by design: bookings are created through the API (T4.4), not
|
||||
* hand-entered in the admin — so this resource has no create/edit form, just
|
||||
* a list with filters and a status-gated Cancel action (T4.6).
|
||||
*/
|
||||
class BookingResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Booking::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedTicket;
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Operations';
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return BookingsTable::configure($table);
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return BookingInfolist::configure($schema);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListBookings::route('/'),
|
||||
'view' => ViewBooking::route('/{record}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Filament\Resources\Bookings\Pages;
|
||||
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Modules\Booking\Filament\Resources\Bookings\BookingResource;
|
||||
|
||||
class ListBookings extends ListRecords
|
||||
{
|
||||
protected static string $resource = BookingResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
// No CreateAction — bookings are created through the API (T4.4), not
|
||||
// hand-entered here.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
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
|
||||
{
|
||||
protected static string $resource = BookingResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
AssignDriverTableAction::make(),
|
||||
SetRemarkTableAction::make(),
|
||||
CancelBookingTableAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Filament\Resources\Bookings\Schemas;
|
||||
|
||||
use Filament\Infolists\Components\RepeatableEntry;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
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
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Section::make('Booking')
|
||||
->schema([
|
||||
Grid::make(4)
|
||||
->schema([
|
||||
TextEntry::make('booking_ref')->label('Ref'),
|
||||
TextEntry::make('status')
|
||||
->badge()
|
||||
->color(fn (BookingStatus $state) => match ($state) {
|
||||
BookingStatus::PendingPayment => 'warning',
|
||||
BookingStatus::Confirmed => 'success',
|
||||
BookingStatus::Cancelled => 'gray',
|
||||
BookingStatus::Expired => 'danger',
|
||||
}),
|
||||
TextEntry::make('created_by_channel')->badge(),
|
||||
TextEntry::make('created_at')->dateTime(),
|
||||
]),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make('Trip')
|
||||
->schema([
|
||||
Grid::make(3)
|
||||
->schema([
|
||||
TextEntry::make('route.company.name')->label('Company'),
|
||||
TextEntry::make('route.fromDestination.name')->label('From'),
|
||||
TextEntry::make('route.toDestination.name')->label('To'),
|
||||
TextEntry::make('timeSlot.label')->label('Time Slot'),
|
||||
TextEntry::make('travel_date')->date(),
|
||||
TextEntry::make('is_round_trip')->label('Round Trip')->badge(),
|
||||
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')
|
||||
->label('')
|
||||
->schema([
|
||||
Grid::make(4)
|
||||
->schema([
|
||||
TextEntry::make('vehicle_option')->badge(),
|
||||
TextEntry::make('passenger_count'),
|
||||
TextEntry::make('unit_price')->numeric(2),
|
||||
TextEntry::make('line_total')->numeric(2),
|
||||
]),
|
||||
]),
|
||||
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)
|
||||
->schema([
|
||||
TextEntry::make('pickup_address'),
|
||||
TextEntry::make('dropoff_address'),
|
||||
TextEntry::make('pickup_lat')->label('Pickup Lat')->placeholder('—'),
|
||||
TextEntry::make('dropoff_lat')->label('Dropoff Lat')->placeholder('—'),
|
||||
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([
|
||||
Grid::make(4)
|
||||
->schema([
|
||||
TextEntry::make('driver_name')->label('Driver')->placeholder('Not yet assigned'),
|
||||
TextEntry::make('driver_phone')->label('Driver Phone')->placeholder('Not yet assigned'),
|
||||
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(8)
|
||||
->schema([
|
||||
TextEntry::make('gateway')->badge(),
|
||||
TextEntry::make('status')
|
||||
->badge()
|
||||
->color(fn (PaymentStatus $state) => match ($state) {
|
||||
PaymentStatus::Pending => 'warning',
|
||||
PaymentStatus::Completed => 'success',
|
||||
PaymentStatus::Failed => 'danger',
|
||||
}),
|
||||
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('—'),
|
||||
]),
|
||||
])
|
||||
->placeholder('No payment attempts yet.'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
use Filament\Tables\Filters\TrashedFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
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;
|
||||
|
||||
class BookingsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->with([
|
||||
'route.company', 'route.fromDestination', 'route.toDestination', 'timeSlot', 'vehicleOptions',
|
||||
]))
|
||||
->defaultSort('created_at', 'desc')
|
||||
->columns([
|
||||
TextColumn::make('booking_ref')
|
||||
->label('Ref')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->color(fn (BookingStatus $state) => match ($state) {
|
||||
BookingStatus::PendingPayment => 'warning',
|
||||
BookingStatus::Confirmed => 'success',
|
||||
BookingStatus::Cancelled => 'gray',
|
||||
BookingStatus::Expired => 'danger',
|
||||
}),
|
||||
TextColumn::make('route.company.name')
|
||||
->label('Company')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('route.fromDestination.name')
|
||||
->label('From'),
|
||||
TextColumn::make('route.toDestination.name')
|
||||
->label('To'),
|
||||
TextColumn::make('travel_date')
|
||||
->date()
|
||||
->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
|
||||
->map(fn ($line) => str($line->vehicle_option->value)->headline().' x'.$line->passenger_count)
|
||||
->all())
|
||||
->listWithLineBreaks(),
|
||||
TextColumn::make('price')
|
||||
->numeric(2)
|
||||
->sortable(),
|
||||
TextColumn::make('passenger_name')
|
||||
->label('Passenger')
|
||||
->description(fn (Booking $record) => $record->passenger_phone)
|
||||
->searchable(['passenger_name', 'passenger_phone'])
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('created_by_channel')
|
||||
->badge()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('driver_name')
|
||||
->label('Driver')
|
||||
->placeholder('Not yet assigned')
|
||||
->description(fn (Booking $record) => collect([$record->driver_phone, $record->car_plate_number, $record->car_model])
|
||||
->filter()
|
||||
->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()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('status')
|
||||
->options(array_combine(
|
||||
array_map(fn (BookingStatus $status) => $status->value, BookingStatus::cases()),
|
||||
array_map(fn (BookingStatus $status) => str($status->value)->headline()->toString(), BookingStatus::cases()),
|
||||
)),
|
||||
Filter::make('travel_date')
|
||||
->schema([
|
||||
DatePicker::make('travel_date'),
|
||||
])
|
||||
->query(fn (Builder $query, array $data) => $query->when(
|
||||
$data['travel_date'] ?? null,
|
||||
fn (Builder $q, $date) => $q->whereDate('travel_date', $date),
|
||||
)),
|
||||
SelectFilter::make('ev_route_id')
|
||||
->label('Route')
|
||||
->options(fn () => EvRoute::with(['fromDestination', 'toDestination'])->get()
|
||||
->mapWithKeys(fn (EvRoute $route) => [
|
||||
$route->id => "{$route->fromDestination?->name} → {$route->toDestination?->name}",
|
||||
]))
|
||||
->searchable(),
|
||||
SelectFilter::make('company')
|
||||
->options(fn () => EvCompany::pluck('name', 'id'))
|
||||
->searchable()
|
||||
->query(fn (Builder $query, array $data) => $query->when(
|
||||
$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.
|
||||
TrashedFilter::make(),
|
||||
])
|
||||
->recordActions([
|
||||
ViewAction::make(),
|
||||
AssignDriverTableAction::make(),
|
||||
SetRemarkTableAction::make(),
|
||||
CancelBookingTableAction::make(),
|
||||
DeleteBookingTableAction::make(),
|
||||
RestoreBookingTableAction::make(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Filament\Widgets;
|
||||
|
||||
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
|
||||
/**
|
||||
* Dispatch-facing snapshot of today's trips — "today" means travel_date, not
|
||||
* created_at, since this is what staff care about when assigning
|
||||
* drivers/vehicles (domain.md §5a), not how many bookings were made today.
|
||||
*/
|
||||
class BookingsTodayWidget extends BaseWidget
|
||||
{
|
||||
protected function getStats(): array
|
||||
{
|
||||
$today = Booking::query()->whereDate('travel_date', today());
|
||||
|
||||
$confirmedToday = (clone $today)->where('status', BookingStatus::Confirmed)->count();
|
||||
$pendingToday = (clone $today)->where('status', BookingStatus::PendingPayment)->count();
|
||||
|
||||
return [
|
||||
Stat::make('Trips Today', (clone $today)->count())
|
||||
->description('Bookings scheduled for today')
|
||||
->color('primary'),
|
||||
Stat::make('Confirmed', $confirmedToday)
|
||||
->description('Paid & ready for driver assignment')
|
||||
->color('success'),
|
||||
Stat::make('Awaiting Payment', $pendingToday)
|
||||
->description('Still pending_payment')
|
||||
->color($pendingToday > 0 ? 'warning' : 'gray'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Filament\Widgets;
|
||||
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Widgets\TableWidget as BaseWidget;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
|
||||
class RecentBookingsTableWidget extends BaseWidget
|
||||
{
|
||||
protected static ?int $sort = 2;
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->heading('Recent Bookings')
|
||||
->query(
|
||||
Booking::query()
|
||||
->with(['route.fromDestination', 'route.toDestination'])
|
||||
->latest('created_at')
|
||||
->limit(10),
|
||||
)
|
||||
->columns([
|
||||
TextColumn::make('booking_ref')
|
||||
->label('Ref'),
|
||||
TextColumn::make('status')
|
||||
->badge()
|
||||
->color(fn (BookingStatus $state) => match ($state) {
|
||||
BookingStatus::PendingPayment => 'warning',
|
||||
BookingStatus::Confirmed => 'success',
|
||||
BookingStatus::Cancelled => 'gray',
|
||||
BookingStatus::Expired => 'danger',
|
||||
}),
|
||||
TextColumn::make('route.fromDestination.name')
|
||||
->label('From'),
|
||||
TextColumn::make('route.toDestination.name')
|
||||
->label('To'),
|
||||
TextColumn::make('travel_date')
|
||||
->date(),
|
||||
TextColumn::make('price')
|
||||
->numeric(2),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->since(),
|
||||
])
|
||||
->paginated(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Modules\Booking\Actions\CancelBookingAction;
|
||||
use Modules\Booking\Actions\CreateBookingAction;
|
||||
use Modules\Booking\Data\CreateBookingData;
|
||||
use Modules\Booking\Data\VehicleSelectionData;
|
||||
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
|
||||
{
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
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,
|
||||
private CancelBookingAction $cancelBookingAction,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$openid = $request->attributes->get('fastapi_openid');
|
||||
|
||||
$query = Booking::query();
|
||||
|
||||
if ($openid !== null) {
|
||||
// FastAPI agent (JWT auth, no Laravel user) — scoped to the
|
||||
// verified token's own openid, never a client-supplied value,
|
||||
// so one agent session can't list another customer's bookings.
|
||||
$query->where('openid', $openid);
|
||||
} else {
|
||||
Gate::authorize('viewAny', Booking::class);
|
||||
$query->where('user_id', $request->user()->id);
|
||||
}
|
||||
|
||||
$bookings = $query
|
||||
// Only bookings that actually have a completed payment — a
|
||||
// pending_payment booking never had money move, so it's noise
|
||||
// in a booking list, not a real reservation to show.
|
||||
->whereHas('payments', fn ($paymentQuery) => $paymentQuery->where('status', PaymentStatus::Completed))
|
||||
// A round trip is two Booking rows (outbound + return leg,
|
||||
// linked via linked_booking_id — domain.md §2b), but it should
|
||||
// still surface once here, not as two separate list entries.
|
||||
// The outbound row's `linked_booking` already carries the
|
||||
// return leg's full detail (including vehicle_options).
|
||||
->where('is_return_leg', false)
|
||||
->when($request->filled('booking_ref'), fn ($q) => $q->where('booking_ref', 'ilike', '%'.$request->string('booking_ref').'%'))
|
||||
->with(self::EAGER_LOADS)
|
||||
->latest()
|
||||
->paginate();
|
||||
|
||||
return BookingResource::collection($bookings);
|
||||
}
|
||||
|
||||
public function show(Request $request, Booking $booking): BookingResource
|
||||
{
|
||||
$openid = $request->attributes->get('fastapi_openid');
|
||||
|
||||
if ($openid !== null) {
|
||||
abort_if($booking->openid !== $openid, 404);
|
||||
} else {
|
||||
Gate::authorize('view', $booking);
|
||||
}
|
||||
|
||||
return new BookingResource($booking->load(self::EAGER_LOADS));
|
||||
}
|
||||
|
||||
public function store(StoreBookingRequest $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$openid = $request->attributes->get('fastapi_openid');
|
||||
|
||||
if ($openid === null) {
|
||||
Gate::authorize('create', Booking::class);
|
||||
}
|
||||
|
||||
$selections = array_map(
|
||||
fn (array $selection) => new VehicleSelectionData(
|
||||
vehicleOption: VehicleOption::from($selection['vehicle_option']),
|
||||
passengerCount: $selection['passenger_count'],
|
||||
),
|
||||
$validated['selections'],
|
||||
);
|
||||
|
||||
$isRoundTrip = $validated['is_round_trip'] ?? false;
|
||||
|
||||
$returnSelections = $isRoundTrip
|
||||
? array_map(
|
||||
fn (array $selection) => new VehicleSelectionData(
|
||||
vehicleOption: VehicleOption::from($selection['vehicle_option']),
|
||||
passengerCount: $selection['passenger_count'],
|
||||
),
|
||||
$validated['return_selections'],
|
||||
)
|
||||
: null;
|
||||
|
||||
// The agent's own auth path always wins over anything a header could
|
||||
// claim; customer channels come from Device-Type, not a
|
||||
// client-supplied body field (BookingChannel::fromDeviceTypeHeader
|
||||
// already refuses to hand back Agent/Admin from a header value).
|
||||
$channel = $openid !== null
|
||||
? BookingChannel::Agent
|
||||
: BookingChannel::fromDeviceTypeHeader($request->header('Device-Type'));
|
||||
|
||||
$booking = $this->createBookingAction->handle(new CreateBookingData(
|
||||
evRouteId: $validated['ev_route_id'],
|
||||
departureTimeSlotId: $validated['departure_time_slot_id'],
|
||||
travelDate: $validated['travel_date'],
|
||||
selections: $selections,
|
||||
passengerName: $validated['passenger_name'],
|
||||
passengerPhone: $validated['passenger_phone'],
|
||||
notes: $validated['notes'] ?? null,
|
||||
pickupAddress: $validated['pickup_address'],
|
||||
dropoffAddress: $validated['dropoff_address'],
|
||||
createdByChannel: $channel,
|
||||
// A verified FastAPI JWT's own openid always wins over a
|
||||
// client-supplied one — a request can never claim a different
|
||||
// customer's identity than its own token proves.
|
||||
userId: $request->user()?->id,
|
||||
openid: $openid ?? $validated['openid'] ?? null,
|
||||
pickupLat: $validated['pickup_lat'] ?? null,
|
||||
pickupLng: $validated['pickup_lng'] ?? null,
|
||||
dropoffLat: $validated['dropoff_lat'] ?? null,
|
||||
dropoffLng: $validated['dropoff_lng'] ?? null,
|
||||
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)))
|
||||
->response()
|
||||
->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function cancel(Request $request, Booking $booking): BookingResource
|
||||
{
|
||||
Gate::authorize('cancel', $booking);
|
||||
|
||||
$this->cancelBookingAction->handle($booking, $request->user()?->id);
|
||||
|
||||
return new BookingResource($booking->load(self::EAGER_LOADS));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
|
||||
/**
|
||||
* Shape validation only — business rules (front-seat limit, disabled vehicle
|
||||
* options, pricing) stay in BookingService/PricingService, not here.
|
||||
*/
|
||||
class StoreBookingRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'ev_route_id' => ['required', 'integer', 'exists:ev_routes,id'],
|
||||
'departure_time_slot_id' => ['required', 'integer', 'exists:departure_time_slots,id'],
|
||||
'travel_date' => ['required', 'date'],
|
||||
// One or more Vehicle Option lines — e.g. front_seat + back_seat together
|
||||
// (domain.md §2). Duplicate-option/whole-vehicle-exclusivity rules stay in
|
||||
// BookingService, not here.
|
||||
'selections' => ['required', 'array', 'min:1'],
|
||||
'selections.*.vehicle_option' => ['required', Rule::enum(VehicleOption::class)],
|
||||
'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_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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class BookingResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'booking_ref' => $this->booking_ref,
|
||||
'status' => $this->status,
|
||||
'travel_date' => $this->travel_date?->toDateString(),
|
||||
'is_round_trip' => $this->is_round_trip,
|
||||
'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,
|
||||
'dropoff_address' => $this->dropoff_address,
|
||||
'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,
|
||||
'driver_phone' => $this->driver_phone,
|
||||
'car_plate_number' => $this->car_plate_number,
|
||||
'car_model' => $this->car_model,
|
||||
'vehicle_options' => $this->whenLoaded('vehicleOptions', fn () => $this->vehicleOptions->map(fn ($selection) => [
|
||||
'vehicle_option' => $selection->vehicle_option,
|
||||
'passenger_count' => $selection->passenger_count,
|
||||
'unit_price' => $selection->unit_price,
|
||||
'line_total' => $selection->line_total,
|
||||
])),
|
||||
'route' => $this->whenLoaded('route', fn () => [
|
||||
'id' => $this->route->id,
|
||||
'ev_company_id' => $this->route->ev_company_id,
|
||||
'from_destination_id' => $this->route->from_destination_id,
|
||||
'to_destination_id' => $this->route->to_destination_id,
|
||||
]),
|
||||
'time_slot' => $this->whenLoaded('timeSlot', fn () => [
|
||||
'id' => $this->timeSlot->id,
|
||||
'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}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Modules\Booking\Database\Factories\BookingFactory;
|
||||
use Modules\Booking\Enums\BookingChannel;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Catalog\Models\DepartureTimeSlot;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use Modules\Routing\Models\EvRoute;
|
||||
use Spatie\Activitylog\Models\Concerns\LogsActivity;
|
||||
use Spatie\Activitylog\Support\LogOptions;
|
||||
|
||||
class Booking extends Model
|
||||
{
|
||||
/** @use HasFactory<BookingFactory> */
|
||||
use HasFactory, LogsActivity, SoftDeletes;
|
||||
|
||||
/**
|
||||
* Audit trail on status transitions and driver/vehicle assignment only —
|
||||
* not every column (domain.md §6, §5a; T6.2).
|
||||
*/
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logOnly(['status', 'driver_name', 'driver_phone', 'car_plate_number', 'car_model'])
|
||||
->logOnlyDirty()
|
||||
->dontLogEmptyChanges()
|
||||
->useLogName('booking');
|
||||
}
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'booking_ref',
|
||||
'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',
|
||||
'dropoff_address',
|
||||
'dropoff_lat',
|
||||
'dropoff_lng',
|
||||
'price',
|
||||
'status',
|
||||
'created_by_channel',
|
||||
'driver_name',
|
||||
'driver_phone',
|
||||
'car_plate_number',
|
||||
'car_model',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'travel_date' => 'date',
|
||||
'pickup_lat' => 'decimal:7',
|
||||
'pickup_lng' => 'decimal:7',
|
||||
'dropoff_lat' => 'decimal:7',
|
||||
'dropoff_lng' => 'decimal:7',
|
||||
'price' => 'decimal:2',
|
||||
'status' => BookingStatus::class,
|
||||
'is_return_leg' => 'boolean',
|
||||
'created_by_channel' => BookingChannel::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function route(): BelongsTo
|
||||
{
|
||||
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');
|
||||
}
|
||||
|
||||
public function vehicleOptions(): HasMany
|
||||
{
|
||||
return $this->hasMany(BookingVehicleOption::class);
|
||||
}
|
||||
|
||||
public function payments(): HasMany
|
||||
{
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Modules\Booking\Database\Factories\BookingVehicleOptionFactory;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
|
||||
/**
|
||||
* One Vehicle Option line on a Booking (e.g. "back_seat x2"). A booking can
|
||||
* have more than one of these — see domain.md §2.
|
||||
*/
|
||||
class BookingVehicleOption extends Model
|
||||
{
|
||||
/** @use HasFactory<BookingVehicleOptionFactory> */
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'booking_id',
|
||||
'vehicle_option',
|
||||
'passenger_count',
|
||||
'unit_price',
|
||||
'line_total',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'vehicle_option' => VehicleOption::class,
|
||||
'passenger_count' => 'integer',
|
||||
'unit_price' => 'decimal:2',
|
||||
'line_total' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
||||
public function booking(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Booking::class);
|
||||
}
|
||||
}
|
||||
@@ -3,22 +3,29 @@
|
||||
namespace Modules\Booking\Policies;
|
||||
|
||||
use App\Models\User;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
|
||||
/**
|
||||
* Skeleton only — role/permission gates for now. Per-booking ownership
|
||||
* checks (e.g. a customer may only view/cancel their own booking) are
|
||||
* filled in against the real Booking model once it exists (Phase 4).
|
||||
*/
|
||||
class BookingPolicy
|
||||
{
|
||||
/**
|
||||
* Listing is always scoped to the caller's own bookings at the query
|
||||
* level (BookingController::index) — any authenticated user may look at
|
||||
* their own list. Staff get the full, unscoped list via the Filament
|
||||
* BookingResource (T4.7), not this gate.
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->can('view_bookings');
|
||||
return true;
|
||||
}
|
||||
|
||||
public function view(User $user, mixed $booking): bool
|
||||
/**
|
||||
* A booking's owner may always view it; anyone else needs the
|
||||
* view_bookings permission (admin/support roles).
|
||||
*/
|
||||
public function view(User $user, Booking $booking): bool
|
||||
{
|
||||
return $user->can('view_bookings');
|
||||
return $user->id === $booking->user_id || $user->can('view_bookings');
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
@@ -26,13 +33,56 @@ class BookingPolicy
|
||||
return true;
|
||||
}
|
||||
|
||||
public function cancel(User $user, mixed $booking): bool
|
||||
/**
|
||||
* A booking's owner may cancel their own pending_payment booking; staff
|
||||
* can cancel any pending_payment booking via manage_bookings. Cancelling
|
||||
* a confirmed (paid) booking refunds it (CancelBookingAction, T5.12) —
|
||||
* that's the same authorization boundary as refund(), staff only
|
||||
* (domain.md §8: refund initiation is a staff-only operation).
|
||||
*/
|
||||
public function cancel(User $user, Booking $booking): bool
|
||||
{
|
||||
return $user->can('manage_bookings');
|
||||
if ($booking->status === BookingStatus::Confirmed) {
|
||||
return $user->can('process_refunds');
|
||||
}
|
||||
|
||||
return $user->id === $booking->user_id || $user->can('manage_bookings');
|
||||
}
|
||||
|
||||
public function refund(User $user, mixed $booking): bool
|
||||
{
|
||||
return $user->can('process_refunds');
|
||||
}
|
||||
|
||||
/**
|
||||
* A booking's owner may pay for their own (still pending_payment only —
|
||||
* enforced by InitiatePaymentAction, not here); staff can initiate on
|
||||
* behalf of a customer via manage_bookings.
|
||||
*/
|
||||
public function pay(User $user, Booking $booking): bool
|
||||
{
|
||||
return $user->id === $booking->user_id || $user->can('manage_bookings');
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff-only, and only once a booking is terminal (cancelled/expired) —
|
||||
* a pending_payment or confirmed (paid) booking must never be deleted
|
||||
* out from under an in-flight payment/refund flow. Soft delete only
|
||||
* (Booking uses SoftDeletes); Payment/Refund history stays intact.
|
||||
*/
|
||||
public function delete(User $user, Booking $booking): bool
|
||||
{
|
||||
return in_array($booking->status, [BookingStatus::Cancelled, BookingStatus::Expired], true)
|
||||
&& $user->can('manage_bookings');
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff-only. No status restriction beyond RestoreAction's own built-in
|
||||
* "only if trashed" visibility — a booking's status doesn't change on
|
||||
* delete, so whatever made it deletable still holds once restored.
|
||||
*/
|
||||
public function restore(User $user, Booking $booking): bool
|
||||
{
|
||||
return $user->can('manage_bookings');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Services;
|
||||
|
||||
use Modules\Booking\Models\Booking;
|
||||
|
||||
class BookingRefGenerator
|
||||
{
|
||||
private const PREFIX = 'EVB';
|
||||
|
||||
private const CHARS = '123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
|
||||
/**
|
||||
* Must be called inside the same DB::transaction() as the booking insert
|
||||
* — the row lock on the latest booking is what keeps concurrent callers
|
||||
* from generating the same ref, and it only holds for the transaction's
|
||||
* lifetime.
|
||||
*/
|
||||
public function generate(): string
|
||||
{
|
||||
// Lock the latest row so concurrent transactions can't read the same ref.
|
||||
$latest = Booking::lockForUpdate()->orderByDesc('id')->value('booking_ref');
|
||||
|
||||
// If the latest ref doesn't match the expected format, start the sequence fresh.
|
||||
if ($latest && preg_match('/^[A-Z]+-[A-Z0-9]+$/', $latest)) {
|
||||
return $this->incrementRef($latest);
|
||||
}
|
||||
|
||||
return self::PREFIX.'-AAAAA1';
|
||||
}
|
||||
|
||||
private function incrementRef(string $ref): string
|
||||
{
|
||||
preg_match('/^(.*)-([A-Z0-9]+)$/', $ref, $matches);
|
||||
|
||||
$prefix = $matches[1];
|
||||
$suffix = str_split($matches[2]);
|
||||
$base = strlen(self::CHARS);
|
||||
$i = count($suffix) - 1;
|
||||
$carry = true;
|
||||
|
||||
while ($i >= 0 && $carry) {
|
||||
$idx = strpos(self::CHARS, $suffix[$i]);
|
||||
|
||||
if ($idx + 1 < $base) {
|
||||
$suffix[$i] = self::CHARS[$idx + 1];
|
||||
$carry = false;
|
||||
} else {
|
||||
$suffix[$i] = self::CHARS[0];
|
||||
}
|
||||
$i--;
|
||||
}
|
||||
|
||||
if ($carry) {
|
||||
array_unshift($suffix, self::CHARS[0]);
|
||||
}
|
||||
|
||||
return $prefix.'-'.implode('', $suffix);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Services;
|
||||
|
||||
use Modules\Booking\Data\VehicleSelectionData;
|
||||
use Modules\Booking\Exceptions\InvalidVehicleSelectionException;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
|
||||
class BookingService
|
||||
{
|
||||
/**
|
||||
* Enforces the only v1 inventory rule (max Front Seats per booking), the
|
||||
* blunt config toggles for Back Seat / Whole Vehicle availability, and
|
||||
* shape rules around combining options in one booking (no duplicate
|
||||
* option lines, Whole Vehicle can't be mixed with anything else since it
|
||||
* already covers the whole car).
|
||||
*
|
||||
* Deliberately does not check real capacity/availability — that's an
|
||||
* explicitly deferred future phase (domain.md §2, §7).
|
||||
*
|
||||
* @param list<VehicleSelectionData> $selections
|
||||
*
|
||||
* @throws InvalidVehicleSelectionException
|
||||
*/
|
||||
public function validateSelections(array $selections): void
|
||||
{
|
||||
$seen = [];
|
||||
|
||||
foreach ($selections as $selection) {
|
||||
if (isset($seen[$selection->vehicleOption->value])) {
|
||||
throw InvalidVehicleSelectionException::duplicateOption($selection->vehicleOption);
|
||||
}
|
||||
|
||||
$seen[$selection->vehicleOption->value] = true;
|
||||
|
||||
$this->validateOption($selection->vehicleOption, $selection->passengerCount);
|
||||
}
|
||||
|
||||
if (isset($seen[VehicleOption::WholeVehicle->value]) && count($seen) > 1) {
|
||||
throw InvalidVehicleSelectionException::wholeVehicleCannotBeCombined();
|
||||
}
|
||||
}
|
||||
|
||||
private function validateOption(VehicleOption $vehicleOption, int $passengerCount): void
|
||||
{
|
||||
match ($vehicleOption) {
|
||||
VehicleOption::FrontSeat => $this->validateFrontSeat($passengerCount),
|
||||
VehicleOption::BackSeat => $this->validateEnabled($vehicleOption, 'booking.back_seat_enabled'),
|
||||
VehicleOption::WholeVehicle => $this->validateEnabled($vehicleOption, 'booking.whole_vehicle_enabled'),
|
||||
};
|
||||
}
|
||||
|
||||
private function validateFrontSeat(int $passengerCount): void
|
||||
{
|
||||
$max = config('booking.front_seat_max_per_booking');
|
||||
|
||||
if ($passengerCount > $max) {
|
||||
throw InvalidVehicleSelectionException::frontSeatLimitExceeded($passengerCount, $max);
|
||||
}
|
||||
}
|
||||
|
||||
private function validateEnabled(VehicleOption $vehicleOption, string $configKey): void
|
||||
{
|
||||
if (! config($configKey)) {
|
||||
throw InvalidVehicleSelectionException::optionDisabled($vehicleOption);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Payment\Contracts\PaymentGatewayInterface;
|
||||
use Modules\Payment\Data\PaymentRequestData;
|
||||
use Modules\Payment\Data\PaymentResultData;
|
||||
use Modules\Payment\Data\RefundResultData;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Enums\RefundStatus;
|
||||
use Modules\Payment\Factories\PaymentGatewayFactory;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
|
||||
/**
|
||||
* Never calls the real KBZ refund API in tests.
|
||||
*/
|
||||
class FakeCancelApiRefundGateway implements PaymentGatewayInterface
|
||||
{
|
||||
public function initiate(PaymentRequestData $data): PaymentResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
}
|
||||
|
||||
public function verify(string $gatewayTransactionId): PaymentResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
}
|
||||
|
||||
public function refund(string $gatewayTransactionId, string $amount, string $reason): RefundResultData
|
||||
{
|
||||
return new RefundResultData(status: RefundStatus::Completed, gatewayRefundId: 'REFUND123', gatewayPayload: []);
|
||||
}
|
||||
|
||||
public function handleWebhook(array $payload): PaymentResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
foreach (['manage_bookings', 'process_refunds'] as $permission) {
|
||||
Permission::findOrCreate($permission, 'web');
|
||||
}
|
||||
|
||||
app(PaymentGatewayFactory::class)->register(PaymentMethod::KbzMiniApp, FakeCancelApiRefundGateway::class);
|
||||
|
||||
$this->owner = User::factory()->create();
|
||||
$this->token = $this->owner->createToken('test-token')->plainTextToken;
|
||||
});
|
||||
|
||||
test('the owner can cancel their own pending_payment booking', function () {
|
||||
$booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::PendingPayment]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel")
|
||||
->assertSuccessful()
|
||||
->assertJsonPath('data.status', BookingStatus::Cancelled->value);
|
||||
|
||||
expect($booking->refresh()->status)->toBe(BookingStatus::Cancelled);
|
||||
});
|
||||
|
||||
test('the owner cannot cancel their own confirmed booking without process_refunds', function () {
|
||||
$booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::Confirmed]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel")
|
||||
->assertForbidden();
|
||||
|
||||
expect($booking->refresh()->status)->toBe(BookingStatus::Confirmed);
|
||||
});
|
||||
|
||||
test('staff with process_refunds can cancel a confirmed booking, which refunds it in full', function () {
|
||||
$staff = User::factory()->create()->givePermissionTo('process_refunds');
|
||||
$staffToken = $staff->createToken('staff-token')->plainTextToken;
|
||||
|
||||
$booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::Confirmed, 'price' => 15000]);
|
||||
Payment::factory()->completed()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'gateway' => PaymentMethod::KbzMiniApp,
|
||||
'amount' => 15000,
|
||||
'gateway_transaction_id' => 'EVB-CANCEL-API-1',
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$staffToken}")
|
||||
->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel")
|
||||
->assertSuccessful()
|
||||
->assertJsonPath('data.status', BookingStatus::Cancelled->value);
|
||||
|
||||
expect($booking->refresh()->status)->toBe(BookingStatus::Cancelled);
|
||||
});
|
||||
|
||||
test('cancelling a confirmed booking with no completed payment surfaces as 422 and leaves it untouched', function () {
|
||||
$staff = User::factory()->create()->givePermissionTo('process_refunds');
|
||||
$staffToken = $staff->createToken('staff-token')->plainTextToken;
|
||||
|
||||
$booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::Confirmed]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$staffToken}")
|
||||
->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel")
|
||||
->assertStatus(422);
|
||||
|
||||
expect($booking->refresh()->status)->toBe(BookingStatus::Confirmed);
|
||||
});
|
||||
|
||||
test('a non-owner without manage_bookings cannot cancel someone else\'s booking', function () {
|
||||
$booking = Booking::factory()->create([
|
||||
'user_id' => User::factory()->create()->id,
|
||||
'status' => BookingStatus::PendingPayment,
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel")
|
||||
->assertForbidden();
|
||||
|
||||
expect($booking->refresh()->status)->toBe(BookingStatus::PendingPayment);
|
||||
});
|
||||
|
||||
test('staff with manage_bookings can cancel someone else\'s pending_payment booking', function () {
|
||||
$staff = User::factory()->create()->givePermissionTo('manage_bookings');
|
||||
$staffToken = $staff->createToken('staff-token')->plainTextToken;
|
||||
|
||||
$booking = Booking::factory()->create(['user_id' => $this->owner->id, 'status' => BookingStatus::PendingPayment]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$staffToken}")
|
||||
->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel")
|
||||
->assertSuccessful();
|
||||
|
||||
expect($booking->refresh()->status)->toBe(BookingStatus::Cancelled);
|
||||
});
|
||||
|
||||
test('unauthenticated requests are rejected', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
|
||||
$this->postJson("/api/v1/bookings/{$booking->booking_ref}/cancel")->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('404s for a booking that does not exist', function () {
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/bookings/EVB-DOES-NOT-EXIST/cancel')
|
||||
->assertNotFound();
|
||||
});
|
||||
@@ -0,0 +1,369 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Modules\Booking\Enums\BookingChannel;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Catalog\Models\DepartureTimeSlot;
|
||||
use Modules\Routing\Models\EvRoute;
|
||||
use Modules\Routing\Models\RoutePricing;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->token = User::factory()->create()->createToken('test-token')->plainTextToken;
|
||||
});
|
||||
|
||||
/**
|
||||
* @param array<int, array{0: VehicleOption, 1: string}> $pricedOptions
|
||||
*/
|
||||
function bookableRouteAndSlot(array $pricedOptions): array
|
||||
{
|
||||
$route = EvRoute::factory()->create(['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];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{vehicle_option: string, passenger_count: int}> $selections
|
||||
*/
|
||||
function bookingPayload(EvRoute $route, DepartureTimeSlot $timeSlot, array $selections): array
|
||||
{
|
||||
return [
|
||||
'ev_route_id' => $route->id,
|
||||
'departure_time_slot_id' => $timeSlot->id,
|
||||
'travel_date' => now()->addDay()->toDateString(),
|
||||
'selections' => $selections,
|
||||
'passenger_name' => 'Jane Doe',
|
||||
'passenger_phone' => '+959123456789',
|
||||
'pickup_address' => '123 Pickup St',
|
||||
'dropoff_address' => '456 Dropoff Ave',
|
||||
];
|
||||
}
|
||||
|
||||
test('happy path: it creates a pending_payment booking with a snapshotted price', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
[$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.status', BookingStatus::PendingPayment->value)
|
||||
->assertJsonPath('data.price', '15000.00')
|
||||
->assertJsonPath('data.vehicle_options.0.vehicle_option', VehicleOption::BackSeat->value)
|
||||
->assertJsonPath('data.route.id', $route->id)
|
||||
->assertJsonPath('data.time_slot.id', $timeSlot->id);
|
||||
|
||||
expect(Booking::count())->toBe(1);
|
||||
});
|
||||
|
||||
test('happy path: front seat and back seat can be booked together', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
[$route, $timeSlot] = bookableRouteAndSlot([
|
||||
[VehicleOption::FrontSeat, '12000.00'],
|
||||
[VehicleOption::BackSeat, '9000.00'],
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [
|
||||
['vehicle_option' => 'front_seat', 'passenger_count' => 1],
|
||||
['vehicle_option' => 'back_seat', 'passenger_count' => 2],
|
||||
]))
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.price', '30000.00')
|
||||
->assertJsonCount(2, 'data.vehicle_options');
|
||||
});
|
||||
|
||||
test('front-seat-limit rejection surfaces as 422', function () {
|
||||
config(['booking.front_seat_max_per_booking' => 1]);
|
||||
|
||||
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::FrontSeat, '12000.00']]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [
|
||||
['vehicle_option' => 'front_seat', 'passenger_count' => 2],
|
||||
]))
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('message', 'Front seat request [2] exceeds the max of [1] per booking.');
|
||||
|
||||
expect(Booking::count())->toBe(0);
|
||||
});
|
||||
|
||||
test('disabled-vehicle-option rejection surfaces as 422', function () {
|
||||
config(['booking.whole_vehicle_enabled' => false]);
|
||||
|
||||
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::WholeVehicle, '30000.00']]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [
|
||||
['vehicle_option' => 'whole_vehicle', 'passenger_count' => 1],
|
||||
]))
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('message', 'Vehicle option [whole_vehicle] is not currently available for booking.');
|
||||
|
||||
expect(Booking::count())->toBe(0);
|
||||
});
|
||||
|
||||
test('mixing whole vehicle with another option surfaces as 422', function () {
|
||||
config([
|
||||
'booking.back_seat_enabled' => true,
|
||||
'booking.whole_vehicle_enabled' => true,
|
||||
]);
|
||||
|
||||
[$route, $timeSlot] = bookableRouteAndSlot([
|
||||
[VehicleOption::WholeVehicle, '30000.00'],
|
||||
[VehicleOption::BackSeat, '9000.00'],
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [
|
||||
['vehicle_option' => 'whole_vehicle', 'passenger_count' => 1],
|
||||
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
|
||||
]))
|
||||
->assertStatus(422);
|
||||
|
||||
expect(Booking::count())->toBe(0);
|
||||
});
|
||||
|
||||
test('unauthenticated requests are rejected', function () {
|
||||
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
|
||||
|
||||
$this->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [
|
||||
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
|
||||
]))->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('shape validation rejects a missing required field', function () {
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/bookings', [])
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors([
|
||||
'ev_route_id', 'departure_time_slot_id', 'travel_date', 'selections',
|
||||
'passenger_name', 'passenger_phone', 'pickup_address', 'dropoff_address',
|
||||
]);
|
||||
});
|
||||
|
||||
test('shape validation rejects an invalid vehicle_option value', function () {
|
||||
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
|
||||
|
||||
$payload = bookingPayload($route, $timeSlot, [
|
||||
['vehicle_option' => 'business_class', 'passenger_count' => 1],
|
||||
]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/bookings', $payload)
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors(['selections.0.vehicle_option']);
|
||||
});
|
||||
|
||||
test('shape validation rejects an empty selections array', function () {
|
||||
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, []))
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors(['selections']);
|
||||
});
|
||||
|
||||
test('created_by_channel defaults to kbz_miniapp when no Device-Type header is sent', function () {
|
||||
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [
|
||||
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
|
||||
]))
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.created_by_channel', BookingChannel::MiniApp->value);
|
||||
});
|
||||
|
||||
test('created_by_channel is taken from the Device-Type header', function (string $deviceType, BookingChannel $expected) {
|
||||
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->withHeader('Device-Type', $deviceType)
|
||||
->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [
|
||||
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
|
||||
]))
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.created_by_channel', $expected->value);
|
||||
})->with([
|
||||
'android' => ['android', BookingChannel::Android],
|
||||
'ios' => ['ios', BookingChannel::Ios],
|
||||
'web' => ['web', BookingChannel::Web],
|
||||
'kbz_miniapp' => ['kbz_miniapp', BookingChannel::MiniApp],
|
||||
]);
|
||||
|
||||
test('customer-supplied notes are stored and returned', function () {
|
||||
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
|
||||
|
||||
$payload = bookingPayload($route, $timeSlot, [
|
||||
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
|
||||
]);
|
||||
$payload['notes'] = 'Please call before arriving.';
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/bookings', $payload)
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.notes', 'Please call before arriving.');
|
||||
|
||||
expect(Booking::first()->notes)->toBe('Please call before arriving.');
|
||||
});
|
||||
|
||||
test('notes is optional and defaults to null', function () {
|
||||
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [
|
||||
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
|
||||
]))
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.notes', null);
|
||||
});
|
||||
|
||||
test('a Device-Type header cannot spoof the agent or admin channel', function (string $deviceType) {
|
||||
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->withHeader('Device-Type', $deviceType)
|
||||
->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [
|
||||
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
|
||||
]))
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.created_by_channel', BookingChannel::MiniApp->value);
|
||||
})->with([
|
||||
'agent' => ['agent'],
|
||||
'admin' => ['admin'],
|
||||
'unrecognized value' => ['smart-fridge'],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Same company as $outbound, from/to swapped — the true reverse route.
|
||||
*
|
||||
* @param array<int, array{0: VehicleOption, 1: string}> $pricedOptions
|
||||
*/
|
||||
function reverseRouteAndSlot(EvRoute $outbound, array $pricedOptions): array
|
||||
{
|
||||
$route = EvRoute::factory()->create([
|
||||
'ev_company_id' => $outbound->ev_company_id,
|
||||
'from_destination_id' => $outbound->to_destination_id,
|
||||
'to_destination_id' => $outbound->from_destination_id,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$timeSlot = DepartureTimeSlot::factory()->create();
|
||||
$route->timeSlots()->attach($timeSlot->id, ['is_active' => true]);
|
||||
|
||||
foreach ($pricedOptions as [$vehicleOption, $price]) {
|
||||
RoutePricing::factory()->create([
|
||||
'ev_route_id' => $route->id,
|
||||
'vehicle_option' => $vehicleOption,
|
||||
'price' => $price,
|
||||
]);
|
||||
}
|
||||
|
||||
return [$route, $timeSlot];
|
||||
}
|
||||
|
||||
test('round trip: creates two linked bookings, each priced against its own route', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
[$outboundRoute, $outboundSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '9000.00']]);
|
||||
[$returnRoute, $returnSlot] = reverseRouteAndSlot($outboundRoute, [[VehicleOption::BackSeat, '11000.00']]);
|
||||
|
||||
$payload = bookingPayload($outboundRoute, $outboundSlot, [
|
||||
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
|
||||
]);
|
||||
$payload['is_round_trip'] = true;
|
||||
$payload['return_ev_route_id'] = $returnRoute->id;
|
||||
$payload['return_departure_time_slot_id'] = $returnSlot->id;
|
||||
$payload['return_travel_date'] = now()->addDays(3)->toDateString();
|
||||
$payload['return_selections'] = [['vehicle_option' => 'back_seat', 'passenger_count' => 1]];
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/bookings', $payload)
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.is_round_trip', true)
|
||||
->assertJsonPath('data.is_return_leg', false)
|
||||
->assertJsonPath('data.price', '9000.00')
|
||||
->assertJsonPath('data.linked_booking.is_return_leg', true)
|
||||
->assertJsonPath('data.linked_booking.route.id', $returnRoute->id)
|
||||
->assertJsonPath('data.linked_booking.vehicle_options.0.vehicle_option', 'back_seat')
|
||||
->assertJsonPath('data.linked_booking.vehicle_options.0.unit_price', '11000.00');
|
||||
|
||||
expect(Booking::count())->toBe(2);
|
||||
|
||||
$return = Booking::where('is_return_leg', true)->firstOrFail();
|
||||
expect($return->price)->toEqual('11000.00')
|
||||
->and($return->ev_route_id)->toBe($returnRoute->id);
|
||||
});
|
||||
|
||||
test('round trip: a return route that is not the reverse of the outbound route surfaces as 422', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
[$outboundRoute, $outboundSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '9000.00']]);
|
||||
[$unrelatedRoute, $unrelatedSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '9000.00']]);
|
||||
|
||||
$payload = bookingPayload($outboundRoute, $outboundSlot, [
|
||||
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
|
||||
]);
|
||||
$payload['is_round_trip'] = true;
|
||||
$payload['return_ev_route_id'] = $unrelatedRoute->id;
|
||||
$payload['return_departure_time_slot_id'] = $unrelatedSlot->id;
|
||||
$payload['return_travel_date'] = now()->addDays(3)->toDateString();
|
||||
$payload['return_selections'] = [['vehicle_option' => 'back_seat', 'passenger_count' => 1]];
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/bookings', $payload)
|
||||
->assertStatus(422);
|
||||
|
||||
expect(Booking::count())->toBe(0);
|
||||
});
|
||||
|
||||
test('round trip: return fields are required when is_round_trip is true', function () {
|
||||
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '15000.00']]);
|
||||
|
||||
$payload = bookingPayload($route, $timeSlot, [
|
||||
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
|
||||
]);
|
||||
$payload['is_round_trip'] = true;
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/bookings', $payload)
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors([
|
||||
'return_ev_route_id', 'return_departure_time_slot_id', 'return_travel_date', 'return_selections',
|
||||
]);
|
||||
});
|
||||
|
||||
test('round trip: return_travel_date before travel_date is rejected', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
[$outboundRoute, $outboundSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '9000.00']]);
|
||||
[$returnRoute, $returnSlot] = reverseRouteAndSlot($outboundRoute, [[VehicleOption::BackSeat, '9000.00']]);
|
||||
|
||||
$payload = bookingPayload($outboundRoute, $outboundSlot, [
|
||||
['vehicle_option' => 'back_seat', 'passenger_count' => 1],
|
||||
]);
|
||||
$payload['is_round_trip'] = true;
|
||||
$payload['return_ev_route_id'] = $returnRoute->id;
|
||||
$payload['return_departure_time_slot_id'] = $returnSlot->id;
|
||||
$payload['return_travel_date'] = now()->toDateString(); // before travel_date (addDay())
|
||||
$payload['return_selections'] = [['vehicle_option' => 'back_seat', 'passenger_count' => 1]];
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->postJson('/api/v1/bookings', $payload)
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors(['return_travel_date']);
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Booking\Policies\BookingPolicy;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
|
||||
@@ -10,16 +12,37 @@ beforeEach(function () {
|
||||
}
|
||||
});
|
||||
|
||||
test('viewAny and view require the view_bookings permission', function () {
|
||||
test('viewAny is open to any authenticated user — listing is scoped to their own bookings at the query level', function () {
|
||||
$policy = new BookingPolicy;
|
||||
|
||||
$withPermission = User::factory()->create()->givePermissionTo('view_bookings');
|
||||
$withoutPermission = User::factory()->create();
|
||||
expect($policy->viewAny(User::factory()->create()))->toBeTrue();
|
||||
});
|
||||
|
||||
expect($policy->viewAny($withPermission))->toBeTrue()
|
||||
->and($policy->view($withPermission, null))->toBeTrue()
|
||||
->and($policy->viewAny($withoutPermission))->toBeFalse()
|
||||
->and($policy->view($withoutPermission, null))->toBeFalse();
|
||||
test('view allows the booking\'s owner', function () {
|
||||
$policy = new BookingPolicy;
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$booking = Booking::factory()->create(['user_id' => $owner->id]);
|
||||
|
||||
expect($policy->view($owner, $booking))->toBeTrue();
|
||||
});
|
||||
|
||||
test('view rejects a non-owner without the view_bookings permission', function () {
|
||||
$policy = new BookingPolicy;
|
||||
|
||||
$stranger = User::factory()->create();
|
||||
$booking = Booking::factory()->create(['user_id' => User::factory()->create()->id]);
|
||||
|
||||
expect($policy->view($stranger, $booking))->toBeFalse();
|
||||
});
|
||||
|
||||
test('view allows a non-owner with the view_bookings permission (admin/support)', function () {
|
||||
$policy = new BookingPolicy;
|
||||
|
||||
$admin = User::factory()->create()->givePermissionTo('view_bookings');
|
||||
$booking = Booking::factory()->create(['user_id' => User::factory()->create()->id]);
|
||||
|
||||
expect($policy->view($admin, $booking))->toBeTrue();
|
||||
});
|
||||
|
||||
test('create is open to any authenticated user', function () {
|
||||
@@ -28,14 +51,31 @@ test('create is open to any authenticated user', function () {
|
||||
expect($policy->create(User::factory()->create()))->toBeTrue();
|
||||
});
|
||||
|
||||
test('cancel requires the manage_bookings permission', function () {
|
||||
test('cancel allows the booking\'s owner', function () {
|
||||
$policy = new BookingPolicy;
|
||||
|
||||
$withPermission = User::factory()->create()->givePermissionTo('manage_bookings');
|
||||
$withoutPermission = User::factory()->create();
|
||||
$owner = User::factory()->create();
|
||||
$booking = Booking::factory()->create(['user_id' => $owner->id]);
|
||||
|
||||
expect($policy->cancel($withPermission, null))->toBeTrue()
|
||||
->and($policy->cancel($withoutPermission, null))->toBeFalse();
|
||||
expect($policy->cancel($owner, $booking))->toBeTrue();
|
||||
});
|
||||
|
||||
test('cancel allows staff with the manage_bookings permission on someone else\'s booking', function () {
|
||||
$policy = new BookingPolicy;
|
||||
|
||||
$staff = User::factory()->create()->givePermissionTo('manage_bookings');
|
||||
$booking = Booking::factory()->create(['user_id' => User::factory()->create()->id]);
|
||||
|
||||
expect($policy->cancel($staff, $booking))->toBeTrue();
|
||||
});
|
||||
|
||||
test('cancel rejects a non-owner without the manage_bookings permission', function () {
|
||||
$policy = new BookingPolicy;
|
||||
|
||||
$stranger = User::factory()->create();
|
||||
$booking = Booking::factory()->create(['user_id' => User::factory()->create()->id]);
|
||||
|
||||
expect($policy->cancel($stranger, $booking))->toBeFalse();
|
||||
});
|
||||
|
||||
test('refund requires the process_refunds permission', function () {
|
||||
@@ -47,3 +87,53 @@ test('refund requires the process_refunds permission', function () {
|
||||
expect($policy->refund($withPermission, null))->toBeTrue()
|
||||
->and($policy->refund($withoutPermission, null))->toBeFalse();
|
||||
});
|
||||
|
||||
test('delete allows staff with manage_bookings on a cancelled booking', function () {
|
||||
$policy = new BookingPolicy;
|
||||
|
||||
$staff = User::factory()->create()->givePermissionTo('manage_bookings');
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Cancelled]);
|
||||
|
||||
expect($policy->delete($staff, $booking))->toBeTrue();
|
||||
});
|
||||
|
||||
test('delete allows staff with manage_bookings on an expired booking', function () {
|
||||
$policy = new BookingPolicy;
|
||||
|
||||
$staff = User::factory()->create()->givePermissionTo('manage_bookings');
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Expired]);
|
||||
|
||||
expect($policy->delete($staff, $booking))->toBeTrue();
|
||||
});
|
||||
|
||||
test('delete rejects a pending_payment or confirmed booking even with manage_bookings', function () {
|
||||
$policy = new BookingPolicy;
|
||||
|
||||
$staff = User::factory()->create()->givePermissionTo('manage_bookings');
|
||||
|
||||
$pending = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
$confirmed = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
expect($policy->delete($staff, $pending))->toBeFalse()
|
||||
->and($policy->delete($staff, $confirmed))->toBeFalse();
|
||||
});
|
||||
|
||||
test('delete rejects a cancelled booking without manage_bookings, even for the owner', function () {
|
||||
$policy = new BookingPolicy;
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$booking = Booking::factory()->create(['user_id' => $owner->id, 'status' => BookingStatus::Cancelled]);
|
||||
|
||||
expect($policy->delete($owner, $booking))->toBeFalse();
|
||||
});
|
||||
|
||||
test('restore requires the manage_bookings permission', function () {
|
||||
$policy = new BookingPolicy;
|
||||
|
||||
$staff = User::factory()->create()->givePermissionTo('manage_bookings');
|
||||
$stranger = User::factory()->create();
|
||||
$booking = Booking::factory()->create();
|
||||
|
||||
expect($policy->restore($staff, $booking))->toBeTrue()
|
||||
->and($policy->restore($stranger, $booking))->toBeFalse();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
<?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 () {
|
||||
Permission::findOrCreate('view_bookings', 'web');
|
||||
|
||||
$this->owner = User::factory()->create();
|
||||
$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 = 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')
|
||||
->assertSuccessful()
|
||||
->assertJsonCount(2, 'data')
|
||||
->assertJsonPath('data.0.id', $mineNewer->id)
|
||||
->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();
|
||||
});
|
||||
|
||||
test('show allows the owner to view their own booking', function () {
|
||||
$booking = Booking::factory()->create(['user_id' => $this->owner->id]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson("/api/v1/bookings/{$booking->booking_ref}")
|
||||
->assertSuccessful()
|
||||
->assertJsonPath('data.id', $booking->id);
|
||||
});
|
||||
|
||||
test('show rejects a non-owner without the view_bookings permission', function () {
|
||||
$booking = Booking::factory()->create(['user_id' => User::factory()->create()->id]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson("/api/v1/bookings/{$booking->booking_ref}")
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('show allows an admin/support user (view_bookings permission) to view someone else\'s booking', function () {
|
||||
$admin = User::factory()->create();
|
||||
$admin->givePermissionTo('view_bookings');
|
||||
$adminToken = $admin->createToken('admin-token')->plainTextToken;
|
||||
|
||||
$booking = Booking::factory()->create(['user_id' => $this->owner->id]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$adminToken}")
|
||||
->getJson("/api/v1/bookings/{$booking->booking_ref}")
|
||||
->assertSuccessful()
|
||||
->assertJsonPath('data.id', $booking->id);
|
||||
});
|
||||
|
||||
test('show rejects unauthenticated requests', function () {
|
||||
$booking = Booking::factory()->create();
|
||||
|
||||
$this->getJson("/api/v1/bookings/{$booking->id}")->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('show 404s for a booking that does not exist', function () {
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson('/api/v1/bookings/EVB-DOES-NOT-EXIST')
|
||||
->assertNotFound();
|
||||
});
|
||||
@@ -0,0 +1,386 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Pages\ListBookings;
|
||||
use Modules\Booking\Filament\Resources\Bookings\Pages\ViewBooking;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Booking\Models\BookingVehicleOption;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Enums\PaymentStatus;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
|
||||
beforeEach(function () {
|
||||
foreach (['view_bookings', 'manage_bookings', 'process_refunds'] as $permission) {
|
||||
Permission::findOrCreate($permission, 'web');
|
||||
}
|
||||
|
||||
$this->admin = User::factory()->create()->givePermissionTo(['view_bookings', 'manage_bookings', 'process_refunds']);
|
||||
$this->actingAs($this->admin);
|
||||
});
|
||||
|
||||
test('can list bookings', function () {
|
||||
$bookings = Booking::factory()->count(3)->create();
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertOk()
|
||||
->assertCanSeeTableRecords($bookings);
|
||||
});
|
||||
|
||||
test('can filter bookings by status', function () {
|
||||
$pending = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
$confirmed = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->filterTable('status', BookingStatus::PendingPayment->value)
|
||||
->assertCanSeeTableRecords([$pending])
|
||||
->assertCanNotSeeTableRecords([$confirmed]);
|
||||
});
|
||||
|
||||
test('the cancel action is visible and enabled for a pending_payment booking', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionVisible('cancel', $booking)
|
||||
->assertTableActionEnabled('cancel', $booking);
|
||||
});
|
||||
|
||||
test('the cancel action is visible but disabled for a confirmed booking', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionVisible('cancel', $booking)
|
||||
->assertTableActionDisabled('cancel', $booking);
|
||||
});
|
||||
|
||||
test('the cancel action is hidden from a user without manage_bookings and not the owner', function () {
|
||||
$stranger = User::factory()->create();
|
||||
$this->actingAs($stranger);
|
||||
|
||||
$booking = Booking::factory()->create(['user_id' => User::factory()->create()->id, 'status' => BookingStatus::PendingPayment]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionHidden('cancel', $booking);
|
||||
});
|
||||
|
||||
test('calling the cancel action cancels a pending_payment booking', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->callTableAction('cancel', $booking)
|
||||
->assertNotified();
|
||||
|
||||
expect($booking->refresh()->status)->toBe(BookingStatus::Cancelled);
|
||||
});
|
||||
|
||||
test('the view action is visible for a user with view_bookings', function () {
|
||||
$booking = Booking::factory()->create();
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionVisible('view', $booking);
|
||||
});
|
||||
|
||||
test('the view action is hidden from a non-owner without view_bookings', function () {
|
||||
$stranger = User::factory()->create();
|
||||
$this->actingAs($stranger);
|
||||
|
||||
$booking = Booking::factory()->create(['user_id' => User::factory()->create()->id]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionHidden('view', $booking);
|
||||
});
|
||||
|
||||
test('can view a booking\'s detail page', function () {
|
||||
$booking = Booking::factory()->create([
|
||||
'passenger_name' => 'Jane Doe',
|
||||
'passenger_phone' => '+959123456789',
|
||||
]);
|
||||
|
||||
BookingVehicleOption::factory()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'vehicle_option' => VehicleOption::BackSeat,
|
||||
'passenger_count' => 2,
|
||||
'unit_price' => 9000,
|
||||
'line_total' => 18000,
|
||||
]);
|
||||
|
||||
Livewire::test(ViewBooking::class, ['record' => $booking->getRouteKey()])
|
||||
->assertOk()
|
||||
->assertSee($booking->booking_ref)
|
||||
->assertSee('Jane Doe')
|
||||
->assertSee('+959123456789')
|
||||
->assertSee($booking->route->company->name)
|
||||
->assertSee($booking->pickup_address)
|
||||
->assertSee($booking->dropoff_address);
|
||||
});
|
||||
|
||||
test('the booking detail page shows its related payments', function () {
|
||||
$booking = Booking::factory()->create();
|
||||
|
||||
Payment::factory()->completed()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'gateway' => PaymentMethod::KbzMiniApp,
|
||||
'gateway_transaction_id' => 'EVB-INFOLIST-TEST-1',
|
||||
]);
|
||||
|
||||
Livewire::test(ViewBooking::class, ['record' => $booking->getRouteKey()])
|
||||
->assertOk()
|
||||
->assertSee('EVB-INFOLIST-TEST-1')
|
||||
->assertSee(PaymentStatus::Completed->value);
|
||||
});
|
||||
|
||||
test('the booking detail page shows a placeholder when there are no payments yet', function () {
|
||||
$booking = Booking::factory()->create();
|
||||
|
||||
Livewire::test(ViewBooking::class, ['record' => $booking->getRouteKey()])
|
||||
->assertOk()
|
||||
->assertSee('No payment attempts yet.');
|
||||
});
|
||||
|
||||
test('the assign driver action is visible for a confirmed booking and hidden otherwise', function () {
|
||||
$confirmed = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
$pending = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionVisible('assignDriver', $confirmed)
|
||||
->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);
|
||||
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionHidden('assignDriver', $booking);
|
||||
});
|
||||
|
||||
test('calling the assign driver action sets driver and car details on a confirmed booking', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->callTableAction('assignDriver', $booking, data: [
|
||||
'driver_name' => 'U Aung',
|
||||
'driver_phone' => '+959111222333',
|
||||
'car_plate_number' => 'YGN-1234',
|
||||
'car_model' => 'Tesla Model Y',
|
||||
])
|
||||
->assertNotified();
|
||||
|
||||
$booking->refresh();
|
||||
|
||||
expect($booking->driver_name)->toBe('U Aung')
|
||||
->and($booking->driver_phone)->toBe('+959111222333')
|
||||
->and($booking->car_plate_number)->toBe('YGN-1234')
|
||||
->and($booking->car_model)->toBe('Tesla Model Y');
|
||||
});
|
||||
|
||||
test('the assign driver form requires driver_name, driver_phone, and car_plate_number', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->callTableAction('assignDriver', $booking, data: [
|
||||
'driver_name' => '',
|
||||
'driver_phone' => '',
|
||||
'car_plate_number' => '',
|
||||
])
|
||||
->assertHasTableActionErrors(['driver_name' => 'required', 'driver_phone' => 'required', 'car_plate_number' => 'required']);
|
||||
|
||||
expect($booking->refresh()->driver_name)->toBeNull();
|
||||
});
|
||||
|
||||
test('the assign driver form is pre-filled with the booking\'s existing driver/car details', function () {
|
||||
$booking = Booking::factory()->create([
|
||||
'status' => BookingStatus::Confirmed,
|
||||
'driver_name' => 'U Aung',
|
||||
'driver_phone' => '+959111222333',
|
||||
'car_plate_number' => 'YGN-1234',
|
||||
'car_model' => 'Tesla Model Y',
|
||||
]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->mountTableAction('assignDriver', $booking)
|
||||
->assertTableActionDataSet([
|
||||
'driver_name' => 'U Aung',
|
||||
'driver_phone' => '+959111222333',
|
||||
'car_plate_number' => 'YGN-1234',
|
||||
'car_model' => 'Tesla Model Y',
|
||||
]);
|
||||
});
|
||||
|
||||
test('the detail page also has assign driver and cancel actions, shared with the table', function () {
|
||||
$confirmed = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
Livewire::test(ViewBooking::class, ['record' => $confirmed->getRouteKey()])
|
||||
->assertActionVisible('assignDriver')
|
||||
->assertActionVisible('cancel')
|
||||
->assertActionDisabled('cancel');
|
||||
});
|
||||
|
||||
test('calling assign driver from the detail page sets driver and car details', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
Livewire::test(ViewBooking::class, ['record' => $booking->getRouteKey()])
|
||||
->callAction('assignDriver', data: [
|
||||
'driver_name' => 'U Aung',
|
||||
'driver_phone' => '+959111222333',
|
||||
'car_plate_number' => 'YGN-1234',
|
||||
'car_model' => 'Tesla Model Y',
|
||||
])
|
||||
->assertNotified();
|
||||
|
||||
expect($booking->refresh()->driver_name)->toBe('U Aung');
|
||||
});
|
||||
|
||||
test('the detail page\'s assign driver action is hidden for a pending_payment booking', function () {
|
||||
$pending = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
|
||||
Livewire::test(ViewBooking::class, ['record' => $pending->getRouteKey()])
|
||||
->assertActionHidden('assignDriver')
|
||||
->assertActionEnabled('cancel');
|
||||
});
|
||||
|
||||
test('the delete action is hidden for a pending_payment or confirmed booking, even with manage_bookings', function () {
|
||||
$pending = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
$confirmed = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
// authorize('delete') ties visibility straight to BookingPolicy::delete
|
||||
// (status + permission combined) — a non-terminal booking never shows
|
||||
// this button at all, rather than a dead disabled one.
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionHidden('delete', $pending)
|
||||
->assertTableActionHidden('delete', $confirmed);
|
||||
});
|
||||
|
||||
test('the delete action is visible and enabled for a cancelled or expired booking', function () {
|
||||
$cancelled = Booking::factory()->create(['status' => BookingStatus::Cancelled]);
|
||||
$expired = Booking::factory()->create(['status' => BookingStatus::Expired]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionVisible('delete', $cancelled)
|
||||
->assertTableActionEnabled('delete', $cancelled)
|
||||
->assertTableActionVisible('delete', $expired)
|
||||
->assertTableActionEnabled('delete', $expired);
|
||||
});
|
||||
|
||||
test('the delete action is hidden from a user without manage_bookings', function () {
|
||||
$stranger = User::factory()->create();
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Cancelled]);
|
||||
|
||||
$this->actingAs($stranger);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionHidden('delete', $booking);
|
||||
});
|
||||
|
||||
test('deleting a cancelled booking soft-deletes it', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Cancelled]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->callTableAction('delete', $booking)
|
||||
->assertSuccessful();
|
||||
|
||||
expect(Booking::find($booking->id))->toBeNull();
|
||||
expect(Booking::withTrashed()->find($booking->id))->not->toBeNull();
|
||||
expect(Booking::withTrashed()->find($booking->id)->trashed())->toBeTrue();
|
||||
});
|
||||
|
||||
test('a soft-deleted booking is hidden from the default list but visible via the trashed filter', function () {
|
||||
$active = Booking::factory()->create();
|
||||
$deleted = Booking::factory()->create();
|
||||
$deleted->delete();
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertCanSeeTableRecords([$active])
|
||||
->assertCanNotSeeTableRecords([$deleted])
|
||||
->filterTable('trashed', true)
|
||||
->assertCanSeeTableRecords([$active, $deleted]);
|
||||
});
|
||||
|
||||
test('the restore action is only visible for a trashed booking', function () {
|
||||
$active = Booking::factory()->create();
|
||||
$deleted = Booking::factory()->create();
|
||||
$deleted->delete();
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->filterTable('trashed', true)
|
||||
->assertTableActionHidden('restore', $active)
|
||||
->assertTableActionVisible('restore', $deleted);
|
||||
});
|
||||
|
||||
test('restoring a deleted booking brings it back', function () {
|
||||
$booking = Booking::factory()->create();
|
||||
$booking->delete();
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->filterTable('trashed', true)
|
||||
->callTableAction('restore', $booking)
|
||||
->assertSuccessful();
|
||||
|
||||
expect(Booking::find($booking->id))->not->toBeNull();
|
||||
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();
|
||||
$booking->delete();
|
||||
|
||||
$this->actingAs($stranger);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->filterTable('trashed', true)
|
||||
->assertTableActionHidden('restore', $booking);
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\QueryException;
|
||||
use Modules\Booking\Enums\BookingChannel;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Booking\Models\BookingVehicleOption;
|
||||
use Modules\Catalog\Models\DepartureTimeSlot;
|
||||
use Modules\Routing\Models\EvRoute;
|
||||
use Modules\Routing\Models\RoutePricing;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
|
||||
test('a booking belongs to a route and a time slot', function () {
|
||||
$route = EvRoute::factory()->create();
|
||||
$timeSlot = DepartureTimeSlot::factory()->create();
|
||||
|
||||
$booking = Booking::factory()->create([
|
||||
'ev_route_id' => $route->id,
|
||||
'departure_time_slot_id' => $timeSlot->id,
|
||||
]);
|
||||
|
||||
expect($booking->route)->toBeInstanceOf(EvRoute::class)
|
||||
->and($booking->route->is($route))->toBeTrue()
|
||||
->and($booking->timeSlot)->toBeInstanceOf(DepartureTimeSlot::class)
|
||||
->and($booking->timeSlot->is($timeSlot))->toBeTrue();
|
||||
});
|
||||
|
||||
test('booking_ref is unique', function () {
|
||||
Booking::factory()->create(['booking_ref' => 'EVB-DUPLICATE']);
|
||||
|
||||
expect(fn () => Booking::factory()->create(['booking_ref' => 'EVB-DUPLICATE']))
|
||||
->toThrow(QueryException::class);
|
||||
});
|
||||
|
||||
test('status and created_by_channel cast to their enums', function () {
|
||||
$booking = Booking::factory()->create([
|
||||
'status' => BookingStatus::Confirmed,
|
||||
'created_by_channel' => BookingChannel::Android,
|
||||
]);
|
||||
|
||||
expect($booking->status)->toBe(BookingStatus::Confirmed)
|
||||
->and($booking->created_by_channel)->toBe(BookingChannel::Android);
|
||||
});
|
||||
|
||||
test('a booking defaults to pending_payment', function () {
|
||||
$booking = Booking::factory()->create();
|
||||
|
||||
expect($booking->status)->toBe(BookingStatus::PendingPayment);
|
||||
});
|
||||
|
||||
test('a booking can have multiple vehicle option lines, e.g. front seat and back seat together', function () {
|
||||
$booking = Booking::factory()->create();
|
||||
|
||||
BookingVehicleOption::factory()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'vehicle_option' => VehicleOption::FrontSeat,
|
||||
'passenger_count' => 1,
|
||||
'unit_price' => 12000,
|
||||
'line_total' => 12000,
|
||||
]);
|
||||
BookingVehicleOption::factory()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'vehicle_option' => VehicleOption::BackSeat,
|
||||
'passenger_count' => 2,
|
||||
'unit_price' => 9000,
|
||||
'line_total' => 18000,
|
||||
]);
|
||||
|
||||
expect($booking->vehicleOptions)->toHaveCount(2)
|
||||
->and($booking->vehicleOptions->pluck('vehicle_option')->map(fn ($option) => $option->value)->sort()->values()->all())
|
||||
->toEqual(['back_seat', 'front_seat']);
|
||||
});
|
||||
|
||||
test('a vehicle option line cannot be duplicated on the same booking', function () {
|
||||
$booking = Booking::factory()->create();
|
||||
|
||||
BookingVehicleOption::factory()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'vehicle_option' => VehicleOption::BackSeat,
|
||||
]);
|
||||
|
||||
expect(fn () => BookingVehicleOption::factory()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'vehicle_option' => VehicleOption::BackSeat,
|
||||
]))->toThrow(QueryException::class);
|
||||
});
|
||||
|
||||
test('price is snapshotted onto the booking and does not change when RoutePricing is edited later', function () {
|
||||
$route = EvRoute::factory()->create();
|
||||
|
||||
$pricing = RoutePricing::factory()->create([
|
||||
'ev_route_id' => $route->id,
|
||||
'vehicle_option' => VehicleOption::BackSeat,
|
||||
'price' => 15000,
|
||||
]);
|
||||
|
||||
$booking = Booking::factory()->create([
|
||||
'ev_route_id' => $route->id,
|
||||
'price' => $pricing->price,
|
||||
]);
|
||||
|
||||
BookingVehicleOption::factory()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'vehicle_option' => VehicleOption::BackSeat,
|
||||
'unit_price' => $pricing->price,
|
||||
'line_total' => $pricing->price,
|
||||
]);
|
||||
|
||||
$pricing->update(['price' => 25000]);
|
||||
|
||||
expect($booking->refresh()->price)->toEqual('15000.00')
|
||||
->and($booking->vehicleOptions()->first()->unit_price)->toEqual('15000.00')
|
||||
->and($pricing->refresh()->price)->toEqual('25000.00');
|
||||
});
|
||||
|
||||
test('a booking can have a user or be guest-checked-out via mini app openid', function () {
|
||||
$guestBooking = Booking::factory()->create([
|
||||
'user_id' => null,
|
||||
'openid' => 'mini-app-openid-123',
|
||||
]);
|
||||
|
||||
expect($guestBooking->user_id)->toBeNull()
|
||||
->and($guestBooking->openid)->toBe('mini-app-openid-123');
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Filament\Widgets\BookingsTodayWidget;
|
||||
use Modules\Booking\Models\Booking;
|
||||
|
||||
test('it counts todays trips by status, ignoring other days', function () {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
Booking::factory()->create(['travel_date' => today(), 'status' => BookingStatus::Confirmed]);
|
||||
Booking::factory()->create(['travel_date' => today(), 'status' => BookingStatus::PendingPayment]);
|
||||
Booking::factory()->create(['travel_date' => today()->addDay(), 'status' => BookingStatus::Confirmed]);
|
||||
|
||||
Livewire::test(BookingsTodayWidget::class)
|
||||
->assertOk()
|
||||
->assertSee('Trips Today')
|
||||
->assertSee('2')
|
||||
->assertSee('Confirmed')
|
||||
->assertSee('Awaiting Payment');
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Modules\Booking\Actions\CreateBookingAction;
|
||||
use Modules\Booking\Data\CreateBookingData;
|
||||
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;
|
||||
|
||||
/**
|
||||
* @param array<int, array{0: VehicleOption, 1: string}> $pricedOptions
|
||||
*/
|
||||
function makeBookableRoute(array $pricedOptions): array
|
||||
{
|
||||
$route = EvRoute::factory()->create();
|
||||
$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];
|
||||
}
|
||||
|
||||
function bookingData(EvRoute $route, DepartureTimeSlot $timeSlot, array $selections, array $roundTrip = []): CreateBookingData
|
||||
{
|
||||
return new CreateBookingData(
|
||||
evRouteId: $route->id,
|
||||
departureTimeSlotId: $timeSlot->id,
|
||||
travelDate: now()->addDay()->toDateString(),
|
||||
selections: $selections,
|
||||
passengerName: 'Jane Doe',
|
||||
passengerPhone: '+959123456789',
|
||||
pickupAddress: '123 Pickup St',
|
||||
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]);
|
||||
|
||||
[$route, $timeSlot] = makeBookableRoute([[VehicleOption::BackSeat, '15000.00']]);
|
||||
|
||||
$booking = app(CreateBookingAction::class)->handle(
|
||||
bookingData($route, $timeSlot, [new VehicleSelectionData(VehicleOption::BackSeat)])
|
||||
);
|
||||
|
||||
expect($booking->exists)->toBeTrue()
|
||||
->and($booking->booking_ref)->toBe('EVB-AAAAA1')
|
||||
->and($booking->status)->toBe(BookingStatus::PendingPayment)
|
||||
->and($booking->price)->toEqual('15000.00')
|
||||
->and($booking->ev_route_id)->toBe($route->id)
|
||||
->and($booking->departure_time_slot_id)->toBe($timeSlot->id)
|
||||
->and($booking->openid)->toBe('mini-app-openid-123')
|
||||
->and($booking->vehicleOptions)->toHaveCount(1)
|
||||
->and($booking->vehicleOptions->first()->vehicle_option)->toBe(VehicleOption::BackSeat)
|
||||
->and($booking->vehicleOptions->first()->unit_price)->toEqual('15000.00');
|
||||
});
|
||||
|
||||
test('it books front seat and back seat together and sums the price across both lines', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
[$route, $timeSlot] = makeBookableRoute([
|
||||
[VehicleOption::FrontSeat, '12000.00'],
|
||||
[VehicleOption::BackSeat, '9000.00'],
|
||||
]);
|
||||
|
||||
$booking = app(CreateBookingAction::class)->handle(bookingData($route, $timeSlot, [
|
||||
new VehicleSelectionData(VehicleOption::FrontSeat, 1),
|
||||
new VehicleSelectionData(VehicleOption::BackSeat, 2),
|
||||
]));
|
||||
|
||||
expect($booking->price)->toEqual('30000.00') // 12000 + (9000 * 2)
|
||||
->and($booking->vehicleOptions)->toHaveCount(2);
|
||||
|
||||
$frontSeatLine = $booking->vehicleOptions->firstWhere('vehicle_option', VehicleOption::FrontSeat);
|
||||
$backSeatLine = $booking->vehicleOptions->firstWhere('vehicle_option', VehicleOption::BackSeat);
|
||||
|
||||
expect($frontSeatLine->passenger_count)->toBe(1)
|
||||
->and($frontSeatLine->line_total)->toEqual('12000.00')
|
||||
->and($backSeatLine->passenger_count)->toBe(2)
|
||||
->and($backSeatLine->line_total)->toEqual('18000.00');
|
||||
});
|
||||
|
||||
test('it dispatches BookingCreated', function () {
|
||||
Event::fake([BookingCreated::class]);
|
||||
config(['booking.whole_vehicle_enabled' => true]);
|
||||
|
||||
[$route, $timeSlot] = makeBookableRoute([[VehicleOption::WholeVehicle, '30000.00']]);
|
||||
|
||||
$booking = app(CreateBookingAction::class)->handle(
|
||||
bookingData($route, $timeSlot, [new VehicleSelectionData(VehicleOption::WholeVehicle)])
|
||||
);
|
||||
|
||||
Event::assertDispatched(BookingCreated::class, fn (BookingCreated $event) => $event->booking->is($booking));
|
||||
});
|
||||
|
||||
test('it rejects a disabled vehicle option before touching the database', function () {
|
||||
config(['booking.whole_vehicle_enabled' => false]);
|
||||
|
||||
[$route, $timeSlot] = makeBookableRoute([[VehicleOption::WholeVehicle, '30000.00']]);
|
||||
|
||||
expect(fn () => app(CreateBookingAction::class)->handle(
|
||||
bookingData($route, $timeSlot, [new VehicleSelectionData(VehicleOption::WholeVehicle)])
|
||||
))->toThrow(InvalidVehicleSelectionException::class);
|
||||
|
||||
expect(Booking::count())->toBe(0);
|
||||
});
|
||||
|
||||
test('it rejects mixing whole vehicle with another option before touching the database', function () {
|
||||
config([
|
||||
'booking.back_seat_enabled' => true,
|
||||
'booking.whole_vehicle_enabled' => true,
|
||||
]);
|
||||
|
||||
[$route, $timeSlot] = makeBookableRoute([
|
||||
[VehicleOption::WholeVehicle, '30000.00'],
|
||||
[VehicleOption::BackSeat, '9000.00'],
|
||||
]);
|
||||
|
||||
expect(fn () => app(CreateBookingAction::class)->handle(bookingData($route, $timeSlot, [
|
||||
new VehicleSelectionData(VehicleOption::WholeVehicle),
|
||||
new VehicleSelectionData(VehicleOption::BackSeat),
|
||||
])))->toThrow(InvalidVehicleSelectionException::class);
|
||||
|
||||
expect(Booking::count())->toBe(0);
|
||||
});
|
||||
|
||||
test('each booking created gets a unique, sequential booking_ref', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
[$route, $timeSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
|
||||
|
||||
$first = app(CreateBookingAction::class)->handle(bookingData($route, $timeSlot, [new VehicleSelectionData(VehicleOption::BackSeat)]));
|
||||
$second = app(CreateBookingAction::class)->handle(bookingData($route, $timeSlot, [new VehicleSelectionData(VehicleOption::BackSeat)]));
|
||||
|
||||
expect($first->booking_ref)->toBe('EVB-AAAAA1')
|
||||
->and($second->booking_ref)->toBe('EVB-AAAAA2');
|
||||
});
|
||||
|
||||
test('a plain one-way booking has no linked leg', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
[$route, $timeSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
|
||||
|
||||
$booking = app(CreateBookingAction::class)->handle(
|
||||
bookingData($route, $timeSlot, [new VehicleSelectionData(VehicleOption::BackSeat)])
|
||||
);
|
||||
|
||||
expect($booking->linked_booking_id)->toBeNull()
|
||||
->and($booking->is_round_trip)->toBeFalse()
|
||||
->and($booking->is_return_leg)->toBeFalse()
|
||||
->and(Booking::count())->toBe(1);
|
||||
});
|
||||
|
||||
test('a round trip creates two bookings linked bidirectionally, each priced independently', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
[$outboundRoute, $outboundSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
|
||||
[$returnRoute, $returnSlot] = makeReverseRoute($outboundRoute, [[VehicleOption::BackSeat, '11000.00']]);
|
||||
|
||||
$outbound = app(CreateBookingAction::class)->handle(bookingData(
|
||||
$outboundRoute,
|
||||
$outboundSlot,
|
||||
[new VehicleSelectionData(VehicleOption::BackSeat)],
|
||||
roundTrip: [
|
||||
'route' => $returnRoute,
|
||||
'timeSlot' => $returnSlot,
|
||||
'selections' => [new VehicleSelectionData(VehicleOption::BackSeat)],
|
||||
],
|
||||
));
|
||||
|
||||
expect(Booking::count())->toBe(2)
|
||||
->and($outbound->is_return_leg)->toBeFalse()
|
||||
->and($outbound->is_round_trip)->toBeTrue()
|
||||
->and($outbound->price)->toEqual('9000.00');
|
||||
|
||||
$return = $outbound->linkedBooking;
|
||||
|
||||
expect($return)->not->toBeNull()
|
||||
->and($return->is_return_leg)->toBeTrue()
|
||||
->and($return->is_round_trip)->toBeTrue()
|
||||
->and($return->linked_booking_id)->toBe($outbound->id)
|
||||
->and($return->ev_route_id)->toBe($returnRoute->id)
|
||||
->and($return->departure_time_slot_id)->toBe($returnSlot->id)
|
||||
->and($return->price)->toEqual('11000.00');
|
||||
});
|
||||
|
||||
test('a round trip dispatches BookingCreated for both legs', function () {
|
||||
Event::fake([BookingCreated::class]);
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
[$outboundRoute, $outboundSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
|
||||
[$returnRoute, $returnSlot] = makeReverseRoute($outboundRoute, [[VehicleOption::BackSeat, '9000.00']]);
|
||||
|
||||
$outbound = app(CreateBookingAction::class)->handle(bookingData(
|
||||
$outboundRoute,
|
||||
$outboundSlot,
|
||||
[new VehicleSelectionData(VehicleOption::BackSeat)],
|
||||
roundTrip: [
|
||||
'route' => $returnRoute,
|
||||
'timeSlot' => $returnSlot,
|
||||
'selections' => [new VehicleSelectionData(VehicleOption::BackSeat)],
|
||||
],
|
||||
));
|
||||
|
||||
Event::assertDispatched(BookingCreated::class, 2);
|
||||
Event::assertDispatched(BookingCreated::class, fn (BookingCreated $event) => $event->booking->is($outbound));
|
||||
Event::assertDispatched(BookingCreated::class, fn (BookingCreated $event) => $event->booking->is($outbound->linkedBooking));
|
||||
});
|
||||
|
||||
test('it rejects a return route that is not the reverse of the outbound route', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
[$outboundRoute, $outboundSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
|
||||
// Unrelated route — not from/to swapped.
|
||||
[$unrelatedRoute, $unrelatedSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
|
||||
|
||||
expect(fn () => app(CreateBookingAction::class)->handle(bookingData(
|
||||
$outboundRoute,
|
||||
$outboundSlot,
|
||||
[new VehicleSelectionData(VehicleOption::BackSeat)],
|
||||
roundTrip: [
|
||||
'route' => $unrelatedRoute,
|
||||
'timeSlot' => $unrelatedSlot,
|
||||
'selections' => [new VehicleSelectionData(VehicleOption::BackSeat)],
|
||||
],
|
||||
)))->toThrow(InvalidReturnRouteException::class);
|
||||
|
||||
// The whole transaction rolls back — no orphan outbound-only booking.
|
||||
expect(Booking::count())->toBe(0);
|
||||
});
|
||||
|
||||
test('return leg selections are validated independently of the outbound leg', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
[$outboundRoute, $outboundSlot] = makeBookableRoute([[VehicleOption::FrontSeat, '12000.00']]);
|
||||
[$returnRoute, $returnSlot] = makeReverseRoute($outboundRoute, [[VehicleOption::FrontSeat, '12000.00']]);
|
||||
|
||||
expect(fn () => app(CreateBookingAction::class)->handle(bookingData(
|
||||
$outboundRoute,
|
||||
$outboundSlot,
|
||||
[new VehicleSelectionData(VehicleOption::FrontSeat, 1)],
|
||||
roundTrip: [
|
||||
'route' => $returnRoute,
|
||||
'timeSlot' => $returnSlot,
|
||||
// Front seat max per booking is 1 — this should fail validation
|
||||
// for the return leg even though the outbound leg is valid.
|
||||
'selections' => [new VehicleSelectionData(VehicleOption::FrontSeat, 2)],
|
||||
],
|
||||
)))->toThrow(InvalidVehicleSelectionException::class);
|
||||
|
||||
expect(Booking::count())->toBe(0);
|
||||
});
|
||||
|
||||
test('a failed return-leg price lookup rolls back the outbound leg too', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
[$outboundRoute, $outboundSlot] = makeBookableRoute([[VehicleOption::BackSeat, '9000.00']]);
|
||||
// Return route exists (true reverse) but has no pricing rows at all.
|
||||
$returnRoute = EvRoute::factory()->create([
|
||||
'ev_company_id' => $outboundRoute->ev_company_id,
|
||||
'from_destination_id' => $outboundRoute->to_destination_id,
|
||||
'to_destination_id' => $outboundRoute->from_destination_id,
|
||||
]);
|
||||
$returnSlot = DepartureTimeSlot::factory()->create();
|
||||
|
||||
expect(fn () => app(CreateBookingAction::class)->handle(bookingData(
|
||||
$outboundRoute,
|
||||
$outboundSlot,
|
||||
[new VehicleSelectionData(VehicleOption::BackSeat)],
|
||||
roundTrip: [
|
||||
'route' => $returnRoute,
|
||||
'timeSlot' => $returnSlot,
|
||||
'selections' => [new VehicleSelectionData(VehicleOption::BackSeat)],
|
||||
],
|
||||
)))->toThrow(RoutePricingNotFoundException::class);
|
||||
|
||||
expect(Booking::count())->toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
use Firebase\JWT\JWT;
|
||||
use Modules\Booking\Enums\BookingChannel;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Catalog\Models\DepartureTimeSlot;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use Modules\Routing\Models\EvRoute;
|
||||
use Modules\Routing\Models\RoutePricing;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
|
||||
beforeEach(function () {
|
||||
config(['services.fastapi_agent.jwt_secret' => 'test-fastapi-agent-secret-0123456789ABCDEF']);
|
||||
config(['services.fastapi_agent.jwt_algorithm' => 'HS256']);
|
||||
});
|
||||
|
||||
function fastApiAgentToken(string $openid): string
|
||||
{
|
||||
return JWT::encode([
|
||||
'sub' => $openid,
|
||||
'iat' => time(),
|
||||
'exp' => time() + 3600,
|
||||
], 'test-fastapi-agent-secret-0123456789ABCDEF', 'HS256');
|
||||
}
|
||||
|
||||
test('a FastAPI JWT booking is stored against the verified openid, ignoring a spoofed body value', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
$route = EvRoute::factory()->create(['is_active' => true]);
|
||||
$timeSlot = DepartureTimeSlot::factory()->create();
|
||||
$route->timeSlots()->attach($timeSlot->id, ['is_active' => true]);
|
||||
RoutePricing::factory()->create([
|
||||
'ev_route_id' => $route->id,
|
||||
'vehicle_option' => VehicleOption::BackSeat,
|
||||
'price' => '15000.00',
|
||||
]);
|
||||
|
||||
$token = fastApiAgentToken('real-customer-openid');
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$token}")
|
||||
->withHeader('Device-Type', 'android') // the agent's own channel always wins, ignored here.
|
||||
->postJson('/api/v1/bookings', [
|
||||
'ev_route_id' => $route->id,
|
||||
'departure_time_slot_id' => $timeSlot->id,
|
||||
'travel_date' => now()->addDay()->toDateString(),
|
||||
'selections' => [['vehicle_option' => 'back_seat', 'passenger_count' => 1]],
|
||||
'passenger_name' => 'Jane Doe',
|
||||
'passenger_phone' => '+959123456789',
|
||||
'pickup_address' => '123 Pickup St',
|
||||
'dropoff_address' => '456 Dropoff Ave',
|
||||
'openid' => 'spoofed-openid',
|
||||
])
|
||||
->assertCreated();
|
||||
|
||||
$booking = Booking::sole();
|
||||
expect($booking->openid)->toBe('real-customer-openid')
|
||||
->and($booking->user_id)->toBeNull()
|
||||
->and($booking->created_by_channel)->toBe(BookingChannel::Agent);
|
||||
});
|
||||
|
||||
test('a FastAPI JWT can list and show only its own openid\'s bookings', function () {
|
||||
$mine = Booking::factory()->create(['openid' => 'agent-openid-mine']);
|
||||
Payment::factory()->completed()->create(['booking_id' => $mine->id, 'gateway' => PaymentMethod::KbzMiniApp]);
|
||||
Booking::factory()->create(['openid' => 'agent-openid-someone-else']);
|
||||
|
||||
$token = fastApiAgentToken('agent-openid-mine');
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson('/api/v1/bookings')
|
||||
->assertSuccessful()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.id', $mine->id);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson("/api/v1/bookings/{$mine->booking_ref}")
|
||||
->assertSuccessful()
|
||||
->assertJsonPath('data.id', $mine->id);
|
||||
});
|
||||
|
||||
test('a FastAPI JWT gets a 404 for a booking belonging to a different openid', function () {
|
||||
$someoneElses = Booking::factory()->create(['openid' => 'agent-openid-someone-else']);
|
||||
|
||||
$token = fastApiAgentToken('agent-openid-mine');
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$token}")
|
||||
->getJson("/api/v1/bookings/{$someoneElses->booking_ref}")
|
||||
->assertNotFound();
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
use Modules\Booking\Filament\Widgets\RecentBookingsTableWidget;
|
||||
use Modules\Booking\Models\Booking;
|
||||
|
||||
test('it lists the most recently created bookings', function () {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$older = Booking::factory()->create(['created_at' => now()->subDay()]);
|
||||
$newer = Booking::factory()->create(['created_at' => now()]);
|
||||
|
||||
Livewire::test(RecentBookingsTableWidget::class)
|
||||
->assertOk()
|
||||
->assertCanSeeTableRecords([$newer, $older]);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Events\DriverAssigned;
|
||||
use Modules\Booking\Listeners\SendDriverAssignedSms;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Catalog\Models\Destination;
|
||||
use Modules\Routing\Models\EvRoute;
|
||||
use Modules\Shared\Sms\SmsService;
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'app.name' => 'FamousLY4 EV',
|
||||
'app.support_phone' => '+959123456789',
|
||||
'app.support_email' => 'support@famousLY4.test',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a first driver assignment texts the passenger with an "assigned" message including the route', function () {
|
||||
$booking = Booking::factory()->create([
|
||||
'status' => BookingStatus::Confirmed,
|
||||
'passenger_phone' => '+959999888777',
|
||||
'driver_name' => 'U Aung',
|
||||
'driver_phone' => '+959111222333',
|
||||
'car_plate_number' => 'YGN-1234',
|
||||
'car_model' => 'Tesla Model Y',
|
||||
'ev_route_id' => EvRoute::factory()->create([
|
||||
'from_destination_id' => Destination::factory()->create(['name' => 'Yangon'])->id,
|
||||
'to_destination_id' => Destination::factory()->create(['name' => 'Mandalay'])->id,
|
||||
])->id,
|
||||
]);
|
||||
|
||||
$sms = Mockery::mock(SmsService::class);
|
||||
$sms->shouldReceive('send')
|
||||
->once()
|
||||
->with('+959999888777', Mockery::on(fn (string $message) => str_contains($message, 'assigned')
|
||||
&& str_contains($message, 'U Aung')
|
||||
&& str_contains($message, 'YGN-1234')
|
||||
&& str_contains($message, 'Yangon - Mandalay')
|
||||
&& str_contains($message, config('app.name'))
|
||||
&& str_contains($message, config('app.support_phone'))
|
||||
&& str_contains($message, config('app.support_email'))
|
||||
&& str_contains($message, 'ယာဉ်မောင်း')
|
||||
&& str_contains($message, 'အကူအညီလိုအပ်ပါက ဆက်သွယ်ရန်')));
|
||||
|
||||
(new SendDriverAssignedSms($sms))->handle(new DriverAssigned($booking, isFirstAssignment: true));
|
||||
});
|
||||
|
||||
test('a driver reassignment texts the passenger with an "updated" message including the route', function () {
|
||||
$booking = Booking::factory()->create([
|
||||
'status' => BookingStatus::Confirmed,
|
||||
'passenger_phone' => '+959999888777',
|
||||
'driver_name' => 'Daw Hla',
|
||||
'driver_phone' => '+959444555666',
|
||||
'car_plate_number' => 'YGN-5678',
|
||||
'ev_route_id' => EvRoute::factory()->create([
|
||||
'from_destination_id' => Destination::factory()->create(['name' => 'Yangon'])->id,
|
||||
'to_destination_id' => Destination::factory()->create(['name' => 'Mandalay'])->id,
|
||||
])->id,
|
||||
]);
|
||||
|
||||
$sms = Mockery::mock(SmsService::class);
|
||||
$sms->shouldReceive('send')
|
||||
->once()
|
||||
->with('+959999888777', Mockery::on(fn (string $message) => str_contains($message, 'updated')
|
||||
&& str_contains($message, 'Daw Hla')
|
||||
&& str_contains($message, 'Yangon - Mandalay')
|
||||
&& str_contains($message, config('app.name'))
|
||||
&& str_contains($message, config('app.support_phone'))
|
||||
&& str_contains($message, config('app.support_email'))
|
||||
&& str_contains($message, 'ယာဉ်မောင်း')
|
||||
&& str_contains($message, 'အကူအညီလိုအပ်ပါက ဆက်သွယ်ရန်')));
|
||||
|
||||
(new SendDriverAssignedSms($sms))->handle(new DriverAssigned($booking, isFirstAssignment: false));
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
<?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;
|
||||
|
||||
test('it assigns driver and car details to a confirmed booking', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
$updated = (new AssignDriverAction)->handle($booking, new AssignDriverData(
|
||||
driverName: 'U Aung',
|
||||
driverPhone: '+959111222333',
|
||||
carPlateNumber: 'YGN-1234',
|
||||
carModel: 'Tesla Model Y',
|
||||
));
|
||||
|
||||
expect($updated->driver_name)->toBe('U Aung')
|
||||
->and($updated->driver_phone)->toBe('+959111222333')
|
||||
->and($updated->car_plate_number)->toBe('YGN-1234')
|
||||
->and($updated->car_model)->toBe('Tesla Model Y')
|
||||
->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]);
|
||||
|
||||
$updated = (new AssignDriverAction)->handle($booking, new AssignDriverData(
|
||||
driverName: 'U Aung',
|
||||
driverPhone: '+959111222333',
|
||||
carPlateNumber: 'YGN-1234',
|
||||
));
|
||||
|
||||
expect($updated->car_model)->toBeNull();
|
||||
});
|
||||
|
||||
test('it guards against assigning a driver to a pending_payment booking', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
|
||||
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 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]);
|
||||
|
||||
expect(fn () => (new AssignDriverAction)->handle($booking, new AssignDriverData(
|
||||
driverName: 'U Aung',
|
||||
driverPhone: '+959111222333',
|
||||
carPlateNumber: 'YGN-1234',
|
||||
)))->toThrow(DriverAssignmentNotAllowedException::class);
|
||||
});
|
||||
|
||||
test('reassigning a different driver on a still-confirmed booking overwrites the previous values', function () {
|
||||
$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',
|
||||
));
|
||||
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Booking\Services\BookingRefGenerator;
|
||||
|
||||
test('the first booking ref starts the sequence at AAAAA1', function () {
|
||||
$ref = (new BookingRefGenerator)->generate();
|
||||
|
||||
expect($ref)->toBe('EVB-AAAAA1');
|
||||
});
|
||||
|
||||
test('the ref increments digit by digit through the alphabet', function () {
|
||||
Booking::factory()->create(['booking_ref' => 'EVB-AAAAA9']);
|
||||
|
||||
expect((new BookingRefGenerator)->generate())->toBe('EVB-AAAAAA');
|
||||
});
|
||||
|
||||
test('the ref carries over into the next position once the alphabet is exhausted', function () {
|
||||
Booking::factory()->create(['booking_ref' => 'EVB-AAAAZZ']);
|
||||
|
||||
expect((new BookingRefGenerator)->generate())->toBe('EVB-AAAB11');
|
||||
});
|
||||
|
||||
test('generated refs are unique across repeated calls', function () {
|
||||
$refs = [];
|
||||
|
||||
for ($i = 0; $i < 20; $i++) {
|
||||
$ref = (new BookingRefGenerator)->generate();
|
||||
Booking::factory()->create(['booking_ref' => $ref]);
|
||||
$refs[] = $ref;
|
||||
}
|
||||
|
||||
expect($refs)->toEqual(array_unique($refs));
|
||||
});
|
||||
|
||||
test('an unrecognised existing ref format resets the sequence rather than throwing', function () {
|
||||
Booking::factory()->create(['booking_ref' => 'LEGACY-2024-0001']);
|
||||
|
||||
expect((new BookingRefGenerator)->generate())->toBe('EVB-AAAAA1');
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
use Modules\Booking\Data\VehicleSelectionData;
|
||||
use Modules\Booking\Exceptions\InvalidVehicleSelectionException;
|
||||
use Modules\Booking\Services\BookingService;
|
||||
use Modules\Shared\Enums\VehicleOption;
|
||||
|
||||
test('a normal single-option selection of each vehicle option passes', function () {
|
||||
config([
|
||||
'booking.back_seat_enabled' => true,
|
||||
'booking.whole_vehicle_enabled' => true,
|
||||
]);
|
||||
|
||||
$service = new BookingService;
|
||||
|
||||
foreach (VehicleOption::cases() as $option) {
|
||||
expect(fn () => $service->validateSelections([new VehicleSelectionData($option)]))
|
||||
->not->toThrow(InvalidVehicleSelectionException::class);
|
||||
}
|
||||
});
|
||||
|
||||
test('front seat and back seat can be selected together in one booking', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
$service = new BookingService;
|
||||
|
||||
expect(fn () => $service->validateSelections([
|
||||
new VehicleSelectionData(VehicleOption::FrontSeat, 1),
|
||||
new VehicleSelectionData(VehicleOption::BackSeat, 2),
|
||||
]))->not->toThrow(InvalidVehicleSelectionException::class);
|
||||
});
|
||||
|
||||
test('requesting more front seats than the configured max is rejected', function () {
|
||||
config(['booking.front_seat_max_per_booking' => 1]);
|
||||
|
||||
$service = new BookingService;
|
||||
|
||||
expect(fn () => $service->validateSelections([new VehicleSelectionData(VehicleOption::FrontSeat, 2)]))
|
||||
->toThrow(InvalidVehicleSelectionException::class);
|
||||
});
|
||||
|
||||
test('requesting front seats up to the configured max passes', function () {
|
||||
config(['booking.front_seat_max_per_booking' => 2]);
|
||||
|
||||
$service = new BookingService;
|
||||
|
||||
expect(fn () => $service->validateSelections([new VehicleSelectionData(VehicleOption::FrontSeat, 2)]))
|
||||
->not->toThrow(InvalidVehicleSelectionException::class);
|
||||
});
|
||||
|
||||
test('back seat is rejected when disabled via config', function () {
|
||||
config(['booking.back_seat_enabled' => false]);
|
||||
|
||||
$service = new BookingService;
|
||||
|
||||
expect(fn () => $service->validateSelections([new VehicleSelectionData(VehicleOption::BackSeat)]))
|
||||
->toThrow(InvalidVehicleSelectionException::class);
|
||||
});
|
||||
|
||||
test('whole vehicle is rejected when disabled via config', function () {
|
||||
config(['booking.whole_vehicle_enabled' => false]);
|
||||
|
||||
$service = new BookingService;
|
||||
|
||||
expect(fn () => $service->validateSelections([new VehicleSelectionData(VehicleOption::WholeVehicle)]))
|
||||
->toThrow(InvalidVehicleSelectionException::class);
|
||||
});
|
||||
|
||||
test('the same vehicle option cannot be selected twice in one booking', function () {
|
||||
config(['booking.back_seat_enabled' => true]);
|
||||
|
||||
$service = new BookingService;
|
||||
|
||||
expect(fn () => $service->validateSelections([
|
||||
new VehicleSelectionData(VehicleOption::BackSeat, 1),
|
||||
new VehicleSelectionData(VehicleOption::BackSeat, 1),
|
||||
]))->toThrow(InvalidVehicleSelectionException::class);
|
||||
});
|
||||
|
||||
test('whole vehicle cannot be combined with another vehicle option', function () {
|
||||
config([
|
||||
'booking.back_seat_enabled' => true,
|
||||
'booking.whole_vehicle_enabled' => true,
|
||||
]);
|
||||
|
||||
$service = new BookingService;
|
||||
|
||||
expect(fn () => $service->validateSelections([
|
||||
new VehicleSelectionData(VehicleOption::WholeVehicle),
|
||||
new VehicleSelectionData(VehicleOption::BackSeat),
|
||||
]))->toThrow(InvalidVehicleSelectionException::class);
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
use Modules\Booking\Actions\CancelBookingAction;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Exceptions\BookingCannotBeCancelledException;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Payment\Contracts\PaymentGatewayInterface;
|
||||
use Modules\Payment\Data\PaymentRequestData;
|
||||
use Modules\Payment\Data\PaymentResultData;
|
||||
use Modules\Payment\Data\RefundResultData;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Enums\RefundStatus;
|
||||
use Modules\Payment\Factories\PaymentGatewayFactory;
|
||||
use Modules\Payment\Models\Payment;
|
||||
|
||||
/**
|
||||
* Never calls the real KBZ refund API in tests.
|
||||
*/
|
||||
class FakeCancelRefundGateway implements PaymentGatewayInterface
|
||||
{
|
||||
public static ?string $lastAmount = null;
|
||||
|
||||
public function initiate(PaymentRequestData $data): PaymentResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
}
|
||||
|
||||
public function verify(string $gatewayTransactionId): PaymentResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
}
|
||||
|
||||
public function refund(string $gatewayTransactionId, string $amount, string $reason): RefundResultData
|
||||
{
|
||||
self::$lastAmount = $amount;
|
||||
|
||||
return new RefundResultData(status: RefundStatus::Completed, gatewayRefundId: 'REFUND123', gatewayPayload: []);
|
||||
}
|
||||
|
||||
public function handleWebhook(array $payload): PaymentResultData
|
||||
{
|
||||
throw new RuntimeException('not needed for this test');
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
FakeCancelRefundGateway::$lastAmount = null;
|
||||
app(PaymentGatewayFactory::class)->register(PaymentMethod::KbzMiniApp, FakeCancelRefundGateway::class);
|
||||
});
|
||||
|
||||
test('it cancels a pending_payment booking directly', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
|
||||
|
||||
$cancelled = app(CancelBookingAction::class)->handle($booking);
|
||||
|
||||
expect($cancelled->status)->toBe(BookingStatus::Cancelled)
|
||||
->and($booking->refresh()->status)->toBe(BookingStatus::Cancelled);
|
||||
});
|
||||
|
||||
test('it cancels a confirmed booking by refunding it in full', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'price' => 15000]);
|
||||
Payment::factory()->completed()->create([
|
||||
'booking_id' => $booking->id,
|
||||
'gateway' => PaymentMethod::KbzMiniApp,
|
||||
'amount' => 15000,
|
||||
'gateway_transaction_id' => 'EVB-CANCEL-TEST-1',
|
||||
]);
|
||||
|
||||
$cancelled = app(CancelBookingAction::class)->handle($booking);
|
||||
|
||||
expect($cancelled->status)->toBe(BookingStatus::Cancelled)
|
||||
->and(FakeCancelRefundGateway::$lastAmount)->toBe('15000.00');
|
||||
});
|
||||
|
||||
test('it guards against cancelling an already cancelled booking', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Cancelled]);
|
||||
|
||||
expect(fn () => app(CancelBookingAction::class)->handle($booking))
|
||||
->toThrow(BookingCannotBeCancelledException::class);
|
||||
});
|
||||
|
||||
test('it guards against cancelling an expired booking', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Expired]);
|
||||
|
||||
expect(fn () => app(CancelBookingAction::class)->handle($booking))
|
||||
->toThrow(BookingCannotBeCancelledException::class);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Catalog\Database\Factories;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Modules\Catalog\Models\DepartureTimeSlot;
|
||||
|
||||
/**
|
||||
* @extends Factory<DepartureTimeSlot>
|
||||
*/
|
||||
class DepartureTimeSlotFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$time = Carbon::createFromTime(fake()->numberBetween(0, 23), fake()->randomElement([0, 15, 30, 45]));
|
||||
|
||||
return [
|
||||
'label' => $time->format('h:i A'),
|
||||
'time' => $time->format('H:i'),
|
||||
'is_active' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('departure_time_slots', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('label');
|
||||
$table->time('time');
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('departure_time_slots');
|
||||
}
|
||||
};
|
||||
@@ -1 +1,10 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Catalog\Http\Controllers\DestinationController;
|
||||
use Modules\Catalog\Http\Controllers\EvCompanyController;
|
||||
|
||||
Route::prefix('api/v1')->middleware(['api', 'api.auth', 'throttle:api-read'])->group(function () {
|
||||
Route::get('/companies', [EvCompanyController::class, 'index'])->name('catalog.companies.index');
|
||||
Route::get('/destinations', [DestinationController::class, 'index'])->name('catalog.destinations.index');
|
||||
});
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Catalog\Filament\Resources\DepartureTimeSlots;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
use Modules\Catalog\Filament\Resources\DepartureTimeSlots\Pages\CreateDepartureTimeSlot;
|
||||
use Modules\Catalog\Filament\Resources\DepartureTimeSlots\Pages\EditDepartureTimeSlot;
|
||||
use Modules\Catalog\Filament\Resources\DepartureTimeSlots\Pages\ListDepartureTimeSlots;
|
||||
use Modules\Catalog\Filament\Resources\DepartureTimeSlots\Schemas\DepartureTimeSlotForm;
|
||||
use Modules\Catalog\Filament\Resources\DepartureTimeSlots\Tables\DepartureTimeSlotsTable;
|
||||
use Modules\Catalog\Models\DepartureTimeSlot;
|
||||
use UnitEnum;
|
||||
|
||||
class DepartureTimeSlotResource extends Resource
|
||||
{
|
||||
protected static ?string $model = DepartureTimeSlot::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedClock;
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Catalog';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return DepartureTimeSlotForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return DepartureTimeSlotsTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListDepartureTimeSlots::route('/'),
|
||||
'create' => CreateDepartureTimeSlot::route('/create'),
|
||||
'edit' => EditDepartureTimeSlot::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Catalog\Filament\Resources\DepartureTimeSlots\Pages;
|
||||
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Modules\Catalog\Filament\Resources\DepartureTimeSlots\DepartureTimeSlotResource;
|
||||
|
||||
class CreateDepartureTimeSlot extends CreateRecord
|
||||
{
|
||||
protected static string $resource = DepartureTimeSlotResource::class;
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Catalog\Filament\Resources\DepartureTimeSlots\Pages;
|
||||
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Modules\Catalog\Filament\Resources\DepartureTimeSlots\DepartureTimeSlotResource;
|
||||
|
||||
class EditDepartureTimeSlot extends EditRecord
|
||||
{
|
||||
protected static string $resource = DepartureTimeSlotResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Catalog\Filament\Resources\DepartureTimeSlots\Pages;
|
||||
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Modules\Catalog\Filament\Resources\DepartureTimeSlots\DepartureTimeSlotResource;
|
||||
|
||||
class ListDepartureTimeSlots extends ListRecords
|
||||
{
|
||||
protected static string $resource = DepartureTimeSlotResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Catalog\Filament\Resources\DepartureTimeSlots\Schemas;
|
||||
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\TimePicker;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class DepartureTimeSlotForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
TextInput::make('label')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TimePicker::make('time')
|
||||
->required(),
|
||||
Toggle::make('is_active')
|
||||
->required()
|
||||
->default(true),
|
||||
]);
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Catalog\Filament\Resources\DepartureTimeSlots\Tables;
|
||||
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\TernaryFilter;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class DepartureTimeSlotsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('label')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('time')
|
||||
->time('H:i')
|
||||
->sortable(),
|
||||
IconColumn::make('is_active')
|
||||
->boolean(),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->defaultSort('time')
|
||||
->filters([
|
||||
TernaryFilter::make('is_active'),
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Catalog\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Modules\Catalog\Http\Resources\DestinationResource;
|
||||
use Modules\Catalog\Models\Destination;
|
||||
|
||||
class DestinationController extends Controller
|
||||
{
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$terms = array_filter(array_map(
|
||||
trim(...),
|
||||
explode(',', (string) $request->string('search')),
|
||||
));
|
||||
|
||||
return DestinationResource::collection(
|
||||
Destination::query()
|
||||
->where('is_active', true)
|
||||
->when($terms !== [], fn ($query) => $query->where(function ($query) use ($terms) {
|
||||
foreach ($terms as $term) {
|
||||
$query->orWhere('name', 'ilike', "%{$term}%")
|
||||
->orWhere('mm_name', 'ilike', "%{$term}%");
|
||||
}
|
||||
}))
|
||||
->paginate()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Catalog\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Modules\Catalog\Http\Resources\EvCompanyResource;
|
||||
use Modules\Catalog\Models\EvCompany;
|
||||
|
||||
class EvCompanyController extends Controller
|
||||
{
|
||||
public function index(): AnonymousResourceCollection
|
||||
{
|
||||
return EvCompanyResource::collection(
|
||||
EvCompany::query()->where('is_active', true)->paginate()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Catalog\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class DestinationResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'mm_name' => $this->mm_name,
|
||||
'region' => $this->region,
|
||||
'description' => $this->description,
|
||||
'mm_description' => $this->mm_description,
|
||||
'latitude' => $this->latitude,
|
||||
'longitude' => $this->longitude,
|
||||
'popular' => $this->popular,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Catalog\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class EvCompanyResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'mm_name' => $this->mm_name,
|
||||
'slug' => $this->slug,
|
||||
'description' => $this->description,
|
||||
'mm_description' => $this->mm_description,
|
||||
'contact' => $this->contact,
|
||||
'address' => $this->address,
|
||||
'logo' => $this->logo_url,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Catalog\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Modules\Catalog\Database\Factories\DepartureTimeSlotFactory;
|
||||
use Modules\Routing\Models\EvRoute;
|
||||
use Spatie\Activitylog\Models\Concerns\LogsActivity;
|
||||
use Spatie\Activitylog\Support\LogOptions;
|
||||
|
||||
/**
|
||||
* A shared catalog of departure times, attached to routes via a pivot in the
|
||||
* Routing module — not owned by any single route.
|
||||
*/
|
||||
class DepartureTimeSlot extends Model
|
||||
{
|
||||
/** @use HasFactory<DepartureTimeSlotFactory> */
|
||||
use HasFactory, LogsActivity;
|
||||
|
||||
/**
|
||||
* Full CRUD audit trail — catalog admin writes are staff-only and
|
||||
* infrequent, so logging every attribute change is affordable
|
||||
* (domain.md §6; T6.2).
|
||||
*/
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logFillable()
|
||||
->logOnlyDirty()
|
||||
->dontLogEmptyChanges()
|
||||
->useLogName('catalog');
|
||||
}
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'label',
|
||||
'time',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'time' => 'datetime:H:i',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function routes(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(EvRoute::class, 'ev_route_time_slots')
|
||||
->withPivot('is_active')
|
||||
->withTimestamps();
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,27 @@ namespace Modules\Catalog\Models;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Modules\Catalog\Database\Factories\DestinationFactory;
|
||||
use Spatie\Activitylog\Models\Concerns\LogsActivity;
|
||||
use Spatie\Activitylog\Support\LogOptions;
|
||||
|
||||
class Destination extends Model
|
||||
{
|
||||
/** @use HasFactory<DestinationFactory> */
|
||||
use HasFactory;
|
||||
use HasFactory, LogsActivity;
|
||||
|
||||
/**
|
||||
* Full CRUD audit trail — catalog admin writes are staff-only and
|
||||
* infrequent, so logging every attribute change is affordable
|
||||
* (domain.md §6; T6.2).
|
||||
*/
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logFillable()
|
||||
->logOnlyDirty()
|
||||
->dontLogEmptyChanges()
|
||||
->useLogName('catalog');
|
||||
}
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
|
||||
@@ -2,15 +2,33 @@
|
||||
|
||||
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;
|
||||
use Spatie\Activitylog\Support\LogOptions;
|
||||
|
||||
class EvCompany extends Model
|
||||
{
|
||||
/** @use HasFactory<EvCompanyFactory> */
|
||||
use HasFactory;
|
||||
use HasFactory, LogsActivity;
|
||||
|
||||
/**
|
||||
* Full CRUD audit trail — catalog admin writes are staff-only and
|
||||
* infrequent, so logging every attribute change is affordable
|
||||
* (domain.md §6; T6.2).
|
||||
*/
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logFillable()
|
||||
->logOnlyDirty()
|
||||
->dontLogEmptyChanges()
|
||||
->useLogName('catalog');
|
||||
}
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
@@ -56,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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Modules\Catalog\Models\Destination;
|
||||
use Modules\Catalog\Models\EvCompany;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->token = User::factory()->create()->createToken('test-token')->plainTextToken;
|
||||
});
|
||||
|
||||
test('lists active ev companies', function () {
|
||||
$active = EvCompany::factory()->create(['is_active' => true]);
|
||||
EvCompany::factory()->create(['is_active' => false]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson('/api/v1/companies')
|
||||
->assertSuccessful()
|
||||
->assertJsonCount(1, 'data')
|
||||
->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]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson('/api/v1/destinations')
|
||||
->assertSuccessful()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonFragment(['id' => $active->id]);
|
||||
});
|
||||
|
||||
test('paginates companies and destinations', function () {
|
||||
EvCompany::factory()->count(20)->create(['is_active' => true]);
|
||||
Destination::factory()->count(20)->create(['is_active' => true]);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson('/api/v1/companies')
|
||||
->assertSuccessful()
|
||||
->assertJsonCount(15, 'data')
|
||||
->assertJsonPath('meta.total', 20);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson('/api/v1/destinations')
|
||||
->assertSuccessful()
|
||||
->assertJsonCount(15, 'data')
|
||||
->assertJsonPath('meta.total', 20);
|
||||
});
|
||||
|
||||
test('searches destinations by comma-separated terms', function () {
|
||||
$yangon = Destination::factory()->create(['is_active' => true, 'name' => 'Yangon']);
|
||||
$mandalay = Destination::factory()->create(['is_active' => true, 'name' => 'Mandalay']);
|
||||
Destination::factory()->create(['is_active' => true, 'name' => 'Bagan']);
|
||||
|
||||
$this->withHeader('Authorization', "Bearer {$this->token}")
|
||||
->getJson('/api/v1/destinations?search=yangon,mandalay')
|
||||
->assertSuccessful()
|
||||
->assertJsonCount(2, 'data')
|
||||
->assertJsonFragment(['id' => $yangon->id])
|
||||
->assertJsonFragment(['id' => $mandalay->id]);
|
||||
});
|
||||
|
||||
test('companies endpoint rejects unauthenticated requests', function () {
|
||||
$this->getJson('/api/v1/companies')->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('destinations endpoint rejects unauthenticated requests', function () {
|
||||
$this->getJson('/api/v1/destinations')->assertUnauthorized();
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
use Modules\Catalog\Filament\Resources\DepartureTimeSlots\Pages\CreateDepartureTimeSlot;
|
||||
use Modules\Catalog\Filament\Resources\DepartureTimeSlots\Pages\EditDepartureTimeSlot;
|
||||
use Modules\Catalog\Filament\Resources\DepartureTimeSlots\Pages\ListDepartureTimeSlots;
|
||||
use Modules\Catalog\Models\DepartureTimeSlot;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
use function Pest\Laravel\assertDatabaseHas;
|
||||
|
||||
beforeEach(function () {
|
||||
Role::findOrCreate('admin', 'web');
|
||||
|
||||
$this->admin = User::factory()->create()->assignRole('admin');
|
||||
$this->actingAs($this->admin);
|
||||
});
|
||||
|
||||
test('can list departure time slots', function () {
|
||||
$timeSlots = DepartureTimeSlot::factory()->count(3)->create();
|
||||
|
||||
Livewire::test(ListDepartureTimeSlots::class)
|
||||
->assertOk()
|
||||
->assertCanSeeTableRecords($timeSlots);
|
||||
});
|
||||
|
||||
test('can create a departure time slot', function () {
|
||||
$timeSlot = DepartureTimeSlot::factory()->make();
|
||||
|
||||
Livewire::test(CreateDepartureTimeSlot::class)
|
||||
->fillForm([
|
||||
'label' => $timeSlot->label,
|
||||
'time' => $timeSlot->time,
|
||||
'is_active' => true,
|
||||
])
|
||||
->call('create')
|
||||
->assertNotified()
|
||||
->assertRedirect();
|
||||
|
||||
assertDatabaseHas(DepartureTimeSlot::class, [
|
||||
'label' => $timeSlot->label,
|
||||
]);
|
||||
});
|
||||
|
||||
test('can edit a departure time slot', function () {
|
||||
$timeSlot = DepartureTimeSlot::factory()->create();
|
||||
|
||||
Livewire::test(EditDepartureTimeSlot::class, ['record' => $timeSlot->getRouteKey()])
|
||||
->assertOk()
|
||||
->fillForm(['label' => 'Updated Label'])
|
||||
->call('save')
|
||||
->assertNotified();
|
||||
|
||||
assertDatabaseHas(DepartureTimeSlot::class, [
|
||||
'id' => $timeSlot->id,
|
||||
'label' => 'Updated Label',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a departure time slot is a shared catalog entity not owned by a single route', function () {
|
||||
$timeSlot = DepartureTimeSlot::factory()->create();
|
||||
|
||||
expect($timeSlot->getFillable())->not->toContain('ev_route_id');
|
||||
});
|
||||
@@ -21,6 +21,11 @@ class RolePermissionSeeder extends Seeder
|
||||
'view_payments',
|
||||
'process_refunds',
|
||||
'view_audit_log',
|
||||
'manage_staff',
|
||||
'manage_roles',
|
||||
'view_customers',
|
||||
'manage_settings',
|
||||
'view_reports',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -36,6 +41,11 @@ class RolePermissionSeeder extends Seeder
|
||||
'view_payments',
|
||||
'process_refunds',
|
||||
'view_audit_log',
|
||||
'manage_staff',
|
||||
'manage_roles',
|
||||
'view_customers',
|
||||
'manage_settings',
|
||||
'view_reports',
|
||||
],
|
||||
'admin' => [
|
||||
'manage_catalog',
|
||||
@@ -46,11 +56,15 @@ class RolePermissionSeeder extends Seeder
|
||||
'view_payments',
|
||||
'process_refunds',
|
||||
'view_audit_log',
|
||||
'view_customers',
|
||||
'manage_settings',
|
||||
'view_reports',
|
||||
],
|
||||
'support' => [
|
||||
'view_bookings',
|
||||
'view_payments',
|
||||
'view_audit_log',
|
||||
'view_customers',
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user