Skip to content

Webshop - Cart & Checkout

Overview

The shopping cart and checkout flow allows customers to collect products and complete purchases. The checkout is a 3-step process: Cart Review → Shipping & Delivery → Payment & Confirmation.

Cart

Cart Entity

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

    #[ORM\ManyToOne(targetEntity: Customer::class)]
    private ?Customer $customer; // null for anonymous carts

    #[ORM\Column(length: 64, nullable: true)]
    private ?string $sessionId; // for anonymous carts

    #[ORM\ManyToOne(targetEntity: Tenant::class)]
    private Tenant $tenant;

    #[ORM\OneToMany(mappedBy: 'cart', targetEntity: CartItem::class, cascade: ['persist', 'remove'])]
    private Collection $items;

    #[ORM\Column(type: 'datetime_immutable')]
    private \DateTimeImmutable $createdAt;

    #[ORM\Column(type: 'datetime_immutable')]
    private \DateTimeImmutable $updatedAt;

    public function addItem(Article $article, int $quantity): CartItem
    {
        // Check if article already in cart
        foreach ($this->items as $item) {
            if ($item->getArticle()->getArticleNumber() === $article->getArticleNumber()) {
                $item->setQuantity($item->getQuantity() + $quantity);
                return $item;
            }
        }

        // Add new item
        $item = new CartItem();
        $item->setCart($this);
        $item->setArticle($article);
        $item->setQuantity($quantity);
        $this->items->add($item);

        return $item;
    }

    public function removeItem(CartItem $item): void
    {
        $this->items->removeElement($item);
    }

    public function clear(): void
    {
        $this->items->clear();
    }

    public function isEmpty(): bool
    {
        return $this->items->isEmpty();
    }

    public function getItemCount(): int
    {
        return array_sum($this->items->map(fn($item) => $item->getQuantity())->toArray());
    }
}

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

    #[ORM\ManyToOne(targetEntity: Cart::class, inversedBy: 'items')]
    private Cart $cart;

    #[ORM\ManyToOne(targetEntity: Article::class)]
    private Article $article;

    #[ORM\Column]
    private int $quantity;

    #[ORM\Column(type: 'datetime_immutable')]
    private \DateTimeImmutable $addedAt;
}

Cart Service

class CartService
{
    public function getCart(Request $request, Tenant $tenant): Cart
    {
        $customer = $this->security->getUser()?->getCustomer();

        if ($customer) {
            // Logged in: get or create customer cart
            $cart = $this->cartRepository->findOneBy([
                'customer' => $customer,
                'tenant' => $tenant,
            ]);

            if (!$cart) {
                $cart = new Cart();
                $cart->setCustomer($customer);
                $cart->setTenant($tenant);
            }

            // Merge any anonymous cart
            $this->mergeAnonymousCart($request, $cart);

            return $cart;
        }

        // Anonymous: get or create session cart
        $sessionId = $request->getSession()->getId();

        $cart = $this->cartRepository->findOneBy([
            'sessionId' => $sessionId,
            'tenant' => $tenant,
        ]);

        if (!$cart) {
            $cart = new Cart();
            $cart->setSessionId($sessionId);
            $cart->setTenant($tenant);
        }

        return $cart;
    }

    private function mergeAnonymousCart(Request $request, Cart $customerCart): void
    {
        $sessionId = $request->getSession()->getId();

        $anonymousCart = $this->cartRepository->findOneBy([
            'sessionId' => $sessionId,
            'tenant' => $customerCart->getTenant(),
        ]);

        if (!$anonymousCart || $anonymousCart->isEmpty()) {
            return;
        }

        // Merge items
        foreach ($anonymousCart->getItems() as $item) {
            $customerCart->addItem($item->getArticle(), $item->getQuantity());
        }

        // Delete anonymous cart
        $this->entityManager->remove($anonymousCart);
    }

    public function addToCart(Cart $cart, Article $article, int $quantity): CartItem
    {
        // Validate stock
        $availableStock = $this->stockService->getAvailableStock($article);
        if ($availableStock < $quantity) {
            throw new InsufficientStockException($article, $availableStock);
        }

        $item = $cart->addItem($article, $quantity);
        $cart->setUpdatedAt(new \DateTimeImmutable());

        $this->entityManager->persist($cart);
        $this->entityManager->flush();

        return $item;
    }

