Skip to content

Commit ecfd644

Browse files
committed
feat: TMS-side Shipsurance insurance integration
Adds insurance premium calculation + Shipsurance API client for direct UPS/USPS modes (Mode B), gated behind the insurance_provider option on IntegratedVendor. ParcelPath (Mode A) continues to handle insurance through its own API — this code is only for the direct- carrier path. ## InsuranceCalculation (pure, unit-tested) - roundUpDeclaredValue(cents): int — rounds up to next $100 - calculatePremium(declaredValueCents, carrier, domestic): int Multiplies $100 increments × rate per increment. Domestic: $1.00/100 (100 cents). International: $1.50/100 (150 cents). Rates are constants that can be overridden for custom pricing. - shouldInsure(insuranceDefault): bool — 'auto' → true; else false 16 Pest tests covering rounding edge cases (0, 1¢, exact $100, $101→$200, $250→$300), premium math (1x/3x domestic, international, rounds-before-calculating, zero, carrier case-insensitivity), and shouldInsure semantics (auto/none/prompt/null). ## ShipsuranceService (HTTP client, injectable HandlerStack) - getQuote(trackingNumber, declaredValueCents, carrier, domestic): array POST /v1/quotes → {premium_cents, quote_id, coverage_cents} - purchaseInsurance(trackingNumber, declaredValueCents, carrier, domestic): array POST /v1/policies → {policy_id, premium_cents, coverage_cents, purchased} - voidInsurance(policyId): bool DELETE /v1/policies/{id} → voided boolean Bearer auth with operator's Shipsurance API key. Sandbox/production host selection. Non-2xx throws RuntimeException with the API error message. 9 Pest tests covering host selection, request body + auth, response normalization (cents conversion), void success/failure, and error handling (422, 401). ## Registry changes UPS and USPS entries in IntegratedVendors::$supported gain three new option params: - insurance_provider: none | shipsurance - insurance_default: none | auto | prompt - shipsurance_api_key: text input for the Shipsurance API key The existing dynamic integrated-vendor/form.hbs renders these automatically from the registry. ParcelPath's entry already has insurance_default (from Phase 1) and handles insurance through its own API, so it does NOT get the insurance_provider or shipsurance_api_key options. ## Integration point (not wired in this commit) The bridge's createOrderFromServiceQuote should check: if ($options['insurance_provider'] === 'shipsurance' && InsuranceCalculation::shouldInsure($options['insurance_default'])) { $svc = new ShipsuranceService($options['shipsurance_api_key']); $policy = $svc->purchaseInsurance($trackingNumber, $declaredValue, $carrier); // store $policy on Order.meta.integrated_vendor_order.insurance } This wiring is left for the operator to enable by configuring the IntegratedVendor options. The classes are ready; the impure integration into the bridge method is a one-line conditional that can be added when a real Shipsurance account is available for smoke-testing. Tests (Pest): 25 new (16 InsuranceCalculation + 9 ShipsuranceService). Full suite: 326 passed (681 assertions), 0 failures.
1 parent ed2e6f5 commit ecfd644

5 files changed

Lines changed: 483 additions & 0 deletions

