Webshop - Customers¶
Overview¶
Customer management handles B2B and B2C customers with configurable registration flows per tenant. Each customer belongs to exactly one tenant.
Customer Entity¶
#[ORM\Entity]
class Customer
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private int $id;
#[ORM\ManyToOne(targetEntity: Tenant::class)]
private Tenant $tenant;
#[ORM\Column(length: 100)]
private string $companyName;
#[ORM\Column(length: 50, nullable: true)]
private ?string $vatNumber;
#[ORM\Column(length: 100)]
private string $firstName;
#[ORM\Column(length: 100)]
private string $lastName;
#[ORM\Column(length: 180, unique: true)]
private string $email;
#[ORM\Column(length: 20, nullable: true)]
private ?string $phone;
#[ORM\Embedded(class: Address::class)]
private Address $billingAddress;
#[ORM\OneToMany(mappedBy: 'customer', targetEntity: CustomerAddress::class)]
private Collection $deliveryAddresses;
#[ORM\Column(length: 20)]
private string $status; // pending, approved, rejected, suspended
#[ORM\Column(length: 20, nullable: true)]
private ?string $approvalType; // manual, auto
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $approvalExpiresAt;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $approvedAt;
#[ORM\ManyToOne(targetEntity: User::class)]
private ?User $approvedBy;
#[ORM\Column(length: 50, nullable: true)]
private ?string $erpCustomerId; // Odoo customer ID
#[ORM\OneToMany(mappedBy: 'customer', targetEntity: CustomerMargin::class)]
private Collection $margins;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
public function isApproved(): bool
{
if ($this->status !== 'approved') {
return false;
}
// Check if approval has expired
if ($this->approvalExpiresAt && $this->approvalExpiresAt < new \DateTimeImmutable()) {
return false;
}
return true;
}
public function canPlaceOrders(): bool
{
return $this->isApproved() && $this->status !== 'suspended';
}
}
Customer Status Flow¶
┌─────────────┐
│ PENDING │
└──────┬──────┘
│
┌───────────────┼───────────────┐
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ APPROVED │ │ REJECTED │ │ (timeout) │
└──────┬──────┘ └─────────────┘ └──────┬──────┘
│ │
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ SUSPENDED │ │ EXPIRED │
└─────────────┘ └─────────────┘
Status Definitions¶
| Status | Can Login | Can Order | Description |
|---|---|---|---|
pending |
Yes | No | Awaiting approval |
approved |
Yes | Yes | Active customer |
rejected |
No | No | Registration rejected |
suspended |
Yes | No | Temporarily blocked |
expired |
Yes | No | Auto-approval expired |
Authentication¶
Login Entity¶
#[ORM\Entity]
class Login
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private int $id;
#[ORM\OneToOne(targetEntity: Customer::class)]
private Customer $customer;
#[ORM\Column(length: 180, unique: true)]
private string $email;
#[ORM\Column]
private string $passwordHash;
#[ORM\Column]
private bool $twoFactorEnabled = false;
#[ORM\Column(length: 255, nullable: true)]
private ?string $twoFactorSecret;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $lastLoginAt;
#[ORM\Column]
private int $failedLoginAttempts = 0;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $lockedUntil;
#[ORM\OneToMany(mappedBy: 'login', targetEntity: TrustedDevice::class)]
private Collection $trustedDevices;
}
Two-Factor Authentication (2FA)¶
class TwoFactorAuthService
{
public function enable(Login $login): string
{
$secret = $this->totpGenerator->generateSecret();
$login->setTwoFactorSecret($secret);
$login->setTwoFactorEnabled(true);
return $secret;
}
public function verify(Login $login, string $code): bool
{
if (!$login->isTwoFactorEnabled()) {
return true;
}
return $this->totpValidator->validate($login->getTwoFactorSecret(), $code);
}
public function trustDevice(Login $login, Request $request): void
{
$device = new TrustedDevice();
$device->setLogin($login);
$device->setUserAgent($request->headers->get('User-Agent'));
$device->setIpAddress($request->getClientIp());
$device->setToken($this->tokenGenerator->generate());
$device->setExpiresAt(new \DateTimeImmutable('+30 days'));
$this->entityManager->persist($device);
}
public function isDeviceTrusted(Login $login, Request $request): bool
{
$token = $request->cookies->get('trusted_device');
if (!$token) {
return false;
}
$device = $this->trustedDeviceRepository->findOneBy([
'login' => $login,
'token' => $token,
]);
return $device && $device->isValid();
}
}
Password Reset Flow¶
class PasswordResetService
{
public function requestReset(string $email): void
{
$login = $this->loginRepository->findByEmail($email);
if (!$login) {
// Don't reveal if email exists
return;
}
$token = $this->tokenGenerator->generate();
$login->setPasswordResetToken($token);
$login->setPasswordResetExpiresAt(new \DateTimeImmutable('+1 hour'));
$this->mailer->sendPasswordResetEmail($login, $token);
}
public function resetPassword(string $token, string $newPassword): void
{
$login = $this->loginRepository->findByResetToken($token);
if (!$login || !$login->isPasswordResetValid()) {
throw new InvalidTokenException();
}
$login->setPasswordHash($this->passwordHasher->hash($newPassword));
$login->clearPasswordResetToken();
}
}
Registration Flows¶
B2B with Approval (Default for Atraxion)¶
class RegistrationService
{
public function register(RegistrationRequest $request, Tenant $tenant): Customer
{
// Validate VAT if required
if ($tenant->isVatNumberRequired()) {
$this->validateVatNumber($request->vatNumber);
}
$customer = new Customer();
$customer->setTenant($tenant);
$customer->setEmail($request->email);
$customer->setCompanyName($request->companyName);
$customer->setVatNumber($request->vatNumber);
$customer->setFirstName($request->firstName);
$customer->setLastName($request->lastName);
$customer->setBillingAddress($request->billingAddress);
// Determine initial status
if ($tenant->isApprovalRequired()) {
$customer->setStatus('pending');
// Check for auto-approval
if ($this->autoApprovalService->shouldAutoApprove($customer, $tenant)) {
$this->autoApprovalService->approve($customer, $tenant);
}
} else {
$customer->setStatus('approved');
$customer->setApprovedAt(new \DateTimeImmutable());
}
// Create login
$login = new Login();
$login->setCustomer($customer);
$login->setEmail($request->email);
$login->setPasswordHash($this->passwordHasher->hash($request->password));
$this->entityManager->persist($customer);
$this->entityManager->persist($login);
// Sync to ERP
$this->erpSyncService->syncNewCustomer($customer);
// Send welcome email
$this->mailer->sendWelcomeEmail($customer);
return $customer;
}
}
VAT Validation¶
class VatValidationService
{
public function validate(string $vatNumber): VatValidationResult
{
// Clean VAT number
$vatNumber = preg_replace('/[^A-Z0-9]/', '', strtoupper($vatNumber));
// Extract country code
$countryCode = substr($vatNumber, 0, 2);
$number = substr($vatNumber, 2);
// Validate format per country
if (!$this->validateFormat($countryCode, $number)) {
return VatValidationResult::invalid('Invalid format');
}
// Validate with VIES (EU VAT validation service)
try {
$viesResult = $this->viesClient->check($countryCode, $number);
return new VatValidationResult(
isValid: $viesResult->isValid(),
companyName: $viesResult->getName(),
address: $viesResult->getAddress(),
);
} catch (ViesException $e) {
// VIES unavailable, allow registration but flag for manual check
return VatValidationResult::pending('VIES service unavailable');
}
}
}
Customer Pricing¶
Customers can have specific margins/discounts that apply to their orders.
Margin Entity¶
#[ORM\Entity]
class CustomerMargin
{
#[ORM\ManyToOne(targetEntity: Customer::class)]
private Customer $customer;
#[ORM\ManyToOne(targetEntity: ArticleCategory::class, nullable: true)]
private ?ArticleCategory $category; // null = all categories
#[ORM\ManyToOne(targetEntity: ArticleBrand::class, nullable: true)]
private ?ArticleBrand $brand; // null = all brands
#[ORM\Column(type: 'decimal', precision: 5, scale: 2)]
private string $percentage; // negative = discount, positive = markup
}
Margin Resolution¶
class CustomerMarginService
{
/**
* Find the most specific margin for a customer and article.
* Priority: Brand+Category > Category > Brand > Global
*/
public function findApplicableMargin(
Customer $customer,
ArticleCategory $category,
ArticleBrand $brand
): ?CustomerMargin {
// Try brand + category specific
$margin = $this->marginRepository->findOneBy([
'customer' => $customer,
'category' => $category,
'brand' => $brand,
]);
if ($margin) return $margin;
// Try category specific
$margin = $this->marginRepository->findOneBy([
'customer' => $customer,
'category' => $category,
'brand' => null,
]);
if ($margin) return $margin;
// Try brand specific
$margin = $this->marginRepository->findOneBy([
'customer' => $customer,
'category' => null,
'brand' => $brand,
]);
if ($margin) return $margin;
// Try global
return $this->marginRepository->findOneBy([
'customer' => $customer,
'category' => null,
'brand' => null,
]);
}
}
Delivery Addresses¶
Customers can have multiple delivery addresses.
#[ORM\Entity]
class CustomerAddress
{
#[ORM\ManyToOne(targetEntity: Customer::class)]
private Customer $customer;
#[ORM\Column(length: 100)]
private string $name; // "Warehouse", "Shop", etc.
#[ORM\Embedded(class: Address::class)]
private Address $address;
#[ORM\Column]
private bool $isDefault = false;
}
#[ORM\Embeddable]
class Address
{
#[ORM\Column(length: 255)]
private string $street;
#[ORM\Column(length: 10)]
private string $number;
#[ORM\Column(length: 10, nullable: true)]
private ?string $box;
#[ORM\Column(length: 10)]
private string $postalCode;
#[ORM\Column(length: 100)]
private string $city;
#[ORM\Column(length: 2)]
private string $countryCode;
}
ERP Synchronization¶
New customers and updates are synced to Odoo via the ERP middleware.
class CustomerErpSyncService
{
public function syncNewCustomer(Customer $customer): void
{
$response = $this->erpClient->createCustomer([
'name' => $customer->getCompanyName(),
'vat' => $customer->getVatNumber(),
'email' => $customer->getEmail(),
'phone' => $customer->getPhone(),
'street' => $customer->getBillingAddress()->getStreet(),
'city' => $customer->getBillingAddress()->getCity(),
'zip' => $customer->getBillingAddress()->getPostalCode(),
'country_code' => $customer->getBillingAddress()->getCountryCode(),
]);
$customer->setErpCustomerId($response['id']);
}
public function syncCustomerUpdate(Customer $customer): void
{
if (!$customer->getErpCustomerId()) {
$this->syncNewCustomer($customer);
return;
}
$this->erpClient->updateCustomer($customer->getErpCustomerId(), [
// ... updated fields
]);
}
}
Gherkin Scenarios¶
Feature: Customer Registration
As a potential customer
I want to register for an account
So that I can place orders
Scenario: B2B registration with VAT validation
Given tenant "Atraxion" requires VAT number
And tenant "Atraxion" requires approval
When I register with VAT number "BE0123456789"
And the VAT number is valid
Then my account should be created with status "pending"
And I should receive a welcome email
And I should not be able to place orders
Scenario: B2B registration with invalid VAT
Given tenant "Atraxion" requires VAT number
When I register with VAT number "INVALID123"
Then I should see an error "Invalid VAT number format"
And my account should not be created
Scenario: B2B registration with auto-approval
Given tenant "Reseller" has auto-approval enabled
And auto-approval rules require valid Belgian VAT
When I register with a valid Belgian VAT number
Then my account should be created with status "approved"
And approval type should be "auto"
And approval should expire in 30 days
Scenario: B2C registration without VAT
Given tenant "ConsumerShop" does not require VAT
And tenant "ConsumerShop" does not require approval
When I register without a VAT number
Then my account should be created with status "approved"
And I should be able to place orders immediately
Scenario: Manual approval by tenant owner
Given a pending customer registration for tenant "Atraxion"
When the tenant owner approves the registration
Then the customer status should be "approved"
And approval type should be "manual"
And the customer should receive an approval email
Feature: Customer Authentication
As a registered customer
I want to log in securely
So that I can access my account
Scenario: Login with valid credentials
Given I am a registered customer
When I log in with correct email and password
Then I should be logged in successfully
Scenario: Login with 2FA enabled
Given I have 2FA enabled on my account
When I log in with correct email and password
Then I should be prompted for my 2FA code
When I enter a valid 2FA code
Then I should be logged in successfully
Scenario: Login with trusted device
Given I have 2FA enabled
And I have previously trusted this device
When I log in with correct email and password
Then I should be logged in without 2FA prompt
Scenario: Account lockout after failed attempts
Given I am a registered customer
When I fail to log in 5 times
Then my account should be locked for 15 minutes
And I should see "Account temporarily locked"
Feature: Customer Pricing
As a B2B customer
I want to see my specific prices
So that I know what I'll pay
Scenario: Customer with category discount
Given I have a 10% discount on tyres
And a tyre costs €100
When I view the tyre
Then the price should show €90
Scenario: Customer with brand-specific discount
Given I have a 15% discount on Michelin products
And a Michelin tyre costs €150
When I view the tyre
Then the price should show €127.50
Scenario: Most specific margin applies
Given I have a 10% discount on tyres
And I have a 15% discount on Michelin
And I have a 20% discount on Michelin tyres
When I view a Michelin tyre costing €100
Then the price should show €80 (20% discount)
Database Schema¶
-- Customers
CREATE TABLE customers (
id INT PRIMARY KEY AUTO_INCREMENT,
tenant_id INT NOT NULL,
company_name VARCHAR(100) NOT NULL,
vat_number VARCHAR(50),
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
email VARCHAR(180) NOT NULL UNIQUE,
phone VARCHAR(20),
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,
status VARCHAR(20) NOT NULL,
approval_type VARCHAR(20),
approval_expires_at DATETIME,
approved_at DATETIME,
approved_by_id INT,
erp_customer_id VARCHAR(50),
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
FOREIGN KEY (approved_by_id) REFERENCES users(id),
INDEX idx_tenant_email (tenant_id, email),
INDEX idx_status (status)
);
-- Login credentials
CREATE TABLE logins (
id INT PRIMARY KEY AUTO_INCREMENT,
customer_id INT NOT NULL UNIQUE,
email VARCHAR(180) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
two_factor_enabled BOOLEAN DEFAULT FALSE,
two_factor_secret VARCHAR(255),
last_login_at DATETIME,
failed_login_attempts INT DEFAULT 0,
locked_until DATETIME,
password_reset_token VARCHAR(255),
password_reset_expires_at DATETIME,
FOREIGN KEY (customer_id) REFERENCES customers(id)
);
-- Trusted devices for 2FA
CREATE TABLE trusted_devices (
id INT PRIMARY KEY AUTO_INCREMENT,
login_id INT NOT NULL,
token VARCHAR(255) NOT NULL,
user_agent TEXT,
ip_address VARCHAR(45),
created_at DATETIME NOT NULL,
expires_at DATETIME NOT NULL,
FOREIGN KEY (login_id) REFERENCES logins(id),
INDEX idx_token (token)
);
-- Delivery addresses
CREATE TABLE customer_addresses (
id INT PRIMARY KEY AUTO_INCREMENT,
customer_id INT NOT NULL,
name VARCHAR(100) NOT NULL,
street VARCHAR(255) NOT NULL,
number VARCHAR(10) NOT NULL,
box VARCHAR(10),
postal_code VARCHAR(10) NOT NULL,
city VARCHAR(100) NOT NULL,
country_code CHAR(2) NOT NULL,
is_default BOOLEAN DEFAULT FALSE,
FOREIGN KEY (customer_id) REFERENCES customers(id)
);
-- Customer margins
CREATE TABLE customer_margins (
id INT PRIMARY KEY AUTO_INCREMENT,
customer_id INT NOT NULL,
category_id INT,
brand_id INT,
percentage DECIMAL(5,2) NOT NULL,
FOREIGN KEY (customer_id) REFERENCES customers(id),
FOREIGN KEY (category_id) REFERENCES article_categories(id),
FOREIGN KEY (brand_id) REFERENCES article_brands(id),
UNIQUE KEY (customer_id, category_id, brand_id)
);