@fleetbase/fleetops-engine 0.6.67 → 0.6.68

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.
package/RELEASE.md CHANGED
@@ -1,27 +1,21 @@
1
- > v0.6.67 ~ "Telematics reliability"
1
+ > v0.6.68 ~ "Telematics sync status and SASCO fuel"
2
2
 
3
3
  ---
4
4
  ## What's New
5
- - **Safee / DSCO syncs use a few batched requests.** Each sweep fetches every vehicle's current position, speed, heading and odometer in batches of up to 1,000 vehicles, with the vehicle list cached between sweeps. The old sync made three extra requests per vehicle; a 93-vehicle fleet now uses one request per minute instead of about 281. Temperature, door and history lookups stay out of routine polling.
6
- - **Safee sign-in is reused and refreshed.** Access tokens are cached encrypted and refreshed before they expire, requests stay within Safee's 50-per-second account limit, and rate-limit responses are honoured.
7
- - **Telematics can run on dedicated queue workers.** `TELEMATICS_POLL_QUEUE`, `TELEMATICS_INGESTION_QUEUE` and the new `TELEMATICS_BROADCAST_QUEUE` move polling, position processing and live-map broadcasts off the `default` queue for instances that need it. Nothing changes when they are unset. See `docs/TELEMATICS_QUEUES.md`.
8
- - **Live telemetry status is easier to read.** Connection details show telemetry inside the page layout with a compact grid and collapsible setup and failure sections.
5
+ - **SASCO is a native fuel provider.** Connect a SASCO B2B account alongside PetroApp to import fuel transactions, with sandbox and production environments, amounts in SAR, driver name and phone, and receipt images. Transactions are matched to vehicles by plate.
6
+ - **"Sync Devices" works while telematics polling is running.** A manual sync requested while a scheduled sweep is queued or running is recorded and completed by the next sweep, instead of failing with "already queued or running".
7
+ - **Telematics connection status stays current.** Each completed scheduled sweep updates the connection status and last sync time, so a connection that has recovered no longer shows "Needs attention" or an old last sync date.
9
8
 
10
9
  ---
11
10
  ## Fixes
12
- - Telematics syncs no longer fail with "SyncTelematicDevicesJob has been attempted too many times" for providers on bounded polling. Poll attempts finish within the queue's 90-second reservation, and older queued sync jobs hand off to it instead of running for up to an hour.
13
- - A transient provider or TLS timeout no longer pauses scheduled polling for several minutes, and connections left in an error state keep being polled.
14
- - Vehicles that have never reported a position no longer mark every sweep as partial, quarantine deliveries, or show the connection as degraded.
15
- - Older or delayed samples cannot move a device or attached vehicle position backwards, and partial messages keep existing device identity and metadata.
16
- - AFAQY recovers from unreadable cached tokens and shares one request deadline across sign-in and unit retrieval.
17
- - Pausing Safee polling no longer queues failing sync jobs every minute.
18
- - Device event lookups use a new index instead of full-table scans.
19
- - The Radar dashboard widget links correctly when rendered outside the Fleet-Ops engine.
11
+ - Manual telematics sync requests can no longer stay queued or failed indefinitely after their job is lost; the next scheduled sweep completes them.
12
+ - Fuel provider environment labels and sync run details no longer refer to PetroApp for other providers.
20
13
 
21
14
  ---
22
15
  ## Testing
23
- - Backend tests cover Safee batching, token refresh, rate limits, ordered ingestion, tenant isolation, delivery checkpoints, paused providers, retry backoff and queue routing.
24
- - A local run on a single default queue worker polled a 93-vehicle Safee fleet: 5 sweeps used 7 API requests and each completed with 91 positions applied and 2 vehicles without a fix.
16
+ - Backend tests cover manual syncs during scheduled sweeps, stale failure recovery, partial sweeps, and adoption of abandoned requests.
17
+ - SASCO provider tests cover login, token caching, re-login after a 401, connection tests, pagination limits, error handling and transaction normalization.
18
+ - `docs/TELEMATICS_QUEUES.md` now documents that dedicated telematics workers must share the queue worker's image and `APP_KEY`, with verification commands and troubleshooting.
25
19
 
26
20
  ---
27
21
  ## Need help?
@@ -99,8 +99,8 @@
99
99
  <div class="grid grid-cols-1 gap-2 lg:grid-cols-2 no-input-group-padding text-xs">
100
100
  <InputGroup @name="Environment">
101
101
  <select class="form-select w-full" data-test-fuel-environment {{on "change" this.setEnvironment}}>
102
- <option value="sandbox" selected={{eq this.environment "sandbox"}}>Sandbox (PetroApp staging)</option>
103
- <option value="production" selected={{eq this.environment "production"}}>Production</option>
102
+ <option value="sandbox" selected={{eq this.environment "sandbox"}}>{{this.sandboxLabel}}</option>
103
+ <option value="production" selected={{eq this.environment "production"}}>{{this.productionLabel}}</option>
104
104
  </select>
105
105
  </InputGroup>
