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
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,20 @@
namespace MyParcelNL\Pdk\App\DeliveryOptions\Contract;

use MyParcelNL\Pdk\App\Cart\Model\PdkCart;
use MyParcelNL\Pdk\Shipment\Collection\ShipmentOptionsCollection;

interface DeliveryOptionsServiceInterface
{
/**
* Creates an array with the packageType and carrierSettings key of the delivery options config.
*/
public function createAllCarrierSettings(PdkCart $cart): array;

/**
* Calculate, per carrier, the shipment options this cart would be exported with — the same
* settings chain and capabilities rules the real export runs — so the checkout can show and
* lock options that are already decided on the merchant side (for example 18+ forcing
* signature and only recipient on). The collection is keyed by legacy carrier identifier.
*/
public function createCartShipmentOptions(PdkCart $cart): ShipmentOptionsCollection;
}
125 changes: 117 additions & 8 deletions src/App/DeliveryOptions/Service/DeliveryOptionsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,32 @@
use MyParcelNL\Pdk\App\Cart\Contract\CartCalculationServiceInterface;
use MyParcelNL\Pdk\App\Cart\Model\PdkCart;
use MyParcelNL\Pdk\App\DeliveryOptions\Contract\DeliveryOptionsServiceInterface;
use MyParcelNL\Pdk\App\Order\Contract\PdkOrderOptionsServiceInterface;
use MyParcelNL\Pdk\App\Order\Model\PdkOrder;
use MyParcelNL\Pdk\App\Tax\Contract\TaxServiceInterface;
use MyParcelNL\Pdk\Base\Contract\CountryServiceInterface;
use MyParcelNL\Pdk\Base\Contract\CurrencyServiceInterface;
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;
use MyParcelNL\Pdk\Facade\FrontendData;
use MyParcelNL\Pdk\Facade\Logger;
use MyParcelNL\Pdk\Facade\Pdk;
use MyParcelNL\Pdk\Facade\Settings;
use MyParcelNL\Pdk\Settings\Model\CarrierSettings;
use MyParcelNL\Pdk\Settings\Model\CheckoutSettings;
use MyParcelNL\Pdk\Shipment\Collection\ShipmentOptionsCollection;
use MyParcelNL\Pdk\Shipment\Contract\DropOffServiceInterface;
use MyParcelNL\Pdk\Shipment\Model\DeliveryOptions;
use MyParcelNL\Pdk\Shipment\Model\ShipmentOptions;
use MyParcelNL\Sdk\Client\Generated\CoreApi\Model\RefShipmentPackageTypeV2;
use MyParcelNL\Sdk\Client\Generated\CoreApi\Model\RefTypesDeliveryTypeV2;
use MyParcelNL\Sdk\Support\Str;
use Throwable;

