feat: add eBay and Frappe channel adapters with order infrastructure

eBay adapter covers OAuth, inventory API, fulfillment API, taxonomy
service and webhook signature verification. Frappe ERP adapter wraps
the Frappe HTTP client for order/invoice sync.

Includes CustomerResolver, InvoiceMailer, and the EbayWebhookController
for inbound eBay marketplace notifications.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Simon Kuehn 2026-05-17 22:43:52 +00:00
parent fddfd920f5
commit 46cff4553f
18 changed files with 1185 additions and 0 deletions

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Application\Order;
use App\Domain\Order\Customer;
interface CustomerResolverInterface
{
/**
* Finds or creates a Customer using a two-stage cascade:
* Stage 1: exact platform_id match (fast path)
* Stage 2: exact lowercase(name|street|city|zip) match (cross-platform dedup)
* No match: creates new customer in DB + Frappe ERP.
*
* @param array<string, string> $address Keys: street, city, zip
*/
public function resolve(
string $platform,
string $platformUserId,
string $name,
string $email,
array $address,
): Customer;
}

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Application\Order;
use App\Domain\Order\Customer;
use App\Domain\Order\Order;
interface ErpAdapterInterface
{
/**
* Creates a Customer document in Frappe ERP.
* Returns the Frappe document name (e.g. "CUST-00001").
*/
public function createCustomer(Customer $customer): string;
/**
* Creates and submits a Sales Invoice in Frappe ERP.
* Returns the Frappe document name (e.g. "SINV-00001").
*/
public function createSalesInvoice(Order $order): string;
/**
* Fetches the PDF of a submitted Sales Invoice.
* Returns raw binary PDF content.
*/
public function fetchInvoicePdf(string $frappeInvoiceId): string;
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Application\Order;
use App\Domain\Order\Invoice;
interface InvoiceMailerInterface
{
/**
* Sends the invoice PDF to the configured supplier email address.
* The supplier then ships the item (dropshipping model).
*/
public function sendInvoice(Invoice $invoice): void;
}

View file

@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Channel\Ebay;
use App\Application\Channel\ChannelAdapterInterface;
use App\Domain\Article\Article;
use App\Domain\Article\ArticleCondition;
use App\Domain\Order\Order;
final class EbayAdapter implements ChannelAdapterInterface
{
public function __construct(
private readonly EbayInventoryApiClient $apiClient,
) {
}
public function getType(): string
{
return 'ebay';
}
public function publishListing(Article $article): string
{
$sku = $article->getSku();
$this->apiClient->upsertInventoryItem($sku, [
'availability' => [
'shipToLocationAvailability' => [
'quantity' => $article->getStock(),
],
],
'condition' => $this->mapCondition($article->getCondition()),
'conditionDescription' => $article->getConditionNotes() ?? '',
'product' => [
'title' => $article->getEbayTitle() ?? $article->getSku(),
'description' => $article->getEbayDescription() ?? '',
'aspects' => $this->buildAspects($article),
],
]);
$offerId = $this->apiClient->createOffer([
'sku' => $sku,
'marketplaceId' => 'EBAY_DE',
'format' => 'FIXED_PRICE',
'availableQuantity' => $article->getStock(),
'pricingSummary' => [
'price' => [
'currency' => 'EUR',
'value' => number_format((float) ($article->getListingPrice() ?? '0'), 2, '.', ''),
],
],
'listingDescription' => $article->getEbayDescription() ?? '',
'categoryId' => $this->getCategoryId(),
]);
return $this->apiClient->publishOffer($offerId);
}
public function updateStock(Article $article, int $stock): void
{
$this->apiClient->bulkUpdateInventoryItems([
'requests' => [
[
'sku' => $article->getSku(),
'shipToLocationAvailability' => [
'quantity' => $stock,
],
],
],
]);
}
public function deactivateListing(Article $article): void
{
$listingId = $article->getEbayListingId();
if (null === $listingId) {
return;
}
$this->apiClient->withdrawOffer($listingId);
}
public function pushTracking(Order $order): void
{
if (null === $order->getTrackingNumber()) {
throw new \RuntimeException('Order has no tracking number');
}
$this->apiClient->addTrackingToOrder($order->getPlatformOrderId(), [
'lineItems' => [
['lineItemId' => $order->getPlatformOrderId(), 'quantity' => 1],
],
'shippingCarrierCode' => $order->getCarrier() ?? 'DHL',
'trackingNumber' => $order->getTrackingNumber(),
]);
}
private function mapCondition(ArticleCondition $condition): string
{
return match ($condition) {
ArticleCondition::New => 'NEW',
ArticleCondition::LikeNew => 'LIKE_NEW',
ArticleCondition::Good => 'GOOD',
ArticleCondition::Acceptable => 'ACCEPTABLE',
};
}
/** @return array<string, list<string>> */
private function buildAspects(Article $article): array
{
$aspects = [];
foreach ($article->getAttributeValues() as $value) {
$name = $value->getAttributeDefinition()->getName();
$aspects[$name] = [$value->getValue()];
}
return $aspects;
}
private function getCategoryId(): string
{
return '177';
}
}

View file

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Channel\Ebay;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class EbayFulfillmentApiClient
{
public function __construct(
private readonly EbayOAuthClient $oauthClient,
private readonly HttpClientInterface $httpClient,
private readonly string $apiBaseUrl,
) {
}
/**
* Fetches a full order from eBay Sell Fulfillment API v1.
*
* Returns a normalized array with keys:
* orderId, buyerUsername, buyerName, buyerEmail,
* shippingStreet, shippingCity, shippingZip, shippingCountry,
* ebayListingId, salePrice, saleDate
*
* @return array<string, string>
*/
public function getOrder(string $orderId): array
{
$token = $this->oauthClient->getAccessToken();
$response = $this->httpClient->request(
'GET',
$this->apiBaseUrl.'/sell/fulfillment/v1/order/'.urlencode($orderId),
['headers' => ['Authorization' => 'Bearer '.$token]],
);
/** @var array<string, mixed> $data */
$data = $response->toArray();
$ship = $this->nestedArray($data, 'fulfillmentStartInstructions', 0, 'shippingStep', 'shipTo');
$addr = $this->nestedArray($ship, 'contactAddress');
$line = $this->nestedArray($data, 'lineItems', 0);
$buyer = $this->nestedArray($data, 'buyer');
$buyerAddr = $this->nestedArray($buyer, 'buyerRegistrationAddress');
$total = $this->nestedArray($data, 'pricingSummary', 'total');
return [
'orderId' => $this->str($data['orderId'] ?? $orderId),
'buyerUsername' => $this->str($buyer['username'] ?? ''),
'buyerName' => $this->str($ship['fullName'] ?? $buyerAddr['fullName'] ?? ''),
'buyerEmail' => $this->str($ship['email'] ?? $buyerAddr['email'] ?? ($this->str($buyer['username'] ?? '').'@members.ebay.com')),
'shippingStreet' => $this->str($addr['addressLine1'] ?? ''),
'shippingCity' => $this->str($addr['city'] ?? ''),
'shippingZip' => $this->str($addr['postalCode'] ?? ''),
'shippingCountry' => $this->str($addr['countryCode'] ?? 'DE'),
'ebayListingId' => $this->str($line['legacyItemId'] ?? ''),
'salePrice' => $this->str($total['value'] ?? '0.00'),
'saleDate' => $this->str($data['creationDate'] ?? (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)),
];
}
/**
* Safely retrieves a nested value from a mixed array using a variadic key path.
* Each key can be a string or int.
*
* @param array<string, mixed> $data
*
* @return array<string, mixed>
*/
private function nestedArray(array $data, string|int ...$keys): array
{
$current = $data;
foreach ($keys as $key) {
if (!\array_key_exists($key, $current)) {
return [];
}
$val = $current[$key];
if (!\is_array($val)) {
return [];
}
$current = $val;
}
/** @var array<string, mixed> $current */
return $current;
}
private function str(mixed $value): string
{
return \is_scalar($value) ? (string) $value : '';
}
}

View file

@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Channel\Ebay;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class EbayInventoryApiClient
{
private const INVENTORY_BASE = '/sell/inventory/v1';
private const FULFILLMENT_BASE = '/sell/fulfillment/v2';
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly EbayOAuthClient $oauthClient,
private readonly string $apiBaseUrl,
private readonly string $marketplaceId,
) {
}
/**
* Creates or updates an inventory item (product + stock).
*
* @param array<string, mixed> $body
*/
public function upsertInventoryItem(string $sku, array $body): void
{
$this->request('PUT', self::INVENTORY_BASE.'/inventory_item/'.urlencode($sku), $body);
}
/**
* Creates an offer (links inventory item to a listing).
*
* @param array<string, mixed> $body
*
* @return string offerId
*/
public function createOffer(array $body): string
{
/** @var array{offerId: string} $response */
$response = $this->request('POST', self::INVENTORY_BASE.'/offer', $body);
return $response['offerId'];
}
/**
* Updates an existing offer.
*
* @param array<string, mixed> $body
*/
public function updateOffer(string $offerId, array $body): void
{
$this->request('PUT', self::INVENTORY_BASE.'/offer/'.urlencode($offerId), $body);
}
/** @return string listingId */
public function publishOffer(string $offerId): string
{
/** @var array{listingId: string} $response */
$response = $this->request('POST', self::INVENTORY_BASE.'/offer/'.urlencode($offerId).'/publish', []);
return $response['listingId'];
}
public function withdrawOffer(string $offerId): void
{
$this->request('POST', self::INVENTORY_BASE.'/offer/'.urlencode($offerId).'/withdraw', []);
}
/** @param array<string, mixed> $quantityUpdate */
public function bulkUpdateInventoryItems(array $quantityUpdate): void
{
$this->request('POST', self::INVENTORY_BASE.'/bulk_update_price_quantity', $quantityUpdate);
}
/**
* Adds tracking info to an order.
*
* @param array<string, mixed> $body
*/
public function addTrackingToOrder(string $orderId, array $body): void
{
$this->request('POST', self::FULFILLMENT_BASE.'/order/'.urlencode($orderId).'/shipping_fulfillment', $body);
}
/**
* @param array<string, mixed> $body
*
* @return array<string, mixed>
*/
private function request(string $method, string $path, array $body): array
{
$token = $this->oauthClient->getAccessToken();
$options = [
'headers' => [
'Authorization' => 'Bearer '.$token,
'Content-Type' => 'application/json',
'X-EBAY-C-MARKETPLACE-ID' => $this->marketplaceId,
],
];
if ([] !== $body) {
$options['json'] = $body;
}
$response = $this->httpClient->request($method, $this->apiBaseUrl.$path, $options);
$statusCode = $response->getStatusCode();
if ($statusCode >= 400) {
$content = $response->getContent(false);
throw new \RuntimeException("eBay API error {$statusCode}: {$content}");
}
if (204 === $statusCode || '' === $response->getContent(false)) {
return [];
}
/** @var array<string, mixed> $data */
$data = $response->toArray();
return $data;
}
}

