Skip to content

Webshop - Shipping (Transsmart)

Overview

Shipping cost calculation is handled via Transsmart API for real-time carrier rates. The system includes a circuit breaker pattern for fallback when the API is unavailable.

Transsmart Client Library

// atraxion/transsmart-client library

namespace Atraxion\Transsmart;

class TranssmartClient
{
    public function __construct(
        private string $apiKey,
        private string $accountId,
        private HttpClientInterface $httpClient,
        private string $baseUrl = 'https://api.transsmart.com',
    ) {}

    public function calculateRates(ShippingRateRequest $request): array
    {
        $response = $this->httpClient->request('POST', "{$this->baseUrl}/v2/rates", [
            'json' => [
                'accountId' => $this->accountId,
                'shipment' => [
                    'reference' => $request->reference,
                    'sender' => $this->mapAddress($request->senderAddress),
                    'recipient' => $this->mapAddress($request->recipientAddress),
                    'packages' => array_map(fn($p) => [
                        'weight' => $p->weightKg,
                        'length' => $p->lengthCm,
                        'width' => $p->widthCm,
                        'height' => $p->heightCm,
                    ], $request->packages),
                ],
            ],
            'headers' => [
                'Authorization' => "Bearer {$this->apiKey}",
                'Content-Type' => 'application/json',
            ],
        ]);

        $data = $response->toArray();

        return array_map(fn($rate) => new ShippingRate(
            carrierId: $rate['carrierId'],
            carrierName: $rate['carrierName'],
            serviceId: $rate['serviceId'],
            serviceName: $rate['serviceName'],
            price: Money::EUR($rate['price']['amount']),
            currency: $rate['price']['currency'],
            estimatedDays: $rate['estimatedDeliveryDays'] ?? null,
        ), $data['rates']);
    }

    public function createShipment(CreateShipmentRequest $request): Shipment
    {
        $response = $this->httpClient->request('POST', "{$this->baseUrl}/v2/shipments", [
            'json' => [
                'accountId' => $this->accountId,
                'carrierId' => $request->carrierId,
                'serviceId' => $request->serviceId,
                'reference' => $request->reference,
                'sender' => $this->mapAddress($request->senderAddress),
                'recipient' => $this->mapAddress($request->recipientAddress),
                'packages' => $request->packages,
            ],
            'headers' => [
                'Authorization' => "Bearer {$this->apiKey}",
            ],
        ]);

        $data = $response->toArray();

        return new Shipment(
            shipmentId: $data['shipmentId'],
            trackingNumber: $data['trackingNumber'],
            trackingUrl: $data['trackingUrl'],
            labelUrl: $data['labelUrl'],
        );
    }

    private function mapAddress(Address $address): array
    {
        return [
            'name' => $address->name,
            'street' => $address->street,
            'houseNumber' => $address->number,
            'postalCode' => $address->postalCode,
            'city' => $address->city,
            'country' => $address->countryCode,
        ];
    }
}

class ShippingRateRequest
{
    public function __construct(
        public string $reference,
        public Address $senderAddress,
        public Address $recipientAddress,
        public array $packages,
    ) {}
}

class ShippingRate
{
    public function __construct(
        public string $carrierId,
        public string $carrierName,
        public string $serviceId,
        public string $serviceName,
        public Money $price,
        public string $currency,
        public ?int $estimatedDays,
    ) {}

    public function getId(): string
    {
        return "{$this->carrierId}_{$this->serviceId}";
    }
}

Shipping Service

class ShippingService
{
    public function __construct(
        private TranssmartClient $transsmartClient,
        private ShippingFallbackCalculator $fallbackCalculator,
        private CircuitBreaker $circuitBreaker,
        private CacheInterface $cache,
        private ShippingConfigRepository $configRepository,
    ) {}

    public function calculateOptions(Cart $cart, Address $deliveryAddress): array
    {
        // Get sender address (warehouse)
        $senderAddress = $this->configRepository->getWarehouseAddress();

        // Calculate total weight
        $totalWeight = $this->calculateTotalWeight($cart);

        // Build packages
        $packages = $this->buildPackages($cart);

        // Try to get rates from Transsmart
        if ($this->circuitBreaker->isAvailable('transsmart')) {
            try {
                $rates = $this->transsmartClient->calculateRates(new ShippingRateRequest(
                    reference: uniqid('RATE-'),
                    senderAddress: $senderAddress,
                    recipientAddress: $deliveryAddress,
                    packages: $packages,
                ));

                $this->circuitBreaker->recordSuccess('transsmart');

                return $this->filterAndSortRates($rates);
            } catch (TranssmartException $e) {
                $this->circuitBreaker->recordFailure('transsmart');
                $this->logger->warning('Transsmart API failed, using fallback', [
                    'error' => $e->getMessage(),
                ]);
            }
        }

        // Fallback to local calculation
        return $this->fallbackCalculator->calculate(
            $deliveryAddress->getCountryCode(),
            $totalWeight
        );
    }

