Skip to content

Webshop - Orders

Overview

Order management handles the complete lifecycle from order creation through fulfillment. Orders are synced to the ERP (Odoo) for processing, and invoices are generated in the ERP.

Order Entity

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

    #[ORM\Column(length: 20, unique: true)]
    private string $orderNumber; // e.g., "ORD-2024-00001"

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

    #[ORM\ManyToOne(targetEntity: Customer::class)]
    private Customer $customer;

    #[ORM\Column(length: 20)]
    private string $status;

    #[ORM\Column(length: 20)]
    private string $paymentStatus;

    #[ORM\Column(length: 50, nullable: true)]
    private ?string $paymentMethod;

    #[ORM\Column(length: 100, nullable: true)]
    private ?string $paymentTransactionId;

    #[ORM\OneToMany(mappedBy: 'order', targetEntity: OrderLine::class, cascade: ['persist'])]
    private Collection $lines;

    // Snapshot of customer data at order time
    #[ORM\Embedded(class: OrderCustomerData::class)]
    private OrderCustomerData $customerData;

    #[ORM\Embedded(class: Address::class, columnPrefix: 'billing_')]
    private Address $billingAddress;

    #[ORM\Embedded(class: Address::class, columnPrefix: 'delivery_')]
    private Address $deliveryAddress;

    #[ORM\Column(length: 20)]
    private string $deliveryMode; // billing, delivery, dropship, pickup

    #[ORM\Embedded(class: DropshipData::class, columnPrefix: 'dropship_')]
    private ?DropshipData $dropshipData;

    // Shipping
    #[ORM\Column(length: 50)]
    private string $shippingMethod;

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

    #[ORM\Column(length: 100, nullable: true)]
    private ?string $trackingNumber;

    #[ORM\Column(length: 255, nullable: true)]
    private ?string $trackingUrl;

    // Totals (stored at order time)
    #[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
    private string $subtotal;

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

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

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

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

    // ERP sync
    #[ORM\Column(length: 50, nullable: true)]
    private ?string $erpOrderId;

    #[ORM\Column(type: 'datetime_immutable', nullable: true)]
    private ?\DateTimeImmutable $erpSyncedAt;

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

    #[ORM\Column(type: 'datetime_immutable', nullable: true)]
    private ?\DateTimeImmutable $paidAt;

    #[ORM\Column(type: 'datetime_immutable', nullable: true)]
    private ?\DateTimeImmutable $shippedAt;

    #[ORM\Column(type: 'datetime_immutable', nullable: true)]
    private ?\DateTimeImmutable $deliveredAt;

    #[ORM\Column(type: 'text', nullable: true)]
    private ?string $customerNote;

    #[ORM\Column(type: 'text', nullable: true)]
    private ?string $internalNote;
}

#[ORM\Embeddable]
class OrderCustomerData
{
    #[ORM\Column(length: 100)]
    private string $companyName;

    #[ORM\Column(length: 50, nullable: true)]
    private ?string $vatNumber;

    #[ORM\Column(length: 100)]
    private string $contactName;

    #[ORM\Column(length: 180)]
    private string $email;

    #[ORM\Column(length: 20, nullable: true)]
    private ?string $phone;
}

Order Line

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

    #[ORM\ManyToOne(targetEntity: Order::class, inversedBy: 'lines')]
    private Order $order;

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

    // Snapshot of article data at order time
    #[ORM\Column(length: 50)]
    private string $articleNumber;

    #[ORM\Column(length: 255)]
    private string $articleName;

    #[ORM\Column(length: 13, nullable: true)]
    private ?string $ean;

    #[ORM\Column]
    private int $quantity;

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

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

    #[ORM\Column(type: 'decimal', precision: 8, scale: 3)]
    private string $weight; // in kg
}

Order Status Flow

┌─────────────────┐
│     PENDING     │ ← Order created, awaiting payment
└────────┬────────┘
         │
         ▼
┌─────────────────┐     ┌─────────────────┐
│ PENDING_PAYMENT │────►│    CANCELLED    │ (payment timeout)
└────────┬────────┘     └─────────────────┘
         │ (payment received)
         ▼
┌─────────────────┐
│    CONFIRMED    │ ← Payment confirmed, ready for processing
└────────┬────────┘
         │ (synced to ERP)
         ▼
┌─────────────────┐
│   PROCESSING    │ ← Being picked/packed in warehouse
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│     SHIPPED     │ ← Handed to carrier, tracking available
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│    DELIVERED    │ ← Confirmed delivery
└─────────────────┘

Status Definitions

Status Description Can Cancel
pending Order created, awaiting action Yes
pending_payment Waiting for payment completion Yes
confirmed Payment received, ready for processing Limited
processing Being prepared in warehouse No
shipped Handed to carrier No
delivered Delivery confirmed No
cancelled Order cancelled -

