Skip to content

Webshop - Coupons

Overview

The coupon system allows tenants to create discount codes with various award types. Coupons are tenant-specific and can have complex validity rules.

Coupon Entity

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

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

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

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

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

    #[ORM\Column(length: 20)]
    private string $awardType; // 'discount', 'gift', 'free_assembly', 'loyalty_points'

    #[ORM\Column(type: 'json')]
    private array $awardConfig; // Type-specific configuration

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

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

    #[ORM\Column(nullable: true)]
    private ?int $usageLimit; // null = unlimited

    #[ORM\Column]
    private int $usageCount = 0;

    #[ORM\Column(nullable: true)]
    private ?int $usageLimitPerCustomer;

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

    #[ORM\Column]
    private bool $isActive = true;

    #[ORM\ManyToMany(targetEntity: ArticleCategory::class)]
    private Collection $applicableCategories; // Empty = all categories

    #[ORM\ManyToMany(targetEntity: ArticleBrand::class)]
    private Collection $applicableBrands; // Empty = all brands

    #[ORM\ManyToMany(targetEntity: Customer::class)]
    private Collection $limitedToCustomers; // Empty = all customers

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

Award Types

Discount Award

class DiscountAward implements CouponAwardInterface
{
    public function __construct(
        public readonly string $discountType, // 'percentage' or 'fixed'
        public readonly string $discountValue, // e.g., '10' for 10% or '25.00' for €25
        public readonly ?string $maxDiscount, // Maximum discount amount for percentage
    ) {}

    public function calculate(Cart $cart, Coupon $coupon): Money
    {
        $applicableTotal = $this->getApplicableTotal($cart, $coupon);

        $discount = match ($this->discountType) {
            'percentage' => $applicableTotal->multiply($this->discountValue / 100),
            'fixed' => Money::EUR($this->discountValue),
        };

        // Apply maximum discount cap
        if ($this->maxDiscount && $discount->greaterThan(Money::EUR($this->maxDiscount))) {
            $discount = Money::EUR($this->maxDiscount);
        }

        // Cannot exceed applicable total
        if ($discount->greaterThan($applicableTotal)) {
            $discount = $applicableTotal;
        }

        return $discount;
    }

    private function getApplicableTotal(Cart $cart, Coupon $coupon): Money
    {
        $total = Money::EUR(0);

        foreach ($cart->getItems() as $item) {
            if ($this->isItemApplicable($item, $coupon)) {
                $total = $total->add($item->getLineTotal());
            }
        }

        return $total;
    }

    private function isItemApplicable(CartItem $item, Coupon $coupon): bool
    {
        $article = $item->getArticle();

        // Check category restriction
        if (!$coupon->getApplicableCategories()->isEmpty()) {
            $categoryIds = $coupon->getApplicableCategories()->map(fn($c) => $c->getId());
            if (!$categoryIds->contains($article->getCategory()->getId())) {
                return false;
            }
        }

        // Check brand restriction
        if (!$coupon->getApplicableBrands()->isEmpty()) {
            $brandIds = $coupon->getApplicableBrands()->map(fn($b) => $b->getId());
            if (!$brandIds->contains($article->getBrand()->getId())) {
                return false;
            }
        }

        return true;
    }
}

Gift Award

class GiftAward implements CouponAwardInterface
{
    public function __construct(
        public readonly string $giftArticleNumber,
        public readonly int $quantity = 1,
    ) {}

    public function apply(Cart $cart, Coupon $coupon): void
    {
        $giftArticle = $this->articleRepository->findByNumber($this->giftArticleNumber);

        if (!$giftArticle) {
            throw new GiftArticleNotFoundException($this->giftArticleNumber);
        }

        // Add gift to cart as free item
        $cart->addItem($giftArticle, $this->quantity, isFreeGift: true);
    }

    public function calculate(Cart $cart, Coupon $coupon): Money
    {
        // Gift doesn't provide monetary discount on cart total
        return Money::EUR(0);
    }
}

Free Assembly Award

class FreeAssemblyAward implements CouponAwardInterface
{
    public function calculate(Cart $cart, Coupon $coupon): Money
    {
        // Calculate assembly cost that will be waived
        return $this->assemblyService->calculateCost($cart);
    }

    public function apply(Order $order, Coupon $coupon): void
    {
        $order->setAssemblyFree(true);
    }
}

Loyalty Points Award

class LoyaltyPointsAward implements CouponAwardInterface
{
    public function __construct(
        public readonly int $bonusPoints,
    ) {}

    public function calculate(Cart $cart, Coupon $coupon): Money
    {
        // No monetary discount
        return Money::EUR(0);
    }