    public function updateQuantity(CartItem $item, int $quantity): void
    {
        if ($quantity <= 0) {
            $this->removeItem($item);
            return;
        }

        // Validate stock
        $availableStock = $this->stockService->getAvailableStock($item->getArticle());
        if ($availableStock < $quantity) {
            throw new InsufficientStockException($item->getArticle(), $availableStock);
        }

        $item->setQuantity($quantity);
        $item->getCart()->setUpdatedAt(new \DateTimeImmutable());

        $this->entityManager->flush();
    }

    public function removeItem(CartItem $item): void
    {
        $cart = $item->getCart();
        $cart->removeItem($item);
        $cart->setUpdatedAt(new \DateTimeImmutable());

        $this->entityManager->remove($item);
        $this->entityManager->flush();
    }
}

Cart Price Calculation

class CartPriceCalculator
{
    public function calculate(Cart $cart, ?Customer $customer = null): CartPriceResult
    {
        $tenant = $cart->getTenant();
        $subtotal = Money::EUR(0);
        $itemPrices = [];

        foreach ($cart->getItems() as $item) {
            $unitPrice = $this->priceCalculator->calculatePrice(
                $item->getArticle(),
                $tenant,
                $customer
            );

            $lineTotal = $unitPrice->multiply($item->getQuantity());

            $itemPrices[$item->getId()] = new CartItemPrice(
                item: $item,
                unitPrice: $unitPrice,
                lineTotal: $lineTotal,
            );

            $subtotal = $subtotal->add($lineTotal);
        }

        return new CartPriceResult(
            items: $itemPrices,
            subtotal: $subtotal,
        );
    }
}

Checkout Flow

Step 1: Cart Review

Display cart contents with prices and allow quantity adjustments.

#[Route('/cart', name: 'cart_view')]
class CartController
{
    public function view(Request $request): Response
    {
        $cart = $this->cartService->getCart($request, $this->tenant);
        $customer = $this->security->getUser()?->getCustomer();

        $priceResult = $this->cartPriceCalculator->calculate($cart, $customer);

        return $this->render('cart/view.html.twig', [
            'cart' => $cart,
            'prices' => $priceResult,
        ]);
    }
}

Step 2: Shipping & Delivery

Select delivery address and shipping method.

class CheckoutStep2Controller
{
    #[Route('/checkout/shipping', name: 'checkout_shipping')]
    public function shipping(Request $request): Response
    {
        $cart = $this->cartService->getCart($request, $this->tenant);
        $customer = $this->security->getUser()->getCustomer();

        // Get available addresses
        $addresses = $customer->getDeliveryAddresses();

        // Calculate shipping options (via Transsmart)
        $shippingOptions = $this->shippingService->calculateOptions(
            $cart,
            $customer->getBillingAddress()
        );

        return $this->render('checkout/shipping.html.twig', [
            'cart' => $cart,
            'addresses' => $addresses,
            'shippingOptions' => $shippingOptions,
        ]);
    }

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

        if ($addressId === 'billing') {
            $address = $this->security->getUser()->getCustomer()->getBillingAddress();
        } else {
            $address = $this->addressRepository->find($addressId);
        }

        $shippingOptions = $this->shippingService->calculateOptions($cart, $address);

        return $this->json([
            'options' => array_map(fn($opt) => [
                'id' => $opt->getId(),
                'name' => $opt->getName(),
                'price' => $opt->getPrice()->getAmount(),
                'estimatedDays' => $opt->getEstimatedDays(),
            ], $shippingOptions),
        ]);
    }
}

Delivery Options

enum DeliveryMode: string
{
    case BILLING_ADDRESS = 'billing';      // Deliver to billing address
    case DELIVERY_ADDRESS = 'delivery';    // Deliver to selected delivery address
    case DROPSHIP = 'dropship';            // Neutral delivery to customer's customer
    case PICKUP = 'pickup';                // Customer picks up
}

class CheckoutDeliveryData
{
    public DeliveryMode $mode;
    public ?Address $deliveryAddress;
    public ?string $shippingMethodId;
    public ?DropshipData $dropship;
}

class DropshipData
{
    public string $companyName;
    public Address $address;
    public bool $neutralPackaging = true; // No Atraxion branding
}

Step 3: Payment & Confirmation

Select payment method and confirm order.

