@fleetbase/fleetops-engine 0.6.67 → 0.6.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/RELEASE.md +11 -15
  2. package/addon/components/contact/form.hbs +13 -2
  3. package/addon/components/customer/details.hbs +17 -17
  4. package/addon/components/customer/form.hbs +13 -2
  5. package/addon/components/customer/form.js +0 -56
  6. package/addon/components/driver/details.hbs +13 -19
  7. package/addon/components/driver/form.hbs +20 -35
  8. package/addon/components/driver/form.js +0 -58
  9. package/addon/components/fuel-integration/form.hbs +2 -2
  10. package/addon/components/fuel-integration/form.js +8 -0
  11. package/addon/components/fuel-integration/hub.js +1 -1
  12. package/addon/components/modals/driver-form.hbs +2 -2
  13. package/addon/components/modals/reset-profile-credentials.hbs +23 -0
  14. package/addon/components/modals/reset-profile-credentials.js +70 -0
  15. package/addon/components/profile-identity-hint.hbs +13 -0
  16. package/addon/components/profile-identity-hint.js +49 -0
  17. package/addon/controllers/management/drivers/index.js +4 -0
  18. package/addon/extension.js +4 -2
  19. package/addon/services/contact-actions.js +50 -89
  20. package/addon/services/driver-actions.js +98 -0
  21. package/addon/templates/connectivity/fuel-providers/details/index.hbs +1 -1
  22. package/addon/utils/fuel-transaction.js +2 -0
  23. package/addon/utils/profile-login-actions.js +172 -0
  24. package/app/components/modals/{reset-customer-credentials.js → reset-profile-credentials.js} +1 -1
  25. package/app/components/profile-identity-hint.js +1 -0
  26. package/composer.json +1 -1
  27. package/docs/SAFEE.md +5 -0
  28. package/docs/TELEMATICS_QUEUES.md +59 -8
  29. package/extension.json +1 -1
  30. package/package.json +3 -3
  31. package/scripts/pest-bootstrap.php +12 -0
  32. package/server/config/fuel-providers.php +53 -0
  33. package/server/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php +132 -0
  34. package/server/resources/views/mail/driver-credentials.blade.php +21 -0
  35. package/server/src/Console/Commands/FixCustomerCompanies.php +1 -1
  36. package/server/src/Console/Commands/FixDriverCompanies.php +1 -1
  37. package/server/src/Exceptions/ProfileIdentityConflictException.php +34 -0
  38. package/server/src/Http/Controllers/Api/v1/CustomerController.php +14 -0
  39. package/server/src/Http/Controllers/Api/v1/DriverController.php +56 -39
  40. package/server/src/Http/Controllers/Internal/v1/ContactController.php +5 -17
  41. package/server/src/Http/Controllers/Internal/v1/CustomerController.php +40 -4
  42. package/server/src/Http/Controllers/Internal/v1/DriverController.php +182 -162
  43. package/server/src/Http/Controllers/Internal/v1/FleetOpsLookupController.php +47 -0
  44. package/server/src/Http/Controllers/Internal/v1/OrderController.php +156 -27
  45. package/server/src/Http/Requests/CreateDriverRequest.php +6 -2
  46. package/server/src/Http/Requests/Internal/CreateDriverRequest.php +9 -18
  47. package/server/src/Http/Resources/v1/Contact.php +3 -0
  48. package/server/src/Http/Resources/v1/Driver.php +3 -0
  49. package/server/src/Jobs/PollTelematicTelemetry.php +36 -0
  50. package/server/src/Jobs/SyncTelematicDevicesJob.php +2 -2
  51. package/server/src/Mail/DriverCredentialsMail.php +55 -0
  52. package/server/src/Models/Contact.php +55 -90
  53. package/server/src/Models/Sensor.php +3 -5
  54. package/server/src/Observers/ContactObserver.php +2 -22
  55. package/server/src/Observers/DriverObserver.php +5 -6
  56. package/server/src/Providers/FleetOpsServiceProvider.php +15 -0
  57. package/server/src/Support/Ai/Capabilities/AbstractFleetOpsAICapability.php +62 -5
  58. package/server/src/Support/Ai/Capabilities/AssetStatusCapability.php +24 -5
  59. package/server/src/Support/Ai/Capabilities/CreateOrderPreviewCapability.php +6 -2
  60. package/server/src/Support/Ai/Capabilities/OrderInsightsCapability.php +40 -7
  61. package/server/src/Support/Ai/Capabilities/SearchResourcesCapability.php +86 -24
  62. package/server/src/Support/Ai/FleetOpsAiConsoleCommands.php +122 -0
  63. package/server/src/Support/Ai/Tools/CreateOrderTool.php +86 -0
  64. package/server/src/Support/Ai/Tools/ImportOrdersTool.php +53 -0
  65. package/server/src/Support/Ai/Tools/OptimizeOrderRouteTool.php +70 -0
  66. package/server/src/Support/Ai/Tools/SearchResourcesTool.php +73 -0
  67. package/server/src/Support/FuelProviders/Providers/SascoFuelProvider.php +272 -0
  68. package/server/src/Support/ProfileAccountManager.php +405 -0
  69. package/server/src/Support/Telematics/TelematicService.php +6 -4
  70. package/server/src/Support/Telematics/Telemetry/Inbox.php +27 -9
  71. package/server/src/routes.php +5 -0
  72. package/server/tests/AiOperationalQueryCapabilityTest.php +1 -0
  73. package/server/tests/ApiCustomerControllerContractsTest.php +21 -0
  74. package/server/tests/ApiDriverControllerContractsTest.php +102 -20
  75. package/server/tests/CommandContractsTest.php +4 -4
  76. package/server/tests/ControllerHelperContractsTest.php +3 -11
  77. package/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php +5 -0
  78. package/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +36 -0
  79. package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +19 -6
  80. package/server/tests/Feature/Http/Api/ProfileOnlyUserConversionMigrationTest.php +201 -0
  81. package/server/tests/Feature/Http/Internal/ContactControllerEndpointsTest.php +6 -4
  82. package/server/tests/Feature/Http/Internal/CustomerControllerCredentialsTest.php +6 -2
  83. package/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php +23 -0
  84. package/server/tests/Feature/Http/Internal/DriverControllerCreateRecordTest.php +42 -17
  85. package/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php +168 -277
  86. package/server/tests/Feature/Http/Internal/DriverControllerLoginManagementTest.php +716 -0
  87. package/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php +10 -3
  88. package/server/tests/Feature/Http/Internal/OrderControllerImportFromFilesTest.php +18 -0
  89. package/server/tests/Feature/Http/Internal/OrderControllerTenantScopingTest.php +411 -0
  90. package/server/tests/Feature/Http/Internal/OrderControllerUpstreamNotFoundTest.php +8 -20
  91. package/server/tests/Feature/Http/ManualTelemetryPollingTest.php +88 -4
  92. package/server/tests/InternalCustomerControllerContractsTest.php +32 -1
  93. package/server/tests/ModelAccessorContractsTest.php +82 -20
  94. package/server/tests/ObserverContractsTest.php +27 -2
  95. package/server/tests/RequestContractsTest.php +19 -11
  96. package/server/tests/SupportJobAndAiCoverageTest.php +3 -3
  97. package/server/tests/TelematicControllerContractsTest.php +22 -4
  98. package/server/tests/Unit/Models/ContactCustomerUserTest.php +90 -14
  99. package/server/tests/Unit/Models/ContactTest.php +48 -5
  100. package/server/tests/Unit/Models/SensorTest.php +2 -2
  101. package/server/tests/Unit/Models/SmallModelSurfacesTest.php +31 -32
  102. package/server/tests/Unit/Observers/ObserverListenerSeamsTest.php +7 -2
  103. package/server/tests/Unit/Providers/FleetOpsAiRegistrationTest.php +15 -2
  104. package/server/tests/Unit/Support/Ai/AssetStatusCapabilityQueriesTest.php +42 -0
  105. package/server/tests/Unit/Support/Ai/Capabilities/OrderInsightsCapabilityTest.php +19 -1
  106. package/server/tests/Unit/Support/Ai/Capabilities/SearchResourcesCapabilityTest.php +85 -1
  107. package/server/tests/Unit/Support/Ai/FleetOpsAiConsoleCommandsTest.php +31 -0
  108. package/server/tests/Unit/Support/Ai/Tools/FleetOpsAiToolsTest.php +246 -0
  109. package/server/tests/Unit/Support/AiCapabilityPermissionsTest.php +3 -3
  110. package/server/tests/Unit/Support/ProfileAccountManagerTest.php +643 -0
  111. package/server/tests/Unit/Support/SascoFuelProviderTest.php +338 -0
  112. package/translations/en-us.yaml +37 -2
  113. package/translations/uk-ua.yaml +3475 -0
  114. package/addon/components/modals/reset-customer-credentials.hbs +0 -18
  115. package/addon/components/modals/reset-customer-credentials.js +0 -55
