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/Http/Controllers/Internal/v1/UserController.php b/src/Http/Controllers/Internal/v1/UserController.php index 5f0e85f3..22189776 100644 --- a/src/Http/Controllers/Internal/v1/UserController.php +++ b/src/Http/Controllers/Internal/v1/UserController.php @@ -486,7 +486,10 @@ public function resendInvitation(ResendUserInvite $request) #[SkipAuthorizationCheck] public function acceptCompanyInvite(AcceptCompanyInvite $request) { - $invite = Invite::where('code', $request->input('code'))->with(['subject'])->first(); + $invite = $this->findCompanyInvite($request->input('code')); + if (!$invite) { + return response()->error('This invitation has already been accepted or is no longer available.'); + } // get invited email $email = Arr::first($invite->recipients); @@ -563,6 +566,11 @@ public function acceptCompanyInvite(AcceptCompanyInvite $request) ]); } + protected function findCompanyInvite(string $code): ?Invite + { + return Invite::where('code', $code)->with(['subject'])->first(); + } + /** * Deactivates a user. * diff --git a/src/Http/Requests/Internal/AcceptCompanyInvite.php b/src/Http/Requests/Internal/AcceptCompanyInvite.php index 60547032..f22a565c 100644 --- a/src/Http/Requests/Internal/AcceptCompanyInvite.php +++ b/src/Http/Requests/Internal/AcceptCompanyInvite.php @@ -24,7 +24,7 @@ public function authorize() public function rules() { return [ - 'code' => ['required', 'exists:invites,code'], + 'code' => ['required'], ]; } } 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/Routing/RESTRegistrar.php b/src/Routing/RESTRegistrar.php index 30cd87ad..7852ecd5 100644 --- a/src/Routing/RESTRegistrar.php +++ b/src/Routing/RESTRegistrar.php @@ -13,7 +13,7 @@ class RESTRegistrar extends ResourceRegistrar * * @var string[] */ - protected $resourceDefaults = ['query', 'find', 'create', 'update', 'delete']; + protected $resourceDefaults = ['query', 'create', 'bulkDelete', 'find', 'update', 'delete']; /** * Build a set of prefixed resource routes. @@ -85,6 +85,31 @@ protected function addResourceFind($name, $id, $controller, $options) return $this->router->get($uri, $action)->name($uniqueName); } + /** + * Add the bulk delete method for a resourceful route. + * + * DELETE /resource/bulk-delete + * + * @param string $name + * @param string $id + * @param string $controller + * @param array $options + * + * @return \Illuminate\Routing\Route + */ + protected function addResourceBulkDelete($name, $id, $controller, $options) + { + $name = $this->getShallowName($name, $options); + + $uri = $this->getResourceUri($name) . '/bulk-delete'; + + $action = $this->getResourceAction($name, $controller, 'bulkDelete', $options); + + $uniqueName = $this->getUniqueRouteName(['bulk-delete', 'delete'], $name, $options); + + return $this->router->delete($uri, $action)->name($uniqueName); + } + /** * Add the create method for a resourceful route. * diff --git a/src/Services/CustomHttpSmsService.php b/src/Services/CustomHttpSmsService.php index 6e717357..f168a1f0 100644 --- a/src/Services/CustomHttpSmsService.php +++ b/src/Services/CustomHttpSmsService.php @@ -27,9 +27,11 @@ public function send(string $to, string $text, ?string $from = null, array $opti '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', [ + $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}}', 'from' => '{{from}}', @@ -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/src/routes.php b/src/routes.php index 2ccb6c42..f8766ba8 100644 --- a/src/routes.php +++ b/src/routes.php @@ -162,7 +162,6 @@ function ($router) { $router->fleetbaseRoutes( 'api-credentials', function ($router, $controller) { - $router->delete('bulk-delete', $controller('bulkDelete')); $router->patch('roll/{id}', $controller('roll')); $router->get('export', $controller('export')); } @@ -263,7 +262,6 @@ function ($router, $controller) { $router->patch('activate/{id}', $controller('activate')); $router->patch('verify/{id}', $controller('verify')); $router->delete('remove-from-company/{id}', $controller('removeFromCompany')); - $router->delete('bulk-delete', $controller('bulkDelete')); $router->post('invite-user', $controller('inviteUser')); $router->post('resend-invite', $controller('resendInvitation')); $router->post('set-password', $controller('setCurrentUserPassword')); @@ -309,7 +307,6 @@ function ($router, $controller) { $router->get('get-settings', $controller('getSettings')); $router->put('mark-as-read', $controller('markAsRead')); $router->put('mark-all-read', $controller('markAllAsRead')); - $router->delete('bulk-delete', $controller('bulkDelete')); $router->post('save-settings', $controller('saveSettings')); }); $router->fleetbaseRoutes('dashboards', function ($router, $controller) { @@ -348,7 +345,6 @@ function ($router, $controller) { $router->fleetbaseRoutes('templates', function ($router, $controller) { $router->get('context-schemas', $controller('contextSchemas')); $router->post('preview', $controller('previewUnsaved')); - $router->delete('bulk-delete', $controller('bulkDelete')); $router->post('{id}/preview', $controller('preview')); $router->post('{id}/render', $controller('render')); }); diff --git a/tests/Unit/AcceptCompanyInviteTest.php b/tests/Unit/AcceptCompanyInviteTest.php new file mode 100644 index 00000000..7fd00cf1 --- /dev/null +++ b/tests/Unit/AcceptCompanyInviteTest.php @@ -0,0 +1,91 @@ +json( + array_merge([ + 'errors' => is_array($error) ? $error : [$error], + ], $data), + $statusCode + ); + } + + public function json(array $data, int $statusCode = 200): JsonResponse + { + return new JsonResponse($data, $statusCode); + } + } + + class AcceptCompanyInviteTestController extends UserController + { + public function __construct() + { + } + + protected function findCompanyInvite(string $code): ?Invite + { + return null; + } + } + + test('accepting an unavailable company invite returns a fleetbase error response', function () { + $request = AcceptCompanyInvite::create('/internal/v1/users/accept-company-invite', 'POST', [ + 'code' => 'USED123', + ]); + + $response = (new AcceptCompanyInviteTestController())->acceptCompanyInvite($request); + $payload = json_decode($response->getContent(), true); + + expect($response->getStatusCode())->toBe(400) + ->and($payload)->toBe([ + 'errors' => ['This invitation has already been accepted or is no longer available.'], + ]); + }); + + test('company invite acceptance still requires a code', function () { + $request = new AcceptCompanyInvite(); + + expect($request->rules())->toBe([ + 'code' => ['required'], + ]); + }); +} diff --git a/tests/Unit/MultiProviderSmsServiceTest.php b/tests/Unit/MultiProviderSmsServiceTest.php index c05a938f..17d4b239 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,7 +76,8 @@ 'headers' => [ 'X-Tenant' => 'fleetbase', ], - 'body' => [ + 'query_params' => [], + 'body' => [ 'recipient' => '{{to}}', 'message' => '{{text}}', 'sender' => '{{from}}', @@ -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'])); diff --git a/tests/Unit/RESTRegistrarTest.php b/tests/Unit/RESTRegistrarTest.php new file mode 100644 index 00000000..e5e4196d --- /dev/null +++ b/tests/Unit/RESTRegistrarTest.php @@ -0,0 +1,36 @@ +register('devices', 'DeviceController', $options); + + return array_map( + fn ($route) => [ + 'methods' => $route->methods(), + 'uri' => $route->uri(), + 'action' => $route->getActionName(), + ], + $router->getRoutes()->getRoutes() + ); +}; + +test('rest routes register bulk delete before item routes', function () use ($registeredRestRoutes) { + $routes = $registeredRestRoutes(); + $uris = array_column($routes, 'uri'); + + expect($uris)->toContain('devices/bulk-delete'); + expect(array_search('devices/bulk-delete', $uris, true))->toBeLessThan(array_search('devices/{device}', $uris, true)); + + $bulkDeleteRoute = collect($routes)->first(fn ($route) => $route['uri'] === 'devices/bulk-delete'); + + expect($bulkDeleteRoute['methods'])->toContain('DELETE'); + expect($bulkDeleteRoute['action'])->toBe('DeviceController@bulkDelete'); +});