Compare commits

..

15 Commits

Author SHA1 Message Date
Nyan Lin Paing 31ed52500a add suggestion management
PHP Tests / php-tests (push) Waiting to run
2026-09-01 00:16:17 +07:00
Nyan Lin Paing b8d31e3dc4 Add bnfexpress signed admin client and AI Agent Filament UI
- Modules\Shared\Bnfexpress\BnfexpressAdminClient: HMAC-signed HTTP client
  for bnfexpress's admin API (EV FAQs, agent instructions, chat history),
  with a bnfexpress:smoke-test command and full unit coverage.
- New ai-agent module: Filament pages to manage EV FAQs, publish/roll back
  agent instruction versions, and browse EV chat history + transcripts.
- New manage_ai_agent permission (super_admin/admin).
- Recorded .ai/rules for the client's auth scheme and non-Resource
  Filament page/table testing gotchas.
2026-08-30 23:52:21 +07:00
Nyan Lin Paing 95b369174d Redesign the Payment view page infolist
Groups fields into Payment/Timeline sections with icons, copyable IDs, and
a money-formatted amount. Drops the permission-gated Gateway Response
section (raw gateway_payload) and its two visibility tests along with it.
2026-08-30 15:46:19 +07:00
Nyan Lin Paing bebcab88fa Generalize per-vehicle-option booking rules to Front Seat too
Front Seat previously had a hardcoded max-per-booking check with no enable
toggle; now every Vehicle Option (front_seat/back_seat/whole_vehicle) gets
the same config-driven pair — an on/off toggle and a max passenger_count —
surfaced via new BOOKING_*_ENABLED/BOOKING_*_MAX_PER_BOOKING env vars,
editable from ManageAppSettings, and exposed on route pricing as
max_per_booking.
2026-08-30 15:45:27 +07:00
Nyan Lin Paing 914b7f97f3 Add phone-verified user registration
Two-step flow: request a one-time code by email/phone (RegistrationVerification,
mailed via RegistrationCodeMail, rate-limited by the new api-otp limiter keyed
to the identifier), then verify the code and register with RegistrationController.
User gains a phone column/fillable.
2026-08-30 14:53:18 +07:00
Nyan Lin Paing b6934e1fb5 Add Popular Routes
Curated from/to destination pairs for marketing content, editable via a
Filament resource and served publicly (no auth, IP-throttled like CMS
pages) through GET /api/v1/popular-routes. The API response trims each
nested destination down to id/name/mm_name rather than the full catalog
resource.
2026-08-30 14:52:39 +07:00
Nyan Lin Paing 76f75c5581 Add CMS module
Static content pages (CmsPage) manageable from a new Filament resource and
readable via a public API endpoint. Wired into the admin panel with its own
CmsPlugin and "CMS" navigation group.
2026-08-30 14:51:57 +07:00
Nyan Lin Paing 231f5679ef Add refund action to booking list/detail with full-refund toggle
- New RefundBookingTableAction on the booking list row and detail page,
  refunding a Confirmed booking directly via RefundBookingAction — no need
  to hunt up its Payment on the Refunds resource first.
- Payment::refundableBalance() extracted from RefundBookingAction's private
  balance check so both refund forms can display and cap against it.
- RefundBookingAction::resolveRefundablePayment() made public for the same
  reason (round-trip leg resolution reused by the UI).
- Both refund forms (ProcessRefundAction and the new booking action) gain a
  "Full refund" toggle, on by default, which refunds the payment's whole
  remaining balance without requiring a manually typed amount. Turning it
  off reveals an amount field capped at the refundable balance.
2026-08-30 14:46:26 +07:00
Nyan Lin Paing a905320d50 Resolve KBZ cert paths relative to storage_path()
KBZ_CERT_PATH/KBZ_CERT_KEY_PATH/KBZ_CA_PATH now store paths relative to
storage/, wrapped in storage_path() here instead of requiring an absolute
path in .env.
2026-08-30 14:46:13 +07:00
Nyan Lin Paing 4f0f20659d Return EvCompany logo as a full URL in the API resource
PHP Tests / php-tests (push) Has been cancelled
EvCompanyResource returned the raw disk-relative path stored by
Filament's FileUpload (e.g. "logos/xxx.png"), not something API
consumers can render directly.

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

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

- BookingsRevenueExport backs both CSV and XLSX via maatwebsite/excel
  ^4.0 (the only version compatible with PHP 8.5; 3.1.x caps
  phpoffice/phpspreadsheet below 8.5).
- CSV export writes a UTF-8 BOM so non-Latin passenger names (Burmese)
  open correctly in Excel.
- New view_reports permission (super_admin/admin/support) gates the
  page; new indexes on bookings.travel_date/status/created_by_channel
  and payments.completed_at support the report's filters.