Payment Status

Status Description
pending Payment not yet initiated
processing Payment in progress (at gateway)
paid Payment received
failed Payment failed
refunded Payment refunded
on_account B2B on-account (no immediate payment)

Order Creation

class OrderCreator
{
    public function create(
        Cart $cart,
        Customer $customer,
        CheckoutData $checkoutData
    ): Order {
        // Generate order number
        $orderNumber = $this->orderNumberGenerator->generate($cart->getTenant());

        // Create order
        $order = new Order();
        $order->setOrderNumber($orderNumber);
        $order->setTenant($cart->getTenant());
        $order->setCustomer($customer);
        $order->setStatus('pending');
        $order->setPaymentStatus('pending');

        // Snapshot customer data
        $order->setCustomerData(new OrderCustomerData(
            companyName: $customer->getCompanyName(),
            vatNumber: $customer->getVatNumber(),
            contactName: $customer->getFullName(),
            email: $customer->getEmail(),
            phone: $customer->getPhone(),
        ));

        // Set addresses
        $order->setBillingAddress($customer->getBillingAddress());
        $order->setDeliveryAddress($checkoutData->getDeliveryAddress());
        $order->setDeliveryMode($checkoutData->getDeliveryMode()->value);

        if ($checkoutData->getDropshipData()) {
            $order->setDropshipData($checkoutData->getDropshipData());
        }

        // Set shipping
        $order->setShippingMethod($checkoutData->getShippingMethodId());
        $order->setShippingCost($checkoutData->getShippingCost());

        // Create order lines
        $subtotal = Money::EUR(0);

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

            $lineTotal = $unitPrice->multiply($cartItem->getQuantity());
            $subtotal = $subtotal->add($lineTotal);

            $line = new OrderLine();
            $line->setOrder($order);
            $line->setArticle($cartItem->getArticle());
            $line->setArticleNumber($cartItem->getArticle()->getArticleNumber());
            $line->setArticleName($cartItem->getArticle()->getName());
            $line->setEan($cartItem->getArticle()->getEan());
            $line->setQuantity($cartItem->getQuantity());
            $line->setUnitPrice($unitPrice->getAmount());
            $line->setLineTotal($lineTotal->getAmount());
            $line->setWeight($cartItem->getArticle()->getWeight());

            $order->addLine($line);
        }

        // Calculate totals
        $order->setSubtotal($subtotal->getAmount());
        $order->setDiscountTotal($checkoutData->getDiscountTotal());

        $netTotal = $subtotal->subtract(Money::EUR($checkoutData->getDiscountTotal()));
        $netTotal = $netTotal->add(Money::EUR($checkoutData->getShippingCost()));

        $vatRate = $this->vatService->getRate($customer, $order->getDeliveryAddress());
        $vatAmount = $netTotal->multiply($vatRate / 100);

        $order->setVatRate($vatRate);
        $order->setVatAmount($vatAmount->getAmount());
        $order->setGrandTotal($netTotal->add($vatAmount)->getAmount());

        // Persist
        $this->entityManager->persist($order);
        $this->entityManager->flush();

        // Dispatch event
        $this->eventDispatcher->dispatch(new OrderCreatedEvent($order));

        return $order;
    }
}

class OrderNumberGenerator
{
    public function generate(Tenant $tenant): string
    {
        $year = date('Y');
        $sequence = $this->getNextSequence($tenant, $year);

        return sprintf('ORD-%d-%05d', $year, $sequence);
    }

    private function getNextSequence(Tenant $tenant, int $year): int
    {
        // Atomic increment in database
        $this->connection->executeStatement(
            'INSERT INTO order_sequences (tenant_id, year, sequence) VALUES (?, ?, 1)
             ON DUPLICATE KEY UPDATE sequence = sequence + 1',
            [$tenant->getId(), $year]
        );

        return (int) $this->connection->fetchOne(
            'SELECT sequence FROM order_sequences WHERE tenant_id = ? AND year = ?',
            [$tenant->getId(), $year]
        );
    }
}

ERP Synchronization

Orders are synced to Odoo for processing and fulfillment.

