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:
@@ -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 – 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';
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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]];
|
||||
}
|
||||
}
|
||||
@@ -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 – 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 – 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<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}
|
||||
{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}
|
||||
<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} />
|
||||
<span class="ck-opt-body">
|
||||
<span class="ck-opt-name">{$pMethod->name}</span>
|
||||
@@ -272,6 +272,8 @@
|
||||
</span>
|
||||
<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}
|
||||
{* 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->accountType == '0'}+ {$pMethod->paymentAdd|number_format:"2":",":"."} €
|
||||
{elseif $pMethod->accountType == '1'}− {$pMethod->paymentAdd|number_format:"2":",":"."} €
|
||||
@@ -460,6 +462,10 @@
|
||||
.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-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 textarea { margin-top: 8px; }
|
||||
@@ -586,6 +592,15 @@ $(document).ready(function() {
|
||||
// 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_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>
|
||||
{/literal}
|
||||
|
||||
Reference in New Issue
Block a user