View file

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Channel\Ebay;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class EbayOAuthClient
{
private const TOKEN_CACHE_KEY = 'ebay_access_token';
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly CacheInterface $cache,
private readonly string $clientId,
private readonly string $clientSecret,
private readonly string $oauthBaseUrl,
) {
}
public function getAccessToken(): string
{
return $this->cache->get(self::TOKEN_CACHE_KEY, function (ItemInterface $item): string {
$credentials = base64_encode($this->clientId.':'.$this->clientSecret);
$response = $this->httpClient->request('POST', $this->oauthBaseUrl.'/identity/v1/oauth2/token', [
'headers' => [
'Authorization' => 'Basic '.$credentials,
'Content-Type' => 'application/x-www-form-urlencoded',
],
'body' => 'grant_type=client_credentials&scope=https%3A%2F%2Fapi.ebay.com%2Foauth%2Fapi_scope',
]);
/** @var array{access_token: string, expires_in: int} $data */
$data = $response->toArray();
$item->expiresAfter((int) ($data['expires_in'] * 0.8));
return $data['access_token'];
});
}
}

View file

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Channel\Ebay;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class EbayTaxonomyService
{
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly EbayOAuthClient $oauthClient,
private readonly CacheInterface $cache,
private readonly string $apiBaseUrl,
private readonly string $marketplaceId,
) {
}
/**
* Returns the required item aspect names for a given eBay category.
*
* @return list<array{name: string, required: bool, values: list<string>}>
*/
public function getCategoryAspects(string $categoryId): array
{
$cacheKey = 'ebay_aspects_'.md5($this->marketplaceId.$categoryId);
return $this->cache->get($cacheKey, function (ItemInterface $item) use ($categoryId): array {
$item->expiresAfter(86400 * 7);
$token = $this->oauthClient->getAccessToken();
$response = $this->httpClient->request(
'GET',
$this->apiBaseUrl.'/commerce/taxonomy/v1/category_tree/'.$this->getTreeId().'/get_item_aspects_for_category',
[
'headers' => [
'Authorization' => 'Bearer '.$token,
'X-EBAY-C-MARKETPLACE-ID' => $this->marketplaceId,
],
'query' => ['category_id' => $categoryId],
],
);
/** @var array{aspects?: list<array{localizedAspectName: string, aspectConstraint?: array{aspectRequired?: bool}, aspectValues?: list<array{localizedValue: string}>}>} $data */
$data = $response->toArray();
$aspects = [];
foreach ($data['aspects'] ?? [] as $aspect) {
$aspects[] = [
'name' => $aspect['localizedAspectName'],
'required' => $aspect['aspectConstraint']['aspectRequired'] ?? false,
'values' => array_column($aspect['aspectValues'] ?? [], 'localizedValue'),
];
}
return $aspects;
});
}
private function getTreeId(): string
{
return match ($this->marketplaceId) {
'EBAY_DE' => '77',
'EBAY_US' => '0',
'EBAY_UK' => '3',
default => '77',
};
}
}

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Channel\Ebay;
final class EbayWebhookVerifier
{
public function __construct(
private readonly string $verificationToken,
private readonly string $endpointUrl,
) {
}
/**
* Verifies the HMAC-SHA256 signature on an eBay webhook notification.
* eBay signature: base64(SHA256(requestBody + verificationToken + endpointUrl)).
*/
public function verify(string $requestBody, string $signatureHeader): bool
{
$expected = base64_encode(
hash('sha256', $requestBody.$this->verificationToken.$this->endpointUrl, binary: true),
);
return hash_equals($expected, $signatureHeader);
}
/**
* Returns the expected challenge response for endpoint registration.
* eBay sends GET ?challenge_code=XXX we respond with SHA256(code + token + url).
*/
public function challengeResponse(string $challengeCode): string
{
return hash('sha256', $challengeCode.$this->verificationToken.$this->endpointUrl);
}
}

