Skip to content

Commit ed2e6f5

Browse files
committed
feat: ParcelWebhookController for carrier tracking event ingestion
Adds POST /webhooks/parcel/{providerKey} endpoint that accepts carrier webhook payloads from ParcelPath, UPS, and USPS, normalizes them into TrackingStatus rows, and lets the existing TrackingStatusObserver (Task 22) handle downstream Order status transitions + SocketCluster broadcast. ## Architecture: pure normalizer + thin controller WebhookPayloadNormalizer (pure, unit-tested): - isValidProvider(string): bool — checks against [parcelpath, ups, usps], case-insensitive - normalize(providerKey, payload): array — dispatches to provider-specific normalizer: * ParcelPath: events already carry Fleetbase-compatible codes; uppercased and passed through * UPS: reuses UPS::upsActivityCodeToFleetbaseCode (I→IN_TRANSIT, D→DELIVERED, etc.) * USPS: reuses USPS::uspsEventTypeToFleetbaseCode (ALERT→EXCEPTION, rest verbatim) - dedupKey(trackingNumberUuid, event): array — composite key for TrackingStatus::firstOrCreate matching the poll jobs' dedup logic exactly ParcelWebhookController (impure, thin): 1. Validate provider key → 404 if unknown 2. Verify shared secret → 401 if mismatch (see below) 3. Normalize payload via the pure normalizer 4. For each event: resolve TrackingNumber (carrier_tracking_number index from Task 7, falling back to tracking_number column), firstOrCreate a TrackingStatus row with the dedup key 5. Return 200 with {processed, skipped} counts ## Authentication: shared-secret header (pluggable) Checks config('services.{providerKey}.webhook_secret') against the X-Webhook-Secret request header using hash_equals (timing-safe). If no secret is configured (null/empty), verification passes — this makes the endpoint open during development and initial integration testing. The verification logic is isolated in verifyWebhookSecret() so it can be replaced with HMAC signing or IP allowlist per provider without touching the ingestion logic. ## Idempotency TrackingStatus::firstOrCreate with composite key (tracking_number_uuid, code, created_at) — identical to the key used by PollParcelPathTrackingJob, PollUPSTrackingJob, and PollUSPSTrackingJob. Duplicate webhook deliveries are silently de-duped. The firstOrCreate match returns the existing row without inserting; the TrackingStatusObserver only fires on actual `created` events, so duplicate deliveries also don't trigger redundant Order status transitions. ## Per-event error isolation A malformed event (missing tracking number, unresolvable TrackingNumber record, Eloquent exception) is skipped and report()ed but does not abort the webhook request. The response always returns 200 so the webhook sender considers delivery successful and doesn't enter an infinite retry loop on permanently malformed events. ## Expected webhook request shapes ParcelPath: POST /webhooks/parcel/parcelpath X-Webhook-Secret: {configured secret} { "tracking_number": "1Z999AA10123456784", "carrier": "UPS", "events": [ {"code": "IN_TRANSIT", "status": "In Transit", "timestamp": "2026-04-07T10:00:00Z", "location": "Chicago"} ] } UPS (single event): POST /webhooks/parcel/ups X-Webhook-Secret: {configured secret} { "trackingNumber": "1Z999AA10123456784", "eventType": "D", "eventDescription": "Delivered", "eventTimestamp": "2026-04-09T14:22:00", "eventCity": "New York", "eventState": "NY" } USPS (single event): POST /webhooks/parcel/usps X-Webhook-Secret: {configured secret} { "trackingNumber": "9400111202555999999999", "eventType": "DELIVERED", "eventTimestamp": "2026-04-09T14:22:00", "eventCity": "New York" } ## Route registration POST /webhooks/parcel/{providerKey} added to the existing webhooks group in routes.php, alongside the telematics webhook routes. Follows the same pattern (providerKey path parameter, any-method for telematics → POST-only for parcel). Tests (Pest): 16 new normalizer tests covering ParcelPath / UPS / USPS normalization, code mapping reuse, dedupKey composition, provider validation, and edge cases (missing tracking number, no events, unknown provider). Full suite: 301 passed (644 assertions).
1 parent f0f0f6c commit ed2e6f5

4 files changed

Lines changed: 570 additions & 0 deletions