106
106
  {{#each this.credentialFields as |field|}}
@@ -69,6 +69,14 @@ export default class FuelIntegrationFormComponent extends Component {
69
69
  return this.args.resource?.environment ?? 'production';
70
70
  }
71
71
 
72
+ get sandboxLabel() {
73
+ return this.selectedProvider?.metadata?.environment_labels?.sandbox ?? 'Sandbox';
74
+ }
75
+
76
+ get productionLabel() {
77
+ return this.selectedProvider?.metadata?.environment_labels?.production ?? 'Production';
78
+ }
79
+
72
80
  get baseUrl() {
73
81
  return this.credentials.base_url?.trim() || this.selectedProvider?.metadata?.base_urls?.[this.environment];
74
82
  }
@@ -116,7 +116,7 @@ export default class FuelIntegrationHubComponent extends Component {
116
116
  get formatSpend() {
117
117
  const currencies = new Map();
118
118
  for (const connection of this.connections) {
119
- const currency = connection.last_sync_state?.summary?.currency || (connection.provider === 'petroapp' ? 'SAR' : null);
119
+ const currency = connection.last_sync_state?.summary?.currency || (['petroapp', 'sasco'].includes(connection.provider) ? 'SAR' : null);
120
120
  if (currency) currencies.set(currency, (currencies.get(currency) || 0) + Number(connection.last_sync_state?.summary?.amount || 0));
121
121
  }
122
122
  return [...currencies].map(([currency, amount]) => fuelMoney(amount, currency)).join(' · ') || 'No spend recorded';
@@ -30,7 +30,7 @@
30
30
  <p class="mt-2 text-xs text-gray-500 dark:text-gray-400">Requested dates: {{this.details.latestRun.window}}</p>
31
31
  {{#unless this.details.latestRun.received}}
32
32
  <p class="mt-3 text-xs leading-5 text-gray-500 dark:text-gray-400">If you expected purchases, choose their dates on the Sync tab and confirm that they belong to this
33
- PetroApp account.</p>
33
+ provider account.</p>
34
34
  {{/unless}}
35
35
  {{else}}
36
36
  <p class="text-sm text-gray-900 dark:text-gray-100">No syncs yet</p>
@@ -99,6 +99,8 @@ export function purchaseGroups(transaction) {
99
99
  ['Account branch', value('branch_name')],
100
100
  ['Representative', value('delegate_name')],
101
101
  ['Representative phone', petroapp ? value('delegate_mobile') : null],
102
+ ['Driver', value('driver_name')],
103
+ ['Driver phone', value('driver_phone')],
102
104
  ]),
103
105
  group('Vehicle & trip references', [
104
106
  ['Plate number', transaction.plate_number],
package/composer.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fleetbase/fleetops-api",
3
- "version": "0.6.67",
3
+ "version": "0.6.68",
4
4
  "description": "Fleet & Transport Management Extension for Fleetbase",
5
5
  "keywords": [
6
6
  "fleetbase-extension",
package/docs/SAFEE.md CHANGED
@@ -86,6 +86,11 @@ cached configuration as part of the application's normal deployment process.
86
86
  Do not retry an old failed monolithic job to recover a connection; start a new
87
87
  manual sync or let scheduled polling recover it.
88
88
 
89
+ A completed scheduled sweep updates the connection's status and last sync time, so
90
+ a recovered connection no longer shows an old failure. Clicking "Sync Devices" while
91
+ a sweep is queued or running records the request for the next sweep instead of
92
+ returning an error. A partial sweep leaves the last reported state unchanged.
93
+
89
94
  Inspect the shared telemetry diagnostics for fetched/applied/failed counts,
90
95
  incomplete sweeps, pending deliveries, queue delay, and source age. Manual sync
91
96
  completion is tied to its ingestion run; queueing or fetching alone is not
@@ -51,28 +51,56 @@ no worker wait indefinitely.
51
51
 
52
52
  ### 1. Add telematics workers
53
53
 
54
- Add worker services with the same image, environment, and volumes as the existing
55
- `queue` service. Change only the command and healthcheck. Docker Compose example:
54
+ Each telematics worker must be an exact copy of the existing `queue` worker: the
55
+ same image and tag, the same `env_file`, `environment`, volumes and `depends_on`,
56
+ including every Compose override file that changes `queue`. Change only the command.
57
+ Workers running a different build, or missing `APP_KEY`, fail every telematics job.
58
+
59
+ Docker Compose example, assuming `queue` is defined in `docker-compose.yml` and any
60
+ image or environment overrides for it live in `docker-compose.override.yml`:
56
61
 
57
62
  ```yaml
63
+ # docker-compose.yml: copy the full `queue` service definition, then change the command.
58
64
  services:
59
65
  telematics-queue:
60
- image: fleetbase/fleetbase-api:latest
66
+ # image, env_file, environment, volumes, depends_on: identical to `queue`
61
67
  command: ["php", "artisan", "queue:work", "--queue=telematics", "--sleep=1", "--max-time=3600"]
62
68
  restart: unless-stopped
63
69
  deploy:
64
70
  replicas: 2
65
- # environment, volumes and depends_on: copy from the existing `queue` service
66
71
 
67
72
  telematics-broadcast-queue:
68
- image: fleetbase/fleetbase-api:latest
73
+ # image, env_file, environment, volumes, depends_on: identical to `queue`
69
74
  command: ["php", "artisan", "queue:work", "--queue=telematics-broadcasts", "--sleep=1", "--max-time=3600"]
70
75
  restart: unless-stopped
71
- # environment, volumes and depends_on: copy from the existing `queue` service
76
+ ```
77
+
78
+ ```yaml
79
+ # docker-compose.override.yml: repeat every override that applies to `queue`.
80
+ services:
81
+ telematics-queue:
82
+ image: "fleetbase-api:latest" # the same image as `queue`
83
+ telematics-broadcast-queue:
84
+ image: "fleetbase-api:latest"
72
85
  ```
73
86
 
74
87
  Notes:
75
88
 
89
+ - **Use the same image as `queue`, not a separate `fleetbase/fleetbase-api:latest`.**
90
+ Docker does not download a newer `latest` when a copy already exists locally, so a
91
+ new service can start from an old cached image. If you do use a registry image,
92
+ pin a version tag and pull it for every API container together.
93
+ - **Every API container needs the same `APP_KEY`**: application, scheduler, the
94
+ default worker and the telematics workers. Provider credentials, token caches and
95
+ telematics deliveries are encrypted with it. If only the application service
96
+ receives it (for example through a mounted `.env` or an override), put it in a
97
+ shared `env_file` instead. Never generate a new key for a worker.
98
+ - **Check the merged configuration, including when using `extends`.** Run
99
+ `docker compose config` and compare the resolved `image`, `env_file` and
100
+ `environment` of `queue` and each telematics service before deploying.
101
+ - The `--queue` names must match the variables in step 2 exactly. A worker listening
102
+ on `telematics-broadcast` while broadcasts go to `telematics-broadcasts` receives
103
+ nothing.
76
104
  - Leave the existing `queue` service unchanged. `queue:work` without `--queue`
77
105
  processes the connection's default queue (`REDIS_QUEUE`, normally `default`).
78
106
  - Leave `--timeout` at its default (60 seconds) or set any value below the Redis
@@ -83,8 +111,6 @@ Notes:
83
111
  memory. `restart: unless-stopped` starts them again.
84
112
  - On Kubernetes, ECS, or another platform, create equivalent deployments with the
85
113
  same image, environment, and commands.
86
- - Every worker container needs the same `APP_KEY`, database, Redis, and broadcasting
87
- settings as the application. Deliveries are encrypted with `APP_KEY`.
88
114
 
89
115
  ### 2. Set the queue variables for this instance only
90
116
 
@@ -154,6 +180,19 @@ of 50 requests per second per account across all workers.
154
180
 
155
181
  ## Verification
156
182
 
183
+ First confirm every API container runs the same build and application key. Each line
184
+ must show the same key fingerprint (a hash, never the key) and Fleet-Ops version:
185
+
186
+ ```bash
187
+ for c in $(docker ps --format '{{.Names}}' | grep -E 'application|scheduler|queue'); do echo "== $c $(docker inspect --format '{{.Config.Image}}' $c)"; docker exec $c php artisan tinker --execute="echo config('app.key') ? substr(hash('sha256', config('app.key')), 0, 12) : 'MISSING', ' ', json_decode(file_get_contents('vendor/fleetbase/fleetops-api/composer.json'))->version, PHP_EOL;"; done
188
+ ```
189
+
190
+ Confirm Laravel loaded the queue names in the application and a telematics worker:
191
+
192
+ ```bash
193
+ php artisan config:show telematics.telemetry
194
+ ```
195
+
157
196
  Queue depth should stay close to zero between minute ticks:
158
197
 
159
198
  ```bash
@@ -168,6 +207,18 @@ Also verify:
168
207
  on the telematics workers, and broadcast jobs only on the broadcast worker.
169
208
  - Other jobs on `default`, such as notifications and order events, start promptly
170
209
  again.
210
+ - The connection's status and "Last sync" update after each completed scheduled
211
+ sweep. Clicking "Sync Devices" while a sweep is queued or running records the
212
+ request, and the next sweep completes it.
213
+
214
+ ## Troubleshooting
215
+
216
+ | Symptom | Cause |
217
+ | --- | --- |
218
+ | Telematics worker logs show `MissingAppKeyException` | The worker has no `APP_KEY`. Give every API container the same key and recreate the workers. |
219
+ | A worker reports a different Fleet-Ops version | The worker uses a different or stale cached image. Use the `queue` service's image. |
220
+ | Broadcast jobs are never processed | The broadcast worker's `--queue` name does not match `TELEMATICS_BROADCAST_QUEUE`. |
221
+ | Scheduled sync commands report `Queued 0 telematics sync job(s)` for an enabled connection | A poll job failed on a worker that could not load it, leaving its one-hour uniqueness lock. It expires on its own; after fixing the workers you can release it with `Cache::lock('laravel_unique_job:Fleetbase\\FleetOps\\Jobs\\PollTelematicTelemetry' . $telematicUuid)->forceRelease()`. |
171
222
 
172
223
  ## Rollback
173
224
 
package/extension.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "Fleet-Ops",
3
- "version": "0.6.67",
3
+ "version": "0.6.68",
4
4
  "description": "Fleet & Transport Management Extension for Fleetbase",
5
5
  "repository": "https://github.com/fleetbase/fleetops",
6
6
  "license": "AGPL-3.0-or-later",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fleetbase/fleetops-engine",
3
- "version": "0.6.67",
3
+ "version": "0.6.68",
4
4
  "description": "Fleet & Transport Management Extension for Fleetbase",
5
5
  "fleetbase": {
6
6
  "route": "fleet-ops"
@@ -55,9 +55,62 @@ return [
55
55
  'auth_type' => 'ws_sk_header',
56
56
  'supported_auth_types' => ['ws_sk_header', 'bearer_token'],
57
57
  'base_urls' => \Fleetbase\FleetOps\Support\FuelProviders\Providers\PetroAppFuelProvider::BASE_URLS,
58
+ 'environment_labels' => ['sandbox' => 'Sandbox (PetroApp staging)', 'production' => 'Production'],
58
59
  'pagination' => 'page',
59
60
  'default_currency' => 'SAR',
60
61
  ],
61
62
  ],
63
+ [
64
+ 'key' => 'sasco',
65
+ 'label' => 'SASCO',
66
+ 'type' => 'native',
67
+ 'category' => 'Fuel card integration',
68
+ 'icon' => 'gas-pump',
69
+ 'driver_class' => \Fleetbase\FleetOps\Support\FuelProviders\Providers\SascoFuelProvider::class,
70
+ 'description' => 'SASCO B2B fleet fuel transactions and vehicles.',
71
+ 'required_fields' => [
72
+ [
73
+ 'name' => 'username',
74
+ 'label' => 'B2B admin username',
75
+ 'type' => 'text',
76
+ 'help_text' => 'The SASCO B2B company admin login, usually an email address.',
77
+ 'required' => true,
78
+ ],
79
+ [
80
+ 'name' => 'password',
81
+ 'label' => 'B2B admin password',
82
+ 'type' => 'password',
83
+ 'help_text' => 'Used to request a short-lived access token from SASCO. Fleetbase re-authenticates automatically when it expires.',
84
+ 'required' => true,
85
+ ],
86
+ [
87
+ 'name' => 'base_url',
88
+ 'label' => 'Base URL override (optional)',
89
+ 'type' => 'url',
90
+ 'help_text' => 'Leave blank to use the selected environment. A custom URL overrides the environment selection.',
91
+ 'required' => false,
92
+ ],
93
+ ],
94
+ 'capabilities' => ['vehicles', 'transactions'],
95
+ 'sync_defaults' => [
96
+ 'window_days' => 7,
97
+ 'matching_order' => ['plate_number', 'internal_id', 'vin', 'serial_number', 'call_sign', 'fuel_card_number', 'trip_number'],
98
+ 'auto_create_fuel_reports' => true,
99
+ ],
100
+ 'setup_instructions' => [
101
+ 'Request B2B API access from SASCO and obtain the company admin username and password for QC/UAT and Production.',
102
+ 'Select Sandbox for the SASCO QC environment, enter the credentials, and run Test Connection.',
103
+ 'SASCO transactions carry the vehicle plate only, so make sure Fleetbase vehicle plate numbers use the letters-then-digits format (for example NXD 1240).',
104
+ 'Sync a small date window first to verify imported fuel transactions and vehicle matches.',
105
+ ],
106
+ 'metadata' => [
107
+ 'auth_type' => 'username_password',
108
+ 'supported_auth_types' => ['username_password'],
109
+ 'base_urls' => \Fleetbase\FleetOps\Support\FuelProviders\Providers\SascoFuelProvider::BASE_URLS,
110
+ 'environment_labels' => ['sandbox' => 'Sandbox (SASCO QC)', 'production' => 'Production'],
111
+ 'pagination' => 'current_page',
112
+ 'default_currency' => 'SAR',
113
+ ],
114
+ ],
62
115
  ],
63
116
  ];
@@ -109,6 +109,7 @@ class PollTelematicTelemetry implements ShouldQueue, ShouldBeUnique
109
109
  try {
110
110
  $started = microtime(true);
111
111
  DB::table('telematic_sync_runs')->insert(['uuid' => $run, 'telematic_uuid' => $telematic->uuid, 'status' => 'fetching', 'created_at' => now(), 'updated_at' => now()]);
112
+ $this->adoptPendingRequest($run);
112
113
  $this->manualProgress([
113
114
  'last_sync_run_uuid' => $run, 'last_sync_run_job_id' => $this->manualJobId,
114
115
  'last_sync_result' => 'running', 'last_sync_phase' => 'fetching_inventory',
@@ -186,6 +187,41 @@ class PollTelematicTelemetry implements ShouldQueue, ShouldBeUnique
186
187
  return $remaining;
187
188
  }
188
189
 
190
+ /**
191
+ * Bind a manual request that has no active run to this scheduled sweep.
192
+ *
193
+ * Every poll job for a connection shares one unique lease, so while a scheduled sweep
194
+ * runs, the request's own job is either not queued (the request was recorded while
195
+ * the lease was held) or no longer exists (lost before or after an interrupted run).
196
+ */
197
+ private function adoptPendingRequest(string $run): void
198
+ {
199
+ if ($this->manualJobId) {
200
+ return;
201
+ }
202
+ DB::transaction(function () use ($run) {
203
+ $connection = Telematic::withoutGlobalScopes()->where('uuid', $this->telematicUuid)->lockForUpdate()->first();
204
+ $meta = $connection?->meta ?? [];
205
+ $request = data_get($meta, 'last_sync_job_id');
206
+ if (!$request || !in_array(data_get($meta, 'last_sync_result'), Inbox::PENDING_REQUEST_RESULTS, true)) {
207
+ return;
208
+ }
209
+ $bound = data_get($meta, 'last_sync_run_uuid');
210
+ if ($bound && DB::table('telematic_sync_runs')->where('uuid', $bound)->whereIn('status', ['fetching', 'ingesting'])->exists()) {
211
+ return;
212
+ }
213
+ $connection->status = 'synchronizing';
214
+ $connection->meta = array_merge($meta, [
215
+ 'last_sync_run_uuid' => $run, 'last_sync_run_job_id' => $request,
216
+ 'last_sync_result' => 'running', 'last_sync_phase' => 'fetching_inventory',
217
+ 'last_sync_fetched_total' => 0, 'last_sync_page_count' => 0,
218
+ 'last_sync_linked_total' => 0, 'last_sync_failed_total' => 0, 'last_sync_error' => null,
219
+ 'last_sync_progress_at' => now()->toDateTimeString(),
220
+ ]);
221
+ $connection->save();
222
+ });
223
+ }
224
+
189
225
  private function manualProgress(array $attributes): void
190
226
  {
191
227
  if (!$this->manualJobId) {
@@ -58,8 +58,8 @@ class SyncTelematicDevicesJob implements ShouldQueue
58
58
  try {
59
59
  $service->queueTelemetrySync($this->telematic, $this->options, $this->jobId);
60
60
  } catch (\Illuminate\Validation\ValidationException) {
61
- // Paused or already queued/running. Failing here would mark the connection
62
- // as errored and detach the active request's progress tracking.
61
+ // Polling was paused after this job was queued. Failing here would mark the
62
+ // connection as errored and detach any active request's progress tracking.
63
63
  Log::info('Skipped legacy telematics sync redirect.', ['telematic_uuid' => $this->telematic->uuid]);
64
64
  }
65
65
  }
@@ -0,0 +1,272 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Support\FuelProviders\Providers;
4
+
5
+ use Fleetbase\FleetOps\Models\FuelProviderConnection;
6
+ use Illuminate\Http\Client\Response;
7
+ use Illuminate\Support\Carbon;
8
+ use Illuminate\Support\Collection;
9
+ use Illuminate\Support\Facades\Cache;
10
+ use Illuminate\Support\Facades\Http;
11
+
12
+ class SascoFuelProvider extends AbstractFuelProvider
13
+ {
14
+ public const BASE_URLS = [
15
+ 'sandbox' => 'https://loyalty-api-qc.sasco.sa',
16
+ 'production' => 'https://loyalty-api.sasco.com.sa',
17
+ ];
18
+
19
+ public const LOGIN_ENDPOINT = 'apigateway/api/identity/user/b2badmin-login';
20
+ public const VEHICLES_ENDPOINT = 'apigateway/api/loyaltycore/b2bexternaladmin/b2bvehicles';
21
+ public const TRANSACTIONS_ENDPOINT = 'apigateway/api/analytics/v1/b2bexternal/drivers-transactions';
22
+
23
+ /** Fallback token lifetime when the JWT carries no readable exp claim. */
24
+ protected const DEFAULT_TOKEN_TTL = 3600;
25
+
26
+ public function key(): string
27
+ {
28
+ return 'sasco';
29
+ }
30
+
31
+ public function name(): string
32
+ {
33
+ return 'SASCO';
34
+ }
35
+
36
+ public function authenticate(FuelProviderConnection $connection): array
37
+ {
38
+ $credentials = (array) $connection->credentials;
39
+ $response = Http::baseUrl($this->baseUrl($connection))
40
+ ->timeout((int) data_get($credentials, 'timeout', 30))
41
+ ->acceptJson()
42
+ ->asJson()
43
+ ->post(self::LOGIN_ENDPOINT, [
44
+ 'username' => trim((string) data_get($credentials, 'username')),
45
+ 'password' => (string) data_get($credentials, 'password'),
46
+ ]);
47
+
48
+ $token = $response->json('accessToken');
49
+ if (!$response->successful() || !is_string($token) || $token === '') {
50
+ return [
51
+ 'success' => false,
52
+ 'message' => $this->errorMessage($response, 'SASCO login failed. Check the B2B admin username and password.'),
53
+ 'status' => $response->status(),
54
+ ];
55
+ }
56
+
57
+ Cache::put($this->tokenCacheKey($connection), $token, $this->tokenTtl($token));
58
+
59
+ return ['success' => true, 'message' => 'SASCO login successful.', 'token' => $token];
60
+ }
61
+
62
+ public function testConnection(FuelProviderConnection $connection): array
63
+ {
64
+ $metadata = [
65
+ 'environment' => $connection->environment ?? 'production',
66
+ 'host' => parse_url($this->baseUrl($connection), PHP_URL_HOST),
67
+ ];
68
+
69
+ try {
70
+ $response = $this->get($connection, self::VEHICLES_ENDPOINT, ['Page_size' => 1, 'Current_page' => 1]);
71
+ $metadata['status'] = $response->status();
72
+
73
+ if (!$response->successful() || !is_array($response->json('vehicles'))) {
74
+ return [
75
+ 'success' => false,
76
+ 'message' => $this->errorMessage($response, 'Unable to connect to SASCO.'),
77
+ 'metadata' => $metadata,
78
+ ];
79
+ }
80
+
81
+ return [
82
+ 'success' => true,
83
+ 'message' => 'SASCO connection successful.',
84
+ 'metadata' => array_merge($metadata, ['total_vehicles' => $response->json('totalItems')]),
85
+ ];
86
+ } catch (\Throwable $e) {
87
+ return ['success' => false, 'message' => $e->getMessage(), 'metadata' => $metadata];
88
+ }
89
+ }
90
+
91
+ public function listVehicles(FuelProviderConnection $connection, array $options = []): Collection
92
+ {
93
+ return $this->paginatedGet($connection, self::VEHICLES_ENDPOINT, 'vehicles', $options);
94
+ }
95
+
96
+ public function listTransactions(FuelProviderConnection $connection, Carbon $from, Carbon $to, array $options = []): Collection
97
+ {
98
+ $params = [
99
+ 'FromDate' => $from->toDateString(),
100
+ 'ToDate' => $to->toDateString(),
101
+ ];
102
+
103
+ if (array_key_exists('is_trip', $options)) {
104
+ $params['IsTrip'] = $options['is_trip'] ? 'true' : 'false';
105
+ }
106
+
107
+ return $this->paginatedGet($connection, self::TRANSACTIONS_ENDPOINT, 'driverTransactions', array_merge($options, $params))
108
+ ->map(fn ($payload) => $this->normalizeTransaction($payload));
109
+ }
110
+
111
+ protected function baseUrl(FuelProviderConnection $connection): string
112
+ {
113
+ $credentials = (array) $connection->credentials;
114
+ $override = trim((string) data_get($credentials, 'base_url', ''));
115
+
116
+ return rtrim($override ?: self::BASE_URLS[$connection->environment ?? 'production'], '/');
117
+ }
118
+
119
+ protected function headers(FuelProviderConnection $connection): array
120
+ {
121
+ return ['Authorization' => 'Bearer ' . $this->token($connection)];
122
+ }
123
+
124
+ /**
125
+ * Returns a cached access token, logging in when none is cached.
126
+ */
127
+ protected function token(FuelProviderConnection $connection): string
128
+ {
129
+ $token = Cache::get($this->tokenCacheKey($connection));
130
+ if (is_string($token) && $token !== '') {
131
+ return $token;
132
+ }
133
+
134
+ $result = $this->authenticate($connection);
135
+ if (!$result['success']) {
136
+ throw new \RuntimeException($result['message']);
137
+ }
138
+
139
+ return $result['token'];
140
+ }
141
+
142
+ /**
143
+ * Keyed by host and username rather than connection uuid so unsaved
144
+ * connections built for credential tests share the same cache entry.
145
+ */
146
+ protected function tokenCacheKey(FuelProviderConnection $connection): string
147
+ {
148
+ $credentials = (array) $connection->credentials;
149
+
150
+ return 'fuel-provider:sasco:token:' . hash('sha256', $this->baseUrl($connection) . '|' . trim((string) data_get($credentials, 'username')));
151
+ }
152
+
153
+ protected function tokenTtl(string $token): int
154
+ {
155
+ $segments = explode('.', $token);
156
+ $payload = isset($segments[1]) ? json_decode((string) base64_decode(strtr($segments[1], '-_', '+/')), true) : null;
157
+ $expires = (int) data_get($payload, 'exp', 0);
158
+
159
+ if ($expires <= 0) {
160
+ return self::DEFAULT_TOKEN_TTL;
161
+ }
162
+
163
+ // Refresh a minute early so a request never goes out with a token about to lapse.
164
+ return max(1, $expires - now()->timestamp - 60);
165
+ }
166
+
167
+ /**
168
+ * Sends an authenticated GET, re-authenticating once when SASCO rejects a cached token.
169
+ */
170
+ protected function get(FuelProviderConnection $connection, string $endpoint, array $params = []): Response
171
+ {
172
+ $response = $this->client($connection)->get($endpoint, $params);
173
+
174
+ if ($response->status() === 401) {
175
+ Cache::forget($this->tokenCacheKey($connection));
176
+ $response = $this->client($connection)->get($endpoint, $params);
177
+ }
178
+
179
+ return $response;
180
+ }
181
+
182
+ protected function paginatedGet(FuelProviderConnection $connection, string $endpoint, string $dataKey, array $params = []): Collection
183
+ {
184
+ $page = max(1, (int) data_get($params, 'page', 1));
185
+ $pageSize = max(1, (int) data_get($params, 'page_size', 100));
186
+ $maxPages = (int) data_get($params, 'max_pages', 100);
187
+ $query = collect($params)->except(['page', 'page_size', 'max_pages', 'is_trip'])->all();
188
+
189
+ $items = collect();
190
+
191
+ do {
192
+ $response = $this->get($connection, $endpoint, array_merge($query, ['Page_size' => $pageSize, 'Current_page' => $page]));
193
+
194
+ if (!$response->successful()) {
195
+ throw new \RuntimeException($this->errorMessage($response, "SASCO {$endpoint} request failed."));
196
+ }
197
+
198
+ $data = $response->json($dataKey);
199
+ if (!is_array($data)) {
200
+ throw new \RuntimeException("SASCO {$endpoint} returned an invalid response.");
201
+ }
202
+ $items = $items->merge($data);
203
+
204
+ $total = (int) $response->json('totalItems', 0);
205
+ $hasMore = count($data) > 0 && $items->count() < $total;
206
+ $page++;
207
+ } while ($hasMore && $page <= $maxPages);
208
+
209
+ if ($hasMore) {
210
+ throw new \RuntimeException('SASCO returned more pages than the sync limit. Choose a shorter date range and retry; the import was not marked complete.');
211
+ }
212
+
213
+ return $items;
214
+ }
215
+
216
+ protected function errorMessage(Response $response, string $fallback): string
217
+ {
218
+ $message = $response->json('message') ?? $response->json('title') ?? $response->json('error');
219
+
220
+ return is_string($message) && $message !== '' ? $message : $fallback;
221
+ }
222
+
223
+ /**
224
+ * Saudi plates are printed as three letters and up to four digits, e.g. "NXD 1240".
225
+ */
226
+ protected function plateNumber(array $plate): ?string
227
+ {
228
+ $number = $this->compactIdentifier($plate['plateNumber'] ?? null);
229
+ $letters = collect([$plate['firstPlateLetter'] ?? null, $plate['secondPlateLetter'] ?? null, $plate['thirdPlateLetter'] ?? null])
230
+ ->map(fn ($letter) => $this->compactIdentifier($letter))
231
+ ->filter()
232
+ ->implode('');
233
+
234
+ return $this->compactIdentifier(trim($letters . ' ' . $number));
235
+ }
236
+
237
+ protected function normalizeTransaction(array $transaction): array
238
+ {
239
+ $plate = (array) ($transaction['vehiclePlate'] ?? []);
240
+ $providerId = $this->compactIdentifier($transaction['transactionNumber'] ?? null);
241
+ $fallbackId = $this->transactionHash([
242
+ $transaction['createdAt'] ?? null,
243
+ $plate,
244
+ $transaction['stationName'] ?? null,
245
+ $transaction['noOfLitters'] ?? null,
246
+ $transaction['amount'] ?? null,
247
+ $transaction['driverPhone'] ?? null,
248
+ ]);
249
+
250
+ return [
251
+ 'provider' => $this->key(),
252
+ 'provider_transaction_id' => (string) ($providerId ?: $fallbackId),
253
+ 'plate_number' => $this->plateNumber($plate),
254
+ 'trip_number' => $this->compactIdentifier($transaction['tripId'] ?? null),
255
+ 'station_name' => $this->compactIdentifier($transaction['stationName'] ?? null),
256
+ 'transaction_at' => $this->dateFrom($transaction['createdAt'] ?? null),
257
+ 'volume' => $transaction['noOfLitters'] ?? null,
258
+ 'metric_unit' => 'l',
259
+ 'amount' => $this->minorCurrencyUnit($transaction['amount'] ?? null),
260
+ 'currency' => 'SAR',
261
+ 'normalized_payload' => [
262
+ 'fuel_type' => $transaction['fuelType'] ?? null,
263
+ 'driver_name' => $transaction['driverName'] ?? null,
264
+ 'driver_phone' => $transaction['driverPhone'] ?? null,
265
+ 'type' => $transaction['type'] ?? null,
266
+ 'period' => $transaction['period'] ?? null,
267
+ 'fuel_image_url' => $transaction['fuelImageUrl'] ?? null,
268
+ ],
269
+ 'raw_payload' => $transaction,
270
+ ];
271
+ }
272
+ }
@@ -188,18 +188,20 @@ class TelematicService
188
188
  }
189
189
  $job = (new \Fleetbase\FleetOps\Jobs\PollTelematicTelemetry($connection->uuid, $jobId, $options))
190
190
  ->onQueue($settings['poll_queue'] ?? 'default');
191
- if (!Telemetry\Queue::dispatch($job)) {
191
+ $queued = Telemetry\Queue::dispatch($job);
192
+ if (!$queued) {
192
193
  $existing = data_get($connection->meta, 'last_sync_job_id');
193
- if ($existing && in_array(data_get($connection->meta, 'last_sync_result'), ['queued', 'running', 'retrying'], true)) {
194
+ if ($existing && in_array(data_get($connection->meta, 'last_sync_result'), Telemetry\Inbox::PENDING_REQUEST_RESULTS, true)) {
194
195
  return $existing;
195
196
  }
196
- throw ValidationException::withMessages(['telematic' => ['Telemetry synchronization is already queued or running.']]);
197
+ // A scheduled sweep holds this connection's poll lease. Record the request so
198
+ // the next sweep adopts and completes it, rather than rejecting the user.
197
199
  }
198
200
  $connection->status = 'synchronizing';
199
201
  $connection->meta = array_merge($connection->meta ?? [], [
200
202
  'last_sync_job_id' => $jobId, 'last_sync_run_uuid' => null, 'last_sync_run_job_id' => null,
201
203
  'last_sync_started_at' => now()->toDateTimeString(), 'last_sync_result' => 'queued',
202
- 'last_sync_phase' => 'queued', 'last_sync_error' => null, 'last_sync_failed_reason' => null,
204
+ 'last_sync_phase' => $queued ? 'queued' : 'waiting_for_sweep', 'last_sync_error' => null, 'last_sync_failed_reason' => null,
203
205
  ]);
204
206
  $connection->save();
205
207
 
@@ -10,6 +10,9 @@ use Illuminate\Support\Str;
10
10
 
11
11
  class Inbox
12
12
  {
13
+ /** Manual request states that a sweep may still finalize. */
14
+ public const PENDING_REQUEST_RESULTS = ['queued', 'running', 'retrying'];
15
+
13
16
  public function accept(Telematic $telematic, array $payload, string $source, ?string $run = null, ?string $receivedAt = null): string
14
17
  {
15
18
  $json = json_encode($payload, JSON_THROW_ON_ERROR);
@@ -64,17 +67,12 @@ class Inbox
64
67
  if ($status === 'incomplete') {
65
68
  return;
66
69
  }
67
- // A previous sweep may finish after another manual request was queued.
68
- // Only the run explicitly associated with that request may finalize it.
69
70
  $connection = Telematic::withoutGlobalScopes()->where('uuid', $run->telematic_uuid)->lockForUpdate()->first();
70
- if (!$connection || !self::enabled($connection)
71
- || data_get($connection->meta, 'last_sync_run_uuid') !== $id
72
- || !data_get($connection->meta, 'last_sync_run_job_id')
73
- || data_get($connection->meta, 'last_sync_run_job_id') !== data_get($connection->meta, 'last_sync_job_id')) {
71
+ if (!$connection || !self::enabled($connection)) {
74
72
  return;
75
73
  }
76
- $connection->status = $status === 'completed' ? 'active' : 'error';
77
- $connection->meta = array_merge($connection->meta ?? [], [
74
+ $meta = $connection->meta ?? [];
75
+ $result = [
78
76
  'last_sync_result' => $status === 'completed' ? 'success' : $status,
79
77
  'last_sync_phase' => $status, 'last_sync_progress_at' => now()->toDateTimeString(),
80
78
  'last_sync_fetched_total' => (int) $run->units, 'last_sync_page_count' => (int) $run->pages,
@@ -82,8 +80,28 @@ class Inbox
82
80
  'last_sync_failed_total' => (int) $failed,
83
81
  'last_sync_error' => $status === 'completed' ? null : ($run->error ?? 'Some units could not be applied; inspect telemetry diagnostics.'),
84
82
  $status === 'completed' ? 'last_sync_completed_at' : 'last_sync_failed_at' => now()->toDateTimeString(),
83
+ ];
84
+ // A previous sweep may finish after another manual request was queued.
85
+ // Only the run explicitly associated with that request may finalize it.
86
+ if (data_get($meta, 'last_sync_run_uuid') === $id && data_get($meta, 'last_sync_run_job_id')
87
+ && data_get($meta, 'last_sync_run_job_id') === data_get($meta, 'last_sync_job_id')) {
88
+ $connection->status = $status === 'completed' ? 'active' : 'error';
89
+ $connection->meta = array_merge($meta, $result);
90
+ $connection->save();
91
+
92
+ return;
93
+ }
94
+ // A completed scheduled sweep proves the connection is healthy, so it replaces a
95
+ // stale failure. It never finalizes or overrides a manual request still in flight,
96
+ // and a partial sweep leaves the last reported state alone.
97
+ if ($status !== 'completed' || in_array(data_get($meta, 'last_sync_result'), self::PENDING_REQUEST_RESULTS, true)) {
98
+ return;
99
+ }
100
+ // Written without model events: sweeps run every minute and the model logs every save.
101
+ DB::table('telematics')->where('uuid', $connection->uuid)->update([
102
+ 'status' => in_array($connection->status, ['error', 'synchronizing'], true) ? 'active' : $connection->status,
103
+ 'meta' => json_encode(array_merge($meta, $result), JSON_THROW_ON_ERROR),
85
104
  ]);
86
- $connection->save();
87
105
  });
88
106
  }
89
107
  }
@@ -104,10 +104,11 @@ class ManualTelemetryProvider extends ExampleTelemetryProvider
104
104
  public array $requests = [];
105
105
  public bool $fail = false;
106
106
  public ?int $failOnCall = null;
107
+ public bool $polling = true;
107
108
 
108
109
  public function telemetryOptions(): array
109
110
  {
110
- return array_merge(parent::telemetryOptions(), ['manual_batch_sync' => true, 'batch_size' => 1, 'webhooks_enabled' => false]);
111
+ return array_merge(parent::telemetryOptions(), ['manual_batch_sync' => true, 'batch_size' => 1, 'webhooks_enabled' => false, 'polling_enabled' => $this->polling]);
111
112
  }
112
113
 
113
114
  public function fetchDevices(array $options = []): array
@@ -213,13 +214,37 @@ test('old queued discovery jobs delegate without entering their legacy lock or H
213
214
  }
214
215
  });
215
216
 
216
- test('old queued discovery jobs finish quietly when a scheduled poll already holds the request', function () {
217
+ test('manual requests made while a scheduled sweep holds the poll lease are completed by the next sweep', function () {
217
218
  [$connection, $provider, $registry, $service] = manualTelemetrySetup();
218
- expect(Fleetbase\FleetOps\Support\Telematics\Telemetry\Queue::dispatch(new PollTelematicTelemetry($connection->uuid)))->toBeTrue();
219
+ $scheduled = new PollTelematicTelemetry($connection->uuid);
220
+ expect(Fleetbase\FleetOps\Support\Telematics\Telemetry\Queue::dispatch($scheduled))->toBeTrue();
221
+ $id = $service->discoverDevices($connection);
222
+ // Old queued discovery jobs join the same pending request instead of failing.
223
+ (new SyncTelematicDevicesJob($connection->fresh(), [], 'old-job'))->handle($registry, $service);
224
+ $waiting = $connection->fresh();
225
+ expect(count($GLOBALS['manual_telemetry_jobs']))->toBe(1)
226
+ ->and($waiting->status)->toBe('synchronizing')
227
+ ->and(data_get($waiting->meta, 'last_sync_job_id'))->toBe($id)
228
+ ->and(data_get($waiting->meta, 'last_sync_phase'))->toBe('waiting_for_sweep');
229
+ ExampleTelemetryProvider::$pages = [['devices' => [manualTelemetrySample()], 'has_more' => false, 'next_cursor' => null]];
230
+ $scheduled->handle($registry, new Inbox());
231
+ expect(data_get($connection->fresh()->meta, 'last_sync_result'))->toBe('running');
232
+ manualTelemetryProcessPending($service);
233
+ $fresh = $connection->fresh();
234
+ expect($fresh->status)->toBe('active')
235
+ ->and(data_get($fresh->meta, 'last_sync_job_id'))->toBe($id)
236
+ ->and(data_get($fresh->meta, 'last_sync_result'))->toBe('success')
237
+ ->and(data_get($fresh->meta, 'last_sync_completed_at'))->toBe('2026-09-15 12:00:00')
238
+ ->and(data_get($fresh->meta, 'last_sync_total'))->toBe(1);
239
+ });
240
+
241
+ test('old queued discovery jobs finish quietly when polling was paused after they were queued', function () {
242
+ [$connection, $provider, $registry, $service] = manualTelemetrySetup();
243
+ $provider->polling = false;
219
244
  (new SyncTelematicDevicesJob($connection, [], 'old-job'))->handle($registry, $service);
220
245
  $fresh = $connection->fresh();
221
246
  // A thrown ValidationException would fail the tries=1 job and mark this connection as errored.
222
- expect(count($GLOBALS['manual_telemetry_jobs']))->toBe(1)
247
+ expect(count($GLOBALS['manual_telemetry_jobs']))->toBe(0)
223
248
  ->and($fresh->status)->toBe('active')
224
249
  ->and(data_get($fresh->meta, 'last_sync_job_id'))->toBeNull();
225
250
  });
@@ -459,3 +484,62 @@ test('manual sync re-checks the locked connection before queueing', function ()
459
484
  expect(count($GLOBALS['manual_telemetry_jobs']))->toBe(0)
460
485
  ->and(DB::table('telematics')->value('status'))->toBe('disabled');
461
486
  });
487
+
488
+
489
+ function manualTelemetryConnectionState(Telematic $connection, string $status, array $meta): void
490
+ {
491
+ DB::table('telematics')->where('uuid', $connection->uuid)->update(['status' => $status, 'meta' => json_encode($meta)]);
492
+ }
493
+
494
+ test('completed scheduled sweeps replace a stale sync failure without saving through the model', function () {
495
+ [$connection, $provider, $registry, $service] = manualTelemetrySetup();
496
+ manualTelemetryConnectionState($connection, 'error', [
497
+ 'last_sync_job_id' => 'lost-request', 'last_sync_result' => 'failed', 'last_sync_error' => 'Polling retries exhausted. MaxAttemptsExceededException',
498
+ 'last_sync_completed_at' => '2026-06-24 07:32:18', 'preserved' => 'yes',
499
+ ]);
500
+ ExampleTelemetryProvider::$pages = [['devices' => [manualTelemetrySample()], 'has_more' => false, 'next_cursor' => null]];
501
+ (new PollTelematicTelemetry($connection->uuid))->handle($registry, new Inbox());
502
+ manualTelemetryProcessPending($service);
503
+ $fresh = $connection->fresh();
504
+ expect($fresh->status)->toBe('active')
505
+ ->and(data_get($fresh->meta, 'last_sync_result'))->toBe('success')
506
+ ->and(data_get($fresh->meta, 'last_sync_error'))->toBeNull()
507
+ ->and(data_get($fresh->meta, 'last_sync_completed_at'))->toBe('2026-09-15 12:00:00')
508
+ ->and(data_get($fresh->meta, 'last_sync_fetched_total'))->toBe(1)
509
+ ->and(data_get($fresh->meta, 'last_sync_job_id'))->toBe('lost-request')
510
+ ->and(data_get($fresh->meta, 'preserved'))->toBe('yes')
511
+ ->and($fresh->updated_at)->toBeNull();
512
+ });
513
+
514
+ test('partial scheduled sweeps leave the last reported sync state unchanged', function () {
515
+ [$connection, $provider, $registry, $service] = manualTelemetrySetup();
516
+ manualTelemetryConnectionState($connection, 'error', ['last_sync_result' => 'failed', 'last_sync_error' => 'Earlier failure']);
517
+ ExampleTelemetryProvider::$pages = [['devices' => [manualTelemetrySample()], 'has_more' => false, 'next_cursor' => null]];
518
+ (new PollTelematicTelemetry($connection->uuid))->handle($registry, new Inbox());
519
+ $run = DB::table('telematic_sync_runs')->value('uuid');
520
+ DB::table('telematic_deliveries')->update(['status' => 'quarantined', 'failed' => 1]);
521
+ Inbox::finishRun($run);
522
+ $fresh = $connection->fresh();
523
+ expect(DB::table('telematic_sync_runs')->value('status'))->toBe('partial')
524
+ ->and($fresh->status)->toBe('error')
525
+ ->and(data_get($fresh->meta, 'last_sync_error'))->toBe('Earlier failure');
526
+ });
527
+
528
+ test('scheduled sweeps adopt abandoned requests but never one bound to a sweep in progress', function (string $boundStatus, bool $adopted) {
529
+ [$connection, $provider, $registry, $service] = manualTelemetrySetup();
530
+ DB::table('telematic_sync_runs')->insert(['uuid' => 'bound-run', 'telematic_uuid' => $connection->uuid, 'status' => $boundStatus, 'created_at' => now(), 'updated_at' => now()]);
531
+ manualTelemetryConnectionState($connection, 'synchronizing', [
532
+ 'last_sync_job_id' => 'abandoned-request', 'last_sync_result' => 'retrying',
533
+ 'last_sync_run_uuid' => 'bound-run', 'last_sync_run_job_id' => 'abandoned-request',
534
+ ]);
535
+ ExampleTelemetryProvider::$pages = [['devices' => [manualTelemetrySample()], 'has_more' => false, 'next_cursor' => null]];
536
+ (new PollTelematicTelemetry($connection->uuid))->handle($registry, new Inbox());
537
+ manualTelemetryProcessPending($service);
538
+ $fresh = $connection->fresh();
539
+ expect($fresh->status)->toBe($adopted ? 'active' : 'synchronizing')
540
+ ->and(data_get($fresh->meta, 'last_sync_result'))->toBe($adopted ? 'success' : 'retrying')
541
+ ->and(data_get($fresh->meta, 'last_sync_run_uuid'))->toBe($adopted ? DB::table('telematic_sync_runs')->where('uuid', '!=', 'bound-run')->value('uuid') : 'bound-run');
542
+ })->with([
543
+ 'interrupted run' => ['incomplete', true],
544
+ 'run still ingesting' => ['ingesting', false],
545
+ ]);
@@ -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
+ });