add sms sending feat
This commit is contained in:
@@ -4,6 +4,7 @@ namespace Modules\Booking\Actions;
|
||||
|
||||
use Modules\Booking\Data\AssignDriverData;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Events\DriverAssigned;
|
||||
use Modules\Booking\Exceptions\DriverAssignmentNotAllowedException;
|
||||
use Modules\Booking\Models\Booking;
|
||||
|
||||
@@ -21,6 +22,12 @@ class AssignDriverAction
|
||||
throw DriverAssignmentNotAllowedException::notConfirmed($booking);
|
||||
}
|
||||
|
||||
if ($booking->travel_date->lt(today())) {
|
||||
throw DriverAssignmentNotAllowedException::travelDateInPast($booking);
|
||||
}
|
||||
|
||||
$isFirstAssignment = $booking->driver_name === null;
|
||||
|
||||
$booking->update([
|
||||
'driver_name' => $data->driverName,
|
||||
'driver_phone' => $data->driverPhone,
|
||||
@@ -28,6 +35,13 @@ class AssignDriverAction
|
||||
'car_model' => $data->carModel,
|
||||
]);
|
||||
|
||||
// Guards against a double-submit of the same form resulting in two
|
||||
// identical SMS notifications to the passenger — a genuine
|
||||
// reassignment always changes at least one of these columns.
|
||||
if ($booking->wasChanged(['driver_name', 'driver_phone', 'car_plate_number', 'car_model'])) {
|
||||
DriverAssigned::dispatch($booking, $isFirstAssignment);
|
||||
}
|
||||
|
||||
return $booking;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Booking\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Modules\Booking\Models\Booking;
|
||||
|
||||
/**
|
||||
* Fired whenever AssignDriverAction sets or updates a booking's
|
||||
* driver/vehicle details — covers both the first assignment and any later
|
||||
* reassignment, since both go through the same action. $isFirstAssignment
|
||||
* lets listeners (e.g. the SMS notification) word the message differently
|
||||
* for "driver assigned" vs "driver info updated".
|
||||
*/
|
||||
class DriverAssigned
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(public Booking $booking, public bool $isFirstAssignment) {}
|
||||
}
|
||||
@@ -16,6 +16,13 @@ class DriverAssignmentNotAllowedException extends RuntimeException
|
||||
);
|
||||
}
|
||||
|
||||
public static function travelDateInPast(Booking $booking): self
|
||||
{
|
||||
return new self(
|
||||
"Booking [{$booking->booking_ref}] cannot have a driver assigned because its travel date [{$booking->travel_date->toDateString()}] is in the past."
|
||||
);
|
||||
}
|
||||
|
||||
public function render(Request $request): ?JsonResponse
|
||||
{
|
||||
if ($request->expectsJson()) {
|
||||
|
||||
+1
@@ -25,6 +25,7 @@ class AssignDriverTableAction
|
||||
->icon(Heroicon::OutlinedTruck)
|
||||
->color('primary')
|
||||
->visible(fn (Booking $record): bool => $record->status === BookingStatus::Confirmed
|
||||
&& $record->travel_date->gte(today())
|
||||
&& (auth()->user()?->can('manage_bookings') ?? false))
|
||||
->schema([
|
||||
TextInput::make('driver_name')->required(),
|
||||
|
||||
@@ -0,0 +1,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;
|
||||
|
||||
use Illuminate\Contracts\Auth\Access\Gate;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Modules\Booking\Events\DriverAssigned;
|
||||
use Modules\Booking\Listeners\SendDriverAssignedSms;
|
||||
use Modules\Booking\Policies\BookingPolicy;
|
||||
|
||||
class BookingServiceProvider extends ServiceProvider
|
||||
@@ -13,5 +16,7 @@ class BookingServiceProvider extends ServiceProvider
|
||||
public function boot(Gate $gate): void
|
||||
{
|
||||
$gate->policy('Modules\Booking\Models\Booking', BookingPolicy::class);
|
||||
|
||||
// Event::listen(DriverAssigned::class, SendDriverAssignedSms::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +149,15 @@ test('the assign driver action is visible for a confirmed booking and hidden oth
|
||||
->assertTableActionHidden('assignDriver', $pending);
|
||||
});
|
||||
|
||||
test('the assign driver action is hidden once the travel date has passed', function () {
|
||||
$past = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'travel_date' => today()->subDay()]);
|
||||
$today = Booking::factory()->create(['status' => BookingStatus::Confirmed, 'travel_date' => today()]);
|
||||
|
||||
Livewire::test(ListBookings::class)
|
||||
->assertTableActionHidden('assignDriver', $past)
|
||||
->assertTableActionVisible('assignDriver', $today);
|
||||
});
|
||||
|
||||
test('the assign driver action is hidden from a user without manage_bookings', function () {
|
||||
$viewer = User::factory()->create()->givePermissionTo('view_bookings');
|
||||
$this->actingAs($viewer);
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Events\DriverAssigned;
|
||||
use Modules\Booking\Listeners\SendDriverAssignedSms;
|
||||
use Modules\Booking\Models\Booking;
|
||||
use Modules\Catalog\Models\Destination;
|
||||
use Modules\Routing\Models\EvRoute;
|
||||
use Modules\Shared\Sms\SmsService;
|
||||
|
||||
beforeEach(function () {
|
||||
config([
|
||||
'app.name' => 'FamousLY4 EV',
|
||||
'app.support_phone' => '+959123456789',
|
||||
'app.support_email' => 'support@famousLY4.test',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a first driver assignment texts the passenger with an "assigned" message including the route', function () {
|
||||
$booking = Booking::factory()->create([
|
||||
'status' => BookingStatus::Confirmed,
|
||||
'passenger_phone' => '+959999888777',
|
||||
'driver_name' => 'U Aung',
|
||||
'driver_phone' => '+959111222333',
|
||||
'car_plate_number' => 'YGN-1234',
|
||||
'car_model' => 'Tesla Model Y',
|
||||
'ev_route_id' => EvRoute::factory()->create([
|
||||
'from_destination_id' => Destination::factory()->create(['name' => 'Yangon'])->id,
|
||||
'to_destination_id' => Destination::factory()->create(['name' => 'Mandalay'])->id,
|
||||
])->id,
|
||||
]);
|
||||
|
||||
$sms = Mockery::mock(SmsService::class);
|
||||
$sms->shouldReceive('send')
|
||||
->once()
|
||||
->with('+959999888777', Mockery::on(fn (string $message) => str_contains($message, 'assigned')
|
||||
&& str_contains($message, 'U Aung')
|
||||
&& str_contains($message, 'YGN-1234')
|
||||
&& str_contains($message, 'Yangon - Mandalay')
|
||||
&& str_contains($message, config('app.name'))
|
||||
&& str_contains($message, config('app.support_phone'))
|
||||
&& str_contains($message, config('app.support_email'))
|
||||
&& str_contains($message, 'ယာဉ်မောင်း')
|
||||
&& str_contains($message, 'အကူအညီလိုအပ်ပါက ဆက်သွယ်ရန်')));
|
||||
|
||||
(new SendDriverAssignedSms($sms))->handle(new DriverAssigned($booking, isFirstAssignment: true));
|
||||
});
|
||||
|
||||
test('a driver reassignment texts the passenger with an "updated" message including the route', function () {
|
||||
$booking = Booking::factory()->create([
|
||||
'status' => BookingStatus::Confirmed,
|
||||
'passenger_phone' => '+959999888777',
|
||||
'driver_name' => 'Daw Hla',
|
||||
'driver_phone' => '+959444555666',
|
||||
'car_plate_number' => 'YGN-5678',
|
||||
'ev_route_id' => EvRoute::factory()->create([
|
||||
'from_destination_id' => Destination::factory()->create(['name' => 'Yangon'])->id,
|
||||
'to_destination_id' => Destination::factory()->create(['name' => 'Mandalay'])->id,
|
||||
])->id,
|
||||
]);
|
||||
|
||||
$sms = Mockery::mock(SmsService::class);
|
||||
$sms->shouldReceive('send')
|
||||
->once()
|
||||
->with('+959999888777', Mockery::on(fn (string $message) => str_contains($message, 'updated')
|
||||
&& str_contains($message, 'Daw Hla')
|
||||
&& str_contains($message, 'Yangon - Mandalay')
|
||||
&& str_contains($message, config('app.name'))
|
||||
&& str_contains($message, config('app.support_phone'))
|
||||
&& str_contains($message, config('app.support_email'))
|
||||
&& str_contains($message, 'ယာဉ်မောင်း')
|
||||
&& str_contains($message, 'အကူအညီလိုအပ်ပါက ဆက်သွယ်ရန်')));
|
||||
|
||||
(new SendDriverAssignedSms($sms))->handle(new DriverAssigned($booking, isFirstAssignment: false));
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Modules\Booking\Actions\AssignDriverAction;
|
||||
use Modules\Booking\Data\AssignDriverData;
|
||||
use Modules\Booking\Enums\BookingStatus;
|
||||
use Modules\Booking\Events\DriverAssigned;
|
||||
use Modules\Booking\Exceptions\DriverAssignmentNotAllowedException;
|
||||
use Modules\Booking\Models\Booking;
|
||||
|
||||
@@ -23,6 +25,57 @@ test('it assigns driver and car details to a confirmed booking', function () {
|
||||
->and($booking->refresh()->driver_name)->toBe('U Aung');
|
||||
});
|
||||
|
||||
test('it dispatches DriverAssigned with isFirstAssignment true for a booking with no prior driver', function () {
|
||||
Event::fake([DriverAssigned::class]);
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
(new AssignDriverAction)->handle($booking, new AssignDriverData(
|
||||
driverName: 'U Aung',
|
||||
driverPhone: '+959111222333',
|
||||
carPlateNumber: 'YGN-1234',
|
||||
));
|
||||
|
||||
Event::assertDispatched(DriverAssigned::class, fn (DriverAssigned $event) => $event->booking->is($booking) && $event->isFirstAssignment === true);
|
||||
});
|
||||
|
||||
test('it dispatches DriverAssigned with isFirstAssignment false when reassigning', function () {
|
||||
Event::fake([DriverAssigned::class]);
|
||||
$booking = Booking::factory()->create([
|
||||
'status' => BookingStatus::Confirmed,
|
||||
'driver_name' => 'U Aung',
|
||||
'driver_phone' => '+959111222333',
|
||||
'car_plate_number' => 'YGN-1234',
|
||||
]);
|
||||
|
||||
(new AssignDriverAction)->handle($booking, new AssignDriverData(
|
||||
driverName: 'Daw Hla',
|
||||
driverPhone: '+959444555666',
|
||||
carPlateNumber: 'YGN-5678',
|
||||
));
|
||||
|
||||
Event::assertDispatched(DriverAssigned::class, fn (DriverAssigned $event) => $event->isFirstAssignment === false);
|
||||
});
|
||||
|
||||
test('it does not dispatch DriverAssigned again when resubmitted with identical driver/car details', function () {
|
||||
Event::fake([DriverAssigned::class]);
|
||||
$booking = Booking::factory()->create([
|
||||
'status' => BookingStatus::Confirmed,
|
||||
'driver_name' => 'U Aung',
|
||||
'driver_phone' => '+959111222333',
|
||||
'car_plate_number' => 'YGN-1234',
|
||||
'car_model' => 'Tesla Model Y',
|
||||
]);
|
||||
|
||||
(new AssignDriverAction)->handle($booking, new AssignDriverData(
|
||||
driverName: 'U Aung',
|
||||
driverPhone: '+959111222333',
|
||||
carPlateNumber: 'YGN-1234',
|
||||
carModel: 'Tesla Model Y',
|
||||
));
|
||||
|
||||
Event::assertNotDispatched(DriverAssigned::class);
|
||||
});
|
||||
|
||||
test('car_model is optional', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
@@ -47,6 +100,36 @@ test('it guards against assigning a driver to a pending_payment booking', functi
|
||||
expect($booking->refresh()->driver_name)->toBeNull();
|
||||
});
|
||||
|
||||
test('it guards against assigning a driver when the travel date has already passed', function () {
|
||||
$booking = Booking::factory()->create([
|
||||
'status' => BookingStatus::Confirmed,
|
||||
'travel_date' => today()->subDay(),
|
||||
]);
|
||||
|
||||
expect(fn () => (new AssignDriverAction)->handle($booking, new AssignDriverData(
|
||||
driverName: 'U Aung',
|
||||
driverPhone: '+959111222333',
|
||||
carPlateNumber: 'YGN-1234',
|
||||
)))->toThrow(DriverAssignmentNotAllowedException::class);
|
||||
|
||||
expect($booking->refresh()->driver_name)->toBeNull();
|
||||
});
|
||||
|
||||
test('it allows assigning a driver when the travel date is today', function () {
|
||||
$booking = Booking::factory()->create([
|
||||
'status' => BookingStatus::Confirmed,
|
||||
'travel_date' => today(),
|
||||
]);
|
||||
|
||||
$updated = (new AssignDriverAction)->handle($booking, new AssignDriverData(
|
||||
driverName: 'U Aung',
|
||||
driverPhone: '+959111222333',
|
||||
carPlateNumber: 'YGN-1234',
|
||||
));
|
||||
|
||||
expect($updated->driver_name)->toBe('U Aung');
|
||||
});
|
||||
|
||||
test('it guards against assigning a driver to a cancelled booking', function () {
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Cancelled]);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace Modules\Identity\Filament\Pages;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\TagsInput;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Notifications\Notification;
|
||||
@@ -12,6 +13,7 @@ use Filament\Schemas\Components\Actions;
|
||||
use Filament\Schemas\Components\Form;
|
||||
use Filament\Schemas\Components\Tabs;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
@@ -61,6 +63,11 @@ class ManageAppSettings extends Page
|
||||
'back_seat_enabled' => (bool) config('booking.back_seat_enabled'),
|
||||
'whole_vehicle_enabled' => (bool) config('booking.whole_vehicle_enabled'),
|
||||
'front_seat_max_per_booking' => config('booking.front_seat_max_per_booking'),
|
||||
'booking_admin_emails' => config('booking.admin_emails'),
|
||||
'sms_enabled' => (bool) config('services.sms.enabled'),
|
||||
'sms_server' => config('services.sms.sms_poh.server'),
|
||||
'sms_token' => config('services.sms.sms_poh.token'),
|
||||
'sms_sender' => config('services.sms.sms_poh.sender'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -111,7 +118,34 @@ class ManageAppSettings extends Page
|
||||
->minValue(1)
|
||||
->required()
|
||||
->helperText('Max Front Seats a single booking may request.'),
|
||||
TagsInput::make('booking_admin_emails')
|
||||
->label('Admin Emails')
|
||||
->required()
|
||||
->helperText('Notified on booking events. Press enter after each address.'),
|
||||
]),
|
||||
Tab::make('SMS')
|
||||
->schema([
|
||||
Toggle::make('sms_enabled')
|
||||
->label('SMS Enabled')
|
||||
->live()
|
||||
->helperText('Whether driver/car SMS notifications are sent at all.'),
|
||||
TextInput::make('sms_server')
|
||||
->label('SMS Server URL')
|
||||
->url()
|
||||
->maxLength(255)
|
||||
->required(fn (Get $get): bool => (bool) $get('sms_enabled')),
|
||||
TextInput::make('sms_token')
|
||||
->label('SMS Token')
|
||||
->password()
|
||||
->revealable()
|
||||
->maxLength(255)
|
||||
->required(fn (Get $get): bool => (bool) $get('sms_enabled')),
|
||||
TextInput::make('sms_sender')
|
||||
->label('SMS Sender')
|
||||
->maxLength(255)
|
||||
->helperText('Default sender name/number for outgoing SMS.'),
|
||||
])
|
||||
->columns(2),
|
||||
]),
|
||||
])
|
||||
->livewireSubmitHandler('save')
|
||||
@@ -139,6 +173,11 @@ class ManageAppSettings extends Page
|
||||
'BOOKING_BACK_SEAT_ENABLED' => (bool) $state['back_seat_enabled'],
|
||||
'BOOKING_WHOLE_VEHICLE_ENABLED' => (bool) $state['whole_vehicle_enabled'],
|
||||
'BOOKING_FRONT_SEAT_MAX_PER_BOOKING' => (int) $state['front_seat_max_per_booking'],
|
||||
'BOOKING_ADMIN_EMAILS' => implode(',', $state['booking_admin_emails'] ?? []),
|
||||
'SMS_ENABLED' => (bool) $state['sms_enabled'],
|
||||
'SMS_SERVER' => $state['sms_server'],
|
||||
'SMS_TOKEN' => $state['sms_token'],
|
||||
'SMS_SENDER' => $state['sms_sender'],
|
||||
]);
|
||||
|
||||
Artisan::call('config:clear');
|
||||
|
||||
@@ -28,6 +28,37 @@ test('a booking status transition is recorded in the audit log', function () {
|
||||
expect($activity->attribute_changes->get('attributes'))->toMatchArray(['status' => BookingStatus::Confirmed->value]);
|
||||
});
|
||||
|
||||
test('assigning a driver is recorded in the audit log with who and when', function () {
|
||||
$dispatcher = User::factory()->create();
|
||||
$booking = Booking::factory()->create(['status' => BookingStatus::Confirmed]);
|
||||
|
||||
$this->actingAs($dispatcher);
|
||||
|
||||
$booking->update([
|
||||
'driver_name' => 'U Aung',
|
||||
'driver_phone' => '+959111222333',
|
||||
'car_plate_number' => 'YGN-1234',
|
||||
'car_model' => 'Tesla Model Y',
|
||||
]);
|
||||
|
||||
$activity = Activity::where('subject_type', Booking::class)
|
||||
->where('subject_id', $booking->id)
|
||||
->where('log_name', 'booking')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($activity)->not->toBeNull();
|
||||
expect($activity->attribute_changes->get('attributes'))->toMatchArray([
|
||||
'driver_name' => 'U Aung',
|
||||
'driver_phone' => '+959111222333',
|
||||
'car_plate_number' => 'YGN-1234',
|
||||
'car_model' => 'Tesla Model Y',
|
||||
]);
|
||||
expect($activity->causer_type)->toBe(User::class);
|
||||
expect($activity->causer_id)->toBe($dispatcher->id);
|
||||
expect($activity->created_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('a catalog CRUD write is recorded in the audit log', function () {
|
||||
$company = EvCompany::factory()->create(['name' => 'Original Name']);
|
||||
|
||||
|
||||
@@ -43,6 +43,11 @@ test('a super_admin can view and save app settings, writing them to .env', funct
|
||||
'back_seat_enabled' => false,
|
||||
'whole_vehicle_enabled' => true,
|
||||
'front_seat_max_per_booking' => 2,
|
||||
'booking_admin_emails' => ['ops@evbooking.test', 'dispatch@evbooking.test'],
|
||||
'sms_enabled' => true,
|
||||
'sms_server' => 'https://sms.example.test/send',
|
||||
'sms_token' => 'secret-token',
|
||||
'sms_sender' => 'EVBooking',
|
||||
])
|
||||
->call('save')
|
||||
->assertHasNoFormErrors();
|
||||
@@ -56,7 +61,23 @@ test('a super_admin can view and save app settings, writing them to .env', funct
|
||||
->toContain('APP_CURRENCY=MMK')
|
||||
->toContain('BOOKING_BACK_SEAT_ENABLED=false')
|
||||
->toContain('BOOKING_WHOLE_VEHICLE_ENABLED=true')
|
||||
->toContain('BOOKING_FRONT_SEAT_MAX_PER_BOOKING=2');
|
||||
->toContain('BOOKING_FRONT_SEAT_MAX_PER_BOOKING=2')
|
||||
->toContain('BOOKING_ADMIN_EMAILS=ops@evbooking.test,dispatch@evbooking.test')
|
||||
->toContain('SMS_ENABLED=true')
|
||||
->toContain('SMS_SERVER=https://sms.example.test/send')
|
||||
->toContain('SMS_TOKEN=secret-token')
|
||||
->toContain('SMS_SENDER=EVBooking');
|
||||
});
|
||||
|
||||
test('sms server and token are required once sms is enabled', function () {
|
||||
$superAdmin = User::factory()->create();
|
||||
$superAdmin->assignRole('super_admin');
|
||||
$this->actingAs($superAdmin);
|
||||
|
||||
Livewire::test(ManageAppSettings::class)
|
||||
->fillForm(['sms_enabled' => true, 'sms_server' => '', 'sms_token' => ''])
|
||||
->call('save')
|
||||
->assertHasFormErrors(['sms_server', 'sms_token']);
|
||||
});
|
||||
|
||||
test('front seat max per booking must be at least 1', function () {
|
||||
|
||||
@@ -2,15 +2,10 @@
|
||||
|
||||
namespace Modules\Payment\Providers;
|
||||
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Modules\Payment\Enums\PaymentMethod;
|
||||
use Modules\Payment\Events\PaymentCompleted;
|
||||
use Modules\Payment\Events\RefundProcessed;
|
||||
use Modules\Payment\Factories\PaymentGatewayFactory;
|
||||
use Modules\Payment\Gateways\KbzMiniAppGateway;
|
||||
use Modules\Payment\Listeners\MarkBookingPaid;
|
||||
use Modules\Payment\Listeners\MarkBookingRefunded;
|
||||
use Modules\Payment\Models\Payment;
|
||||
use Modules\Payment\Observers\PaymentObserver;
|
||||
|
||||
@@ -28,9 +23,11 @@ class PaymentServiceProvider extends ServiceProvider
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
Event::listen(PaymentCompleted::class, MarkBookingPaid::class);
|
||||
Event::listen(RefundProcessed::class, MarkBookingRefunded::class);
|
||||
|
||||
// MarkBookingPaid/MarkBookingRefunded are auto-discovered by
|
||||
// internachi/modular's EventsPlugin (any Listeners/*.php with a
|
||||
// handle(SomeEvent $event) signature) — registering them here too
|
||||
// used to double-dispatch both listeners (see DriverAssigned's
|
||||
// BookingServiceProvider for the same fix).
|
||||
Payment::observe(PaymentObserver::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Shared\Sms;
|
||||
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Thin wrapper around the sms_poh gateway (the only provider configured
|
||||
* today, config('services.sms')). No-ops when SMS is disabled so callers
|
||||
* (queued listeners) can call send() unconditionally in every environment.
|
||||
*/
|
||||
class SmsService
|
||||
{
|
||||
private readonly bool $enabled;
|
||||
|
||||
private readonly ?string $server;
|
||||
|
||||
private readonly ?string $token;
|
||||
|
||||
private readonly ?string $sender;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $config
|
||||
*/
|
||||
public function __construct(?array $config = null)
|
||||
{
|
||||
$config ??= (array) config('services.sms');
|
||||
$providerConfig = (array) ($config['sms_poh'] ?? []);
|
||||
|
||||
$this->enabled = (bool) ($config['enabled'] ?? false);
|
||||
$this->server = $providerConfig['server'] ?? null;
|
||||
$this->token = $providerConfig['token'] ?? null;
|
||||
$this->sender = $providerConfig['sender'] ?? null;
|
||||
}
|
||||
|
||||
public function send(string $to, string $message, ?string $from = null): bool
|
||||
{
|
||||
if (! $this->enabled || $this->server === null || $this->token === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::withToken($this->token)
|
||||
->post($this->server, [
|
||||
'to' => $to,
|
||||
'message' => $message,
|
||||
'from' => $from ?? $this->sender,
|
||||
]);
|
||||
|
||||
Log::notice('Send SMS Response : '.$to.' '.$response->body());
|
||||
|
||||
return $response->successful();
|
||||
} catch (ConnectionException $exception) {
|
||||
Log::error('Send SMS Error : '.$to.' '.$exception->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Modules\Shared\Sms\SmsService;
|
||||
|
||||
$config = [
|
||||
'enabled' => true,
|
||||
'sms_poh' => [
|
||||
'server' => 'https://sms.test/send',
|
||||
'token' => 'test-token',
|
||||
'sender' => 'FamousLY4',
|
||||
],
|
||||
];
|
||||
|
||||
test('send posts to the configured server with a bearer token and returns true on success', function () use ($config) {
|
||||
Http::fake(['sms.test/*' => Http::response(['status' => 'ok'])]);
|
||||
|
||||
$result = (new SmsService($config))->send('+959111222333', 'Your driver is here.');
|
||||
|
||||
expect($result)->toBeTrue();
|
||||
Http::assertSent(function ($request) {
|
||||
return $request->url() === 'https://sms.test/send'
|
||||
&& $request->hasHeader('Authorization', 'Bearer test-token')
|
||||
&& $request['to'] === '+959111222333'
|
||||
&& $request['message'] === 'Your driver is here.'
|
||||
&& $request['from'] === 'FamousLY4';
|
||||
});
|
||||
});
|
||||
|
||||
test('send returns false and does not call the gateway when disabled', function () use ($config) {
|
||||
Http::fake();
|
||||
$config['enabled'] = false;
|
||||
|
||||
$result = (new SmsService($config))->send('+959111222333', 'Your driver is here.');
|
||||
|
||||
expect($result)->toBeFalse();
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('send returns false on a non-successful gateway response', function () use ($config) {
|
||||
Http::fake(['sms.test/*' => Http::response(['error' => 'invalid'], 422)]);
|
||||
|
||||
$result = (new SmsService($config))->send('+959111222333', 'Your driver is here.');
|
||||
|
||||
expect($result)->toBeFalse();
|
||||
});
|
||||
|
||||
test('send uses an explicit from over the configured sender', function () use ($config) {
|
||||
Http::fake(['sms.test/*' => Http::response(['status' => 'ok'])]);
|
||||
|
||||
(new SmsService($config))->send('+959111222333', 'Hello', 'OtherSender');
|
||||
|
||||
Http::assertSent(fn ($request) => $request['from'] === 'OtherSender');
|
||||
});
|
||||
Reference in New Issue
Block a user