View file

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Channel\Frappe;
use App\Application\Order\ErpAdapterInterface;
use App\Domain\Order\Customer;
use App\Domain\Order\Order;
final class FrappeErpAdapter implements ErpAdapterInterface
{
public function __construct(
private readonly FrappeHttpClient $frappe,
private readonly string $genericItemCode,
) {
}
public function createCustomer(Customer $customer): string
{
$data = [
'customer_name' => $customer->getName(),
'customer_type' => 'Individual',
'customer_group' => 'Individual',
'territory' => 'Germany',
'custom_superseller_customer_id' => $customer->getId()->toRfc4122(),
];
$ebayUserId = $customer->getPlatformId('ebay');
if (null !== $ebayUserId) {
$data['custom_ebay_user_id'] = $ebayUserId;
}
$response = $this->frappe->post('/api/resource/Customer', $data);
/** @var array{data: array{name: string}} $response */
return $response['data']['name'];
}
public function createSalesInvoice(Order $order): string
{
$article = $order->getArticle();
$customer = $order->getCustomer();
$draft = $this->frappe->post('/api/resource/Sales Invoice', [
'customer' => $customer->getFrappeCustomerId(),
'posting_date' => $order->getSaleDate()->format('Y-m-d'),
'due_date' => $order->getSaleDate()->format('Y-m-d'),
'items' => [
[
'item_code' => $this->genericItemCode,
'item_name' => $article->getEbayTitle() ?? $article->getSku(),
'description' => \sprintf('%s — Inventar: %s', $article->getEbayTitle() ?? $article->getSku(), $article->getInventoryNumber()),
'qty' => 1,
'rate' => $order->getSalePrice(),
],
],
'custom_platform_order_id' => $order->getPlatformOrderId(),
'custom_article_inventory_number' => $article->getInventoryNumber(),
]);
/** @var array{data: array{name: string}} $draft */
$invoiceName = $draft['data']['name'];
$this->frappe->post('/api/resource/Sales Invoice/'.$invoiceName.'/submit', []);
return $invoiceName;
}
public function fetchInvoicePdf(string $frappeInvoiceId): string
{
$path = http_build_query([
'doctype' => 'Sales Invoice',
'name' => $frappeInvoiceId,
'format' => 'Standard',
'no_letterhead' => '0',
'_lang' => 'de',
]);
return $this->frappe->getContent('/api/method/frappe.utils.print_format.download_pdf?'.$path);
}
}