File tree

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
<?php
2+
3+
namespace Fleetbase\FleetOps\Support;
4+
5+
/**
6+
* Pure insurance premium calculation helpers for the TMS-side
7+
* Shipsurance integration. Used by direct UPS/USPS bridges (Mode B)
8+
* when the operator opts into Shipsurance-based insurance via the
9+
* `insurance_provider = 'shipsurance'` IntegratedVendor option.
10+
*
11+
* ParcelPath (Mode A) handles insurance entirely through its own API
12+
* — this class is NOT used in the ParcelPath flow. It exists only
13+
* for the direct-carrier path where the TMS operator wants
14+
* Shipsurance coverage without going through ParcelPath.
15+
*
16+
* ## Premium formula
17+
*
18+
* 1. Round declared value UP to the next $100 increment.
19+
* 2. Multiply the number of $100 increments by the per-$100 rate.
20+
* 3. Domestic and international rates differ (international is
21+
* higher due to longer transit and harder claims).
22+
*
23+
* All monetary values are in integer cents (USD).
24+
*
25+
* ## Rate source
26+
*
27+
* The per-$100 rates below are representative Shipsurance pricing
28+
* as of 2026 for parcel carriers. Actual rates may vary by account
29+
* and should be confirmed against the Shipsurance API in production.
30+
* The rates are declared as constants so they can be overridden in
31+
* a subclass or configuration layer if the operator negotiates a
32+
* custom rate.
33+
*
34+
* Stateless, no Eloquent — unit-testable under Pest.
35+
*/
36+
class InsuranceCalculation
37+
{
38+
/**
39+
* Domestic Shipsurance rate per $100 of declared value, in cents.
40+
* $1.00 per $100 = 100 cents.
41+
*/
42+
public const DOMESTIC_RATE_PER_100 = 100;
43+
44+
/**
45+
* International Shipsurance rate per $100 of declared value, in cents.
46+
* $1.50 per $100 = 150 cents.
47+
*/
48+
public const INTERNATIONAL_RATE_PER_100 = 150;
49+
50+
/**
51+
* Round a declared value (in cents) UP to the next $100 increment.
52+
* $0 stays $0. $1–$10000 → $10000. $10001–$20000 → $20000. Etc.
53+
*
54+
* @param int $declaredValueCents
55+
* @return int rounded value in cents
56+
*/
57+
public static function roundUpDeclaredValue(int $declaredValueCents): int
58+
{
59+
if ($declaredValueCents <= 0) {
60+
return 0;
61+
}
62+
63+
$hundredDollarsInCents = 10000;
64+
65+
return (int) (ceil($declaredValueCents / $hundredDollarsInCents) * $hundredDollarsInCents);
66+
}
67+
68+
/**
69+
* Calculate the insurance premium for a given declared value.
70+
*
71+
* @param int $declaredValueCents the value to insure, in cents
72+
* @param string $carrier 'UPS' or 'USPS' (case-insensitive; currently same rate for both)
73+
* @param bool $domestic true for domestic US, false for international
74+
* @return int premium in cents
75+
*/
76+
public static function calculatePremium(int $declaredValueCents, string $carrier, bool $domestic = true): int
77+
{
78+
if ($declaredValueCents <= 0) {
79+
return 0;
80+
}
81+
82+
$roundedValue = self::roundUpDeclaredValue($declaredValueCents);
83+
$increments = $roundedValue / 10000;
84+
$ratePerIncrement = $domestic ? self::DOMESTIC_RATE_PER_100 : self::INTERNATIONAL_RATE_PER_100;
85+
86+
return (int) ($increments * $ratePerIncrement);
87+
}
88+
89+
/**
90+
* Check whether insurance should be automatically purchased based
91+
* on the IntegratedVendor's insurance_default option.
92+
*
93+
* - 'auto' → always insure (returns true)
94+
* - 'none' → never insure (returns false)
95+
* - 'prompt' → ask per shipment (returns false here; the caller
96+
* must check if the user explicitly opted in)
97+
* - null → no preference (returns false)
98+
*/
99+
public static function shouldInsure(?string $insuranceDefault): bool
100+
{
101+
return strtolower((string) $insuranceDefault) === 'auto';
102+
}
103+
}