class DeliveryOptionsService implements DeliveryOptionsServiceInterface
{
Expand Down Expand Up @@ -62,19 +69,25 @@ class DeliveryOptionsService implements DeliveryOptionsServiceInterface
*/
private $dropOffService;

/**
* @var \MyParcelNL\Pdk\App\Order\Contract\PdkOrderOptionsServiceInterface
*/
private $orderOptionsService;

/**
* @var \MyParcelNL\Pdk\App\Tax\Contract\TaxServiceInterface
*/
private $taxService;

/**
* @param \MyParcelNL\Pdk\App\Cart\Contract\CartCalculationServiceInterface $cartCalculationService
* @param \MyParcelNL\Pdk\Carrier\Service\CapabilitiesValidationService $capabilitiesValidation
* @param \MyParcelNL\Pdk\Carrier\Contract\CarrierRepositoryInterface $carrierRepository
* @param \MyParcelNL\Pdk\Base\Contract\CountryServiceInterface $countryService
* @param \MyParcelNL\Pdk\Base\Contract\CurrencyServiceInterface $currencyService
* @param \MyParcelNL\Pdk\Shipment\Contract\DropOffServiceInterface $dropOffService
* @param \MyParcelNL\Pdk\App\Tax\Contract\TaxServiceInterface $taxService
* @param \MyParcelNL\Pdk\App\Cart\Contract\CartCalculationServiceInterface $cartCalculationService
* @param \MyParcelNL\Pdk\Carrier\Service\CapabilitiesValidationService $capabilitiesValidation
* @param \MyParcelNL\Pdk\Carrier\Contract\CarrierRepositoryInterface $carrierRepository
* @param \MyParcelNL\Pdk\Base\Contract\CountryServiceInterface $countryService
* @param \MyParcelNL\Pdk\Base\Contract\CurrencyServiceInterface $currencyService
* @param \MyParcelNL\Pdk\Shipment\Contract\DropOffServiceInterface $dropOffService
* @param \MyParcelNL\Pdk\App\Order\Contract\PdkOrderOptionsServiceInterface $orderOptionsService
* @param \MyParcelNL\Pdk\App\Tax\Contract\TaxServiceInterface $taxService
*/
public function __construct(
CartCalculationServiceInterface $cartCalculationService,
Expand All @@ -83,6 +96,7 @@ public function __construct(
CountryServiceInterface $countryService,
CurrencyServiceInterface $currencyService,
DropOffServiceInterface $dropOffService,
PdkOrderOptionsServiceInterface $orderOptionsService,
TaxServiceInterface $taxService
) {
$this->cartCalculationService = $cartCalculationService;
Expand All @@ -91,6 +105,7 @@ public function __construct(
$this->countryService = $countryService;
$this->currencyService = $currencyService;
$this->dropOffService = $dropOffService;
$this->orderOptionsService = $orderOptionsService;
$this->taxService = $taxService;
}

Expand Down Expand Up @@ -133,6 +148,90 @@ public function createAllCarrierSettings(PdkCart $cart): array
return $settings;
}

/**
* Calculate, per carrier, the shipment options this cart would be exported with, so the
* checkout can show and lock options that are already decided on the merchant side. Runs
* the same full calculation pipeline as an export (settings chain plus capabilities
* requires/excludes rules) on an order built from the cart.
*
* The result holds calculated ShipmentOptions models with tri-state values; converting
* them to the widget's boolean format happens at the CheckoutContext boundary
* ({@see \MyParcelNL\Pdk\Shipment\Model\ShipmentOptions::toBooleanOptions()}).
*
* @param \MyParcelNL\Pdk\App\Cart\Model\PdkCart $cart
*
* @return \MyParcelNL\Pdk\Shipment\Collection\ShipmentOptionsCollection keyed by legacy
* carrier identifier.
*/
public function createCartShipmentOptions(PdkCart $cart): ShipmentOptionsCollection
{
if (! $cart->shippingMethod->hasDeliveryOptions) {
return new ShipmentOptionsCollection();
}

[$packageType, $carriers] = $this->getValidCarrierOptions($cart);

$cartShipmentOptions = new ShipmentOptionsCollection();

foreach ($carriers as $carrier) {
Comment thread
GravendeelJochem marked this conversation as resolved.
if (null === $carrier->carrier) {
continue;
}

/*
* The config here is only passed to the JS Context for the delivery options.
* A failure should not be fatal: the delivery options degrades gracefully and will continue functioning without applying shipment option restrictions from the merchant.
*/
try {
// Use the legacy identifier, matching the carrierSettings keys in the config.
$identifier = FrontendData::getLegacyCarrierIdentifier($carrier->carrier);

$cartShipmentOptions->put($identifier, $this->calculateCartShipmentOptions($carrier, $cart, $packageType));
} catch (Throwable $e) {
Logger::error('An error occured when trying to calculate a pending carts shipment options for the delivery options', [
'carrier' => $carrier->carrier,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
}

return $cartShipmentOptions;
}

/**
* Build an order representing what this cart would ship with for the given carrier, run
* the full calculation pipeline on it, and return its calculated shipment options.
* Mirrors the per-carrier synthetic order the admin context uses for inherited delivery
* options ({@see \MyParcelNL\Pdk\Context\Model\OrderDataContext}).
*
* @param \MyParcelNL\Pdk\Carrier\Model\Carrier $carrier Carrier to calculate for.
* @param \MyParcelNL\Pdk\App\Cart\Model\PdkCart $cart Cart supplying the order
* lines (product settings,
* weight) and shipping address.
* @param string $packageType Package type name resolved
* for this cart, e.g. 'package'.
*
* @return \MyParcelNL\Pdk\Shipment\Model\ShipmentOptions
*/
private function calculateCartShipmentOptions(Carrier $carrier, PdkCart $cart, string $packageType): ShipmentOptions
{
$order = new PdkOrder([
'lines' => $cart->lines,
'shippingAddress' => $cart->shippingMethod->shippingAddress,
'deliveryOptions' => ['packageType' => $packageType],
// A cart has no order notes yet, and calculators may read them (the label
// description can include the customer note).
'notes' => [],
]);

$order->deliveryOptions->carrier = $carrier;

$calculatedOrder = $this->orderOptionsService->calculate($order);
Comment thread
FreekVR marked this conversation as resolved.

return $calculatedOrder->deliveryOptions->shipmentOptions;
}

/**
* Create the settings for a specific carrier based on the cart.
* @param \MyParcelNL\Pdk\Carrier\Model\Carrier $carrier
Expand Down Expand Up @@ -229,8 +328,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
7 changes: 7 additions & 0 deletions src/App/Order/Model/PdkOrder.php
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,13 @@ public function getNotesAttribute(): PdkOrderNoteCollection
return $this->getCastAttributeValue('notes');
}

// Notes belong to the order in the shop, looked up by its identifier. Orders that only
// exist in memory (like the one built from a cart) have none, and asking the shop for
// the notes of a null identifier throws — which broke the checkout once.
if (null === $this->externalIdentifier) {
return new PdkOrderNoteCollection();
}

/** @var \MyParcelNL\Pdk\App\Order\Contract\PdkOrderNoteRepositoryInterface $orderNoteRepository */
$orderNoteRepository = Pdk::get(PdkOrderNoteRepositoryInterface::class);

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
53 changes: 38 additions & 15 deletions src/Context/Model/CheckoutContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

use MyParcelNL\Pdk\App\Api\Contract\FrontendEndpointServiceInterface;
use MyParcelNL\Pdk\App\Cart\Model\PdkCart;
use MyParcelNL\Pdk\App\DeliveryOptions\Service\DeliveryOptionsService;
use MyParcelNL\Pdk\App\DeliveryOptions\Contract\DeliveryOptionsServiceInterface;
use MyParcelNL\Pdk\App\Request\Collection\EndpointRequestCollection;
use MyParcelNL\Pdk\Base\Model\Model;
use MyParcelNL\Pdk\Base\Support\Collection;
Expand All @@ -15,27 +15,37 @@
use MyParcelNL\Pdk\Facade\Pdk;
use MyParcelNL\Pdk\Facade\Settings;
use MyParcelNL\Pdk\Settings\Model\CheckoutSettings;
use MyParcelNL\Pdk\Shipment\Model\ShipmentOptions;
use MyParcelNL\Sdk\Support\Str;

/**
* @property null|DeliveryOptionsConfig $config
* @property array{string,string} $strings
* @property array $settings
* @property null|DeliveryOptionsConfig $config
* @property Collection<array<string, bool>> $cartShipmentOptions
* @property array{string,string} $strings
* @property array $settings
*/
class CheckoutContext extends Model
{
public $attributes = [
'config' => null,
'strings' => [],
'settings' => [],
'endpoints' => EndpointRequestCollection::class,
'config' => null,
/**
* The shipment options this cart would be exported with, calculated per carrier —
* cart state, deliberately next to `config` rather than inside it. The checkout
* widget uses it to show and lock options that are already decided on the merchant
* side (e.g. 18+ forcing signature and only recipient on).
*/
'cartShipmentOptions' => Collection::class,
'strings' => [],
'settings' => [],
'endpoints' => EndpointRequestCollection::class,
];

protected $casts = [
'config' => DeliveryOptionsConfig::class,
'strings' => 'array',
'settings' => 'array',
'endpoints' => EndpointRequestCollection::class,
'config' => DeliveryOptionsConfig::class,
'cartShipmentOptions' => Collection::class,
'strings' => 'array',
'settings' => 'array',
'endpoints' => EndpointRequestCollection::class,
];

/**
Expand All @@ -56,10 +66,23 @@ public function __construct(?array $data = null)
*/
public static function fromCart(PdkCart $cart): self
{
return new self([
/** @var \MyParcelNL\Pdk\App\DeliveryOptions\Contract\DeliveryOptionsServiceInterface $deliveryOptionsService */
$deliveryOptionsService = Pdk::get(DeliveryOptionsServiceInterface::class);

// The widget represents shipment options as booleans (its DeliveryOptionsOutput
// format), so the calculated models are converted at this wire boundary. Built as a
// plain array on purpose: mapping on the typed collection would cast the boolean
// arrays straight back into ShipmentOptions models.
$cartShipmentOptions = [];

'config' => DeliveryOptionsConfig::fromCart($cart),
'settings' => [
foreach ($deliveryOptionsService->createCartShipmentOptions($cart)->all() as $identifier => $shipmentOptions) {
$cartShipmentOptions[$identifier] = ShipmentOptions::toBooleanOptions($shipmentOptions);
}

return new self([
'config' => DeliveryOptionsConfig::fromCart($cart),
'cartShipmentOptions' => $cartShipmentOptions,
'settings' => [
'hasDeliveryOptions' => $cart->shippingMethod->hasDeliveryOptions,
Comment thread
FreekVR marked this conversation as resolved.
],
]);
Expand Down
12 changes: 9 additions & 3 deletions src/Frontend/View/CarrierSettingsItemView.php
Original file line number Diff line number Diff line change
Expand Up @@ -268,8 +268,11 @@ private function getDefaultExportFields(): array
$ageCheckElement = (new InteractiveElement($ageCheckDefinition->getCarrierSettingsKey(), Components::INPUT_TOGGLE))
->builder(function (FormOperationBuilder $builder) use ($signatureKey, $onlyRecipientKey) {
$builder->afterUpdate(function (FormAfterUpdateBuilder $afterUpdate) use ($signatureKey, $onlyRecipientKey) {
// Toggle settings are stored as TriState integers (-1/0/1); operands
// must match so the frontend's strict `$eq` check succeeds.
// The admin form normalizes toggle values to TriState ints (1/0/-1)
// at the component boundary, so the strict `$eq` checks below compare
// int against int. Turning age check OFF deliberately leaves signature
// and only recipient stored as enabled — they just unlock again, so
// what the merchant sees is exactly what is stored.
if ($signatureKey) {
$afterUpdate->setValue(TriStateService::ENABLED)->on($signatureKey)->if->eq(TriStateService::ENABLED);
}
Expand Down Expand Up @@ -303,7 +306,10 @@ function (FormOperationBuilder $builder) use ($ageCheckDefinition) {
if (! $ageCheckDefinition || ! $this->carrierValidationService->supportsShipmentOption($this->carrier, $ageCheckDefinition)) {
return;
}
$builder->readOnlyWhen($ageCheckDefinition->getCarrierSettingsKey());
// Lock only while age check is explicitly enabled. A bare (truthy) check
// would also match INHERIT (-1) and lock the fields while age check was
// never turned on.
$builder->readOnlyWhen($ageCheckDefinition->getCarrierSettingsKey(), TriStateService::ENABLED);
},
$signatureElements,
$onlyRecipientElements
Expand Down
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
16 changes: 16 additions & 0 deletions src/Shipment/Collection/ShipmentOptionsCollection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace MyParcelNL\Pdk\Shipment\Collection;

use MyParcelNL\Pdk\Base\Support\Collection;
use MyParcelNL\Pdk\Shipment\Model\ShipmentOptions;

/**
* @property \MyParcelNL\Pdk\Shipment\Model\ShipmentOptions[] $items
*/
class ShipmentOptionsCollection extends Collection
{
protected $cast = ShipmentOptions::class;
}
Loading
Loading