class CheckoutStep3Controller
{
    #[Route('/checkout/confirm', name: 'checkout_confirm')]
    public function confirm(Request $request): Response
    {
        $cart = $this->cartService->getCart($request, $this->tenant);
        $customer = $this->security->getUser()->getCustomer();

        // Get checkout data from session
        $checkoutData = $this->checkoutSession->getData();

        // Get available payment methods
        $paymentMethods = $this->paymentService->getAvailableMethods($customer);

        // Calculate final totals
        $totals = $this->orderTotalCalculator->calculate(
            $cart,
            $customer,
            $checkoutData->shippingMethodId,
            $checkoutData->deliveryAddress
        );

        return $this->render('checkout/confirm.html.twig', [
            'cart' => $cart,
            'checkoutData' => $checkoutData,
            'paymentMethods' => $paymentMethods,
            'totals' => $totals,
        ]);
    }

    #[Route('/checkout/place-order', name: 'checkout_place_order', methods: ['POST'])]
    public function placeOrder(Request $request): Response
    {
        $cart = $this->cartService->getCart($request, $this->tenant);
        $customer = $this->security->getUser()->getCustomer();
        $checkoutData = $this->checkoutSession->getData();

        // Validate stock one more time
        $this->stockValidator->validate($cart);

        // Create order
        $order = $this->orderCreator->create(
            $cart,
            $customer,
            $checkoutData
        );

        // Clear cart
        $this->cartService->clear($cart);

        // Initiate payment
        $paymentMethod = $request->request->get('payment_method');

        if ($this->paymentService->requiresRedirect($paymentMethod)) {
            // Redirect to payment gateway
            $redirectUrl = $this->paymentService->initiatePayment($order, $paymentMethod);
            return $this->redirect($redirectUrl);
        }

        // For on-account payment (B2B)
        return $this->redirectToRoute('checkout_success', ['orderId' => $order->getId()]);
    }
}

Order Total Calculation

class OrderTotalCalculator
{
    public function calculate(
        Cart $cart,
        Customer $customer,
        string $shippingMethodId,
        Address $deliveryAddress
    ): OrderTotals {
        // Product subtotal
        $cartPrices = $this->cartPriceCalculator->calculate($cart, $customer);
        $subtotal = $cartPrices->getSubtotal();

        // Shipping cost
        $shippingCost = $this->shippingService->getPrice(
            $shippingMethodId,
            $cart,
            $deliveryAddress
        );

        // Apply coupons
        $couponDiscount = Money::EUR(0);
        $appliedCoupons = $this->checkoutSession->getAppliedCoupons();
        foreach ($appliedCoupons as $coupon) {
            $discount = $this->couponService->calculateDiscount($coupon, $subtotal);
            $couponDiscount = $couponDiscount->add($discount);
        }

        // Calculate VAT
        $netTotal = $subtotal->add($shippingCost)->subtract($couponDiscount);
        $vatRate = $this->vatService->getRate($customer, $deliveryAddress);
        $vatAmount = $netTotal->multiply($vatRate / 100);

        // Gross total
        $grossTotal = $netTotal->add($vatAmount);

        return new OrderTotals(
            subtotal: $subtotal,
            shippingCost: $shippingCost,
            couponDiscount: $couponDiscount,
            netTotal: $netTotal,
            vatRate: $vatRate,
            vatAmount: $vatAmount,
            grossTotal: $grossTotal,
        );
    }
}

Coupon Application

class CouponService
{
    public function applyCoupon(string $code, Cart $cart, Customer $customer): CouponResult
    {
        $coupon = $this->couponRepository->findByCode($code);

        if (!$coupon) {
            return CouponResult::invalid('Coupon not found');
        }

        // Validate coupon
        $validation = $this->couponValidator->validate($coupon, $cart, $customer);
        if (!$validation->isValid()) {
            return CouponResult::invalid($validation->getError());
        }

        // Store in session
        $this->checkoutSession->addCoupon($coupon);

        return CouponResult::success($coupon);
    }

    public function calculateDiscount(Coupon $coupon, Money $subtotal): Money
    {
        return match ($coupon->getDiscountType()) {
            'percentage' => $subtotal->multiply($coupon->getDiscountValue() / 100),
            'fixed' => Money::EUR($coupon->getDiscountValue()),
            default => Money::EUR(0),
        };
    }
}

Stock Validation

class StockValidator
{
    public function validate(Cart $cart): void
    {
        $errors = [];

        foreach ($cart->getItems() as $item) {
            $availableStock = $this->stockService->getAvailableStock($item->getArticle());

            if ($availableStock < $item->getQuantity()) {
                $errors[] = new StockValidationError(
                    article: $item->getArticle(),
                    requested: $item->getQuantity(),
                    available: $availableStock,
                );
            }
        }

        if (!empty($errors)) {
            throw new InsufficientStockException($errors);
        }
    }
}

