diff --git a/src/Application/Order/CustomerResolverInterface.php b/src/Application/Order/CustomerResolverInterface.php new file mode 100644 index 0000000..5c0e947 --- /dev/null +++ b/src/Application/Order/CustomerResolverInterface.php @@ -0,0 +1,26 @@ + $address Keys: street, city, zip + */ + public function resolve( + string $platform, + string $platformUserId, + string $name, + string $email, + array $address, + ): Customer; +} diff --git a/src/Application/Order/ErpAdapterInterface.php b/src/Application/Order/ErpAdapterInterface.php new file mode 100644 index 0000000..2d69686 --- /dev/null +++ b/src/Application/Order/ErpAdapterInterface.php @@ -0,0 +1,29 @@ +getSku(); + + $this->apiClient->upsertInventoryItem($sku, [ + 'availability' => [ + 'shipToLocationAvailability' => [ + 'quantity' => $article->getStock(), + ], + ], + 'condition' => $this->mapCondition($article->getCondition()), + 'conditionDescription' => $article->getConditionNotes() ?? '', + 'product' => [ + 'title' => $article->getEbayTitle() ?? $article->getSku(), + 'description' => $article->getEbayDescription() ?? '', + 'aspects' => $this->buildAspects($article), + ], + ]); + + $offerId = $this->apiClient->createOffer([ + 'sku' => $sku, + 'marketplaceId' => 'EBAY_DE', + 'format' => 'FIXED_PRICE', + 'availableQuantity' => $article->getStock(), + 'pricingSummary' => [ + 'price' => [ + 'currency' => 'EUR', + 'value' => number_format((float) ($article->getListingPrice() ?? '0'), 2, '.', ''), + ], + ], + 'listingDescription' => $article->getEbayDescription() ?? '', + 'categoryId' => $this->getCategoryId(), + ]); + + return $this->apiClient->publishOffer($offerId); + } + + public function updateStock(Article $article, int $stock): void + { + $this->apiClient->bulkUpdateInventoryItems([ + 'requests' => [ + [ + 'sku' => $article->getSku(), + 'shipToLocationAvailability' => [ + 'quantity' => $stock, + ], + ], + ], + ]); + } + + public function deactivateListing(Article $article): void + { + $listingId = $article->getEbayListingId(); + if (null === $listingId) { + return; + } + + $this->apiClient->withdrawOffer($listingId); + } + + public function pushTracking(Order $order): void + { + if (null === $order->getTrackingNumber()) { + throw new \RuntimeException('Order has no tracking number'); + } + + $this->apiClient->addTrackingToOrder($order->getPlatformOrderId(), [ + 'lineItems' => [ + ['lineItemId' => $order->getPlatformOrderId(), 'quantity' => 1], + ], + 'shippingCarrierCode' => $order->getCarrier() ?? 'DHL', + 'trackingNumber' => $order->getTrackingNumber(), + ]); + } + + private function mapCondition(ArticleCondition $condition): string + { + return match ($condition) { + ArticleCondition::New => 'NEW', + ArticleCondition::LikeNew => 'LIKE_NEW', + ArticleCondition::Good => 'GOOD', + ArticleCondition::Acceptable => 'ACCEPTABLE', + }; + } + + /** @return array> */ + private function buildAspects(Article $article): array + { + $aspects = []; + foreach ($article->getAttributeValues() as $value) { + $name = $value->getAttributeDefinition()->getName(); + $aspects[$name] = [$value->getValue()]; + } + + return $aspects; + } + + private function getCategoryId(): string + { + return '177'; + } +} diff --git a/src/Infrastructure/Channel/Ebay/EbayFulfillmentApiClient.php b/src/Infrastructure/Channel/Ebay/EbayFulfillmentApiClient.php new file mode 100644 index 0000000..8226bed --- /dev/null +++ b/src/Infrastructure/Channel/Ebay/EbayFulfillmentApiClient.php @@ -0,0 +1,93 @@ + + */ + public function getOrder(string $orderId): array + { + $token = $this->oauthClient->getAccessToken(); + + $response = $this->httpClient->request( + 'GET', + $this->apiBaseUrl.'/sell/fulfillment/v1/order/'.urlencode($orderId), + ['headers' => ['Authorization' => 'Bearer '.$token]], + ); + + /** @var array $data */ + $data = $response->toArray(); + + $ship = $this->nestedArray($data, 'fulfillmentStartInstructions', 0, 'shippingStep', 'shipTo'); + $addr = $this->nestedArray($ship, 'contactAddress'); + $line = $this->nestedArray($data, 'lineItems', 0); + $buyer = $this->nestedArray($data, 'buyer'); + $buyerAddr = $this->nestedArray($buyer, 'buyerRegistrationAddress'); + $total = $this->nestedArray($data, 'pricingSummary', 'total'); + + return [ + 'orderId' => $this->str($data['orderId'] ?? $orderId), + 'buyerUsername' => $this->str($buyer['username'] ?? ''), + 'buyerName' => $this->str($ship['fullName'] ?? $buyerAddr['fullName'] ?? ''), + 'buyerEmail' => $this->str($ship['email'] ?? $buyerAddr['email'] ?? ($this->str($buyer['username'] ?? '').'@members.ebay.com')), + 'shippingStreet' => $this->str($addr['addressLine1'] ?? ''), + 'shippingCity' => $this->str($addr['city'] ?? ''), + 'shippingZip' => $this->str($addr['postalCode'] ?? ''), + 'shippingCountry' => $this->str($addr['countryCode'] ?? 'DE'), + 'ebayListingId' => $this->str($line['legacyItemId'] ?? ''), + 'salePrice' => $this->str($total['value'] ?? '0.00'), + 'saleDate' => $this->str($data['creationDate'] ?? (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)), + ]; + } + + /** + * Safely retrieves a nested value from a mixed array using a variadic key path. + * Each key can be a string or int. + * + * @param array $data + * + * @return array + */ + private function nestedArray(array $data, string|int ...$keys): array + { + $current = $data; + foreach ($keys as $key) { + if (!\array_key_exists($key, $current)) { + return []; + } + $val = $current[$key]; + if (!\is_array($val)) { + return []; + } + $current = $val; + } + + /** @var array $current */ + return $current; + } + + private function str(mixed $value): string + { + return \is_scalar($value) ? (string) $value : ''; + } +} diff --git a/src/Infrastructure/Channel/Ebay/EbayInventoryApiClient.php b/src/Infrastructure/Channel/Ebay/EbayInventoryApiClient.php new file mode 100644 index 0000000..e667bc8 --- /dev/null +++ b/src/Infrastructure/Channel/Ebay/EbayInventoryApiClient.php @@ -0,0 +1,125 @@ + $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 (204 === $statusCode || '' === $response->getContent(false)) { + return []; + } + + /** @var array $data */ + $data = $response->toArray(); + + return $data; + } +} diff --git a/src/Infrastructure/Channel/Ebay/EbayOAuthClient.php b/src/Infrastructure/Channel/Ebay/EbayOAuthClient.php new file mode 100644 index 0000000..c5b559f --- /dev/null +++ b/src/Infrastructure/Channel/Ebay/EbayOAuthClient.php @@ -0,0 +1,45 @@ +cache->get(self::TOKEN_CACHE_KEY, function (ItemInterface $item): string { + $credentials = base64_encode($this->clientId.':'.$this->clientSecret); + + $response = $this->httpClient->request('POST', $this->oauthBaseUrl.'/identity/v1/oauth2/token', [ + 'headers' => [ + 'Authorization' => 'Basic '.$credentials, + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + 'body' => 'grant_type=client_credentials&scope=https%3A%2F%2Fapi.ebay.com%2Foauth%2Fapi_scope', + ]); + + /** @var array{access_token: string, expires_in: int} $data */ + $data = $response->toArray(); + + $item->expiresAfter((int) ($data['expires_in'] * 0.8)); + + return $data['access_token']; + }); + } +} diff --git a/src/Infrastructure/Channel/Ebay/EbayTaxonomyService.php b/src/Infrastructure/Channel/Ebay/EbayTaxonomyService.php new file mode 100644 index 0000000..1640b18 --- /dev/null +++ b/src/Infrastructure/Channel/Ebay/EbayTaxonomyService.php @@ -0,0 +1,73 @@ +}> + */ + public function getCategoryAspects(string $categoryId): array + { + $cacheKey = 'ebay_aspects_'.md5($this->marketplaceId.$categoryId); + + return $this->cache->get($cacheKey, function (ItemInterface $item) use ($categoryId): array { + $item->expiresAfter(86400 * 7); + + $token = $this->oauthClient->getAccessToken(); + + $response = $this->httpClient->request( + 'GET', + $this->apiBaseUrl.'/commerce/taxonomy/v1/category_tree/'.$this->getTreeId().'/get_item_aspects_for_category', + [ + 'headers' => [ + 'Authorization' => 'Bearer '.$token, + 'X-EBAY-C-MARKETPLACE-ID' => $this->marketplaceId, + ], + 'query' => ['category_id' => $categoryId], + ], + ); + + /** @var array{aspects?: list}>} $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', + }; + } +} diff --git a/src/Infrastructure/Channel/Ebay/EbayWebhookVerifier.php b/src/Infrastructure/Channel/Ebay/EbayWebhookVerifier.php new file mode 100644 index 0000000..c524b30 --- /dev/null +++ b/src/Infrastructure/Channel/Ebay/EbayWebhookVerifier.php @@ -0,0 +1,36 @@ +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); + } +} diff --git a/src/Infrastructure/Channel/Frappe/FrappeErpAdapter.php b/src/Infrastructure/Channel/Frappe/FrappeErpAdapter.php new file mode 100644 index 0000000..f65b86a --- /dev/null +++ b/src/Infrastructure/Channel/Frappe/FrappeErpAdapter.php @@ -0,0 +1,82 @@ + $customer->getName(), + 'customer_type' => 'Individual', + 'customer_group' => 'Individual', + 'territory' => 'Germany', + 'custom_superseller_customer_id' => $customer->getId()->toRfc4122(), + ]; + + $ebayUserId = $customer->getPlatformId('ebay'); + if (null !== $ebayUserId) { + $data['custom_ebay_user_id'] = $ebayUserId; + } + + $response = $this->frappe->post('/api/resource/Customer', $data); + + /** @var array{data: array{name: string}} $response */ + return $response['data']['name']; + } + + public function createSalesInvoice(Order $order): string + { + $article = $order->getArticle(); + $customer = $order->getCustomer(); + + $draft = $this->frappe->post('/api/resource/Sales Invoice', [ + 'customer' => $customer->getFrappeCustomerId(), + 'posting_date' => $order->getSaleDate()->format('Y-m-d'), + 'due_date' => $order->getSaleDate()->format('Y-m-d'), + 'items' => [ + [ + 'item_code' => $this->genericItemCode, + 'item_name' => $article->getEbayTitle() ?? $article->getSku(), + 'description' => \sprintf('%s — Inventar: %s', $article->getEbayTitle() ?? $article->getSku(), $article->getInventoryNumber()), + 'qty' => 1, + 'rate' => $order->getSalePrice(), + ], + ], + 'custom_platform_order_id' => $order->getPlatformOrderId(), + 'custom_article_inventory_number' => $article->getInventoryNumber(), + ]); + + /** @var array{data: array{name: string}} $draft */ + $invoiceName = $draft['data']['name']; + + $this->frappe->post('/api/resource/Sales Invoice/'.$invoiceName.'/submit', []); + + return $invoiceName; + } + + public function fetchInvoicePdf(string $frappeInvoiceId): string + { + $path = http_build_query([ + 'doctype' => 'Sales Invoice', + 'name' => $frappeInvoiceId, + 'format' => 'Standard', + 'no_letterhead' => '0', + '_lang' => 'de', + ]); + + return $this->frappe->getContent('/api/method/frappe.utils.print_format.download_pdf?'.$path); + } +} diff --git a/src/Infrastructure/Channel/Frappe/FrappeHttpClient.php b/src/Infrastructure/Channel/Frappe/FrappeHttpClient.php new file mode 100644 index 0000000..01e0546 --- /dev/null +++ b/src/Infrastructure/Channel/Frappe/FrappeHttpClient.php @@ -0,0 +1,54 @@ +authHeader = "token {$apiKey}:{$apiSecret}"; + } + + /** + * POST to a Frappe resource endpoint. + * + * @param array $data + * + * @return array + */ + public function post(string $path, array $data): array + { + $response = $this->httpClient->request('POST', $this->baseUrl.$path, [ + 'headers' => [ + 'Authorization' => $this->authHeader, + 'Content-Type' => 'application/json', + ], + 'json' => $data, + ]); + + /** @var array $result */ + $result = $response->toArray(); + + return $result; + } + + /** GET raw binary content (for PDF downloads). */ + public function getContent(string $path): string + { + $response = $this->httpClient->request('GET', $this->baseUrl.$path, [ + 'headers' => ['Authorization' => $this->authHeader], + ]); + + return $response->getContent(); + } +} diff --git a/src/Infrastructure/Http/Controller/Webhook/EbayWebhookController.php b/src/Infrastructure/Http/Controller/Webhook/EbayWebhookController.php new file mode 100644 index 0000000..e342b24 --- /dev/null +++ b/src/Infrastructure/Http/Controller/Webhook/EbayWebhookController.php @@ -0,0 +1,100 @@ +query->getString('challenge_code'); + if ('' === $challengeCode) { + return $this->json(['error' => 'Missing challenge_code'], Response::HTTP_BAD_REQUEST); + } + + return $this->json(['challengeResponse' => $this->verifier->challengeResponse($challengeCode)]); + } + + #[Route('', name: '_notify', methods: ['POST'])] + public function notify(Request $request): Response + { + $signature = $request->headers->get('X-EBAY-SIGNATURE', ''); + $body = $request->getContent(); + + if (!$this->verifier->verify($body, $signature)) { + $this->logger->warning('eBay webhook: invalid signature', ['ip' => $request->getClientIp()]); + + return new Response('', Response::HTTP_UNAUTHORIZED); + } + + try { + /** @var array{notification?: array{topic?: string, data?: array}} $payload */ + $payload = json_decode($body, true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return new Response('', Response::HTTP_BAD_REQUEST); + } + + $topic = $payload['notification']['topic'] ?? ''; + $data = $payload['notification']['data'] ?? []; + + $this->logger->info('eBay webhook received', ['topic' => $topic]); + + match ($topic) { + 'FIXED_PRICE_TRANSACTION', + 'AUCTION_CHECKOUT_COMPLETE' => $this->handleOrderEvent($data), + 'MARKETPLACE_ACCOUNT_DELETION' => $this->handleAccountDeletion($data), + default => null, + }; + + return new Response('', Response::HTTP_OK); + } + + /** @param array $data */ + private function handleOrderEvent(array $data): void + { + $rawOrderId = $data['orderId'] ?? null; + if (null === $rawOrderId) { + /** @var array $orderData */ + $orderData = \is_array($data['order'] ?? null) ? $data['order'] : []; + $rawOrderId = $orderData['orderId'] ?? ''; + } + $orderId = \is_scalar($rawOrderId) ? (string) $rawOrderId : ''; + if ('' === $orderId) { + $this->logger->error('eBay webhook: order event with no orderId', ['data' => $data]); + + return; + } + + $this->bus->dispatch(new OrderReceivedMessage( + platformOrderId: $orderId, + platformType: 'ebay', + rawPayload: $data, + )); + } + + /** @param array $data */ + private function handleAccountDeletion(array $data): void + { + $this->logger->info('eBay MARKETPLACE_ACCOUNT_DELETION received', ['data' => $data]); + } +} diff --git a/src/Infrastructure/Mail/SymfonyInvoiceMailer.php b/src/Infrastructure/Mail/SymfonyInvoiceMailer.php new file mode 100644 index 0000000..2081b08 --- /dev/null +++ b/src/Infrastructure/Mail/SymfonyInvoiceMailer.php @@ -0,0 +1,55 @@ +getOrder(); + $article = $order->getArticle(); + $customer = $order->getCustomer(); + + $body = \sprintf( + "Neue Bestellung eingegangen — bitte sofort versenden.\n\n" + ."Bestellnummer : %s\n" + ."Artikel : %s\n" + ."Inventarnummer: %s\n" + ."Käufer : %s\n" + ."Verkaufspreis : €%s\n\n" + .'Die Rechnung liegt diesem E-Mail als PDF bei.', + $order->getPlatformOrderId(), + $article->getEbayTitle() ?? $article->getSku(), + $article->getInventoryNumber(), + $customer->getName(), + $order->getSalePrice(), + ); + + $email = (new Email()) + ->from($this->senderEmail) + ->to($this->supplierEmail) + ->subject('Neue Bestellung: '.$order->getPlatformOrderId()) + ->text($body) + ->attachFromPath( + $invoice->getFullPath(), + 'Rechnung-'.$invoice->getFrappeInvoiceId().'.pdf', + 'application/pdf', + ); + + $this->mailer->send($email); + } +} diff --git a/src/Infrastructure/Order/CustomerResolver.php b/src/Infrastructure/Order/CustomerResolver.php new file mode 100644 index 0000000..3ece85c --- /dev/null +++ b/src/Infrastructure/Order/CustomerResolver.php @@ -0,0 +1,51 @@ +customers->findByPlatformId($platform, $platformUserId); + if (null !== $customer) { + return $customer; + } + + $probe = new Customer($name, $email, $address); + $customer = $this->customers->findByMatchingKey($probe->getMatchingKey()); + if (null !== $customer) { + $customer->addPlatformId($platform, $platformUserId); + $this->customers->save($customer); + + return $customer; + } + + $customer = new Customer($name, $email, $address); + $customer->addPlatformId($platform, $platformUserId); + + $frappeId = $this->erp->createCustomer($customer); + $customer->setFrappeCustomerId($frappeId); + + $this->customers->save($customer); + + return $customer; + } +} diff --git a/tests/Unit/Infrastructure/Channel/Ebay/EbayAdapterTest.php b/tests/Unit/Infrastructure/Channel/Ebay/EbayAdapterTest.php new file mode 100644 index 0000000..01b60e9 --- /dev/null +++ b/tests/Unit/Infrastructure/Channel/Ebay/EbayAdapterTest.php @@ -0,0 +1,61 @@ +apiClient = $this->createMock(EbayInventoryApiClient::class); + $this->adapter = new EbayAdapter($this->apiClient); + $this->article = new Article(new ArticleType('Notebook'), 'NB-001', 'INV-001', 1, ArticleCondition::Good); + $this->article->setEbayTitle('Dell Latitude 5520'); + $this->article->setListingPrice('299.00'); + } + + public function test_get_type_returns_ebay(): void + { + $this->assertSame('ebay', $this->adapter->getType()); + } + + public function test_publish_listing_calls_upsert_create_and_publish(): void + { + $this->apiClient->expects($this->once())->method('upsertInventoryItem'); + $this->apiClient->expects($this->once())->method('createOffer')->willReturn('offer-123'); + $this->apiClient->expects($this->once())->method('publishOffer')->with('offer-123')->willReturn('listing-456'); + + $listingId = $this->adapter->publishListing($this->article); + + $this->assertSame('listing-456', $listingId); + } + + public function test_deactivate_listing_withdraws_offer(): void + { + $this->article->setEbayListingId('offer-123'); + + $this->apiClient->expects($this->once())->method('withdrawOffer')->with('offer-123'); + + $this->adapter->deactivateListing($this->article); + } + + public function test_deactivate_listing_is_noop_when_no_listing_id(): void + { + $this->apiClient->expects($this->never())->method('withdrawOffer'); + + $this->adapter->deactivateListing($this->article); + } +} diff --git a/tests/Unit/Infrastructure/Channel/Ebay/EbayWebhookVerifierTest.php b/tests/Unit/Infrastructure/Channel/Ebay/EbayWebhookVerifierTest.php new file mode 100644 index 0000000..1c716e9 --- /dev/null +++ b/tests/Unit/Infrastructure/Channel/Ebay/EbayWebhookVerifierTest.php @@ -0,0 +1,42 @@ +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)); + } +} diff --git a/tests/Unit/Infrastructure/Channel/Frappe/FrappeErpAdapterTest.php b/tests/Unit/Infrastructure/Channel/Frappe/FrappeErpAdapterTest.php new file mode 100644 index 0000000..bef8d86 --- /dev/null +++ b/tests/Unit/Infrastructure/Channel/Frappe/FrappeErpAdapterTest.php @@ -0,0 +1,82 @@ +frappe = $this->createMock(FrappeHttpClient::class); + $this->adapter = new FrappeErpAdapter($this->frappe, 'REFURB-HW'); + } + + public function test_create_customer_returns_frappe_id(): void + { + $this->frappe + ->method('post') + ->with('/api/resource/Customer', $this->isType('array')) + ->willReturn(['data' => ['name' => 'CUST-00001']]); + + $customer = new Customer('Max Mustermann', 'max@test.de', ['street' => 'Str 1', 'city' => 'Berlin', 'zip' => '10115']); + $customer->addPlatformId('ebay', 'buyer123'); + + $result = $this->adapter->createCustomer($customer); + + $this->assertSame('CUST-00001', $result); + } + + public function test_create_sales_invoice_submits_and_returns_id(): void + { + $this->frappe + ->expects($this->exactly(2)) + ->method('post') + ->willReturnOnConsecutiveCalls( + ['data' => ['name' => 'SINV-00001']], + ['data' => ['name' => 'SINV-00001', 'docstatus' => 1]], + ); + + $customer = new Customer('Max Mustermann', 'max@test.de', ['street' => 'Str 1', 'city' => 'Berlin', 'zip' => '10115']); + $customer->setFrappeCustomerId('CUST-00001'); + + $order = new Order( + new Article(new ArticleType('Laptop'), 'LAP-001', 'INV-001', 1, ArticleCondition::Good), + $customer, + new Platform('ebay', 'eBay DE'), + 'ORDER-001', + '299.99', + new \DateTimeImmutable('2026-05-13'), + ); + + $result = $this->adapter->createSalesInvoice($order); + + $this->assertSame('SINV-00001', $result); + } + + public function test_fetch_invoice_pdf_returns_binary(): void + { + $this->frappe + ->method('getContent') + ->with($this->stringContains('SINV-00001')) + ->willReturn('%PDF-binary-content'); + + $result = $this->adapter->fetchInvoicePdf('SINV-00001'); + + $this->assertStringStartsWith('%PDF', $result); + } +} diff --git a/tests/Unit/Infrastructure/Order/CustomerResolverTest.php b/tests/Unit/Infrastructure/Order/CustomerResolverTest.php new file mode 100644 index 0000000..2fff7bf --- /dev/null +++ b/tests/Unit/Infrastructure/Order/CustomerResolverTest.php @@ -0,0 +1,89 @@ +customerRepo = $this->createMock(CustomerRepositoryInterface::class); + $this->erp = $this->createMock(ErpAdapterInterface::class); + $this->resolver = new CustomerResolver($this->customerRepo, $this->erp); + } + + public function test_stage_1_platform_id_match_returns_existing_customer_without_erp_call(): void + { + $existing = new Customer('Max Mustermann', 'max@test.de', ['street' => 'Musterstr 1', 'city' => 'Berlin', 'zip' => '10115']); + $existing->addPlatformId('ebay', 'buyer123'); + + $this->customerRepo + ->method('findByPlatformId') + ->with('ebay', 'buyer123') + ->willReturn($existing); + + $this->erp->expects($this->never())->method('createCustomer'); + $this->customerRepo->expects($this->never())->method('save'); + + $result = $this->resolver->resolve( + 'ebay', 'buyer123', + 'Max Mustermann', 'max@test.de', + ['street' => 'Musterstr 1', 'city' => 'Berlin', 'zip' => '10115'], + ); + + $this->assertSame($existing, $result); + } + + public function test_stage_2_address_match_adds_platform_id_and_saves(): void + { + $existing = new Customer('Max Mustermann', 'max@test.de', ['street' => 'Musterstr 1', 'city' => 'Berlin', 'zip' => '10115']); + + $this->customerRepo->method('findByPlatformId')->willReturn(null); + $this->customerRepo + ->method('findByMatchingKey') + ->with($existing->getMatchingKey()) + ->willReturn($existing); + $this->customerRepo->expects($this->once())->method('save')->with($existing); + + $this->erp->expects($this->never())->method('createCustomer'); + + $result = $this->resolver->resolve( + 'ebay', 'buyer456', + 'Max Mustermann', 'max@test.de', + ['street' => 'Musterstr 1', 'city' => 'Berlin', 'zip' => '10115'], + ); + + $this->assertSame($existing, $result); + $this->assertSame('buyer456', $result->getPlatformId('ebay')); + } + + public function test_no_match_creates_new_customer_via_erp(): void + { + $this->customerRepo->method('findByPlatformId')->willReturn(null); + $this->customerRepo->method('findByMatchingKey')->willReturn(null); + $this->customerRepo->expects($this->once())->method('save'); + $this->erp->method('createCustomer')->willReturn('CUST-99999'); + + $result = $this->resolver->resolve( + 'ebay', 'newbuyer', + 'Neue Käuferin', 'neu@test.de', + ['street' => 'Neustr 5', 'city' => 'München', 'zip' => '80333'], + ); + + $this->assertSame('CUST-99999', $result->getFrappeCustomerId()); + $this->assertSame('newbuyer', $result->getPlatformId('ebay')); + $this->assertSame('Neue Käuferin', $result->getName()); + } +}