diff --git a/core/paymentmethods/molliepaymentmethod.class.php b/core/paymentmethods/molliepaymentmethod.class.php new file mode 100644 index 0000000..5e25820 --- /dev/null +++ b/core/paymentmethods/molliepaymentmethod.class.php @@ -0,0 +1,122 @@ +base_object = $base_object; + $this->layout_object = $layout_object; + } + + public function set_data($customer, $shipping, $billing, $shoppingcart, $delivery, $payment, $order_id = false) + { + $this->customer_data = $customer; + $this->payment_method = $payment; + $this->order_id = $order_id; + } + + public function set_gift_certificate($gift_certificate) + { + $this->gift_certificate = $gift_certificate; + } + + public function set_order_id($order_id) + { + $this->order_id = $order_id; + } + + /** Vor dem Speichern: Anbieter einsatzbereit und Methode gesetzt? Sonst Fehlertext fuer die Kasse. */ + public function before_order($argument = null) + { + $provider = \Payments\Registry::get(self::MODULE, $this->base_object); + if (!$provider) { + return 'Diese Zahlart ist derzeit nicht verfuegbar. Bitte waehlen Sie eine andere Zahlart.'; + } + $method = \strtolower(\trim((string) $argument)); + if ($method === '' || !\preg_match('/^[a-z0-9]{2,30}$/', $method)) { + \error_log('Mollie: Zahlart ohne Methode im Feld argument (payment_methods.id ' . ($this->payment_method->id ?? '?') . ')'); + return 'Diese Zahlart ist nicht richtig eingerichtet. Bitte waehlen Sie eine andere Zahlart.'; + } + return ''; + } + + public function during_order($argument = null) + { + return ''; + } + + /** + * Nach dem Speichern: Zahlung bei Mollie anlegen, Kunde per 302 zur Checkout-Seite. + */ + public function after_order($order_total = false) + { + $amount = (float) $order_total; + if ($this->gift_certificate && isset($this->gift_certificate->with_vat)) { + $amount -= (float) $this->gift_certificate->with_vat; + } + $amount = \round($amount, 2); + $label = $this->label(); + + try { + $provider = \Payments\Registry::get(self::MODULE, $this->base_object); + if (!$provider) { + throw new \RuntimeException('Provider mollie nicht installiert'); + } + $url = $provider->start((int) $this->order_id, $amount); + } catch (\Throwable $e) { + \error_log('Mollie after_order (Order ' . $this->order_id . '): ' . $e->getMessage()); + if (\function_exists('shop_event')) { + \shop_event('failed', [ + 'order_id' => $this->order_id, 'betrag' => $amount, + 'customer_id' => $this->customer_data->id ?? null, + 'detail' => 'mollie: start fehlgeschlagen -- ' . $e->getMessage(), + ]); + } + $this->layout_object->assign('call', 'payment_error'); + $this->layout_object->assign('provider_label', $label); + $this->layout_object->assign('message', 'Die Zahlung ueber ' . \htmlspecialchars($label) . ' konnte nicht gestartet werden. Ihr Warenkorb ist noch da – bitte versuchen Sie es erneut oder waehlen Sie eine andere Zahlart.'); + return $this->layout_object->_fetch('content_cash_desk/payment_result.tpl'); + } + + if (!\headers_sent()) { + \header('Location: ' . $url, true, 302); + exit; + } + $this->layout_object->assign('call', 'payment_open'); + $this->layout_object->assign('provider_label', $label); + $this->layout_object->assign('payment_link', $url); + return $this->layout_object->_fetch('content_cash_desk/payment_result.tpl'); + } + + /** Rueckkehr laeuft ueber website_cashdesk::run() (?payment_return=mollie), nicht hier. */ + public function change_order() + { + return ''; + } + + /** Anzeigename der gewaehlten Zahlart (z.B. "Klarna"), Rueckfall "Mollie". */ + private function label(): string + { + $name = \trim((string) ($this->payment_method->name ?? '')); + return $name !== '' ? $name : 'Mollie'; + } +} diff --git a/core/payments/cli.php b/core/payments/cli.php index 1a8ba49..ae676dd 100644 --- a/core/payments/cli.php +++ b/core/payments/cli.php @@ -90,10 +90,14 @@ switch ($cmd) { continue; } $secrets = Secrets::keyAvailable() ? Secrets::status($base_object->db, $m) : []; - $res = $base_object->db->query("SELECT id, name, active FROM payment_methods WHERE module='" . $base_object->db->real_escape_string($m) . "'"); - $pm = $res ? $res->fetch_object() : null; - echo "\n[$m] " . $p->label() . ($p instanceof \Payments\Providers\PaypalV2\Provider ? ' Modus ' . $p->mode() : '') . "\n"; - echo ' Zahlart: ' . ($pm ? "id {$pm->id} '{$pm->name}' " . ($pm->active ? 'aktiv' : 'inaktiv') : 'NICHT angelegt') . "\n"; + $res = $base_object->db->query("SELECT id, name, active, argument FROM payment_methods WHERE module='" . $base_object->db->real_escape_string($m) . "'"); + // 27.09.2026: ein Anbieter kann mehrere Zahlarten tragen (Mollie: eine Zeile je Methode) + $zahlarten = []; + while ($res && ($pm = $res->fetch_object())) { + $zahlarten[] = "id {$pm->id} '{$pm->name}'" . ($pm->argument !== '' ? " [{$pm->argument}]" : '') . ' ' . ($pm->active ? 'aktiv' : 'inaktiv'); + } + echo "\n[$m] " . $p->label() . (\method_exists($p, 'mode') ? ' Modus ' . $p->mode() : '') . "\n"; + echo ' Zahlart: ' . ($zahlarten ? \implode(', ', $zahlarten) : 'NICHT angelegt') . "\n"; foreach ($p->secretFields() as $name => $label) { echo " $label: " . (isset($secrets[$name]) ? 'hinterlegt (' . $secrets[$name] . ')' : '-') . "\n"; } diff --git a/core/payments/install_mollie.sql b/core/payments/install_mollie.sql new file mode 100644 index 0000000..6481ffc --- /dev/null +++ b/core/payments/install_mollie.sql @@ -0,0 +1,88 @@ +-- ============================================================================ +-- core/payments -- Mollie (MySQL, Shop-DB). Additiv, mehrfach ausfuehrbar. +-- Voraussetzung: install.sql (payment_transactions, payment_provider_secrets) ist drin. +-- +-- Getrennte Gateways je Zahlungsart (Tom, 27.09.2026): je Mollie-Methode EINE Zeile in +-- payment_methods mit module='mollie' und argument=. Mollie zeigt dann nur +-- diese Methode, keine Auswahl bei Mollie, kein abweichendes Warenkorb-Verhalten. +-- +-- Reihenfolge: 1) Versandgebiet-Spalten 2) Einstellungen 3) Zahlarten 4) Freischaltung (manuell) +-- ============================================================================ + +-- 1) Die Kasse haengt jede Zahlart ueber helperjoinkey an vier Spalten in shipping_area +-- (active_/add_/account_type_/free_payment_). Neue Keys = neue Spalten. +-- Aufschlaege sind bei Karten/Klarna/Wallets ohnehin nicht zulaessig -> add=0, aktiv=1. +ALTER TABLE `shipping_area` + ADD COLUMN IF NOT EXISTS `active_mollie_klarna` TINYINT(2) NOT NULL DEFAULT 1, + ADD COLUMN IF NOT EXISTS `add_mollie_klarna` DECIMAL(9,2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS `account_type_mollie_klarna` TINYINT(4) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS `free_payment_mollie_klarna` DECIMAL(6,2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS `active_mollie_creditcard` TINYINT(2) NOT NULL DEFAULT 1, + ADD COLUMN IF NOT EXISTS `add_mollie_creditcard` DECIMAL(9,2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS `account_type_mollie_creditcard` TINYINT(4) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS `free_payment_mollie_creditcard` DECIMAL(6,2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS `active_mollie_applepay` TINYINT(2) NOT NULL DEFAULT 1, + ADD COLUMN IF NOT EXISTS `add_mollie_applepay` DECIMAL(9,2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS `account_type_mollie_applepay` TINYINT(4) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS `free_payment_mollie_applepay` DECIMAL(6,2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS `active_mollie_googlepay` TINYINT(2) NOT NULL DEFAULT 1, + ADD COLUMN IF NOT EXISTS `add_mollie_googlepay` DECIMAL(9,2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS `account_type_mollie_googlepay` TINYINT(4) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS `free_payment_mollie_googlepay` DECIMAL(6,2) NOT NULL DEFAULT 0; + +-- 2) Einstellungen (Zahlart-Editor, Reiter "Einstellungen", Gruppe mollie). API-Keys sind +-- Secrets (verschluesselt), nicht hier: test_api_key, live_api_key. +INSERT IGNORE INTO `configuration` (`name`, `text`, `group_id`, `setting`, `type`, `description`) VALUES + ('mollie_mode', '', 'mollie', 'test', 1, 'test oder live (waehlt den API-Key)'), + ('mollie_locale', '', 'mollie', 'de_DE', 1, 'Sprache der Mollie-Seite (xx_XX)'), + ('mollie_currency', '', 'mollie', 'EUR', 1, 'Waehrung (ISO 4217)'), + ('mollie_description', '', 'mollie', 'Bestellung [#order_number]', 1, 'Verwendungszweck auf dem Kontoauszug des Kunden'); + +-- 3) Zahlarten: eine Zeile je Methode. active=1, aber noch KEINER Kundengruppe zugeordnet +-- => im Shop unsichtbar, bis Schritt 4 laeuft. argument = Mollie-Methode (Pflicht). +INSERT INTO `payment_methods` + (`name`, `module`, `argument`, `active`, `rang`, `invoice_text`, `order_text`, `order_email_text`, `helperjoinkey`, `configuration_group_id`) +SELECT 'Klarna', 'mollie', 'klarna', 1, 3, + 'Bezahlung erfolgte per Klarna', + 'Sie werden zur Bezahlung Ihrer Bestellung zu Klarna weitergeleitet (Rechnung, Ratenkauf oder Sofort bezahlen).', + 'Der zu zahlende Betrag wird ueber Klarna abgewickelt.', + 'mollie_klarna', 'mollie' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `payment_methods` WHERE `module`='mollie' AND `argument`='klarna'); + +INSERT INTO `payment_methods` + (`name`, `module`, `argument`, `active`, `rang`, `invoice_text`, `order_text`, `order_email_text`, `helperjoinkey`, `configuration_group_id`) +SELECT 'Kreditkarte', 'mollie', 'creditcard', 1, 4, + 'Bezahlung erfolgte per Kreditkarte', + 'Sie werden zur sicheren Eingabe Ihrer Kartendaten weitergeleitet (Visa, Mastercard, American Express).', + 'Der zu zahlende Betrag wurde per Kreditkarte beglichen.', + 'mollie_creditcard', 'mollie' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `payment_methods` WHERE `module`='mollie' AND `argument`='creditcard'); + +INSERT INTO `payment_methods` + (`name`, `module`, `argument`, `active`, `rang`, `invoice_text`, `order_text`, `order_email_text`, `helperjoinkey`, `configuration_group_id`) +SELECT 'Apple Pay', 'mollie', 'applepay', 1, 5, + 'Bezahlung erfolgte per Apple Pay', + 'Sie werden zur Bezahlung mit Apple Pay weitergeleitet.', + 'Der zu zahlende Betrag wurde per Apple Pay beglichen.', + 'mollie_applepay', 'mollie' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `payment_methods` WHERE `module`='mollie' AND `argument`='applepay'); + +INSERT INTO `payment_methods` + (`name`, `module`, `argument`, `active`, `rang`, `invoice_text`, `order_text`, `order_email_text`, `helperjoinkey`, `configuration_group_id`) +SELECT 'Google Pay', 'mollie', 'googlepay', 1, 6, + 'Bezahlung erfolgte per Google Pay', + 'Sie werden zur Bezahlung mit Google Pay weitergeleitet.', + 'Der zu zahlende Betrag wurde per Google Pay beglichen.', + 'mollie_googlepay', 'mollie' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `payment_methods` WHERE `module`='mollie' AND `argument`='googlepay'); + +-- 4) FREISCHALTUNG (manuell, nach Test): Kundengruppen 1 Endverbraucher, 103 Gast +-- (Haendler 101 bewusst nicht -- die zahlen kundenspezifisch), Versandgebiet 1. +-- INSERT INTO `customer_group_payment_method_configuration` (`group_id`, `payment_method_id`, `shipping_area_id`, `active`) +-- SELECT g.id, pm.id, 1, 1 FROM payment_methods pm JOIN customer_groups g ON g.id IN (1,103) +-- WHERE pm.module='mollie' +-- AND NOT EXISTS (SELECT 1 FROM customer_group_payment_method_configuration c WHERE c.group_id=g.id AND c.payment_method_id=pm.id AND c.shipping_area_id=1); + +-- 5) LEICHEN (nach Go-Live): alte Klarna-/paymorrow-/BILLPAY-/computop-Zahlarten verstecken. +-- Nicht loeschen -- Bestellungen von frueher zeigen noch auf die ids. +-- UPDATE `payment_methods` SET active=0 WHERE id IN (8,9,10,11,12,13); diff --git a/core/payments/providers/mollie/Client.php b/core/payments/providers/mollie/Client.php new file mode 100644 index 0000000..cf7c574 --- /dev/null +++ b/core/payments/providers/mollie/Client.php @@ -0,0 +1,60 @@ +|null $payload + * @param list $extraHeaders + * @return array{0:int,1:array} + */ + public function request(string $method, string $path, ?array $payload = null, array $extraHeaders = []): array + { + $headers = \array_merge([ + 'Authorization: Bearer ' . $this->apiKey, + 'Content-Type: application/json', + 'Accept: application/json', + 'User-Agent: intelectra-shop/1.0 core-payments-mollie', + ], $extraHeaders); + + $ch = \curl_init(self::BASE . $path); + \curl_setopt_array($ch, [ + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + CURLOPT_TIMEOUT => 30, + CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_HTTPHEADER => $headers, + ]); + if ($payload !== null) { + \curl_setopt($ch, CURLOPT_POSTFIELDS, (string) \json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + } + $resp = \curl_exec($ch); + if ($resp === false) { + $err = \curl_error($ch); + \curl_close($ch); + throw new \RuntimeException('Mollie HTTP-Transportfehler: ' . $err); + } + $status = (int) \curl_getinfo($ch, CURLINFO_HTTP_CODE); + \curl_close($ch); + + $decoded = $resp === '' ? [] : \json_decode((string) $resp, true); + return [$status, \is_array($decoded) ? $decoded : ['raw' => (string) $resp]]; + } +} diff --git a/core/payments/providers/mollie/Provider.php b/core/payments/providers/mollie/Provider.php new file mode 100644 index 0000000..091ea05 --- /dev/null +++ b/core/payments/providers/mollie/Provider.php @@ -0,0 +1,547 @@ + (klarna, creditcard, applepay, googlepay, ...). Beim Anlegen + * der Zahlung wird `method` fest gesetzt, die Mollie-Seite zeigt nur diese Methode. + * + * Ablauf: start() legt die Zahlung an (POST /v2/payments) und leitet zur Checkout-URL. + * Die Rueckkehr-URL traegt unsere Bestellnummer (o=), weil Mollie beim + * Redirect nichts anhaengt. onReturn()/onCancel()/onWebhook()/reconcile() holen den + * Zahlungsstatus IMMER per GET bei Mollie -- der Webhook selbst traegt nur die ID und + * ist nicht signiert; die Abfrage ist die Verifikation. + * + * Status bei Mollie: open, pending, authorized (nicht final) / paid, canceled, expired, + * failed (final). Klarna verlangt lines + billingAddress; Betrag = Summe der lines. + * + * Einstellungen (configuration, Gruppe mollie): mode (test|live), locale, currency, description + * Secrets (payment_provider_secrets): test_api_key, live_api_key + */ +final class Provider extends AbstractProvider +{ + private ?Client $client = null; + + /** Anzeigenamen je Mollie-Methode (fuer Protokoll und Hinweistexte). */ + private const LABELS = [ + 'klarna' => 'Klarna', + 'creditcard' => 'Kreditkarte', + 'applepay' => 'Apple Pay', + 'googlepay' => 'Google Pay', + 'paypal' => 'PayPal', + 'banktransfer' => 'Ueberweisung', + 'ideal' => 'iDEAL', + 'bancontact' => 'Bancontact', + 'eps' => 'EPS', + 'twint' => 'TWINT', + 'trustly' => 'Trustly', + 'paybybank' => 'Pay by Bank', + 'riverty' => 'Riverty', + 'in3' => 'in3', + 'billie' => 'Billie', + ]; + + /** Methoden, fuer die Mollie Positionen (lines) und volle Adressen verlangt. */ + private const NEEDS_LINES = ['klarna', 'billie', 'in3', 'riverty', 'billink', 'voucher']; + + public function module(): string + { + return 'mollie'; + } + + public function label(): string + { + return 'Mollie'; + } + + public function secretFields(): array + { + return [ + 'test_api_key' => 'Test-API-Key (test_...)', + 'live_api_key' => 'Live-API-Key (live_...)', + ]; + } + + public function mode(): string + { + return $this->setting('mode', 'test') === 'live' ? 'live' : 'test'; + } + + private function client(): Client + { + if ($this->client === null) { + $this->client = new Client($this->secret($this->mode() . '_api_key')); + } + return $this->client; + } + + public static function methodLabel(string $method): string + { + return self::LABELS[$method] ?? \ucfirst($method); + } + + /** Mollie-Methode der Zahlart, die an der Bestellung haengt (payment_methods.argument). */ + public function methodForOrder(object $order): string + { + $pmId = (int) ($order->payment_method ?? 0); + $res = $this->db->query("SELECT module, argument FROM payment_methods WHERE id=$pmId LIMIT 1"); + $row = $res ? $res->fetch_object() : null; + if (!$row || (string) $row->module !== $this->module()) { + throw new \RuntimeException('Mollie: Zahlart ' . $pmId . ' der Bestellung ist keine Mollie-Zahlart.'); + } + $method = \strtolower(\trim((string) $row->argument)); + if ($method === '' || !\preg_match('/^[a-z0-9]{2,30}$/', $method)) { + throw new \RuntimeException('Mollie: Zahlart ' . $pmId . ' hat keine Mollie-Methode im Feld "argument" (z.B. klarna, creditcard, applepay).'); + } + return $method; + } + + // ------------------------------------------------------------------ start + + public function start(int $orderId, float $amount): string + { + $order = $this->orderById($orderId); + $number = (string) $order->order_number; + $method = $this->methodForOrder($order); + $payload = $this->buildPayload($order, $amount, $method); + + // Deterministischer Idempotency-Key je (Bestellung, Betrag): ein zweiter Klick oder + // Retry innerhalb einer Stunde bekommt dieselbe Zahlung zurueck. + $idem = 'shop-' . $orderId . '-' . \substr(\hash('sha256', $number . '|' . $payload['amount']['value'] . '|' . $method), 0, 24); + + [$status, $body] = $this->client()->request('POST', '/v2/payments', $payload, ['Idempotency-Key: ' . $idem]); + Log::write($this->module(), 'create', $orderId, $number, (string) ($body['id'] ?? ''), (string) ($body['status'] ?? ''), $status, + ['request' => $payload, 'response' => $body]); + + if ($status !== 201 || empty($body['id'])) { + throw new \RuntimeException('Mollie: Zahlung konnte nicht angelegt werden (HTTP ' . $status . '): ' + . (string) ($body['detail'] ?? \json_encode($body)) . (isset($body['field']) ? ' [Feld ' . $body['field'] . ']' : '')); + } + $checkout = (string) ($body['_links']['checkout']['href'] ?? ''); + if ($checkout === '') { + throw new \RuntimeException('Mollie: keine Checkout-URL in der Antwort.'); + } + + $this->openTransaction($orderId, (string) $body['id'], $amount, $checkout); + $this->setTransactionStatus((string) $body['id'], 'open', 'method ' . $method); + return $checkout; + } + + /** + * Anfrage an Mollie zusammenbauen. Oeffentlich, damit sie sich ohne API-Key auf der + * Kommandozeile pruefen laesst (Betrag = Summe der lines usw.). + * @return array + */ + public function buildPayload(object $order, float $amount, string $method): array + { + $orderId = (int) $order->id; + $number = (string) $order->order_number; + $value = \number_format($amount, 2, '.', ''); + + $description = \str_replace('[#order_number]', $number, $this->setting('description', 'Bestellung [#order_number]')); + + $payload = [ + 'amount' => ['currency' => $this->currency(), 'value' => $value], + 'description' => \mb_substr($description, 0, 255), + 'redirectUrl' => $this->returnUrl() . '&o=' . $orderId, + 'cancelUrl' => $this->cancelUrl() . '&o=' . $orderId, + 'webhookUrl' => $this->webhookUrl(), + 'method' => $method, + 'locale' => $this->setting('locale', 'de_DE'), + 'metadata' => ['order_id' => $orderId, 'order_number' => $number, 'shop' => $this->shopBaseUrl()], + ]; + + $billing = $this->address($order, 'billing'); + if ($billing !== null) { + $payload['billingAddress'] = $billing; + } + $shipping = $this->address($order, 'shipping'); + if ($shipping !== null) { + $payload['shippingAddress'] = $shipping; + } + + if (\in_array($method, self::NEEDS_LINES, true)) { + $payload['lines'] = $this->lines($order, $amount); + } + return $payload; + } + + // ----------------------------------------------------------------- return + + public function onReturn(array $query): Result + { + return $this->afterRedirect($query, false); + } + + public function onCancel(array $query): Result + { + return $this->afterRedirect($query, true); + } + + /** + * Rueckkehr des Kunden (Erfolg oder Abbruch-URL): Vorgang ueber unsere Bestellnummer + * finden, Status bei Mollie holen, danach handeln. Der Abbruch-Link ist keine + * Wahrheit -- auch dort wird nachgefragt. + */ + private function afterRedirect(array $query, bool $viaCancelUrl): Result + { + $orderId = (int) ($query['o'] ?? 0); + $tx = $orderId > 0 ? $this->transactionByOrder($orderId) : null; + if (!$tx) { + $p = $_SESSION['easyshop']['payment_pending'] ?? null; + if (\is_array($p) && ($p['module'] ?? '') === $this->module() && !empty($p['reference'])) { + $tx = $this->transactionByReference((string) $p['reference']); + } + } + if (!$tx) { + $this->pendingClear(); + return $viaCancelUrl + ? Result::cancelled() + : Result::error('Der Zahlungsvorgang konnte nicht zugeordnet werden. Ihr Warenkorb ist noch da.'); + } + if ($tx->status === 'paid') { + $this->pendingClear(); + return Result::paid((int) $tx->order_id, (string) $tx->order_number); + } + + try { + $payment = $this->fetch((string) $tx->reference, (int) $tx->order_id, (string) $tx->order_number); + } catch (\Throwable $e) { + \error_log('Mollie return: ' . $e->getMessage()); + return Result::error('Die Rueckmeldung des Zahlungsanbieters ist gerade nicht erreichbar. Ihr Warenkorb ist noch da – bitte versuchen Sie es in einer Minute erneut.', + (int) $tx->order_id, (string) $tx->order_number); + } + return $this->apply($tx, $payment, $viaCancelUrl ? 'cancel' : 'return'); + } + + /** + * Zahlungsstatus von Mollie auf den Vorgang anwenden -- eine Stelle fuer Rueckkehr, + * Webhook und Abgleicher. + * @param array $payment + */ + private function apply(object $tx, array $payment, string $source): Result + { + $ref = (string) $tx->reference; + $orderId = (int) $tx->order_id; + $number = (string) $tx->order_number; + $status = (string) ($payment['status'] ?? ''); + $method = (string) ($payment['method'] ?? ''); + $label = $method !== '' ? self::methodLabel($method) : 'Mollie'; + + switch ($status) { + case 'paid': + $ok = $this->markPaid($ref, (string) ($payment['amount']['value'] ?? ''), (string) ($payment['amount']['currency'] ?? ''), + $ref, $this->payerEmail($payment), (string) ($payment['details']['consumerAccount'] ?? ''), $source . ' ' . $method); + if ($ok) { + return Result::paid($orderId, $number); + } + return Result::error('Die Zahlung konnte nicht zugeordnet werden. Bitte kontaktieren Sie uns unter Angabe der Bestellnummer ' . $number . '.', $orderId, $number); + + case 'authorized': + case 'pending': + $this->setTransactionStatus($ref, 'pending', $label . ': ' . $status . ' (' . $source . ')'); + return Result::pending($orderId, $number, $label . ' hat die Zahlung angenommen und prueft sie noch. Sie erhalten die Bestellbestaetigung, sobald die Zahlung bestaetigt ist.'); + + case 'canceled': + $this->markCancelled($ref, 'Kunde hat bei ' . $label . ' abgebrochen', $source); + return Result::cancelled($orderId, $number); + + case 'expired': + $this->markCancelled($ref, $label . ': Zahlung abgelaufen (expired)', $source); + return Result::cancelled($orderId, $number); + + case 'failed': + $this->markCancelled($ref, $label . ': Zahlung fehlgeschlagen (failed)', $source); + return Result::error('Die Zahlung ueber ' . $label . ' wurde nicht angenommen. Ihr Warenkorb ist noch da – bitte waehlen Sie eine andere Zahlart.', $orderId, $number); + + case 'open': + default: + // Kunde ist ohne Abschluss zurueck (Fenster zu, Zurueck-Taste). Der Vorgang + // bleibt offen: Mollie laesst ihn verfallen, der Abgleicher raeumt auf. + // Der Session-Riegel schickt einen zweiten "Bestellen"-Klick auf dieselbe Checkout-URL. + $this->setTransactionStatus($ref, 'open', $label . ': ' . ($status ?: 'unbekannt') . ' bei Rueckkehr (' . $source . ')'); + return Result::error('Die Zahlung ueber ' . $label . ' wurde nicht abgeschlossen. Ihr Warenkorb ist noch da – Sie koennen es erneut versuchen oder eine andere Zahlart waehlen.', $orderId, $number); + } + } + + // ---------------------------------------------------------------- webhook + + /** + * Mollie schickt application/x-www-form-urlencoded mit genau einem Feld: id=tr_... + * Keine Signatur -- die Wahrheit kommt per GET von Mollie. Antwort 200 = angenommen, + * alles andere wird bis zu 10x in 26 h wiederholt. + */ + public function onWebhook(array $headers, string $rawBody): int + { + $params = []; + \parse_str($rawBody, $params); + $id = (string) ($params['id'] ?? ($_POST['id'] ?? '')); + if (!\preg_match('/^tr_[A-Za-z0-9]{6,40}$/', $id)) { + Log::write($this->module(), 'webhook', null, null, $id, 'rejected', null, ['body' => \mb_substr($rawBody, 0, 500)], 'keine Zahlungs-ID'); + return 400; + } + $tx = $this->transactionByReference($id); + if (!$tx) { + // Unbekannt (z.B. Test aus dem Mollie-Dashboard): protokollieren, 200 damit Mollie nicht 26 h lang wiederholt. + Log::write($this->module(), 'webhook', null, null, $id, 'unknown', null, null, 'kein Vorgang zu dieser ID'); + return 200; + } + try { + $payment = $this->fetch($id, (int) $tx->order_id, (string) $tx->order_number, 'webhook'); + } catch (\Throwable $e) { + \error_log('Mollie webhook ' . $id . ': ' . $e->getMessage()); + return 500; // Mollie versucht es spaeter noch einmal + } + if ($tx->status === 'paid') { + return 200; + } + $this->apply($tx, $payment, 'webhook'); + return 200; + } + + // -------------------------------------------------------------- reconcile + + public function reconcile(bool $dryRun = true): array + { + $out = []; + // Nach 10 Minuten nachsehen, nach 72 Stunden aufgeben. + foreach ($this->openTransactions(10, 72) as $tx) { + $ref = (string) $tx->reference; + try { + $payment = $this->fetch($ref, (int) $tx->order_id, (string) $tx->order_number, 'reconcile'); + } catch (\Throwable $e) { + $out[] = "$ref (Order {$tx->order_id}): API-Fehler " . $e->getMessage(); + continue; + } + $status = (string) ($payment['status'] ?? ''); + $age = (\time() - \strtotime((string) $tx->created_at)) / 3600; + $tag = $dryRun ? ' [dry-run]' : ''; + + if ($status === 'paid') { + $out[] = "$ref (Order {$tx->order_id}): bei Mollie bezahlt, im Shop offen -> bezahlt setzen$tag"; + } elseif (\in_array($status, ['canceled', 'expired', 'failed'], true)) { + $out[] = "$ref (Order {$tx->order_id}): Mollie $status -> storniert$tag"; + } elseif ($age >= 24) { + // Nach 24 h ohne Abschluss: bei Mollie abbrechen (wenn noch moeglich) und stornieren. + $out[] = "$ref (Order {$tx->order_id}): seit " . \round($age) . " h $status -> abbrechen + storniert$tag"; + if (!$dryRun) { + if (!empty($payment['isCancelable'])) { + [$s, $b] = $this->client()->request('DELETE', '/v2/payments/' . \rawurlencode($ref)); + Log::write($this->module(), 'cancel_api', (int) $tx->order_id, (string) $tx->order_number, $ref, (string) ($b['status'] ?? ''), $s, $b); + } + $this->markCancelled($ref, 'Mollie ' . $status . ' nach ' . \round($age) . ' h ohne Abschluss', 'reconcile'); + } + continue; + } else { + $out[] = "$ref (Order {$tx->order_id}): $status, " . \round($age, 1) . " h alt -> warten"; + continue; + } + if (!$dryRun) { + $this->apply($tx, $payment, 'reconcile'); + } + } + return $out; + } + + // ----------------------------------------------------------------- intern + + /** GET /v2/payments/{id} mit Protokoll. @return array */ + private function fetch(string $id, int $orderId, string $number, string $action = 'get'): array + { + [$status, $body] = $this->client()->request('GET', '/v2/payments/' . \rawurlencode($id)); + Log::write($this->module(), $action, $orderId, $number, $id, (string) ($body['status'] ?? ''), $status, $body); + if ($status !== 200 || !isset($body['status'])) { + throw new \RuntimeException('Mollie GET payment ' . $id . ' fehlgeschlagen (HTTP ' . $status . '): ' . (string) ($body['detail'] ?? \json_encode($body))); + } + return $body; + } + + /** @param array $payment */ + private function payerEmail(array $payment): string + { + $d = $payment['details'] ?? []; + foreach (['consumerAccount', 'payerEmail', 'email'] as $k) { + $v = (string) ($d[$k] ?? ''); + if ($v !== '' && \str_contains($v, '@')) { + return $v; + } + } + return (string) ($payment['billingAddress']['email'] ?? ''); + } + + /** + * Adresse der Bestellung im Mollie-Format. null, wenn keine Strasse da ist (dann lieber + * gar keine Adresse als eine, die Mollie ablehnt). + * @return array|null + */ + private function address(object $order, string $kind): ?array + { + $p = static fn(string $f) => \trim((string) ($order->{$kind . '_' . $f} ?? '')); + $street = \trim($p('street') . ' ' . $p('house_number')); + if ($street === '' || $p('zip_code') === '' || $p('city') === '') { + return null; + } + $iso = 'DE'; + $cid = (int) ($order->{$kind . '_country'} ?? 0); + if ($cid > 0) { + $res = $this->db->query("SELECT iso_code_2 FROM countries WHERE id=$cid"); + $r = $res ? $res->fetch_object() : null; + if ($r && \strlen((string) $r->iso_code_2) === 2) { + $iso = \strtoupper((string) $r->iso_code_2); + } + } + $first = $p('firstname'); + $last = $p('surname'); + $company = $p('company'); + // Klarna verlangt Vor- und Nachname mit je >= 2 Zeichen; Firmenkunden ohne Person: Firma als Name. + if (\mb_strlen($first) < 2 && $company !== '') { + $first = $company; + } + if (\mb_strlen($last) < 2) { + $last = $company !== '' ? $company : $first; + } + $a = [ + 'givenName' => \mb_substr($first, 0, 100), + 'familyName' => \mb_substr($last, 0, 100), + 'streetAndNumber' => \mb_substr($street, 0, 100), + 'postalCode' => $p('zip_code'), + 'city' => $p('city'), + 'country' => $iso, + ]; + if ($company !== '') { + $a['organizationName'] = \mb_substr($company, 0, 100); + } + $email = $this->customerEmail($order); + if ($email !== '') { + $a['email'] = $email; + } + $phone = $this->phoneE164((string) ($order->customer_phone ?? ''), $iso); + if ($phone !== '') { + $a['phone'] = $phone; + } + return $a; + } + + private function customerEmail(object $order): string + { + $e = \trim((string) ($order->customer_email ?? '')); + if ($e === '') { + $cid = (int) ($order->customer_id ?? 0); + $res = $cid > 0 ? $this->db->query("SELECT email FROM customers WHERE id=$cid") : null; + $r = $res ? $res->fetch_object() : null; + $e = $r ? \trim((string) $r->email) : ''; + } + return \filter_var($e, FILTER_VALIDATE_EMAIL) ? $e : ''; + } + + /** Telefon nach E.164 (nur Ziffern, Landesvorwahl); unbrauchbare Eingaben fallen weg. */ + private function phoneE164(string $raw, string $iso): string + { + $d = \preg_replace('/[^0-9+]/', '', $raw) ?? ''; + if ($d === '') { + return ''; + } + if (\str_starts_with($d, '00')) { + $d = '+' . \substr($d, 2); + } elseif (\str_starts_with($d, '0') && $iso === 'DE') { + $d = '+49' . \substr($d, 1); + } elseif (!\str_starts_with($d, '+')) { + return ''; + } + return \preg_match('/^\+[1-9][0-9]{6,14}$/', $d) ? $d : ''; + } + + /** + * Positionen fuer Klarna & Co.: Artikel (brutto je Stueck aus Netto + Steuersatz), + * Versand, Zahlartaufschlag, Gutschein, ggf. Rundungsausgleich -- die Summe der + * totalAmount MUSS dem Zahlbetrag entsprechen, sonst lehnt Mollie ab. + * @return list> + */ + public function lines(object $order, float $amount): array + { + $cur = $this->currency(); + $money = static fn(float $v) => ['currency' => $cur, 'value' => \number_format($v, 2, '.', '')]; + $lines = []; + $sum = 0.0; + $maxRate = 0.0; + + $orderId = (int) $order->id; + $res = $this->db->query("SELECT item_id, item_number, item_name, amount, price, tax_rate FROM order_item WHERE order_id=$orderId AND (parent_item_id IS NULL OR parent_item_id=0) ORDER BY id"); + while ($res && ($it = $res->fetch_object())) { + $qty = \max(1, (int) $it->amount); + $rate = (float) $it->tax_rate; + $maxRate = \max($maxRate, $rate); + $unit = \round((float) $it->price * (1 + $rate / 100), 2); + $total = \round($unit * $qty, 2); + $lines[] = [ + 'type' => 'physical', + 'description' => \mb_substr(\trim((string) $it->item_name) !== '' ? (string) $it->item_name : ('Artikel ' . $it->item_number), 0, 255), + 'sku' => \mb_substr((string) $it->item_number, 0, 64), + 'quantity' => $qty, + 'quantityUnit'=> 'pcs', + 'unitPrice' => $money($unit), + 'totalAmount' => $money($total), + 'vatRate' => \number_format($rate, 2, '.', ''), + 'vatAmount' => $money(\round($total * $rate / (100 + $rate), 2)), + ]; + $sum += $total; + } + if (!$lines) { + throw new \RuntimeException('Mollie: Bestellung ' . $orderId . ' hat keine Positionen.'); + } + $rate = $maxRate > 0 ? $maxRate : 19.0; + + $ship = \round((float) ($order->shipping_charges ?? 0), 2); + if ($ship > 0) { + $lines[] = ['type' => 'shipping_fee', 'description' => 'Versand', 'quantity' => 1, + 'unitPrice' => $money($ship), 'totalAmount' => $money($ship), + 'vatRate' => \number_format($rate, 2, '.', ''), 'vatAmount' => $money(\round($ship * $rate / (100 + $rate), 2))]; + $sum += $ship; + } + $fee = \round((float) ($order->payment_method_charges ?? 0), 2); + if ($fee > 0) { + $lines[] = ['type' => 'surcharge', 'description' => 'Zahlartgebuehr', 'quantity' => 1, + 'unitPrice' => $money($fee), 'totalAmount' => $money($fee), + 'vatRate' => \number_format($rate, 2, '.', ''), 'vatAmount' => $money(\round($fee * $rate / (100 + $rate), 2))]; + $sum += $fee; + } elseif ($fee < 0) { + $lines[] = ['type' => 'discount', 'description' => 'Zahlartrabatt', 'quantity' => 1, + 'unitPrice' => $money($fee), 'totalAmount' => $money($fee), + 'vatRate' => \number_format($rate, 2, '.', ''), 'vatAmount' => $money(\round($fee * $rate / (100 + $rate), 2))]; + $sum += $fee; + } + $gift = \round((float) ($order->gift_certificate_total ?? 0), 2); + if ($gift > 0) { + $lines[] = ['type' => 'gift_card', 'description' => 'Gutschein', 'quantity' => 1, + 'unitPrice' => $money(-$gift), 'totalAmount' => $money(-$gift), 'vatRate' => '0.00', 'vatAmount' => $money(0)]; + $sum -= $gift; + } + $disc = \round((float) ($order->discount_total ?? 0), 2) + \round((float) ($order->rebate_total ?? 0), 2); + if ($disc > 0) { + $lines[] = ['type' => 'discount', 'description' => 'Rabatt', 'quantity' => 1, + 'unitPrice' => $money(-$disc), 'totalAmount' => $money(-$disc), + 'vatRate' => \number_format($rate, 2, '.', ''), 'vatAmount' => $money(\round(-$disc * $rate / (100 + $rate), 2))]; + $sum -= $disc; + } + + // Rundung: Rechenwege des Shops (Netto-Summen, dann Steuer) und der Zeilen (brutto je Stueck) + // koennen um Cents auseinanderliegen. Ausgleich als eigene Zeile ohne Steuer. + $diff = \round($amount - $sum, 2); + if (\abs($diff) >= 0.01) { + $lines[] = ['type' => $diff > 0 ? 'surcharge' : 'discount', 'description' => 'Rundungsausgleich', 'quantity' => 1, + 'unitPrice' => $money($diff), 'totalAmount' => $money($diff), 'vatRate' => '0.00', 'vatAmount' => $money(0)]; + } + return $lines; + } +} diff --git a/web/intelectra_shop/themes/theme2/templates/content_cash_desk_neu.tpl b/web/intelectra_shop/themes/theme2/templates/content_cash_desk_neu.tpl index b0cc7ca..9886a1d 100644 --- a/web/intelectra_shop/themes/theme2/templates/content_cash_desk_neu.tpl +++ b/web/intelectra_shop/themes/theme2/templates/content_cash_desk_neu.tpl @@ -259,7 +259,7 @@ {foreach item=pMethod from=$payment_methods name=pMethodLoop} {if ($pMethod->helperjoinkey != 'be' && $pMethod->helperjoinkey != 'r') || ($pMethod->helperjoinkey == 'be' && $customer->direct_debit_active == 1) || ($pMethod->helperjoinkey == 'r' && $customer->rechnung_active == 1)} {if $pMethod->activeType > 0} -