SuperSeller3000/docs/superpowers/plans/2026-05-13-05-ebay-adapter.md
Simon Kuehn f55e96b094 chore: add tooling config, test bootstrap, env templates and docs
PHPUnit config (phpunit.dist.xml, bin/phpunit, bootstrap.php), PHP CS
Fixer config, .editorconfig. Separate .env.dev/.env.test templates.
Ollama tunnel setup script. Architecture and plan docs. Updated
application-layer unit tests to match current service signatures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 22:44:16 +00:00

42 KiB

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
// 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
// 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
# 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
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
// 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
// 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
# .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
# 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
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
// 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
docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Channel/Ebay/EbayAdapterTest.php
# Expected: FAIL
  • Step 3: EbayAdapter implementieren
<?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
docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Channel/Ebay/EbayAdapterTest.php
# Expected: PASS (4 tests)
  • Step 5: Commit
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
// 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:

// 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
    App\Infrastructure\Channel\Ebay\EbayTaxonomyService:
        arguments:
            $apiBaseUrl: '%env(EBAY_API_BASE_URL)%'
            $marketplaceId: '%env(EBAY_MARKETPLACE_ID)%'
            $cache: '@cache.app'
  • Step 4: Commit
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
// 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
// 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
// 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
# 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
// 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
// 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
// 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:

// 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
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
// 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
docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Channel/Ebay/EbayWebhookVerifierTest.php
# Expected: FAIL
  • Step 3: EbayWebhookVerifier implementieren
<?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
docker compose run --rm app ./vendor/bin/pest tests/Unit/Infrastructure/Channel/Ebay/EbayWebhookVerifierTest.php
# Expected: PASS (3 tests)
  • Step 5: EbayWebhookController implementieren
<?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
// 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:

        App\Infrastructure\Messenger\Message\OrderReceivedMessage: orders
  • Step 7: Route + services.yaml
# config/routes/api.yaml (oder eigene Datei)
webhook_ebay:
    path: /webhooks/ebay
    controller: App\Infrastructure\Http\Controller\Webhook\EbayWebhookController
# config/services.yaml
    App\Infrastructure\Channel\Ebay\EbayWebhookVerifier:
        arguments:
            $verificationToken: '%env(EBAY_VERIFICATION_TOKEN)%'
            $endpointUrl: '%env(EBAY_ENDPOINT_URL)%'
  • Step 8: Commit
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)