2026-08-23 22:32:49 +07:00
Nyan Lin Paing da9cd9bbe0 add sms sending feat 2026-08-23 20:44:52 +07:00
Nyan Lin Paing 41c9454334 modify booking response data 2026-08-23 14:51:10 +07:00
152 changed files with 6742 additions and 169 deletions
+15
View File
@@ -0,0 +1,15 @@
---
paths:
- 'app-modules/shared/src/Bnfexpress/**'
---
# Bnfexpress
## bnfexpress admin API calls go through BnfexpressAdminClient
Signed backend-to-backend calls to bnfexpress's admin API (EV FAQs, agent instructions, chat history) go through `Modules\Shared\Bnfexpress\BnfexpressAdminClient` — do not call `Http::` directly against BNFEXPRESS_AI_API_URL elsewhere.
Auth is HMAC, not JWT/session: X-Client-Id/X-Timestamp/X-Signature per `BnfexpressSignature::headers()`, signed over `METHOD\nPATH\nTIMESTAMP\nRAW_BODY` (path only, no query string; empty string body for GET/DELETE). Timestamps must be generated fresh per request (server rejects >300s skew) — never cache/reuse a signed header set.
Config lives in `config('services.bnfexpress')` (BNFEXPRESS_AI_API_URL/CLIENT_ID/CLIENT_SECRET in .env). The client_secret must match bnfexpress's own ADMIN_SERVICE_CLIENTS entry for ev_admin — get it from whoever manages that deploy.
Non-2xx responses throw `BnfexpressApiException` carrying the gateway's `{"detail": "..."}` message. Verify signing end-to-end with `php artisan bnfexpress:smoke-test` before wiring up any UI.
+8
View File
@@ -0,0 +1,8 @@
# Project Rules Index
Before planning or editing, find the row whose globs match the file's path and read that rule file.
| Applies to | Rule file |
| --- | --- |
| app-modules/shared/src/Bnfexpress/** | .ai/rules/bnfexpress.md |
| app-modules/*/src/Filament/Pages/** | .ai/rules/pages.md |
+18
View File
@@ -0,0 +1,18 @@
---
paths:
- 'app-modules/*/src/Filament/Pages/**'
---
# Pages
## Non-Resource Filament pages need an explicit table-rendering view + deferLoading-aware tests
A `Filament\Pages\Page implements HasTable` (not a Resource) does NOT render its table automatically — it must set `protected string $view = '<module>::filament.pages.<slug>';` pointing at a Blade file containing `<x-filament-panels::page>{{ $this->table }}</x-filament-panels::page>` (see `ManageFaqs`/`ViewEvChatHistory`/`BookingsRevenueReport`). Omitting this silently renders an empty page — no error, just a blank `fi-page-content`.
Filament v4 tables default to deferred loading. In Pest/Livewire tests, call `->loadTable()` before any `assertSee()`/`assertCanSeeTableRecords()` on a freshly-mounted component, or the table body won't be in the rendered HTML yet.
For custom-data (`->records()`-backed, non-Eloquent) tables: use `->callTableAction($name, $record, data: [...])` / `->mountTableAction(...)` (not the generic `->callAction()`, which targets page-level actions and misses table header/record actions), and use `->assertMountedActionModalSee(...)` to check `->modalContent()` output — modal content is lazily rendered and won't appear in a plain `->html()`/`->assertSee()` snapshot even after mounting the action. See `[[project_internachi_modular]]`-style module layout in `app-modules/ai-agent`.
## Custom-data table bulk actions: fetchSelectedRecords(false) still hydrates full rows
On a `Table::records()`-backed (non-Eloquent) page, `BulkAction::make(...)->fetchSelectedRecords(false)` does NOT skip hydration the way it does for an Eloquent table — the `Collection $records` passed to `->action()` still contains full row arrays (keyed by the record key), not bare ids. Use `$records->keys()->all()` to get just the selected ids; `$records->all()`/`$records->values()` gives you full row data instead. See `ManageSuggestions::deleteSelectedBulkAction()` / `ManageSuggestionMisses::promoteBulkAction()`.
Also: `BnfexpressAdminClient`'s non-2xx handling (`errorMessage()`) must handle `detail` being a list of `{msg, ...}` objects, not just a string — FastAPI's own request-validation failures (422s) return `detail` in that shape, and casting it straight to `(string)` silently produces the literal "Array".
+6
View File
@@ -0,0 +1,6 @@
{
"enabledMcpjsonServers": [
"laravel-boost"
],
"enableAllProjectMcpServers": true
}
+17 -2
View File
@@ -53,9 +53,19 @@ REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null REDIS_PASSWORD=null
REDIS_PORT=6379 REDIS_PORT=6379
BOOKING_BACK_SEAT_ENABLED=true BOOKING_FRONT_SEAT_ENABLED=true
BOOKING_WHOLE_VEHICLE_ENABLED=true
BOOKING_FRONT_SEAT_MAX_PER_BOOKING=1 BOOKING_FRONT_SEAT_MAX_PER_BOOKING=1
BOOKING_BACK_SEAT_ENABLED=true
BOOKING_BACK_SEAT_MAX_PER_BOOKING=3
BOOKING_WHOLE_VEHICLE_ENABLED=true
BOOKING_WHOLE_VEHICLE_MAX_PER_BOOKING=4
BOOKING_ADMIN_EMAILS="example@gmail.com"
SMS_ENABLED=false
SMS_SERVER=
SMS_TOKEN=
SMS_SENDER=
KBZ_APP_ID= KBZ_APP_ID=
KBZ_MERCHANT_CODE= KBZ_MERCHANT_CODE=
@@ -89,3 +99,8 @@ VITE_APP_NAME="${APP_NAME}"
FASTAPI_AGENT_JWT_SECRET= FASTAPI_AGENT_JWT_SECRET=
FASTAPI_AGENT_JWT_ALGORITHM=HS256 FASTAPI_AGENT_JWT_ALGORITHM=HS256
# Must match the ev_admin entry in bnfexpress's own ADMIN_SERVICE_CLIENTS.
BNFEXPRESS_AI_API_URL=http://bnfexpress-app:8000
BNFEXPRESS_AI_CLIENT_ID=ev_admin
BNFEXPRESS_AI_CLIENT_SECRET=
+24
View File
@@ -0,0 +1,24 @@
{
"name": "modules/ai-agent",
"description": "",
"type": "library",
"version": "1.0",
"license": "proprietary",
"require": {},
"autoload": {
"psr-4": {
"Modules\\AiAgent\\": "src/",
"Modules\\AiAgent\\Tests\\": "tests/",
"Modules\\AiAgent\\Database\\Factories\\": "database/factories/",
"Modules\\AiAgent\\Database\\Seeders\\": "database/seeders/"
}
},
"minimum-stability": "stable",
"extra": {
"laravel": {
"providers": [
"Modules\\AiAgent\\Providers\\AiAgentServiceProvider"
]
}
}
}
@@ -0,0 +1,13 @@
<x-filament-panels::page>
<x-filament::section heading="Active Instruction">
@if ($active)
<pre class="whitespace-pre-wrap text-sm">{{ $active['content'] ?? '' }}</pre>
@elseif ($activeError)
<p class="text-sm text-danger-600">Could not load the active instruction: {{ $activeError }}</p>
@else
<p class="text-sm text-gray-500">No active instruction.</p>
@endif
</x-filament::section>
{{ $this->table }}
</x-filament-panels::page>
@@ -0,0 +1,3 @@
<x-filament-panels::page>
{{ $this->table }}
</x-filament-panels::page>
@@ -0,0 +1,3 @@
<x-filament-panels::page>
{{ $this->table }}
</x-filament-panels::page>
@@ -0,0 +1,15 @@
<x-filament-panels::page>
<x-filament::section heading="Sync Status">
@if ($syncJobId)
<div wire:poll.2s="pollSyncStatus" class="text-sm">
Job {{ $syncJobId }}: {{ $syncStatus }}...
</div>
@elseif ($syncResult)
<pre class="whitespace-pre-wrap text-sm">{{ json_encode($syncResult, JSON_PRETTY_PRINT) }}</pre>
@else
<p class="text-sm text-gray-500">No sync running.</p>
@endif
</x-filament::section>
{{ $this->table }}
</x-filament-panels::page>
@@ -0,0 +1 @@
<p class="text-sm text-danger-600">Could not load this transcript: {{ $message }}</p>
@@ -0,0 +1,34 @@
@php
$labels = [
'user' => 'User',
'bnfexpress_ev_agent' => 'Assistant',
];
@endphp
<div class="max-h-[32rem] space-y-3 overflow-y-auto">
@forelse (($transcript['messages'] ?? []) as $message)
@php
$author = $message['author'] ?? 'unknown';
$isUser = $author === 'user';
@endphp
<div @class([
'max-w-[85%] rounded-lg border p-3',
'ms-auto border-primary-200 bg-primary-50 dark:border-primary-800 dark:bg-primary-950' => $isUser,
'border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800' => ! $isUser,
])>
<div class="mb-1 flex items-center justify-between gap-3">
<span class="text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
{{ $labels[$author] ?? $author }}
</span>
@if (isset($message['timestamp']))
<span class="text-xs text-gray-400 dark:text-gray-500">
{{ \Illuminate\Support\Carbon::createFromTimestamp($message['timestamp'])->format('M j, Y g:i A') }}
</span>
@endif
</div>
<p class="whitespace-pre-wrap text-sm">{{ $message['text'] ?? '' }}</p>
</div>
@empty
<p class="text-sm text-gray-500">No messages in this session.</p>
@endforelse
</div>
@@ -0,0 +1,3 @@
<x-filament-panels::page>
{{ $this->table }}
</x-filament-panels::page>
@@ -0,0 +1,29 @@
<?php
namespace Modules\AiAgent;
use Filament\Contracts\Plugin;
use Filament\Panel;
class AiAgentPlugin implements Plugin
{
public function getId(): string
{
return 'ai-agent';
}
public function register(Panel $panel): void
{
$panel->discoverPages(
in: __DIR__.'/Filament/Pages',
for: 'Modules\AiAgent\Filament\Pages',
);
}
public function boot(Panel $panel): void {}
public static function make(): static
{
return app(static::class);
}
}
@@ -0,0 +1,45 @@
<?php
namespace Modules\AiAgent\Filament\Concerns;
use Filament\Notifications\Notification;
use Modules\Shared\Bnfexpress\Exceptions\BnfexpressApiException;
/**
* Shared try/call/notify wrapper for bnfexpress admin API calls triggered
* from a Filament action every mutating action across the AI Agent pages
* (create/update/delete/publish/activate) follows this same shape.
*/
trait HandlesBnfexpressErrors
{
/**
* @param callable(): void $callback
*/
protected function callBnfexpress(callable $callback, string $successTitle, string $failureTitle): void
{
try {
$callback();
Notification::make()->title($successTitle)->success()->send();
} catch (BnfexpressApiException $exception) {
Notification::make()->title($failureTitle)->body($exception->getMessage())->danger()->send();
}
}
/**
* Same shape as callBnfexpress(), but for calls whose success notification
* needs the response (e.g. a "{created} created, {skipped} skipped" body)
* $onSuccess builds/sends its own Notification from $callback()'s return value.
*
* @param callable(): mixed $callback
* @param callable(mixed): void $onSuccess
*/
protected function callBnfexpressForResult(callable $callback, callable $onSuccess, string $failureTitle): void
{
try {
$onSuccess($callback());
} catch (BnfexpressApiException $exception) {
Notification::make()->title($failureTitle)->body($exception->getMessage())->danger()->send();
}
}
}
@@ -0,0 +1,33 @@
<?php
namespace Modules\AiAgent\Filament\Concerns;
use Illuminate\Pagination\LengthAwarePaginator;
/**
* Bridges a bnfexpress list response (a bare JSON array confirmed live via
* `php artisan bnfexpress:smoke-test` for FAQs/instructions, and the same
* shape for suggestions/misses per their `response_model=list[...]`) into a
* LengthAwarePaginator for Table::records(). bnfexpress reports no total
* count, so this falls back to a "there might be one more page" heuristic
* also tolerates a {total, <$itemsKey>} envelope in case that ever changes.
*/
trait PaginatesBnfexpressLists
{
/**
* @param array<string, mixed> $result
*/
private function paginateBareList(array $result, string $itemsKey, string $recordKey, int $page, int $recordsPerPage): LengthAwarePaginator
{
$items = $result[$itemsKey] ?? (array_is_list($result) ? $result : []);
$total = $result['total'] ?? (($page - 1) * $recordsPerPage) + count($items) + (count($items) === $recordsPerPage ? 1 : 0);
return new LengthAwarePaginator(
items: collect($items)->mapWithKeys(fn (array $item): array => [$item[$recordKey] => $item]),
total: $total,
perPage: $recordsPerPage,
currentPage: $page,
);
}
}
@@ -0,0 +1,145 @@
<?php
namespace Modules\AiAgent\Filament\Pages;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Pagination\LengthAwarePaginator;
use Modules\AiAgent\Filament\Concerns\HandlesBnfexpressErrors;
use Modules\Shared\Bnfexpress\BnfexpressAdminClient;
use Modules\Shared\Bnfexpress\Exceptions\BnfexpressApiException;
use UnitEnum;
/**
* Version history + publish/rollback for the EV agent's system prompt.
* Needs more than a bare table (an "active version" banner above the
* history), so like ManageAppSettings it renders through a custom view
* rather than relying purely on Filament's generated table layout.
*/
class ManageAgentInstructions extends Page implements HasTable
{
use HandlesBnfexpressErrors;
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedDocumentText;
protected static string|UnitEnum|null $navigationGroup = 'AI Agent';
protected static ?string $navigationLabel = 'Agent Instructions';
protected static ?string $title = 'Agent Instructions';
protected string $view = 'ai-agent::filament.pages.manage-agent-instructions';
/**
* @var array<string, mixed>|null
*/
public ?array $active = null;
public ?string $activeError = null;
public static function canAccess(): bool
{
return auth()->user()?->can('manage_ai_agent') ?? false;
}
public function mount(): void
{
$this->refreshActive();
}
public function table(Table $table): Table
{
return $table
->records(function (int $page, int $recordsPerPage): LengthAwarePaginator {
$result = app(BnfexpressAdminClient::class)->listInstructions(
limit: $recordsPerPage,
offset: ($page - 1) * $recordsPerPage,
);
$items = $result['instructions'] ?? (array_is_list($result) ? $result : []);
$total = $result['total'] ?? (($page - 1) * $recordsPerPage) + count($items) + (count($items) === $recordsPerPage ? 1 : 0);
return new LengthAwarePaginator(
items: collect($items)->mapWithKeys(fn (array $item): array => [$item['id'] => $item]),
total: $total,
perPage: $recordsPerPage,
currentPage: $page,
);
})
->columns([
TextColumn::make('id'),
IconColumn::make('is_active')->boolean(),
TextColumn::make('content')->limit(80)->wrap(),
TextColumn::make('created_at')->dateTime(),
])
->recordActions([
Action::make('activate')
->label('Activate')
->icon(Heroicon::OutlinedArrowUturnLeft)
->visible(fn (array $record): bool => ! ($record['is_active'] ?? false))
->requiresConfirmation()
->action(function (array $record): void {
$this->callBnfexpress(
fn () => app(BnfexpressAdminClient::class)->activateInstruction($record['id']),
successTitle: 'Instruction activated',
failureTitle: 'Failed to activate instruction',
);
$this->resetTable();
$this->refreshActive();
}),
])
->headerActions([
Action::make('publish')
->label('Publish New Version')
->icon(Heroicon::OutlinedPlusCircle)
->schema([
Textarea::make('content')
->required()
->rows(10),
Toggle::make('activate')
->label('Activate immediately')
->default(true)
->helperText('Deactivates the current active version automatically.'),
])
->action(function (array $data): void {
$this->callBnfexpress(
fn () => app(BnfexpressAdminClient::class)->publishInstruction($data['content'], $data['activate']),
successTitle: 'New instruction version published',
failureTitle: 'Failed to publish instruction',
);
$this->resetTable();
$this->refreshActive();
}),
]);
}
private function refreshActive(): void
{
try {
$this->active = app(BnfexpressAdminClient::class)->getActiveInstruction();
$this->activeError = null;
} catch (BnfexpressApiException $exception) {
$this->active = null;
$this->activeError = $exception->getMessage();
Notification::make()
->title('Could not load the active instruction')
->body($exception->getMessage())
->danger()
->send();
}
}
}
@@ -0,0 +1,177 @@
<?php
namespace Modules\AiAgent\Filament\Pages;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Textarea;
use Filament\Pages\Page;
use Filament\Schemas\Components\Component;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Pagination\LengthAwarePaginator;
use Modules\AiAgent\Filament\Concerns\HandlesBnfexpressErrors;
use Modules\Shared\Bnfexpress\BnfexpressAdminClient;
use UnitEnum;
/**
* Manage EV FAQs stored by bnfexpress data isn't Eloquent-backed, so the
* table is fed via Table::records() (Filament's documented "custom data"
* mechanism) rather than a query, and row/header actions use plain
* Filament\Actions\Action instead of EditAction/DeleteAction (which assume
* a Model).
*/
class ManageFaqs extends Page implements HasTable
{
use HandlesBnfexpressErrors;
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedQuestionMarkCircle;
protected static string|UnitEnum|null $navigationGroup = 'AI Agent';
protected static ?string $navigationLabel = 'EV FAQs';
protected static ?string $title = 'EV FAQs';
protected string $view = 'ai-agent::filament.pages.manage-faqs';
public static function canAccess(): bool
{
return auth()->user()?->can('manage_ai_agent') ?? false;
}
public function table(Table $table): Table
{
return $table
->records(function (?string $search, array $filters, int $page, int $recordsPerPage): LengthAwarePaginator {
$result = app(BnfexpressAdminClient::class)->listFaqs(
q: $search,
search: $filters['search_mode']['value'] ?? 'normal',
limit: $recordsPerPage,
offset: ($page - 1) * $recordsPerPage,
);
return $this->paginate($result, 'faqs', $page, $recordsPerPage);
})
->columns([
TextColumn::make('id'),
TextColumn::make('content')
->limit(80)
->wrap(),
TextColumn::make('metadata')
->formatStateUsing(fn (mixed $state): string => json_encode($state ?? [], JSON_THROW_ON_ERROR))
->toggleable(isToggledHiddenByDefault: true),
])
->searchable()
->filters([
SelectFilter::make('search_mode')
->label('Search mode')
->options([
'normal' => 'Normal (substring)',
'semantic' => 'Semantic (meaning-based)',
])
->default('normal'),
])
->recordActions([
$this->editAction(),
$this->deleteAction(),
])
->headerActions([
$this->createAction(),
]);
}
protected function createAction(): Action
{
return Action::make('create')
->label('New FAQ')
->icon(Heroicon::OutlinedPlus)
->schema($this->formSchema())
->action(function (array $data): void {
$this->callBnfexpress(
fn () => app(BnfexpressAdminClient::class)->createFaq($data['content'], $data['metadata'] ?? []),
successTitle: 'FAQ created',
failureTitle: 'Failed to create FAQ',
);
$this->resetTable();
});
}
protected function editAction(): Action
{
return Action::make('edit')
->icon(Heroicon::OutlinedPencilSquare)
->fillForm(fn (array $record): array => $record)
->schema($this->formSchema())
->action(function (array $data, array $record): void {
$this->callBnfexpress(
fn () => app(BnfexpressAdminClient::class)->updateFaq($record['id'], $data['content'], $data['metadata'] ?? []),
successTitle: 'FAQ updated',
failureTitle: 'Failed to update FAQ',
);
$this->resetTable();
});
}
protected function deleteAction(): Action
{
return Action::make('delete')
->color('danger')
->icon(Heroicon::OutlinedTrash)
->requiresConfirmation()
->action(function (array $record): void {
$this->callBnfexpress(
fn () => app(BnfexpressAdminClient::class)->deleteFaq($record['id']),
successTitle: 'FAQ deleted',
failureTitle: 'Failed to delete FAQ',
);
$this->resetTable();
});
}
/**
* @return array<int, Component>
*/
protected function formSchema(): array
{
return [
Textarea::make('content')
->required()
->rows(4),
KeyValue::make('metadata'),
];
}
/**
* bnfexpress's list/faqs and list/agent-instructions endpoints return a
* bare JSON array (confirmed live via `php artisan bnfexpress:smoke-test`),
* with no total/limit/offset envelope so there's no real total to
* report, and this falls back to a "there might be one more page"
* heuristic (also tolerates a {total, <$itemsKey>} envelope, in case
* that ever changes).
*
* @param array<string, mixed> $result
*/
private function paginate(array $result, string $itemsKey, int $page, int $recordsPerPage): LengthAwarePaginator
{
$items = $result[$itemsKey] ?? (array_is_list($result) ? $result : []);
$total = $result['total'] ?? (($page - 1) * $recordsPerPage) + count($items) + (count($items) === $recordsPerPage ? 1 : 0);
return new LengthAwarePaginator(
items: collect($items)->mapWithKeys(fn (array $item): array => [$item['id'] => $item]),
total: $total,
perPage: $recordsPerPage,
currentPage: $page,
);
}
}
@@ -0,0 +1,150 @@
<?php
namespace Modules\AiAgent\Filament\Pages;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\BulkAction;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Modules\AiAgent\Filament\Concerns\HandlesBnfexpressErrors;
use Modules\AiAgent\Filament\Concerns\PaginatesBnfexpressLists;
use Modules\Shared\Bnfexpress\BnfexpressAdminClient;
use UnitEnum;
/**
* Browse bnfexpress's "suggestion misses" queries typed by real users that
* no suggestion tier answered and either dismiss them (noise) or promote
* a batch straight into the suggestions bank. Read-mostly: no create/edit,
* these rows are only ever produced by bnfexpress's own suggest pipeline.
*/
class ManageSuggestionMisses extends Page implements HasTable
{
use HandlesBnfexpressErrors;
use InteractsWithTable;
use PaginatesBnfexpressLists;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedMagnifyingGlassCircle;
protected static string|UnitEnum|null $navigationGroup = 'AI Agent';
protected static ?string $navigationLabel = 'Suggestion Misses';
protected static ?string $title = 'Suggestion Misses';
protected string $view = 'ai-agent::filament.pages.manage-suggestion-misses';
public static function canAccess(): bool
{
return auth()->user()?->can('manage_ai_agent') ?? false;
}
public function table(Table $table): Table
{
return $table
->records(function (array $filters, int $page, int $recordsPerPage): LengthAwarePaginator {
$wasUsed = $filters['was_used']['value'] ?? null;
$result = app(BnfexpressAdminClient::class)->listSuggestionMisses(
wasUsed: $wasUsed === null || $wasUsed === '' ? null : (bool) $wasUsed,
limit: $recordsPerPage,
offset: ($page - 1) * $recordsPerPage,
);
return $this->paginateBareList($result, 'misses', 'id', $page, $recordsPerPage);
})
->columns([
TextColumn::make('id'),
TextColumn::make('text_norm')
->limit(80)
->wrap(),
TextColumn::make('lang')
->placeholder('—'),
TextColumn::make('syllables')
->placeholder('—'),
IconColumn::make('was_used')
->boolean(),
TextColumn::make('created_at')
->dateTime()
->sortable(),
])
->defaultSort('created_at', 'desc')
// SelectFilter (not TernaryFilter) so the value lands in
// $filters['was_used']['value'] predictably — TernaryFilter's
// internal field key isn't documented for the custom-data path.
->filters([
SelectFilter::make('was_used')
->label('Used?')
->options(['1' => 'Used', '0' => 'Not used']),
])
->recordActions([
$this->dismissAction(),
])
->toolbarActions([
$this->promoteBulkAction(),
]);
}
protected function dismissAction(): Action
{
return Action::make('dismiss')
->color('danger')
->icon(Heroicon::OutlinedTrash)
->requiresConfirmation()
->action(function (array $record): void {
$this->callBnfexpress(
fn () => app(BnfexpressAdminClient::class)->dismissSuggestionMiss($record['id']),
successTitle: 'Miss dismissed',
failureTitle: 'Failed to dismiss miss',
);
$this->resetTable();
});
}
protected function promoteBulkAction(): BulkAction
{
return BulkAction::make('promote')
->label('Promote Selected')
->icon(Heroicon::OutlinedArrowUp)
->fetchSelectedRecords(false)
->schema([
TextInput::make('lang')
->label('Language override')
->helperText("Applied to every selected miss; leave blank to keep each one's own language.")
->maxLength(10),
TextInput::make('intent'),
])
->deselectRecordsAfterCompletion()
->action(function (array $data, Collection $records): void {
// Same caveat as ManageSuggestions' deleteSelected — the collection
// holds full row arrays, not just keys, for a custom-data table.
$this->callBnfexpressForResult(
fn () => app(BnfexpressAdminClient::class)->promoteSuggestionMisses(
$records->keys()->all(),
$data['lang'] ?: null,
$data['intent'] ?: null,
),
function (array $result): void {
Notification::make()
->title("{$result['created']} promoted, {$result['skipped']} skipped")
->success()
->send();
$this->resetTable();
},
failureTitle: 'Failed to promote suggestion misses',
);
});
}
}
@@ -0,0 +1,414 @@
<?php
namespace Modules\AiAgent\Filament\Pages;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\BulkAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Component;
use Filament\Schemas\Components\Text;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Modules\AiAgent\Filament\Concerns\HandlesBnfexpressErrors;
use Modules\AiAgent\Filament\Concerns\PaginatesBnfexpressLists;
use Modules\Shared\Bnfexpress\BnfexpressAdminClient;
use Modules\Shared\Bnfexpress\Exceptions\BnfexpressApiException;
use UnitEnum;
/**
* Manage bnfexpress's autocomplete "suggestions" phrase bank: CRUD, batch
* import, batch delete, and syncing embeddings folded into one page per
* the sync-alongside-CRUD layout (rather than a separate sync-only page).
* Data isn't Eloquent-backed, so the table is fed via Table::records() and
* mutating actions use plain Filament\Actions\Action, same as ManageFaqs.
*/
class ManageSuggestions extends Page implements HasTable
{
use HandlesBnfexpressErrors;
use InteractsWithTable;
use PaginatesBnfexpressLists;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedSparkles;
protected static string|UnitEnum|null $navigationGroup = 'AI Agent';
protected static ?string $navigationLabel = 'Suggestions';
protected static ?string $title = 'Suggestions';
protected string $view = 'ai-agent::filament.pages.manage-suggestions';
public ?string $syncJobId = null;
public ?string $syncStatus = null;
/**
* @var mixed
*/
public $syncResult = null;
public static function canAccess(): bool
{
return auth()->user()?->can('manage_ai_agent') ?? false;
}
public function table(Table $table): Table
{
return $table
->records(function (?string $search, int $page, int $recordsPerPage): LengthAwarePaginator {
$result = app(BnfexpressAdminClient::class)->listSuggestions(
q: $search,
limit: $recordsPerPage,
offset: ($page - 1) * $recordsPerPage,
);
return $this->paginateBareList($result, 'suggestions', 'id', $page, $recordsPerPage);
})
->columns([
TextColumn::make('id'),
TextColumn::make('text_display')
->limit(80)
->wrap(),
TextColumn::make('lang'),
TextColumn::make('intent')
->placeholder('—'),
TextColumn::make('weight')
->sortable(false),
TextColumn::make('source'),
TextColumn::make('synced_at')
->dateTime()
->placeholder('Never')
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('updated_at')
->dateTime(),
])
->searchable()
->recordActions([
$this->editAction(),
$this->deleteAction(),
$this->syncEmbeddingAction(),
$this->deleteEmbeddingAction(),
])
->toolbarActions([
$this->deleteSelectedBulkAction(),
])
->headerActions([
$this->createAction(),
$this->createManyAction(),
$this->syncAction(),
$this->reloadIndexAction(),
]);
}
protected function createAction(): Action
{
return Action::make('create')
->label('New Suggestion')
->icon(Heroicon::OutlinedPlus)
->schema($this->formSchema())
->action(function (array $data): void {
$this->callBnfexpress(
fn () => app(BnfexpressAdminClient::class)->createSuggestion(
$data['text_display'],
$data['lang'],
$data['intent'] ?: null,
(int) ($data['weight'] ?? 0),
$data['source'] ?: 'admin',
),
successTitle: 'Suggestion created',
failureTitle: 'Failed to create suggestion',
);
$this->resetTable();
});
}
protected function createManyAction(): Action
{
return Action::make('createMany')
->label('Create Many')
->icon(Heroicon::OutlinedQueueList)
->schema([
// No source field here — bnfexpress's batch endpoint always tags
// these rows source: "mined" server-side (it's a thin wrapper
// around the same promote() misses-promotion uses), regardless
// of what's sent, so exposing a picker would be misleading.
Text::make('Created rows are tagged source: mined by bnfexpress.')
->color('gray'),
Textarea::make('items_raw')
->label('Phrases (one per line)')
->required()
->rows(8),
TextInput::make('lang')
->required()
->maxLength(10),
TextInput::make('intent'),
])
->action(function (array $data): void {
$items = collect(preg_split('/\r\n|\r|\n/', (string) $data['items_raw']))
->map(fn (string $line): string => trim($line))
->filter()
->map(fn (string $text): array => array_filter([
'text' => $text,
'lang' => $data['lang'],
'intent' => $data['intent'] ?: null,
], fn (mixed $value): bool => $value !== null))
->values()
->all();
$this->callBnfexpressForResult(
fn () => app(BnfexpressAdminClient::class)->batchCreateSuggestions($items),
function (array $result): void {
Notification::make()
->title("{$result['created']} created, {$result['skipped']} skipped")
->success()
->send();
$this->resetTable();
},
failureTitle: 'Failed to create suggestions',
);
});
}
protected function editAction(): Action
{
return Action::make('edit')
->icon(Heroicon::OutlinedPencilSquare)
->fillForm(fn (array $record): array => $record)
->schema($this->formSchema())
->action(function (array $data, array $record): void {
$this->callBnfexpress(
fn () => app(BnfexpressAdminClient::class)->updateSuggestion(
$record['id'],
$data['text_display'],
$data['lang'],
$data['intent'] ?: null,
(int) ($data['weight'] ?? 0),
$data['source'] ?: null,
),
successTitle: 'Suggestion updated',
failureTitle: 'Failed to update suggestion',
);
$this->resetTable();
});
}
protected function deleteAction(): Action
{
return Action::make('delete')
->color('danger')
->icon(Heroicon::OutlinedTrash)
->requiresConfirmation()
->action(function (array $record): void {
$this->callBnfexpress(
fn () => app(BnfexpressAdminClient::class)->deleteSuggestion($record['id']),
successTitle: 'Suggestion deleted',
failureTitle: 'Failed to delete suggestion',
);
$this->resetTable();
});
}
protected function deleteSelectedBulkAction(): BulkAction
{
return BulkAction::make('deleteSelected')
->label('Delete Selected')
->color('danger')
->icon(Heroicon::OutlinedTrash)
->requiresConfirmation()
->fetchSelectedRecords(false)
->deselectRecordsAfterCompletion()
->action(function (Collection $records): void {
// fetchSelectedRecords(false) still resolves full row arrays for a
// custom-data table (there's no cheap ID-only path like an Eloquent
// query) — the record keys (our suggestion ids) are what's wanted here.
$this->callBnfexpressForResult(
fn () => app(BnfexpressAdminClient::class)->batchDeleteSuggestions($records->keys()->all()),
function (array $result): void {
Notification::make()
->title("{$result['deleted']} deleted, {$result['skipped']} skipped")
->success()
->send();
$this->resetTable();
},
failureTitle: 'Failed to delete suggestions',
);
});
}
protected function syncEmbeddingAction(): Action
{
return Action::make('syncEmbedding')
->label('Sync Embedding')
->icon(Heroicon::OutlinedArrowPath)
->action(function (array $record): void {
$this->callBnfexpress(
fn () => app(BnfexpressAdminClient::class)->syncOneSuggestion($record['id']),
successTitle: 'Embedding synced',
failureTitle: 'Failed to sync embedding',
);
$this->resetTable();
});
}
protected function deleteEmbeddingAction(): Action
{
return Action::make('deleteEmbedding')
->label('Delete Embedding')
->color('danger')
->icon(Heroicon::OutlinedXCircle)
->requiresConfirmation()
->action(function (array $record): void {
$this->callBnfexpress(
fn () => app(BnfexpressAdminClient::class)->deleteSuggestionEmbedding($record['id']),
successTitle: 'Embedding deleted',
failureTitle: 'Failed to delete embedding',
);
$this->resetTable();
});
}
/**
* Kicks off a full re-embed job and starts polling for it (pollSyncStatus(),
* driven by wire:poll in the view) rather than notifying immediately
* the real outcome only lands once the job finishes.
*/
protected function syncAction(): Action
{
return Action::make('sync')
->label('Sync to Chroma')
->icon(Heroicon::OutlinedArrowPath)
->action(function (): void {
try {
$result = app(BnfexpressAdminClient::class)->syncSuggestions();
$this->syncJobId = $result['job_id'] ?? null;
$this->syncStatus = 'queued';
$this->syncResult = null;
} catch (BnfexpressApiException $exception) {
Notification::make()
->title('Failed to start sync')
->body($exception->getMessage())
->danger()
->send();
}
});
}
protected function reloadIndexAction(): Action
{
return Action::make('reloadIndex')
->label('Reload Index')
->icon(Heroicon::OutlinedArrowPath)
->requiresConfirmation()
->action(function (): void {
$this->callBnfexpress(
fn () => app(BnfexpressAdminClient::class)->reloadSuggestionIndex(),
successTitle: 'Index reloaded',
failureTitle: 'Failed to reload index',
);
});
}
/**
* Polls a sync job's status (wire:poll.2s, see the view). On "finished",
* chains a reload-index call same two-step flow shweai_backend's admin
* JS does (POST sync-chroma poll POST reload-index) and refreshes
* the table. On "failed", notifies and stops polling.
*/
public function pollSyncStatus(): void
{
if ($this->syncJobId === null) {
return;
}
try {
$status = app(BnfexpressAdminClient::class)->getSuggestionSyncStatus($this->syncJobId);
} catch (BnfexpressApiException $exception) {
$this->syncJobId = null;
Notification::make()
->title('Failed to check sync status')
->body($exception->getMessage())
->danger()
->send();
return;
}
$this->syncStatus = $status['status'] ?? null;
if ($this->syncStatus === 'finished') {
$this->syncResult = $status['result'] ?? null;
$this->syncJobId = null;
$this->callBnfexpress(
fn () => app(BnfexpressAdminClient::class)->reloadSuggestionIndex(),
successTitle: 'Sync completed and index reloaded',
failureTitle: 'Sync finished but reloading the index failed',
);
$this->resetTable();
} elseif ($this->syncStatus === 'failed') {
$this->syncJobId = null;
Notification::make()
->title('Sync failed')
->body(is_string($status['result'] ?? null) ? $status['result'] : 'Unknown error.')
->danger()
->send();
}
// else: still queued/running — the view keeps polling.
}
/**
* @return array<int, Component>
*/
protected function formSchema(): array
{
return [
Textarea::make('text_display')
->required()
->rows(3),
TextInput::make('lang')
->required()
->maxLength(10),
TextInput::make('intent'),
TextInput::make('weight')
->numeric()
->default(0),
// Fixed choices rather than free text (matching shweai_backend's
// seed/mined dropdown) — 'admin' is bnfexpress's own default for
// a CRUD-created row (SuggestionAdminCreate.source), 'mined' is
// what promote() tags a row with. 'seed' isn't used by any
// bnfexpress code path today (unlike the other two, which are
// hardcoded/defaulted server-side) — source is just a free string
// there, so this is offered for admins bootstrapping initial
// phrases who want that distinct from an ad-hoc manual entry,
// same convention as shweai_backend's own seed/mined dropdown.
Select::make('source')
->options([
'admin' => 'Admin (manual entry)',
'seed' => 'Seed (initial/bootstrap data)',
'mined' => 'Mined (promoted from a miss)',
])
->default('admin')
->native(false)
->required(),
];
}
}
@@ -0,0 +1,109 @@
<?php
namespace Modules\AiAgent\Filament\Pages;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Contracts\View\View;
use Illuminate\Pagination\LengthAwarePaginator;
use Modules\Shared\Bnfexpress\BnfexpressAdminClient;
use Modules\Shared\Bnfexpress\Exceptions\BnfexpressApiException;
use UnitEnum;
/**
* Read-only browse of all users' EV chat sessions. No create/edit/delete
* this is a support/QA tool, not a data-management screen.
*/
class ViewEvChatHistory extends Page implements HasTable
{
use InteractsWithTable;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedChatBubbleLeftRight;
protected static string|UnitEnum|null $navigationGroup = 'AI Agent';
protected static ?string $navigationLabel = 'EV Chat History';
protected static ?string $title = 'EV Chat History';
protected string $view = 'ai-agent::filament.pages.view-ev-chat-history';
public static function canAccess(): bool
{
return auth()->user()?->can('manage_ai_agent') ?? false;
}
public function table(Table $table): Table
{
return $table
->records(function (int $page, int $recordsPerPage): LengthAwarePaginator {
$result = app(BnfexpressAdminClient::class)->listSessions(
limit: $recordsPerPage,
offset: ($page - 1) * $recordsPerPage,
);
$sessions = $result['sessions'] ?? [];
return new LengthAwarePaginator(
items: collect($sessions)->mapWithKeys(
fn (array $session): array => ["{$session['user_id']}:{$session['session_id']}" => $session]
),
total: $result['total'] ?? count($sessions),
perPage: $result['limit'] ?? $recordsPerPage,
currentPage: $page,
);
})
->columns([
TextColumn::make('session_id')
->limit(20)
->tooltip(fn (TextColumn $column): ?string => $this->tooltipIfTruncated($column)),
TextColumn::make('user_id')
->limit(20)
->tooltip(fn (TextColumn $column): ?string => $this->tooltipIfTruncated($column)),
TextColumn::make('title')->limit(50),
TextColumn::make('last_message')->limit(80)->wrap(),
TextColumn::make('updated_at')->dateTime(),
])
->recordActions([
Action::make('view')
->label('View Transcript')
->icon(Heroicon::OutlinedEye)
->modalHeading(fn (array $record): string => "Transcript — {$record['session_id']}")
->modalContent(fn (array $record): View => $this->transcriptView($record))
->modalWidth('2xl')
->modalSubmitAction(false)
->modalCancelActionLabel('Close'),
]);
}
private function tooltipIfTruncated(TextColumn $column): ?string
{
$state = (string) $column->getState();
return strlen($state) > $column->getCharacterLimit() ? $state : null;
}
private function transcriptView(array $record): View
{
try {
$transcript = app(BnfexpressAdminClient::class)->getSessionTranscript($record['user_id'], $record['session_id']);
return view('ai-agent::filament.pages.partials.transcript', ['transcript' => $transcript]);
} catch (BnfexpressApiException $exception) {
Notification::make()
->title('Could not load transcript')
->body($exception->getMessage())
->danger()
->send();
return view('ai-agent::filament.pages.partials.transcript-error', ['message' => $exception->getMessage()]);
}
}
}
@@ -0,0 +1,12 @@
<?php
namespace Modules\AiAgent\Providers;
use Illuminate\Support\ServiceProvider;
class AiAgentServiceProvider extends ServiceProvider
{
public function register(): void {}
public function boot(): void {}
}
@@ -0,0 +1,97 @@
<?php
use App\Models\User;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
use Modules\AiAgent\Filament\Pages\ManageAgentInstructions;
use Spatie\Permission\Models\Permission;
beforeEach(function () {
config([
'services.bnfexpress.ai_api_url' => 'https://bnfexpress.test',
'services.bnfexpress.client_id' => 'ev_admin',
'services.bnfexpress.client_secret' => 'test-secret',
]);
Permission::findOrCreate('manage_ai_agent', 'web');
$this->admin = User::factory()->create()->givePermissionTo(['manage_ai_agent']);
$this->actingAs($this->admin);
});
test('a user without manage_ai_agent cannot access it', function () {
$this->actingAs(User::factory()->create());
expect(ManageAgentInstructions::canAccess())->toBeFalse();
});
test('it renders the active instruction and version history', function () {
Http::fake([
'bnfexpress.test/admin/agent-instructions/active*' => Http::response(['id' => 2, 'content' => 'You are the EV assistant.']),
'bnfexpress.test/admin/agent-instructions*' => Http::response([
'total' => 1,
'instructions' => [['id' => 2, 'is_active' => true, 'content' => 'You are the EV assistant.', 'created_at' => now()->toIso8601String()]],
]),
]);
Livewire::test(ManageAgentInstructions::class)
->assertOk()
->assertSee('You are the EV assistant.')
->loadTable()
->assertSee('You are the EV assistant.');
});
test('publishing a new version calls publishInstruction and shows a success notification', function () {
Http::fake([
'bnfexpress.test/admin/agent-instructions/active*' => Http::response(['id' => 3, 'content' => 'New instructions.']),
'bnfexpress.test/admin/agent-instructions*' => fn (Request $request) => match ($request->method()) {
'POST' => Http::response(['id' => 3, 'content' => 'New instructions.', 'is_active' => true], 201),
default => Http::response(['total' => 0, 'instructions' => []]),
},
]);
Livewire::test(ManageAgentInstructions::class)
->loadTable()
->callTableAction('publish', data: ['content' => 'New instructions.', 'activate' => true])
->assertNotified('New instruction version published');
Http::assertSent(fn (Request $request) => $request->method() === 'POST'
&& str_contains($request->url(), '/admin/agent-instructions')
&& ! str_contains($request->url(), '/active')
&& $request['content'] === 'New instructions.'
&& $request['activate'] === true);
});
test('a failed publish surfaces the gateway detail message', function () {
Http::fake([
'bnfexpress.test/admin/agent-instructions/active*' => Http::response(['id' => 1, 'content' => 'Old instructions.']),
'bnfexpress.test/admin/agent-instructions*' => fn (Request $request) => match ($request->method()) {
'POST' => Http::response(['detail' => 'Content is required.'], 422),
default => Http::response(['total' => 0, 'instructions' => []]),
},
]);
Livewire::test(ManageAgentInstructions::class)
->loadTable()
->callTableAction('publish', data: ['content' => 'New instructions.', 'activate' => true])
->assertNotified('Failed to publish instruction');
});
test('activate is hidden on the already-active row and visible on others', function () {
Http::fake([
'bnfexpress.test/admin/agent-instructions/active*' => Http::response(['id' => 2, 'content' => 'Current.']),
'bnfexpress.test/admin/agent-instructions*' => Http::response([
'total' => 2,
'instructions' => [
['id' => 2, 'is_active' => true, 'content' => 'Current.', 'created_at' => now()->toIso8601String()],
['id' => 1, 'is_active' => false, 'content' => 'Older.', 'created_at' => now()->toIso8601String()],
],
]),
]);
Livewire::test(ManageAgentInstructions::class)
->loadTable()
->assertTableActionHidden('activate', 2)
->assertTableActionVisible('activate', 1);
});
@@ -0,0 +1,83 @@
<?php
use App\Models\User;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
use Modules\AiAgent\Filament\Pages\ManageFaqs;
use Spatie\Permission\Models\Permission;
beforeEach(function () {
config([
'services.bnfexpress.ai_api_url' => 'https://bnfexpress.test',
'services.bnfexpress.client_id' => 'ev_admin',
'services.bnfexpress.client_secret' => 'test-secret',
]);
Permission::findOrCreate('manage_ai_agent', 'web');
$this->admin = User::factory()->create()->givePermissionTo(['manage_ai_agent']);
$this->actingAs($this->admin);
});
test('a user without manage_ai_agent cannot access it', function () {
$this->actingAs(User::factory()->create());
expect(ManageFaqs::canAccess())->toBeFalse();
});
test('it renders and lists faqs from the client', function () {
Http::fake(['bnfexpress.test/*' => Http::response([
'total' => 1,
'faqs' => [
['id' => 1, 'content' => 'How do I charge my EV?', 'metadata' => []],
],
])]);
Livewire::test(ManageFaqs::class)
->assertOk()
->loadTable()
->assertSee('How do I charge my EV?');
Http::assertSent(fn (Request $request) => str_contains($request->url(), '/admin/faqs') && $request->method() === 'GET');
});
test('creating a faq calls createFaq and shows a success notification', function () {
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match ($request->method()) {
'POST' => Http::response(['id' => 1, 'content' => 'New FAQ', 'metadata' => []], 201),
default => Http::response(['total' => 0, 'faqs' => []]),
}]);
Livewire::test(ManageFaqs::class)
->loadTable()
->callTableAction('create', data: ['content' => 'New FAQ', 'metadata' => []])
->assertNotified('FAQ created');
Http::assertSent(fn (Request $request) => $request->method() === 'POST' && $request['content'] === 'New FAQ');
});
test('a failed create surfaces the gateway detail message', function () {
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match ($request->method()) {
'POST' => Http::response(['detail' => 'Content is required.'], 422),
default => Http::response(['total' => 0, 'faqs' => []]),
}]);
Livewire::test(ManageFaqs::class)
->loadTable()
->callTableAction('create', data: ['content' => 'New FAQ', 'metadata' => []])
->assertNotified('Failed to create FAQ');
});
test('deleting a faq calls deleteFaq and shows a success notification', function () {
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match ($request->method()) {
'DELETE' => Http::response(['deleted' => true]),
default => Http::response(['total' => 1, 'faqs' => [['id' => 1, 'content' => 'To delete', 'metadata' => []]]]),
}]);
Livewire::test(ManageFaqs::class)
->loadTable()
->callTableAction('delete', 1)
->assertNotified('FAQ deleted');
Http::assertSent(fn (Request $request) => $request->method() === 'DELETE' && str_contains($request->url(), '/admin/faqs/1'));
});
@@ -0,0 +1,89 @@
<?php
use App\Models\User;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
use Modules\AiAgent\Filament\Pages\ManageSuggestionMisses;
use Spatie\Permission\Models\Permission;
beforeEach(function () {
config([
'services.bnfexpress.ai_api_url' => 'https://bnfexpress.test',
'services.bnfexpress.client_id' => 'ev_admin',
'services.bnfexpress.client_secret' => 'test-secret',
]);
Permission::findOrCreate('manage_ai_agent', 'web');
$this->admin = User::factory()->create()->givePermissionTo(['manage_ai_agent']);
$this->actingAs($this->admin);
});
test('a user without manage_ai_agent cannot access it', function () {
$this->actingAs(User::factory()->create());
expect(ManageSuggestionMisses::canAccess())->toBeFalse();
});
test('it renders and lists misses from the client', function () {
Http::fake(['bnfexpress.test/*' => Http::response([
['id' => 1, 'text_norm' => 'ev charging cost', 'lang' => 'en', 'syllables' => 3, 'was_used' => false, 'created_at' => now()->toIso8601String()],
])]);
Livewire::test(ManageSuggestionMisses::class)
->assertOk()
->loadTable()
->assertSee('ev charging cost');
Http::assertSent(fn (Request $request) => str_contains($request->url(), '/admin/suggestion-misses') && $request->method() === 'GET');
});
test('dismiss calls dismissSuggestionMiss and shows a success notification', function () {
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match ($request->method()) {
'DELETE' => Http::response([]),
default => Http::response([
['id' => 1, 'text_norm' => 'to dismiss', 'lang' => 'en', 'syllables' => 2, 'was_used' => false, 'created_at' => now()->toIso8601String()],
]),
}]);
Livewire::test(ManageSuggestionMisses::class)
->loadTable()
->callTableAction('dismiss', 1)
->assertNotified('Miss dismissed');
Http::assertSent(fn (Request $request) => $request->method() === 'DELETE' && str_contains($request->url(), '/admin/suggestion-misses/1'));
});
test('a failed dismiss surfaces the gateway detail message', function () {
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match ($request->method()) {
'DELETE' => Http::response(['detail' => 'Suggestion miss not found.'], 404),
default => Http::response([
['id' => 1, 'text_norm' => 'to dismiss', 'lang' => 'en', 'syllables' => 2, 'was_used' => false, 'created_at' => now()->toIso8601String()],
]),
}]);
Livewire::test(ManageSuggestionMisses::class)
->loadTable()
->callTableAction('dismiss', 1)
->assertNotified('Failed to dismiss miss');
});
test('promote bulk action calls promoteSuggestionMisses with the selected ids and shows the result', function () {
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match (true) {
str_contains($request->url(), '/admin/suggestion-misses/promote') => Http::response(['created' => 2, 'skipped' => 0, 'trie_rebuilt' => true]),
default => Http::response([
['id' => 1, 'text_norm' => 'a', 'lang' => 'en', 'syllables' => 1, 'was_used' => false, 'created_at' => now()->toIso8601String()],
['id' => 2, 'text_norm' => 'b', 'lang' => 'en', 'syllables' => 1, 'was_used' => false, 'created_at' => now()->toIso8601String()],
]),
}]);
Livewire::test(ManageSuggestionMisses::class)
->loadTable()
->callTableBulkAction('promote', [1, 2], data: ['lang' => 'my', 'intent' => null])
->assertNotified('2 promoted, 0 skipped');
Http::assertSent(fn (Request $request) => str_contains($request->url(), '/admin/suggestion-misses/promote')
&& $request['miss_ids'] === [1, 2]
&& $request['lang'] === 'my');
});
@@ -0,0 +1,187 @@
<?php
use App\Models\User;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
use Modules\AiAgent\Filament\Pages\ManageSuggestions;
use Spatie\Permission\Models\Permission;
beforeEach(function () {
config([
'services.bnfexpress.ai_api_url' => 'https://bnfexpress.test',
'services.bnfexpress.client_id' => 'ev_admin',
'services.bnfexpress.client_secret' => 'test-secret',
]);
Permission::findOrCreate('manage_ai_agent', 'web');
$this->admin = User::factory()->create()->givePermissionTo(['manage_ai_agent']);
$this->actingAs($this->admin);
});
test('a user without manage_ai_agent cannot access it', function () {
$this->actingAs(User::factory()->create());
expect(ManageSuggestions::canAccess())->toBeFalse();
});
test('it renders and lists suggestions from the client', function () {
Http::fake(['bnfexpress.test/*' => Http::response([
['id' => 1, 'text_display' => 'How do I charge my EV?', 'lang' => 'en', 'intent' => null, 'weight' => 0, 'source' => 'manual', 'updated_at' => now()->toIso8601String()],
])]);
Livewire::test(ManageSuggestions::class)
->assertOk()
->loadTable()
->assertSee('How do I charge my EV?');
Http::assertSent(fn (Request $request) => str_contains($request->url(), '/admin/suggestions') && $request->method() === 'GET');
});
test('creating a suggestion calls createSuggestion and shows a success notification', function () {
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match ($request->method()) {
'POST' => Http::response(['id' => 1], 201),
default => Http::response([]),
}]);
Livewire::test(ManageSuggestions::class)
->loadTable()
->callTableAction('create', data: ['text_display' => 'New phrase', 'lang' => 'en', 'intent' => null, 'weight' => 0, 'source' => 'admin'])
->assertNotified('Suggestion created');
Http::assertSent(fn (Request $request) => $request->method() === 'POST'
&& $request->url() === 'https://bnfexpress.test/admin/suggestions'
&& $request['text_display'] === 'New phrase');
});
test('a failed create surfaces the gateway detail message', function () {
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match ($request->method()) {
'POST' => Http::response(['detail' => 'text_display is required.'], 422),
default => Http::response([]),
}]);
Livewire::test(ManageSuggestions::class)
->loadTable()
->callTableAction('create', data: ['text_display' => 'New phrase', 'lang' => 'en', 'intent' => null, 'weight' => 0, 'source' => 'admin'])
->assertNotified('Failed to create suggestion');
});
test('createMany splits pasted lines into batch items and shows the created/skipped result', function () {
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match (true) {
str_contains($request->url(), '/admin/suggestions/batch') => Http::response(['created' => 2, 'skipped' => 0, 'trie_rebuilt' => true]),
default => Http::response([]),
}]);
Livewire::test(ManageSuggestions::class)
->loadTable()
->callTableAction('createMany', data: ['items_raw' => "First phrase\nSecond phrase\n\n", 'lang' => 'en', 'intent' => null])
->assertNotified('2 created, 0 skipped');
Http::assertSent(fn (Request $request) => str_contains($request->url(), '/admin/suggestions/batch')
&& $request['items'] === [
['text' => 'First phrase', 'lang' => 'en'],
['text' => 'Second phrase', 'lang' => 'en'],
]);
});
test('deleting a suggestion calls deleteSuggestion and shows a success notification', function () {
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match ($request->method()) {
'DELETE' => Http::response([]),
default => Http::response([
['id' => 1, 'text_display' => 'To delete', 'lang' => 'en', 'intent' => null, 'weight' => 0, 'source' => 'manual', 'updated_at' => now()->toIso8601String()],
]),
}]);
Livewire::test(ManageSuggestions::class)
->loadTable()
->callTableAction('delete', 1)
->assertNotified('Suggestion deleted');
Http::assertSent(fn (Request $request) => $request->method() === 'DELETE' && str_contains($request->url(), '/admin/suggestions/1'));
});
test('deleteSelected bulk action calls batchDeleteSuggestions with just the selected ids', function () {
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match (true) {
$request->method() === 'DELETE' && str_contains($request->url(), '/admin/suggestions/batch') => Http::response(['deleted' => 2, 'skipped' => 0]),
default => Http::response([
['id' => 1, 'text_display' => 'A', 'lang' => 'en', 'intent' => null, 'weight' => 0, 'source' => 'manual', 'updated_at' => now()->toIso8601String()],
['id' => 2, 'text_display' => 'B', 'lang' => 'en', 'intent' => null, 'weight' => 0, 'source' => 'manual', 'updated_at' => now()->toIso8601String()],
]),
}]);
Livewire::test(ManageSuggestions::class)
->loadTable()
->callTableBulkAction('deleteSelected', [1, 2])
->assertNotified('2 deleted, 0 skipped');
Http::assertSent(fn (Request $request) => $request->method() === 'DELETE'
&& str_contains($request->url(), '/admin/suggestions/batch')
&& $request['ids'] === [1, 2]);
});
test('sync sets job state and polling chains a reload-index call on finished', function () {
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match (true) {
str_contains($request->url(), '/sync-chroma/job-1') => Http::response(['status' => 'finished', 'result' => ['synced' => 3]]),
str_contains($request->url(), '/sync-chroma') => Http::response(['job_id' => 'job-1'], 202),
str_contains($request->url(), '/reload-index') => Http::response(['trie_rebuilt' => true]),
default => Http::response([]),
}]);
$test = Livewire::test(ManageSuggestions::class)
->loadTable()
->callTableAction('sync')
->assertSet('syncJobId', 'job-1')
->assertSet('syncStatus', 'queued');
$test->call('pollSyncStatus')
->assertSet('syncJobId', null)
->assertSet('syncStatus', 'finished')
->assertNotified('Sync completed and index reloaded');
Http::assertSent(fn (Request $request) => str_contains($request->url(), '/admin/suggestions/reload-index'));
});
test('a failed sync job notifies danger and stops polling', function () {
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match (true) {
str_contains($request->url(), '/sync-chroma/job-1') => Http::response(['status' => 'failed', 'result' => null]),
str_contains($request->url(), '/sync-chroma') => Http::response(['job_id' => 'job-1'], 202),
default => Http::response([]),
}]);
Livewire::test(ManageSuggestions::class)
->loadTable()
->callTableAction('sync')
->call('pollSyncStatus')
->assertSet('syncJobId', null)
->assertNotified('Sync failed');
});
test('reloadIndex calls reloadSuggestionIndex directly', function () {
Http::fake(['bnfexpress.test/*' => Http::response(['trie_rebuilt' => true])]);
Livewire::test(ManageSuggestions::class)
->loadTable()
->callTableAction('reloadIndex')
->assertNotified('Index reloaded');
Http::assertSent(fn (Request $request) => str_contains($request->url(), '/admin/suggestions/reload-index'));
});
test('syncEmbedding and deleteEmbedding row actions call the right per-id endpoint', function () {
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match (true) {
str_contains($request->url(), '/1/sync-chroma') => Http::response(['synced' => true]),
str_contains($request->url(), '/1/chroma') => Http::response([]),
default => Http::response([
['id' => 1, 'text_display' => 'A', 'lang' => 'en', 'intent' => null, 'weight' => 0, 'source' => 'manual', 'updated_at' => now()->toIso8601String()],
]),
}]);
$test = Livewire::test(ManageSuggestions::class)->loadTable();
$test->callTableAction('syncEmbedding', 1)->assertNotified('Embedding synced');
$test->callTableAction('deleteEmbedding', 1)->assertNotified('Embedding deleted');
Http::assertSent(fn (Request $request) => str_contains($request->url(), '/admin/suggestions/1/sync-chroma') && $request->method() === 'POST');
Http::assertSent(fn (Request $request) => str_contains($request->url(), '/admin/suggestions/1/chroma') && $request->method() === 'DELETE');
});
@@ -0,0 +1,88 @@
<?php
use App\Models\User;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
use Modules\AiAgent\Filament\Pages\ViewEvChatHistory;
use Spatie\Permission\Models\Permission;
beforeEach(function () {
config([
'services.bnfexpress.ai_api_url' => 'https://bnfexpress.test',
'services.bnfexpress.client_id' => 'ev_admin',
'services.bnfexpress.client_secret' => 'test-secret',
]);
Permission::findOrCreate('manage_ai_agent', 'web');
$this->admin = User::factory()->create()->givePermissionTo(['manage_ai_agent']);
$this->actingAs($this->admin);
});
test('a user without manage_ai_agent cannot access it', function () {
$this->actingAs(User::factory()->create());
expect(ViewEvChatHistory::canAccess())->toBeFalse();
});
test('it lists sessions from the client', function () {
Http::fake(['bnfexpress.test/admin/ev/history*' => Http::response([
'total' => 1,
'limit' => 25,
'offset' => 0,
'sessions' => [
['session_id' => 'sess-1', 'user_id' => 'user-1', 'title' => 'Charging question', 'last_message' => 'Thanks!', 'updated_at' => now()->toIso8601String()],
],
])]);
Livewire::test(ViewEvChatHistory::class)
->assertOk()
->loadTable()
->assertSee('Charging question');
});
test('viewing a transcript shows its messages', function () {
Http::fake([
'bnfexpress.test/admin/ev/history/user-1/sess-1' => Http::response([
'session_id' => 'sess-1',
'user_id' => 'user-1',
'messages' => [
['author' => 'user', 'text' => 'How do I charge my EV?', 'timestamp' => now()->timestamp],
['author' => 'bnfexpress_ev_agent', 'text' => 'Plug it in at a station.', 'timestamp' => now()->timestamp],
],
]),
'bnfexpress.test/admin/ev/history*' => Http::response([
'total' => 1,
'limit' => 25,
'offset' => 0,
'sessions' => [
['session_id' => 'sess-1', 'user_id' => 'user-1', 'title' => 'Charging question', 'last_message' => 'Thanks!', 'updated_at' => now()->toIso8601String()],
],
]),
]);
Livewire::test(ViewEvChatHistory::class)
->loadTable()
->mountTableAction('view', 'user-1:sess-1')
->assertMountedActionModalSee('Plug it in at a station.');
});
test('a failed transcript fetch surfaces the gateway detail message', function () {
Http::fake([
'bnfexpress.test/admin/ev/history/user-1/sess-1' => Http::response(['detail' => 'Session not found.'], 404),
'bnfexpress.test/admin/ev/history*' => Http::response([
'total' => 1,
'limit' => 25,
'offset' => 0,
'sessions' => [
['session_id' => 'sess-1', 'user_id' => 'user-1', 'title' => 'Charging question', 'last_message' => 'Thanks!', 'updated_at' => now()->toIso8601String()],
],
]),
]);
Livewire::test(ViewEvChatHistory::class)
->loadTable()
->mountTableAction('view', 'user-1:sess-1')
->assertMountedActionModalSee('Session not found.')
->assertNotified('Could not load transcript');
});
@@ -4,6 +4,7 @@ namespace Modules\Booking\Actions;
use Modules\Booking\Data\AssignDriverData; use Modules\Booking\Data\AssignDriverData;
use Modules\Booking\Enums\BookingStatus; use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Events\DriverAssigned;
use Modules\Booking\Exceptions\DriverAssignmentNotAllowedException; use Modules\Booking\Exceptions\DriverAssignmentNotAllowedException;
use Modules\Booking\Models\Booking; use Modules\Booking\Models\Booking;
@@ -21,6 +22,12 @@ class AssignDriverAction
throw DriverAssignmentNotAllowedException::notConfirmed($booking); throw DriverAssignmentNotAllowedException::notConfirmed($booking);
} }
if ($booking->travel_date->lt(today())) {
throw DriverAssignmentNotAllowedException::travelDateInPast($booking);
}
$isFirstAssignment = $booking->driver_name === null;
$booking->update([ $booking->update([
'driver_name' => $data->driverName, 'driver_name' => $data->driverName,
'driver_phone' => $data->driverPhone, 'driver_phone' => $data->driverPhone,
@@ -28,6 +35,13 @@ class AssignDriverAction
'car_model' => $data->carModel, '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; return $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) {}
}
@@ -16,6 +16,13 @@ class DriverAssignmentNotAllowedException extends RuntimeException
); );
} }
public static function travelDateInPast(Booking $booking): self
{
return new self(
"Booking [{$booking->booking_ref}] cannot have a driver assigned because its travel date [{$booking->travel_date->toDateString()}] is in the past."
);
}
public function render(Request $request): ?JsonResponse public function render(Request $request): ?JsonResponse
{ {
if ($request->expectsJson()) { if ($request->expectsJson()) {
@@ -9,9 +9,9 @@ use RuntimeException;
class InvalidVehicleSelectionException extends RuntimeException class InvalidVehicleSelectionException extends RuntimeException
{ {
public static function frontSeatLimitExceeded(int $requested, int $max): self public static function passengerLimitExceeded(VehicleOption $vehicleOption, int $requested, int $max): self
{ {
return new self("Front seat request [{$requested}] exceeds the max of [{$max}] per booking."); return new self("Vehicle option [{$vehicleOption->value}] passenger count [{$requested}] exceeds the max of [{$max}] per booking.");
} }
public static function optionDisabled(VehicleOption $vehicleOption): self public static function optionDisabled(VehicleOption $vehicleOption): self
@@ -25,6 +25,7 @@ class AssignDriverTableAction
->icon(Heroicon::OutlinedTruck) ->icon(Heroicon::OutlinedTruck)
->color('primary') ->color('primary')
->visible(fn (Booking $record): bool => $record->status === BookingStatus::Confirmed ->visible(fn (Booking $record): bool => $record->status === BookingStatus::Confirmed
&& $record->travel_date->gte(today())
&& (auth()->user()?->can('manage_bookings') ?? false)) && (auth()->user()?->can('manage_bookings') ?? false))
->schema([ ->schema([
TextInput::make('driver_name')->required(), TextInput::make('driver_name')->required(),
@@ -7,6 +7,7 @@ use Modules\Booking\Filament\Resources\Bookings\Actions\AssignDriverTableAction;
use Modules\Booking\Filament\Resources\Bookings\Actions\CancelBookingTableAction; use Modules\Booking\Filament\Resources\Bookings\Actions\CancelBookingTableAction;
use Modules\Booking\Filament\Resources\Bookings\Actions\SetRemarkTableAction; use Modules\Booking\Filament\Resources\Bookings\Actions\SetRemarkTableAction;
use Modules\Booking\Filament\Resources\Bookings\BookingResource; use Modules\Booking\Filament\Resources\Bookings\BookingResource;
use Modules\Payment\Filament\Actions\RefundBookingTableAction;
class ViewBooking extends ViewRecord class ViewBooking extends ViewRecord
{ {
@@ -18,6 +19,7 @@ class ViewBooking extends ViewRecord
AssignDriverTableAction::make(), AssignDriverTableAction::make(),
SetRemarkTableAction::make(), SetRemarkTableAction::make(),
CancelBookingTableAction::make(), CancelBookingTableAction::make(),
RefundBookingTableAction::make(),
]; ];
} }
} }
@@ -20,6 +20,7 @@ use Modules\Booking\Filament\Resources\Bookings\Actions\RestoreBookingTableActio
use Modules\Booking\Filament\Resources\Bookings\Actions\SetRemarkTableAction; use Modules\Booking\Filament\Resources\Bookings\Actions\SetRemarkTableAction;
use Modules\Booking\Models\Booking; use Modules\Booking\Models\Booking;
use Modules\Catalog\Models\EvCompany; use Modules\Catalog\Models\EvCompany;
use Modules\Payment\Filament\Actions\RefundBookingTableAction;
use Modules\Routing\Models\EvRoute; use Modules\Routing\Models\EvRoute;
class BookingsTable class BookingsTable
@@ -148,6 +149,7 @@ class BookingsTable
AssignDriverTableAction::make(), AssignDriverTableAction::make(),
SetRemarkTableAction::make(), SetRemarkTableAction::make(),
CancelBookingTableAction::make(), CancelBookingTableAction::make(),
RefundBookingTableAction::make(),
DeleteBookingTableAction::make(), DeleteBookingTableAction::make(),
RestoreBookingTableAction::make(), RestoreBookingTableAction::make(),
]); ]);
@@ -15,6 +15,7 @@ use Modules\Booking\Enums\BookingChannel;
use Modules\Booking\Http\Requests\StoreBookingRequest; use Modules\Booking\Http\Requests\StoreBookingRequest;
use Modules\Booking\Http\Resources\BookingResource; use Modules\Booking\Http\Resources\BookingResource;
use Modules\Booking\Models\Booking; use Modules\Booking\Models\Booking;
use Modules\Payment\Enums\PaymentStatus;
use Modules\Shared\Enums\VehicleOption; use Modules\Shared\Enums\VehicleOption;
class BookingController extends Controller class BookingController extends Controller
@@ -50,6 +51,17 @@ class BookingController extends Controller
} }
$bookings = $query $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) ->with(self::EAGER_LOADS)
->latest() ->latest()
->paginate(); ->paginate();
@@ -31,6 +31,15 @@ class BookingResource extends JsonResource
'dropoff_lat' => $this->dropoff_lat, 'dropoff_lat' => $this->dropoff_lat,
'dropoff_lng' => $this->dropoff_lng, 'dropoff_lng' => $this->dropoff_lng,
'price' => $this->price, '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, 'created_by_channel' => $this->created_by_channel,
// Only ever populated once status is confirmed — see AssignDriverAction. // Only ever populated once status is confirmed — see AssignDriverAction.
'driver_name' => $this->driver_name, 'driver_name' => $this->driver_name,
@@ -82,6 +91,15 @@ class BookingResource extends JsonResource
'line_total' => $selection->line_total, 'line_total' => $selection->line_total,
]) ])
: null, : 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, 'created_at' => $this->created_at,
]; ];
@@ -0,0 +1,50 @@
<?php
namespace Modules\Booking\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Modules\Booking\Events\DriverAssigned;
use Modules\Shared\Sms\SmsService;
/**
* Notifies the passenger of their driver/car details whenever a driver is
* assigned or reassigned (domain.md driver/vehicle assignment). Queued
* since it's an outbound HTTP call to the SMS gateway.
*/
class SendDriverAssignedSms implements ShouldQueue
{
public function __construct(private readonly SmsService $smsService) {}
public function handle(DriverAssigned $event): void
{
$booking = $event->booking;
$this->smsService->send($booking->passenger_phone, $this->message($event));
}
private function message(DriverAssigned $event): string
{
$booking = $event->booking;
$vehicle = trim($booking->car_model !== null
? "{$booking->car_plate_number} ({$booking->car_model})"
: $booking->car_plate_number);
$route = $booking->route->fromDestination->name.' - '.$booking->route->toDestination->name;
$mmRoute = $booking->route->fromDestination->mm_name.' - '.$booking->route->toDestination->mm_name;
$appName = 'BNF Express - '.config('app.name');
$supportPhone = config('app.support_phone');
$supportEmail = config('app.support_email');
$contact = "Help: {$supportPhone} / {$supportEmail}\nအကူအညီလိုအပ်ပါက ဆက်သွယ်ရန်: {$supportPhone} / {$supportEmail}";
if ($event->isFirstAssignment) {
$en = "Your driver has been assigned for booking {$booking->booking_ref} ({$route}). Driver: {$booking->driver_name}, {$booking->driver_phone}. Vehicle: {$vehicle}.";
$mm = "ဘွတ်ကင် {$booking->booking_ref} ({$mmRoute}) အတွက် ယာဉ်မောင်း သတ်မှတ်ပြီးပါပြီ။ ယာဉ်မောင်း - {$booking->driver_name}, {$booking->driver_phone}။ ယာဉ် - {$vehicle}";
} else {
$en = "Driver info updated for booking {$booking->booking_ref} ({$route}). Driver: {$booking->driver_name}, {$booking->driver_phone}. Vehicle: {$vehicle}.";
$mm = "ဘွတ်ကင် {$booking->booking_ref} ({$mmRoute}) ၏ ယာဉ်မောင်းအချက်အလက်ကို ပြင်ဆင်ထားပါသည်။ ယာဉ်မောင်း - {$booking->driver_name}, {$booking->driver_phone}။ ယာဉ် - {$vehicle}";
}
return "{$appName}\n{$en}\n{$mm}\n{$contact}";
}
}
@@ -3,7 +3,10 @@
namespace Modules\Booking\Providers; namespace Modules\Booking\Providers;
use Illuminate\Contracts\Auth\Access\Gate; use Illuminate\Contracts\Auth\Access\Gate;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Modules\Booking\Events\DriverAssigned;
use Modules\Booking\Listeners\SendDriverAssignedSms;
use Modules\Booking\Policies\BookingPolicy; use Modules\Booking\Policies\BookingPolicy;
class BookingServiceProvider extends ServiceProvider class BookingServiceProvider extends ServiceProvider
@@ -13,5 +16,7 @@ class BookingServiceProvider extends ServiceProvider
public function boot(Gate $gate): void public function boot(Gate $gate): void
{ {
$gate->policy('Modules\Booking\Models\Booking', BookingPolicy::class); $gate->policy('Modules\Booking\Models\Booking', BookingPolicy::class);
// Event::listen(DriverAssigned::class, SendDriverAssignedSms::class);
} }
} }
@@ -9,11 +9,11 @@ use Modules\Shared\Enums\VehicleOption;
class BookingService class BookingService
{ {
/** /**
* Enforces the only v1 inventory rule (max Front Seats per booking), the * Enforces the same two blunt, config-driven rules for every Vehicle
* blunt config toggles for Back Seat / Whole Vehicle availability, and * Option an on/off toggle and a max passenger_count per booking (see
* shape rules around combining options in one booking (no duplicate * domain.md §2) plus shape rules around combining options in one
* option lines, Whole Vehicle can't be mixed with anything else since it * booking (no duplicate option lines, Whole Vehicle can't be mixed with
* already covers the whole car). * anything else since it already covers the whole car).
* *
* Deliberately does not check real capacity/availability that's an * Deliberately does not check real capacity/availability that's an
* explicitly deferred future phase (domain.md §2, §7). * explicitly deferred future phase (domain.md §2, §7).
@@ -43,26 +43,23 @@ class BookingService
private function validateOption(VehicleOption $vehicleOption, int $passengerCount): void private function validateOption(VehicleOption $vehicleOption, int $passengerCount): void
{ {
match ($vehicleOption) { $this->validateEnabled($vehicleOption);
VehicleOption::FrontSeat => $this->validateFrontSeat($passengerCount), $this->validateMax($vehicleOption, $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 private function validateEnabled(VehicleOption $vehicleOption): void
{ {
$max = config('booking.front_seat_max_per_booking'); if (! config("booking.{$vehicleOption->value}_enabled")) {
if ($passengerCount > $max) {
throw InvalidVehicleSelectionException::frontSeatLimitExceeded($passengerCount, $max);
}
}
private function validateEnabled(VehicleOption $vehicleOption, string $configKey): void
{
if (! config($configKey)) {
throw InvalidVehicleSelectionException::optionDisabled($vehicleOption); throw InvalidVehicleSelectionException::optionDisabled($vehicleOption);
} }
} }
private function validateMax(VehicleOption $vehicleOption, int $passengerCount): void
{
$max = config("booking.{$vehicleOption->value}_max_per_booking");
if ($passengerCount > $max) {
throw InvalidVehicleSelectionException::passengerLimitExceeded($vehicleOption, $passengerCount, $max);
}
}
} }
@@ -97,7 +97,37 @@ test('front-seat-limit rejection surfaces as 422', function () {
['vehicle_option' => 'front_seat', 'passenger_count' => 2], ['vehicle_option' => 'front_seat', 'passenger_count' => 2],
])) ]))
->assertStatus(422) ->assertStatus(422)
->assertJsonPath('message', 'Front seat request [2] exceeds the max of [1] per booking.'); ->assertJsonPath('message', 'Vehicle option [front_seat] passenger count [2] exceeds the max of [1] per booking.');
expect(Booking::count())->toBe(0);
});
test('back-seat-limit rejection surfaces as 422', function () {
config(['booking.back_seat_max_per_booking' => 1]);
[$route, $timeSlot] = bookableRouteAndSlot([[VehicleOption::BackSeat, '9000.00']]);
$this->withHeader('Authorization', "Bearer {$this->token}")
->postJson('/api/v1/bookings', bookingPayload($route, $timeSlot, [
['vehicle_option' => 'back_seat', 'passenger_count' => 2],
]))
->assertStatus(422)
->assertJsonPath('message', 'Vehicle option [back_seat] passenger count [2] exceeds the max of [1] per booking.');
expect(Booking::count())->toBe(0);
});
test('whole-vehicle-limit rejection surfaces as 422', function () {
config(['booking.whole_vehicle_max_per_booking' => 1]);
[$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' => 2],
]))
->assertStatus(422)
->assertJsonPath('message', 'Vehicle option [whole_vehicle] passenger count [2] exceeds the max of [1] per booking.');
expect(Booking::count())->toBe(0); expect(Booking::count())->toBe(0);
}); });
@@ -1,7 +1,10 @@
<?php <?php
use App\Models\User; use App\Models\User;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Models\Booking; use Modules\Booking\Models\Booking;
use Modules\Payment\Enums\PaymentMethod;
use Modules\Payment\Models\Payment;
use Spatie\Permission\Models\Permission; use Spatie\Permission\Models\Permission;
beforeEach(function () { beforeEach(function () {
@@ -11,10 +14,27 @@ beforeEach(function () {
$this->token = $this->owner->createToken('test-token')->plainTextToken; $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 () { test('index lists only the authenticated user\'s own bookings, latest first', function () {
$mine = Booking::factory()->create(['user_id' => $this->owner->id, 'created_at' => now()->subMinute()]); $mine = paidBooking(['user_id' => $this->owner->id, 'created_at' => now()->subMinute()]);
$mineNewer = Booking::factory()->create(['user_id' => $this->owner->id]); $mineNewer = paidBooking(['user_id' => $this->owner->id]);
Booking::factory()->create(['user_id' => User::factory()->create()->id]); paidBooking(['user_id' => User::factory()->create()->id]);
$this->withHeader('Authorization', "Bearer {$this->token}") $this->withHeader('Authorization', "Bearer {$this->token}")
->getJson('/api/v1/bookings') ->getJson('/api/v1/bookings')
@@ -24,6 +44,115 @@ test('index lists only the authenticated user\'s own bookings, latest first', fu
->assertJsonPath('data.1.id', $mine->id); ->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 () { test('index rejects unauthenticated requests', function () {
$this->getJson('/api/v1/bookings')->assertUnauthorized(); $this->getJson('/api/v1/bookings')->assertUnauthorized();
}); });
@@ -7,12 +7,46 @@ use Modules\Booking\Filament\Resources\Bookings\Pages\ListBookings;
use Modules\Booking\Filament\Resources\Bookings\Pages\ViewBooking; use Modules\Booking\Filament\Resources\Bookings\Pages\ViewBooking;
use Modules\Booking\Models\Booking; use Modules\Booking\Models\Booking;
use Modules\Booking\Models\BookingVehicleOption; use Modules\Booking\Models\BookingVehicleOption;
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\PaymentMethod;
use Modules\Payment\Enums\PaymentStatus; use Modules\Payment\Enums\PaymentStatus;
use Modules\Payment\Enums\RefundStatus;
use Modules\Payment\Factories\PaymentGatewayFactory;
use Modules\Payment\Models\Payment; use Modules\Payment\Models\Payment;
use Modules\Payment\Models\Refund;
use Modules\Shared\Enums\VehicleOption; use Modules\Shared\Enums\VehicleOption;
use Spatie\Permission\Models\Permission; use Spatie\Permission\Models\Permission;
/**
* Never calls the real KBZ refund API in tests (mirrors RefundResourceTest's
* fake for the Refunds resource's own process action).
*/
class FakeBookingResourceRefundGateway 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 () { beforeEach(function () {
foreach (['view_bookings', 'manage_bookings', 'process_refunds'] as $permission) { foreach (['view_bookings', 'manage_bookings', 'process_refunds'] as $permission) {
Permission::findOrCreate($permission, 'web'); Permission::findOrCreate($permission, 'web');
@@ -149,6 +183,15 @@ test('the assign driver action is visible for a confirmed booking and hidden oth
->assertTableActionHidden('assignDriver', $pending); ->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 () { test('the assign driver action is hidden from a user without manage_bookings', function () {
$viewer = User::factory()->create()->givePermissionTo('view_bookings'); $viewer = User::factory()->create()->givePermissionTo('view_bookings');
$this->actingAs($viewer); $this->actingAs($viewer);
@@ -375,3 +418,100 @@ test('the restore action is hidden from a user without manage_bookings', functio
->filterTable('trashed', true) ->filterTable('trashed', true)
->assertTableActionHidden('restore', $booking); ->assertTableActionHidden('restore', $booking);
}); });
test('the refund action is visible and enabled for a confirmed booking with process_refunds', function () {
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
Livewire::test(ListBookings::class)
->assertTableActionVisible('refund', $booking)
->assertTableActionEnabled('refund', $booking);
});
test('the refund action is visible but disabled for a pending_payment booking', function () {
$booking = Booking::factory()->create(['status' => BookingStatus::PendingPayment]);
Livewire::test(ListBookings::class)
->assertTableActionVisible('refund', $booking)
->assertTableActionDisabled('refund', $booking);
});
test('the refund action is hidden from a user without process_refunds', function () {
$viewer = User::factory()->create()->givePermissionTo('view_bookings');
$this->actingAs($viewer);
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
Livewire::test(ListBookings::class)
->assertTableActionHidden('refund', $booking);
});
test('calling the refund action from the bookings list with full refund toggled on refunds the whole balance', function () {
app(PaymentGatewayFactory::class)->register(PaymentMethod::KbzMiniApp, FakeBookingResourceRefundGateway::class);
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'price' => 15000]);
$payment = Payment::factory()->completed()->create([
'booking_id' => $booking->id,
'gateway' => PaymentMethod::KbzMiniApp,
'amount' => 15000,
'gateway_transaction_id' => 'EVB-BOOKING-REFUND-1',
]);
Livewire::test(ListBookings::class)
->callTableAction('refund', $booking, data: [
'full_refund' => true,
'reason' => 'customer requested cancellation',
])
->assertNotified();
expect(Refund::where('payment_id', $payment->id)->where('status', RefundStatus::Completed)->where('amount', 15000)->exists())->toBeTrue();
});
test('calling the refund action with full refund toggled off refunds only the given amount', function () {
app(PaymentGatewayFactory::class)->register(PaymentMethod::KbzMiniApp, FakeBookingResourceRefundGateway::class);
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'price' => 15000]);
$payment = Payment::factory()->completed()->create([
'booking_id' => $booking->id,
'gateway' => PaymentMethod::KbzMiniApp,
'amount' => 15000,
'gateway_transaction_id' => 'EVB-BOOKING-PARTIAL-1',
]);
Livewire::test(ListBookings::class)
->callTableAction('refund', $booking, data: [
'full_refund' => false,
'amount' => 5000,
'reason' => 'customer requested cancellation',
])
->assertNotified();
expect(Refund::where('payment_id', $payment->id)->where('status', RefundStatus::Completed)->where('amount', 5000)->exists())->toBeTrue();
});
test('the refund action\'s amount field is capped at the booking\'s payment\'s refundable balance', 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-BOOKING-MAX-1',
]);
Livewire::test(ListBookings::class)
->callTableAction('refund', $booking, data: [
'full_refund' => false,
'amount' => 15000.01,
'reason' => 'reason',
])
->assertHasTableActionErrors(['amount' => 'max']);
expect(Refund::where('booking_id', $booking->id)->exists())->toBeFalse();
});
test('the detail page also has a refund action, shared with the table', function () {
$confirmed = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
Livewire::test(ViewBooking::class, ['record' => $confirmed->getRouteKey()])
->assertActionVisible('refund')
->assertActionEnabled('refund');
});
@@ -4,6 +4,8 @@ use Firebase\JWT\JWT;
use Modules\Booking\Enums\BookingChannel; use Modules\Booking\Enums\BookingChannel;
use Modules\Booking\Models\Booking; use Modules\Booking\Models\Booking;
use Modules\Catalog\Models\DepartureTimeSlot; use Modules\Catalog\Models\DepartureTimeSlot;
use Modules\Payment\Enums\PaymentMethod;
use Modules\Payment\Models\Payment;
use Modules\Routing\Models\EvRoute; use Modules\Routing\Models\EvRoute;
use Modules\Routing\Models\RoutePricing; use Modules\Routing\Models\RoutePricing;
use Modules\Shared\Enums\VehicleOption; use Modules\Shared\Enums\VehicleOption;
@@ -59,6 +61,7 @@ test('a FastAPI JWT booking is stored against the verified openid, ignoring a sp
test('a FastAPI JWT can list and show only its own openid\'s bookings', function () { test('a FastAPI JWT can list and show only its own openid\'s bookings', function () {
$mine = Booking::factory()->create(['openid' => 'agent-openid-mine']); $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']); Booking::factory()->create(['openid' => 'agent-openid-someone-else']);
$token = fastApiAgentToken('agent-openid-mine'); $token = fastApiAgentToken('agent-openid-mine');
@@ -0,0 +1,75 @@
<?php
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Events\DriverAssigned;
use Modules\Booking\Listeners\SendDriverAssignedSms;
use Modules\Booking\Models\Booking;
use Modules\Catalog\Models\Destination;
use Modules\Routing\Models\EvRoute;
use Modules\Shared\Sms\SmsService;
beforeEach(function () {
config([
'app.name' => 'FamousLY4 EV',
'app.support_phone' => '+959123456789',
'app.support_email' => 'support@famousLY4.test',
]);
});
test('a first driver assignment texts the passenger with an "assigned" message including the route', function () {
$booking = Booking::factory()->create([
'status' => BookingStatus::Confirmed,
'passenger_phone' => '+959999888777',
'driver_name' => 'U Aung',
'driver_phone' => '+959111222333',
'car_plate_number' => 'YGN-1234',
'car_model' => 'Tesla Model Y',
'ev_route_id' => EvRoute::factory()->create([
'from_destination_id' => Destination::factory()->create(['name' => 'Yangon'])->id,
'to_destination_id' => Destination::factory()->create(['name' => 'Mandalay'])->id,
])->id,
]);
$sms = Mockery::mock(SmsService::class);
$sms->shouldReceive('send')
->once()
->with('+959999888777', Mockery::on(fn (string $message) => str_contains($message, 'assigned')
&& str_contains($message, 'U Aung')
&& str_contains($message, 'YGN-1234')
&& str_contains($message, 'Yangon - Mandalay')
&& str_contains($message, config('app.name'))
&& str_contains($message, config('app.support_phone'))
&& str_contains($message, config('app.support_email'))
&& str_contains($message, 'ယာဉ်မောင်း')
&& str_contains($message, 'အကူအညီလိုအပ်ပါက ဆက်သွယ်ရန်')));
(new SendDriverAssignedSms($sms))->handle(new DriverAssigned($booking, isFirstAssignment: true));
});
test('a driver reassignment texts the passenger with an "updated" message including the route', function () {
$booking = Booking::factory()->create([
'status' => BookingStatus::Confirmed,
'passenger_phone' => '+959999888777',
'driver_name' => 'Daw Hla',
'driver_phone' => '+959444555666',
'car_plate_number' => 'YGN-5678',
'ev_route_id' => EvRoute::factory()->create([
'from_destination_id' => Destination::factory()->create(['name' => 'Yangon'])->id,
'to_destination_id' => Destination::factory()->create(['name' => 'Mandalay'])->id,
])->id,
]);
$sms = Mockery::mock(SmsService::class);
$sms->shouldReceive('send')
->once()
->with('+959999888777', Mockery::on(fn (string $message) => str_contains($message, 'updated')
&& str_contains($message, 'Daw Hla')
&& str_contains($message, 'Yangon - Mandalay')
&& str_contains($message, config('app.name'))
&& str_contains($message, config('app.support_phone'))
&& str_contains($message, config('app.support_email'))
&& str_contains($message, 'ယာဉ်မောင်း')
&& str_contains($message, 'အကူအညီလိုအပ်ပါက ဆက်သွယ်ရန်')));
(new SendDriverAssignedSms($sms))->handle(new DriverAssigned($booking, isFirstAssignment: false));
});
@@ -1,8 +1,10 @@
<?php <?php
use Illuminate\Support\Facades\Event;
use Modules\Booking\Actions\AssignDriverAction; use Modules\Booking\Actions\AssignDriverAction;
use Modules\Booking\Data\AssignDriverData; use Modules\Booking\Data\AssignDriverData;
use Modules\Booking\Enums\BookingStatus; use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Events\DriverAssigned;
use Modules\Booking\Exceptions\DriverAssignmentNotAllowedException; use Modules\Booking\Exceptions\DriverAssignmentNotAllowedException;
use Modules\Booking\Models\Booking; use Modules\Booking\Models\Booking;
@@ -23,6 +25,57 @@ test('it assigns driver and car details to a confirmed booking', function () {
->and($booking->refresh()->driver_name)->toBe('U Aung'); ->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 () { test('car_model is optional', function () {
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]); $booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
@@ -47,6 +100,36 @@ test('it guards against assigning a driver to a pending_payment booking', functi
expect($booking->refresh()->driver_name)->toBeNull(); 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 () { test('it guards against assigning a driver to a cancelled booking', function () {
$booking = Booking::factory()->create(['status' => BookingStatus::Cancelled]); $booking = Booking::factory()->create(['status' => BookingStatus::Cancelled]);
@@ -30,40 +30,52 @@ test('front seat and back seat can be selected together in one booking', functio
]))->not->toThrow(InvalidVehicleSelectionException::class); ]))->not->toThrow(InvalidVehicleSelectionException::class);
}); });
test('requesting more front seats than the configured max is rejected', function () { test('requesting more passengers than the configured max is rejected', function (VehicleOption $option) {
config(["booking.{$option->value}_max_per_booking" => 1]);
$service = new BookingService;
expect(fn () => $service->validateSelections([new VehicleSelectionData($option, 2)]))
->toThrow(InvalidVehicleSelectionException::class);
})->with([
'front_seat' => [VehicleOption::FrontSeat],
'back_seat' => [VehicleOption::BackSeat],
'whole_vehicle' => [VehicleOption::WholeVehicle],
]);
test('requesting passengers up to the configured max passes', function (VehicleOption $option) {
config(["booking.{$option->value}_max_per_booking" => 2]);
$service = new BookingService;
expect(fn () => $service->validateSelections([new VehicleSelectionData($option, 2)]))
->not->toThrow(InvalidVehicleSelectionException::class);
})->with([
'front_seat' => [VehicleOption::FrontSeat],
'back_seat' => [VehicleOption::BackSeat],
'whole_vehicle' => [VehicleOption::WholeVehicle],
]);
test('an option is rejected when disabled via config', function (VehicleOption $option) {
config(["booking.{$option->value}_enabled" => false]);
$service = new BookingService;
expect(fn () => $service->validateSelections([new VehicleSelectionData($option)]))
->toThrow(InvalidVehicleSelectionException::class, "Vehicle option [{$option->value}] is not currently available for booking.");
})->with([
'front_seat' => [VehicleOption::FrontSeat],
'back_seat' => [VehicleOption::BackSeat],
'whole_vehicle' => [VehicleOption::WholeVehicle],
]);
test('exceeding the max produces the expected message', function () {
config(['booking.front_seat_max_per_booking' => 1]); config(['booking.front_seat_max_per_booking' => 1]);
$service = new BookingService; $service = new BookingService;
expect(fn () => $service->validateSelections([new VehicleSelectionData(VehicleOption::FrontSeat, 2)])) expect(fn () => $service->validateSelections([new VehicleSelectionData(VehicleOption::FrontSeat, 2)]))
->toThrow(InvalidVehicleSelectionException::class); ->toThrow(InvalidVehicleSelectionException::class, 'Vehicle option [front_seat] passenger count [2] exceeds the max of [1] per booking.');
});
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 () { test('the same vehicle option cannot be selected twice in one booking', function () {
@@ -23,7 +23,7 @@ class EvCompanyResource extends JsonResource
'mm_description' => $this->mm_description, 'mm_description' => $this->mm_description,
'contact' => $this->contact, 'contact' => $this->contact,
'address' => $this->address, 'address' => $this->address,
'logo' => $this->logo, 'logo' => $this->logo_url,
]; ];
} }
} }
@@ -2,8 +2,10 @@
namespace Modules\Catalog\Models; namespace Modules\Catalog\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Modules\Catalog\Database\Factories\EvCompanyFactory; use Modules\Catalog\Database\Factories\EvCompanyFactory;
use Spatie\Activitylog\Models\Concerns\LogsActivity; use Spatie\Activitylog\Models\Concerns\LogsActivity;
@@ -72,4 +74,26 @@ class EvCompany extends Model
'is_active' => 'boolean', 'is_active' => 'boolean',
]; ];
} }
/**
* `logo` is stored as the disk-relative path Filament's FileUpload
* writes (e.g. "logos/xxx.png"), not a URL API consumers need a full
* absolute URL to render it directly. Guards against the disk itself
* already returning an absolute URL (e.g. an s3 disk), so this stays
* correct if the storage disk ever changes from local.
*/
public function logoUrl(): Attribute
{
return Attribute::make(
get: function (): ?string {
if (blank($this->logo)) {
return null;
}
$url = Storage::disk(config('filesystems.default'))->url($this->logo);
return str($url)->startsWith(['http://', 'https://']) ? $url : url($url);
},
);
}
} }
@@ -1,6 +1,7 @@
<?php <?php
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Storage;
use Modules\Catalog\Models\Destination; use Modules\Catalog\Models\Destination;
use Modules\Catalog\Models\EvCompany; use Modules\Catalog\Models\EvCompany;
@@ -19,6 +20,24 @@ test('lists active ev companies', function () {
->assertJsonFragment(['id' => $active->id]); ->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 () { test('lists active destinations', function () {
$active = Destination::factory()->create(['is_active' => true]); $active = Destination::factory()->create(['is_active' => true]);
Destination::factory()->create(['is_active' => false]); Destination::factory()->create(['is_active' => false]);
+24
View File
@@ -0,0 +1,24 @@
{
"name": "modules/cms",
"description": "",
"type": "library",
"version": "1.0",
"license": "proprietary",
"require": {},
"autoload": {
"psr-4": {
"Modules\\Cms\\": "src/",
"Modules\\Cms\\Tests\\": "tests/",
"Modules\\Cms\\Database\\Factories\\": "database/factories/",
"Modules\\Cms\\Database\\Seeders\\": "database/seeders/"
}
},
"minimum-stability": "stable",
"extra": {
"laravel": {
"providers": [
"Modules\\Cms\\Providers\\CmsServiceProvider"
]
}
}
}
@@ -0,0 +1,31 @@
<?php
namespace Modules\Cms\Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Modules\Cms\Models\CmsPage;
/**
* @extends Factory<CmsPage>
*/
class CmsPageFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'page' => fake()->unique()->slug(),
'title' => fake()->sentence(3),
'mm_title' => fake()->sentence(3),
'meta_tags' => fake()->words(3, true),
'meta_keywords' => fake()->words(3, true),
'content' => fake()->paragraphs(3, true),
'mm_content' => fake()->paragraphs(3, true),
'is_active' => true,
];
}
}
@@ -0,0 +1,35 @@
<?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('cms_pages', function (Blueprint $table) {
$table->id();
$table->string('page')->unique();
$table->string('title');
$table->string('mm_title');
$table->string('meta_tags')->nullable();
$table->string('meta_keywords')->nullable();
$table->longText('content')->nullable();
$table->longText('mm_content')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cms_pages');
}
};
+12
View File
@@ -0,0 +1,12 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Cms\Http\Controllers\CmsPageController;
// CMS content (FAQ, About Us, Terms, etc.) is public and typically shown
// before the user logs in, so unlike the catalog/routing endpoints this
// group skips api.auth — throttle:api-read still rate-limits it by IP.
Route::prefix('api/v1')->middleware(['api', 'throttle:api-read'])->group(function () {
Route::get('/cms-pages', [CmsPageController::class, 'index'])->name('cms.pages.index');
Route::get('/cms-pages/{page}', [CmsPageController::class, 'show'])->name('cms.pages.show');
});
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace Modules\Cms;
use Filament\Contracts\Plugin;
use Filament\Panel;
class CmsPlugin implements Plugin
{
public function getId(): string
{
return 'cms';
}
public function register(Panel $panel): void
{
$panel
->discoverResources(
in: __DIR__.'/Filament/Resources',
for: 'Modules\Cms\Filament\Resources',
)
->discoverPages(
in: __DIR__.'/Filament/Pages',
for: 'Modules\Cms\Filament\Pages',
)
->discoverWidgets(
in: __DIR__.'/Filament/Widgets',
for: 'Modules\Cms\Filament\Widgets',
);
}
public function boot(Panel $panel): void {}
public static function make(): static
{
return app(static::class);
}
}
@@ -0,0 +1,51 @@
<?php
namespace Modules\Cms\Filament\Resources\CmsPages;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Modules\Cms\Filament\Resources\CmsPages\Pages\CreateCmsPage;
use Modules\Cms\Filament\Resources\CmsPages\Pages\EditCmsPage;
use Modules\Cms\Filament\Resources\CmsPages\Pages\ListCmsPages;
use Modules\Cms\Filament\Resources\CmsPages\Schemas\CmsPageForm;
use Modules\Cms\Filament\Resources\CmsPages\Tables\CmsPagesTable;
use Modules\Cms\Models\CmsPage;
use UnitEnum;
class CmsPageResource extends Resource
{
protected static ?string $model = CmsPage::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedDocumentText;
protected static string|UnitEnum|null $navigationGroup = 'CMS';
public static function form(Schema $schema): Schema
{
return CmsPageForm::configure($schema);
}
public static function table(Table $table): Table
{
return CmsPagesTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListCmsPages::route('/'),
'create' => CreateCmsPage::route('/create'),
'edit' => EditCmsPage::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,11 @@
<?php
namespace Modules\Cms\Filament\Resources\CmsPages\Pages;
use Filament\Resources\Pages\CreateRecord;
use Modules\Cms\Filament\Resources\CmsPages\CmsPageResource;
class CreateCmsPage extends CreateRecord
{
protected static string $resource = CmsPageResource::class;
}
@@ -0,0 +1,19 @@
<?php
namespace Modules\Cms\Filament\Resources\CmsPages\Pages;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
use Modules\Cms\Filament\Resources\CmsPages\CmsPageResource;
class EditCmsPage extends EditRecord
{
protected static string $resource = CmsPageResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace Modules\Cms\Filament\Resources\CmsPages\Pages;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Modules\Cms\Filament\Resources\CmsPages\CmsPageResource;
class ListCmsPages extends ListRecords
{
protected static string $resource = CmsPageResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}
@@ -0,0 +1,40 @@
<?php
namespace Modules\Cms\Filament\Resources\CmsPages\Schemas;
use Filament\Forms\Components\RichEditor;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Schema;
class CmsPageForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('meta_tags')
->maxLength(255),
TextInput::make('meta_keywords')
->maxLength(255),
TextInput::make('page')
->required()
->unique(ignoreRecord: true)
->maxLength(255)
->helperText('Unique key used to look up this page, e.g. "miniapp_faq".'),
TextInput::make('title')
->required()
->maxLength(255),
TextInput::make('mm_title')
->required()
->maxLength(255),
RichEditor::make('content')
->columnSpanFull(),
RichEditor::make('mm_content')
->columnSpanFull(),
Toggle::make('is_active')
->required()
->default(true),
]);
}
}
@@ -0,0 +1,46 @@
<?php
namespace Modules\Cms\Filament\Resources\CmsPages\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 CmsPagesTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('page')
->searchable()
->sortable(),
TextColumn::make('title')
->searchable(),
TextColumn::make('mm_title')
->searchable(),
IconColumn::make('is_active')
->boolean(),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('created_at', 'desc')
->filters([
TernaryFilter::make('is_active'),
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}
@@ -0,0 +1,28 @@
<?php
namespace Modules\Cms\Http\Controllers;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Routing\Controller;
use Modules\Cms\Http\Resources\CmsPageResource;
use Modules\Cms\Models\CmsPage;
class CmsPageController extends Controller
{
public function index(): AnonymousResourceCollection
{
return CmsPageResource::collection(
CmsPage::query()->where('is_active', true)->paginate()
);
}
public function show(string $page): CmsPageResource
{
$cmsPage = CmsPage::query()
->where('page', $page)
->where('is_active', true)
->firstOrFail();
return new CmsPageResource($cmsPage);
}
}
@@ -0,0 +1,28 @@
<?php
namespace Modules\Cms\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class CmsPageResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'page' => $this->page,
'title' => $this->title,
'mm_title' => $this->mm_title,
'meta_tags' => $this->meta_tags,
'meta_keywords' => $this->meta_keywords,
'content' => $this->content,
'mm_content' => $this->mm_content,
];
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace Modules\Cms\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Modules\Cms\Database\Factories\CmsPageFactory;
use Spatie\Activitylog\Models\Concerns\LogsActivity;
use Spatie\Activitylog\Support\LogOptions;
class CmsPage extends Model
{
/** @use HasFactory<CmsPageFactory> */
use HasFactory, LogsActivity;
/**
* Full CRUD audit trail CMS content 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('cms');
}
/**
* @var list<string>
*/
protected $fillable = [
'page',
'title',
'mm_title',
'meta_tags',
'meta_keywords',
'content',
'mm_content',
'is_active',
];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'is_active' => 'boolean',
];
}
}
@@ -0,0 +1,12 @@
<?php
namespace Modules\Cms\Providers;
use Illuminate\Support\ServiceProvider;
class CmsServiceProvider extends ServiceProvider
{
public function register(): void {}
public function boot(): void {}
}
View File
@@ -0,0 +1,79 @@
<?php
use App\Models\User;
use Livewire\Livewire;
use Modules\Cms\Filament\Resources\CmsPages\Pages\CreateCmsPage;
use Modules\Cms\Filament\Resources\CmsPages\Pages\EditCmsPage;
use Modules\Cms\Filament\Resources\CmsPages\Pages\ListCmsPages;
use Modules\Cms\Models\CmsPage;
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 cms pages', function () {
$pages = CmsPage::factory()->count(3)->create();
Livewire::test(ListCmsPages::class)
->assertOk()
->assertCanSeeTableRecords($pages);
});
test('can create a cms page', function () {
$page = CmsPage::factory()->make();
Livewire::test(CreateCmsPage::class)
->fillForm([
'page' => $page->page,
'title' => $page->title,
'mm_title' => $page->mm_title,
'meta_tags' => $page->meta_tags,
'meta_keywords' => $page->meta_keywords,
'content' => $page->content,
'mm_content' => $page->mm_content,
'is_active' => true,
])
->call('create')
->assertNotified()
->assertRedirect();
assertDatabaseHas(CmsPage::class, [
'page' => $page->page,
'title' => $page->title,
]);
});
test('can edit a cms page', function () {
$page = CmsPage::factory()->create();
Livewire::test(EditCmsPage::class, ['record' => $page->getRouteKey()])
->assertOk()
->fillForm(['title' => 'Updated Title'])
->call('save')
->assertNotified();
assertDatabaseHas(CmsPage::class, [
'id' => $page->id,
'title' => 'Updated Title',
]);
});
test('requires a unique page slug', function () {
CmsPage::factory()->create(['page' => 'miniapp_faq']);
$page = CmsPage::factory()->make(['page' => 'miniapp_faq']);
Livewire::test(CreateCmsPage::class)
->fillForm([
'page' => $page->page,
'title' => $page->title,
'mm_title' => $page->mm_title,
])
->call('create')
->assertHasFormErrors(['page' => 'unique']);
});
@@ -0,0 +1,37 @@
<?php
use Modules\Cms\Models\CmsPage;
test('lists active cms pages', function () {
$active = CmsPage::factory()->create(['is_active' => true]);
CmsPage::factory()->create(['is_active' => false]);
$this->getJson('/api/v1/cms-pages')
->assertSuccessful()
->assertJsonCount(1, 'data')
->assertJsonFragment(['id' => $active->id]);
});
test('does not require authentication', function () {
CmsPage::factory()->create(['is_active' => true]);
$this->getJson('/api/v1/cms-pages')->assertSuccessful();
});
test('shows an active cms page by its page slug', function () {
$page = CmsPage::factory()->create(['page' => 'miniapp_faq', 'is_active' => true]);
$this->getJson('/api/v1/cms-pages/miniapp_faq')
->assertSuccessful()
->assertJsonFragment(['id' => $page->id, 'page' => 'miniapp_faq']);
});
test('returns not found for an inactive page', function () {
CmsPage::factory()->create(['page' => 'miniapp_faq', 'is_active' => false]);
$this->getJson('/api/v1/cms-pages/miniapp_faq')->assertNotFound();
});
test('returns not found for an unknown page slug', function () {
$this->getJson('/api/v1/cms-pages/unknown-page')->assertNotFound();
});
@@ -0,0 +1,57 @@
<?php
namespace Modules\Identity\Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Modules\Identity\Models\RegistrationVerification;
/**
* @extends Factory<RegistrationVerification>
*/
class RegistrationVerificationFactory extends Factory
{
protected $model = RegistrationVerification::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'identifier' => fake()->unique()->safeEmail(),
'type' => 'email',
'code' => Hash::make('123456'),
'attempts' => 0,
'verified_at' => null,
'verification_token' => null,
'consumed_at' => null,
'expires_at' => now()->addMinutes(10),
];
}
public function phone(): self
{
return $this->state(fn (array $attributes) => [
'identifier' => fake()->numerify('+959#########'),
'type' => 'phone',
]);
}
public function verified(): self
{
return $this->state(fn (array $attributes) => [
'verified_at' => now(),
'verification_token' => str()->random(64),
]);
}
public function expired(): self
{
return $this->state(fn (array $attributes) => [
'expires_at' => now()->subMinute(),
]);
}
}
@@ -0,0 +1,28 @@
<?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::table('users', function (Blueprint $table) {
$table->string('phone')->nullable()->unique()->after('email');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('phone');
});
}
};
@@ -0,0 +1,35 @@
<?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('registration_verifications', function (Blueprint $table) {
$table->id();
$table->string('identifier')->unique();
$table->string('type');
$table->string('code');
$table->unsignedTinyInteger('attempts')->default(0);
$table->timestamp('verified_at')->nullable();
$table->string('verification_token')->nullable()->unique();
$table->timestamp('consumed_at')->nullable();
$table->timestamp('expires_at');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('registration_verifications');
}
};
@@ -25,6 +25,8 @@ class RolePermissionSeeder extends Seeder
'manage_roles', 'manage_roles',
'view_customers', 'view_customers',
'manage_settings', 'manage_settings',
'view_reports',
'manage_ai_agent',
]; ];
/** /**
@@ -44,6 +46,8 @@ class RolePermissionSeeder extends Seeder
'manage_roles', 'manage_roles',
'view_customers', 'view_customers',
'manage_settings', 'manage_settings',
'view_reports',
'manage_ai_agent',
], ],
'admin' => [ 'admin' => [
'manage_catalog', 'manage_catalog',
@@ -56,6 +60,8 @@ class RolePermissionSeeder extends Seeder
'view_audit_log', 'view_audit_log',
'view_customers', 'view_customers',
'manage_settings', 'manage_settings',
'view_reports',
'manage_ai_agent',
], ],
'support' => [ 'support' => [
'view_bookings', 'view_bookings',
@@ -0,0 +1,14 @@
<x-mail::message>
# Verification Code
Use the code below to confirm your account:
<x-mail::panel>
{{ $code }}
</x-mail::panel>
This code expires in 10 minutes. If you didn't request this, you can safely ignore this email.
Thanks,<br>
{{ config('app.name') }}
</x-mail::message>
@@ -1,8 +1,17 @@
<?php <?php
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Modules\Identity\Http\Controllers\RegistrationController;
use Modules\Identity\Http\Controllers\TokenController; use Modules\Identity\Http\Controllers\TokenController;
Route::prefix('api/v1')->middleware(['api', 'throttle:api-auth'])->group(function () { Route::prefix('api/v1')->middleware(['api', 'throttle:api-auth'])->group(function () {
Route::post('/auth/token', [TokenController::class, 'store'])->name('identity.auth.token'); Route::post('/auth/token', [TokenController::class, 'store'])->name('identity.auth.token');
Route::post('/auth/registration/request-code', [RegistrationController::class, 'requestCode'])
->middleware('throttle:api-otp')
->name('identity.auth.registration.request-code');
Route::post('/auth/registration/verify-code', [RegistrationController::class, 'verifyCode'])
->name('identity.auth.registration.verify-code');
Route::post('/auth/register', [RegistrationController::class, 'store'])
->name('identity.auth.register');
}); });
@@ -4,6 +4,7 @@ namespace Modules\Identity\Filament\Pages;
use BackedEnum; use BackedEnum;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle; use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
@@ -12,6 +13,7 @@ use Filament\Schemas\Components\Actions;
use Filament\Schemas\Components\Form; use Filament\Schemas\Components\Form;
use Filament\Schemas\Components\Tabs; use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab; use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon; use Filament\Support\Icons\Heroicon;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
@@ -58,9 +60,17 @@ class ManageAppSettings extends Page
'support_phone' => config('app.support_phone'), 'support_phone' => config('app.support_phone'),
'timezone' => config('app.timezone'), 'timezone' => config('app.timezone'),
'currency' => config('app.currency'), 'currency' => config('app.currency'),
'back_seat_enabled' => (bool) config('booking.back_seat_enabled'), 'front_seat_enabled' => (bool) config('booking.front_seat_enabled'),
'whole_vehicle_enabled' => (bool) config('booking.whole_vehicle_enabled'),
'front_seat_max_per_booking' => config('booking.front_seat_max_per_booking'), 'front_seat_max_per_booking' => config('booking.front_seat_max_per_booking'),
'back_seat_enabled' => (bool) config('booking.back_seat_enabled'),
'back_seat_max_per_booking' => config('booking.back_seat_max_per_booking'),
'whole_vehicle_enabled' => (bool) config('booking.whole_vehicle_enabled'),
'whole_vehicle_max_per_booking' => config('booking.whole_vehicle_max_per_booking'),
'booking_admin_emails' => config('booking.admin_emails'),
'sms_enabled' => (bool) config('services.sms.enabled'),
'sms_server' => config('services.sms.sms_poh.server'),
'sms_token' => config('services.sms.sms_poh.token'),
'sms_sender' => config('services.sms.sms_poh.sender'),
]); ]);
} }
@@ -99,19 +109,62 @@ class ManageAppSettings extends Page
->columns(2), ->columns(2),
Tab::make('Booking') Tab::make('Booking')
->schema([ ->schema([
Toggle::make('back_seat_enabled') Toggle::make('front_seat_enabled')
->label('Back Seat Enabled') ->label('Front Seat Enabled')
->helperText('Whether customers can select Back Seat at all right now.'), ->helperText('Whether customers can select Front Seat at all right now.'),
Toggle::make('whole_vehicle_enabled')
->label('Whole Vehicle Enabled')
->helperText('Whether customers can select Whole Vehicle at all right now.'),
TextInput::make('front_seat_max_per_booking') TextInput::make('front_seat_max_per_booking')
->label('Front Seat Max Per Booking') ->label('Front Seat Max Per Booking')
->numeric() ->numeric()
->minValue(1) ->minValue(1)
->required() ->required()
->helperText('Max Front Seats a single booking may request.'), ->helperText('Max Front Seats a single booking may request.'),
]), Toggle::make('back_seat_enabled')
->label('Back Seat Enabled')
->helperText('Whether customers can select Back Seat at all right now.'),
TextInput::make('back_seat_max_per_booking')
->label('Back Seat Max Per Booking')
->numeric()
->minValue(1)
->required()
->helperText('Max Back Seats a single booking may request.'),
Toggle::make('whole_vehicle_enabled')
->label('Whole Vehicle Enabled')
->helperText('Whether customers can select Whole Vehicle at all right now.'),
TextInput::make('whole_vehicle_max_per_booking')
->label('Whole Vehicle Max Per Booking')
->numeric()
->minValue(1)
->required()
->helperText('Max Whole Vehicle passenger count a single booking may request.'),
TagsInput::make('booking_admin_emails')
->label('Admin Emails')
->required()
->helperText('Notified on booking events. Press enter after each address.'),
])
->columns(2),
Tab::make('SMS')
->schema([
Toggle::make('sms_enabled')
->label('SMS Enabled')
->live()
->helperText('Whether driver/car SMS notifications are sent at all.'),
TextInput::make('sms_server')
->label('SMS Server URL')
->url()
->maxLength(255)
->required(fn (Get $get): bool => (bool) $get('sms_enabled')),
TextInput::make('sms_token')
->label('SMS Token')
->password()
->revealable()
->maxLength(255)
->required(fn (Get $get): bool => (bool) $get('sms_enabled')),
TextInput::make('sms_sender')
->label('SMS Sender')
->maxLength(255)
->helperText('Default sender name/number for outgoing SMS.'),
])
->columns(2),
]), ]),
]) ])
->livewireSubmitHandler('save') ->livewireSubmitHandler('save')
@@ -136,9 +189,17 @@ class ManageAppSettings extends Page
'SUPPORT_PHONE' => $state['support_phone'], 'SUPPORT_PHONE' => $state['support_phone'],
'APP_TIMEZONE' => $state['timezone'], 'APP_TIMEZONE' => $state['timezone'],
'APP_CURRENCY' => $state['currency'], 'APP_CURRENCY' => $state['currency'],
'BOOKING_BACK_SEAT_ENABLED' => (bool) $state['back_seat_enabled'], 'BOOKING_FRONT_SEAT_ENABLED' => (bool) $state['front_seat_enabled'],
'BOOKING_WHOLE_VEHICLE_ENABLED' => (bool) $state['whole_vehicle_enabled'],
'BOOKING_FRONT_SEAT_MAX_PER_BOOKING' => (int) $state['front_seat_max_per_booking'], 'BOOKING_FRONT_SEAT_MAX_PER_BOOKING' => (int) $state['front_seat_max_per_booking'],
'BOOKING_BACK_SEAT_ENABLED' => (bool) $state['back_seat_enabled'],
'BOOKING_BACK_SEAT_MAX_PER_BOOKING' => (int) $state['back_seat_max_per_booking'],
'BOOKING_WHOLE_VEHICLE_ENABLED' => (bool) $state['whole_vehicle_enabled'],
'BOOKING_WHOLE_VEHICLE_MAX_PER_BOOKING' => (int) $state['whole_vehicle_max_per_booking'],
'BOOKING_ADMIN_EMAILS' => implode(',', $state['booking_admin_emails'] ?? []),
'SMS_ENABLED' => (bool) $state['sms_enabled'],
'SMS_SERVER' => $state['sms_server'],
'SMS_TOKEN' => $state['sms_token'],
'SMS_SENDER' => $state['sms_sender'],
]); ]);
Artisan::call('config:clear'); Artisan::call('config:clear');
@@ -0,0 +1,76 @@
<?php
namespace Modules\Identity\Http\Controllers;
use App\Models\User;
use Illuminate\Routing\Controller;
use Illuminate\Validation\ValidationException;
use Modules\Identity\Enums\TokenAbility;
use Modules\Identity\Http\Requests\RegisterRequest;
use Modules\Identity\Http\Requests\RequestRegistrationCodeRequest;
use Modules\Identity\Http\Requests\VerifyRegistrationCodeRequest;
use Modules\Identity\Models\RegistrationVerification;
/**
* Confirm-first registration (mini app / mobile app): request a code for an
* email or phone, verify it, then complete registration with the
* verification_token that step returns. Kept as three actions on one
* controller since they're steps of a single flow sharing the same model.
*/
class RegistrationController extends Controller
{
public function requestCode(RequestRegistrationCodeRequest $request): array
{
RegistrationVerification::issueFor($request->string('identifier')->toString(), $request->identifierType());
return ['message' => 'A verification code has been sent.'];
}
public function verifyCode(VerifyRegistrationCodeRequest $request): array
{
$verification = RegistrationVerification::where('identifier', $request->string('identifier'))->first();
if (! $verification || $verification->isExpired()) {
throw ValidationException::withMessages([
'code' => ['This code has expired. Please request a new one.'],
]);
}
if (! $verification->attemptVerify($request->string('code')->toString())) {
throw ValidationException::withMessages([
'code' => ['The provided code is incorrect.'],
]);
}
return ['verification_token' => $verification->verification_token];
}
public function store(RegisterRequest $request): array
{
$verification = RegistrationVerification::where('verification_token', $request->string('verification_token'))->first();
if (! $verification || ! $verification->isVerified() || $verification->isConsumed() || $verification->isExpired()) {
throw ValidationException::withMessages([
'verification_token' => ['This verification has expired or was already used. Please start again.'],
]);
}
$user = User::create([
'name' => $request->string('name'),
'email' => $verification->type === 'email' ? $verification->identifier : null,
'phone' => $verification->type === 'phone' ? $verification->identifier : null,
'password' => $request->string('password'),
]);
$verification->update(['consumed_at' => now()]);
$token = $user->createToken(
$request->string('device_name')->toString(),
TokenAbility::customerAbilities(),
);
return [
'token' => $token->plainTextToken,
];
}
}
@@ -0,0 +1,27 @@
<?php
namespace Modules\Identity\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Password;
class RegisterRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, array<int, mixed>>
*/
public function rules(): array
{
return [
'verification_token' => ['required', 'string'],
'name' => ['required', 'string', 'max:255'],
'password' => ['required', 'string', 'confirmed', Password::defaults()],
'device_name' => ['required', 'string', 'max:255'],
];
}
}
@@ -0,0 +1,53 @@
<?php
namespace Modules\Identity\Http\Requests;
use App\Models\User;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
class RequestRegistrationCodeRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, array<int, string>>
*/
public function rules(): array
{
return [
'identifier' => ['required', 'string'],
];
}
/**
* "email" or "phone" the identifier's format determines the channel
* the code is delivered over.
*/
public function identifierType(): string
{
return filter_var($this->string('identifier'), FILTER_VALIDATE_EMAIL) !== false ? 'email' : 'phone';
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
$identifier = $this->string('identifier')->toString();
if ($this->identifierType() === 'phone' && ! preg_match('/^\+?[0-9]{7,15}$/', $identifier)) {
$validator->errors()->add('identifier', 'The identifier must be a valid email address or phone number.');
return;
}
$column = $this->identifierType() === 'email' ? 'email' : 'phone';
if (User::where($column, $identifier)->exists()) {
$validator->errors()->add('identifier', 'An account with this '.$column.' already exists.');
}
});
}
}
@@ -0,0 +1,24 @@
<?php
namespace Modules\Identity\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class VerifyRegistrationCodeRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, array<int, string>>
*/
public function rules(): array
{
return [
'identifier' => ['required', 'string'],
'code' => ['required', 'string', 'size:6'],
];
}
}
@@ -0,0 +1,21 @@
<?php
namespace Modules\Identity\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class RegistrationCodeMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(public readonly string $code) {}
public function build(): self
{
return $this
->subject(config('app.name').' - Verification Code')
->view('identity::mail.registration-code');
}
}
@@ -0,0 +1,135 @@
<?php
namespace Modules\Identity\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Modules\Identity\Database\Factories\RegistrationVerificationFactory;
use Modules\Identity\Mail\RegistrationCodeMail;
use Modules\Shared\Sms\SmsService;
class RegistrationVerification extends Model
{
/** @use HasFactory<RegistrationVerificationFactory> */
use HasFactory;
/**
* A code is only good for this long kept short since it's delivered
* over email/SMS and re-requesting a fresh one is cheap (throttled by
* the api-otp rate limiter).
*/
private const CODE_LIFETIME_MINUTES = 10;
/**
* Wrong-code guesses allowed before the code is locked out and a fresh
* one must be requested.
*/
private const MAX_ATTEMPTS = 5;
/**
* @var list<string>
*/
protected $fillable = [
'identifier',
'type',
'code',
'attempts',
'verified_at',
'verification_token',
'consumed_at',
'expires_at',
];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'verified_at' => 'datetime',
'consumed_at' => 'datetime',
'expires_at' => 'datetime',
];
}
/**
* Generates a fresh code for the identifier and delivers it over
* email or SMS, replacing any previous pending verification for the
* same identifier (resend just supersedes the old code).
*/
public static function issueFor(string $identifier, string $type): self
{
$code = (string) random_int(100000, 999999);
$verification = self::query()->updateOrCreate(
['identifier' => $identifier],
[
'type' => $type,
'code' => Hash::make($code),
'attempts' => 0,
'verified_at' => null,
'verification_token' => null,
'consumed_at' => null,
'expires_at' => now()->addMinutes(self::CODE_LIFETIME_MINUTES),
],
);
$verification->deliver($code);
return $verification;
}
/**
* Checks the given code against this pending verification. On success,
* marks it verified and issues the one-time token step 3 (registration)
* will need to complete the flow.
*/
public function attemptVerify(string $code): bool
{
if ($this->isExpired() || $this->attempts >= self::MAX_ATTEMPTS || ! Hash::check($code, $this->code)) {
$this->increment('attempts');
return false;
}
$this->forceFill([
'verified_at' => now(),
'verification_token' => Str::random(64),
])->save();
return true;
}
public function isExpired(): bool
{
return $this->expires_at->isPast();
}
public function isVerified(): bool
{
return $this->verified_at !== null;
}
public function isConsumed(): bool
{
return $this->consumed_at !== null;
}
private function deliver(string $code): void
{
if ($this->type === 'email') {
Mail::to($this->identifier)->send(new RegistrationCodeMail($code));
return;
}
$appName = config('app.name');
app(SmsService::class)->send(
$this->identifier,
"{$appName}: Your verification code is {$code}. It expires in ".self::CODE_LIFETIME_MINUTES.' minutes.',
);
}
}
@@ -91,6 +91,7 @@ test('the agent token can still read routes and create/read bookings', function
->assertSuccessful(); ->assertSuccessful();
$booking = Booking::factory()->create(['user_id' => $this->agent->id]); $booking = Booking::factory()->create(['user_id' => $this->agent->id]);
Payment::factory()->completed()->create(['booking_id' => $booking->id, 'gateway' => PaymentMethod::KbzMiniApp]);
$this->withHeader('Authorization', "Bearer {$this->agentToken}") $this->withHeader('Authorization', "Bearer {$this->agentToken}")
->getJson('/api/v1/bookings') ->getJson('/api/v1/bookings')
@@ -28,6 +28,37 @@ test('a booking status transition is recorded in the audit log', function () {
expect($activity->attribute_changes->get('attributes'))->toMatchArray(['status' => BookingStatus::Confirmed->value]); expect($activity->attribute_changes->get('attributes'))->toMatchArray(['status' => BookingStatus::Confirmed->value]);
}); });
test('assigning a driver is recorded in the audit log with who and when', function () {
$dispatcher = User::factory()->create();
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
$this->actingAs($dispatcher);
$booking->update([
'driver_name' => 'U Aung',
'driver_phone' => '+959111222333',
'car_plate_number' => 'YGN-1234',
'car_model' => 'Tesla Model Y',
]);
$activity = Activity::where('subject_type', Booking::class)
->where('subject_id', $booking->id)
->where('log_name', 'booking')
->latest('id')
->first();
expect($activity)->not->toBeNull();
expect($activity->attribute_changes->get('attributes'))->toMatchArray([
'driver_name' => 'U Aung',
'driver_phone' => '+959111222333',
'car_plate_number' => 'YGN-1234',
'car_model' => 'Tesla Model Y',
]);
expect($activity->causer_type)->toBe(User::class);
expect($activity->causer_id)->toBe($dispatcher->id);
expect($activity->created_at)->not->toBeNull();
});
test('a catalog CRUD write is recorded in the audit log', function () { test('a catalog CRUD write is recorded in the audit log', function () {
$company = EvCompany::factory()->create(['name' => 'Original Name']); $company = EvCompany::factory()->create(['name' => 'Original Name']);
@@ -40,9 +40,17 @@ test('a super_admin can view and save app settings, writing them to .env', funct
'support_phone' => '+95912345678', 'support_phone' => '+95912345678',
'timezone' => 'Asia/Yangon', 'timezone' => 'Asia/Yangon',
'currency' => 'MMK', 'currency' => 'MMK',
'back_seat_enabled' => false, 'front_seat_enabled' => false,
'whole_vehicle_enabled' => true,
'front_seat_max_per_booking' => 2, 'front_seat_max_per_booking' => 2,
'back_seat_enabled' => false,
'back_seat_max_per_booking' => 5,
'whole_vehicle_enabled' => true,
'whole_vehicle_max_per_booking' => 6,
'booking_admin_emails' => ['ops@evbooking.test', 'dispatch@evbooking.test'],
'sms_enabled' => true,
'sms_server' => 'https://sms.example.test/send',
'sms_token' => 'secret-token',
'sms_sender' => 'EVBooking',
]) ])
->call('save') ->call('save')
->assertHasNoFormErrors(); ->assertHasNoFormErrors();
@@ -54,18 +62,41 @@ test('a super_admin can view and save app settings, writing them to .env', funct
->toContain('SUPPORT_EMAIL=help@evbooking.test') ->toContain('SUPPORT_EMAIL=help@evbooking.test')
->toContain('APP_TIMEZONE=Asia/Yangon') ->toContain('APP_TIMEZONE=Asia/Yangon')
->toContain('APP_CURRENCY=MMK') ->toContain('APP_CURRENCY=MMK')
->toContain('BOOKING_FRONT_SEAT_ENABLED=false')
->toContain('BOOKING_FRONT_SEAT_MAX_PER_BOOKING=2')
->toContain('BOOKING_BACK_SEAT_ENABLED=false') ->toContain('BOOKING_BACK_SEAT_ENABLED=false')
->toContain('BOOKING_BACK_SEAT_MAX_PER_BOOKING=5')
->toContain('BOOKING_WHOLE_VEHICLE_ENABLED=true') ->toContain('BOOKING_WHOLE_VEHICLE_ENABLED=true')
->toContain('BOOKING_FRONT_SEAT_MAX_PER_BOOKING=2'); ->toContain('BOOKING_WHOLE_VEHICLE_MAX_PER_BOOKING=6')
->toContain('BOOKING_ADMIN_EMAILS=ops@evbooking.test,dispatch@evbooking.test')
->toContain('SMS_ENABLED=true')
->toContain('SMS_SERVER=https://sms.example.test/send')
->toContain('SMS_TOKEN=secret-token')
->toContain('SMS_SENDER=EVBooking');
}); });
test('front seat max per booking must be at least 1', function () { test('sms server and token are required once sms is enabled', function () {
$superAdmin = User::factory()->create(); $superAdmin = User::factory()->create();
$superAdmin->assignRole('super_admin'); $superAdmin->assignRole('super_admin');
$this->actingAs($superAdmin); $this->actingAs($superAdmin);
Livewire::test(ManageAppSettings::class) Livewire::test(ManageAppSettings::class)
->fillForm(['front_seat_max_per_booking' => 0]) ->fillForm(['sms_enabled' => true, 'sms_server' => '', 'sms_token' => ''])
->call('save') ->call('save')
->assertHasFormErrors(['front_seat_max_per_booking']); ->assertHasFormErrors(['sms_server', 'sms_token']);
}); });
test('max per booking fields must be at least 1', function (string $field) {
$superAdmin = User::factory()->create();
$superAdmin->assignRole('super_admin');
$this->actingAs($superAdmin);
Livewire::test(ManageAppSettings::class)
->fillForm([$field => 0])
->call('save')
->assertHasFormErrors([$field]);
})->with([
'front_seat_max_per_booking',
'back_seat_max_per_booking',
'whole_vehicle_max_per_booking',
]);
@@ -0,0 +1,188 @@
<?php
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Modules\Identity\Enums\TokenAbility;
use Modules\Identity\Mail\RegistrationCodeMail;
use Modules\Identity\Models\RegistrationVerification;
beforeEach(function () {
config([
'services.sms.enabled' => true,
'services.sms.sms_poh.server' => 'https://sms.example.test/send',
'services.sms.sms_poh.token' => 'test-token',
'services.sms.sms_poh.sender' => 'App',
]);
});
test('requesting a code for a new email sends a mail and creates a pending verification', function () {
Mail::fake();
$this->postJson('/api/v1/auth/registration/request-code', ['identifier' => 'new@example.com'])
->assertSuccessful();
Mail::assertSent(RegistrationCodeMail::class);
$verification = RegistrationVerification::where('identifier', 'new@example.com')->sole();
expect($verification->type)->toBe('email')
->and($verification->verified_at)->toBeNull();
});
test('requesting a code for a new phone number sends an sms', function () {
Http::fake(['sms.example.test/*' => Http::response('OK', 200)]);
$this->postJson('/api/v1/auth/registration/request-code', ['identifier' => '+959123456789'])
->assertSuccessful();
Http::assertSent(fn ($request) => $request->url() === 'https://sms.example.test/send'
&& $request['to'] === '+959123456789');
$verification = RegistrationVerification::where('identifier', '+959123456789')->sole();
expect($verification->type)->toBe('phone');
});
test('requesting a code rejects an already registered email', function () {
Mail::fake();
User::factory()->create(['email' => 'taken@example.com']);
$this->postJson('/api/v1/auth/registration/request-code', ['identifier' => 'taken@example.com'])
->assertUnprocessable()
->assertJsonValidationErrors('identifier');
Mail::assertNothingSent();
});
test('requesting a code rejects an already registered phone', function () {
User::factory()->create(['phone' => '+959123456789']);
$this->postJson('/api/v1/auth/registration/request-code', ['identifier' => '+959123456789'])
->assertUnprocessable()
->assertJsonValidationErrors('identifier');
});
test('verifying with the correct code returns a verification token', function () {
Mail::fake();
$this->postJson('/api/v1/auth/registration/request-code', ['identifier' => 'new@example.com']);
$verification = RegistrationVerification::where('identifier', 'new@example.com')->sole();
// The plaintext code isn't returned by the API by design, so reach
// into the model the same way the real code was generated to recover
// it for the test — simplest is to reissue with a known code via the
// factory instead of parsing outbound mail content.
$verification->forceFill(['code' => Hash::make('654321')])->save();
$this->postJson('/api/v1/auth/registration/verify-code', [
'identifier' => 'new@example.com',
'code' => '654321',
])
->assertSuccessful()
->assertJsonStructure(['verification_token']);
expect($verification->fresh()->verified_at)->not->toBeNull();
});
test('verifying with the wrong code fails and increments attempts', function () {
$verification = RegistrationVerification::factory()->create();
$this->postJson('/api/v1/auth/registration/verify-code', [
'identifier' => $verification->identifier,
'code' => '000000',
])->assertUnprocessable();
expect($verification->fresh()->attempts)->toBe(1);
});
test('verifying locks out after too many wrong attempts', function () {
$verification = RegistrationVerification::factory()->create(['attempts' => 5]);
$this->postJson('/api/v1/auth/registration/verify-code', [
'identifier' => $verification->identifier,
'code' => '000000',
])->assertUnprocessable();
});
test('verifying an expired code fails', function () {
$verification = RegistrationVerification::factory()->expired()->create();
$this->postJson('/api/v1/auth/registration/verify-code', [
'identifier' => $verification->identifier,
'code' => '000000',
])->assertUnprocessable();
});
test('registering with a valid verification token creates a user and returns a token', function () {
$verification = RegistrationVerification::factory()->verified()->create(['identifier' => 'new@example.com']);
$response = $this->postJson('/api/v1/auth/register', [
'verification_token' => $verification->verification_token,
'name' => 'Jane Doe',
'password' => 'super-secret-password',
'password_confirmation' => 'super-secret-password',
'device_name' => 'iphone',
]);
$response->assertSuccessful()->assertJsonStructure(['token']);
$user = User::where('email', 'new@example.com')->sole();
expect($user->name)->toBe('Jane Doe')
->and($verification->fresh()->consumed_at)->not->toBeNull();
$accessToken = $user->tokens()->sole();
expect($accessToken->abilities)->toEqualCanonicalizing(TokenAbility::customerAbilities());
});
test('registering fails when the verification token was already consumed', function () {
$verification = RegistrationVerification::factory()->verified()->create([
'identifier' => 'new@example.com',
'consumed_at' => now(),
]);
$this->postJson('/api/v1/auth/register', [
'verification_token' => $verification->verification_token,
'name' => 'Jane Doe',
'password' => 'super-secret-password',
'password_confirmation' => 'super-secret-password',
'device_name' => 'iphone',
])->assertUnprocessable();
});
test('registering fails with an unknown verification token', function () {
$this->postJson('/api/v1/auth/register', [
'verification_token' => 'not-a-real-token',
'name' => 'Jane Doe',
'password' => 'super-secret-password',
'password_confirmation' => 'super-secret-password',
'device_name' => 'iphone',
])->assertUnprocessable();
});
test('the full request-code, verify-code, register flow works end to end', function () {
Mail::fake();
$this->postJson('/api/v1/auth/registration/request-code', ['identifier' => 'flow@example.com'])
->assertSuccessful();
$verification = RegistrationVerification::where('identifier', 'flow@example.com')->sole();
$verification->forceFill(['code' => Hash::make('111222')])->save();
$verifyResponse = $this->postJson('/api/v1/auth/registration/verify-code', [
'identifier' => 'flow@example.com',
'code' => '111222',
])->assertSuccessful();
$registerResponse = $this->postJson('/api/v1/auth/register', [
'verification_token' => $verifyResponse->json('verification_token'),
'name' => 'Flow User',
'password' => 'super-secret-password',
'password_confirmation' => 'super-secret-password',
'device_name' => 'iphone',
])->assertSuccessful();
$token = $registerResponse->json('token');
$this->withHeader('Authorization', "Bearer {$token}")
->getJson('/api/v1/companies')
->assertSuccessful();
});
@@ -35,14 +35,7 @@ class RefundBookingAction
throw RefundNotAllowedException::notConfirmed($booking); throw RefundNotAllowedException::notConfirmed($booking);
} }
// Round trip: payment is combined on the outbound leg, so a return $payment = $this->resolveRefundablePayment($booking);
// leg has no Payment of its own — refund against its linked leg's
// Payment instead (domain.md §2b). The Confirmed check above still
// applies to $booking itself, not the payment holder, so each leg
// remains independently cancellable/refundable.
$paymentBooking = $booking->is_return_leg ? ($booking->linkedBooking ?? $booking) : $booking;
$payment = $paymentBooking->payments()->where('status', PaymentStatus::Completed->value)->latest()->first();
if ($payment === null) { if ($payment === null) {
throw RefundNotAllowedException::noCompletedPayment($booking); throw RefundNotAllowedException::noCompletedPayment($booking);
@@ -80,10 +73,27 @@ class RefundBookingAction
return $refund; return $refund;
} }
/**
* The Completed Payment a refund against $booking would apply to.
* Public so the Filament refund forms can look up the same Payment to
* surface its refundable balance before staff submit an amount.
*
* Round trip: payment is combined on the outbound leg, so a return leg
* has no Payment of its own resolve against its linked leg's Payment
* instead (domain.md §2b). The Confirmed check in handle() still applies
* to $booking itself, not the payment holder, so each leg remains
* independently cancellable/refundable.
*/
public function resolveRefundablePayment(Booking $booking): ?Payment
{
$paymentBooking = $booking->is_return_leg ? ($booking->linkedBooking ?? $booking) : $booking;
return $paymentBooking->payments()->where('status', PaymentStatus::Completed->value)->latest()->first();
}
private function assertWithinRefundableBalance(Payment $payment, string $amount): void private function assertWithinRefundableBalance(Payment $payment, string $amount): void
{ {
$alreadyRefunded = (string) $payment->refunds()->where('status', RefundStatus::Completed->value)->sum('amount'); $remaining = $payment->refundableBalance();
$remaining = bcsub((string) $payment->amount, $alreadyRefunded, 2);
if (bccomp($amount, $remaining, 2) === 1) { if (bccomp($amount, $remaining, 2) === 1) {
throw RefundNotAllowedException::exceedsRefundableBalance($payment, $amount, $remaining); throw RefundNotAllowedException::exceedsRefundableBalance($payment, $amount, $remaining);
@@ -0,0 +1,77 @@
<?php
namespace Modules\Payment\Filament\Actions;
use Filament\Actions\Action;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Support\Icons\Heroicon;
use Modules\Booking\Enums\BookingStatus;
use Modules\Booking\Models\Booking;
use Modules\Payment\Actions\RefundBookingAction;
use Modules\Payment\Exceptions\RefundFailedException;
use Modules\Payment\Exceptions\RefundNotAllowedException;
/**
* Shared between BookingsTable (row action) and ViewBooking (header action)
* in the Booking module lets staff refund a booking directly instead of
* hunting up its Payment on the Refunds resource (ProcessRefundAction). Both
* surfaces call the same RefundBookingAction used by the API.
*/
class RefundBookingTableAction
{
public static function make(): Action
{
return Action::make('refund')
->label('Refund')
->icon(Heroicon::OutlinedReceiptRefund)
->color('danger')
->visible(fn (): bool => auth()->user()?->can('process_refunds') ?? false)
->disabled(fn (Booking $record): bool => $record->status !== BookingStatus::Confirmed)
->schema([
Toggle::make('full_refund')
->label('Full refund')
->live()
->default(true)
->helperText(fn (Booking $record): string => 'Refundable balance: '.(app(RefundBookingAction::class)
->resolveRefundablePayment($record)?->refundableBalance() ?? '0.00')),
TextInput::make('amount')
->numeric()
->minValue(0.01)
->visible(fn (Get $get): bool => ! $get('full_refund'))
->required(fn (Get $get): bool => ! $get('full_refund'))
->maxValue(fn (Booking $record): ?string => app(RefundBookingAction::class)
->resolveRefundablePayment($record)?->refundableBalance()),
Textarea::make('reason')
->required(),
])
->action(function (Booking $record, array $data): void {
$amount = $data['full_refund']
? app(RefundBookingAction::class)->resolveRefundablePayment($record)?->refundableBalance() ?? '0.00'
: (string) $data['amount'];
try {
app(RefundBookingAction::class)->handle(
$record,
$amount,
$data['reason'],
auth()->id(),
);
Notification::make()
->title('Refund processed')
->success()
->send();
} catch (RefundNotAllowedException|RefundFailedException $exception) {
Notification::make()
->title('Refund failed')
->body($exception->getMessage())
->danger()
->send();
}
});
}
}
@@ -6,6 +6,7 @@ use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Modules\Payment\Enums\PaymentStatus; use Modules\Payment\Enums\PaymentStatus;
class PaymentInfolist class PaymentInfolist
@@ -15,40 +16,51 @@ class PaymentInfolist
return $schema return $schema
->components([ ->components([
Section::make('Payment') Section::make('Payment')
->icon(Heroicon::OutlinedBanknotes)
->schema([ ->schema([
Grid::make(4) Grid::make(4)
->schema([ ->schema([
TextEntry::make('booking.booking_ref')->label('Booking'), TextEntry::make('booking.booking_ref')
TextEntry::make('gateway')->badge(), ->label('Booking')
->icon(Heroicon::OutlinedTicket)
->copyable(),
TextEntry::make('gateway')
->icon(Heroicon::OutlinedCreditCard)
->badge(),
TextEntry::make('status') TextEntry::make('status')
->icon(Heroicon::OutlinedCheckCircle)
->badge() ->badge()
->color(fn (PaymentStatus $state) => match ($state) { ->color(fn (PaymentStatus $state) => match ($state) {
PaymentStatus::Pending => 'warning', PaymentStatus::Pending => 'warning',
PaymentStatus::Completed => 'success', PaymentStatus::Completed => 'success',
PaymentStatus::Failed => 'danger', PaymentStatus::Failed => 'danger',
}), }),
TextEntry::make('gateway_transaction_id')->label('Gateway Txn ID'), TextEntry::make('amount')
TextEntry::make('amount')->numeric(2), ->label('Amount')
TextEntry::make('currency'), ->icon(Heroicon::OutlinedCurrencyDollar)
TextEntry::make('initiated_at')->dateTime(), ->money(fn ($record) => $record->currency)
TextEntry::make('completed_at')->dateTime()->placeholder('—'), ->weight('bold'),
]), ]),
]), ]),
// Raw gateway response — may include data not meant for the Section::make('Timeline')
// support role, so it's gated the same as refund initiation ->icon(Heroicon::OutlinedClock)
// (process_refunds: admin/super_admin only, domain.md §6).
Section::make('Gateway Response')
->visible(fn () => auth()->user()?->can('process_refunds') ?? false)
->schema([ ->schema([
TextEntry::make('gateway_payload') Grid::make(3)
->label('') ->schema([
->formatStateUsing(fn (mixed $state) => match (true) { TextEntry::make('gateway_transaction_id')
is_array($state) => json_encode($state, JSON_PRETTY_PRINT), ->label('Gateway Txn ID')
is_string($state) && $state !== '' => $state, ->icon(Heroicon::OutlinedHashtag)
default => null, ->copyable()
}) ->placeholder('—'),
->placeholder('—') TextEntry::make('initiated_at')
->columnSpanFull(), ->icon(Heroicon::OutlinedPlayCircle)
->dateTime()
->placeholder('—'),
TextEntry::make('completed_at')
->icon(Heroicon::OutlinedFlag)
->dateTime()
->placeholder('—'),
]),
]), ]),
]); ]);
} }
@@ -6,7 +6,9 @@ use Filament\Actions\Action;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea; use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Support\Icons\Heroicon; use Filament\Support\Icons\Heroicon;
use Modules\Payment\Actions\RefundBookingAction; use Modules\Payment\Actions\RefundBookingAction;
use Modules\Payment\Enums\PaymentStatus; use Modules\Payment\Enums\PaymentStatus;
@@ -43,11 +45,21 @@ class ProcessRefundAction
$payment->id => "{$payment->booking?->booking_ref}{$payment->amount} {$payment->currency} (#{$payment->id})", $payment->id => "{$payment->booking?->booking_ref}{$payment->amount} {$payment->currency} (#{$payment->id})",
])) ]))
->searchable() ->searchable()
->live()
->required(), ->required(),
Toggle::make('full_refund')
->label('Full refund')
->live()
->default(true)
->helperText(fn (Get $get): string => $get('payment_id')
? 'Refundable balance: '.(Payment::find($get('payment_id'))?->refundableBalance() ?? '0.00')
: 'Select a payment to see its refundable balance.'),
TextInput::make('amount') TextInput::make('amount')
->numeric() ->numeric()
->minValue(0.01) ->minValue(0.01)
->required(), ->visible(fn (Get $get): bool => ! $get('full_refund'))
->required(fn (Get $get): bool => ! $get('full_refund'))
->maxValue(fn (Get $get): ?string => Payment::find($get('payment_id'))?->refundableBalance()),
Textarea::make('reason') Textarea::make('reason')
->required(), ->required(),
]) ])
@@ -68,10 +80,12 @@ class ProcessRefundAction
return; return;
} }
$amount = $data['full_refund'] ? $payment->refundableBalance() : (string) $data['amount'];
try { try {
app(RefundBookingAction::class)->handle( app(RefundBookingAction::class)->handle(
$payment->booking, $payment->booking,
(string) $data['amount'], $amount,
$data['reason'], $data['reason'],
auth()->id(), auth()->id(),
); );
@@ -10,6 +10,7 @@ use Modules\Booking\Models\Booking;
use Modules\Payment\Database\Factories\PaymentFactory; use Modules\Payment\Database\Factories\PaymentFactory;
use Modules\Payment\Enums\PaymentMethod; use Modules\Payment\Enums\PaymentMethod;
use Modules\Payment\Enums\PaymentStatus; use Modules\Payment\Enums\PaymentStatus;
use Modules\Payment\Enums\RefundStatus;
use Spatie\Activitylog\Models\Concerns\LogsActivity; use Spatie\Activitylog\Models\Concerns\LogsActivity;
use Spatie\Activitylog\Support\LogOptions; use Spatie\Activitylog\Support\LogOptions;
@@ -74,4 +75,17 @@ class Payment extends Model
{ {
return $this->hasMany(Refund::class); return $this->hasMany(Refund::class);
} }
/**
* What's left to refund on this Payment its total minus whatever has
* already been completed-refunded (partial refunds supported, domain.md
* §6). Shared by RefundBookingAction's own guard and the Filament refund
* forms, which surface it to staff before they submit.
*/
public function refundableBalance(): string
{
$alreadyRefunded = (string) $this->refunds()->where('status', RefundStatus::Completed->value)->sum('amount');
return bcsub((string) $this->amount, $alreadyRefunded, 2);
}
} }
@@ -2,15 +2,10 @@
namespace Modules\Payment\Providers; namespace Modules\Payment\Providers;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Modules\Payment\Enums\PaymentMethod; use Modules\Payment\Enums\PaymentMethod;
use Modules\Payment\Events\PaymentCompleted;
use Modules\Payment\Events\RefundProcessed;
use Modules\Payment\Factories\PaymentGatewayFactory; use Modules\Payment\Factories\PaymentGatewayFactory;
use Modules\Payment\Gateways\KbzMiniAppGateway; use Modules\Payment\Gateways\KbzMiniAppGateway;
use Modules\Payment\Listeners\MarkBookingPaid;
use Modules\Payment\Listeners\MarkBookingRefunded;
use Modules\Payment\Models\Payment; use Modules\Payment\Models\Payment;
use Modules\Payment\Observers\PaymentObserver; use Modules\Payment\Observers\PaymentObserver;
@@ -28,9 +23,11 @@ class PaymentServiceProvider extends ServiceProvider
public function boot(): void public function boot(): void
{ {
Event::listen(PaymentCompleted::class, MarkBookingPaid::class); // MarkBookingPaid/MarkBookingRefunded are auto-discovered by
Event::listen(RefundProcessed::class, MarkBookingRefunded::class); // internachi/modular's EventsPlugin (any Listeners/*.php with a
// handle(SomeEvent $event) signature) — registering them here too
// used to double-dispatch both listeners (see DriverAssigned's
// BookingServiceProvider for the same fix).
Payment::observe(PaymentObserver::class); Payment::observe(PaymentObserver::class);
} }
} }
@@ -56,25 +56,3 @@ test('can view a payment\'s detail page', function () {
->assertSee($booking->booking_ref) ->assertSee($booking->booking_ref)
->assertSee('EVB-VIEWTEST-1'); ->assertSee('EVB-VIEWTEST-1');
}); });
test('the gateway response is visible to a user with process_refunds', function () {
$admin = User::factory()->create()->givePermissionTo(['view_payments', 'process_refunds']);
$this->actingAs($admin);
$payment = Payment::factory()->create(['gateway_payload' => ['prepay_id' => 'PREPAY-SECRET-123']]);
Livewire::test(ViewPayment::class, ['record' => $payment->getRouteKey()])
->assertOk()
->assertSee('PREPAY-SECRET-123');
});
test('the gateway response is hidden from a user without process_refunds', function () {
$support = User::factory()->create()->givePermissionTo('view_payments');
$this->actingAs($support);
$payment = Payment::factory()->create(['gateway_payload' => ['prepay_id' => 'PREPAY-SECRET-123']]);
Livewire::test(ViewPayment::class, ['record' => $payment->getRouteKey()])
->assertOk()
->assertDontSee('PREPAY-SECRET-123');
});
@@ -77,7 +77,7 @@ test('the process action is visible to a user with process_refunds', function ()
->assertActionVisible('process'); ->assertActionVisible('process');
}); });
test('processing a refund via the action calls RefundBookingAction and cancels the booking', function () { test('processing a partial refund via the action calls RefundBookingAction and cancels the booking', function () {
$admin = User::factory()->create()->givePermissionTo(['view_payments', 'process_refunds']); $admin = User::factory()->create()->givePermissionTo(['view_payments', 'process_refunds']);
$this->actingAs($admin); $this->actingAs($admin);
@@ -92,13 +92,64 @@ test('processing a refund via the action calls RefundBookingAction and cancels t
Livewire::test(ListRefunds::class) Livewire::test(ListRefunds::class)
->callAction('process', data: [ ->callAction('process', data: [
'payment_id' => $payment->id, 'payment_id' => $payment->id,
'amount' => 15000, 'full_refund' => false,
'amount' => 5000,
'reason' => 'customer requested cancellation', 'reason' => 'customer requested cancellation',
]) ])
->assertNotified(); ->assertNotified();
expect($booking->refresh()->status)->toBe(BookingStatus::Cancelled) expect($booking->refresh()->status)->toBe(BookingStatus::Cancelled)
->and(Refund::where('payment_id', $payment->id)->where('status', RefundStatus::Completed)->exists())->toBeTrue(); ->and(Refund::where('payment_id', $payment->id)->where('status', RefundStatus::Completed)->where('amount', 5000)->exists())->toBeTrue();
});
test('the full refund toggle refunds the payment\'s whole refundable balance without an amount input', function () {
$admin = User::factory()->create()->givePermissionTo(['view_payments', 'process_refunds']);
$this->actingAs($admin);
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'price' => 15000]);
$payment = Payment::factory()->completed()->create([
'booking_id' => $booking->id,
'gateway' => PaymentMethod::KbzMiniApp,
'amount' => 15000,
'gateway_transaction_id' => 'EVB-FILAMENT-FULL-1',
]);
Livewire::test(ListRefunds::class)
->callAction('process', data: [
'payment_id' => $payment->id,
'full_refund' => true,
'reason' => 'customer requested cancellation',
])
->assertNotified();
expect(Refund::where('payment_id', $payment->id)->where('status', RefundStatus::Completed)->where('amount', 15000)->exists())->toBeTrue();
});
test('the full refund toggle defaults to on', function () {
$admin = User::factory()->create()->givePermissionTo(['view_payments', 'process_refunds']);
$this->actingAs($admin);
Livewire::test(ListRefunds::class)
->mountAction('process')
->assertActionDataSet(['full_refund' => true]);
});
test('turning the full refund toggle off requires an amount', function () {
$admin = User::factory()->create()->givePermissionTo(['view_payments', 'process_refunds']);
$this->actingAs($admin);
$payment = Payment::factory()->completed()->create([
'gateway' => PaymentMethod::KbzMiniApp,
'amount' => 15000,
]);
Livewire::test(ListRefunds::class)
->callAction('process', data: [
'payment_id' => $payment->id,
'full_refund' => false,
'reason' => 'reason',
])
->assertHasFormErrors(['amount' => 'required']);
}); });
test('a non-completed payment is not offered in the process action\'s payment select', function () { test('a non-completed payment is not offered in the process action\'s payment select', function () {
@@ -151,3 +202,27 @@ test('a payment whose booking has been soft-deleted is not offered in the proces
expect(Refund::where('payment_id', $payment->id)->exists())->toBeFalse(); expect(Refund::where('payment_id', $payment->id)->exists())->toBeFalse();
}); });
test('the process action\'s amount field is capped at the selected payment\'s refundable balance', function () {
$admin = User::factory()->create()->givePermissionTo(['view_payments', 'process_refunds']);
$this->actingAs($admin);
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'price' => 15000]);
$payment = Payment::factory()->completed()->create([
'booking_id' => $booking->id,
'gateway' => PaymentMethod::KbzMiniApp,
'amount' => 15000,
'gateway_transaction_id' => 'EVB-FILAMENT-MAX-1',
]);
Livewire::test(ListRefunds::class)
->callAction('process', data: [
'payment_id' => $payment->id,
'full_refund' => false,
'amount' => 15000.01,
'reason' => 'reason',
])
->assertHasFormErrors(['amount' => 'max']);
expect(Refund::where('payment_id', $payment->id)->exists())->toBeFalse();
});
@@ -0,0 +1,28 @@
<?php
use Modules\Payment\Models\Payment;
use Modules\Payment\Models\Refund;
test('refundable balance is the full amount when nothing has been refunded yet', function () {
$payment = Payment::factory()->completed()->create(['amount' => 15000]);
expect($payment->refundableBalance())->toBe('15000.00');
});
test('refundable balance subtracts only completed refunds', function () {
$payment = Payment::factory()->completed()->create(['amount' => 15000]);
Refund::factory()->completed()->for($payment)->create(['amount' => 5000]);
Refund::factory()->failed()->for($payment)->create(['amount' => 3000]);
Refund::factory()->for($payment)->create(['amount' => 2000]); // default state is Pending
expect($payment->refundableBalance())->toBe('10000.00');
});
test('refundable balance reaches zero once fully refunded', function () {
$payment = Payment::factory()->completed()->create(['amount' => 15000]);
Refund::factory()->completed()->for($payment)->create(['amount' => 15000]);
expect($payment->refundableBalance())->toBe('0.00');
});
+26
View File
@@ -0,0 +1,26 @@
{
"name": "modules/reporting",
"description": "",
"type": "library",
"version": "1.0",
"license": "proprietary",
"require": {
"maatwebsite/excel": "^4.0"
},
"autoload": {
"psr-4": {
"Modules\\Reporting\\": "src/",
"Modules\\Reporting\\Tests\\": "tests/",
"Modules\\Reporting\\Database\\Factories\\": "database/factories/",
"Modules\\Reporting\\Database\\Seeders\\": "database/seeders/"
}
},
"minimum-stability": "stable",
"extra": {
"laravel": {
"providers": [
"Modules\\Reporting\\Providers\\ReportingServiceProvider"
]
}
}
}
@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Supports the Bookings & Revenue report's filters travel_date/status/
* created_by_channel on bookings and completed_at on payments had no
* standalone index before this (only openid and the composite
* [ev_route_id, travel_date, departure_time_slot_id] existed).
*/
public function up(): void
{
Schema::table('bookings', function (Blueprint $table) {
$table->index('travel_date');
$table->index('status');
$table->index('created_by_channel');
});
Schema::table('payments', function (Blueprint $table) {
$table->index('completed_at');
});
}
public function down(): void
{
Schema::table('bookings', function (Blueprint $table) {
$table->dropIndex(['travel_date']);
$table->dropIndex(['status']);
$table->dropIndex(['created_by_channel']);
});
Schema::table('payments', function (Blueprint $table) {
$table->dropIndex(['completed_at']);
});
}
};

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