Skip to content

Webshop - EDI (Electronic Data Interchange)

Overview

EDI allows B2B customers to place orders programmatically via XML. Each EDI client is linked to a customer (one customer can have multiple EDI clients for different locations/systems).

Legacy gap coverage in this document: - API-002 Tyrestream alias endpoint (/tyrestream)

Endpoint Aliases (API-002)

Legacy _api wiring exposes two equivalent entrypoints to the same controller/action:

  1. POST /edi -> EdiController::index
  2. POST /tyrestream -> EdiController::index

Both endpoints share the same middleware stack and parsing/auth rules, so the alias is a compatibility contract rather than a distinct business flow.

Migration requirement: - Keep /tyrestream support (or implement an explicit deprecation + redirect strategy) to avoid breaking integrations still calling the alias path.

EDI Client Entity

#[ORM\Entity]
class EdiClient
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private int $id;

    #[ORM\ManyToOne(targetEntity: Tenant::class)]
    private Tenant $tenant;

    #[ORM\ManyToOne(targetEntity: Customer::class)]
    private Customer $customer; // Billing/invoicing customer

    #[ORM\Column(length: 50, unique: true)]
    private string $username;

    #[ORM\Column]
    private string $passwordHash;

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

    #[ORM\Column(length: 100)]
    private string $name; // e.g., "Warehouse Brussels"

    #[ORM\Embedded(class: Address::class, columnPrefix: 'delivery_')]
    private ?Address $defaultDeliveryAddress; // Optional default delivery address

    #[ORM\Column]
    private bool $isActive = true;

    #[ORM\Column(type: 'datetime_immutable')]
    private \DateTimeImmutable $createdAt;

    #[ORM\Column(type: 'datetime_immutable', nullable: true)]
    private ?\DateTimeImmutable $lastUsedAt;
}

Authentication

Middleware Stack

// 1. Basic Auth - validates username/password
class EdiBasicAuthMiddleware implements MiddlewareInterface
{
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $authHeader = $request->getHeaderLine('Authorization');

        if (!str_starts_with($authHeader, 'Basic ')) {
            return $this->unauthorizedResponse('Missing Basic Auth');
        }

        $credentials = base64_decode(substr($authHeader, 6));
        [$username, $password] = explode(':', $credentials, 2);

        $client = $this->clientRepository->findByUsername($username);

        if (!$client || !$this->passwordHasher->verify($password, $client->getPasswordHash())) {
            return $this->unauthorizedResponse('Invalid credentials');
        }

        if (!$client->isActive()) {
            return $this->unauthorizedResponse('Client is disabled');
        }

        // Store client in request for later use
        $request = $request->withAttribute('edi_client', $client);

        return $handler->handle($request);
    }
}

// 2. API Key - additional security layer
class EdiApiKeyMiddleware implements MiddlewareInterface
{
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $client = $request->getAttribute('edi_client');
        $apiKey = $request->getHeaderLine('X-Api-Key');

        if ($client->getApiKey() && $client->getApiKey() !== $apiKey) {
            return $this->unauthorizedResponse('Invalid API key');
        }

        return $handler->handle($request);
    }
}

EDI Message Types

Order Request

<?xml version="1.0" encoding="UTF-8"?>
<Order>
    <Header>
        <OrderNumber>EXT-2024-001</OrderNumber>
        <OrderDate>2024-01-15</OrderDate>
    </Header>
    <PaymentTerms>
        <PaymentMethod>K</PaymentMethod>
    </PaymentTerms>
    <DeliveryAddress>
        <CompanyName>Customer Warehouse</CompanyName>
        <Street>Industrial Road 15</Street>
        <PostalCode>1000</PostalCode>
        <City>Brussels</City>
        <Country>BE</Country>
    </DeliveryAddress>
    <Lines>
        <Line>
            <LineNumber>1</LineNumber>
            <ArticleNumber>TYRE-001</ArticleNumber>
            <EAN>1234567890123</EAN>
            <Quantity>4</Quantity>
        </Line>
        <Line>
            <LineNumber>2</LineNumber>
            <ArticleNumber>WHEEL-001</ArticleNumber>
            <Quantity>4</Quantity>
        </Line>
    </Lines>
</Order>

Order Response