    public function getPrice(string $shippingMethodId, Cart $cart, Address $deliveryAddress): Money
    {
        $options = $this->calculateOptions($cart, $deliveryAddress);

        foreach ($options as $option) {
            if ($option->getId() === $shippingMethodId) {
                return $option->price;
            }
        }

        throw new ShippingMethodNotFoundException($shippingMethodId);
    }

    private function calculateTotalWeight(Cart $cart): float
    {
        $weight = 0.0;

        foreach ($cart->getItems() as $item) {
            $weight += $item->getArticle()->getWeight() * $item->getQuantity();
        }

        return $weight;
    }

    private function buildPackages(Cart $cart): array
    {
        // Simple implementation: one package with total weight
        $totalWeight = $this->calculateTotalWeight($cart);

        return [
            new Package(
                weightKg: $totalWeight,
                lengthCm: 60, // Default dimensions
                widthCm: 40,
                heightCm: 40,
            ),
        ];
    }

    private function filterAndSortRates(array $rates): array
    {
        // Filter out unavailable services
        $rates = array_filter($rates, fn($rate) => $this->isServiceEnabled($rate));

        // Sort by price
        usort($rates, fn($a, $b) => $a->price->getAmount() <=> $b->price->getAmount());

        return $rates;
    }
}

Circuit Breaker

Prevents cascading failures when Transsmart is unavailable.

class CircuitBreaker
{
    private const FAILURE_THRESHOLD = 5;
    private const TIMEOUT_SECONDS = 300; // 5 minutes

    public function __construct(
        private CacheInterface $cache,
    ) {}

    public function isAvailable(string $service): bool
    {
        $state = $this->getState($service);

        if ($state['status'] === 'open') {
            // Check if timeout has passed
            if (time() > $state['openedAt'] + self::TIMEOUT_SECONDS) {
                // Move to half-open state (allow one request)
                $this->setState($service, [
                    'status' => 'half-open',
                    'failures' => $state['failures'],
                    'openedAt' => $state['openedAt'],
                ]);
                return true;
            }
            return false;
        }

        return true;
    }

    public function recordSuccess(string $service): void
    {
        $this->setState($service, [
            'status' => 'closed',
            'failures' => 0,
            'openedAt' => null,
        ]);
    }

    public function recordFailure(string $service): void
    {
        $state = $this->getState($service);
        $failures = $state['failures'] + 1;

        if ($failures >= self::FAILURE_THRESHOLD) {
            $this->setState($service, [
                'status' => 'open',
                'failures' => $failures,
                'openedAt' => time(),
            ]);
        } else {
            $this->setState($service, [
                'status' => 'closed',
                'failures' => $failures,
                'openedAt' => null,
            ]);
        }
    }

    private function getState(string $service): array
    {
        return $this->cache->get("circuit_breaker_{$service}", fn() => [
            'status' => 'closed',
            'failures' => 0,
            'openedAt' => null,
        ]);
    }

    private function setState(string $service, array $state): void
    {
        $this->cache->set("circuit_breaker_{$service}", $state, 3600);
    }
}

Fallback Calculator

Local shipping cost calculation when Transsmart is unavailable.

class ShippingFallbackCalculator
{
    public function __construct(
        private ShippingZoneRepository $zoneRepository,
        private WeightTierRepository $weightTierRepository,
    ) {}

    public function calculate(string $countryCode, float $weightKg): array
    {
        // Get shipping zone for country
        $zone = $this->zoneRepository->findByCountry($countryCode);

        if (!$zone) {
            throw new ShippingNotAvailableException($countryCode);
        }

        // Get weight tier
        $tier = $this->weightTierRepository->findByWeight($zone, $weightKg);

        if (!$tier) {
            throw new WeightExceedsLimitException($weightKg);
        }

        return [
            new ShippingRate(
                carrierId: 'fallback',
                carrierName: 'Standard Shipping',
                serviceId: 'standard',
                serviceName: 'Standard Delivery',
                price: Money::EUR($tier->getPrice()),
                currency: 'EUR',
                estimatedDays: $zone->getEstimatedDays(),
            ),
        ];
    }
}

