# 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 */ private array $adapters = []; /** @param iterable $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 */ 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 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 $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 $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 $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 $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 $body */ public function addTrackingToOrder(string $orderId, array $body): void { $this->request('POST', self::FULFILLMENT_BASE.'/order/'.urlencode($orderId).'/shipping_fulfillment', $body); } /** * @param array $body * @return array */ 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 $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 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 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> */ 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 }> */ 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}>} $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 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 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 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 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 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 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}} $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 $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 $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 $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)