Skip to content

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