Skip to content

Webshop - Loyalty (Arteel Integration)

Overview

The loyalty system allows customers to earn and redeem points. It uses an abstract interface with Arteel as the current implementation, allowing for future provider changes. Configuration is tenant-specific.

Architecture

┌─────────────────────────────────────────┐
│           Application Layer             │
│        LoyaltyService (uses interface)  │
└────────────────────┬────────────────────┘
                     │
┌────────────────────▼────────────────────┐
│           Domain Layer                  │
│    LoyaltyProviderInterface             │
└────────────────────┬────────────────────┘
                     │
        ┌────────────┴────────────┐
        │                         │
┌───────▼───────┐        ┌────────▼────────┐
│ ArteelAdapter │        │ FutureAdapter   │
│ (Infrastructure)│      │ (if needed)     │
└───────────────┘        └─────────────────┘

Domain Interface

namespace App\Domain\Loyalty;

interface LoyaltyProviderInterface
{
    public function getBalance(Customer $customer): int;

    public function awardPoints(Customer $customer, int $points, string $reason): void;

    public function redeemPoints(Customer $customer, int $points, string $reason): void;

    public function getTransactions(Customer $customer, int $limit = 20): array;
}

class LoyaltyTransaction
{
    public function __construct(
        public readonly string $id,
        public readonly int $points,
        public readonly string $type, // 'earn' or 'redeem'
        public readonly string $reason,
        public readonly \DateTimeImmutable $createdAt,
    ) {}
}

Arteel Adapter

// atraxion/arteel-client library

namespace Atraxion\Arteel;

class ArteelClient
{
    public function __construct(
        private string $apiKey,
        private string $companyId,
        private HttpClientInterface $httpClient,
    ) {}

    public function getMemberBalance(string $memberId): int
    {
        $response = $this->httpClient->request('GET',
            "https://api.arteel.com/v1/members/{$memberId}/balance", [
            'headers' => $this->getHeaders(),
        ]);

        return $response->toArray()['balance'];
    }

    public function addPoints(string $memberId, int $points, string $description): void
    {
        $this->httpClient->request('POST',
            "https://api.arteel.com/v1/members/{$memberId}/points", [
            'headers' => $this->getHeaders(),
            'json' => [
                'points' => $points,
                'description' => $description,
            ],
        ]);
    }

    public function redeemPoints(string $memberId, int $points, string $description): void
    {
        $this->httpClient->request('POST',
            "https://api.arteel.com/v1/members/{$memberId}/redeem", [
            'headers' => $this->getHeaders(),
            'json' => [
                'points' => $points,
                'description' => $description,
            ],
        ]);
    }

    public function getTransactions(string $memberId, int $limit): array
    {
        $response = $this->httpClient->request('GET',
            "https://api.arteel.com/v1/members/{$memberId}/transactions", [
            'headers' => $this->getHeaders(),
            'query' => ['limit' => $limit],
        ]);

        return $response->toArray()['transactions'];
    }

    private function getHeaders(): array
    {
        return [
            'Authorization' => "Bearer {$this->apiKey}",
            'X-Company-Id' => $this->companyId,
        ];
    }
}
// Infrastructure adapter

namespace App\Infrastructure\Loyalty;

class ArteelLoyaltyAdapter implements LoyaltyProviderInterface
{
    public function __construct(
        private ArteelClient $client,
        private CustomerArteelMemberRepository $memberRepository,
    ) {}

    public function getBalance(Customer $customer): int
    {
        $memberId = $this->getMemberId($customer);

        if (!$memberId) {
            return 0;
        }

        return $this->client->getMemberBalance($memberId);
    }

    public function awardPoints(Customer $customer, int $points, string $reason): void
    {
        $memberId = $this->getOrCreateMemberId($customer);

        $this->client->addPoints($memberId, $points, $reason);
    }

    public function redeemPoints(Customer $customer, int $points, string $reason): void
    {
        $memberId = $this->getMemberId($customer);

        if (!$memberId) {
            throw new LoyaltyMemberNotFoundException($customer);
        }

        $this->client->redeemPoints($memberId, $points, $reason);
    }

    public function getTransactions(Customer $customer, int $limit = 20): array
    {
        $memberId = $this->getMemberId($customer);

        if (!$memberId) {
            return [];
        }

        $transactions = $this->client->getTransactions($memberId, $limit);

        return array_map(fn($t) => new LoyaltyTransaction(
            id: $t['id'],
            points: $t['points'],
            type: $t['type'],
            reason: $t['description'],
            createdAt: new \DateTimeImmutable($t['created_at']),
        ), $transactions);
    }

    private function getMemberId(Customer $customer): ?string
    {
        $member = $this->memberRepository->findByCustomer($customer);
        return $member?->getArteelMemberId();
    }

