feat(payments): Mollie-Provider -- getrennte Zahlarten je Methode (Klarna, Kreditkarte, Apple Pay, Google Pay)

- core/payments/providers/mollie: Client (Bearer API-Key, Idempotency-Key), Provider nach dem
  PayPal-v2-Vertrag: start (POST /v2/payments mit method aus payment_methods.argument),
  onReturn/onCancel (Status per GET, Rueckkehr-URL traegt o=<orders.id>), onWebhook
  (id=tr_..., Wahrheit per GET), reconcile (paid/canceled/expired/failed, >24h abbrechen)
- Klarna: lines aus order_item (brutto je Stueck), Versand, Zahlartgebuehr, Gutschein, Rabatt,
  Rundungsausgleich; billing/shippingAddress mit E-Mail und Telefon (E.164)
- core/paymentmethods/molliepaymentmethod.class.php: Kassen-Adapter ohne Express
- install_mollie.sql: 16 Spalten in shipping_area (4 helperjoinkeys), Einstellungen Gruppe
  mollie, 4 Zahlarten (module mollie, argument = Methode), Freischaltung/Leichen als Kommentar
- Kasse neu: Mollie-Logos je Methode, Apple Pay nur auf faehigen Geraeten
- cli.php status: mehrere Zahlarten je Anbieter, Modus generisch
This commit is contained in:
Thomas Bartelt
2026-09-27 10:39:23 +02:00
parent d5a3727884
commit b8111a1e7f
6 changed files with 841 additions and 5 deletions
@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
/**
* Zahlart-Adapter fuer den Cashdesk: payment_methods.module = 'mollie'.
*
* Gilt fuer ALLE Mollie-Zahlarten (Klarna, Kreditkarte, Apple Pay, Google Pay ...): welche
* Methode gemeint ist, steht in payment_methods.argument der gewaehlten Zahlart; der
* Provider liest sie ueber die Bestellung. Kopie des PayPal-v2-Adapters ohne Express.
*/
require_once __DIR__ . '/../payments/Registry.php';
class MolliePaymentMethod
{
private const MODULE = 'mollie';
private $base_object;
private $layout_object;
private $customer_data;
private $payment_method;
private $gift_certificate = false;
private $order_id = false;
public function __construct($base_object, $layout_object)
{
$this->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 &ndash; 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';
}
}
+8 -4
View File
@@ -90,10 +90,14 @@ switch ($cmd) {
continue; continue;
} }
$secrets = Secrets::keyAvailable() ? Secrets::status($base_object->db, $m) : []; $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) . "'"); $res = $base_object->db->query("SELECT id, name, active, argument FROM payment_methods WHERE module='" . $base_object->db->real_escape_string($m) . "'");
$pm = $res ? $res->fetch_object() : null; // 27.09.2026: ein Anbieter kann mehrere Zahlarten tragen (Mollie: eine Zeile je Methode)
echo "\n[$m] " . $p->label() . ($p instanceof \Payments\Providers\PaypalV2\Provider ? ' Modus ' . $p->mode() : '') . "\n"; $zahlarten = [];
echo ' Zahlart: ' . ($pm ? "id {$pm->id} '{$pm->name}' " . ($pm->active ? 'aktiv' : 'inaktiv') : 'NICHT angelegt') . "\n"; 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) { foreach ($p->secretFields() as $name => $label) {
echo " $label: " . (isset($secrets[$name]) ? 'hinterlegt (' . $secrets[$name] . ')' : '-') . "\n"; echo " $label: " . (isset($secrets[$name]) ? 'hinterlegt (' . $secrets[$name] . ')' : '-') . "\n";
} }
+88
View File
@@ -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=<Methode>. 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_<key>). 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);
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Payments\Providers\Mollie;
/**
* Duenner HTTP-Client fuer die Mollie-API v2 (https://api.mollie.com/v2/...).
*
* Auth ist ein einzelner API-Key als Bearer-Token (test_... oder live_...), kein OAuth.
* TLS-Pruefung erzwungen. POSTs bekommen einen Idempotency-Key, damit ein Retry
* innerhalb einer Stunde dieselbe Zahlung liefert statt einer zweiten.
*/
final class Client
{
private const BASE = 'https://api.mollie.com';
public function __construct(private readonly string $apiKey)
{
}
/**
* @param array<string,mixed>|null $payload
* @param list<string> $extraHeaders
* @return array{0:int,1:array<mixed>}
*/
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]];
}
}
+547
View File
@@ -0,0 +1,547 @@
<?php
declare(strict_types=1);
namespace Payments\Providers\Mollie;
use Payments\AbstractProvider;
use Payments\Log;
use Payments\Result;
require_once __DIR__ . '/Client.php';
/**
* Mollie, Payments API v2 -- EIN Provider, mehrere Zahlarten.
*
* Toms Vorgabe (27.09.2026): getrennte Gateways je Zahlungsart, nicht "Mollie waehlt".
* Darum steht in payment_methods je Zahlart EINE Zeile mit module='mollie' und
* argument=<Mollie-Methode> (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=<orders.id>), 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<string,mixed>
*/
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 &ndash; 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<mixed> $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 &ndash; 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 &ndash; 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<mixed> */
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<mixed> $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<string,string>|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<array<string,mixed>>
*/
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;
}
}
@@ -259,7 +259,7 @@
{foreach item=pMethod from=$payment_methods name=pMethodLoop} {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->helperjoinkey != 'be' && $pMethod->helperjoinkey != 'r') || ($pMethod->helperjoinkey == 'be' && $customer->direct_debit_active == 1) || ($pMethod->helperjoinkey == 'r' && $customer->rechnung_active == 1)}
{if $pMethod->activeType > 0} {if $pMethod->activeType > 0}
<label class="ck-opt ck-opt-pay ck-pay-{$pMethod->module|default:'std'}"> <label class="ck-opt ck-opt-pay ck-pay-{$pMethod->module|default:'std'}{if $pMethod->module == 'mollie'} ck-pay-{$pMethod->argument|escape}{/if}" data-method="{if $pMethod->module == 'mollie'}{$pMethod->argument|escape}{else}{$pMethod->module|escape}{/if}">
<input type="radio" name="payment_method" value="{$pMethod->id}" id="selection_{$pMethod->helperjoinkey}" {if (isset($personal_data.payment_method) && $personal_data.payment_method == $pMethod->id) || (!isset($personal_data.payment_method) && $smarty.foreach.pMethodLoop.first)}checked="checked"{/if} /> <input type="radio" name="payment_method" value="{$pMethod->id}" id="selection_{$pMethod->helperjoinkey}" {if (isset($personal_data.payment_method) && $personal_data.payment_method == $pMethod->id) || (!isset($personal_data.payment_method) && $smarty.foreach.pMethodLoop.first)}checked="checked"{/if} />
<span class="ck-opt-body"> <span class="ck-opt-body">
<span class="ck-opt-name">{$pMethod->name}</span> <span class="ck-opt-name">{$pMethod->name}</span>
@@ -272,6 +272,8 @@
</span> </span>
<span class="ck-opt-price"> <span class="ck-opt-price">
{if $pMethod->module == 'paypal_v2' || $pMethod->module == 'paypal'}<img class="ck-paylogo" src="https://www.paypalobjects.com/webstatic/de_DE/i/de-pp-logo-100px.png" alt="PayPal" />{/if} {if $pMethod->module == 'paypal_v2' || $pMethod->module == 'paypal'}<img class="ck-paylogo" src="https://www.paypalobjects.com/webstatic/de_DE/i/de-pp-logo-100px.png" alt="PayPal" />{/if}
{* Mollie-Zahlarten (Klarna, Kreditkarte, Apple Pay, Google Pay): Logo je Methode von Mollie *}
{if $pMethod->module == 'mollie' && $pMethod->argument}<img class="ck-paylogo ck-paylogo-mollie" src="https://www.mollie.com/external/icons/payment-methods/{$pMethod->argument|escape:'url'}.svg" alt="{$pMethod->name|escape}" />{/if}
{if $pMethod->paymentAdd != 0} {if $pMethod->paymentAdd != 0}
{if $pMethod->accountType == '0'}+ {$pMethod->paymentAdd|number_format:"2":",":"."} &euro; {if $pMethod->accountType == '0'}+ {$pMethod->paymentAdd|number_format:"2":",":"."} &euro;
{elseif $pMethod->accountType == '1'}&minus; {$pMethod->paymentAdd|number_format:"2":",":"."} &euro; {elseif $pMethod->accountType == '1'}&minus; {$pMethod->paymentAdd|number_format:"2":",":"."} &euro;
@@ -460,6 +462,10 @@
.ck-opt-err { font-size: 12px; color: #b00020; } .ck-opt-err { font-size: 12px; color: #b00020; }
.ck-opt-price { flex: 0 0 auto; display: flex; align-items: center; gap: 8px; font-weight: 700; white-space: nowrap; } .ck-opt-price { flex: 0 0 auto; display: flex; align-items: center; gap: 8px; font-weight: 700; white-space: nowrap; }
.ck-paylogo { height: 18px; width: auto; } .ck-paylogo { height: 18px; width: auto; }
.ck-paylogo-mollie { height: 24px; }
/* Apple Pay nur auf Geraeten, die es koennen (Safari/iOS); Skript unten blendet ein */
.ck-pay-applepay { display: none; }
.ck-pay-applepay.ck-pay-ok { display: flex; }
.ck-note summary { cursor: pointer; font-weight: 700; } .ck-note summary { cursor: pointer; font-weight: 700; }
.ck-note textarea { margin-top: 8px; } .ck-note textarea { margin-top: 8px; }
@@ -586,6 +592,15 @@ $(document).ready(function() {
// Lieferadresse: eigenes Formular ein-/ausblenden (Gast; eingeloggt macht es content_cash_desk.js) // Lieferadresse: eigenes Formular ein-/ausblenden (Gast; eingeloggt macht es content_cash_desk.js)
$('#shipping_address_select').change(function() { $('.shipping_address_form_container').toggle($(this).val() != -1); }); $('#shipping_address_select').change(function() { $('.shipping_address_form_container').toggle($(this).val() != -1); });
$('.shipping_address_form_container').toggle($('#shipping_address_select').val() != -1); $('.shipping_address_form_container').toggle($('#shipping_address_select').val() != -1);
// Apple Pay (Mollie) nur zeigen, wenn das Geraet es kann. War die versteckte Zahlart
// vorausgewaehlt, rueckt die erste sichtbare nach.
var applePayOk = false;
try { applePayOk = !!(window.ApplePaySession && window.ApplePaySession.canMakePayments()); } catch (e) {}
if (applePayOk) { $('.ck-pay-applepay').addClass('ck-pay-ok'); }
var $checked = $('.ck-opt-pay input[name=payment_method]:checked');
if ($checked.length && !$checked.closest('.ck-opt-pay').is(':visible')) {
$('.ck-opt-pay:visible input[name=payment_method]').first().attr('checked', 'checked');
}
}); });
</script> </script>
{/literal} {/literal}