File tree

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
<?php
2+
3+
namespace Fleetbase\FleetOps\Http\Controllers\Api\v1;
4+
5+
use Fleetbase\FleetOps\Http\Controllers\FleetOpsController;
6+
use Fleetbase\FleetOps\Models\TrackingNumber;
7+
use Fleetbase\FleetOps\Models\TrackingStatus;
8+
use Fleetbase\FleetOps\Support\WebhookPayloadNormalizer;
9+
use Illuminate\Http\Request;
10+
use Throwable;
11+
12+
/**
13+
* Webhook ingestion endpoint for carrier tracking events.
14+
*
15+
* POST /webhooks/parcel/{providerKey}
16+
*
17+
* Accepts webhook payloads from ParcelPath, UPS, and USPS (or any
18+
* future provider added to WebhookPayloadNormalizer::PROVIDERS),
19+
* normalizes the events via the pure normalizer, and creates
20+
* TrackingStatus rows using the same dedup key as the poll jobs.
21+
*
22+
* The existing TrackingStatusObserver (Task 22) handles downstream
23+
* effects: Order status transitions + SocketCluster broadcast.
24+
* This controller only creates the rows — it does not touch Orders
25+
* or fire notifications directly.
26+
*
27+
* ## Authentication
28+
*
29+
* Shared-secret header validation via X-Webhook-Secret. The secret
30+
* is looked up from config at services.{providerKey}.webhook_secret.
31+
* If no secret is configured, the endpoint is effectively open
32+
* (suitable for development / initial integration testing). The
33+
* verification logic is isolated in verifyWebhookSecret() so it can
34+
* be replaced with HMAC or IP allowlist verification per provider
35+
* without touching the ingestion logic.
36+
*
37+
* ## Idempotency
38+
*
39+
* TrackingStatus::firstOrCreate with the composite key
40+
* (tracking_number_uuid, code, created_at) — same key the poll
41+
* jobs use. Duplicate webhook deliveries are silently de-duped.
42+
*
43+
* ## Error isolation
44+
*
45+
* Per-event isolation: a malformed event row is skipped and logged
46+
* but does not abort the webhook request. The response always
47+
* returns 200 with a count of processed/skipped events, so the
48+
* webhook sender considers delivery successful and doesn't retry
49+
* (avoiding an infinite retry loop on permanently malformed events).
50+
*/
51+
class ParcelWebhookController extends FleetOpsController
52+
{
53+
/**
54+
* Handle an inbound webhook payload.
55+
*
56+
* @param string $providerKey one of: parcelpath, ups, usps
57+
* @param Request $request the webhook HTTP request
58+
*/
59+
public function handle(string $providerKey, Request $request)
60+
{
61+
// 1. Validate provider key.
62+
if (!WebhookPayloadNormalizer::isValidProvider($providerKey)) {
63+
return response()->json(['error' => 'unknown provider'], 404);
64+
}
65+
66+
// 2. Verify shared secret (pluggable — see verifyWebhookSecret).
67+
if (!$this->verifyWebhookSecret($providerKey, $request)) {
68+
return response()->json(['error' => 'unauthorized'], 401);
69+
}
70+
71+
// 3. Normalize the payload into tracking events.
72+
$payload = $request->all();
73+
$events = WebhookPayloadNormalizer::normalize($providerKey, $payload);
74+
75+
if (empty($events)) {
76+
return response()->json(['processed' => 0, 'skipped' => 0]);
77+
}
78+
79+
// 4. Process each event: resolve TrackingNumber, firstOrCreate TrackingStatus.
80+
$processed = 0;
81+
$skipped = 0;
82+
83+
foreach ($events as $event) {
84+
try {
85+
$trackingNumber = $this->resolveTrackingNumber($event['tracking_number'] ?? '');
86+
if ($trackingNumber === null) {
87+
$skipped++;
88+
continue;
89+
}
90+
91+
$dedupKey = WebhookPayloadNormalizer::dedupKey(
92+
$trackingNumber->uuid,
93+
$event
94+
);
95+
96+
TrackingStatus::firstOrCreate(
97+
$dedupKey,
98+
[
99+
'company_uuid' => $trackingNumber->company_uuid,
100+
'status' => $event['status'] ?? $event['code'],
101+
'details' => $event['location'] ?? $event['details'] ?? null,
102+
]
103+
);
104+
105+
$processed++;
106+
} catch (Throwable $e) {
107+
report($e);
108+
$skipped++;
109+
}
110+
}
111+
112+
// 5. Always return 200 so the sender considers delivery successful.
113+
return response()->json([
114+
'processed' => $processed,
115+
'skipped' => $skipped,
116+
]);
117+
}
118+
119+
/**
120+
* Resolve a TrackingNumber model from a carrier tracking identifier.
121+
* Checks the carrier_tracking_number column first (indexed,
122+
* added in Phase 1 Task 7), then falls back to the primary
123+
* tracking_number column.
124+
*/
125+
protected function resolveTrackingNumber(string $carrierTrackingNumber): ?TrackingNumber
126+
{
127+
if ($carrierTrackingNumber === '') {
128+
return null;
129+
}
130+
131+
return TrackingNumber::where('carrier_tracking_number', $carrierTrackingNumber)
132+
->orWhere('tracking_number', $carrierTrackingNumber)
133+
->first();
134+
}
135+
136+
/**
137+
* Verify the webhook request's shared secret. Isolated so it can
138+
* be replaced with HMAC signing or IP allowlist per provider
139+
* without touching the ingestion logic.
140+
*
141+
* Checks config('services.{providerKey}.webhook_secret'). If no
142+
* secret is configured (null/empty), verification passes — this
143+
* makes the endpoint effectively open during development.
144+
*/
145+
protected function verifyWebhookSecret(string $providerKey, Request $request): bool
146+
{
147+
$configuredSecret = config('services.' . strtolower($providerKey) . '.webhook_secret');
148+
149+
// No secret configured = verification disabled (dev mode).
150+
if (empty($configuredSecret)) {
151+
return true;
152+
}
153+
154+
$providedSecret = $request->header('X-Webhook-Secret', '');
155+
156+
return hash_equals((string) $configuredSecret, (string) $providedSecret);
157+
}
158+
}

0 commit comments

Comments
 (0)