    private function getOrCreateMemberId(Customer $customer): string
    {
        $memberId = $this->getMemberId($customer);

        if ($memberId) {
            return $memberId;
        }

        // Register customer with Arteel
        $memberId = $this->client->registerMember([
            'email' => $customer->getEmail(),
            'name' => $customer->getFullName(),
        ]);

        // Store mapping
        $member = new CustomerArteelMember();
        $member->setCustomer($customer);
        $member->setArteelMemberId($memberId);

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

        return $memberId;
    }
}

Tenant Configuration

#[ORM\Entity]
class TenantLoyaltyConfig
{
    #[ORM\Id]
    #[ORM\OneToOne(targetEntity: Tenant::class)]
    private Tenant $tenant;

    #[ORM\Column]
    private bool $isEnabled = false;

    #[ORM\Column(length: 50)]
    private string $provider = 'arteel'; // For future providers

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

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

    #[ORM\OneToMany(mappedBy: 'config', targetEntity: LoyaltyPointsRule::class)]
    private Collection $pointsRules;

    #[ORM\OneToMany(mappedBy: 'config', targetEntity: LoyaltyExclusionRule::class)]
    private Collection $exclusionRules;
}

Points Rules

Earning Rules

#[ORM\Entity]
class LoyaltyPointsRule
{
    #[ORM\ManyToOne(targetEntity: TenantLoyaltyConfig::class)]
    private TenantLoyaltyConfig $config;

    #[ORM\Column(length: 20)]
    private string $type; // 'order_amount', 'brand', 'article'

    #[ORM\Column(length: 50, nullable: true)]
    private ?string $targetId; // brand_id, article_number, or null for order_amount

    #[ORM\Column]
    private int $pointsPerEuro; // e.g., 1 point per €10 = 0.1

    #[ORM\Column(nullable: true)]
    private ?int $multiplier; // e.g., 2 for double points

    #[ORM\Column]
    private int $priority; // Higher priority rules override lower
}

Exclusion Rules

#[ORM\Entity]
class LoyaltyExclusionRule
{
    #[ORM\ManyToOne(targetEntity: TenantLoyaltyConfig::class)]
    private TenantLoyaltyConfig $config;

    #[ORM\Column(length: 20)]
    private string $type; // 'brand', 'article', 'category'

    #[ORM\Column(length: 50)]
    private string $targetId; // brand_id, article_number, category_id
}

Loyalty Service

class LoyaltyService
{
    public function __construct(
        private LoyaltyProviderFactory $providerFactory,
        private LoyaltyPointsCalculator $pointsCalculator,
    ) {}

    public function isEnabledForTenant(Tenant $tenant): bool
    {
        $config = $tenant->getLoyaltyConfig();
        return $config && $config->isEnabled();
    }

    public function getBalance(Customer $customer): int
    {
        if (!$this->isEnabledForTenant($customer->getTenant())) {
            return 0;
        }

        $provider = $this->providerFactory->create($customer->getTenant());
        return $provider->getBalance($customer);
    }

    public function awardPointsForOrder(Order $order): void
    {
        $tenant = $order->getTenant();

        if (!$this->isEnabledForTenant($tenant)) {
            return;
        }

        $points = $this->pointsCalculator->calculateForOrder($order);

        if ($points <= 0) {
            return;
        }

        $provider = $this->providerFactory->create($tenant);
        $provider->awardPoints(
            $order->getCustomer(),
            $points,
            sprintf('Order %s', $order->getOrderNumber())
        );

        // Dispatch event
        $this->eventDispatcher->dispatch(new LoyaltyPointsAwardedEvent(
            $order->getCustomer(),
            $points,
            $order
        ));
    }
}

Points Calculator

class LoyaltyPointsCalculator
{
    public function calculateForOrder(Order $order): int
    {
        $config = $order->getTenant()->getLoyaltyConfig();

        if (!$config) {
            return 0;
        }

        $totalPoints = 0;

        foreach ($order->getLines() as $line) {
            // Check exclusion rules
            if ($this->isExcluded($line->getArticle(), $config)) {
                continue;
            }

            // Find applicable points rule
            $rule = $this->findApplicableRule($line->getArticle(), $config);

            if ($rule) {
                $lineTotal = (float) $line->getLineTotal();
                $points = (int) floor($lineTotal * $rule->getPointsPerEuro());

                if ($rule->getMultiplier()) {
                    $points *= $rule->getMultiplier();
                }

                $totalPoints += $points;
            }
        }

        return $totalPoints;
    }

    private function isExcluded(Article $article, TenantLoyaltyConfig $config): bool
    {
        foreach ($config->getExclusionRules() as $rule) {
            if ($rule->getType() === 'brand' && $rule->getTargetId() === $article->getBrand()->getId()) {
                return true;
            }

            if ($rule->getType() === 'article' && $rule->getTargetId() === $article->getArticleNumber()) {
                return true;
            }

            if ($rule->getType() === 'category' && $rule->getTargetId() === $article->getCategory()->getId()) {
                return true;
            }
        }

        return false;
    }

