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