<?xml version="1.0" encoding="UTF-8"?>
<OrderResponse>
    <Status>ACCEPTED</Status>
    <OrderNumber>ORD-2024-00123</OrderNumber>
    <ExternalOrderNumber>EXT-2024-001</ExternalOrderNumber>
    <Lines>
        <Line>
            <LineNumber>1</LineNumber>
            <ArticleNumber>TYRE-001</ArticleNumber>
            <Status>CONFIRMED</Status>
            <Quantity>4</Quantity>
            <UnitPrice>125.00</UnitPrice>
        </Line>
        <Line>
            <LineNumber>2</LineNumber>
            <ArticleNumber>WHEEL-001</ArticleNumber>
            <Status>PARTIAL</Status>
            <QuantityRequested>4</QuantityRequested>
            <QuantityConfirmed>2</QuantityConfirmed>
            <UnitPrice>200.00</UnitPrice>
            <Remark>Only 2 in stock</Remark>
        </Line>
    </Lines>
    <Totals>
        <Subtotal>900.00</Subtotal>
        <ShippingCost>25.00</ShippingCost>
        <Total>925.00</Total>
    </Totals>
</OrderResponse>

Inquiry Request (Price/Availability Check)

<?xml version="1.0" encoding="UTF-8"?>
<Inquiry>
    <Lines>
        <Line>
            <ArticleNumber>TYRE-001</ArticleNumber>
            <EAN>1234567890123</EAN>
            <Quantity>4</Quantity>
        </Line>
    </Lines>
</Inquiry>

Inquiry Response

<?xml version="1.0" encoding="UTF-8"?>
<InquiryResponse>
    <Lines>
        <Line>
            <ArticleNumber>TYRE-001</ArticleNumber>
            <EAN>1234567890123</EAN>
            <Available>true</Available>
            <Stock>50</Stock>
            <UnitPrice>125.00</UnitPrice>
        </Line>
    </Lines>
</InquiryResponse>

EDI Service

class EdiOrderService
{
    public function processOrder(EdiClient $client, string $xmlContent): EdiOrderResponse
    {
        // Parse XML
        $orderData = $this->orderParser->parse($xmlContent);

        // Validate order
        $validation = $this->orderValidator->validate($orderData, $client);

        if (!$validation->isValid()) {
            return EdiOrderResponse::rejected($validation->getErrors());
        }

        // Create order
        $order = $this->createOrder($client, $orderData);

        // Build response
        return $this->buildOrderResponse($order, $orderData);
    }

    private function createOrder(EdiClient $client, EdiOrderData $orderData): Order
    {
        $customer = $client->getCustomer();
        $tenant = $client->getTenant();

        $order = new Order();
        $order->setTenant($tenant);
        $order->setCustomer($customer);
        $order->setSource('edi');
        $order->setExternalOrderNumber($orderData->externalOrderNumber);

        // Set delivery address
        $deliveryAddress = $orderData->deliveryAddress ?? $client->getDefaultDeliveryAddress();
        $order->setDeliveryAddress($deliveryAddress);

        // Process lines
        foreach ($orderData->lines as $lineData) {
            $article = $this->resolveArticle($lineData);

            if (!$article) {
                throw new ArticleNotFoundException($lineData);
            }

            // Check stock
            $availableStock = $this->stockService->getAvailableStock($article);
            $confirmedQuantity = min($lineData->quantity, $availableStock);

            $line = new OrderLine();
            $line->setArticle($article);
            $line->setQuantity($confirmedQuantity);
            $line->setRequestedQuantity($lineData->quantity);
            $line->setUnitPrice($this->priceService->getPrice($article, $tenant, $customer));

            $order->addLine($line);
        }

        // Calculate totals
        $this->orderCalculator->calculate($order);

        // Set payment method
        $paymentCode = $this->resolvePaymentMethod($orderData, $client);
        $order->setPaymentMethod($paymentCode->getCode());

        // Persist
        $this->entityManager->persist($order);
        $this->entityManager->flush();

        // Dispatch event
        $this->eventDispatcher->dispatch(new EdiOrderCreatedEvent($order, $client));

        return $order;
    }