    private function findApplicableRule(Article $article, TenantLoyaltyConfig $config): ?LoyaltyPointsRule
    {
        $rules = $config->getPointsRules()->toArray();

        // Sort by priority (highest first)
        usort($rules, fn($a, $b) => $b->getPriority() <=> $a->getPriority());

        foreach ($rules as $rule) {
            // Article-specific rule
            if ($rule->getType() === 'article' && $rule->getTargetId() === $article->getArticleNumber()) {
                return $rule;
            }

            // Brand-specific rule
            if ($rule->getType() === 'brand' && $rule->getTargetId() === $article->getBrand()->getId()) {
                return $rule;
            }

            // Generic order amount rule
            if ($rule->getType() === 'order_amount' && $rule->getTargetId() === null) {
                return $rule;
            }
        }

        return null;
    }
}

Customer Account Integration

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

        if (!$this->loyaltyService->isEnabledForTenant($customer->getTenant())) {
            throw $this->createNotFoundException('Loyalty not enabled');
        }

        $balance = $this->loyaltyService->getBalance($customer);
        $transactions = $this->loyaltyService->getTransactions($customer);

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

Gherkin Scenarios

Feature: Loyalty Program
  As a customer
  I want to earn and view loyalty points
  So that I can benefit from my purchases

  Scenario: Earn points on order
    Given tenant "Atraxion" has loyalty enabled
    And the points rule is 1 point per €10
    And I place an order for €150
    When the order is paid
    Then I should earn 15 loyalty points
    And I should receive a loyalty update email

  Scenario: Brand-specific multiplier
    Given tenant has loyalty enabled
    And brand "Michelin" has 2x points multiplier
    When I order €100 of Michelin products
    Then I should earn 20 points (double)

  Scenario: Excluded brand earns no points
    Given tenant has loyalty enabled
    And brand "BudgetBrand" is excluded from loyalty
    When I order €100 of BudgetBrand products
    Then I should earn 0 points

  Scenario: View loyalty balance
    Given I have 150 loyalty points
    When I view my loyalty account
    Then I should see balance of 150 points
    And I should see my transaction history

  Scenario: Loyalty disabled for tenant
    Given tenant "NoLoyalty" has loyalty disabled
    When I place an order
    Then no points should be awarded
    And I should not see loyalty in my account

  Scenario: Points calculation with mixed cart
    Given the base rule is 1 point per €10
    And brand "Premium" has 2x multiplier
    And brand "Budget" is excluded
    When I order:
      | Brand   | Amount |
      | Premium | €100   |
      | Regular | €50    |
      | Budget  | €30    |
    Then I should earn 25 points (20 + 5 + 0)

Database Schema

-- Tenant loyalty configuration
CREATE TABLE tenant_loyalty_configs (
    tenant_id INT PRIMARY KEY,
    is_enabled BOOLEAN DEFAULT FALSE,
    provider VARCHAR(50) DEFAULT 'arteel',
    api_key VARCHAR(255),
    company_id VARCHAR(100),
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    FOREIGN KEY (tenant_id) REFERENCES tenants(id)
);

-- Points earning rules
CREATE TABLE loyalty_points_rules (
    id INT PRIMARY KEY AUTO_INCREMENT,
    config_tenant_id INT NOT NULL,
    type VARCHAR(20) NOT NULL,
    target_id VARCHAR(50),
    points_per_euro DECIMAL(5,2) NOT NULL,
    multiplier INT,
    priority INT NOT NULL DEFAULT 0,
    FOREIGN KEY (config_tenant_id) REFERENCES tenant_loyalty_configs(tenant_id)
);

-- Exclusion rules
CREATE TABLE loyalty_exclusion_rules (
    id INT PRIMARY KEY AUTO_INCREMENT,
    config_tenant_id INT NOT NULL,
    type VARCHAR(20) NOT NULL,
    target_id VARCHAR(50) NOT NULL,
    FOREIGN KEY (config_tenant_id) REFERENCES tenant_loyalty_configs(tenant_id)
);

-- Customer-Arteel member mapping
CREATE TABLE customer_arteel_members (
    id INT PRIMARY KEY AUTO_INCREMENT,
    customer_id INT NOT NULL UNIQUE,
    arteel_member_id VARCHAR(100) NOT NULL,
    created_at DATETIME NOT NULL,
    FOREIGN KEY (customer_id) REFERENCES customers(id)
);

-- Local points log (for audit/debugging)
CREATE TABLE loyalty_points_log (
    id INT PRIMARY KEY AUTO_INCREMENT,
    customer_id INT NOT NULL,
    points INT NOT NULL,
    type VARCHAR(10) NOT NULL,
    reason VARCHAR(255) NOT NULL,
    order_id INT,
    created_at DATETIME NOT NULL,
    FOREIGN KEY (customer_id) REFERENCES customers(id),
    FOREIGN KEY (order_id) REFERENCES orders(id)
);