@@ -0,0 +1,338 @@
1
+ <?php
2
+
3
+ use Fleetbase\FleetOps\Models\FuelProviderConnection;
4
+ use Fleetbase\FleetOps\Support\FuelProviders\Providers\SascoFuelProvider;
5
+ use Illuminate\Http\Client\Request;
6
+ use Illuminate\Support\Carbon;
7
+ use Illuminate\Support\Facades\Cache;
8
+ use Illuminate\Support\Facades\Http;
9
+
10
+ /**
11
+ * Covers the SASCO B2B fuel provider against faked HTTP responses: username and
12
+ * password login with cached JWTs, one-shot re-authentication on 401, connection
13
+ * testing, current_page pagination bounded by totalItems, and driver transaction
14
+ * normalization.
15
+ */
16
+ const SASCO_TEST_BASE = 'https://sasco.test';
17
+
18
+ function fleetopsSascoFresh(): void
19
+ {
20
+ Http::clearResolvedInstances();
21
+ app()->forgetInstance(Illuminate\Http\Client\Factory::class);
22
+ Cache::clear();
23
+ }
24
+
25
+ function fleetopsSascoConnection(array $credentials = [], ?string $environment = null): FuelProviderConnection
26
+ {
27
+ $connection = new FuelProviderConnection();
28
+ $connection->setRawAttributes([
29
+ 'uuid' => 'conn-sasco',
30
+ 'company_uuid' => 'company-1',
31
+ 'provider' => 'sasco',
32
+ 'environment' => $environment,
33
+ 'credentials' => json_encode(array_merge([
34
+ 'base_url' => SASCO_TEST_BASE,
35
+ 'username' => 'fleet@example.test',
36
+ 'password' => 'secret',
37
+ ], $credentials)),
38
+ ], true);
39
+
40
+ return $connection;
41
+ }
42
+
43
+ function fleetopsSascoJwt(?int $exp): string
44
+ {
45
+ $segment = fn (array $data) => rtrim(strtr(base64_encode(json_encode($data)), '+/', '-_'), '=');
46
+
47
+ return $segment(['alg' => 'RS256']) . '.' . $segment($exp ? ['exp' => $exp] : ['sub' => 'user']) . '.signature';
48
+ }
49
+
50
+ function fleetopsSascoInvoke(SascoFuelProvider $provider, string $method, ...$arguments)
51
+ {
52
+ $reflection = new ReflectionMethod($provider, $method);
53
+ $reflection->setAccessible(true);
54
+
55
+ return $reflection->invoke($provider, ...$arguments);
56
+ }
57
+
58
+ function fleetopsSascoTransaction(array $overrides = []): array
59
+ {
60
+ return array_merge([
61
+ 'transactionNumber' => '27307485',
62
+ 'stationName' => ' Station North ',
63
+ 'noOfLitters' => 42.735,
64
+ 'fuelType' => '95',
65
+ 'driverName' => 'Annabella Francesconi',
66
+ 'driverPhone' => '518596945',
67
+ 'createdAt' => '2025-11-03T17:19:49.595558+03:00',
68
+ 'amount' => 100.5,
69
+ 'fuelImageUrl' => 'https://storage.example.test/receipt.jpg',
70
+ 'vehiclePlate' => [
71
+ 'plateNumber' => '1240',
72
+ 'firstPlateLetter' => 'N',
73
+ 'secondPlateLetter' => 'X',
74
+ 'thirdPlateLetter' => 'D',
75
+ ],
76
+ 'type' => 'Trip',
77
+ 'tripId' => null,
78
+ 'period' => 4,
79
+ ], $overrides);
80
+ }
81
+
82
+ test('sasco provider identifies itself and resolves environment base urls', function () {
83
+ $provider = new SascoFuelProvider();
84
+
85
+ expect($provider->key())->toBe('sasco')
86
+ ->and($provider->name())->toBe('SASCO')
87
+ ->and(fleetopsSascoInvoke($provider, 'baseUrl', fleetopsSascoConnection(['base_url' => ''], 'sandbox')))->toBe('https://loyalty-api-qc.sasco.sa')
88
+ ->and(fleetopsSascoInvoke($provider, 'baseUrl', fleetopsSascoConnection(['base_url' => ''])))->toBe('https://loyalty-api.sasco.com.sa')
89
+ ->and(fleetopsSascoInvoke($provider, 'baseUrl', fleetopsSascoConnection(['base_url' => ' https://custom.test/root/ '])))->toBe('https://custom.test/root');
90
+ });
91
+
92
+ test('sasco authenticate caches the access token until shortly before it expires', function () {
93
+ fleetopsSascoFresh();
94
+ Carbon::setTestNow(Carbon::createFromTimestamp(1_000_000));
95
+ $token = fleetopsSascoJwt(1_000_000 + 7200);
96
+ Http::fake([SASCO_TEST_BASE . '/apigateway/api/identity/user/b2badmin-login' => Http::response(['accessToken' => $token, 'loginSucces' => true], 200)]);
97
+
98
+ $provider = new SascoFuelProvider();
99
+ $connection = fleetopsSascoConnection(['username' => ' fleet@example.test ']);
100
+ $result = $provider->authenticate($connection);
101
+
102
+ expect($result)->toBe(['success' => true, 'message' => 'SASCO login successful.', 'token' => $token])
103
+ ->and(Cache::get(fleetopsSascoInvoke($provider, 'tokenCacheKey', $connection)))->toBe($token)
104
+ ->and(fleetopsSascoInvoke($provider, 'tokenTtl', $token))->toBe(7140)
105
+ ->and(fleetopsSascoInvoke($provider, 'tokenTtl', fleetopsSascoJwt(null)))->toBe(3600)
106
+ ->and(fleetopsSascoInvoke($provider, 'tokenTtl', 'not-a-jwt'))->toBe(3600)
107
+ ->and(fleetopsSascoInvoke($provider, 'tokenTtl', fleetopsSascoJwt(1_000_000 + 10)))->toBe(1);
108
+
109
+ Http::assertSent(fn (Request $request) => $request->method() === 'POST'
110
+ && $request->data() === ['username' => 'fleet@example.test', 'password' => 'secret']);
111
+
112
+ Carbon::setTestNow();
113
+ });
114
+
115
+ test('sasco authenticate reports rejected logins with the provider message or a fallback', function () {
116
+ fleetopsSascoFresh();
117
+ Http::fake([SASCO_TEST_BASE . '/*' => Http::sequence()
118
+ ->push(['message' => 'Invalid user credentials'], 400)
119
+ ->push(['loginSucces' => false], 200)
120
+ ->push(['message' => ['not', 'a', 'string']], 500),
121
+ ]);
122
+
123
+ $provider = new SascoFuelProvider();
124
+ $invalid = $provider->authenticate(fleetopsSascoConnection());
125
+ $noToken = $provider->authenticate(fleetopsSascoConnection());
126
+ $badBody = $provider->authenticate(fleetopsSascoConnection());
127
+
128
+ expect($invalid)->toBe(['success' => false, 'message' => 'Invalid user credentials', 'status' => 400])
129
+ ->and($noToken['success'])->toBeFalse()
130
+ ->and($noToken['message'])->toBe('SASCO login failed. Check the B2B admin username and password.')
131
+ ->and($badBody['message'])->toBe('SASCO login failed. Check the B2B admin username and password.');
132
+ });
133
+
134
+ test('sasco test connection reports success failure and login errors', function () {
135
+ fleetopsSascoFresh();
136
+ Http::fake([
137
+ SASCO_TEST_BASE . '/apigateway/api/identity/user/b2badmin-login' => Http::response(['accessToken' => fleetopsSascoJwt(null)], 200),
138
+ SASCO_TEST_BASE . '/apigateway/api/loyaltycore/b2bexternaladmin/b2bvehicles*' => Http::sequence()
139
+ ->push(['vehicles' => [['id' => 'vehicle-1']], 'totalItems' => 12], 200)
140
+ ->push(['title' => 'Forbidden'], 403)
141
+ ->push(['vehicles' => null], 200),
142
+ ]);
143
+
144
+ $provider = new SascoFuelProvider();
145
+ $success = $provider->testConnection(fleetopsSascoConnection([], 'sandbox'));
146
+ $failure = $provider->testConnection(fleetopsSascoConnection());
147
+ $invalid = $provider->testConnection(fleetopsSascoConnection());
148
+
149
+ expect($success)->toBe([
150
+ 'success' => true,
151
+ 'message' => 'SASCO connection successful.',
152
+ 'metadata' => ['environment' => 'sandbox', 'host' => 'sasco.test', 'status' => 200, 'total_vehicles' => 12],
153
+ ])
154
+ ->and($failure['success'])->toBeFalse()
155
+ ->and($failure['message'])->toBe('Forbidden')
156
+ ->and($failure['metadata'])->toBe(['environment' => 'production', 'host' => 'sasco.test', 'status' => 403])
157
+ ->and($invalid['message'])->toBe('Unable to connect to SASCO.');
158
+
159
+ Http::assertSentCount(4);
160
+
161
+ fleetopsSascoFresh();
162
+ Http::fake([SASCO_TEST_BASE . '/*' => Http::response(['error' => 'Account locked'], 401)]);
163
+ $locked = $provider->testConnection(fleetopsSascoConnection());
164
+
165
+ expect($locked['success'])->toBeFalse()
166
+ ->and($locked['message'])->toBe('Account locked')
167
+ ->and($locked['metadata'])->toBe(['environment' => 'production', 'host' => 'sasco.test']);
168
+ });
169
+
170
+ test('sasco requests reuse the cached token and log in again once after a 401', function () {
171
+ fleetopsSascoFresh();
172
+ Http::fake([
173
+ SASCO_TEST_BASE . '/apigateway/api/identity/user/b2badmin-login' => Http::sequence()
174
+ ->push(['accessToken' => 'token-1'], 200)
175
+ ->push(['accessToken' => 'token-2'], 200),
176
+ SASCO_TEST_BASE . '/apigateway/api/loyaltycore/b2bexternaladmin/b2bvehicles*' => Http::sequence()
177
+ ->push(['vehicles' => [['id' => 'a']], 'totalItems' => 1], 200)
178
+ ->push(['message' => 'expired'], 401)
179
+ ->push(['vehicles' => [['id' => 'b']], 'totalItems' => 1], 200),
180
+ ]);
181
+
182
+ $provider = new SascoFuelProvider();
183
+ $first = $provider->listVehicles(fleetopsSascoConnection());
184
+ $second = $provider->listVehicles(fleetopsSascoConnection());
185
+
186
+ $authorizations = collect(Http::recorded())
187
+ ->map(fn ($pair) => $pair[0])
188
+ ->filter(fn (Request $request) => $request->method() === 'GET')
189
+ ->map(fn (Request $request) => $request->header('Authorization')[0])
190
+ ->values()
191
+ ->all();
192
+
193
+ expect($first->pluck('id')->all())->toBe(['a'])
194
+ ->and($second->pluck('id')->all())->toBe(['b'])
195
+ ->and($authorizations)->toBe(['Bearer token-1', 'Bearer token-1', 'Bearer token-2']);
196
+ });
197
+
198
+ test('sasco requests raise the login error when no token can be obtained', function () {
199
+ fleetopsSascoFresh();
200
+ Http::fake([SASCO_TEST_BASE . '/*' => Http::response(['message' => 'Invalid user credentials'], 400)]);
201
+
202
+ expect(fn () => (new SascoFuelProvider())->listVehicles(fleetopsSascoConnection()))
203
+ ->toThrow(RuntimeException::class, 'Invalid user credentials');
204
+ });
205
+
206
+ test('sasco pagination walks current pages until totalItems and guards the page limit', function () {
207
+ fleetopsSascoFresh();
208
+ Http::fake([
209
+ SASCO_TEST_BASE . '/apigateway/api/identity/user/b2badmin-login' => Http::response(['accessToken' => 'token'], 200),
210
+ SASCO_TEST_BASE . '/apigateway/api/loyaltycore/b2bexternaladmin/b2bvehicles*' => Http::sequence()
211
+ ->push(['vehicles' => [['id' => 1], ['id' => 2]], 'totalItems' => 3], 200)
212
+ ->push(['vehicles' => [['id' => 3]], 'totalItems' => 3], 200)
213
+ ->push(['vehicles' => [], 'totalItems' => 9], 200)
214
+ ->push(['vehicles' => [['id' => 4]], 'totalItems' => 5], 200),
215
+ ]);
216
+
217
+ $provider = new SascoFuelProvider();
218
+ $vehicles = $provider->listVehicles(fleetopsSascoConnection(), ['page_size' => 2, 'SearchKey' => 'NXD']);
219
+ $empty = $provider->listVehicles(fleetopsSascoConnection());
220
+
221
+ expect($vehicles->pluck('id')->all())->toBe([1, 2, 3])
222
+ ->and($empty->all())->toBe([]);
223
+
224
+ $queries = collect(Http::recorded())
225
+ ->map(fn ($pair) => $pair[0])
226
+ ->filter(fn (Request $request) => $request->method() === 'GET')
227
+ ->map(fn (Request $request) => parse_url($request->url(), PHP_URL_QUERY))
228
+ ->values()
229
+ ->all();
230
+
231
+ expect($queries[0])->toBe('SearchKey=NXD&Page_size=2&Current_page=1')
232
+ ->and($queries[1])->toBe('SearchKey=NXD&Page_size=2&Current_page=2')
233
+ ->and($queries[2])->toBe('Page_size=100&Current_page=1');
234
+
235
+ expect(fn () => $provider->listVehicles(fleetopsSascoConnection(), ['page' => 3, 'max_pages' => 3]))
236
+ ->toThrow(RuntimeException::class, 'SASCO returned more pages than the sync limit.');
237
+ });
238
+
239
+ test('sasco pagination raises runtime errors for failed and malformed responses', function () {
240
+ fleetopsSascoFresh();
241
+ Http::fake([
242
+ SASCO_TEST_BASE . '/apigateway/api/identity/user/b2badmin-login' => Http::response(['accessToken' => 'token'], 200),
243
+ SASCO_TEST_BASE . '/apigateway/api/loyaltycore/b2bexternaladmin/b2bvehicles*' => Http::sequence()
244
+ ->push('gateway timeout', 504)
245
+ ->push(['data' => []], 200),
246
+ ]);
247
+
248
+ $provider = new SascoFuelProvider();
249
+
250
+ expect(fn () => $provider->listVehicles(fleetopsSascoConnection()))
251
+ ->toThrow(RuntimeException::class, 'SASCO apigateway/api/loyaltycore/b2bexternaladmin/b2bvehicles request failed.')
252
+ ->and(fn () => $provider->listVehicles(fleetopsSascoConnection()))
253
+ ->toThrow(RuntimeException::class, 'SASCO apigateway/api/loyaltycore/b2bexternaladmin/b2bvehicles returned an invalid response.');
254
+ });
255
+
256
+ test('sasco list transactions filters by date and normalizes driver transactions', function () {
257
+ fleetopsSascoFresh();
258
+ Http::fake([
259
+ SASCO_TEST_BASE . '/apigateway/api/identity/user/b2badmin-login' => Http::response(['accessToken' => 'token'], 200),
260
+ SASCO_TEST_BASE . '/apigateway/api/analytics/v1/b2bexternal/drivers-transactions*' => Http::sequence()
261
+ ->push([
262
+ 'driverTransactions' => [
263
+ fleetopsSascoTransaction(),
264
+ fleetopsSascoTransaction(['transactionNumber' => ' ', 'tripId' => ' TRIP-7 ', 'vehiclePlate' => null, 'amount' => null]),
265
+ ],
266
+ 'totalItems' => 2,
267
+ ], 200)
268
+ ->push(['driverTransactions' => [], 'totalItems' => 0], 200)
269
+ ->push(['driverTransactions' => [], 'totalItems' => 0], 200),
270
+ ]);
271
+
272
+ $provider = new SascoFuelProvider();
273
+ $from = Carbon::parse('2025-10-30 08:00:00');
274
+ $to = Carbon::parse('2025-11-03 20:00:00');
275
+
276
+ $transactions = $provider->listTransactions(fleetopsSascoConnection(), $from, $to);
277
+ $provider->listTransactions(fleetopsSascoConnection(), $from, $to, ['is_trip' => true]);
278
+ $provider->listTransactions(fleetopsSascoConnection(), $from, $to, ['is_trip' => false]);
279
+
280
+ [$first, $second] = $transactions->all();
281
+
282
+ expect($first)->toMatchArray([
283
+ 'provider' => 'sasco',
284
+ 'provider_transaction_id' => '27307485',
285
+ 'plate_number' => 'NXD 1240',
286
+ 'trip_number' => null,
287
+ 'station_name' => 'Station North',
288
+ 'volume' => 42.735,
289
+ 'metric_unit' => 'l',
290
+ 'amount' => 10050,
291
+ 'currency' => 'SAR',
292
+ 'normalized_payload' => [
293
+ 'fuel_type' => '95',
294
+ 'driver_name' => 'Annabella Francesconi',
295
+ 'driver_phone' => '518596945',
296
+ 'type' => 'Trip',
297
+ 'period' => 4,
298
+ 'fuel_image_url' => 'https://storage.example.test/receipt.jpg',
299
+ ],
300
+ 'raw_payload' => fleetopsSascoTransaction(),
301
+ ])
302
+ ->and($first['transaction_at']->toIso8601String())->toBe('2025-11-03T17:19:49+03:00')
303
+ ->and($second['provider_transaction_id'])->toHaveLength(64)
304
+ ->and($second['plate_number'])->toBeNull()
305
+ ->and($second['trip_number'])->toBe('TRIP-7')
306
+ ->and($second['amount'])->toBeNull();
307
+
308
+ $queries = collect(Http::recorded())
309
+ ->map(fn ($pair) => $pair[0])
310
+ ->filter(fn (Request $request) => $request->method() === 'GET')
311
+ ->map(fn (Request $request) => parse_url($request->url(), PHP_URL_QUERY))
312
+ ->values()
313
+ ->all();
314
+
315
+ expect($queries)->toBe([
316
+ 'FromDate=2025-10-30&ToDate=2025-11-03&Page_size=100&Current_page=1',
317
+ 'FromDate=2025-10-30&ToDate=2025-11-03&IsTrip=true&Page_size=100&Current_page=1',
318
+ 'FromDate=2025-10-30&ToDate=2025-11-03&IsTrip=false&Page_size=100&Current_page=1',
319
+ ]);
320
+ });
321
+
322
+ test('sasco plate numbers tolerate partial plates', function () {
323
+ $provider = new SascoFuelProvider();
324
+
325
+ expect(fleetopsSascoInvoke($provider, 'plateNumber', ['plateNumber' => '435']))->toBe('435')
326
+ ->and(fleetopsSascoInvoke($provider, 'plateNumber', ['firstPlateLetter' => 'R', 'secondPlateLetter' => ' ', 'thirdPlateLetter' => 'R']))->toBe('RR')
327
+ ->and(fleetopsSascoInvoke($provider, 'plateNumber', []))->toBeNull();
328
+ });
329
+
330
+ test('sasco is registered as a native fuel provider', function () {
331
+ $providers = require __DIR__ . '/../../../config/fuel-providers.php';
332
+ $sasco = collect($providers['providers'])->firstWhere('key', 'sasco');
333
+
334
+ expect($sasco['driver_class'])->toBe(SascoFuelProvider::class)
335
+ ->and(collect($sasco['required_fields'])->pluck('name')->all())->toBe(['username', 'password', 'base_url'])
336
+ ->and($sasco['metadata']['base_urls'])->toBe(SascoFuelProvider::BASE_URLS)
337
+ ->and($sasco['capabilities'])->toBe(['vehicles', 'transactions']);
338
+ });
@@ -770,6 +770,9 @@ contact:
770
770
 