Weight-Based Pricing Configuration

#[ORM\Entity]
class ShippingZone
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private int $id;

    #[ORM\Column(length: 50)]
    private string $name;

    #[ORM\Column(type: 'json')]
    private array $countries; // ['BE', 'NL', 'LU']

    #[ORM\Column]
    private int $estimatedDays;

    #[ORM\OneToMany(mappedBy: 'zone', targetEntity: ShippingWeightTier::class)]
    private Collection $weightTiers;
}

#[ORM\Entity]
class ShippingWeightTier
{
    #[ORM\ManyToOne(targetEntity: ShippingZone::class)]
    private ShippingZone $zone;

    #[ORM\Column(type: 'decimal', precision: 8, scale: 2)]
    private string $minWeight; // kg

    #[ORM\Column(type: 'decimal', precision: 8, scale: 2)]
    private string $maxWeight; // kg

    #[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
    private string $price;
}

AJAX Shipping Calculation

For dynamic shipping cost updates during checkout.

class CheckoutShippingController
{
    #[Route('/checkout/shipping/calculate', name: 'checkout_shipping_calculate', methods: ['POST'])]
    public function calculate(Request $request): JsonResponse
    {
        $cart = $this->cartService->getCart($request, $this->tenant);

        // Get address from request
        $addressData = $request->request->all();

        // Check if address actually changed (optimization)
        $previousHash = $request->getSession()->get('shipping_address_hash');
        $currentHash = md5(json_encode($addressData));

        if ($previousHash === $currentHash) {
            // Return cached result
            $cachedOptions = $request->getSession()->get('shipping_options');
            if ($cachedOptions) {
                return $this->json($cachedOptions);
            }
        }

        // Build address
        $address = new Address(
            street: $addressData['street'],
            number: $addressData['number'],
            postalCode: $addressData['postal_code'],
            city: $addressData['city'],
            countryCode: $addressData['country'],
        );

        // Calculate options
        $options = $this->shippingService->calculateOptions($cart, $address);

        $response = [
            'options' => array_map(fn($opt) => [
                'id' => $opt->getId(),
                'carrier' => $opt->carrierName,
                'service' => $opt->serviceName,
                'price' => $opt->price->getAmount(),
                'priceFormatted' => $this->moneyFormatter->format($opt->price),
                'estimatedDays' => $opt->estimatedDays,
            ], $options),
        ];

        // Cache result
        $request->getSession()->set('shipping_address_hash', $currentHash);
        $request->getSession()->set('shipping_options', $response);

        return $this->json($response);
    }
}

JavaScript Integration

// Shipping calculation on checkout page
class ShippingCalculator {
    constructor() {
        this.previousValues = {};
        this.calculating = false;
        this.container = document.getElementById('shipping-options');
    }

    async calculate() {
        const address = this.getAddressValues();

        // Check if values changed
        if (this.hasValuesChanged(address)) {
            return;
        }

        this.previousValues = address;
        this.showLoading();

        try {
            const response = await fetch('/checkout/shipping/calculate', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                },
                body: new URLSearchParams(address),
            });