Gherkin Scenarios

Feature: Shopping Cart
  As a customer
  I want to manage my shopping cart
  So that I can purchase products

  Scenario: Add product to empty cart
    Given I am logged in as a customer
    And my cart is empty
    When I add product "TYRE-001" with quantity 4
    Then my cart should contain 1 item
    And the item quantity should be 4

  Scenario: Increase quantity of existing product
    Given my cart contains product "TYRE-001" with quantity 2
    When I add product "TYRE-001" with quantity 2
    Then my cart should contain 1 item
    And the item quantity should be 4

  Scenario: Cannot add more than available stock
    Given product "WHEEL-001" has 3 in stock
    When I try to add product "WHEEL-001" with quantity 5
    Then I should see an error "Only 3 available"

  Scenario: Anonymous cart merged on login
    Given I have an anonymous cart with product "TYRE-001"
    When I log in
    Then my customer cart should contain product "TYRE-001"

Feature: Checkout Flow
  As a customer
  I want to complete checkout
  So that I can receive my products

  Scenario: Checkout step 1 - Cart review
    Given I have products in my cart
    When I go to checkout
    Then I should see my cart contents
    And I should see the subtotal

  Scenario: Checkout step 2 - Select shipping
    Given I am on checkout step 2
    When I select my billing address for delivery
    Then I should see available shipping options
    And each option should show a price

  Scenario: Checkout step 2 - Dropship delivery
    Given I am on checkout step 2
    When I select dropship delivery
    And I enter a dropship address
    Then shipping should be recalculated for the dropship address

  Scenario: Checkout step 3 - Online payment
    Given I am on checkout step 3
    And I select payment method "Bancontact"
    When I confirm my order
    Then I should be redirected to Pay.nl
    And an order should be created with status "pending_payment"

  Scenario: Checkout step 3 - On account payment (B2B)
    Given I am a B2B customer with payment terms
    And I am on checkout step 3
    And I select payment method "On Account"
    When I confirm my order
    Then an order should be created with status "confirmed"
    And I should see the order confirmation page

  Scenario: Apply coupon code
    Given I have products totaling €200 in my cart
    And a coupon "SAVE10" exists for 10% discount
    When I apply coupon code "SAVE10"
    Then my order total should show a €20 discount

  Scenario: Stock check at order placement
    Given I have product "WHEEL-001" quantity 2 in my cart
    And product "WHEEL-001" now has only 1 in stock
    When I try to place my order
    Then I should see an error about insufficient stock
    And I should be redirected back to my cart

Database Schema

-- Shopping carts
CREATE TABLE carts (
    id INT PRIMARY KEY AUTO_INCREMENT,
    customer_id INT,
    session_id VARCHAR(64),
    tenant_id INT NOT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    FOREIGN KEY (customer_id) REFERENCES customers(id),
    FOREIGN KEY (tenant_id) REFERENCES tenants(id),
    INDEX idx_customer (customer_id),
    INDEX idx_session (session_id)
);

-- Cart items
CREATE TABLE cart_items (
    id INT PRIMARY KEY AUTO_INCREMENT,
    cart_id INT NOT NULL,
    article_number VARCHAR(50) NOT NULL,
    quantity INT NOT NULL,
    added_at DATETIME NOT NULL,
    FOREIGN KEY (cart_id) REFERENCES carts(id) ON DELETE CASCADE,
    FOREIGN KEY (article_number) REFERENCES articles(article_number),
    UNIQUE KEY (cart_id, article_number)
);

-- Checkout sessions (temporary data during checkout)
CREATE TABLE checkout_sessions (
    id INT PRIMARY KEY AUTO_INCREMENT,
    cart_id INT NOT NULL,
    delivery_mode VARCHAR(20),
    delivery_address_id INT,
    dropship_company VARCHAR(100),
    dropship_street VARCHAR(255),
    dropship_number VARCHAR(10),
    dropship_postal_code VARCHAR(10),
    dropship_city VARCHAR(100),
    dropship_country_code CHAR(2),
    shipping_method_id VARCHAR(50),
    shipping_cost DECIMAL(10,2),
    applied_coupons JSON,
    created_at DATETIME NOT NULL,
    expires_at DATETIME NOT NULL,
    FOREIGN KEY (cart_id) REFERENCES carts(id) ON DELETE CASCADE
);