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