771
771
  customer:
772
772
  fields:
773
+ addresses: Addresses
774
+ no-addresses: This customer has no saved addresses.
775
+ primary-address: Primary
773
776
  customer-details: Customer Details
774
777
  select-address: Select Address
775
778
  new-address: New Adddress
@@ -801,11 +804,9 @@ driver:
801
804
  fields:
802
805
  avatar: Avatar
803
806
  driver-details: Driver Details
804
- select-user: Select User
805
807
  select-vehicle: Select Vehicle
806
808
  upload-new-photo: Upload new photo
807
809
  user-account: User Account
808
- user-account-help-text: Each driver profile needs to be connected to a specific user account. This association is essential for handling the authentication process, ensuring that each driver can securely access and manage their profile.
809
810
  vendor: Vendor
810
811
  select-vendor: Select Vendor
811
812
  driver-license: Drivers License
@@ -837,6 +838,40 @@ driver:
837
838
  unassign-vehicle-body: This removes {driverName} from {vehicleName}. Order assignments are not changed.
838
839
  unassign-vehicle-success: Unassigned {vehicleName} from {driverName}.
839
840
  issue-title: Issue reported for {driverName}
841
+
842
+ profile-account:
843
+ staff-linked-helper: Linked to a team member account — manage email and phone from IAM.
844
+ will-link-to-staff: Will be linked to existing team member {name}.
845
+ actions:
846
+ reset-password: Reset Password
847
+ send-credentials: Send Credentials
848
+ deactivate-login: Deactivate Login
849
+ reactivate-login: Reactivate Login
850
+ convert-to-vendor: Convert to Vendor
851
+ prompts:
852
+ send-credentials-title: Send Login Credentials
853
+ send-credentials-body: Generate a new temporary password and send {name} their login credentials.
854
+ send-credentials-success: Login credentials sent to {name}.
855
+ deactivate-login-body: Deactivate the login for {name}. Their profile and history will be preserved.
856
+ deactivate-login-success: Login deactivated for {name}.
857
+ reactivate-login-body: Reactivate the login for {name}.
858
+ reactivate-login-success: Login reactivated for {name}.
859
+ send-portal-credentials-title: Send Portal Credentials
860
+ send-portal-credentials-body: Generate a new temporary password and email this contact their portal credentials.
861
+ send-portal-credentials-success: Portal credentials sent.
862
+ deactivate-portal-login-body: Deactivate portal access for this contact. Their profile and history will be preserved.
863
+ deactivate-portal-login-success: Portal login deactivated.
864
+ reactivate-portal-login-body: Reactivate portal access for this contact.
865
+ reactivate-portal-login-success: Portal login reactivated.
866
+ reset-password-title: Reset Login Credentials
867
+ reset-password-heading: You are about to reset the password for {name}
868
+ reset-password-body: Please enter the new password and confirm it below. You can optionally send the new credentials by selecting the checkbox.
869
+ reset-password-accept: Reset Credentials
870
+ reset-password-success: Password reset.
871
+ new-password: New Password
872
+ confirm-new-password: Confirm New Password
873
+ send-new-credentials: Send the new credentials to this account
874
+
840
875
  device:
841
876
  attachment:
842
877
  title: Attached asset