9 private const ALGORITHM =
'AMZN-PAY-RSASSA-PSS-V2';
11 private function config(): array {
12 $cfg = function_exists(
'dbx') ?
dbx()->get_config(
'dbxShop') : array();
15 'enabled' => !empty(
$cfg[
'payment_amazon_pay_enabled']),
16 'mode' => (
string)(
$cfg[
'payment_amazon_pay_mode'] ??
'sandbox') ===
'live' ?
'live' :
'sandbox',
17 'region' => in_array((
string)(
$cfg[
'payment_amazon_pay_region'] ??
'EU'), array(
'EU',
'UK',
'US',
'JP'),
true) ? (
string)
$cfg[
'payment_amazon_pay_region'] :
'EU',
18 'merchant_id' => trim((
string)(
$cfg[
'payment_amazon_pay_merchant_id'] ??
'')),
19 'store_id' => trim((
string)(
$cfg[
'payment_amazon_pay_store_id'] ??
'')),
20 'public_key_id' => trim((
string)(
$cfg[
'payment_amazon_pay_public_key_id'] ??
'')),
21 'private_key' => trim((
string)(
$cfg[
'payment_amazon_pay_private_key'] ??
'')),
22 'currency' => strtoupper(substr((
string)(
$cfg[
'default_currency'] ??
'EUR'), 0, 3)) ?:
'EUR',
23 'sandbox_simulation_code' => trim((
string)(
$cfg[
'payment_amazon_pay_sandbox_simulation_code'] ??
'')),
28 $cfg = $this->config();
29 return !empty(
$cfg[
'enabled'])
30 && (string)
$cfg[
'merchant_id'] !==
''
31 && (
string)
$cfg[
'store_id'] !==
''
32 && (string)
$cfg[
'public_key_id'] !==
''
33 && (
string)
$cfg[
'private_key'] !==
'';
36 public function mode(): string {
37 return (string)$this->config()[
'mode'];
41 $region = (string)$this->config()[
'region'];
42 if ($region ===
'US')
return 'na';
43 if ($region ===
'JP')
return 'jp';
47 private function host(): string {
48 $region = (string)$this->config()[
'region'];
49 if ($region ===
'JP')
return 'pay-api.amazon.jp';
50 if ($region ===
'EU' || $region ===
'UK')
return 'pay-api.amazon.eu';
51 return 'pay-api.amazon.com';
54 private function apiBase(): string {
55 $cfg = $this->config();
56 return 'https://' . $this->host() .
'/' . (string)
$cfg[
'mode'] .
'/v2';
59 private function normalizeHeaderValue(
string $value): string {
60 return trim(preg_replace(
'/\s+/',
' ',
$value) ?:
$value);
63 private function canonicalUri(
string $path): string {
64 $parts = explode(
'/', $path);
65 foreach ($parts as &$part) {
66 $part = rawurlencode(rawurldecode($part));
69 return implode(
'/', $parts);
72 private function canonicalQuery(
string $query): string {
76 parse_str($query, $params);
77 ksort($params, SORT_STRING);
79 foreach ($params as $key =>
$value) {
82 foreach (
$value as $item) {
83 $pairs[] = rawurlencode((
string)$key) .
'=' . rawurlencode((
string)$item);
86 $pairs[] = rawurlencode((
string)$key) .
'=' . rawurlencode((
string)
$value);
89 return implode(
'&', $pairs);
92 private function authorizationHeader(
string $method,
string $path, array $headers,
string $body): string {
93 $cfg = $this->config();
95 foreach ($headers as $name =>
$value) {
96 $normalized[strtolower((
string)$name)] = $this->normalizeHeaderValue((
string)
$value);
100 $canonicalHeaders =
'';
102 $canonicalHeaders .= $name .
':' .
$value .
"\n";
104 $signedHeaders = implode(
';', array_keys(
$normalized));
106 $canonicalUri = $this->canonicalUri((
string)($pathParts[
'path'] ?? $path));
107 $canonicalQuery = $this->canonicalQuery((
string)($pathParts[
'query'] ??
''));
108 $canonicalRequest = strtoupper(
$method) .
"\n"
109 . $canonicalUri .
"\n"
110 . $canonicalQuery .
"\n"
111 . $canonicalHeaders .
"\n"
112 . $signedHeaders .
"\n"
113 . hash(
'sha256',
$body);
115 $stringToSign = self::ALGORITHM .
"\n" . hash(
'sha256', $canonicalRequest);
116 $privateKey = PublicKeyLoader::loadPrivateKey((
string)
$cfg[
'private_key'])
118 ->withMGFHash(
'sha256')
120 ->withPadding(RSA::SIGNATURE_PSS);
121 $signature = base64_encode($privateKey->sign($stringToSign));
123 return self::ALGORITHM
124 .
' PublicKeyId=' . (string)
$cfg[
'public_key_id']
125 .
', SignedHeaders=' . $signedHeaders
126 .
', Signature=' . $signature;
129 private function request(
string $method,
string $path, ?array $payload =
null,
string $idempotencyKey =
''): array {
130 if (!$this->isConfigured()) {
131 throw new \RuntimeException(
'Amazon Pay ist nicht vollstaendig konfiguriert.');
134 $cfg = $this->config();
135 $body = $payload !==
null ? json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) :
'';
136 if (!is_string(
$body)) {
141 'accept' =>
'application/json',
142 'content-type' =>
'application/json',
143 'x-amz-pay-date' => gmdate(
'Ymd\THis\Z'),
144 'x-amz-pay-host' => $this->host(),
145 'x-amz-pay-region' => $this->regionCode(),
147 if ($idempotencyKey !==
'') {
148 $headers[
'x-amz-pay-idempotency-key'] = substr(hash(
'sha256', $idempotencyKey), 0, 32);
150 if ((
string)
$cfg[
'mode'] ===
'sandbox' && (
string)
$cfg[
'sandbox_simulation_code'] !==
'') {
151 $headers[
'x-amz-simulation-code'] = (string)
$cfg[
'sandbox_simulation_code'];
153 $headers[
'authorization'] = $this->authorizationHeader(
$method,
'/' . (
string)
$cfg[
'mode'] .
'/v2' . $path, $headers,
$body);
155 $httpHeaders = array();
156 foreach ($headers as $name =>
$value) {
157 $httpHeaders[] = $name .
': ' .
$value;
160 $ch = curl_init($this->apiBase() . $path);
161 curl_setopt($ch, CURLOPT_RETURNTRANSFER,
true);
162 curl_setopt($ch, CURLOPT_CUSTOMREQUEST, strtoupper(
$method));
163 curl_setopt($ch, CURLOPT_TIMEOUT, 25);
164 curl_setopt($ch, CURLOPT_HTTPHEADER, $httpHeaders);
166 curl_setopt($ch, CURLOPT_POSTFIELDS,
$body);
169 $raw = curl_exec($ch);
170 $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
171 $err = curl_error($ch);
174 if ($raw ===
false || $err !==
'') {
175 throw new \RuntimeException(
'Amazon-Pay-Verbindung fehlgeschlagen: ' . $err);
178 $data = json_decode((
string)$raw,
true);
179 if (!is_array($data)) {
180 $data = array(
'raw' => (
string)$raw);
182 $data[
'_http_status'] = $status;
183 if ($status < 200 || $status >= 300) {
184 $msg = $data[
'message'] ?? $data[
'reasonCode'] ?? $data[
'reasonDescription'] ?? (
'HTTP ' . $status);
185 throw new \RuntimeException(
'Amazon-Pay-Fehler: ' . $msg);
191 $cfg = $this->config();
192 $amount = number_format((
float)(
$order[
'total_gross'] ?? 0), 2,
'.',
'');
193 $currency = (string)(
$order[
'currency'] ??
$cfg[
'currency'] ??
'EUR');
195 'webCheckoutDetails' => array(
196 'checkoutReviewReturnUrl' => $returnUrl,
197 'checkoutResultReturnUrl' => $returnUrl,
198 'checkoutCancelUrl' => $cancelUrl,
200 'storeId' => (
string)
$cfg[
'store_id'],
201 'scopes' => array(
'name',
'email',
'phoneNumber',
'billingAddress'),
202 'paymentDetails' => array(
203 'paymentIntent' =>
'AuthorizeWithCapture',
204 'canHandlePendingAuthorization' =>
false,
205 'chargeAmount' => array(
207 'currencyCode' => $currency,
210 'merchantMetadata' => array(
211 'merchantReferenceId' => (
string)(
$order[
'order_no'] ??
''),
212 'merchantStoreName' => (
string)(
$cfg[
'merchant_id'] ??
''),
213 'noteToBuyer' =>
'dbXapp Bestellung ' . (
string)(
$order[
'order_no'] ??
''),
216 return $this->request(
'POST',
'/checkoutSessions', $payload,
'checkout|' . (
string)(
$order[
'order_no'] ??
''));
220 $amount = number_format((float)(
$order[
'total_gross'] ?? 0), 2,
'.',
'');
221 $currency = (string)(
$order[
'currency'] ?? $this->config()[
'currency'] ??
'EUR');
222 return $this->request(
'POST',
'/checkoutSessions/' . rawurlencode($checkoutSessionId) .
'/complete', array(
223 'chargeAmount' => array(
225 'currencyCode' => $currency,
227 ),
'complete|' . $checkoutSessionId);
235 $returnedId = (string)(
$result[
'checkoutSessionId'] ??
$result[
'id'] ??
'');
236 if ($checkoutSessionId ===
'' || !hash_equals($checkoutSessionId, $returnedId)) {
237 throw new \RuntimeException(
'Amazon-Pay-Antwort gehoert nicht zur erwarteten Checkout Session.');
240 $orderNo = (string)(
$order[
'order_no'] ??
'');
241 $merchantReference = (string)(
$result[
'merchantMetadata'][
'merchantReferenceId'] ??
'');
242 if ($orderNo ===
'' || $merchantReference ===
'' || !hash_equals($orderNo, $merchantReference)) {
243 throw new \RuntimeException(
'Amazon-Pay-Antwort ist nicht an die lokale Bestellnummer gebunden.');
246 $amount =
$result[
'paymentDetails'][
'chargeAmount'] ?? (
$result[
'chargeAmount'] ?? array());
247 $actualAmount = round((
float)($amount[
'amount'] ?? 0), 2);
248 $actualCurrency = strtoupper((
string)($amount[
'currencyCode'] ??
''));
249 $expectedAmount = round((
float)(
$order[
'total_gross'] ?? 0), 2);
250 $expectedCurrency = strtoupper((
string)(
$order[
'currency'] ??
'EUR'));
251 if ($actualCurrency !== $expectedCurrency || abs($actualAmount - $expectedAmount) > 0.001) {
252 throw new \RuntimeException(
'Amazon-Pay-Betrag oder Waehrung stimmt nicht mit der Bestellung ueberein.');
255 $httpStatus = (int)(
$result[
'_http_status'] ?? 0);
256 $state = strtolower((
string)(
$result[
'statusDetails'][
'state'] ??
''));
257 if ($httpStatus === 202 &&
$state ===
'authorizationinitiated') {
260 if ($httpStatus >= 200 && $httpStatus < 300 &&
$state ===
'completed') {
263 throw new \RuntimeException(
'Amazon Pay hat keinen gueltigen Abschlussstatus geliefert.');
267 $details = is_array($checkoutSession[
'webCheckoutDetails'] ?? null) ? $checkoutSession[
'webCheckoutDetails'] : array();
268 foreach (array(
'amazonPayRedirectUrl',
'checkoutUrl') as $key) {
269 if (!empty($details[$key])) {
270 return (
string)$details[$key];
280 'mode' => $this->
mode(),
282 'message' =>
'Amazon Pay ist nicht vollstaendig konfiguriert.',
287 $this->authorizationHeader(
'GET',
'/' . $this->
mode() .
'/v2/checkoutSessions/test', array(
288 'accept' =>
'application/json',
289 'content-type' =>
'application/json',
290 'x-amz-pay-date' => gmdate(
'Ymd\THis\Z'),
291 'x-amz-pay-host' => $this->host(),
292 'x-amz-pay-region' => $this->regionCode(),
296 'mode' => $this->
mode(),
297 'region' => $this->regionCode(),
298 'message' =>
'Amazon-Pay-Konfiguration und RSA-PSS-Signatur sind lokal gueltig. Der Live-API-Test erfolgt bei einer echten Checkout Session.',
300 }
catch (\Throwable $e) {
303 'mode' => $this->
mode(),
304 'region' => $this->regionCode(),
305 'message' => $e->getMessage(),