            const data = await response.json();
            this.renderOptions(data.options);
        } catch (error) {
            this.showError('Could not calculate shipping costs');
        }
    }

    getAddressValues() {
        return {
            street: document.getElementById('shipping_street').value,
            number: document.getElementById('shipping_number').value,
            postal_code: document.getElementById('shipping_postal_code').value,
            city: document.getElementById('shipping_city').value,
            country: document.getElementById('shipping_country').value,
        };
    }

    hasValuesChanged(newValues) {
        return JSON.stringify(newValues) === JSON.stringify(this.previousValues);
    }

    showLoading() {
        // Clear container and add loading element
        while (this.container.firstChild) {
            this.container.removeChild(this.container.firstChild);
        }
        const loading = document.createElement('div');
        loading.className = 'loading';
        loading.textContent = 'Calculating shipping costs...';
        this.container.appendChild(loading);
    }

    showError(message) {
        while (this.container.firstChild) {
            this.container.removeChild(this.container.firstChild);
        }
        const error = document.createElement('div');
        error.className = 'error';
        error.textContent = message;
        this.container.appendChild(error);
    }

    renderOptions(options) {
        // Clear container safely
        while (this.container.firstChild) {
            this.container.removeChild(this.container.firstChild);
        }

        // Build options using safe DOM methods
        options.forEach(opt => {
            const label = document.createElement('label');
            label.className = 'shipping-option';

            const input = document.createElement('input');
            input.type = 'radio';
            input.name = 'shipping_method';
            input.value = opt.id;

            const carrier = document.createElement('span');
            carrier.className = 'carrier';
            carrier.textContent = opt.carrier;

            const service = document.createElement('span');
            service.className = 'service';
            service.textContent = opt.service;

            const price = document.createElement('span');
            price.className = 'price';
            price.textContent = opt.priceFormatted;

            label.appendChild(input);
            label.appendChild(carrier);
            label.appendChild(service);
            label.appendChild(price);

            if (opt.estimatedDays) {
                const estimate = document.createElement('span');
                estimate.className = 'estimate';
                estimate.textContent = opt.estimatedDays + ' days';
                label.appendChild(estimate);
            }

            this.container.appendChild(label);
        });
    }
}

// Initialize
const shippingCalculator = new ShippingCalculator();

// Recalculate when address changes
document.querySelectorAll('.shipping-address-field').forEach(field => {
    field.addEventListener('change', () => shippingCalculator.calculate());
});

Gherkin Scenarios

Feature: Shipping Calculation
  As a customer
  I want to see shipping options and costs
  So that I can choose how to receive my products

  Scenario: Calculate shipping via Transsmart
    Given I have products in my cart
    And Transsmart API is available
    When I enter my delivery address in Belgium
    Then I should see multiple shipping options
    And each option should show carrier, service, and price

  Scenario: Transsmart unavailable - fallback
    Given I have products in my cart
    And Transsmart API is unavailable
    When I enter my delivery address
    Then I should see shipping options from fallback calculator
    And I should not see an error message

  Scenario: Circuit breaker opens after failures
    Given Transsmart has failed 5 times in a row
    When I calculate shipping
    Then the system should use fallback calculator
    And not try Transsmart for 5 minutes

  Scenario: Circuit breaker recovery
    Given the circuit breaker is open
    And 5 minutes have passed
    When I calculate shipping
    Then the system should try Transsmart again

  Scenario: Weight-based pricing fallback
    Given I have 50kg of products
    And I'm shipping to Belgium
    When shipping is calculated via fallback
    Then the price should match the weight tier for 50kg

  Scenario: Shipping recalculation on address change
    Given I'm on checkout step 2
    And shipping has been calculated
    When I change my postal code
    Then shipping options should be recalculated

  Scenario: Shipping not available for country
    Given I have products in my cart
    When I enter a delivery address in an unsupported country
    Then I should see "Shipping not available to this country"

Database Schema

-- Shipping zones
CREATE TABLE shipping_zones (
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(50) NOT NULL,
    countries JSON NOT NULL,
    estimated_days INT NOT NULL,
    is_active BOOLEAN DEFAULT TRUE
);

-- Weight tiers per zone
CREATE TABLE shipping_weight_tiers (
    id INT PRIMARY KEY AUTO_INCREMENT,
    zone_id INT NOT NULL,
    min_weight DECIMAL(8,2) NOT NULL,
    max_weight DECIMAL(8,2) NOT NULL,
    price DECIMAL(10,2) NOT NULL,
    FOREIGN KEY (zone_id) REFERENCES shipping_zones(id),
    INDEX idx_zone_weight (zone_id, min_weight, max_weight)
);

-- Carrier configurations
CREATE TABLE shipping_carriers (
    id INT PRIMARY KEY AUTO_INCREMENT,
    transsmart_carrier_id VARCHAR(50),
    name VARCHAR(100) NOT NULL,
    is_active BOOLEAN DEFAULT TRUE,
    sort_order INT DEFAULT 0
);

-- Service configurations
CREATE TABLE shipping_services (
    id INT PRIMARY KEY AUTO_INCREMENT,
    carrier_id INT NOT NULL,
    transsmart_service_id VARCHAR(50),
    name VARCHAR(100) NOT NULL,
    is_active BOOLEAN DEFAULT TRUE,
    FOREIGN KEY (carrier_id) REFERENCES shipping_carriers(id)
);