Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion src/App/DeliveryOptions/Service/DeliveryOptionsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use MyParcelNL\Pdk\Base\Support\Collection;
use MyParcelNL\Pdk\Base\Support\SettingKey;
use MyParcelNL\Pdk\Base\Support\Utils;
use MyParcelNL\Pdk\Carrier\Collection\CarrierCollection;
use MyParcelNL\Pdk\Carrier\Contract\CarrierRepositoryInterface;
use MyParcelNL\Pdk\Carrier\Model\Carrier;
use MyParcelNL\Pdk\Carrier\Service\CapabilitiesValidationService;
Expand Down Expand Up @@ -229,8 +230,18 @@ private function getBaseSettings(CarrierSettings $carrierSettings, PdkCart $cart
*/
private function getValidCarrierOptions(PdkCart $cart): array
{
$carrierSettings = Settings::get(CarrierSettings::ID);

$carrierSettings = array_filter(
is_array($carrierSettings) ? $carrierSettings : [],
static fn($settings): bool => is_array($settings)
);

if (empty($carrierSettings)) {
return [DeliveryOptions::DEFAULT_PACKAGE_TYPE_NAME, new CarrierCollection()];
}

$allCarriers = $this->carrierRepository->all();
$carrierSettings = Settings::get(CarrierSettings::ID);
$shippingAddress = $cart->shippingMethod->shippingAddress;
$cc = $shippingAddress->cc ?? null;
$isBusiness = $shippingAddress->isBusiness;
Expand Down
75 changes: 58 additions & 17 deletions src/App/Order/Calculator/General/InsuranceCalculator.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ public function calculate(): void
* narrow below the carrier-wide contract range. Tier resolution and clamping
* use those shipment-specific bounds.
*
* The capability advertises them as flat `min`/`max`/`default` money objects, each
* optional. An absent minimum means the carrier imposes no floor, an absent maximum
* means it imposes no ceiling, and an absent default falls back to the minimum.
*
* - NULL or DISABLED (0): use carrier minimum.
* - INHERIT (-1): fall back to settings; if settings do not enable insurance, use carrier default.
* - Explicit amount: resolve to nearest valid tier.
Expand All @@ -83,10 +87,13 @@ private function calculateInsurance(?int $amount): int
return 0;
}

$insuredAmount = $carrierInsurance->getInsuredAmount();
$carrierMin = $insuredAmount->getMin()->getAmount();
$carrierMax = $insuredAmount->getMax()->getAmount();
$carrierDefault = $insuredAmount->getDefault()->getAmount();
$min = $carrierInsurance->getMin();
$max = $carrierInsurance->getMax();
$default = $carrierInsurance->getDefault();

$carrierMin = $min ? $min->getAmount() : 0;
$carrierMax = $max ? $max->getAmount() : null;
$carrierDefault = $default ? $default->getAmount() : $carrierMin;

// No insurance set? We still need to respect the carrier's minimum insurance amount or the request will fail.
if (null === $amount || TriStateService::DISABLED === $amount) {
Expand All @@ -97,11 +104,32 @@ private function calculateInsurance(?int $amount): int
return $this->calculateFromSettings($carrier, $carrierMin, $carrierMax, $carrierDefault);
}

// Explicit amount: resolve to nearest valid tier, clamp to shipment range.
$allowedAmounts = InsuranceTierMath::buildTiers($carrierMin, $carrierMax);
return $this->resolveToTier($amount, $carrierMin, $carrierMax);
}

/**
* Resolve a requested amount to a value the carrier accepts.
*
* Snaps the amount up to the nearest tier in the carrier's range, then clamps it to that
* range. Without a carrier maximum there is no ladder to snap to, so the amount passes
* through with only the carrier minimum applied as a floor.
*
* @param int $amount
* @param int $min
* @param null|int $max
*
* @return int
*/
private function resolveToTier(int $amount, int $min, ?int $max): int
{
if (null === $max) {
return max($min, $amount);
}

$allowedAmounts = InsuranceTierMath::buildTiers($min, $max);
$validated = $this->getMinimumInsuranceAmount($allowedAmounts, $amount);

return $this->clampToCarrierRange($validated, $carrierMin, $carrierMax);
return $this->clampToCarrierRange($validated, $min, $max);
}

