From f65901060f2560df47db2f30e3e9f3eb962e396e Mon Sep 17 00:00:00 2001 From: Thomas Bartelt Date: Fri, 18 Sep 2026 07:40:27 +0200 Subject: [PATCH] feat(payments): Express-Start mit Versand und Zahlartaufschlag, PATCH nur bei Abweichung PayPal Live verlangt nach jeder Betragsaenderung eine zweite Freigabe (422 PAYER_ACTION_REQUIRED). Die Kasse rechnet den Endbetrag jetzt beim Express-Start vor (erste aktive Versandart des Gebiets, Gast = Gebiet 1, eingeloggt = Land/Gruppe), der Provider vergleicht vor dem PATCH mit dem Stand bei PayPal und laesst ihn bei Gleichstand aus. Webhooks ordnen ohne custom_id ueber die PayPal-Order-ID zu. --- .../payments/providers/paypal_v2/Provider.php | 46 ++++++++++---- modules/website_cashdesk.php | 61 ++++++++++++++++++- 2 files changed, 93 insertions(+), 14 deletions(-) diff --git a/core/payments/providers/paypal_v2/Provider.php b/core/payments/providers/paypal_v2/Provider.php index 195355d..cec44d8 100644 --- a/core/payments/providers/paypal_v2/Provider.php +++ b/core/payments/providers/paypal_v2/Provider.php @@ -222,6 +222,14 @@ final class Provider extends AbstractProvider $resource = \is_array($event['resource'] ?? null) ? $event['resource'] : []; $orderId = (int) ($resource['custom_id'] ?? ($resource['purchase_units'][0]['custom_id'] ?? 0)); + if ($orderId <= 0) { + // 18.09.2026 -- Express ohne PATCH traegt custom_id 'express': Bestellung ueber die + // PayPal-Order-ID finden (Capture: supplementary_data, Order-Events: resource.id). + $ref = (string) ($resource['supplementary_data']['related_ids']['order_id'] + ?? (\str_starts_with($type, 'CHECKOUT.ORDER.') ? ($resource['id'] ?? '') : '')); + $tx = $ref !== '' ? $this->transactionByReference($ref) : null; + $orderId = $tx ? (int) $tx->order_id : 0; + } Log::write($this->module(), 'webhook', $orderId ?: null, null, (string) ($event['id'] ?? ''), $type, null, $event); switch ($type) { @@ -459,20 +467,32 @@ final class Provider extends AbstractProvider $number = (string) $order->order_number; $value = \number_format($amount, 2, '.', ''); - $patch = [ - ['op' => 'replace', 'path' => "/purchase_units/@reference_id=='default'/amount", - 'value' => ['currency_code' => $this->currency(), 'value' => $value]], - ['op' => 'replace', 'path' => "/purchase_units/@reference_id=='default'/custom_id", 'value' => (string) $orderId], - ['op' => 'add', 'path' => "/purchase_units/@reference_id=='default'/invoice_id", 'value' => $number . '-' . $orderId], - ]; - [$status, $body] = $this->client()->request('PATCH', '/v2/checkout/orders/' . \rawurlencode($reference), $patch); - Log::write($this->module(), 'express_patch', $orderId, $number, $reference, (string) $status, $status, $body ?: ['amount' => $value]); - if ($status !== 204) { - throw new \RuntimeException('PayPal Express: Betrag konnte nicht gesetzt werden (HTTP ' . $status . '): ' . \json_encode($body['details'] ?? $body)); + // 18.09.2026 -- erst nachsehen, was bei PayPal steht. Stimmt der Betrag schon (die Kasse + // hat Versand und Aufschlag beim Express-Start mitgegeben), wird NICHT gepatcht: jede + // Aenderung nach der Freigabe kostet auf Live eine zweite Freigabe (422 + // PAYER_ACTION_REQUIRED, Sandbox winkt das durch). custom_id bleibt dann 'express', + // die Zuordnung laeuft ueber die Referenz (payment_transactions). + [$s0, $b0] = $this->client()->request('GET', '/v2/checkout/orders/' . \rawurlencode($reference)); + $ppValue = (string) ($b0['purchase_units'][0]['amount']['value'] ?? ''); + $ppCur = (string) ($b0['purchase_units'][0]['amount']['currency_code'] ?? ''); + if ($s0 === 200 && $ppValue === $value && $ppCur === $this->currency()) { + Log::write($this->module(), 'express_patch', $orderId, $number, $reference, 'skipped', $s0, ['amount' => $value, 'detail' => 'Betrag stimmt bereits, kein PATCH']); + $b2 = $b0; + } else { + $patch = [ + ['op' => 'replace', 'path' => "/purchase_units/@reference_id=='default'/amount", + 'value' => ['currency_code' => $this->currency(), 'value' => $value]], + ['op' => 'replace', 'path' => "/purchase_units/@reference_id=='default'/custom_id", 'value' => (string) $orderId], + ['op' => 'add', 'path' => "/purchase_units/@reference_id=='default'/invoice_id", 'value' => $number . '-' . $orderId], + ]; + [$status, $body] = $this->client()->request('PATCH', '/v2/checkout/orders/' . \rawurlencode($reference), $patch); + Log::write($this->module(), 'express_patch', $orderId, $number, $reference, (string) $status, $status, $body ?: ['amount' => $value, 'vorher' => $ppValue]); + if ($status !== 204) { + throw new \RuntimeException('PayPal Express: Betrag konnte nicht gesetzt werden (HTTP ' . $status . '): ' . \json_encode($body['details'] ?? $body)); + } + // Approve-URL fuer den Fall, dass PayPal eine erneute Freigabe verlangt + [$s2, $b2] = $this->client()->request('GET', '/v2/checkout/orders/' . \rawurlencode($reference)); } - - // Approve-URL fuer den Fall, dass PayPal eine erneute Freigabe verlangt - [$s2, $b2] = $this->client()->request('GET', '/v2/checkout/orders/' . \rawurlencode($reference)); $approveUrl = ''; foreach (($b2['links'] ?? []) as $link) { if (\in_array($link['rel'] ?? '', ['approve', 'payer-action'], true)) { diff --git a/modules/website_cashdesk.php b/modules/website_cashdesk.php index 00c5e39..ec489cc 100644 --- a/modules/website_cashdesk.php +++ b/modules/website_cashdesk.php @@ -390,7 +390,7 @@ class website_cashdesk { exit; } try { - $url = $provider->startExpress(round((float)$cart['price_sum_gross'], 2)); + $url = $provider->startExpress($this->payment_express_amount($cart, $provider)); } catch (Throwable $e) { error_log('Payments express start '.$module.': '.$e->getMessage()); $this->layout_object->assign('call', 'payment_error'); @@ -402,6 +402,65 @@ class website_cashdesk { exit; } + /** + * 18.09.2026 -- Endbetrag fuer den Express-Start vorab rechnen: Warenwert + Versand + + * Zahlartaufschlag, genau so, wie die Kasse ihn spaeter vorbelegt (erste aktive Versandart + * des Gebiets, Gast: Gebiet 1 wie in manage_guest_payment_method). PayPal Live verlangt + * bei einer Betragsaenderung nach der Freigabe eine zweite Freigabe (422 + * PAYER_ACTION_REQUIRED); stimmt der Betrag schon, entfaellt der PATCH und damit der + * zweite PayPal-Besuch. Land: erstes erlaubtes Express-Land (Setting, Standard DE). + * Im Zweifel (Fehler, keine Versandart) bleibt es beim Warenwert -- dann greift der + * Rueckfall mit zweiter Freigabe wie bisher. + */ + private function payment_express_amount($cart, $provider) { + $amount = round((float)$cart['price_sum_gross'], 2); + try { + $guest_group = (int)$this->base_object->config->shopConfiguration['guest_order_customer_group']; + $guest_id = (int)$this->base_object->config->shopConfiguration['guest_order_customer_id']; + $cid = (int)$this->base_object->customer->customer_id; + $logged_in = $cid > 0 && $cid !== $guest_id; + + $group_id = $guest_group; + $area_id = 1; + if ($logged_in) { + $customer_object = new Customer($this->base_object); + $customer_data = $customer_object->get_by_id($cid); + $group_id = (int)$customer_data->group_id; + $allowed = array_filter(array_map('trim', explode(',', strtoupper($provider->expressCountries())))); + $iso = $allowed ? reset($allowed) : 'DE'; + $country_id = $provider->countryIdByIso($iso); + $area_id = $country_id > 0 ? (int)Country::getShippingAreaById($country_id, $group_id) : 0; + if ($area_id <= 0) { + return $amount; + } + } + + $deliverer = new Deliverer($this->base_object); + $methods = $logged_in + ? $deliverer->getAllActive($area_id, $cart['standard_shipping_items'], $cart['bulk_goods'], $cart['item_weight'], $cart['price_sum_gross']) + : $deliverer->getAllActive($area_id, $cart['standard_shipping_items'], $cart['bulk_goods'], $cart['item_weight']); + if (!is_array($methods) || !$methods) { + return $amount; + } + $first = current($methods); + $shipping = round((float)(isset($first->price_add) ? $first->price_add : 0), 2); + + $payment_add = 0.0; + $payment_method_object = new Payment_method($this->base_object); + $pms = $payment_method_object->getMethodsForGroup($group_id, $cart['price_sum_gross'], $area_id); + foreach ((array)$pms as $pm) { + if (isset($pm->module) && $pm->module === $provider->module()) { + $payment_add = round((float)(isset($pm->paymentAdd) ? $pm->paymentAdd : 0), 2); + break; + } + } + return round($amount + $shipping + $payment_add, 2); + } catch (Throwable $e) { + error_log('Payments express amount: '.$e->getMessage()); + return $amount; + } + } + /** Rueckkehr im Express-Weg: /Kasse/?payment_express=&token=... */ private function payment_express_return() { $module = strtolower(trim((string)$_GET['payment_express']));