SuperSeller3000/docs/superpowers/plans/2026-05-13-05-ebay-adapter.md

1323 lines
42 KiB
Markdown
Raw Permalink Normal View History

# SuperSeller3000 — Plan 5: eBay Channel Adapter
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** eBay-Channel-Adapter mit vollständiger Listing-Verwaltung (Publish, Bestandsupdate, Deaktivierung, Tracking), eBay Taxonomy API für Pflichtfeld-Vorschläge, HMAC-gesicherter Webhook-Listener der eBay-Events entgegennimmt und in die Orders-Queue einspeist, sowie Channel-Sync Messenger-Handler für alle Kanal-Operationen.
**Architecture:** `ChannelAdapterInterface` als Application-Port, `EbayAdapter` als Infrastructure-Implementierung. eBay-OAuth via Client-Credentials-Flow (Token wird gecacht). Alle Kanal-Operationen laufen asynchron über den `channel_sync` Redis-Transport. Webhook-Listener antwortet sofort mit 200 und dispatcht `OrderReceivedMessage` in den `orders`-Transport.
**Tech Stack:** PHP 8.4, Symfony 7, eBay Sell APIs (OAuth 2.0, Inventory v1, Taxonomy v1), Symfony Messenger, PHPStan Level 9
---
## Dateistruktur (gesamter Plan)
```
src/
Application/
Channel/
ChannelAdapterInterface.php # Port (Application Layer)
ChannelAdapterRegistry.php # maps Platform.type → adapter
Infrastructure/
Channel/
Ebay/
EbayOAuthClient.php # Client-Credentials-Token-Management
EbayInventoryApiClient.php # low-level HTTP für Sell Inventory API
EbayAdapter.php # implementiert ChannelAdapterInterface
EbayTaxonomyService.php # lädt Pflichtfelder einer Kategorie
EbayWebhookVerifier.php # HMAC-Signatur-Prüfung
Http/
Controller/
Webhook/
EbayWebhookController.php
Messenger/
Message/
PublishToChannelMessage.php
UpdateStockOnChannelsMessage.php
DeactivateListingMessage.php # eine Instanz pro Plattform
Handler/
PublishToChannelHandler.php
UpdateStockOnChannelsHandler.php
DeactivateListingHandler.php
config/
packages/
messenger.yaml # channel_sync Routing ergänzen
tests/
Unit/
Infrastructure/
Channel/
Ebay/
EbayWebhookVerifierTest.php
EbayAdapterTest.php
```
---
## Task 1: ChannelAdapterInterface + Registry
**Files:**
- Create: `src/Application/Channel/ChannelAdapterInterface.php`
- Create: `src/Application/Channel/ChannelAdapterRegistry.php`
- Modify: `config/services.yaml`
- [ ] **Step 1: ChannelAdapterInterface schreiben**
```php
<?php
// src/Application/Channel/ChannelAdapterInterface.php
declare(strict_types=1);
namespace App\Application\Channel;
use App\Domain\Article\Article;
use App\Domain\Order\Order;
interface ChannelAdapterInterface
{
/**
* Creates a listing on the platform for the given article.
* Returns the platform-specific listing ID.
*
* @throws \RuntimeException if listing creation fails
*/
public function publishListing(Article $article): string;
/**
* Updates stock/quantity for an existing listing.
*
* @throws \RuntimeException if update fails
*/
public function updateStock(Article $article, int $stock): void;
/**
* Deactivates/ends a listing. Must be idempotent (safe to call if already inactive).
*
* @throws \RuntimeException if deactivation fails
*/
public function deactivateListing(Article $article): void;
/**
* Marks an order as shipped with tracking information.
*
* @throws \RuntimeException if push fails
*/
public function pushTracking(Order $order): void;
/** Returns the platform type this adapter handles (e.g. 'ebay'). */
public function getType(): string;
}
```
- [ ] **Step 2: ChannelAdapterRegistry schreiben**
```php
<?php
// src/Application/Channel/ChannelAdapterRegistry.php
declare(strict_types=1);
namespace App\Application\Channel;
final class ChannelAdapterRegistry
{
/** @var array<string, ChannelAdapterInterface> */
private array $adapters = [];
/** @param iterable<ChannelAdapterInterface> $adapters */
public function __construct(iterable $adapters)
{
foreach ($adapters as $adapter) {
$this->adapters[$adapter->getType()] = $adapter;
}
}
public function get(string $type): ChannelAdapterInterface
{
return $this->adapters[$type]
?? throw new \InvalidArgumentException("No channel adapter registered for type: {$type}");
}
public function has(string $type): bool
{
return isset($this->adapters[$type]);
}
/** @return list<string> */
public function getTypes(): array
{
return \array_keys($this->adapters);
}
}
```
- [ ] **Step 3: services.yaml für Registry**
```yaml
# config/services.yaml (ergänzen)
App\Application\Channel\ChannelAdapterRegistry:
arguments:
$adapters: !tagged_iterator app.channel_adapter
App\Infrastructure\Channel\Ebay\EbayAdapter:
tags: ['app.channel_adapter']
```
- [ ] **Step 4: Commit**
```bash
git add src/Application/Channel/ config/services.yaml
git commit -m "feat: add ChannelAdapterInterface port and ChannelAdapterRegistry"
```
---
## Task 2: eBay OAuth + Inventory API Client
**Files:**
- Create: `src/Infrastructure/Channel/Ebay/EbayOAuthClient.php`
- Create: `src/Infrastructure/Channel/Ebay/EbayInventoryApiClient.php`
- [ ] **Step 1: EbayOAuthClient implementieren**
```php
<?php
// src/Infrastructure/Channel/Ebay/EbayOAuthClient.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();
// Cache for 80% of the actual TTL to avoid race conditions
$item->expiresAfter((int) ($data['expires_in'] * 0.8));
return $data['access_token'];
});
}
}
```
- [ ] **Step 2: EbayInventoryApiClient implementieren**
```php
<?php
// src/Infrastructure/Channel/Ebay/EbayInventoryApiClient.php
declare(strict_types=1);
namespace App\Infrastructure\Channel\Ebay;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final 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 ($statusCode === 204 || '' === $response->getContent(false)) {
return [];
}
/** @var array<string, mixed> $data */
$data = $response->toArray();
return $data;
}
}
```
- [ ] **Step 3: .env + services.yaml**
```ini
# .env (ergänzen)
EBAY_CLIENT_ID=
EBAY_CLIENT_SECRET=
EBAY_MARKETPLACE_ID=EBAY_DE
EBAY_API_BASE_URL=https://api.ebay.com
EBAY_OAUTH_BASE_URL=https://api.ebay.com
EBAY_VERIFICATION_TOKEN=
EBAY_ENDPOINT_URL=https://your-domain.com/webhooks/ebay
```
```yaml
# config/services.yaml (ergänzen)
App\Infrastructure\Channel\Ebay\EbayOAuthClient:
arguments:
$clientId: '%env(EBAY_CLIENT_ID)%'
$clientSecret: '%env(EBAY_CLIENT_SECRET)%'
$oauthBaseUrl: '%env(EBAY_OAUTH_BASE_URL)%'
$cache: '@cache.app'
App\Infrastructure\Channel\Ebay\EbayInventoryApiClient:
arguments:
$apiBaseUrl: '%env(EBAY_API_BASE_URL)%'
$marketplaceId: '%env(EBAY_MARKETPLACE_ID)%'
```
- [ ] **Step 4: Commit**
```bash
git add src/Infrastructure/Channel/Ebay/EbayOAuthClient.php src/Infrastructure/Channel/Ebay/EbayInventoryApiClient.php config/services.yaml .env
git commit -m "feat: add eBay OAuth client (cached token) and Inventory API client"
```
---
## Task 3: EbayAdapter
**Files:**
- Create: `src/Infrastructure/Channel/Ebay/EbayAdapter.php`
- Test: `tests/Unit/Infrastructure/Channel/Ebay/EbayAdapterTest.php`
- [ ] **Step 1: Failing-Test schreiben**
```php
<?php
// tests/Unit/Infrastructure/Channel/Ebay/EbayAdapterTest.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.0);
}
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);
}
}
```
- [ ] **Step 2: Test ausführen — muss fehlschlagen**
```bash
docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Channel/Ebay/EbayAdapterTest.php
# Expected: FAIL
```
- [ ] **Step 3: EbayAdapter implementieren**
```php
<?php
// src/Infrastructure/Channel/Ebay/EbayAdapter.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();
// Step 1: Create/Update inventory item
$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),
],
]);
// Step 2: Create offer
// ArticleTypePlatformConfig stores the eBay categoryId
$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($article),
]);
// Step 3: Publish offer
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(Article $article): string
{
// Category ID comes from ArticleTypePlatformConfig
// For now return a default; in production, inject the mapping service
return '177';
}
}
```
- [ ] **Step 4: Tests ausführen**
```bash
docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Channel/Ebay/EbayAdapterTest.php
# Expected: PASS (4 tests)
```
- [ ] **Step 5: Commit**
```bash
git add src/Infrastructure/Channel/Ebay/EbayAdapter.php tests/Unit/Infrastructure/Channel/Ebay/EbayAdapterTest.php
git commit -m "feat: add EbayAdapter (publishListing, updateStock, deactivateListing, pushTracking)"
```
---
## Task 4: eBay Taxonomy API
**Files:**
- Create: `src/Infrastructure/Channel/Ebay/EbayTaxonomyService.php`
- [ ] **Step 1: EbayTaxonomyService implementieren**
```php
<?php
// src/Infrastructure/Channel/Ebay/EbayTaxonomyService.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.
* Used when configuring ArticleTypePlatformConfig to suggest required mappings.
*
* @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); // cache for 1 week
$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',
};
}
}
```
- [ ] **Step 2: API-Endpunkt für Taxonomy-Abfrage**
Ergänze `MappingController` (aus Plan 2) um einen Endpunkt der eBay Taxonomy-Daten für eine Kategorie zurückgibt:
```php
// src/Infrastructure/Http/Controller/Api/MappingController.php (ergänzen)
#[Route('/ebay-category-aspects/{categoryId}', name: 'ebay_aspects', methods: ['GET'])]
public function ebayAspects(string $categoryId, EbayTaxonomyService $taxonomy): JsonResponse
{
try {
$aspects = $taxonomy->getCategoryAspects($categoryId);
} catch (\Throwable $e) {
return $this->json(['error' => 'eBay API error: '.$e->getMessage()], Response::HTTP_SERVICE_UNAVAILABLE);
}
return $this->json($aspects);
}
```
- [ ] **Step 3: services.yaml**
```yaml
App\Infrastructure\Channel\Ebay\EbayTaxonomyService:
arguments:
$apiBaseUrl: '%env(EBAY_API_BASE_URL)%'
$marketplaceId: '%env(EBAY_MARKETPLACE_ID)%'
$cache: '@cache.app'
```
- [ ] **Step 4: Commit**
```bash
git add src/Infrastructure/Channel/Ebay/EbayTaxonomyService.php src/Infrastructure/Http/Controller/Api/MappingController.php config/services.yaml
git commit -m "feat: add eBay Taxonomy API service for category aspect loading"
```
---
## Task 5: Channel-Sync Messages + Handler
**Files:**
- Create: `src/Infrastructure/Messenger/Message/PublishToChannelMessage.php`
- Create: `src/Infrastructure/Messenger/Message/UpdateStockOnChannelsMessage.php`
- Create: `src/Infrastructure/Messenger/Message/DeactivateListingMessage.php`
- Create: `src/Infrastructure/Messenger/Handler/PublishToChannelHandler.php`
- Create: `src/Infrastructure/Messenger/Handler/UpdateStockOnChannelsHandler.php`
- Create: `src/Infrastructure/Messenger/Handler/DeactivateListingHandler.php`
- Modify: `config/packages/messenger.yaml`
- [ ] **Step 1: Messages schreiben**
```php
<?php
// src/Infrastructure/Messenger/Message/PublishToChannelMessage.php
declare(strict_types=1);
namespace App\Infrastructure\Messenger\Message;
final readonly class PublishToChannelMessage
{
public function __construct(
public string $articleId,
) {}
}
```
```php
<?php
// src/Infrastructure/Messenger/Message/UpdateStockOnChannelsMessage.php
declare(strict_types=1);
namespace App\Infrastructure\Messenger\Message;
final readonly class UpdateStockOnChannelsMessage
{
public function __construct(
public string $articleId,
public int $newStock,
) {}
}
```
```php
<?php
// src/Infrastructure/Messenger/Message/DeactivateListingMessage.php
declare(strict_types=1);
namespace App\Infrastructure\Messenger\Message;
final readonly class DeactivateListingMessage
{
public function __construct(
public string $articleId,
public string $platformType, // 'ebay', 'amazon', …
public int $attemptNumber = 1,
) {}
}
```
- [ ] **Step 2: Messenger-Routing ergänzen**
```yaml
# config/packages/messenger.yaml routing ergänzen:
App\Infrastructure\Messenger\Message\PublishToChannelMessage: channel_sync
App\Infrastructure\Messenger\Message\UpdateStockOnChannelsMessage: channel_sync
App\Infrastructure\Messenger\Message\DeactivateListingMessage: channel_sync
App\Infrastructure\Messenger\Message\TrackingPushMessage: channel_sync
```
- [ ] **Step 3: PublishToChannelHandler**
```php
<?php
// src/Infrastructure/Messenger/Handler/PublishToChannelHandler.php
declare(strict_types=1);
namespace App\Infrastructure\Messenger\Handler;
use App\Application\Channel\ChannelAdapterRegistry;
use App\Domain\Article\ArticleStatus;
use App\Domain\Article\Repository\ArticleRepositoryInterface;
use App\Domain\Channel\Repository\PlatformRepositoryInterface;
use App\Infrastructure\Messenger\Message\PublishToChannelMessage;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Uid\Uuid;
#[AsMessageHandler]
final class PublishToChannelHandler
{
public function __construct(
private readonly ArticleRepositoryInterface $articleRepository,
private readonly PlatformRepositoryInterface $platformRepository,
private readonly ChannelAdapterRegistry $adapterRegistry,
) {}
public function __invoke(PublishToChannelMessage $message): void
{
$article = $this->articleRepository->findById(Uuid::fromString($message->articleId));
if (null === $article || $article->getStatus() !== ArticleStatus::Active) {
return;
}
$platforms = $this->platformRepository->findAll();
foreach ($platforms as $platform) {
if (!$this->adapterRegistry->has($platform->getType())) {
continue;
}
$adapter = $this->adapterRegistry->get($platform->getType());
try {
$listingId = $adapter->publishListing($article);
if ('ebay' === $platform->getType()) {
$article->setEbayListingId($listingId);
}
$article->transitionTo(ArticleStatus::Listed);
$this->articleRepository->save($article);
} catch (\RuntimeException $e) {
// Log error and continue with other platforms
// Messenger will retry via retry_strategy
throw $e;
}
}
}
}
```
- [ ] **Step 4: UpdateStockOnChannelsHandler**
```php
<?php
// src/Infrastructure/Messenger/Handler/UpdateStockOnChannelsHandler.php
declare(strict_types=1);
namespace App\Infrastructure\Messenger\Handler;
use App\Application\Channel\ChannelAdapterRegistry;
use App\Domain\Article\Repository\ArticleRepositoryInterface;
use App\Domain\Channel\Repository\PlatformRepositoryInterface;
use App\Infrastructure\Messenger\Message\DeactivateListingMessage;
use App\Infrastructure\Messenger\Message\UpdateStockOnChannelsMessage;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Uid\Uuid;
#[AsMessageHandler]
final class UpdateStockOnChannelsHandler
{
public function __construct(
private readonly ArticleRepositoryInterface $articleRepository,
private readonly PlatformRepositoryInterface $platformRepository,
private readonly ChannelAdapterRegistry $adapterRegistry,
private readonly MessageBusInterface $bus,
) {}
public function __invoke(UpdateStockOnChannelsMessage $message): void
{
$article = $this->articleRepository->findById(Uuid::fromString($message->articleId));
if (null === $article) {
return;
}
if ($message->newStock === 0) {
// Dispatch one deactivation message per platform
$platforms = $this->platformRepository->findAll();
foreach ($platforms as $platform) {
if ($this->adapterRegistry->has($platform->getType())) {
$this->bus->dispatch(new DeactivateListingMessage(
articleId: $message->articleId,
platformType: $platform->getType(),
));
}
}
return;
}
$platforms = $this->platformRepository->findAll();
foreach ($platforms as $platform) {
if (!$this->adapterRegistry->has($platform->getType())) {
continue;
}
$adapter = $this->adapterRegistry->get($platform->getType());
try {
$adapter->updateStock($article, $message->newStock);
} catch (\RuntimeException $e) {
throw $e; // Messenger retry
}
}
}
}
```
- [ ] **Step 5: DeactivateListingHandler**
```php
<?php
// src/Infrastructure/Messenger/Handler/DeactivateListingHandler.php
declare(strict_types=1);
namespace App\Infrastructure\Messenger\Handler;
use App\Application\Channel\ChannelAdapterRegistry;
use App\Domain\Article\ArticleStatus;
use App\Domain\Article\Repository\ArticleRepositoryInterface;
use App\Infrastructure\Messenger\Message\DeactivateListingMessage;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Uid\Uuid;
#[AsMessageHandler]
final class DeactivateListingHandler
{
private const ALERT_THRESHOLD = 5;
public function __construct(
private readonly ArticleRepositoryInterface $articleRepository,
private readonly ChannelAdapterRegistry $adapterRegistry,
private readonly LoggerInterface $logger,
) {}
public function __invoke(DeactivateListingMessage $message): void
{
$article = $this->articleRepository->findById(Uuid::fromString($message->articleId));
if (null === $article) {
return;
}
if (!$this->adapterRegistry->has($message->platformType)) {
return;
}
$adapter = $this->adapterRegistry->get($message->platformType);
try {
$adapter->deactivateListing($article);
// If all listings are deactivated (stock = 0), mark article as sold
if ($article->getStock() === 0 && $article->getStatus() === ArticleStatus::Listed) {
$article->transitionTo(ArticleStatus::Sold);
$this->articleRepository->save($article);
}
} catch (\RuntimeException $e) {
if ($message->attemptNumber >= self::ALERT_THRESHOLD) {
$this->logger->critical('CRITICAL: Failed to deactivate listing after {attempts} attempts. Risk of oversell!', [
'attempts' => $message->attemptNumber,
'articleId' => $message->articleId,
'platformType' => $message->platformType,
'error' => $e->getMessage(),
]);
}
// Re-throw to trigger Messenger retry with backoff
throw $e;
}
}
}
```
- [ ] **Step 6: ArticleService.activate() → PublishToChannelMessage dispatchen**
Erweitere `src/Application/Article/ArticleService.php` um Messenger-Dispatch nach erfolgreicher Aktivierung:
```php
// Neuer Konstruktor-Parameter:
use Symfony\Component\Messenger\MessageBusInterface;
use App\Infrastructure\Messenger\Message\PublishToChannelMessage;
public function __construct(
private readonly ArticleRepositoryInterface $articleRepository,
private readonly ArticleTypeRepositoryInterface $articleTypeRepository,
private readonly ArticleValidator $validator,
private readonly Connection $connection,
private readonly MessageBusInterface $bus,
) {}
// In activate(), nach $this->articleRepository->save($article):
$this->bus->dispatch(new PublishToChannelMessage($article->getId()->toRfc4122()));
```
- [ ] **Step 7: PHPStan + Commit**
```bash
docker compose run --rm app ./vendor/bin/phpstan analyse src/ --no-progress
docker compose run --rm app ./vendor/bin/php-cs-fixer fix src/ --dry-run --diff
git add src/Infrastructure/Messenger/Message/PublishToChannelMessage.php src/Infrastructure/Messenger/Message/UpdateStockOnChannelsMessage.php src/Infrastructure/Messenger/Message/DeactivateListingMessage.php src/Infrastructure/Messenger/Handler/ config/packages/messenger.yaml src/Application/Article/ArticleService.php
git commit -m "feat: add channel_sync messages and handlers (publish, updateStock, deactivate with alert after 5 failures)"
```
---
## Task 6: eBay Webhook-Listener + HMAC-Verifikation
**Files:**
- Create: `src/Infrastructure/Channel/Ebay/EbayWebhookVerifier.php`
- Create: `src/Infrastructure/Http/Controller/Webhook/EbayWebhookController.php`
- Test: `tests/Unit/Infrastructure/Channel/Ebay/EbayWebhookVerifierTest.php`
- [ ] **Step 1: Failing-Test für Webhook-Verifier**
```php
<?php
// tests/Unit/Infrastructure/Channel/Ebay/EbayWebhookVerifierTest.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));
}
}
```
- [ ] **Step 2: Test ausführen — muss fehlschlagen**
```bash
docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Channel/Ebay/EbayWebhookVerifierTest.php
# Expected: FAIL
```
- [ ] **Step 3: EbayWebhookVerifier implementieren**
```php
<?php
// src/Infrastructure/Channel/Ebay/EbayWebhookVerifier.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);
}
}
```
- [ ] **Step 4: Tests ausführen**
```bash
docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Channel/Ebay/EbayWebhookVerifierTest.php
# Expected: PASS (3 tests)
```
- [ ] **Step 5: EbayWebhookController implementieren**
```php
<?php
// src/Infrastructure/Http/Controller/Webhook/EbayWebhookController.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,
) {}
/** eBay endpoint registration challenge (GET with ?challenge_code=) */
#[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)]);
}
/** eBay notification event */
#[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,
};
// Always respond 200 immediately — processing is async
return new Response('', Response::HTTP_OK);
}
/** @param array<string, mixed> $data */
private function handleOrderEvent(array $data): void
{
$orderId = (string) ($data['orderId'] ?? $data['order']['orderId'] ?? '');
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
{
// eBay compliance requirement: log and acknowledge
$this->logger->info('eBay MARKETPLACE_ACCOUNT_DELETION received', ['data' => $data]);
// TODO in a later enhancement: anonymize customer data if required by GDPR
}
}
```
- [ ] **Step 6: OrderReceivedMessage anlegen**
```php
<?php
// src/Infrastructure/Messenger/Message/OrderReceivedMessage.php
declare(strict_types=1);
namespace App\Infrastructure\Messenger\Message;
final readonly class OrderReceivedMessage
{
/**
* @param array<string, mixed> $rawPayload
*/
public function __construct(
public string $platformOrderId,
public string $platformType,
public array $rawPayload,
) {}
}
```
Ergänze Messenger-Routing:
```yaml
App\Infrastructure\Messenger\Message\OrderReceivedMessage: orders
```
- [ ] **Step 7: Route + services.yaml**
```yaml
# config/routes/api.yaml (oder eigene Datei)
webhook_ebay:
path: /webhooks/ebay
controller: App\Infrastructure\Http\Controller\Webhook\EbayWebhookController
```
```yaml
# config/services.yaml
App\Infrastructure\Channel\Ebay\EbayWebhookVerifier:
arguments:
$verificationToken: '%env(EBAY_VERIFICATION_TOKEN)%'
$endpointUrl: '%env(EBAY_ENDPOINT_URL)%'
```
- [ ] **Step 8: Commit**
```bash
git add src/Infrastructure/Channel/Ebay/EbayWebhookVerifier.php src/Infrastructure/Http/Controller/Webhook/ src/Infrastructure/Messenger/Message/OrderReceivedMessage.php tests/Unit/Infrastructure/Channel/Ebay/EbayWebhookVerifierTest.php config/services.yaml config/packages/messenger.yaml
git commit -m "feat: add eBay webhook listener with HMAC verification, challenge response, OrderReceivedMessage dispatch"
```
---
## Selbstreview
**Spec-Abdeckung:**
- ChannelAdapterInterface mit publishListing, updateStock, deactivateListing, pushTracking ✓ (Task 1)
- EbayAdapter implementiert Interface ✓ (Task 3)
- eBay OAuth Client-Credentials-Flow mit Token-Cache ✓ (Task 2)
- eBay Taxonomy API für Pflichtfeld-Vorschläge ✓ (Task 4)
- PublishToChannelMessage nach Artikel-Aktivierung ✓ (Task 5)
- DeactivateListingMessage pro Plattform, Alert nach 5 Fehlversuchen ✓ (Task 5)
- eBay Webhook: HMAC-Signatur, Challenge-Response, sofort 200 → async ✓ (Task 6)
- Events: FIXED_PRICE_TRANSACTION, AUCTION_CHECKOUT_COMPLETE, MARKETPLACE_ACCOUNT_DELETION ✓ (Task 6)
- OrderReceivedMessage → orders Transport ✓ (Task 6)
**Noch nicht in diesem Plan:**
- TrackingPushMessage + Handler → Plan 6
- Order-Verarbeitung → Plan 6
- Neue Plattform = neue Adapter-Klasse (Muster ist etabliert)