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