diff --git a/composer.json b/composer.json index 3c5ffa8..35b42b4 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,7 @@ "require": { "php": "^8.0", "ext-curl": "*", + "ext-intl": "*", "ext-json": "*", "ext-openssl": "*", "composer/ca-bundle": "^1.3" diff --git a/src/API/Endpoints/BaseEndpoint.php b/src/API/Endpoints/BaseEndpoint.php index fb1b534..5312de5 100644 --- a/src/API/Endpoints/BaseEndpoint.php +++ b/src/API/Endpoints/BaseEndpoint.php @@ -10,6 +10,7 @@ use Vatly\API\Resources\Links\LinksResourceFactory; use Vatly\API\Resources\Links\PaginationLinks; use Vatly\API\Resources\ResourceFactory; +use Vatly\API\Support\IdnEmail; use Vatly\API\VatlyApiClient; abstract class BaseEndpoint @@ -228,7 +229,7 @@ protected function parseRequestBody(array $body): ?string return null; } - return @json_encode($body); + return @json_encode(IdnEmail::normalizePayload($body)); } /** diff --git a/src/API/Endpoints/OrderEndpoint.php b/src/API/Endpoints/OrderEndpoint.php index 58d108d..e5ee7e0 100644 --- a/src/API/Endpoints/OrderEndpoint.php +++ b/src/API/Endpoints/OrderEndpoint.php @@ -58,12 +58,11 @@ public function requestAddressUpdateLink(string $id, array $data = []): Link $resource = "{$this->getResourcePath()}/" . urlencode($id) . "/request-address-update-link"; - $body = null; - if (count($data) > 0) { - $body = json_encode($data); - } - - $result = $this->client->performHttpCall(self::REST_CREATE, $resource, $body); + $result = $this->client->performHttpCall( + self::REST_CREATE, + $resource, + $this->parseRequestBody($data), + ); return new Link($result->href, $result->type); } diff --git a/src/API/Endpoints/SubscriptionEndpoint.php b/src/API/Endpoints/SubscriptionEndpoint.php index 426ed23..0345215 100644 --- a/src/API/Endpoints/SubscriptionEndpoint.php +++ b/src/API/Endpoints/SubscriptionEndpoint.php @@ -91,12 +91,11 @@ public function updateBilling(string $subscriptionId, array $data = []): Link $resource = "{$this->getResourcePath()}/" . urlencode($subscriptionId) . "/update-billing"; - $body = null; - if (count($data) > 0) { - $body = json_encode($data); - } - - $result = $this->client->performHttpCall(self::REST_UPDATE, $resource, $body); + $result = $this->client->performHttpCall( + self::REST_UPDATE, + $resource, + $this->parseRequestBody($data), + ); return new Link($result->href, $result->type); } diff --git a/src/API/Support/IdnEmail.php b/src/API/Support/IdnEmail.php new file mode 100644 index 0000000..636cbb3 --- /dev/null +++ b/src/API/Support/IdnEmail.php @@ -0,0 +1,74 @@ + $payload + * @return array + */ + public static function normalizePayload(array $payload): array + { + foreach ($payload as $key => $value) { + if ($key === 'metadata') { + continue; + } + + if (is_array($value)) { + $payload[$key] = self::normalizePayload($value); + + continue; + } + + if ($key === 'email' && is_string($value)) { + $payload[$key] = self::toAscii($value); + } + } + + return $payload; + } +} diff --git a/tests/Endpoints/CustomersEndpointTest.php b/tests/Endpoints/CustomersEndpointTest.php index 41fe730..99e8b39 100644 --- a/tests/Endpoints/CustomersEndpointTest.php +++ b/tests/Endpoints/CustomersEndpointTest.php @@ -95,6 +95,37 @@ public function it_creates_customer_with_minimal_data(): void $this->assertNull($customer->metadata); } + /** @test */ + public function it_punycodes_the_email_domain_before_sending(): void + { + $responseBodyArray = [ + 'id' => 'customer_78b146a7de7d417e9d68d7e6ef193d18', + 'resource' => 'customer', + 'email' => 'user@xn--mnchen-3ya.de', + 'createdAt' => '2020-01-01T00:00:00+00:00', + 'testmode' => true, + 'links' => [ + 'self' => [ + 'href' => self::API_ENDPOINT_URL . '/customers/customer_78b146a7de7d417e9d68d7e6ef193d18', + 'type' => 'application/hal+json', + ], + ], + ]; + + $this->httpClient->setSendReturnObjectFromArray($responseBodyArray); + + $this->client->customers->create([ + 'email' => 'user@münchen.de', + ]); + + $this->assertWasSentOnly( + VatlyApiClient::HTTP_POST, + self::API_ENDPOINT_URL . '/customers', + [], + '{"email":"user@xn--mnchen-3ya.de"}' + ); + } + /** @test */ public function it_can_get_a_customer(): void { diff --git a/tests/Endpoints/OrderEndpointTest.php b/tests/Endpoints/OrderEndpointTest.php index 12edc9b..5740fe1 100644 --- a/tests/Endpoints/OrderEndpointTest.php +++ b/tests/Endpoints/OrderEndpointTest.php @@ -287,4 +287,27 @@ public function can_request_address_update_link(): void ); $this->assertEquals(self::API_ENDPOINT_URL."/customer/invoices/$orderId?editable=true&signature=1234567890abcdef", $response->href); } + + /** @test */ + public function request_address_update_link_punycodes_email_domains_in_billing_address(): void + { + $orderId = 'order_dummy_id'; + $this->httpClient->setSendReturnObjectFromArray([ + 'href' => self::API_ENDPOINT_URL."/customer/invoices/$orderId?editable=true&signature=1234567890abcdef", + 'type' => 'text/html', + ]); + + $this->client->orders->requestAddressUpdateLink($orderId, [ + 'billingAddress' => [ + 'email' => 'billing@müller.de', + ], + ]); + + $this->assertWasSentOnly( + VatlyApiClient::HTTP_POST, + self::API_ENDPOINT_URL.'/orders/'.$orderId.'/request-address-update-link', + [], + '{"billingAddress":{"email":"billing@xn--mller-kva.de"}}' + ); + } } diff --git a/tests/Endpoints/SubscriptionEndpointTest.php b/tests/Endpoints/SubscriptionEndpointTest.php index 9f4f01e..cea55e0 100644 --- a/tests/Endpoints/SubscriptionEndpointTest.php +++ b/tests/Endpoints/SubscriptionEndpointTest.php @@ -246,6 +246,29 @@ public function can_update_billing_details() $this->assertEquals(self::WEBSITE_ENDPOINT_URL.'/checkout/checkout_dummy_id/update', $response->href); } + /** @test */ + public function update_billing_punycodes_email_domains_in_billing_address(): void + { + $this->httpClient->setSendReturnObjectFromArray([ + 'href' => self::WEBSITE_ENDPOINT_URL.'/checkout/checkout_dummy_id/update', + 'type' => 'text/html', + ]); + + $this->client->subscriptions->updateBilling('subscription_123', [ + 'billingAddress' => [ + 'email' => 'billing@müller.de', + 'city' => 'München', + ], + ]); + + $this->assertWasSentOnly( + VatlyApiClient::HTTP_PATCH, + self::API_ENDPOINT_URL.'/subscriptions/subscription_123/update-billing', + [], + '{"billingAddress":{"email":"billing@xn--mller-kva.de","city":"München"}}' + ); + } + /** @test */ public function can_update_subscription_quantity() { diff --git a/tests/Support/IdnEmailTest.php b/tests/Support/IdnEmailTest.php new file mode 100644 index 0000000..7de505a --- /dev/null +++ b/tests/Support/IdnEmailTest.php @@ -0,0 +1,117 @@ +assertSame('user@example.com', IdnEmail::toAscii('user@example.com')); + } + + /** @test */ + public function it_converts_idn_domain_to_punycode(): void + { + $this->assertSame('user@xn--mnchen-3ya.de', IdnEmail::toAscii('user@münchen.de')); + $this->assertSame('billing@xn--mller-kva.de', IdnEmail::toAscii('billing@müller.de')); + } + + /** @test */ + public function it_returns_input_unchanged_when_at_sign_is_missing(): void + { + $this->assertSame('not-an-email', IdnEmail::toAscii('not-an-email')); + $this->assertSame('', IdnEmail::toAscii('')); + } + + /** @test */ + public function it_returns_input_unchanged_when_conversion_fails(): void + { + // Empty domain — idn_to_ascii returns false on this. Fail-open: return original. + $this->assertSame('user@', IdnEmail::toAscii('user@')); + } + + /** @test */ + public function it_splits_on_the_last_at_sign(): void + { + // Quoted local-parts may contain '@'; we should split on the rightmost one. + $this->assertSame('"foo@bar"@xn--mnchen-3ya.de', IdnEmail::toAscii('"foo@bar"@münchen.de')); + } + + /** @test */ + public function it_leaves_unicode_local_part_untouched(): void + { + // No standardized ASCII encoding for non-ASCII local-parts. Server rejects + // these per the API spec; the SDK should not silently transform them. + $this->assertSame('jösé@xn--mnchen-3ya.de', IdnEmail::toAscii('jösé@münchen.de')); + } + + /** @test */ + public function normalize_payload_walks_nested_structures(): void + { + $input = [ + 'email' => 'user@münchen.de', + 'billingAddress' => [ + 'email' => 'billing@müller.de', + 'city' => 'München', + ], + 'metadata' => [ + 'order_id' => '123', + ], + ]; + + $expected = [ + 'email' => 'user@xn--mnchen-3ya.de', + 'billingAddress' => [ + 'email' => 'billing@xn--mller-kva.de', + 'city' => 'München', + ], + 'metadata' => [ + 'order_id' => '123', + ], + ]; + + $this->assertSame($expected, IdnEmail::normalizePayload($input)); + } + + /** @test */ + public function normalize_payload_only_touches_string_email_values(): void + { + $input = [ + 'email' => null, + 'inner' => ['email' => 12345], + ]; + + $this->assertSame($input, IdnEmail::normalizePayload($input)); + } + + /** @test */ + public function normalize_payload_leaves_metadata_untouched(): void + { + // `metadata` is opaque application-defined data per the OpenAPI spec. + // The SDK must not rewrite values nested inside it, even when a caller + // happens to use a key named `email` for their own purposes. + $input = [ + 'email' => 'user@müller.de', + 'metadata' => [ + 'email' => 'tracker+user@müller.de', + 'nested' => ['email' => 'should-also-be-left-alone@münchen.de'], + ], + ]; + + $expected = [ + 'email' => 'user@xn--mller-kva.de', + 'metadata' => [ + 'email' => 'tracker+user@müller.de', + 'nested' => ['email' => 'should-also-be-left-alone@münchen.de'], + ], + ]; + + $this->assertSame($expected, IdnEmail::normalizePayload($input)); + } +}