/**
Expand Down Expand Up @@ -144,15 +172,25 @@ private function fetchShipmentInsurance(Carrier $carrier): ?RefCapabilitiesRespo
/**
* Calculate insurance from carrier settings when the shipment option is set to INHERIT.
*
* With insurance switched off in the settings the carrier default applies. Otherwise the
* "insure up to" setting caps the calculated amount; it defaults to 0 when unset, which
* brings the result down to the carrier minimum.
*
* Without a carrier maximum the amount is not snapped to a tier.
*
* @param \MyParcelNL\Pdk\Carrier\Model\Carrier $carrier
* @param int $carrierMin
* @param int $carrierMax
* @param null|int $carrierMax
* @param int $carrierDefault
*
* @return int
*/
private function calculateFromSettings(Carrier $carrier, int $carrierMin, int $carrierMax, int $carrierDefault): int
{
private function calculateFromSettings(
Carrier $carrier,
int $carrierMin,
?int $carrierMax,
int $carrierDefault
): int {
$carrierSettings = CarrierSettings::fromCarrier($carrier);

if (! $carrierSettings->exportInsurance) {
Expand All @@ -171,8 +209,7 @@ private function calculateFromSettings(Carrier $carrier, int $carrierMin, int $c
return $carrierMin;
}

$allowedAmounts = InsuranceTierMath::buildTiers($carrierMin, $carrierMax);
$validated = $this->getMinimumInsuranceAmount($allowedAmounts, $orderAmount);
$validated = $this->resolveToTier($orderAmount, $carrierMin, $carrierMax);

$insuranceUpToKey = $this->getInsuranceUpToKey($this->order->shippingAddress->cc);
$maxInsuranceValue = $carrierSettings->getAttribute($insuranceUpToKey) ?? 0;
Expand All @@ -184,15 +221,19 @@ private function calculateFromSettings(Carrier $carrier, int $carrierMin, int $c
/**
* Clamp the given amount to the carrier's allowed insurance range.
*
* @param int $amount
* @param int $min
* @param int $max
* A null maximum means the carrier advertises no ceiling, so only the floor applies.
*
* @param int $amount
* @param int $min
* @param null|int $max
*
* @return int
*/
private function clampToCarrierRange(int $amount, int $min, int $max): int
private function clampToCarrierRange(int $amount, int $min, ?int $max): int
{
return max($min, min($amount, $max));
$floored = max($min, $amount);

return null === $max ? $floored : min($floored, $max);
}

/**
Expand Down
7 changes: 6 additions & 1 deletion src/Carrier/Service/CapabilitiesValidationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,16 @@ private function getEnabledCarrierNames(): array
{
$carrierSettings = Settings::get(CarrierSettings::ID) ?? [];

if (! is_array($carrierSettings)) {
return [];
}

return array_keys(
array_filter(
$carrierSettings,
static function ($settings): bool {
return ! empty($settings[CarrierSettings::DELIVERY_OPTIONS_ENABLED]);
return is_array($settings)
&& ! empty($settings[CarrierSettings::DELIVERY_OPTIONS_ENABLED]);
}
)
);
Expand Down
23 changes: 17 additions & 6 deletions src/Carrier/Service/CarrierValidationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use MyParcelNL\Pdk\App\Options\Definition\InsuranceDefinition;
use MyParcelNL\Pdk\Carrier\Model\Carrier;
use MyParcelNL\Pdk\Carrier\Util\InsuranceTierMath;
use MyParcelNL\Pdk\Facade\Logger;
use MyParcelNL\Sdk\Client\Generated\CoreApi\Model\RefShipmentPackageTypeV2;

/**
Expand Down Expand Up @@ -83,7 +84,8 @@ public function supportsDigitalStamp(Carrier $carrier): bool
/**
* Insurance tier ladder allowed for the carrier (cents).
*
* Returns an empty array when the carrier does not support insurance.
* Returns an empty array when the carrier does not support insurance
* or when it does not advertise any maximum amount.
Comment thread
FreekVR marked this conversation as resolved.
*
* @return int[]
*/
Expand All @@ -93,12 +95,21 @@ public function getAllowedInsuranceAmounts(Carrier $carrier): array
return [];
}

$insured = $carrier->options->getInsurance()->getInsuredAmount();
$insurance = $carrier->options->getInsurance();
$max = $insurance ? $insurance->getMax() : null;

return InsuranceTierMath::buildTiers(
$insured->getMin()->getAmount(),
$insured->getMax()->getAmount()
);
if (! $max) {
Logger::warning(
'Carrier advertises insurance without a maximum, so no insurance amounts can be offered',
['carrier' => $carrier->carrier]
);

return [];
}

$min = $insurance->getMin();

return InsuranceTierMath::buildTiers($min ? $min->getAmount() : 0, $max->getAmount());
}

/**
Expand Down
4 changes: 4 additions & 0 deletions src/Frontend/View/CarrierSettingsItemView.php
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,10 @@ private function getExportInsuranceFields(): array
{
$insuranceAmounts = $this->carrierValidationService->getAllowedInsuranceAmounts($this->carrier);

// The insurance fields are tier dropdowns built from this ladder, so there is nothing to
// render without one: no carrier maximum means no tiers, and a single tier means no choice.
// Carriers without a maximum do not occur in practice — CarrierValidationService logs a
// warning if one ever shows up.
if (count($insuranceAmounts) <= 1) {
return [];
}
Expand Down
42 changes: 40 additions & 2 deletions src/SdkApi/Service/CoreApi/Shipment/CapabilitiesService.php
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ public function getCapabilities(array $parameters, bool $filterSupported = false

/** @var CapabilitiesResponsesCapabilitiesV2 $response */
$response = $this->shipmentApi->postCapabilities($request, $this->getUserAgent());
$results = $response->getResults();
$results = $this->dropDeprecatedInsuranceShape($response->getResults());

return $filterSupported ? $this->filterSupportedCapabilities($results) : $results;
}
Expand Down Expand Up @@ -263,8 +263,46 @@ public function getContractDefinitions(?string $carrier, bool $filterSupported =
$request,
$this->getUserAgent()
);
$items = $response->getItems();
$items = $this->dropDeprecatedInsuranceShape($response->getItems());

return $filterSupported ? $this->filterSupportedCapabilities($items) : $items;
}