    public function apply(Order $order, Coupon $coupon): void
    {
        // Points will be awarded after order is paid
        $order->setBonusLoyaltyPoints($this->bonusPoints);
    }
}

Coupon Validator

class CouponValidator
{
    public function validate(Coupon $coupon, Cart $cart, Customer $customer): ValidationResult
    {
        $errors = [];

        // Check if active
        if (!$coupon->isActive()) {
            $errors[] = 'Coupon is not active';
        }

        // Check date validity
        $now = new \DateTimeImmutable();

        if ($coupon->getValidFrom() && $now < $coupon->getValidFrom()) {
            $errors[] = 'Coupon is not yet valid';
        }

        if ($coupon->getValidUntil() && $now > $coupon->getValidUntil()) {
            $errors[] = 'Coupon has expired';
        }

        // Check usage limit
        if ($coupon->getUsageLimit() !== null && $coupon->getUsageCount() >= $coupon->getUsageLimit()) {
            $errors[] = 'Coupon usage limit reached';
        }

        // Check per-customer usage limit
        if ($coupon->getUsageLimitPerCustomer() !== null) {
            $customerUsage = $this->couponUsageRepository->countByCustomerAndCoupon($customer, $coupon);

            if ($customerUsage >= $coupon->getUsageLimitPerCustomer()) {
                $errors[] = 'You have already used this coupon the maximum number of times';
            }
        }

        // Check minimum order amount
        if ($coupon->getMinimumOrderAmount() !== null) {
            $cartTotal = $this->cartPriceCalculator->calculate($cart)->getSubtotal();

            if ($cartTotal->lessThan(Money::EUR($coupon->getMinimumOrderAmount()))) {
                $errors[] = sprintf(
                    'Minimum order amount of €%s required',
                    $coupon->getMinimumOrderAmount()
                );
            }
        }

        // Check customer restriction
        if (!$coupon->getLimitedToCustomers()->isEmpty()) {
            $allowedIds = $coupon->getLimitedToCustomers()->map(fn($c) => $c->getId());

            if (!$allowedIds->contains($customer->getId())) {
                $errors[] = 'This coupon is not available for your account';
            }
        }

        // Check tenant match
        if ($coupon->getTenant()->getId() !== $cart->getTenant()->getId()) {
            $errors[] = 'Coupon is not valid for this shop';
        }

        return new ValidationResult(
            isValid: empty($errors),
            errors: $errors,
        );
    }
}

