dbxapp 4.1.3
CMS, Shop, Workflows und modulare Geschäftsanwendungen
Loading...
Searching...
No Matches
dbxShopAmazonPay.class.php
Go to the documentation of this file.
1<?php
2namespace dbx\dbxShop;
3
4use phpseclib3\Crypt\PublicKeyLoader;
5use phpseclib3\Crypt\RSA;
6
8
9 private const ALGORITHM = 'AMZN-PAY-RSASSA-PSS-V2';
10
11 private function config(): array {
12 $cfg = function_exists('dbx') ? dbx()->get_config('dbxShop') : array();
13 $cfg = is_array($cfg) ? $cfg : array();
14 return 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'] ?? '')),
24 );
25 }
26
27 public function isConfigured(): bool {
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'] !== '';
34 }
35
36 public function mode(): string {
37 return (string)$this->config()['mode'];
38 }
39
40 public function regionCode(): string {
41 $region = (string)$this->config()['region'];
42 if ($region === 'US') return 'na';
43 if ($region === 'JP') return 'jp';
44 return 'eu';
45 }
46
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';
52 }
53
54 private function apiBase(): string {
55 $cfg = $this->config();
56 return 'https://' . $this->host() . '/' . (string)$cfg['mode'] . '/v2';
57 }
58
59 private function normalizeHeaderValue(string $value): string {
60 return trim(preg_replace('/\s+/', ' ', $value) ?: $value);
61 }
62
63 private function canonicalUri(string $path): string {
64 $parts = explode('/', $path);
65 foreach ($parts as &$part) {
66 $part = rawurlencode(rawurldecode($part));
67 }
68 unset($part);
69 return implode('/', $parts);
70 }
71
72 private function canonicalQuery(string $query): string {
73 if ($query === '') {
74 return '';
75 }
76 parse_str($query, $params);
77 ksort($params, SORT_STRING);
78 $pairs = array();
79 foreach ($params as $key => $value) {
80 if (is_array($value)) {
81 sort($value, SORT_STRING);
82 foreach ($value as $item) {
83 $pairs[] = rawurlencode((string)$key) . '=' . rawurlencode((string)$item);
84 }
85 } else {
86 $pairs[] = rawurlencode((string)$key) . '=' . rawurlencode((string)$value);
87 }
88 }
89 return implode('&', $pairs);
90 }
91
92 private function authorizationHeader(string $method, string $path, array $headers, string $body): string {
93 $cfg = $this->config();
94 $normalized = array();
95 foreach ($headers as $name => $value) {
96 $normalized[strtolower((string)$name)] = $this->normalizeHeaderValue((string)$value);
97 }
98 ksort($normalized, SORT_STRING);
99
100 $canonicalHeaders = '';
101 foreach ($normalized as $name => $value) {
102 $canonicalHeaders .= $name . ':' . $value . "\n";
103 }
104 $signedHeaders = implode(';', array_keys($normalized));
105 $pathParts = parse_url($path);
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);
114
115 $stringToSign = self::ALGORITHM . "\n" . hash('sha256', $canonicalRequest);
116 $privateKey = PublicKeyLoader::loadPrivateKey((string)$cfg['private_key'])
117 ->withHash('sha256')
118 ->withMGFHash('sha256')
119 ->withSaltLength(32)
120 ->withPadding(RSA::SIGNATURE_PSS);
121 $signature = base64_encode($privateKey->sign($stringToSign));
122
123 return self::ALGORITHM
124 . ' PublicKeyId=' . (string)$cfg['public_key_id']
125 . ', SignedHeaders=' . $signedHeaders
126 . ', Signature=' . $signature;
127 }
128
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.');
132 }
133
134 $cfg = $this->config();
135 $body = $payload !== null ? json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) : '';
136 if (!is_string($body)) {
137 $body = '';
138 }
139
140 $headers = array(
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(),
146 );
147 if ($idempotencyKey !== '') {
148 $headers['x-amz-pay-idempotency-key'] = substr(hash('sha256', $idempotencyKey), 0, 32);
149 }
150 if ((string)$cfg['mode'] === 'sandbox' && (string)$cfg['sandbox_simulation_code'] !== '') {
151 $headers['x-amz-simulation-code'] = (string)$cfg['sandbox_simulation_code'];
152 }
153 $headers['authorization'] = $this->authorizationHeader($method, '/' . (string)$cfg['mode'] . '/v2' . $path, $headers, $body);
154
155 $httpHeaders = array();
156 foreach ($headers as $name => $value) {
157 $httpHeaders[] = $name . ': ' . $value;
158 }
159
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);
165 if ($body !== '') {
166 curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
167 }
168
169 $raw = curl_exec($ch);
170 $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
171 $err = curl_error($ch);
172 curl_close($ch);
173
174 if ($raw === false || $err !== '') {
175 throw new \RuntimeException('Amazon-Pay-Verbindung fehlgeschlagen: ' . $err);
176 }
177
178 $data = json_decode((string)$raw, true);
179 if (!is_array($data)) {
180 $data = array('raw' => (string)$raw);
181 }
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);
186 }
187 return $data;
188 }
189
190 public function createCheckoutSession(array $order, string $returnUrl, string $cancelUrl): array {
191 $cfg = $this->config();
192 $amount = number_format((float)($order['total_gross'] ?? 0), 2, '.', '');
193 $currency = (string)($order['currency'] ?? $cfg['currency'] ?? 'EUR');
194 $payload = array(
195 'webCheckoutDetails' => array(
196 'checkoutReviewReturnUrl' => $returnUrl,
197 'checkoutResultReturnUrl' => $returnUrl,
198 'checkoutCancelUrl' => $cancelUrl,
199 ),
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(
206 'amount' => $amount,
207 'currencyCode' => $currency,
208 ),
209 ),
210 'merchantMetadata' => array(
211 'merchantReferenceId' => (string)($order['order_no'] ?? ''),
212 'merchantStoreName' => (string)($cfg['merchant_id'] ?? ''),
213 'noteToBuyer' => 'dbXapp Bestellung ' . (string)($order['order_no'] ?? ''),
214 ),
215 );
216 return $this->request('POST', '/checkoutSessions', $payload, 'checkout|' . (string)($order['order_no'] ?? ''));
217 }
218
219 public function completeCheckoutSession(string $checkoutSessionId, array $order): array {
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(
224 'amount' => $amount,
225 'currencyCode' => $currency,
226 ),
227 ), 'complete|' . $checkoutSessionId);
228 }
229
234 public function validateCompletion(array $result, array $order, string $checkoutSessionId): string {
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.');
238 }
239
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.');
244 }
245
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.');
253 }
254
255 $httpStatus = (int)($result['_http_status'] ?? 0);
256 $state = strtolower((string)($result['statusDetails']['state'] ?? ''));
257 if ($httpStatus === 202 && $state === 'authorizationinitiated') {
258 return 'pending';
259 }
260 if ($httpStatus >= 200 && $httpStatus < 300 && $state === 'completed') {
261 return 'completed';
262 }
263 throw new \RuntimeException('Amazon Pay hat keinen gueltigen Abschlussstatus geliefert.');
264 }
265
266 public function redirectUrl(array $checkoutSession): string {
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];
271 }
272 }
273 return '';
274 }
275
276 public function testConnection(): array {
277 if (!$this->isConfigured()) {
278 return array(
279 'ok' => false,
280 'mode' => $this->mode(),
281 'region' => $this->regionCode(),
282 'message' => 'Amazon Pay ist nicht vollstaendig konfiguriert.',
283 );
284 }
285
286 try {
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(),
293 ), '');
294 return array(
295 'ok' => true,
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.',
299 );
300 } catch (\Throwable $e) {
301 return array(
302 'ok' => false,
303 'mode' => $this->mode(),
304 'region' => $this->regionCode(),
305 'message' => $e->getMessage(),
306 );
307 }
308 }
309}
310?>
$cfg
validateCompletion(array $result, array $order, string $checkoutSessionId)
Verifiziert eine serverseitige Amazon-Pay-Antwort und normalisiert ausschliesslich dokumentierte Erfo...
completeCheckoutSession(string $checkoutSessionId, array $order)
createCheckoutSession(array $order, string $returnUrl, string $cancelUrl)
parse_url($data)
Zerlegt URL-/Parameterdaten in einen Array.
Definition dbxApi.php:2416
$dbxMailDeliveryTestKernel mode
if(!defined( 'IMG_WEBP')) define( 'IMG_WEBP'
DBX schema administration.