class OrderErpSyncService
{
    public function syncOrder(Order $order): void
    {
        if ($order->getErpOrderId()) {
            // Already synced, update only
            $this->updateOrder($order);
            return;
        }

        // Create order in ERP
        $response = $this->erpClient->createSaleOrder([
            'partner_id' => $order->getCustomer()->getErpCustomerId(),
            'client_order_ref' => $order->getOrderNumber(),
            'order_line' => $this->buildOrderLines($order),
            'carrier_id' => $this->mapShippingMethod($order->getShippingMethod()),
            'delivery_address' => $this->mapAddress($order->getDeliveryAddress()),
        ]);

        $order->setErpOrderId($response['id']);
        $order->setErpSyncedAt(new \DateTimeImmutable());
        $order->setStatus('confirmed');

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

    private function buildOrderLines(Order $order): array
    {
        $lines = [];

        foreach ($order->getLines() as $line) {
            $lines[] = [
                'product_id' => $line->getArticle()->getErpProductId(),
                'product_uom_qty' => $line->getQuantity(),
                'price_unit' => $line->getUnitPrice(),
            ];
        }

        // Add shipping line
        $lines[] = [
            'product_id' => $this->getShippingProductId(),
            'product_uom_qty' => 1,
            'price_unit' => $order->getShippingCost(),
        ];

        return $lines;
    }
}

Order Events

// Events dispatched during order lifecycle
class OrderCreatedEvent
{
    public function __construct(public readonly Order $order) {}
}

class OrderPaidEvent
{
    public function __construct(public readonly Order $order) {}
}

class OrderShippedEvent
{
    public function __construct(
        public readonly Order $order,
        public readonly string $trackingNumber,
        public readonly ?string $trackingUrl,
    ) {}
}

class OrderCancelledEvent
{
    public function __construct(
        public readonly Order $order,
        public readonly string $reason,
    ) {}
}

Order Event Handlers

class OrderEventSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return [
            OrderCreatedEvent::class => 'onOrderCreated',
            OrderPaidEvent::class => 'onOrderPaid',
            OrderShippedEvent::class => 'onOrderShipped',
        ];
    }

    public function onOrderCreated(OrderCreatedEvent $event): void
    {
        // Send order confirmation email
        $this->mailer->sendOrderConfirmation($event->order);
    }

    public function onOrderPaid(OrderPaidEvent $event): void
    {
        $order = $event->order;

        // Sync to ERP
        $this->erpSyncService->syncOrder($order);

        // Award loyalty points
        $this->loyaltyService->awardPoints($order);

        // Send payment confirmation email
        $this->mailer->sendPaymentConfirmation($order);
    }

    public function onOrderShipped(OrderShippedEvent $event): void
    {
        // Send shipping notification email
        $this->mailer->sendShippingNotification(
            $event->order,
            $event->trackingNumber,
            $event->trackingUrl
        );
    }
}

Customer Order History

class OrderHistoryController
{
    #[Route('/account/orders', name: 'account_orders')]
    public function index(): Response
    {
        $customer = $this->security->getUser()->getCustomer();

        $orders = $this->orderRepository->findByCustomer($customer, [
            'orderBy' => ['createdAt' => 'DESC'],
            'limit' => 20,
        ]);

        return $this->render('account/orders/index.html.twig', [
            'orders' => $orders,
        ]);
    }

    #[Route('/account/orders/{orderNumber}', name: 'account_order_detail')]
    public function detail(string $orderNumber): Response
    {
        $customer = $this->security->getUser()->getCustomer();

        $order = $this->orderRepository->findOneBy([
            'orderNumber' => $orderNumber,
            'customer' => $customer,
        ]);

        if (!$order) {
            throw $this->createNotFoundException();
        }

        // Get invoices from ERP
        $invoices = $this->erpClient->getOrderInvoices($order->getErpOrderId());

        return $this->render('account/orders/detail.html.twig', [
            'order' => $order,
            'invoices' => $invoices,
        ]);
    }
}

Invoice Handling

Invoices are generated in Odoo and retrieved via the ERP middleware.

class InvoiceService
{
    public function getInvoicesForOrder(Order $order): array
    {
        if (!$order->getErpOrderId()) {
            return [];
        }

        return $this->erpClient->getInvoices([
            'order_id' => $order->getErpOrderId(),
        ]);
    }

    public function getInvoicePdf(string $invoiceId): string
    {
        return $this->erpClient->getInvoicePdf($invoiceId);
    }

    public function getCustomerInvoices(Customer $customer): array
    {
        if (!$customer->getErpCustomerId()) {
            return [];
        }

        return $this->erpClient->getInvoices([
            'partner_id' => $customer->getErpCustomerId(),
        ]);
    }
}

Gherkin Scenarios