/**
* Remove the deprecated nested insurance wrapper so only the flat bounds survive.
*
* The API still returns `insured_amount` alongside the flat `min`/`max`/`default`. We drop
* it on the way in so nothing downstream can start depending on it again: stored carrier
* data and the payload handed to the admin end up flat-only, on the migration refresh and
* on every refresh after it.
*
* Uses the same trick as {@see stripUnregisteredOptions()}: `insured_amount` is declared
* non-nullable, and the serializer omits non-nullable nulls, so setting it to null makes
* the key disappear from `jsonSerialize()` entirely.
*
* @TODO: Remove this method and both call sites once INT-1696 has regenerated the SDK
* against the schema without the nested wrapper — there is nothing left to strip
* then, and the property no longer exists on the model.
*
* @param array<int, RefCapabilitiesResponseCapabilityV2|RefCapabilitiesContractDefinitionsResponseContractDefinitionsV2> $models
*
* @return array<int, RefCapabilitiesResponseCapabilityV2|RefCapabilitiesContractDefinitionsResponseContractDefinitionsV2>
*/
private function dropDeprecatedInsuranceShape(array $models): array
{
foreach ($models as $model) {
$options = $model->getOptions();

if (null !== $options) {
$insurance = $options->getInsurance();

if (null !== $insurance && null !== $insurance->getInsuredAmount()) {
// @phpstan-ignore argument.type
$insurance->offsetSet('insured_amount', null);
}
}
}

return $models;
}
}
14 changes: 11 additions & 3 deletions src/Settings/Repository/AbstractPdkSettingsRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -137,10 +137,18 @@ protected function updateSettingsFromCollection(
): Settings {
$category = $this->get($this->createSettingsKey($settingsId)) ?? [];

foreach ($category as $key => $item) {
$values = ['id' => $key] + $this->get($this->createSettingsKey("$settingsId.$key"));
if (! is_array($category)) {
$category = [];
}

foreach (array_keys($category) as $key) {
$values = $this->get($this->createSettingsKey("$settingsId.$key"));

if (! is_array($values)) {
continue;
}

$collection->offsetSet($key, $values);
$collection->offsetSet($key, ['id' => $key] + $values);
}

$settings->setAttribute($settingsId, $collection);
Expand Down
31 changes: 19 additions & 12 deletions tests/Bootstrap/MockCarrierCapabilitiesRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,10 @@ private function buildPermissiveCapabilities(string $carrierName): array
'sameDayDelivery' => $option,
'saturdayDelivery' => $option,
'tracked' => $option,
'insurance' => array_merge($option, [
'insuredAmount' => $this->resolveInsuredAmountFromCarrier($carrierName),
]),
'insurance' => array_merge(
$option,
$this->resolveInsuranceBoundsFromCarrier($carrierName)
),
'priorityDelivery' => $option,
'requiresReceiptCode' => $option,
'scheduledCollection' => $option,
Expand Down Expand Up @@ -154,12 +155,13 @@ private function buildPermissiveCapabilities(string $carrierName): array
* using `factory(Carrier::class)->withInsurance($default, $min, $max)` keep working
* after the calculator switched to per-shipment capability bounds.
*
* Falls back to a permissive 0–500000 range when the carrier or its insurance option
* is not configured.
* Returns the flat `default`/`min`/`max` keys the capabilities response uses, ready to
* merge into the insurance option. Falls back to a permissive 0–500000 range when the
* carrier or its insurance option is not configured.
*
* @return array<string, array<string, int|string>>
*/
private function resolveInsuredAmountFromCarrier(string $carrierName): array
private function resolveInsuranceBoundsFromCarrier(string $carrierName): array
{
try {
$shop = Pdk::get(AccountSettingsServiceInterface::class)->getShop();
Expand All @@ -170,15 +172,20 @@ private function resolveInsuredAmountFromCarrier(string $carrierName): array
})
: null;

$insured = $carrier && $carrier->options
? $carrier->options->getInsurance()->getInsuredAmount() // @phpstan-ignore-line SDK declares non-nullable but may be missing
$insurance = $carrier && $carrier->options
? $carrier->options->getInsurance() // @phpstan-ignore-line SDK declares non-nullable but may be missing
: null;

if ($insured) {
$min = $insurance ? $insurance->getMin() : null;
$max = $insurance ? $insurance->getMax() : null;

if ($min && $max) {
$default = $insurance->getDefault();

return [
'default' => ['currency' => 'EUR', 'amount' => $insured->getDefault()->getAmount()],
'min' => ['currency' => 'EUR', 'amount' => $insured->getMin()->getAmount()],
'max' => ['currency' => 'EUR', 'amount' => $insured->getMax()->getAmount()],
'default' => ['currency' => 'EUR', 'amount' => $default ? $default->getAmount() : 0],
'min' => ['currency' => 'EUR', 'amount' => $min->getAmount()],
'max' => ['currency' => 'EUR', 'amount' => $max->getAmount()],
];
}
} catch (Throwable $e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,15 @@ protected function getDefaultItems(): array
'transactionTypes' => ['B2C', 'B2B'],
'options' => [
'requiresAgeVerification' => ['isSelectedByDefault' => false, 'isRequired' => false],
// Insurance bounds appear twice, matching what the API sends today: the flat
// fields plus the deprecated nested wrapper. The PDK drops the nested one on
// the way in, so only the flat bounds should reach stored carrier data.
'insurance' => [
'isSelectedByDefault' => false,
'isRequired' => false,
'default' => ['amount' => 0, 'currency' => 'EUR'],
'max' => ['amount' => 500000, 'currency' => 'EUR'],
'min' => ['amount' => 0, 'currency' => 'EUR'],
'insuredAmount' => [
'default' => ['amount' => 0, 'currency' => 'EUR'],
'max' => ['amount' => 500000, 'currency' => 'EUR'],
Expand Down
Loading
Loading