diff --git a/.env.example b/.env.example index f68bd43..cdbaac3 100644 --- a/.env.example +++ b/.env.example @@ -57,6 +57,13 @@ BOOKING_BACK_SEAT_ENABLED=true BOOKING_WHOLE_VEHICLE_ENABLED=true BOOKING_FRONT_SEAT_MAX_PER_BOOKING=1 +BOOKING_ADMIN_EMAILS="example@gmail.com" + +SMS_ENABLED=false +SMS_SERVER= +SMS_TOKEN= +SMS_SENDER= + KBZ_APP_ID= KBZ_MERCHANT_CODE= KBZ_MERCHANT_KEY= diff --git a/app-modules/booking/src/Actions/AssignDriverAction.php b/app-modules/booking/src/Actions/AssignDriverAction.php index ba331de..bbd9168 100644 --- a/app-modules/booking/src/Actions/AssignDriverAction.php +++ b/app-modules/booking/src/Actions/AssignDriverAction.php @@ -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; } } diff --git a/app-modules/booking/src/Events/DriverAssigned.php b/app-modules/booking/src/Events/DriverAssigned.php new file mode 100644 index 0000000..b23f81f --- /dev/null +++ b/app-modules/booking/src/Events/DriverAssigned.php @@ -0,0 +1,20 @@ +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()) { diff --git a/app-modules/booking/src/Filament/Resources/Bookings/Actions/AssignDriverTableAction.php b/app-modules/booking/src/Filament/Resources/Bookings/Actions/AssignDriverTableAction.php index ef345a6..1c68f86 100644 --- a/app-modules/booking/src/Filament/Resources/Bookings/Actions/AssignDriverTableAction.php +++ b/app-modules/booking/src/Filament/Resources/Bookings/Actions/AssignDriverTableAction.php @@ -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(), diff --git a/app-modules/booking/src/Listeners/SendDriverAssignedSms.php b/app-modules/booking/src/Listeners/SendDriverAssignedSms.php new file mode 100644 index 0000000..27eb609 --- /dev/null +++ b/app-modules/booking/src/Listeners/SendDriverAssignedSms.php @@ -0,0 +1,50 @@ +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}"; + } +} diff --git a/app-modules/booking/src/Providers/BookingServiceProvider.php b/app-modules/booking/src/Providers/BookingServiceProvider.php index bc96f84..b05bceb 100644 --- a/app-modules/booking/src/Providers/BookingServiceProvider.php +++ b/app-modules/booking/src/Providers/BookingServiceProvider.php @@ -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); } } diff --git a/app-modules/booking/tests/Feature/BookingResourceTest.php b/app-modules/booking/tests/Feature/BookingResourceTest.php index b8b08f4..23281fa 100644 --- a/app-modules/booking/tests/Feature/BookingResourceTest.php +++ b/app-modules/booking/tests/Feature/BookingResourceTest.php @@ -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); diff --git a/app-modules/booking/tests/Feature/SendDriverAssignedSmsTest.php b/app-modules/booking/tests/Feature/SendDriverAssignedSmsTest.php new file mode 100644 index 0000000..67d0ff6 --- /dev/null +++ b/app-modules/booking/tests/Feature/SendDriverAssignedSmsTest.php @@ -0,0 +1,75 @@ + '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)); +}); diff --git a/app-modules/booking/tests/Unit/AssignDriverActionTest.php b/app-modules/booking/tests/Unit/AssignDriverActionTest.php index 7289f4c..ebe06db 100644 --- a/app-modules/booking/tests/Unit/AssignDriverActionTest.php +++ b/app-modules/booking/tests/Unit/AssignDriverActionTest.php @@ -1,8 +1,10 @@ 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]); diff --git a/app-modules/identity/src/Filament/Pages/ManageAppSettings.php b/app-modules/identity/src/Filament/Pages/ManageAppSettings.php index c449005..a25febf 100644 --- a/app-modules/identity/src/Filament/Pages/ManageAppSettings.php +++ b/app-modules/identity/src/Filament/Pages/ManageAppSettings.php @@ -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'); diff --git a/app-modules/identity/tests/Feature/AuditLogTest.php b/app-modules/identity/tests/Feature/AuditLogTest.php index 9a73631..ee597f0 100644 --- a/app-modules/identity/tests/Feature/AuditLogTest.php +++ b/app-modules/identity/tests/Feature/AuditLogTest.php @@ -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']); diff --git a/app-modules/identity/tests/Feature/ManageAppSettingsTest.php b/app-modules/identity/tests/Feature/ManageAppSettingsTest.php index 49184ad..0163305 100644 --- a/app-modules/identity/tests/Feature/ManageAppSettingsTest.php +++ b/app-modules/identity/tests/Feature/ManageAppSettingsTest.php @@ -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 () { diff --git a/app-modules/payment/src/Providers/PaymentServiceProvider.php b/app-modules/payment/src/Providers/PaymentServiceProvider.php index 6898f8b..96c070b 100644 --- a/app-modules/payment/src/Providers/PaymentServiceProvider.php +++ b/app-modules/payment/src/Providers/PaymentServiceProvider.php @@ -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); } } diff --git a/app-modules/shared/src/Sms/SmsService.php b/app-modules/shared/src/Sms/SmsService.php new file mode 100644 index 0000000..5f7b9f3 --- /dev/null +++ b/app-modules/shared/src/Sms/SmsService.php @@ -0,0 +1,61 @@ +|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; + } + } +} diff --git a/app-modules/shared/tests/Unit/SmsServiceTest.php b/app-modules/shared/tests/Unit/SmsServiceTest.php new file mode 100644 index 0000000..b5f1264 --- /dev/null +++ b/app-modules/shared/tests/Unit/SmsServiceTest.php @@ -0,0 +1,54 @@ + 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'); +}); diff --git a/config/services.php b/config/services.php index 70453f9..d93fe6f 100644 --- a/config/services.php +++ b/config/services.php @@ -40,6 +40,15 @@ return [ 'jwt_algorithm' => env('FASTAPI_AGENT_JWT_ALGORITHM', 'HS256'), ], + 'sms' => [ + 'enabled' => env('SMS_ENABLED', false), + 'sms_poh' => [ + 'server' => env('SMS_SERVER'), + 'token' => env('SMS_TOKEN'), + 'sender' => env('SMS_SENDER'), + ], + ], + 'kbz' => [ 'app_id' => env('KBZ_APP_ID'), 'merchant_code' => env('KBZ_MERCHANT_CODE'),