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:
POST /edi->EdiController::indexPOST /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)
);