Feature: Order Management
  As a customer
  I want to place and track orders
  So that I can receive my products

  Scenario: Create order with online payment
    Given I have completed checkout with Bancontact payment
    When my payment is confirmed by Pay.nl
    Then the order status should be "confirmed"
    And payment status should be "paid"
    And the order should be synced to ERP
    And I should receive a payment confirmation email

  Scenario: Create order with on-account payment (B2B)
    Given I am a B2B customer with payment terms
    And I have completed checkout with "On Account" payment
    Then the order status should be "confirmed"
    And payment status should be "on_account"
    And the order should be synced to ERP

  Scenario: Order shipped with tracking
    Given I have a confirmed order
    When the order is shipped with tracking number "1234567890"
    Then the order status should be "shipped"
    And I should receive a shipping notification email
    And the email should contain the tracking link

  Scenario: View order history
    Given I have placed 3 orders
    When I view my order history
    Then I should see all 3 orders
    And they should be sorted by date descending

  Scenario: Download invoice
    Given I have a delivered order with an invoice
    When I download the invoice PDF
    Then I should receive the PDF from ERP

  Scenario: Cancel pending order
    Given I have a pending order
    When I cancel the order
    Then the order status should be "cancelled"
    And I should receive a cancellation confirmation email

  Scenario: Cannot cancel shipped order
    Given I have a shipped order
    When I try to cancel the order
    Then I should see an error "Cannot cancel shipped orders"

Database Schema

-- Orders
CREATE TABLE orders (
    id INT PRIMARY KEY AUTO_INCREMENT,
    order_number VARCHAR(20) NOT NULL UNIQUE,
    tenant_id INT NOT NULL,
    customer_id INT NOT NULL,
    status VARCHAR(20) NOT NULL,
    payment_status VARCHAR(20) NOT NULL,
    payment_method VARCHAR(50),
    payment_transaction_id VARCHAR(100),

    -- Customer snapshot
    customer_company_name VARCHAR(100) NOT NULL,
    customer_vat_number VARCHAR(50),
    customer_contact_name VARCHAR(100) NOT NULL,
    customer_email VARCHAR(180) NOT NULL,
    customer_phone VARCHAR(20),

    -- Billing address
    billing_street VARCHAR(255) NOT NULL,
    billing_number VARCHAR(10) NOT NULL,
    billing_box VARCHAR(10),
    billing_postal_code VARCHAR(10) NOT NULL,
    billing_city VARCHAR(100) NOT NULL,
    billing_country_code CHAR(2) NOT NULL,

    -- Delivery address
    delivery_street VARCHAR(255) NOT NULL,
    delivery_number VARCHAR(10) NOT NULL,
    delivery_box VARCHAR(10),
    delivery_postal_code VARCHAR(10) NOT NULL,
    delivery_city VARCHAR(100) NOT NULL,
    delivery_country_code CHAR(2) NOT NULL,

    delivery_mode VARCHAR(20) NOT NULL,

    -- Dropship (optional)
    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
    shipping_method VARCHAR(50) NOT NULL,
    shipping_cost DECIMAL(10,2) NOT NULL,
    tracking_number VARCHAR(100),
    tracking_url VARCHAR(255),

    -- Totals
    subtotal DECIMAL(10,2) NOT NULL,
    discount_total DECIMAL(10,2) NOT NULL DEFAULT 0,
    vat_rate DECIMAL(5,2) NOT NULL,
    vat_amount DECIMAL(10,2) NOT NULL,
    grand_total DECIMAL(10,2) NOT NULL,

    -- ERP
    erp_order_id VARCHAR(50),
    erp_synced_at DATETIME,

    -- Notes
    customer_note TEXT,
    internal_note TEXT,

    -- Timestamps
    created_at DATETIME NOT NULL,
    paid_at DATETIME,
    shipped_at DATETIME,
    delivered_at DATETIME,

    FOREIGN KEY (tenant_id) REFERENCES tenants(id),
    FOREIGN KEY (customer_id) REFERENCES customers(id),
    INDEX idx_customer (customer_id),
    INDEX idx_status (status),
    INDEX idx_created (created_at)
);

-- Order lines
CREATE TABLE order_lines (
    id INT PRIMARY KEY AUTO_INCREMENT,
    order_id INT NOT NULL,
    article_number VARCHAR(50) NOT NULL,
    article_name VARCHAR(255) NOT NULL,
    ean VARCHAR(13),
    quantity INT NOT NULL,
    unit_price DECIMAL(10,2) NOT NULL,
    line_total DECIMAL(10,2) NOT NULL,
    weight DECIMAL(8,3) NOT NULL,
    FOREIGN KEY (order_id) REFERENCES orders(id),
    FOREIGN KEY (article_number) REFERENCES articles(article_number)
);

-- Order number sequences
CREATE TABLE order_sequences (
    tenant_id INT NOT NULL,
    year INT NOT NULL,
    sequence INT NOT NULL DEFAULT 0,
    PRIMARY KEY (tenant_id, year),
    FOREIGN KEY (tenant_id) REFERENCES tenants(id)
);