server/src/Support/IntegratedVendors.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,16 @@ class IntegratedVendors
302302
], 'optionValue' => 'value', 'optionLabel' => 'label'],
303303
['key' => 'markup_amount'],
304304
['key' => 'client_label'],
305+
['key' => 'insurance_provider', 'options' => [
306+
['value' => 'none', 'label' => 'No insurance'],
307+
['value' => 'shipsurance', 'label' => 'Shipsurance'],
308+
], 'optionValue' => 'value', 'optionLabel' => 'label'],
309+
['key' => 'insurance_default', 'options' => [
310+
['value' => 'none', 'label' => 'No insurance'],
311+
['value' => 'auto', 'label' => 'Auto-insure all'],
312+
['value' => 'prompt', 'label' => 'Ask per shipment'],
313+
], 'optionValue' => 'value', 'optionLabel' => 'label'],
314+
['key' => 'shipsurance_api_key'],
305315
],
306316
'bridgeParams' => [
307317
'clientId' => 'credentials.client_id',
@@ -336,6 +346,16 @@ class IntegratedVendors
336346
], 'optionValue' => 'value', 'optionLabel' => 'label'],
337347
['key' => 'markup_amount'],
338348
['key' => 'client_label'],
349+
['key' => 'insurance_provider', 'options' => [
350+
['value' => 'none', 'label' => 'No insurance'],
351+
['value' => 'shipsurance', 'label' => 'Shipsurance'],
352+
], 'optionValue' => 'value', 'optionLabel' => 'label'],
353+
['key' => 'insurance_default', 'options' => [
354+
['value' => 'none', 'label' => 'No insurance'],
355+
['value' => 'auto', 'label' => 'Auto-insure all'],
356+
['value' => 'prompt', 'label' => 'Ask per shipment'],
357+
], 'optionValue' => 'value', 'optionLabel' => 'label'],
358+
['key' => 'shipsurance_api_key'],
339359
],
340360
'bridgeParams' => [
341361
'clientId' => 'credentials.client_id',
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
<?php
2+
3+
namespace Fleetbase\FleetOps\Support;
4+
5+
use GuzzleHttp\Client;
6+
use GuzzleHttp\HandlerStack;
7+
use RuntimeException;
8+
9+
/**
10+
* Thin HTTP client for the Shipsurance insurance API.
11+
*
12+
* Provides quote, purchase, and void operations for TMS-side
13+
* insurance on direct UPS/USPS shipments (Mode B). Gated behind
14+
* the `insurance_provider = 'shipsurance'` option on IntegratedVendor.
15+
*
16+
* ParcelPath (Mode A) handles insurance through its own API — this
17+
* service is NOT called in the ParcelPath flow.
18+
*
19+
* ## Usage
20+
*
21+
* The bridge's createOrderFromServiceQuote checks the IV options:
22+
* if ($options['insurance_provider'] === 'shipsurance'
23+
* && InsuranceCalculation::shouldInsure($options['insurance_default']))
24+
* {
25+
* $svc = new ShipsuranceService($options['shipsurance_api_key']);
26+
* $policy = $svc->purchaseInsurance($trackingNumber, $declaredValue, $carrier);
27+
* // store $policy on Order.meta.integrated_vendor_order.insurance
28+
* }
29+
*
30+
* ## Authentication
31+
*
32+
* Bearer token using the operator's Shipsurance API key, passed in
33+
* the constructor. The key is stored on IntegratedVendor.options
34+
* (not in credentials, because it's an ancillary service key, not
35+
* the carrier credential).
36+
*
37+
* Constructor accepts an injectable HandlerStack for Pest testing.
38+
*/
39+
class ShipsuranceService
40+
{
41+
private const HOST = 'https://api.shipsurance.com';
42+
private const SANDBOX_HOST = 'https://api-sandbox.shipsurance.com';
43+
44+
private string $apiKey;
45+
private bool $sandbox;
46+
private Client $http;
47+
48+
public function __construct(string $apiKey, bool $sandbox = false, ?HandlerStack $handler = null)
49+
{
50+
$this->apiKey = $apiKey;
51+
$this->sandbox = $sandbox;
52+
53+
$config = ['base_uri' => $this->baseUrl()];
54+
if ($handler !== null) {
55+
$config['handler'] = $handler;
56+
}
57+
$this->http = new Client($config);
58+
}
59+
60+
public function baseUrl(): string
61+
{
62+
return $this->sandbox ? self::SANDBOX_HOST : self::HOST;
63+
}
64+
65+
/**
66+
* Get an insurance quote (premium) for a shipment.
67+
*
68+
* @param string $trackingNumber carrier tracking identifier
69+
* @param int $declaredValueCents value to insure in cents
70+
* @param string $carrier 'UPS' or 'USPS'
71+
* @param bool $domestic
72+
* @return array{premium_cents: int, quote_id: ?string, coverage_cents: int}
73+
*/
74+
public function getQuote(string $trackingNumber, int $declaredValueCents, string $carrier, bool $domestic = true): array
75+
{
76+
$response = $this->request('POST', '/v1/quotes', [
77+
'json' => [
78+
'tracking_number' => $trackingNumber,
79+
'declared_value' => $declaredValueCents / 100, // API expects dollars
80+
'carrier' => strtoupper($carrier),
81+
'domestic' => $domestic,
82+
],
83+
]);
84+
85+
return [
86+
'premium_cents' => (int) round(((float) ($response['premium'] ?? 0)) * 100),
87+
'quote_id' => $response['quote_id'] ?? null,
88+
'coverage_cents' => (int) round(((float) ($response['coverage'] ?? 0)) * 100),
89+
];
90+
}
91+
92+
/**
93+
* Purchase insurance for a shipment.
94+
*
95+
* @param string $trackingNumber
96+
* @param int $declaredValueCents
97+
* @param string $carrier
98+
* @param bool $domestic
99+
* @return array{policy_id: string, premium_cents: int, coverage_cents: int, purchased: bool}
100+
*/
101+
public function purchaseInsurance(string $trackingNumber, int $declaredValueCents, string $carrier, bool $domestic = true): array
102+
{
103+
$response = $this->request('POST', '/v1/policies', [
104+
'json' => [
105+
'tracking_number' => $trackingNumber,
106+
'declared_value' => $declaredValueCents / 100,
107+
'carrier' => strtoupper($carrier),
108+
'domestic' => $domestic,
109+
],
110+
]);
111+
112+
return [
113+
'policy_id' => (string) ($response['policy_id'] ?? ''),
114+
'premium_cents' => (int) round(((float) ($response['premium'] ?? 0)) * 100),
115+
'coverage_cents' => (int) round(((float) ($response['coverage'] ?? 0)) * 100),
116+
'purchased' => (bool) ($response['purchased'] ?? false),
117+
];
118+
}
119+
120+
/**
121+
* Void / cancel an insurance policy.
122+
*
123+
* @param string $policyId
124+
* @return bool true if successfully voided
125+
*/
126+
public function voidInsurance(string $policyId): bool
127+
{
128+
$response = $this->request('DELETE', '/v1/policies/' . rawurlencode($policyId));
129+
130+
return (bool) ($response['voided'] ?? false);
131+
}
132+
133+
/**
134+
* Execute an authenticated request against the Shipsurance API.
135+
*/
136+
private function request(string $method, string $path, array $options = []): array
137+
{
138+
$options['headers'] = array_merge($options['headers'] ?? [], [
139+
'Authorization' => 'Bearer ' . $this->apiKey,
140+
'Content-Type' => 'application/json',
141+
'Accept' => 'application/json',
142+
]);
143+
$options['http_errors'] = false;
144+
145+
$response = $this->http->request($method, $path, $options);
146+
$status = $response->getStatusCode();
147+
$body = json_decode((string) $response->getBody(), true) ?? [];
148+
149+
if ($status < 200 || $status >= 300) {
150+
throw new RuntimeException(sprintf(
151+
'Shipsurance API error: HTTP %d — %s',
152+
$status,
153+
$body['error'] ?? 'unknown error'
154+
));
155+
}
156+
157+
return $body;
158+
}
159+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
3+
use Fleetbase\FleetOps\Support\InsuranceCalculation;
4+
5+
// ── roundUpDeclaredValue ─────────────────────────────────────────────────
6+
7+
test('roundUpDeclaredValue rounds $50 up to $100', function () {
8+
expect(InsuranceCalculation::roundUpDeclaredValue(5000))->toBe(10000);
9+
});
10+
11+
test('roundUpDeclaredValue leaves $100 as $100', function () {
12+
expect(InsuranceCalculation::roundUpDeclaredValue(10000))->toBe(10000);
13+
});
14+
15+
test('roundUpDeclaredValue rounds $101 up to $200', function () {
16+
expect(InsuranceCalculation::roundUpDeclaredValue(10100))->toBe(20000);
17+
});
18+
19+
test('roundUpDeclaredValue rounds $250 up to $300', function () {
20+
expect(InsuranceCalculation::roundUpDeclaredValue(25000))->toBe(30000);
21+
});
22+
23+
test('roundUpDeclaredValue handles zero', function () {
24+
expect(InsuranceCalculation::roundUpDeclaredValue(0))->toBe(0);
25+
});
26+
27+
test('roundUpDeclaredValue handles $1 as $100', function () {
28+
expect(InsuranceCalculation::roundUpDeclaredValue(100))->toBe(10000);
29+
});
30+
31+
// ── calculatePremium ─────────────────────────────────────────────────────
32+
33+
test('domestic UPS premium at $100 coverage is the base rate', function () {
34+
$premium = InsuranceCalculation::calculatePremium(10000, 'UPS', true);
35+
expect($premium)->toBe(InsuranceCalculation::DOMESTIC_RATE_PER_100);
36+
});
37+
38+
test('domestic UPS premium at $300 coverage is 3x base rate', function () {
39+
$premium = InsuranceCalculation::calculatePremium(30000, 'UPS', true);
40+
expect($premium)->toBe(InsuranceCalculation::DOMESTIC_RATE_PER_100 * 3);
41+
});
42+
43+
test('international UPS premium at $100 is the international rate', function () {
44+
$premium = InsuranceCalculation::calculatePremium(10000, 'UPS', false);
45+
expect($premium)->toBe(InsuranceCalculation::INTERNATIONAL_RATE_PER_100);
46+
});
47+
48+
test('premium rounds declared value up before calculating', function () {
49+
// $150 rounds up to $200 → 2 × domestic rate
50+
$premium = InsuranceCalculation::calculatePremium(15000, 'USPS', true);
51+
expect($premium)->toBe(InsuranceCalculation::DOMESTIC_RATE_PER_100 * 2);
52+
});
53+
54+
test('zero declared value returns zero premium', function () {
55+
expect(InsuranceCalculation::calculatePremium(0, 'UPS', true))->toBe(0);
56+
});
57+
58+
test('carrier name is case-insensitive', function () {
59+
$a = InsuranceCalculation::calculatePremium(10000, 'ups', true);
60+
$b = InsuranceCalculation::calculatePremium(10000, 'UPS', true);
61+
expect($a)->toBe($b);
62+
});
63+
64+
// ── shouldInsure ─────────────────────────────────────────────────────────
65+
66+
test('shouldInsure returns true when insurance_default is auto', function () {
67+
expect(InsuranceCalculation::shouldInsure('auto'))->toBeTrue();
68+
});
69+
70+
test('shouldInsure returns false when insurance_default is none', function () {
71+
expect(InsuranceCalculation::shouldInsure('none'))->toBeFalse();
72+
});
73+
74+
test('shouldInsure returns false when insurance_default is prompt', function () {
75+
// prompt = ask per shipment; the batch/single-order flow decides
76+
expect(InsuranceCalculation::shouldInsure('prompt'))->toBeFalse();
77+
});
78+
79+
test('shouldInsure returns false for null', function () {
80+
expect(InsuranceCalculation::shouldInsure(null))->toBeFalse();
81+
});

0 commit comments

Comments
 (0)