    private function resolveArticle(EdiLineData $lineData): ?Article
    {
        // Try by article number
        if ($lineData->articleNumber) {
            $article = $this->articleRepository->findByNumber($lineData->articleNumber);
            if ($article) return $article;
        }

        // Try by EAN
        if ($lineData->ean) {
            $article = $this->articleRepository->findByEan($lineData->ean);
            if ($article) return $article;
        }

        // Try by MPN
        if ($lineData->mpn) {
            $article = $this->articleRepository->findByMpn($lineData->mpn);
            if ($article) return $article;
        }

        return null;
    }

    private function resolvePaymentMethod(EdiOrderData $orderData, EdiClient $client): PaymentCode
    {
        // 1. Try EDI payment method code
        if ($orderData->paymentMethod) {
            $code = $this->paymentCodeRepository->findByEdiCode($orderData->paymentMethod);
            if ($code) return $code;
        }

        // 2. Fall back to customer's default payment method
        return $client->getCustomer()->getDefaultPaymentCode();
    }
}

Admin Interface for EDI Clients

class EdiClientController
{
    #[Route('/admin/edi-clients', name: 'admin_edi_clients')]
    public function index(): Response
    {
        $user = $this->getUser();

        if ($user->getRole() === 'super_admin') {
            $clients = $this->clientRepository->findAll();
        } else {
            $clients = $this->clientRepository->findByTenant($user->getTenant());
        }

        return $this->render('admin/edi/index.html.twig', [
            'clients' => $clients,
        ]);
    }

    #[Route('/admin/edi-clients/create', name: 'admin_edi_client_create')]
    public function create(Request $request): Response
    {
        $client = new EdiClient();
        $client->setTenant($this->getTenant());

        $form = $this->createForm(EdiClientType::class, $client, [
            'tenant' => $this->getTenant(),
        ]);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            // Generate password if not provided
            $plainPassword = $form->get('plainPassword')->getData();
            if (!$plainPassword) {
                $plainPassword = $this->passwordGenerator->generate(16);
            }

            $client->setPasswordHash($this->passwordHasher->hash($plainPassword));

            // Generate API key if requested
            if ($form->get('generateApiKey')->getData()) {
                $client->setApiKey($this->apiKeyGenerator->generate());
            }

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

            // Show credentials once
            $this->addFlash('credentials', [
                'username' => $client->getUsername(),
                'password' => $plainPassword,
                'apiKey' => $client->getApiKey(),
            ]);

            return $this->redirectToRoute('admin_edi_clients');
        }

        return $this->render('admin/edi/create.html.twig', [
            'form' => $form,
        ]);
    }

    #[Route('/admin/edi-clients/{id}/regenerate-password', name: 'admin_edi_client_regenerate_password', methods: ['POST'])]
    public function regeneratePassword(int $id): Response
    {
        $client = $this->clientRepository->find($id);

        $this->denyAccessUnlessGranted('ADMIN_MANAGE_EDI', $client->getTenant());

        $newPassword = $this->passwordGenerator->generate(16);
        $client->setPasswordHash($this->passwordHasher->hash($newPassword));

        $this->entityManager->flush();

        $this->addFlash('new_password', $newPassword);

        return $this->redirectToRoute('admin_edi_client_edit', ['id' => $id]);
    }
}

CLI Command (Legacy Support)

#[AsCommand(name: 'edi:client:create', description: 'Create a new EDI client')]
class CreateEdiClientCommand extends Command
{
    protected function configure(): void
    {
        $this
            ->addArgument('username', InputArgument::REQUIRED, 'Client username')
            ->addOption('password', 'p', InputOption::VALUE_REQUIRED, 'Password')
            ->addOption('random', 'r', InputOption::VALUE_NONE, 'Generate random password')
            ->addOption('api-key', 'k', InputOption::VALUE_REQUIRED, 'API key')
            ->addOption('customer', 'c', InputOption::VALUE_REQUIRED, 'Customer ID');
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $username = $input->getArgument('username');

        // Check if exists
        if ($this->clientRepository->findByUsername($username)) {
            $output->writeln('<error>Username already exists</error>');
            return Command::FAILURE;
        }

        // Get password
        if ($input->getOption('random')) {
            $password = $this->passwordGenerator->generate(16);
        } elseif ($input->getOption('password')) {
            $password = $input->getOption('password');
        } else {
            $helper = $this->getHelper('question');
            $question = new Question('Enter password: ');
            $question->setHidden(true);
            $password = $helper->ask($input, $output, $question);
        }

        // Create client
        $client = new EdiClient();
        $client->setUsername($username);
        $client->setPasswordHash($this->passwordHasher->hash($password));
        $client->setName($username);

        if ($input->getOption('api-key')) {
            $client->setApiKey($input->getOption('api-key'));
        }

        if ($input->getOption('customer')) {
            $customer = $this->customerRepository->find($input->getOption('customer'));
            $client->setCustomer($customer);
            $client->setTenant($customer->getTenant());
        }

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

        $output->writeln('<info>EDI client created successfully</info>');
        $output->writeln("Username: {$username}");
        $output->writeln("Password: {$password}");

        if ($client->getApiKey()) {
            $output->writeln("API Key: {$client->getApiKey()}");
        }

        return Command::SUCCESS;
    }
}

