diff --git a/composer.json b/composer.json index 45482043..47db2dbe 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/core-api", - "version": "1.6.52", + "version": "1.6.53", "description": "Core Framework and Resources for Fleetbase API", "keywords": [ "fleetbase", diff --git a/config/sms.php b/config/sms.php index 6448cedb..5844acb9 100644 --- a/config/sms.php +++ b/config/sms.php @@ -110,11 +110,13 @@ 'custom_http' => [ 'enabled' => env('CUSTOM_HTTP_SMS_ENABLED', false), + 'method' => env('CUSTOM_HTTP_SMS_METHOD', 'POST'), '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' => [], + 'query_params' => [], 'body' => [ 'to' => '{{to}}', 'text' => '{{text}}', diff --git a/migrations/2026_06_22_000001_add_settlement_status_to_transactions_table.php b/migrations/2026_06_22_000001_add_settlement_status_to_transactions_table.php new file mode 100644 index 00000000..6bef2cbc --- /dev/null +++ b/migrations/2026_06_22_000001_add_settlement_status_to_transactions_table.php @@ -0,0 +1,66 @@ +string('settlement_status', 32) + ->default(Transaction::SETTLEMENT_STATUS_UNPAID) + ->after('status') + ->index('transactions_settlement_status_index'); + } + }); + + DB::table('transactions') + ->whereNull('settlement_status') + ->update(['settlement_status' => Transaction::SETTLEMENT_STATUS_UNPAID]); + + DB::table('transactions') + ->where('status', 'completed') + ->update(['status' => Transaction::STATUS_SUCCESS]); + + DB::table('transactions') + ->where('status', 'paid') + ->update([ + 'status' => Transaction::STATUS_SUCCESS, + 'settlement_status' => Transaction::SETTLEMENT_STATUS_PAID, + 'settled_at' => DB::raw('COALESCE(settled_at, updated_at, created_at)'), + ]); + + DB::table('transactions') + ->whereIn('type', [ + Transaction::TYPE_INVOICE_PAYMENT, + Transaction::TYPE_WALLET_DEPOSIT, + Transaction::TYPE_WALLET_WITHDRAWAL, + Transaction::TYPE_WALLET_TRANSFER_IN, + Transaction::TYPE_WALLET_TRANSFER_OUT, + 'deposit', + 'withdrawal', + 'transfer_in', + 'transfer_out', + ]) + ->where('status', Transaction::STATUS_SUCCESS) + ->where('settlement_status', Transaction::SETTLEMENT_STATUS_UNPAID) + ->update([ + 'settlement_status' => Transaction::SETTLEMENT_STATUS_PAID, + 'settled_at' => DB::raw('COALESCE(settled_at, updated_at, created_at)'), + ]); + } + + public function down(): void + { + Schema::table('transactions', function (Blueprint $table) { + if (Schema::hasColumn('transactions', 'settlement_status')) { + $table->dropIndex('transactions_settlement_status_index'); + $table->dropColumn('settlement_status'); + } + }); + } +}; diff --git a/src/Models/Transaction.php b/src/Models/Transaction.php index 9956340d..1f1b38f7 100644 --- a/src/Models/Transaction.php +++ b/src/Models/Transaction.php @@ -82,6 +82,7 @@ class Transaction extends Model 'type', 'direction', 'status', + 'settlement_status', // Monetary (all in smallest currency unit / cents) 'amount', @@ -190,6 +191,16 @@ class Transaction extends Model public const STATUS_VOIDED = 'voided'; public const STATUS_EXPIRED = 'expired'; + // ========================================================================= + // Settlement Status Constants + // ========================================================================= + + public const SETTLEMENT_STATUS_UNPAID = 'unpaid'; + public const SETTLEMENT_STATUS_PARTIALLY_PAID = 'partially_paid'; + public const SETTLEMENT_STATUS_PAID = 'paid'; + public const SETTLEMENT_STATUS_PARTIALLY_REFUNDED = 'partially_refunded'; + public const SETTLEMENT_STATUS_REFUNDED = 'refunded'; + // ========================================================================= // Type Constants — Platform-wide taxonomy // ========================================================================= @@ -352,6 +363,14 @@ public function scopeFailed($query) return $query->where('status', self::STATUS_FAILED); } + /** + * Scope to paid or otherwise settled transactions. + */ + public function scopeSettled($query) + { + return $query->where('settlement_status', self::SETTLEMENT_STATUS_PAID); + } + /** * Scope to a specific transaction type. */ @@ -485,7 +504,34 @@ public function isReversed(): bool */ public function isSettled(): bool { - return $this->settled_at !== null; + return $this->settlement_status === self::SETTLEMENT_STATUS_PAID || $this->settled_at !== null; + } + + /** + * Whether this transaction has not been settled. + */ + public function isUnpaid(): bool + { + return $this->settlement_status === self::SETTLEMENT_STATUS_UNPAID; + } + + /** + * Whether this transaction has been partially settled. + */ + public function isPartiallyPaid(): bool + { + return $this->settlement_status === self::SETTLEMENT_STATUS_PARTIALLY_PAID; + } + + /** + * Whether this transaction has been partially or fully refunded. + */ + public function isRefunded(): bool + { + return in_array($this->settlement_status, [ + self::SETTLEMENT_STATUS_PARTIALLY_REFUNDED, + self::SETTLEMENT_STATUS_REFUNDED, + ], true); } /** diff --git a/src/Services/CustomHttpSmsService.php b/src/Services/CustomHttpSmsService.php index 6e717357..f683294b 100644 --- a/src/Services/CustomHttpSmsService.php +++ b/src/Services/CustomHttpSmsService.php @@ -28,7 +28,9 @@ public function send(string $to, string $text, ?string $from = null, array $opti ]; $url = $this->renderTemplate((string) data_get($this->config, 'url'), $variables); + $method = strtoupper((string) data_get($this->config, 'method', 'POST')); $headers = $this->renderTemplateValues((array) data_get($this->config, 'headers', []), $variables); + $queryParams = $this->renderTemplateValues((array) data_get($this->config, 'query_params', []), $variables); $body = $this->renderTemplateValues((array) data_get($this->config, 'body', [ 'to' => '{{to}}', 'text' => '{{text}}', @@ -43,7 +45,12 @@ public function send(string $to, string $text, ?string $from = null, array $opti Log::info('Sending SMS via custom HTTP gateway', ['to' => $to, 'url' => $url]); - $response = Http::withHeaders($headers)->asJson()->post($url, $body); + $request = Http::withHeaders($headers); + $response = match ($method) { + 'GET' => $request->get($url, $queryParams), + 'POST' => $request->asJson()->post($this->appendQueryParams($url, $queryParams), $body), + default => throw new \InvalidArgumentException("Unsupported custom HTTP SMS method: {$method}"), + }; $payload = $response->json(); if ($response->successful()) { @@ -82,6 +89,11 @@ protected function validateParameters(string $to, string $text): void if (empty($text)) { throw new \InvalidArgumentException('Message text cannot be empty'); } + + $method = strtoupper((string) data_get($this->config, 'method', 'POST')); + if (!in_array($method, ['GET', 'POST'], true)) { + throw new \InvalidArgumentException('Custom HTTP SMS method must be GET or POST'); + } } protected function renderTemplateValues(array $values, array $variables): array @@ -105,4 +117,14 @@ protected function renderTemplate(string $template, array $variables): string return $template; } + + protected function appendQueryParams(string $url, array $queryParams = []): string + { + $queryParams = array_filter($queryParams, static fn ($value) => $value !== null && $value !== ''); + if (empty($queryParams)) { + return $url; + } + + return $url . (str_contains($url, '?') ? '&' : '?') . http_build_query($queryParams); + } } diff --git a/src/Support/EnvironmentMapper.php b/src/Support/EnvironmentMapper.php index c6378275..cba68b87 100644 --- a/src/Support/EnvironmentMapper.php +++ b/src/Support/EnvironmentMapper.php @@ -52,6 +52,7 @@ class EnvironmentMapper '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_METHOD' => 'services.sms.providers.custom_http.method', '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', diff --git a/tests/Unit/MultiProviderSmsServiceTest.php b/tests/Unit/MultiProviderSmsServiceTest.php index c05a938f..1f3005f5 100644 --- a/tests/Unit/MultiProviderSmsServiceTest.php +++ b/tests/Unit/MultiProviderSmsServiceTest.php @@ -17,6 +17,19 @@ use Illuminate\Support\Facades\Http; use Psr\Log\NullLogger; +if (!function_exists('config')) { + function config($key = null, $default = null) + { + $config = Container::getInstance()->make('config'); + + if ($key === null) { + return $config; + } + + return $config->get($key, $default); + } +} + beforeEach(function () { $app = new Container(); @@ -55,6 +68,7 @@ 'source_addr' => 'FLEETBASE', ], 'custom_http' => [ + 'method' => 'POST', 'url' => 'https://sms-gateway.test/send', 'from' => 'Fleetbase', 'auth_header' => 'Authorization', @@ -62,6 +76,7 @@ 'headers' => [ 'X-Tenant' => 'fleetbase', ], + 'query_params' => [], 'body' => [ 'recipient' => '{{to}}', 'message' => '{{text}}', @@ -150,7 +165,7 @@ }); }); -test('custom http sms service renders configured templates', function () { +test('custom http sms service renders configured post templates', function () { Http::fake([ 'https://sms-gateway.test/send' => Http::response([ 'message_id' => 'custom-message-id', @@ -169,7 +184,8 @@ ]); Http::assertSent(function ($request) { - return $request->url() === 'https://sms-gateway.test/send' + return $request->method() === 'POST' + && $request->url() === 'https://sms-gateway.test/send' && $request->hasHeader('Authorization', 'Bearer token') && $request->hasHeader('X-Tenant', 'fleetbase') && $request['recipient'] === '+15551234567' @@ -179,6 +195,57 @@ }); }); +test('custom http sms service supports get method with rendered query params', function () { + Http::fake([ + 'https://sms-gateway.test/send*' => Http::response([ + 'message_id' => 'custom-get-message-id', + 'status' => 'queued', + ], 200), + ]); + + $result = (new CustomHttpSmsService([ + 'method' => 'GET', + 'url' => 'https://sms-gateway.test/send', + 'from' => 'Fleetbase', + 'auth_header' => 'Authorization', + 'auth_token' => 'Bearer {{unique_id}}', + 'headers' => [ + 'X-Recipient' => '{{to}}', + ], + 'query_params' => [ + 'recipient' => '{{to}}', + 'message' => '{{text}}', + 'sender' => '{{from}}', + 'reference' => '{{unique_id}}', + ], + 'body' => [ + 'should_not_send' => '{{text}}', + ], + ]))->send('+15551234567', 'Hello', null, [ + 'unique_id' => 'custom-get-123', + ]); + + expect($result)->toMatchArray([ + 'success' => true, + 'message_id' => 'custom-get-message-id', + 'status' => 'queued', + ]); + + Http::assertSent(function ($request) { + parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query); + + return $request->method() === 'GET' + && str_starts_with($request->url(), 'https://sms-gateway.test/send?') + && $request->hasHeader('Authorization', 'Bearer custom-get-123') + && $request->hasHeader('X-Recipient', '+15551234567') + && $query['recipient'] === '+15551234567' + && $query['message'] === 'Hello' + && $query['sender'] === 'Fleetbase' + && $query['reference'] === 'custom-get-123' + && !isset($request['should_not_send']); + }); +}); + test('aws sns sms service publishes to phone number', function () { $mock = new MockHandler(); $mock->append(new Result(['MessageId' => 'sns-message-id']));