View file

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Channel\Frappe;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class FrappeHttpClient
{
private string $authHeader;
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly string $baseUrl,
string $apiKey,
string $apiSecret,
) {
$this->authHeader = "token {$apiKey}:{$apiSecret}";
}
/**
* POST to a Frappe resource endpoint.
*
* @param array<string, mixed> $data
*
* @return array<string, mixed>
*/
public function post(string $path, array $data): array
{
$response = $this->httpClient->request('POST', $this->baseUrl.$path, [
'headers' => [
'Authorization' => $this->authHeader,
'Content-Type' => 'application/json',
],
'json' => $data,
]);
/** @var array<string, mixed> $result */
$result = $response->toArray();
return $result;
}
/** GET raw binary content (for PDF downloads). */
public function getContent(string $path): string
{
$response = $this->httpClient->request('GET', $this->baseUrl.$path, [
'headers' => ['Authorization' => $this->authHeader],
]);
return $response->getContent();
}
}

View file

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Http\Controller\Webhook;
use App\Infrastructure\Channel\Ebay\EbayWebhookVerifier;
use App\Infrastructure\Messenger\Message\OrderReceivedMessage;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/webhooks/ebay', name: 'webhook_ebay')]
final class EbayWebhookController extends AbstractController
{
public function __construct(
private readonly EbayWebhookVerifier $verifier,
private readonly MessageBusInterface $bus,
private readonly LoggerInterface $logger,
) {
}
#[Route('', name: '_challenge', methods: ['GET'])]
public function challenge(Request $request): JsonResponse
{
$challengeCode = $request->query->getString('challenge_code');
if ('' === $challengeCode) {
return $this->json(['error' => 'Missing challenge_code'], Response::HTTP_BAD_REQUEST);
}
return $this->json(['challengeResponse' => $this->verifier->challengeResponse($challengeCode)]);
}
#[Route('', name: '_notify', methods: ['POST'])]
public function notify(Request $request): Response
{
$signature = $request->headers->get('X-EBAY-SIGNATURE', '');
$body = $request->getContent();
if (!$this->verifier->verify($body, $signature)) {
$this->logger->warning('eBay webhook: invalid signature', ['ip' => $request->getClientIp()]);
return new Response('', Response::HTTP_UNAUTHORIZED);
}
try {
/** @var array{notification?: array{topic?: string, data?: array<string, mixed>}} $payload */
$payload = json_decode($body, true, 512, \JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return new Response('', Response::HTTP_BAD_REQUEST);
}
$topic = $payload['notification']['topic'] ?? '';
$data = $payload['notification']['data'] ?? [];
$this->logger->info('eBay webhook received', ['topic' => $topic]);
match ($topic) {
'FIXED_PRICE_TRANSACTION',
'AUCTION_CHECKOUT_COMPLETE' => $this->handleOrderEvent($data),
'MARKETPLACE_ACCOUNT_DELETION' => $this->handleAccountDeletion($data),
default => null,
};
return new Response('', Response::HTTP_OK);
}
/** @param array<string, mixed> $data */
private function handleOrderEvent(array $data): void
{
$rawOrderId = $data['orderId'] ?? null;
if (null === $rawOrderId) {
/** @var array<string, mixed> $orderData */
$orderData = \is_array($data['order'] ?? null) ? $data['order'] : [];
$rawOrderId = $orderData['orderId'] ?? '';
}
$orderId = \is_scalar($rawOrderId) ? (string) $rawOrderId : '';
if ('' === $orderId) {
$this->logger->error('eBay webhook: order event with no orderId', ['data' => $data]);
return;
}
$this->bus->dispatch(new OrderReceivedMessage(
platformOrderId: $orderId,
platformType: 'ebay',
rawPayload: $data,
));
}
/** @param array<string, mixed> $data */
private function handleAccountDeletion(array $data): void
{
$this->logger->info('eBay MARKETPLACE_ACCOUNT_DELETION received', ['data' => $data]);
}
}

View file

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Mail;
use App\Application\Order\InvoiceMailerInterface;
use App\Domain\Order\Invoice;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
final class SymfonyInvoiceMailer implements InvoiceMailerInterface
{
public function __construct(
private readonly MailerInterface $mailer,
private readonly string $supplierEmail,
private readonly string $senderEmail,
) {
}
public function sendInvoice(Invoice $invoice): void
{
$order = $invoice->getOrder();
$article = $order->getArticle();
$customer = $order->getCustomer();
$body = \sprintf(
"Neue Bestellung eingegangen — bitte sofort versenden.\n\n"
."Bestellnummer : %s\n"
."Artikel : %s\n"
."Inventarnummer: %s\n"
."Käufer : %s\n"
."Verkaufspreis : €%s\n\n"
.'Die Rechnung liegt diesem E-Mail als PDF bei.',
$order->getPlatformOrderId(),
$article->getEbayTitle() ?? $article->getSku(),
$article->getInventoryNumber(),
$customer->getName(),
$order->getSalePrice(),
);
$email = (new Email())
->from($this->senderEmail)
->to($this->supplierEmail)
->subject('Neue Bestellung: '.$order->getPlatformOrderId())
->text($body)
->attachFromPath(
$invoice->getFullPath(),
'Rechnung-'.$invoice->getFrappeInvoiceId().'.pdf',
'application/pdf',
);
$this->mailer->send($email);
}
}

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Order;
use App\Application\Order\CustomerResolverInterface;
use App\Application\Order\ErpAdapterInterface;
use App\Domain\Order\Customer;
use App\Domain\Order\Repository\CustomerRepositoryInterface;
final class CustomerResolver implements CustomerResolverInterface
{
public function __construct(
private readonly CustomerRepositoryInterface $customers,
private readonly ErpAdapterInterface $erp,
) {
}
public function resolve(
string $platform,
string $platformUserId,
string $name,
string $email,
array $address,
): Customer {
$customer = $this->customers->findByPlatformId($platform, $platformUserId);
if (null !== $customer) {
return $customer;
}
$probe = new Customer($name, $email, $address);
$customer = $this->customers->findByMatchingKey($probe->getMatchingKey());
if (null !== $customer) {
$customer->addPlatformId($platform, $platformUserId);
$this->customers->save($customer);
return $customer;
}
$customer = new Customer($name, $email, $address);
$customer->addPlatformId($platform, $platformUserId);
$frappeId = $this->erp->createCustomer($customer);
$customer->setFrappeCustomerId($frappeId);
$this->customers->save($customer);
return $customer;
}
}

View file

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Infrastructure\Channel\Ebay;
use App\Domain\Article\Article;
use App\Domain\Article\ArticleCondition;
use App\Domain\Article\ArticleType;
use App\Infrastructure\Channel\Ebay\EbayAdapter;
use App\Infrastructure\Channel\Ebay\EbayInventoryApiClient;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
final class EbayAdapterTest extends TestCase
{
private EbayInventoryApiClient&MockObject $apiClient;
private EbayAdapter $adapter;
private Article $article;
protected function setUp(): void
{
$this->apiClient = $this->createMock(EbayInventoryApiClient::class);
$this->adapter = new EbayAdapter($this->apiClient);
$this->article = new Article(new ArticleType('Notebook'), 'NB-001', 'INV-001', 1, ArticleCondition::Good);
$this->article->setEbayTitle('Dell Latitude 5520');
$this->article->setListingPrice('299.00');
}
public function test_get_type_returns_ebay(): void
{
$this->assertSame('ebay', $this->adapter->getType());
}
public function test_publish_listing_calls_upsert_create_and_publish(): void
{
$this->apiClient->expects($this->once())->method('upsertInventoryItem');
$this->apiClient->expects($this->once())->method('createOffer')->willReturn('offer-123');
$this->apiClient->expects($this->once())->method('publishOffer')->with('offer-123')->willReturn('listing-456');
$listingId = $this->adapter->publishListing($this->article);
$this->assertSame('listing-456', $listingId);
}
public function test_deactivate_listing_withdraws_offer(): void
{
$this->article->setEbayListingId('offer-123');
$this->apiClient->expects($this->once())->method('withdrawOffer')->with('offer-123');
$this->adapter->deactivateListing($this->article);
}
public function test_deactivate_listing_is_noop_when_no_listing_id(): void
{
$this->apiClient->expects($this->never())->method('withdrawOffer');
$this->adapter->deactivateListing($this->article);
}
}

View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Infrastructure\Channel\Ebay;
use App\Infrastructure\Channel\Ebay\EbayWebhookVerifier;
use PHPUnit\Framework\TestCase;
final class EbayWebhookVerifierTest extends TestCase
{
private EbayWebhookVerifier $verifier;
protected function setUp(): void
{
$this->verifier = new EbayWebhookVerifier(
verificationToken: 'my-secret-token',
endpointUrl: 'https://example.com/webhooks/ebay',
);
}
public function test_valid_signature_passes(): void
{
$body = '{"notification":{"data":{"orderId":"123"}}}';
$expected = base64_encode(hash('sha256', $body.'my-secret-tokenhttps://example.com/webhooks/ebay', binary: true));
$this->assertTrue($this->verifier->verify($body, $expected));
}
public function test_invalid_signature_fails(): void
{
$this->assertFalse($this->verifier->verify('{"body":"x"}', 'invalidsignature'));
}
public function test_challenge_response_returns_correct_hash(): void
{
$challengeCode = 'abc123';
$expected = hash('sha256', $challengeCode.'my-secret-tokenhttps://example.com/webhooks/ebay');
$this->assertSame($expected, $this->verifier->challengeResponse($challengeCode));
}
}

View file

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Infrastructure\Channel\Frappe;
use App\Domain\Article\Article;
use App\Domain\Article\ArticleCondition;
use App\Domain\Article\ArticleType;
use App\Domain\Channel\Platform;
use App\Domain\Order\Customer;
use App\Domain\Order\Order;
use App\Infrastructure\Channel\Frappe\FrappeErpAdapter;
use App\Infrastructure\Channel\Frappe\FrappeHttpClient;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
final class FrappeErpAdapterTest extends TestCase
{
private FrappeHttpClient&MockObject $frappe;
private FrappeErpAdapter $adapter;
protected function setUp(): void
{
$this->frappe = $this->createMock(FrappeHttpClient::class);
$this->adapter = new FrappeErpAdapter($this->frappe, 'REFURB-HW');
}
public function test_create_customer_returns_frappe_id(): void
{
$this->frappe
->method('post')
->with('/api/resource/Customer', $this->isType('array'))
->willReturn(['data' => ['name' => 'CUST-00001']]);
$customer = new Customer('Max Mustermann', 'max@test.de', ['street' => 'Str 1', 'city' => 'Berlin', 'zip' => '10115']);
$customer->addPlatformId('ebay', 'buyer123');
$result = $this->adapter->createCustomer($customer);
$this->assertSame('CUST-00001', $result);
}
public function test_create_sales_invoice_submits_and_returns_id(): void
{
$this->frappe
->expects($this->exactly(2))
->method('post')
->willReturnOnConsecutiveCalls(
['data' => ['name' => 'SINV-00001']],
['data' => ['name' => 'SINV-00001', 'docstatus' => 1]],
);
$customer = new Customer('Max Mustermann', 'max@test.de', ['street' => 'Str 1', 'city' => 'Berlin', 'zip' => '10115']);
$customer->setFrappeCustomerId('CUST-00001');
$order = new Order(
new Article(new ArticleType('Laptop'), 'LAP-001', 'INV-001', 1, ArticleCondition::Good),
$customer,
new Platform('ebay', 'eBay DE'),
'ORDER-001',
'299.99',
new \DateTimeImmutable('2026-05-13'),
);
$result = $this->adapter->createSalesInvoice($order);
$this->assertSame('SINV-00001', $result);
}
public function test_fetch_invoice_pdf_returns_binary(): void
{
$this->frappe
->method('getContent')
->with($this->stringContains('SINV-00001'))
->willReturn('%PDF-binary-content');
$result = $this->adapter->fetchInvoicePdf('SINV-00001');
$this->assertStringStartsWith('%PDF', $result);
}
}

View file

@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Infrastructure\Order;
use App\Application\Order\ErpAdapterInterface;
use App\Domain\Order\Customer;
use App\Domain\Order\Repository\CustomerRepositoryInterface;
use App\Infrastructure\Order\CustomerResolver;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
final class CustomerResolverTest extends TestCase
{
private CustomerRepositoryInterface&MockObject $customerRepo;
private ErpAdapterInterface&MockObject $erp;
private CustomerResolver $resolver;
protected function setUp(): void
{
$this->customerRepo = $this->createMock(CustomerRepositoryInterface::class);
$this->erp = $this->createMock(ErpAdapterInterface::class);
$this->resolver = new CustomerResolver($this->customerRepo, $this->erp);
}
public function test_stage_1_platform_id_match_returns_existing_customer_without_erp_call(): void
{
$existing = new Customer('Max Mustermann', 'max@test.de', ['street' => 'Musterstr 1', 'city' => 'Berlin', 'zip' => '10115']);
$existing->addPlatformId('ebay', 'buyer123');
$this->customerRepo
->method('findByPlatformId')
->with('ebay', 'buyer123')
->willReturn($existing);
$this->erp->expects($this->never())->method('createCustomer');
$this->customerRepo->expects($this->never())->method('save');
$result = $this->resolver->resolve(
'ebay', 'buyer123',
'Max Mustermann', 'max@test.de',
['street' => 'Musterstr 1', 'city' => 'Berlin', 'zip' => '10115'],
);
$this->assertSame($existing, $result);
}
public function test_stage_2_address_match_adds_platform_id_and_saves(): void
{
$existing = new Customer('Max Mustermann', 'max@test.de', ['street' => 'Musterstr 1', 'city' => 'Berlin', 'zip' => '10115']);
$this->customerRepo->method('findByPlatformId')->willReturn(null);
$this->customerRepo
->method('findByMatchingKey')
->with($existing->getMatchingKey())
->willReturn($existing);
$this->customerRepo->expects($this->once())->method('save')->with($existing);
$this->erp->expects($this->never())->method('createCustomer');
$result = $this->resolver->resolve(
'ebay', 'buyer456',
'Max Mustermann', 'max@test.de',
['street' => 'Musterstr 1', 'city' => 'Berlin', 'zip' => '10115'],
);
$this->assertSame($existing, $result);
$this->assertSame('buyer456', $result->getPlatformId('ebay'));
}
public function test_no_match_creates_new_customer_via_erp(): void
{
$this->customerRepo->method('findByPlatformId')->willReturn(null);
$this->customerRepo->method('findByMatchingKey')->willReturn(null);
$this->customerRepo->expects($this->once())->method('save');
$this->erp->method('createCustomer')->willReturn('CUST-99999');
$result = $this->resolver->resolve(
'ebay', 'newbuyer',
'Neue Käuferin', 'neu@test.de',
['street' => 'Neustr 5', 'city' => 'München', 'zip' => '80333'],
);
$this->assertSame('CUST-99999', $result->getFrappeCustomerId());
$this->assertSame('newbuyer', $result->getPlatformId('ebay'));
$this->assertSame('Neue Käuferin', $result->getName());
}
}