Gherkin Scenarios

Feature: EDI Order Processing
  As an EDI client
  I want to place orders via XML
  So that I can integrate with my systems

  Scenario: Successful order placement
    Given I am authenticated as EDI client "warehouse-1"
    And I send an order for 4x "TYRE-001"
    And "TYRE-001" has 10 in stock
    Then the order should be accepted
    And I should receive order confirmation with order number

  Scenario: Partial stock fulfillment
    Given I am authenticated as EDI client "warehouse-1"
    And I send an order for 10x "WHEEL-001"
    And "WHEEL-001" has only 6 in stock
    Then the order should be accepted with partial fulfillment
    And the response should show 6 confirmed, 10 requested

  Scenario: Invalid authentication
    Given I send an order with wrong password
    Then I should receive 401 Unauthorized

  Scenario: Missing API key
    Given EDI client "secure-client" requires API key
    And I authenticate without API key header
    Then I should receive 401 Unauthorized

  Scenario: Price inquiry
    Given I am authenticated as EDI client "warehouse-1"
    When I send an inquiry for "TYRE-001"
    Then I should receive price and availability information

Feature: EDI Client Management
  As a Tenant Owner
  I want to manage EDI clients
  So that my B2B customers can integrate

  Scenario: Create EDI client via admin
    Given I am logged in as Tenant Owner
    When I create a new EDI client for customer "Garage XYZ"
    Then the client should be created
    And I should see the generated credentials

  Scenario: Link multiple EDI clients to one customer
    Given customer "Big Corp" exists
    When I create EDI client "bigcorp-warehouse-a"
    And I create EDI client "bigcorp-warehouse-b"
    And I link both to customer "Big Corp"
    Then orders from both clients should bill to "Big Corp"

  Scenario: Regenerate password
    Given EDI client "old-client" exists
    When I regenerate the password
    Then a new password should be generated
    And I should see the new password once

Database Schema

-- EDI clients
CREATE TABLE edi_clients (
    id INT PRIMARY KEY AUTO_INCREMENT,
    tenant_id INT NOT NULL,
    customer_id INT NOT NULL,
    username VARCHAR(50) NOT NULL UNIQUE,
    password_hash VARCHAR(255) NOT NULL,
    api_key VARCHAR(255),
    name VARCHAR(100) NOT NULL,
    delivery_street VARCHAR(255),
    delivery_number VARCHAR(10),
    delivery_postal_code VARCHAR(10),
    delivery_city VARCHAR(100),
    delivery_country_code CHAR(2),
    is_active BOOLEAN DEFAULT TRUE,
    created_at DATETIME NOT NULL,
    last_used_at DATETIME,
    FOREIGN KEY (tenant_id) REFERENCES tenants(id),
    FOREIGN KEY (customer_id) REFERENCES customers(id)
);

-- EDI request log
CREATE TABLE edi_request_log (
    id INT PRIMARY KEY AUTO_INCREMENT,
    client_id INT NOT NULL,
    request_type VARCHAR(20) NOT NULL,
    request_body TEXT,
    response_status VARCHAR(20) NOT NULL,
    response_body TEXT,
    order_id INT,
    ip_address VARCHAR(45),
    created_at DATETIME NOT NULL,
    FOREIGN KEY (client_id) REFERENCES edi_clients(id),
    FOREIGN KEY (order_id) REFERENCES orders(id),
    INDEX idx_client (client_id),
    INDEX idx_created (created_at)
);