From 5f5419d05df41415f397d2bbf7b521eada8b4232 Mon Sep 17 00:00:00 2001
From: "Ronald A. Richardson"
Date: Sun, 14 Jun 2026 10:29:44 +0800
Subject: [PATCH 1/3] Update Fleetbase blog RSS source
---
src/Support/FleetbaseBlog.php | 4 ++--
tests/Unit/FleetbaseBlogTest.php | 7 ++++---
2 files changed, 6 insertions(+), 5 deletions(-)
diff --git a/src/Support/FleetbaseBlog.php b/src/Support/FleetbaseBlog.php
index 73dbe327..7259b98a 100644
--- a/src/Support/FleetbaseBlog.php
+++ b/src/Support/FleetbaseBlog.php
@@ -62,7 +62,7 @@ public static function normalizeLink(?string $link, ?string $blogUrl = null): st
$host = parse_url($link, PHP_URL_HOST);
$path = trim((string) parse_url($link, PHP_URL_PATH), '/');
- if ($host && Str::contains($host, 'ghost.io') && $path) {
+ if ($host && (Str::contains($host, 'ghost.io') || $host === 'blog.fleetbase.io') && $path) {
return $blogUrl . '/' . $path;
}
@@ -74,7 +74,7 @@ public static function normalizeLink(?string $link, ?string $blogUrl = null): st
*/
public static function getFeedUrl(?string $feedUrl = null): string
{
- return rtrim($feedUrl ?: getenv('FLEETBASE_BLOG_FEED_URL') ?: 'https://fleetbase.ghost.io/rss/', '/') . '/';
+ return rtrim($feedUrl ?: getenv('FLEETBASE_BLOG_FEED_URL') ?: 'https://blog.fleetbase.io/rss/', '/') . '/';
}
/**
diff --git a/tests/Unit/FleetbaseBlogTest.php b/tests/Unit/FleetbaseBlogTest.php
index 1ff3ba31..1aecf17f 100644
--- a/tests/Unit/FleetbaseBlogTest.php
+++ b/tests/Unit/FleetbaseBlogTest.php
@@ -11,7 +11,7 @@ function fleetbaseBlogRssFixture(): string
-
First excerpt.
]]>
- https://fleetbase.ghost.io/first-ghost-post/
+ https://blog.fleetbase.io/first-ghost-post/
ghost-post-1
Wed, 06 May 2026 14:31:46 GMT
@@ -21,7 +21,7 @@ function fleetbaseBlogRssFixture(): string
-
Second excerpt.]]>
- https://fleetbase.ghost.io/second-ghost-post/
+ https://blog.fleetbase.io/second-ghost-post/
ghost-post-2
Wed, 06 May 2026 14:30:46 GMT
@@ -63,5 +63,6 @@ function fleetbaseBlogRssFixture(): string
test('fleetbase blog link normalization keeps non ghost links unchanged', function () {
expect(FleetbaseBlog::normalizeLink('https://www.fleetbase.io/blog/already-canonical'))->toBe('https://www.fleetbase.io/blog/already-canonical')
- ->and(FleetbaseBlog::normalizeLink('https://fleetbase.ghost.io/ghost-post/'))->toBe('https://www.fleetbase.io/blog/ghost-post');
+ ->and(FleetbaseBlog::normalizeLink('https://fleetbase.ghost.io/legacy-ghost-post/'))->toBe('https://www.fleetbase.io/blog/legacy-ghost-post')
+ ->and(FleetbaseBlog::normalizeLink('https://blog.fleetbase.io/ghost-post/'))->toBe('https://www.fleetbase.io/blog/ghost-post');
});
From 8ebeb241b44ca24e6363be59db0a7a0603f0334c Mon Sep 17 00:00:00 2001
From: "Ronald A. Richardson"
Date: Mon, 15 Jun 2026 12:58:26 +0800
Subject: [PATCH 2/3] bumped version to v1.6.52
---
composer.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/composer.json b/composer.json
index d1c78acb..45482043 100644
--- a/composer.json
+++ b/composer.json
@@ -1,6 +1,6 @@
{
"name": "fleetbase/core-api",
- "version": "1.6.51",
+ "version": "1.6.52",
"description": "Core Framework and Resources for Fleetbase API",
"keywords": [
"fleetbase",
From 622fb0252e4e205bfb6df5e56782bc9e1e865fc5 Mon Sep 17 00:00:00 2001
From: "Ronald A. Richardson"
Date: Tue, 16 Jun 2026 13:42:47 +0800
Subject: [PATCH 3/3] Added new sms providers (vonage, sns, messagebird) +
protocol configuration based providers
---
config/sms.php | 55 +++-
src/Exceptions/Handler.php | 16 +-
.../Internal/v1/SettingController.php | 104 ++++++-
src/Services/AwsSnsSmsService.php | 97 +++++++
src/Services/CallProSmsService.php | 8 +-
src/Services/CustomHttpSmsService.php | 108 +++++++
src/Services/MessageBirdSmsService.php | 105 +++++++
src/Services/SmppGatewayClient.php | 136 +++++++++
src/Services/SmppSmsService.php | 82 ++++++
src/Services/SmsService.php | 95 ++++++-
src/Services/VonageSmsService.php | 98 +++++++
src/Support/EnvironmentMapper.php | 22 ++
src/routes.php | 1 +
tests/Unit/MultiProviderSmsServiceTest.php | 268 ++++++++++++++++++
14 files changed, 1180 insertions(+), 15 deletions(-)
create mode 100644 src/Services/AwsSnsSmsService.php
create mode 100644 src/Services/CustomHttpSmsService.php
create mode 100644 src/Services/MessageBirdSmsService.php
create mode 100644 src/Services/SmppGatewayClient.php
create mode 100644 src/Services/SmppSmsService.php
create mode 100644 src/Services/VonageSmsService.php
create mode 100644 tests/Unit/MultiProviderSmsServiceTest.php
diff --git a/config/sms.php b/config/sms.php
index 10aef1e2..6448cedb 100644
--- a/config/sms.php
+++ b/config/sms.php
@@ -9,7 +9,8 @@
| This option controls the default SMS provider that will be used to send
| messages. You may set this to any of the providers defined below.
|
- | Supported: "twilio", "callpro"
+ | Supported: "twilio", "callpro", "vonage", "messagebird", "aws_sns",
+ | "smpp", "custom_http"
|
*/
@@ -68,5 +69,57 @@
'callpro' => [
'enabled' => env('CALLPRO_ENABLED', true),
],
+
+ 'vonage' => [
+ 'enabled' => env('VONAGE_SMS_ENABLED', false),
+ 'api_key' => env('VONAGE_API_KEY', ''),
+ 'api_secret' => env('VONAGE_API_SECRET', ''),
+ 'from' => env('VONAGE_SMS_FROM', ''),
+ 'base_url' => env('VONAGE_SMS_BASE_URL', 'https://rest.nexmo.com/sms/json'),
+ ],
+
+ 'messagebird' => [
+ 'enabled' => env('MESSAGEBIRD_SMS_ENABLED', false),
+ 'access_key' => env('MESSAGEBIRD_ACCESS_KEY', ''),
+ 'originator' => env('MESSAGEBIRD_ORIGINATOR', ''),
+ 'base_url' => env('MESSAGEBIRD_SMS_BASE_URL', 'https://rest.messagebird.com/messages'),
+ ],
+
+ 'aws_sns' => [
+ 'enabled' => env('AWS_SNS_SMS_ENABLED', false),
+ 'key' => env('AWS_SNS_ACCESS_KEY_ID', env('AWS_ACCESS_KEY_ID')),
+ 'secret' => env('AWS_SNS_SECRET_ACCESS_KEY', env('AWS_SECRET_ACCESS_KEY')),
+ 'region' => env('AWS_SNS_REGION', env('AWS_DEFAULT_REGION', 'us-east-1')),
+ 'sender_id' => env('AWS_SNS_SMS_SENDER_ID', ''),
+ 'sms_type' => env('AWS_SNS_SMS_TYPE', 'Transactional'),
+ ],
+
+ 'smpp' => [
+ 'enabled' => env('SMPP_SMS_ENABLED', false),
+ 'host' => env('SMPP_HOST', ''),
+ 'port' => env('SMPP_PORT', 2775),
+ 'tls' => env('SMPP_TLS', false),
+ 'system_id' => env('SMPP_SYSTEM_ID', ''),
+ 'password' => env('SMPP_PASSWORD', ''),
+ 'system_type' => env('SMPP_SYSTEM_TYPE', ''),
+ 'source_addr' => env('SMPP_SOURCE_ADDR', ''),
+ 'bind_type' => env('SMPP_BIND_TYPE', 'transceiver'),
+ 'interface_version' => env('SMPP_INTERFACE_VERSION', 0x34),
+ 'timeout' => env('SMPP_TIMEOUT', 10),
+ ],
+
+ 'custom_http' => [
+ 'enabled' => env('CUSTOM_HTTP_SMS_ENABLED', false),
+ 'url' => env('CUSTOM_HTTP_SMS_URL', ''),
+ 'from' => env('CUSTOM_HTTP_SMS_FROM', ''),
+ 'auth_header' => env('CUSTOM_HTTP_SMS_AUTH_HEADER', ''),
+ 'auth_token' => env('CUSTOM_HTTP_SMS_AUTH_TOKEN', ''),
+ 'headers' => [],
+ 'body' => [
+ 'to' => '{{to}}',
+ 'text' => '{{text}}',
+ 'from' => '{{from}}',
+ ],
+ ],
],
];
diff --git a/src/Exceptions/Handler.php b/src/Exceptions/Handler.php
index 652307c5..4e630c3c 100644
--- a/src/Exceptions/Handler.php
+++ b/src/Exceptions/Handler.php
@@ -4,7 +4,9 @@
use Fleetbase\Support\Utils;
use Illuminate\Auth\AuthenticationException;
+use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
+use Illuminate\Support\Str;
class Handler extends ExceptionHandler
{
@@ -136,7 +138,7 @@ public function getCloudwatchLoggableException(\Throwable $exception)
private function shouldManuallyHandleException(\Throwable $exception): bool
{
$type = Utils::classBasename($exception);
- $exceptions = ['TokenMismatchException', 'ThrottleRequestsException', 'AuthenticationException', 'NotFoundHttpException', 'FleetbaseRequestValidationException'];
+ $exceptions = ['TokenMismatchException', 'ThrottleRequestsException', 'AuthenticationException', 'NotFoundHttpException', 'FleetbaseRequestValidationException', 'ModelNotFoundException'];
return in_array($type, $exceptions);
}
@@ -165,6 +167,9 @@ private function manuallyHandleException(\Throwable $exception): ?\Illuminate\Ht
case 'NotFoundHttpException':
return response()->error('There is nothing to see here.');
+ case 'ModelNotFoundException':
+ return response()->error($this->modelNotFoundMessage($exception), 404);
+
case 'FleetbaseRequestValidationException':
/** @var FleetbaseRequestValidationException $exception */
return response()->error($exception->getErrors());
@@ -173,4 +178,13 @@ private function manuallyHandleException(\Throwable $exception): ?\Illuminate\Ht
return response()->error($exception->getMessage());
}
}
+
+ private function modelNotFoundMessage(\Throwable $exception): string
+ {
+ if ($exception instanceof ModelNotFoundException && $exception->getModel()) {
+ return Str::headline(class_basename($exception->getModel())) . ' not found.';
+ }
+
+ return 'Requested resource not found.';
+ }
}
diff --git a/src/Http/Controllers/Internal/v1/SettingController.php b/src/Http/Controllers/Internal/v1/SettingController.php
index c1ae16f3..c74394cf 100644
--- a/src/Http/Controllers/Internal/v1/SettingController.php
+++ b/src/Http/Controllers/Internal/v1/SettingController.php
@@ -7,6 +7,7 @@
use Fleetbase\Models\File;
use Fleetbase\Models\Setting;
use Fleetbase\Notifications\TestPushNotification;
+use Fleetbase\Services\SmsService;
use Fleetbase\Support\Utils;
use Illuminate\Http\Request;
use Illuminate\Notifications\AnonymousNotifiable;
@@ -461,6 +462,7 @@ public function getServicesConfig(AdminRequest $request)
$twilioSid = config('services.twilio.sid', env('TWILIO_SID'));
$twilioToken = config('services.twilio.token', env('TWILIO_TOKEN'));
$twilioFrom = config('services.twilio.from', env('TWILIO_FROM'));
+ $sms = $this->getSmsServicesConfig();
/** sentry service */
$sentryDsn = config('sentry.dsn', env('SENTRY_LARAVEL_DSN', env('SENTRY_DSN')));
@@ -475,6 +477,7 @@ public function getServicesConfig(AdminRequest $request)
'twilioSid' => $twilioSid,
'twilioToken' => $twilioToken,
'twilioFrom' => $twilioFrom,
+ 'sms' => $sms,
'sentryDsn' => $sentryDsn,
]);
}
@@ -486,16 +489,24 @@ public function getServicesConfig(AdminRequest $request)
*/
public function saveServicesConfig(AdminRequest $request)
{
- $aws = $request->input('aws', config('services.aws'));
- $ipinfo = $request->input('ipinfo', config('services.ipinfo'));
- $googleMaps = $request->input('googleMaps', config('services.google_maps'));
- $twilio = $request->input('twilio', config('services.twilio'));
- $sentry = $request->input('sentry', config('sentry.dsn'));
+ $aws = $request->input('aws', config('services.aws'));
+ $ipinfo = $request->input('ipinfo', config('services.ipinfo'));
+ $googleMaps = $request->input('googleMaps', config('services.google_maps'));
+ $twilio = $request->input('twilio', config('services.twilio'));
+ $sentry = $request->input('sentry', config('sentry.dsn'));
+ $smsProviders = $request->input('sms.providers', config('services.sms.providers', config('sms.providers', [])));
+ $smsDefaultProvider = $request->input('sms.defaultProvider', config('sms.default_provider'));
+ $smsRoutingRules = $request->input('sms.routingRules', config('sms.routing_rules', []));
Setting::configureSystem('services.aws', array_merge(config('services.aws', []), $aws));
Setting::configureSystem('services.ipinfo', array_merge(config('services.ipinfo', []), $ipinfo));
Setting::configureSystem('services.google_maps', array_merge(config('services.google_maps', []), $googleMaps));
Setting::configureSystem('services.twilio', array_merge(config('services.twilio', []), $twilio));
+ Setting::configureSystem('services.sms', array_merge(config('services.sms', []), [
+ 'providers' => $smsProviders,
+ ]));
+ Setting::configureSystem('sms.default_provider', $smsDefaultProvider);
+ Setting::configureSystem('sms.routing_rules', $smsRoutingRules);
Setting::configureSystem('services.sentry', array_merge(config('sentry', []), $sentry));
// Refresh config
@@ -504,6 +515,47 @@ public function saveServicesConfig(AdminRequest $request)
return response()->json(['status' => 'OK']);
}
+ /**
+ * Sends a test SMS message using any configured SMS provider.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function testSmsProviderConfig(AdminRequest $request)
+ {
+ $provider = $request->input('provider', config('sms.default_provider', SmsService::PROVIDER_TWILIO));
+ $phone = $request->input('phone');
+ $message = $request->input('message', 'This is a Fleetbase SMS test.');
+ $config = $request->input('config', []);
+
+ if (!$phone) {
+ return response()->json(['status' => 'error', 'message' => 'No test phone number provided!']);
+ }
+
+ $this->setTemporarySmsProviderConfig($provider, is_array($config) ? $config : []);
+
+ $status = 'success';
+ $responseMessage = 'SMS configuration is successful, SMS sent to ' . $phone . '.';
+ $result = null;
+
+ try {
+ $result = (new SmsService())->send($phone, $message, [], $provider);
+ if (is_array($result) && data_get($result, 'success') === false) {
+ $status = 'error';
+ $responseMessage = data_get($result, 'error', 'SMS provider returned an error.');
+ }
+ } catch (\Throwable $e) {
+ $responseMessage = $e->getMessage();
+ $status = 'error';
+ }
+
+ return response()->json([
+ 'status' => $status,
+ 'message' => $responseMessage,
+ 'provider' => $provider,
+ 'result' => $result,
+ ]);
+ }
+
/**
* Loads and sends the notification channel configurations.
*
@@ -766,6 +818,48 @@ public function testTwilioConfig(AdminRequest $request)
return response()->json(['status' => $status, 'message' => $message]);
}
+ protected function getSmsServicesConfig(): array
+ {
+ $providers = array_replace_recursive(config('sms.providers', []), config('services.sms.providers', []));
+
+ if (!isset($providers[SmsService::PROVIDER_TWILIO])) {
+ $providers[SmsService::PROVIDER_TWILIO] = [];
+ }
+
+ $providers[SmsService::PROVIDER_TWILIO] = array_replace_recursive($providers[SmsService::PROVIDER_TWILIO], config('services.twilio', []));
+
+ return [
+ 'defaultProvider' => config('sms.default_provider', SmsService::PROVIDER_TWILIO),
+ 'routingRules' => config('sms.routing_rules', []),
+ 'providers' => $providers,
+ 'available' => (new SmsService())->getAvailableProviders(),
+ ];
+ }
+
+ protected function setTemporarySmsProviderConfig(string $provider, array $providerConfig): void
+ {
+ $providers = array_replace_recursive(config('sms.providers', []), config('services.sms.providers', []));
+ $providers[$provider] = array_replace_recursive($providers[$provider] ?? [], $providerConfig);
+
+ config([
+ 'services.sms.providers' => $providers,
+ 'sms.providers' => array_replace_recursive(config('sms.providers', []), $providers),
+ ]);
+
+ if ($provider === SmsService::PROVIDER_TWILIO) {
+ config([
+ 'services.twilio' => array_replace_recursive(config('services.twilio', []), $providerConfig),
+ 'twilio.twilio.connections.twilio' => array_replace_recursive(config('twilio.twilio.connections.twilio', []), $providerConfig),
+ ]);
+ }
+
+ if ($provider === SmsService::PROVIDER_CALLPRO) {
+ config([
+ 'services.callpromn' => array_replace_recursive(config('services.callpromn', []), $providerConfig),
+ ]);
+ }
+ }
+
/**
* Sends a test exception to Sentry.
*
diff --git a/src/Services/AwsSnsSmsService.php b/src/Services/AwsSnsSmsService.php
new file mode 100644
index 00000000..7f688e9e
--- /dev/null
+++ b/src/Services/AwsSnsSmsService.php
@@ -0,0 +1,97 @@
+config = array_merge($awsConfig, $smsConfig);
+ $this->client = $client;
+ }
+
+ public function send(string $to, string $text, ?string $from = null, array $options = []): array
+ {
+ $this->validateParameters($to, $text);
+
+ $params = [
+ 'PhoneNumber' => $to,
+ 'Message' => $text,
+ ];
+
+ $senderId = $from ?: data_get($this->config, 'sender_id');
+ $smsType = data_get($options, 'sms_type', data_get($this->config, 'sms_type', 'Transactional'));
+
+ $attributes = array_filter([
+ 'AWS.SNS.SMS.SenderID' => $senderId ? [
+ 'DataType' => 'String',
+ 'StringValue' => $senderId,
+ ] : null,
+ 'AWS.SNS.SMS.SMSType' => $smsType ? [
+ 'DataType' => 'String',
+ 'StringValue' => $smsType,
+ ] : null,
+ ]);
+
+ if (!empty($attributes)) {
+ $params['MessageAttributes'] = $attributes;
+ }
+
+ Log::info('Sending SMS via AWS SNS', ['to' => $to]);
+
+ $result = $this->client()->publish($params);
+
+ return [
+ 'success' => true,
+ 'message_id' => $result->get('MessageId'),
+ 'result' => 'SUCCESS',
+ 'status' => 'sent',
+ 'response' => $result->toArray(),
+ ];
+ }
+
+ public function isConfigured(): bool
+ {
+ return !empty(data_get($this->config, 'key')) && !empty(data_get($this->config, 'secret')) && !empty(data_get($this->config, 'region'));
+ }
+
+ protected function client(): SnsClient
+ {
+ if ($this->client) {
+ return $this->client;
+ }
+
+ return $this->client = new SnsClient([
+ 'version' => 'latest',
+ 'region' => data_get($this->config, 'region', env('AWS_DEFAULT_REGION', 'us-east-1')),
+ 'credentials' => [
+ 'key' => data_get($this->config, 'key', env('AWS_ACCESS_KEY_ID')),
+ 'secret' => data_get($this->config, 'secret', env('AWS_SECRET_ACCESS_KEY')),
+ ],
+ ]);
+ }
+
+ protected function validateParameters(string $to, string $text): void
+ {
+ if (!$this->isConfigured()) {
+ throw new \InvalidArgumentException('AWS SNS SMS provider is not configured');
+ }
+
+ if (empty($to)) {
+ throw new \InvalidArgumentException('Recipient phone number (to) is required');
+ }
+
+ if (empty($text)) {
+ throw new \InvalidArgumentException('Message text cannot be empty');
+ }
+ }
+}
diff --git a/src/Services/CallProSmsService.php b/src/Services/CallProSmsService.php
index 468e464d..d39cd830 100644
--- a/src/Services/CallProSmsService.php
+++ b/src/Services/CallProSmsService.php
@@ -18,9 +18,11 @@ class CallProSmsService
*/
public function __construct()
{
- $this->apiKey = config('services.callpromn.api_key', '');
- $this->from = config('services.callpromn.from', '');
- $this->baseUrl = config('services.callpromn.base_url', 'https://api-text.callpro.mn/v1/sms');
+ $config = array_replace_recursive(config('services.callpromn', []), config('services.sms.providers.callpro', []));
+
+ $this->apiKey = data_get($config, 'api_key', '');
+ $this->from = data_get($config, 'from', '');
+ $this->baseUrl = data_get($config, 'base_url', 'https://api-text.callpro.mn/v1/sms');
Log::info('CallProSmsService initialized', [
'base_url' => $this->baseUrl,
diff --git a/src/Services/CustomHttpSmsService.php b/src/Services/CustomHttpSmsService.php
new file mode 100644
index 00000000..6e717357
--- /dev/null
+++ b/src/Services/CustomHttpSmsService.php
@@ -0,0 +1,108 @@
+config = $config ?? config('services.sms.providers.custom_http', config('sms.providers.custom_http', []));
+ }
+
+ public function send(string $to, string $text, ?string $from = null, array $options = []): array
+ {
+ $this->validateParameters($to, $text);
+
+ $variables = [
+ 'to' => $to,
+ 'text' => $text,
+ 'from' => $from ?: data_get($this->config, 'from', ''),
+ 'provider' => SmsService::PROVIDER_CUSTOM_HTTP,
+ 'timestamp' => date('c'),
+ 'unique_id' => data_get($options, 'unique_id', data_get($options, 'reference', '')),
+ ];
+
+ $url = $this->renderTemplate((string) data_get($this->config, 'url'), $variables);
+ $headers = $this->renderTemplateValues((array) data_get($this->config, 'headers', []), $variables);
+ $body = $this->renderTemplateValues((array) data_get($this->config, 'body', [
+ 'to' => '{{to}}',
+ 'text' => '{{text}}',
+ 'from' => '{{from}}',
+ ]), $variables);
+
+ $authHeaderName = data_get($this->config, 'auth_header');
+ $authHeaderValue = data_get($this->config, 'auth_token');
+ if ($authHeaderName && $authHeaderValue) {
+ $headers[$authHeaderName] = $this->renderTemplate((string) $authHeaderValue, $variables);
+ }
+
+ Log::info('Sending SMS via custom HTTP gateway', ['to' => $to, 'url' => $url]);
+
+ $response = Http::withHeaders($headers)->asJson()->post($url, $body);
+ $payload = $response->json();
+
+ if ($response->successful()) {
+ return [
+ 'success' => true,
+ 'message_id' => data_get($payload, data_get($this->config, 'message_id_path', 'message_id')),
+ 'result' => data_get($payload, data_get($this->config, 'status_path', 'status'), 'sent'),
+ 'status' => data_get($payload, data_get($this->config, 'status_path', 'status'), 'sent'),
+ 'response' => $payload,
+ ];
+ }
+
+ return [
+ 'success' => false,
+ 'error' => data_get($payload, data_get($this->config, 'error_path', 'error'), "Custom HTTP gateway failed with status code: {$response->status()}"),
+ 'code' => $response->status(),
+ 'response' => $payload,
+ ];
+ }
+
+ public function isConfigured(): bool
+ {
+ return !empty(data_get($this->config, 'url'));
+ }
+
+ protected function validateParameters(string $to, string $text): void
+ {
+ if (!$this->isConfigured()) {
+ throw new \InvalidArgumentException('Custom HTTP SMS gateway is not configured');
+ }
+
+ if (empty($to)) {
+ throw new \InvalidArgumentException('Recipient phone number (to) is required');
+ }
+
+ if (empty($text)) {
+ throw new \InvalidArgumentException('Message text cannot be empty');
+ }
+ }
+
+ protected function renderTemplateValues(array $values, array $variables): array
+ {
+ foreach ($values as $key => $value) {
+ if (is_array($value)) {
+ $values[$key] = $this->renderTemplateValues($value, $variables);
+ } elseif (is_string($value)) {
+ $values[$key] = $this->renderTemplate($value, $variables);
+ }
+ }
+
+ return $values;
+ }
+
+ protected function renderTemplate(string $template, array $variables): string
+ {
+ foreach ($variables as $key => $value) {
+ $template = str_replace('{{' . $key . '}}', (string) $value, $template);
+ }
+
+ return $template;
+ }
+}
diff --git a/src/Services/MessageBirdSmsService.php b/src/Services/MessageBirdSmsService.php
new file mode 100644
index 00000000..0baf7785
--- /dev/null
+++ b/src/Services/MessageBirdSmsService.php
@@ -0,0 +1,105 @@
+accessKey = (string) data_get($config, 'access_key', '');
+ $this->originator = (string) data_get($config, 'originator', data_get($config, 'from', ''));
+ $this->baseUrl = rtrim((string) data_get($config, 'base_url', 'https://rest.messagebird.com/messages'), '/');
+ }
+
+ public function send(string $to, string $text, ?string $originator = null, array $options = []): array
+ {
+ $originator = $originator ?: $this->originator;
+ $this->validateParameters($to, $text, $originator);
+
+ $payload = array_filter([
+ 'originator' => $originator,
+ 'recipients' => [$this->normalizeRecipient($to)],
+ 'body' => $text,
+ 'reference' => data_get($options, 'unique_id', data_get($options, 'reference')),
+ 'datacoding' => data_get($options, 'datacoding'),
+ ], static fn ($value) => $value !== null && $value !== '');
+
+ Log::info('Sending SMS via MessageBird', [
+ 'to' => $to,
+ 'originator' => $originator,
+ ]);
+
+ $response = Http::withHeaders([
+ 'Authorization' => 'AccessKey ' . $this->accessKey,
+ 'Accept' => 'application/json',
+ ])->asJson()->post($this->baseUrl, $payload);
+
+ $body = $response->json();
+
+ if ($response->successful() && is_array($body) && isset($body['id'])) {
+ return [
+ 'success' => true,
+ 'message_id' => $body['id'],
+ 'result' => data_get($body, 'recipients.items.0.status', 'sent'),
+ 'status' => data_get($body, 'recipients.items.0.status', 'sent'),
+ 'response' => $body,
+ ];
+ }
+
+ return [
+ 'success' => false,
+ 'error' => $this->getErrorMessage($response->status(), is_array($body) ? $body : null),
+ 'code' => $response->status(),
+ 'response' => $body,
+ ];
+ }
+
+ public function isConfigured(): bool
+ {
+ return !empty($this->accessKey) && !empty($this->originator);
+ }
+
+ protected function validateParameters(string $to, string $text, string $originator): void
+ {
+ if (!$this->isConfigured()) {
+ throw new \InvalidArgumentException('MessageBird SMS provider is not configured');
+ }
+
+ if (empty($to)) {
+ throw new \InvalidArgumentException('Recipient phone number (to) is required');
+ }
+
+ if (empty($text)) {
+ throw new \InvalidArgumentException('Message text cannot be empty');
+ }
+
+ if (empty($originator)) {
+ throw new \InvalidArgumentException('MessageBird originator is required');
+ }
+ }
+
+ protected function normalizeRecipient(string $to): string
+ {
+ return ltrim(preg_replace('/[^0-9+]/', '', $to), '+');
+ }
+
+ protected function getErrorMessage(int $statusCode, ?array $body = null): string
+ {
+ $errors = data_get($body, 'errors');
+ if (is_array($errors) && !empty($errors)) {
+ return collect($errors)->map(fn ($error) => data_get($error, 'description', data_get($error, 'message')))->filter()->implode('; ');
+ }
+
+ return "MessageBird request failed with status code: {$statusCode}";
+ }
+}
diff --git a/src/Services/SmppGatewayClient.php b/src/Services/SmppGatewayClient.php
new file mode 100644
index 00000000..db1b4228
--- /dev/null
+++ b/src/Services/SmppGatewayClient.php
@@ -0,0 +1,136 @@
+config = $config;
+ }
+
+ public function connect(): void
+ {
+ $scheme = data_get($this->config, 'tls') ? 'tls' : 'tcp';
+ $host = data_get($this->config, 'host');
+ $port = (int) data_get($this->config, 'port', 2775);
+ $timeout = (float) data_get($this->config, 'timeout', 10);
+
+ $this->socket = @stream_socket_client("{$scheme}://{$host}:{$port}", $errno, $errstr, $timeout);
+ if (!$this->socket) {
+ throw new \RuntimeException("Unable to connect to SMPP gateway: {$errstr}", $errno);
+ }
+
+ stream_set_timeout($this->socket, (int) $timeout);
+ $this->bind();
+ }
+
+ public function submit(string $from, string $to, string $text, array $options = []): string
+ {
+ $body = $this->cstring((string) data_get($options, 'service_type', ''))
+ . chr((int) data_get($options, 'source_addr_ton', data_get($this->config, 'source_addr_ton', 5)))
+ . chr((int) data_get($options, 'source_addr_npi', data_get($this->config, 'source_addr_npi', 0)))
+ . $this->cstring($from)
+ . chr((int) data_get($options, 'dest_addr_ton', data_get($this->config, 'dest_addr_ton', 1)))
+ . chr((int) data_get($options, 'dest_addr_npi', data_get($this->config, 'dest_addr_npi', 1)))
+ . $this->cstring(ltrim($to, '+'))
+ . chr(0)
+ . chr(0)
+ . chr(0)
+ . $this->cstring('')
+ . $this->cstring('')
+ . chr(1)
+ . chr(0)
+ . chr(0)
+ . chr((int) data_get($options, 'data_coding', data_get($this->config, 'data_coding', 0)))
+ . chr(0)
+ . chr(strlen($text))
+ . $text;
+
+ $response = $this->sendPdu(0x00000004, $body);
+
+ return $this->readCString($response['body']) ?: (string) $response['sequence'];
+ }
+
+ public function close(): void
+ {
+ if (!$this->socket) {
+ return;
+ }
+
+ try {
+ $this->sendPdu(0x00000006, '');
+ } catch (\Throwable $e) {
+ // Ignore disconnect errors while closing the transport.
+ }
+
+ fclose($this->socket);
+ $this->socket = null;
+ }
+
+ protected function bind(): void
+ {
+ $bindMode = (string) data_get($this->config, 'bind_type', 'transceiver');
+ $command = match ($bindMode) {
+ 'transmitter' => 0x00000002,
+ 'receiver' => 0x00000001,
+ default => 0x00000009,
+ };
+
+ $body = $this->cstring((string) data_get($this->config, 'system_id'))
+ . $this->cstring((string) data_get($this->config, 'password', ''))
+ . $this->cstring((string) data_get($this->config, 'system_type', ''))
+ . chr((int) data_get($this->config, 'interface_version', 0x34))
+ . chr((int) data_get($this->config, 'addr_ton', 0))
+ . chr((int) data_get($this->config, 'addr_npi', 0))
+ . $this->cstring((string) data_get($this->config, 'address_range', ''));
+
+ $this->sendPdu($command, $body);
+ }
+
+ protected function sendPdu(int $commandId, string $body): array
+ {
+ $sequence = $this->sequence++;
+ $length = 16 + strlen($body);
+ $packet = pack('NNNN', $length, $commandId, 0, $sequence) . $body;
+
+ fwrite($this->socket, $packet);
+
+ $header = fread($this->socket, 16);
+ if (strlen($header) !== 16) {
+ throw new \RuntimeException('Invalid SMPP response header');
+ }
+
+ $parts = unpack('Nlength/Ncommand/Nstatus/Nsequence', $header);
+ $bodyLength = max(0, $parts['length'] - 16);
+ $responseBody = $bodyLength > 0 ? fread($this->socket, $bodyLength) : '';
+
+ if ((int) $parts['status'] !== 0) {
+ throw new \RuntimeException('SMPP command failed with status: ' . $parts['status'], (int) $parts['status']);
+ }
+
+ return [
+ 'command' => (int) $parts['command'],
+ 'sequence' => (int) $parts['sequence'],
+ 'body' => $responseBody,
+ ];
+ }
+
+ protected function cstring(string $value): string
+ {
+ return $value . "\0";
+ }
+
+ protected function readCString(string $value): string
+ {
+ $position = strpos($value, "\0");
+
+ return $position === false ? $value : substr($value, 0, $position);
+ }
+}
diff --git a/src/Services/SmppSmsService.php b/src/Services/SmppSmsService.php
new file mode 100644
index 00000000..882cd94a
--- /dev/null
+++ b/src/Services/SmppSmsService.php
@@ -0,0 +1,82 @@
+config = $config ?? config('services.sms.providers.smpp', config('sms.providers.smpp', []));
+ $this->clientFactory = $clientFactory;
+ }
+
+ public function send(string $to, string $text, ?string $from = null, array $options = []): array
+ {
+ $from = $from ?: data_get($this->config, 'source_addr');
+ $this->validateParameters($to, $text, $from);
+
+ Log::info('Sending SMS via SMPP gateway', [
+ 'to' => $to,
+ 'from' => $from,
+ ]);
+
+ $client = $this->makeClient();
+ $client->connect();
+
+ try {
+ $messageId = $client->submit($from, $to, $text, $options);
+ } finally {
+ $client->close();
+ }
+
+ return [
+ 'success' => true,
+ 'message_id' => $messageId,
+ 'result' => 'SUCCESS',
+ 'status' => 'sent',
+ ];
+ }
+
+ public function isConfigured(): bool
+ {
+ return !empty(data_get($this->config, 'host'))
+ && !empty(data_get($this->config, 'port'))
+ && !empty(data_get($this->config, 'system_id'))
+ && data_get($this->config, 'password') !== null
+ && !empty(data_get($this->config, 'source_addr'));
+ }
+
+ protected function validateParameters(string $to, string $text, ?string $from): void
+ {
+ if (!$this->isConfigured()) {
+ throw new \InvalidArgumentException('SMPP SMS gateway is not configured');
+ }
+
+ if (empty($to)) {
+ throw new \InvalidArgumentException('Recipient phone number (to) is required');
+ }
+
+ if (empty($text)) {
+ throw new \InvalidArgumentException('Message text cannot be empty');
+ }
+
+ if (empty($from)) {
+ throw new \InvalidArgumentException('SMPP source address is required');
+ }
+ }
+
+ protected function makeClient(): SmppGatewayClient
+ {
+ if (is_callable($this->clientFactory)) {
+ return call_user_func($this->clientFactory, $this->config);
+ }
+
+ return new SmppGatewayClient($this->config);
+ }
+}
diff --git a/src/Services/SmsService.php b/src/Services/SmsService.php
index 4e744218..4599acca 100644
--- a/src/Services/SmsService.php
+++ b/src/Services/SmsService.php
@@ -11,8 +11,13 @@ class SmsService
/**
* Available SMS providers.
*/
- public const PROVIDER_TWILIO = 'twilio';
- public const PROVIDER_CALLPRO = 'callpro';
+ public const PROVIDER_TWILIO = 'twilio';
+ public const PROVIDER_CALLPRO = 'callpro';
+ public const PROVIDER_VONAGE = 'vonage';
+ public const PROVIDER_MESSAGEBIRD = 'messagebird';
+ public const PROVIDER_AWS_SNS = 'aws_sns';
+ public const PROVIDER_SMPP = 'smpp';
+ public const PROVIDER_CUSTOM_HTTP = 'custom_http';
/**
* Default SMS provider.
@@ -70,9 +75,14 @@ public function send(string $to, string $text, array $options = [], ?string $pro
try {
$result = match ($selectedProvider) {
- self::PROVIDER_CALLPRO => $this->sendViaCallPro($normalizedPhone, $text, $options),
- self::PROVIDER_TWILIO => $this->sendViaTwilio($normalizedPhone, $text, $options),
- default => throw new \InvalidArgumentException("Unsupported SMS provider: {$selectedProvider}"),
+ self::PROVIDER_CALLPRO => $this->sendViaCallPro($normalizedPhone, $text, $options),
+ self::PROVIDER_TWILIO => $this->sendViaTwilio($normalizedPhone, $text, $options),
+ self::PROVIDER_VONAGE => $this->sendViaVonage($normalizedPhone, $text, $options),
+ self::PROVIDER_MESSAGEBIRD => $this->sendViaMessageBird($normalizedPhone, $text, $options),
+ self::PROVIDER_AWS_SNS => $this->sendViaAwsSns($normalizedPhone, $text, $options),
+ self::PROVIDER_SMPP => $this->sendViaSmpp($normalizedPhone, $text, $options),
+ self::PROVIDER_CUSTOM_HTTP => $this->sendViaCustomHttp($normalizedPhone, $text, $options),
+ default => throw new \InvalidArgumentException("Unsupported SMS provider: {$selectedProvider}"),
};
$result['provider'] = $selectedProvider;
@@ -176,6 +186,56 @@ protected function sendViaTwilio(string $to, string $text, array $options = []):
}
}
+ /**
+ * Send SMS via Vonage.
+ */
+ protected function sendViaVonage(string $to, string $text, array $options = []): array
+ {
+ $service = new VonageSmsService();
+
+ return $service->send($to, $text, data_get($options, 'from'), $options);
+ }
+
+ /**
+ * Send SMS via MessageBird.
+ */
+ protected function sendViaMessageBird(string $to, string $text, array $options = []): array
+ {
+ $service = new MessageBirdSmsService();
+
+ return $service->send($to, $text, data_get($options, 'from', data_get($options, 'originator')), $options);
+ }
+
+ /**
+ * Send SMS via AWS SNS.
+ */
+ protected function sendViaAwsSns(string $to, string $text, array $options = []): array
+ {
+ $service = new AwsSnsSmsService();
+
+ return $service->send($to, $text, data_get($options, 'from', data_get($options, 'sender_id')), $options);
+ }
+
+ /**
+ * Send SMS via SMPP gateway.
+ */
+ protected function sendViaSmpp(string $to, string $text, array $options = []): array
+ {
+ $service = new SmppSmsService();
+
+ return $service->send($to, $text, data_get($options, 'from', data_get($options, 'source_addr')), $options);
+ }
+
+ /**
+ * Send SMS via custom HTTP gateway.
+ */
+ protected function sendViaCustomHttp(string $to, string $text, array $options = []): array
+ {
+ $service = new CustomHttpSmsService();
+
+ return $service->send($to, $text, data_get($options, 'from'), $options);
+ }
+
/**
* Determine which provider to use based on phone number.
*
@@ -257,6 +317,31 @@ public function getAvailableProviders(): array
'available' => $callProService->isConfigured(),
];
+ $providers[self::PROVIDER_VONAGE] = [
+ 'name' => 'Vonage',
+ 'available' => (new VonageSmsService())->isConfigured(),
+ ];
+
+ $providers[self::PROVIDER_MESSAGEBIRD] = [
+ 'name' => 'MessageBird',
+ 'available' => (new MessageBirdSmsService())->isConfigured(),
+ ];
+
+ $providers[self::PROVIDER_AWS_SNS] = [
+ 'name' => 'AWS SNS',
+ 'available' => (new AwsSnsSmsService())->isConfigured(),
+ ];
+
+ $providers[self::PROVIDER_SMPP] = [
+ 'name' => 'SMPP Gateway',
+ 'available' => (new SmppSmsService())->isConfigured(),
+ ];
+
+ $providers[self::PROVIDER_CUSTOM_HTTP] = [
+ 'name' => 'Custom HTTP Gateway',
+ 'available' => (new CustomHttpSmsService())->isConfigured(),
+ ];
+
return $providers;
}
diff --git a/src/Services/VonageSmsService.php b/src/Services/VonageSmsService.php
new file mode 100644
index 00000000..63677695
--- /dev/null
+++ b/src/Services/VonageSmsService.php
@@ -0,0 +1,98 @@
+apiKey = (string) data_get($config, 'api_key', '');
+ $this->apiSecret = (string) data_get($config, 'api_secret', '');
+ $this->from = (string) data_get($config, 'from', '');
+ $this->baseUrl = rtrim((string) data_get($config, 'base_url', 'https://rest.nexmo.com/sms/json'), '/');
+ }
+
+ public function send(string $to, string $text, ?string $from = null, array $options = []): array
+ {
+ $from = $from ?: $this->from;
+ $this->validateParameters($to, $text, $from);
+
+ $payload = array_filter([
+ 'api_key' => $this->apiKey,
+ 'api_secret' => $this->apiSecret,
+ 'from' => $from,
+ 'to' => $this->normalizeRecipient($to),
+ 'text' => $text,
+ 'type' => data_get($options, 'type'),
+ 'client-ref' => data_get($options, 'unique_id', data_get($options, 'client_ref')),
+ ], static fn ($value) => $value !== null && $value !== '');
+
+ Log::info('Sending SMS via Vonage', [
+ 'to' => $to,
+ 'from' => $from,
+ ]);
+
+ $response = Http::asForm()->post($this->baseUrl, $payload);
+ $body = $response->json();
+ $message = is_array($body) ? data_get($body, 'messages.0', []) : [];
+ $status = (string) data_get($message, 'status', $response->successful() ? '0' : (string) $response->status());
+
+ if ($response->successful() && $status === '0') {
+ return [
+ 'success' => true,
+ 'message_id' => data_get($message, 'message-id'),
+ 'result' => data_get($message, 'status', 'accepted'),
+ 'status' => 'accepted',
+ 'response' => $body,
+ ];
+ }
+
+ return [
+ 'success' => false,
+ 'error' => data_get($message, 'error-text', "Vonage request failed with status code: {$response->status()}"),
+ 'code' => $status,
+ 'response' => $body,
+ ];
+ }
+
+ public function isConfigured(): bool
+ {
+ return !empty($this->apiKey) && !empty($this->apiSecret) && !empty($this->from);
+ }
+
+ protected function validateParameters(string $to, string $text, string $from): void
+ {
+ if (!$this->isConfigured()) {
+ throw new \InvalidArgumentException('Vonage SMS provider is not configured');
+ }
+
+ if (empty($to)) {
+ throw new \InvalidArgumentException('Recipient phone number (to) is required');
+ }
+
+ if (empty($text)) {
+ throw new \InvalidArgumentException('Message text cannot be empty');
+ }
+
+ if (empty($from)) {
+ throw new \InvalidArgumentException('Vonage sender (from) is required');
+ }
+ }
+
+ protected function normalizeRecipient(string $to): string
+ {
+ return ltrim(preg_replace('/[^0-9+]/', '', $to), '+');
+ }
+}
diff --git a/src/Support/EnvironmentMapper.php b/src/Support/EnvironmentMapper.php
index 2cbdc580..c6378275 100644
--- a/src/Support/EnvironmentMapper.php
+++ b/src/Support/EnvironmentMapper.php
@@ -36,6 +36,24 @@ class EnvironmentMapper
'TWILIO_SID' => 'services.twilio.sid',
'TWILIO_TOKEN' => 'services.twilio.token',
'TWILIO_FROM' => 'services.twilio.from',
+ 'SMS_DEFAULT_PROVIDER' => 'sms.default_provider',
+ 'VONAGE_API_KEY' => 'services.sms.providers.vonage.api_key',
+ 'VONAGE_API_SECRET' => 'services.sms.providers.vonage.api_secret',
+ 'VONAGE_SMS_FROM' => 'services.sms.providers.vonage.from',
+ 'MESSAGEBIRD_ACCESS_KEY' => 'services.sms.providers.messagebird.access_key',
+ 'MESSAGEBIRD_ORIGINATOR' => 'services.sms.providers.messagebird.originator',
+ 'AWS_SNS_ACCESS_KEY_ID' => 'services.sms.providers.aws_sns.key',
+ 'AWS_SNS_SECRET_ACCESS_KEY' => 'services.sms.providers.aws_sns.secret',
+ 'AWS_SNS_REGION' => 'services.sms.providers.aws_sns.region',
+ 'AWS_SNS_SMS_SENDER_ID' => 'services.sms.providers.aws_sns.sender_id',
+ 'SMPP_HOST' => 'services.sms.providers.smpp.host',
+ 'SMPP_PORT' => 'services.sms.providers.smpp.port',
+ 'SMPP_SYSTEM_ID' => 'services.sms.providers.smpp.system_id',
+ 'SMPP_PASSWORD' => 'services.sms.providers.smpp.password',
+ 'SMPP_SOURCE_ADDR' => 'services.sms.providers.smpp.source_addr',
+ 'CUSTOM_HTTP_SMS_URL' => 'services.sms.providers.custom_http.url',
+ 'CUSTOM_HTTP_SMS_AUTH_HEADER' => 'services.sms.providers.custom_http.auth_header',
+ 'CUSTOM_HTTP_SMS_AUTH_TOKEN' => 'services.sms.providers.custom_http.auth_token',
'GOOGLE_MAPS_API_KEY' => 'services.google_maps.api_key',
'GOOGLE_MAPS_LOCALE' => 'services.google_maps.locale',
'GOOGLE_CLOUD_PROJECT_ID' => 'filesystem.gcs.project_id',
@@ -95,6 +113,10 @@ class EnvironmentMapper
['settingsKey' => 'services.google_maps', 'configKey' => 'services.google_maps'],
['settingsKey' => 'services.twilio', 'configKey' => 'services.twilio'],
['settingsKey' => 'services.twilio', 'configKey' => 'twilio.twilio.connections.twilio'],
+ ['settingsKey' => 'services.sms', 'configKey' => 'services.sms'],
+ ['settingsKey' => 'services.sms.providers', 'configKey' => 'sms.providers'],
+ ['settingsKey' => 'sms.default_provider', 'configKey' => 'sms.default_provider'],
+ ['settingsKey' => 'sms.routing_rules', 'configKey' => 'sms.routing_rules'],
['settingsKey' => 'services.ipinfo', 'configKey' => 'services.ipinfo'],
['settingsKey' => 'services.ipinfo', 'configKey' => 'fleetbase.services.ipinfo'],
['settingsKey' => 'services.mailgun', 'configKey' => 'services.mailgun'],
diff --git a/src/routes.php b/src/routes.php
index 84e74c11..2ccb6c42 100644
--- a/src/routes.php
+++ b/src/routes.php
@@ -205,6 +205,7 @@ function ($router, $controller) {
$router->post('test-queue-config', $controller('testQueueConfig'));
$router->get('services-config', $controller('getServicesConfig'));
$router->post('services-config', $controller('saveServicesConfig'));
+ $router->post('test-sms-provider-config', $controller('testSmsProviderConfig'));
$router->post('test-twilio-config', $controller('testTwilioConfig'));
$router->post('test-sentry-config', $controller('testSentryConfig'));
$router->post('branding', $controller('saveBrandingSettings'));
diff --git a/tests/Unit/MultiProviderSmsServiceTest.php b/tests/Unit/MultiProviderSmsServiceTest.php
new file mode 100644
index 00000000..c05a938f
--- /dev/null
+++ b/tests/Unit/MultiProviderSmsServiceTest.php
@@ -0,0 +1,268 @@
+instance('config', new Repository([
+ 'services' => [
+ 'aws' => [
+ 'key' => 'aws-key',
+ 'secret' => 'aws-secret',
+ 'region' => 'us-east-1',
+ ],
+ 'sms' => [
+ 'providers' => [
+ 'vonage' => [
+ 'api_key' => 'vonage-key',
+ 'api_secret' => 'vonage-secret',
+ 'from' => 'Fleetbase',
+ 'base_url' => 'https://rest.nexmo.com/sms/json',
+ ],
+ 'messagebird' => [
+ 'access_key' => 'messagebird-key',
+ 'originator' => 'Fleetbase',
+ 'base_url' => 'https://rest.messagebird.com/messages',
+ ],
+ 'aws_sns' => [
+ 'key' => 'aws-key',
+ 'secret' => 'aws-secret',
+ 'region' => 'us-east-1',
+ 'sender_id' => 'FLEETBASE',
+ 'sms_type' => 'Transactional',
+ ],
+ 'smpp' => [
+ 'host' => 'smpp.example.test',
+ 'port' => 2775,
+ 'system_id' => 'fleetbase',
+ 'password' => 'secret',
+ 'source_addr' => 'FLEETBASE',
+ ],
+ 'custom_http' => [
+ 'url' => 'https://sms-gateway.test/send',
+ 'from' => 'Fleetbase',
+ 'auth_header' => 'Authorization',
+ 'auth_token' => 'Bearer token',
+ 'headers' => [
+ 'X-Tenant' => 'fleetbase',
+ ],
+ 'body' => [
+ 'recipient' => '{{to}}',
+ 'message' => '{{text}}',
+ 'sender' => '{{from}}',
+ 'reference' => '{{unique_id}}',
+ ],
+ ],
+ ],
+ ],
+ ],
+ 'sms' => [
+ 'default_provider' => SmsService::PROVIDER_VONAGE,
+ 'routing_rules' => [
+ '+44' => SmsService::PROVIDER_MESSAGEBIRD,
+ ],
+ 'providers' => [],
+ ],
+ ]));
+ $app->instance('log', new NullLogger());
+ $app->instance(Factory::class, new Factory());
+
+ Container::setInstance($app);
+ Facade::setFacadeApplication($app);
+ Facade::clearResolvedInstances();
+});
+
+test('vonage sms service sends form payload and maps success response', function () {
+ Http::fake([
+ 'https://rest.nexmo.com/sms/json' => Http::response([
+ 'messages' => [
+ [
+ 'status' => '0',
+ 'message-id' => 'vonage-message-id',
+ ],
+ ],
+ ], 200),
+ ]);
+
+ $result = (new VonageSmsService())->send('+15551234567', 'Hello');
+
+ expect($result)->toMatchArray([
+ 'success' => true,
+ 'message_id' => 'vonage-message-id',
+ 'status' => 'accepted',
+ ]);
+
+ Http::assertSent(function ($request) {
+ return $request->url() === 'https://rest.nexmo.com/sms/json'
+ && $request['api_key'] === 'vonage-key'
+ && $request['api_secret'] === 'vonage-secret'
+ && $request['from'] === 'Fleetbase'
+ && $request['to'] === '15551234567'
+ && $request['text'] === 'Hello';
+ });
+});
+
+test('messagebird sms service sends json payload and maps message id', function () {
+ Http::fake([
+ 'https://rest.messagebird.com/messages' => Http::response([
+ 'id' => 'messagebird-id',
+ 'recipients' => [
+ 'items' => [
+ ['status' => 'sent'],
+ ],
+ ],
+ ], 201),
+ ]);
+
+ $result = (new MessageBirdSmsService())->send('+15551234567', 'Hello', null, [
+ 'unique_id' => 'verification-123',
+ ]);
+
+ expect($result)->toMatchArray([
+ 'success' => true,
+ 'message_id' => 'messagebird-id',
+ 'status' => 'sent',
+ ]);
+
+ Http::assertSent(function ($request) {
+ return $request->url() === 'https://rest.messagebird.com/messages'
+ && $request->hasHeader('Authorization', 'AccessKey messagebird-key')
+ && $request['originator'] === 'Fleetbase'
+ && $request['recipients'] === ['15551234567']
+ && $request['body'] === 'Hello'
+ && $request['reference'] === 'verification-123';
+ });
+});
+
+test('custom http sms service renders configured templates', function () {
+ Http::fake([
+ 'https://sms-gateway.test/send' => Http::response([
+ 'message_id' => 'custom-message-id',
+ 'status' => 'queued',
+ ], 200),
+ ]);
+
+ $result = (new CustomHttpSmsService())->send('+15551234567', 'Hello', null, [
+ 'unique_id' => 'custom-123',
+ ]);
+
+ expect($result)->toMatchArray([
+ 'success' => true,
+ 'message_id' => 'custom-message-id',
+ 'status' => 'queued',
+ ]);
+
+ Http::assertSent(function ($request) {
+ return $request->url() === 'https://sms-gateway.test/send'
+ && $request->hasHeader('Authorization', 'Bearer token')
+ && $request->hasHeader('X-Tenant', 'fleetbase')
+ && $request['recipient'] === '+15551234567'
+ && $request['message'] === 'Hello'
+ && $request['sender'] === 'Fleetbase'
+ && $request['reference'] === 'custom-123';
+ });
+});
+
+test('aws sns sms service publishes to phone number', function () {
+ $mock = new MockHandler();
+ $mock->append(new Result(['MessageId' => 'sns-message-id']));
+ $client = new SnsClient([
+ 'version' => 'latest',
+ 'region' => 'us-east-1',
+ 'handler' => $mock,
+ 'credentials' => [
+ 'key' => 'aws-key',
+ 'secret' => 'aws-secret',
+ ],
+ ]);
+
+ $result = (new AwsSnsSmsService(null, $client))->send('+15551234567', 'Hello');
+
+ expect($result)->toMatchArray([
+ 'success' => true,
+ 'message_id' => 'sns-message-id',
+ 'status' => 'sent',
+ ]);
+});
+
+test('smpp sms service validates config and delegates to client', function () {
+ $client = new class(config('services.sms.providers.smpp')) extends SmppGatewayClient {
+ public bool $connected = false;
+
+ public bool $closed = false;
+
+ public array $submitted = [];
+
+ public function connect(): void
+ {
+ $this->connected = true;
+ }
+
+ public function submit(string $from, string $to, string $text, array $options = []): string
+ {
+ $this->submitted = compact('from', 'to', 'text', 'options');
+
+ return 'smpp-message-id';
+ }
+
+ public function close(): void
+ {
+ $this->closed = true;
+ }
+ };
+
+ $service = new SmppSmsService(null, fn () => $client);
+ $result = $service->send('+15551234567', 'Hello');
+
+ expect($result)->toMatchArray([
+ 'success' => true,
+ 'message_id' => 'smpp-message-id',
+ ])
+ ->and($client->connected)->toBeTrue()
+ ->and($client->closed)->toBeTrue()
+ ->and($client->submitted['from'])->toBe('FLEETBASE')
+ ->and($client->submitted['to'])->toBe('+15551234567');
+});
+
+test('sms service routes explicit provider and prefix rules to new providers', function () {
+ Http::fake([
+ 'https://rest.messagebird.com/messages' => Http::response([
+ 'id' => 'messagebird-id',
+ ], 201),
+ 'https://rest.nexmo.com/sms/json' => Http::response([
+ 'messages' => [
+ [
+ 'status' => '0',
+ 'message-id' => 'vonage-id',
+ ],
+ ],
+ ], 200),
+ ]);
+
+ $messageBirdResult = (new SmsService())->send('+441234567890', 'Hello');
+ $vonageResult = (new SmsService())->send('+15551234567', 'Hello', [], SmsService::PROVIDER_VONAGE);
+
+ expect($messageBirdResult)->toMatchArray([
+ 'success' => true,
+ 'provider' => SmsService::PROVIDER_MESSAGEBIRD,
+ ])->and($vonageResult)->toMatchArray([
+ 'success' => true,
+ 'provider' => SmsService::PROVIDER_VONAGE,
+ ]);
+});