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)
);