Coupon Service

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

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

        // Validate
        $validation = $this->validator->validate($coupon, $cart, $customer);

        if (!$validation->isValid()) {
            return CouponResult::invalid($validation->getErrors()[0]);
        }

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

        // Calculate discount
        $award = $this->awardFactory->create($coupon);
        $discount = $award->calculate($cart, $coupon);

        return CouponResult::success($coupon, $discount);
    }

    public function removeCoupon(string $code): void
    {
        $this->checkoutSession->removeCoupon($code);
    }

    public function recordUsage(Coupon $coupon, Order $order): void
    {
        // Increment usage count
        $coupon->incrementUsageCount();

        // Record customer usage
        $usage = new CouponUsage();
        $usage->setCoupon($coupon);
        $usage->setCustomer($order->getCustomer());
        $usage->setOrder($order);

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

Admin Management

class CouponController
{
    #[Route('/admin/coupons', name: 'admin_coupons')]
    public function index(): Response
    {
        $tenant = $this->getTenant();
        $coupons = $this->couponRepository->findByTenant($tenant);

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

    #[Route('/admin/coupons/create', name: 'admin_coupon_create')]
    public function create(Request $request): Response
    {
        $coupon = new Coupon();
        $coupon->setTenant($this->getTenant());

        $form = $this->createForm(CouponType::class, $coupon);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $this->entityManager->persist($coupon);
            $this->entityManager->flush();

            return $this->redirectToRoute('admin_coupons');
        }

        return $this->render('admin/coupons/create.html.twig', [
            'form' => $form,
        ]);
    }

    #[Route('/admin/coupons/{id}/stats', name: 'admin_coupon_stats')]
    public function stats(int $id): Response
    {
        $coupon = $this->couponRepository->find($id);

        $stats = [
            'usage_count' => $coupon->getUsageCount(),
            'total_discount_given' => $this->couponUsageRepository->sumDiscountByCoupon($coupon),
            'unique_customers' => $this->couponUsageRepository->countUniqueCustomersByCoupon($coupon),
            'recent_usages' => $this->couponUsageRepository->findRecentByCoupon($coupon, 10),
        ];

        return $this->render('admin/coupons/stats.html.twig', [
            'coupon' => $coupon,
            'stats' => $stats,
        ]);
    }
}

Gherkin Scenarios

Feature: Coupon System
  As a customer
  I want to use coupon codes
  So that I can get discounts on my orders

  Scenario: Apply percentage discount coupon
    Given a coupon "SAVE10" exists with 10% discount
    And I have €200 of products in my cart
    When I apply coupon code "SAVE10"
    Then I should see a discount of €20
    And the cart total should be €180

  Scenario: Apply fixed discount coupon
    Given a coupon "FLAT25" exists with €25 fixed discount
    And I have €100 of products in my cart
    When I apply coupon code "FLAT25"
    Then I should see a discount of €25

  Scenario: Coupon with minimum order amount
    Given a coupon "MIN100" requires minimum €100 order
    And I have €80 of products in my cart
    When I try to apply coupon "MIN100"
    Then I should see error "Minimum order amount of €100 required"

  Scenario: Expired coupon
    Given a coupon "OLD" expired yesterday
    When I try to apply coupon "OLD"
    Then I should see error "Coupon has expired"

  Scenario: Coupon usage limit reached
    Given a coupon "LIMITED" has usage limit of 100
    And it has been used 100 times
    When I try to apply coupon "LIMITED"
    Then I should see error "Coupon usage limit reached"

  Scenario: Per-customer usage limit
    Given a coupon "ONCE" can be used once per customer
    And I have already used coupon "ONCE"
    When I try to apply coupon "ONCE" again
    Then I should see error "You have already used this coupon"

  Scenario: Brand-specific coupon
    Given a coupon "MICHELIN20" gives 20% off Michelin products
    And I have €100 Michelin and €100 Continental in my cart
    When I apply coupon "MICHELIN20"
    Then the discount should be €20 (only on Michelin)

  Scenario: Gift coupon
    Given a coupon "FREECAP" adds a free cap to the order
    When I apply coupon "FREECAP"
    Then my cart should contain the free cap
    And the cap should show €0 price

  Scenario: Loyalty points bonus coupon
    Given a coupon "BONUS500" gives 500 bonus loyalty points
    When I place an order with coupon "BONUS500"
    Then I should receive 500 extra loyalty points after payment

  Scenario: Remove coupon
    Given I have applied coupon "SAVE10"
    When I remove the coupon
    Then the discount should be removed
    And the cart total should be the full amount

Database Schema

-- Coupons
CREATE TABLE coupons (
    id INT PRIMARY KEY AUTO_INCREMENT,
    tenant_id INT NOT NULL,
    code VARCHAR(50) NOT NULL,
    name VARCHAR(100) NOT NULL,
    description TEXT,
    award_type VARCHAR(20) NOT NULL,
    award_config JSON NOT NULL,
    valid_from DATETIME,
    valid_until DATETIME,
    usage_limit INT,
    usage_count INT DEFAULT 0,
    usage_limit_per_customer INT,
    minimum_order_amount DECIMAL(10,2),
    is_active BOOLEAN DEFAULT TRUE,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    FOREIGN KEY (tenant_id) REFERENCES tenants(id),
    UNIQUE KEY (tenant_id, code)
);

-- Coupon category restrictions
CREATE TABLE coupon_categories (
    coupon_id INT NOT NULL,
    category_id INT NOT NULL,
    PRIMARY KEY (coupon_id, category_id),
    FOREIGN KEY (coupon_id) REFERENCES coupons(id),
    FOREIGN KEY (category_id) REFERENCES article_categories(id)
);

-- Coupon brand restrictions
CREATE TABLE coupon_brands (
    coupon_id INT NOT NULL,
    brand_id INT NOT NULL,
    PRIMARY KEY (coupon_id, brand_id),
    FOREIGN KEY (coupon_id) REFERENCES coupons(id),
    FOREIGN KEY (brand_id) REFERENCES article_brands(id)
);

-- Coupon customer restrictions
CREATE TABLE coupon_customers (
    coupon_id INT NOT NULL,
    customer_id INT NOT NULL,
    PRIMARY KEY (coupon_id, customer_id),
    FOREIGN KEY (coupon_id) REFERENCES coupons(id),
    FOREIGN KEY (customer_id) REFERENCES customers(id)
);

-- Coupon usage tracking
CREATE TABLE coupon_usages (
    id INT PRIMARY KEY AUTO_INCREMENT,
    coupon_id INT NOT NULL,
    customer_id INT NOT NULL,
    order_id INT NOT NULL,
    discount_amount DECIMAL(10,2) NOT NULL,
    used_at DATETIME NOT NULL,
    FOREIGN KEY (coupon_id) REFERENCES coupons(id),
    FOREIGN KEY (customer_id) REFERENCES customers(id),
    FOREIGN KEY (order_id) REFERENCES orders(id)
);