Compare commits

...
Author SHA1 Message Date
Thomas Bartelt 4ea1daec9a fix(cms/zahlarten): Platzhalter [#customer_freetextfield3] im Bestelltext ersetzen wie in der Kasse 2026-09-27 22:48:11 +02:00
Thomas Bartelt 91d90b6714 feat(cms): Platzhalter [#zahlarten] in Textboxen -- Liste der aktiven Zahlarten der Kundengruppe aus der Kassen-Logik (Seite Bezahlarten nicht mehr von Hand); Mollie-Aktivschalter in shipping_area auf gruppenspezifisch (2), sonst saehen Haendler Mollie 2026-09-27 22:43:55 +02:00
Thomas Bartelt 21977b8f41 feat(payments/mollie): Zahlart 'Online-Banking' (Pay by Bank, paybybank); Google Pay inaktiv (nur Direkt-Integration) 2026-09-27 22:19:56 +02:00
Thomas Bartelt 5f3bbc2c45 feat(payments/mollie): Next-Gen-Webhooks (JSON, HMAC-SHA256 X-Mollie-Signature) neben klassischem id=tr_; fix: Bestellbestaetigung aus Webhook-/CLI-Kontext (Layout-Objekt im Bootstrap); markCancelled idempotent 2026-09-27 21:51:11 +02:00
Thomas Bartelt b8111a1e7f 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
2026-09-27 10:39:23 +02:00
Thomas Bartelt d5a3727884 fix(artikel): attribute_7 nur als Ziffernfolge in die SQL (Leerzeichen => HTTP 500); zieht Live-Haertung vom 25.08. (Grundpreis-Teiler) nach 2026-09-27 10:16:49 +02:00
Thomas Bartelt 5844b24db4 style(warenkorb-neu): Leer-Ansicht rechtsbuendig, Knopf raus aus der Klick-Falle des fixierten Kopfes 2026-09-27 10:08:31 +02:00
Thomas Bartelt d0b79a2820 fix(warenkorb-neu, kasse-neu): Scroll nicht wiederherstellen, wenn der Korb leer wurde (Knopf lag unter dem fixierten Kopf); sticky Kaesten unter den 152px-Kopf (top 168px) 2026-09-27 10:02:47 +02:00
Thomas Bartelt 4ef81ceec3 fix(warenkorb-neu): 'Weiter einkaufen' ueber das Formular (cart_goto=weiter), Auto-Speichern mit Wartezeit statt Wettlauf gegen den Klick, Doppel-Submit-Riegel; Anfuehrungszeichen 2026-09-27 09:55:16 +02:00
Thomas Bartelt 4c1ecb82a7 fix(kasse): Gast-/Express-Bestellung ohne Anrede verlor die Adresse -- CustomerAddress::validate() verlangt honorific; leer => Firma/Herr, Fehlschlag jetzt im Log 2026-09-27 00:36:03 +02:00
Thomas Bartelt 30c81cae9c fix(kasse-neu): schliessende Klammer stand hinter dem Kommentar -- Syntaxfehler, Bestellen-Knopf ohne Handler 2026-09-26 23:51:42 +02:00
Thomas Bartelt def156e1c2 feat(kasse, warenkorb): neue Ansicht ist Standard, Rueckfall ?ansicht=alt per Session
- Weiche in website_shoppingcart.php / website_cashdesk.php gedreht: content_*_neu.tpl
  ist Standard, ?ansicht=alt schaltet die Sitzung auf die alten Templates, ?ansicht=neu zurueck
- Parameter ansicht=neu aus Form-Actions und Links der neuen Templates entfernt
- Erfolgsseite completed.tpl und payment_result.tpl (theme2) im Stil der neuen Kasse
  (Schrittleiste 3 Fertig, Karte, Bestellnummer, Text aus cash_desk_order_completed)
- Erfolgsseite ohne Navigation (site_type full_width) wie Warenkorb/Kasse
- sticky Bestellkasten unter dem klebenden Theme-Kopf (top 105px statt 16px)
- fix: Korbsumme 0 teilte in manage_gift_certificate() durch null (HTTP 500)
2026-09-26 23:43:41 +02:00
Tom Bartelt 6cea66126a fix(kasse): HTTP 500 in der Gast-Kasse bei leerem Land
- Country::getShippingAreaById: Land/Gruppe als int, leeres Land -> false
  statt 'WHERE id = ' (SQL-Fatal, leere Seite, 16x seit 03.09.2026)
- manage_guest_order_address: Lieferadresse = Rechnungsadresse ohne Land
  -> Standardland fuer die Versandgebiet-Suche
2026-09-22 21:21:38 +02:00
Tom Bartelt 40b4774f0d fix(kundencenter): SQL-Fatal beim Adresse speichern (leerer parent_id)
- main_widget_menu_2: !empty statt isset, sonst 'parent_id in(10636,)'
- add_address: exit nach dem Location-Header
- get_this_url: leere Parameter nicht anhaengen
2026-09-22 20:45:53 +02:00
Tom Bartelt 85bc1d8c9a feat(payments): Block Zahlungsvorgang im Bestell-Editor, Storno-Felder bei automatischem Storno
- Bestell-Editor rechts: Zahlungsvorgang (Status, Grund, Transaktions-ID,
  Referenz, Betraege, Zeiten, Verlauf aus PG payment_log), anbieterneutral
  ueber payment_transactions (Payments\Overview, Log::forOrder)
- markCancelled() fuellt Stornodatum, Stornostatus, Stornogrund und
  Storno-Nachricht der Bestellung (nur leere Felder)
- neuer Stornogrund 'Zahlung beim Anbieter nicht abgeschlossen' (install.sql)
- reconcile: 404-Grund lesbar mit Alter des Vorgangs
2026-09-22 09:30:52 +02:00
26 changed files with 1619 additions and 68 deletions
+8
View File
@@ -261,6 +261,14 @@ class Country extends Main {
public static function getShippingAreaById($countryId, $customer_group_id = false) {
$db = Registry::get('base')->db;
// 22.09.2026: leeres Land (Gast-Kasse per Enter abgeschickt, Land nicht gewaehlt) lief in
// "WHERE id = " und damit in einen SQL-Fatal -> HTTP 500 mit leerer Seite in der Kasse.
$countryId = (int) $countryId;
$customer_group_id = (int) $customer_group_id;
if ($countryId <= 0) {
return false;
}
$sql = "SELECT shipping_area_id
FROM countries
WHERE id = $countryId";
@@ -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';
}
}
+30 -2
View File
@@ -273,15 +273,43 @@ abstract class AbstractProvider implements ProviderInterface
if (!$tx || $tx->status === 'paid') {
return;
}
// 27.09.2026: idempotent -- Rueckkehr UND Webhook melden denselben Abbruch, der
// Stornotext wurde sonst zweimal an die Bestellung gehaengt (Mollie-Test 290617).
if ($tx->status === 'cancelled') {
return;
}
$this->setTransactionStatus($reference, 'cancelled', $why);
$orderId = (int) $tx->order_id;
// 6 = storniert (wie v1-PayPal im Cancel-Zweig)
$this->db->query("UPDATE orders SET order_status=6 WHERE id=$orderId AND payment_status<>2");
// 6 = storniert (wie v1-PayPal im Cancel-Zweig).
// 22.09.2026 -- dazu die Storno-Felder der Bestellung fuellen, damit im Bestell-Editor
// steht, WARUM: bisher stand da nur "Storniert", der Grund lag allein in
// payment_transactions.detail. Vorhandene Eintraege werden nicht ueberschrieben.
$msg = $this->db->real_escape_string(
$this->label() . ': ' . $why . '. Zahlung nicht abgeschlossen, kein Geld geflossen. '
. 'Automatisch storniert (' . $source . ', ' . \date('d.m.Y H:i') . ').'
);
$reason = $this->cancellationReasonId();
$this->db->query(
"UPDATE orders SET order_status=6,
cancellation_date = COALESCE(cancellation_date, CURDATE()),
cancellation_status = IF(cancellation_status IS NULL OR cancellation_status=0, 2, cancellation_status),
cancellation_reason_id = " . ($reason > 0 ? "COALESCE(NULLIF(cancellation_reason_id,0), $reason)" : 'cancellation_reason_id') . ",
cancellation_info = IF(cancellation_info IS NULL OR cancellation_info='', '$msg', CONCAT(cancellation_info, '\n', '$msg'))
WHERE id=$orderId AND payment_status<>2"
);
$this->pendingClear();
$this->event('cancelled', $orderId, (string) $tx->order_number, (float) $tx->expected_amount, $reference, null, (int) $tx->customer_id, $why . ' (' . $source . ')');
Log::write($this->module(), 'cancel', $orderId, (string) $tx->order_number, $reference, 'cancelled', null, null, $why);
}
/** Stornogrund "Zahlung beim Anbieter nicht abgeschlossen" (install.sql), 0 wenn nicht angelegt. */
private function cancellationReasonId(): int
{
$res = $this->db->query("SELECT id FROM cancellation_reasons WHERE name='Zahlung beim Anbieter nicht abgeschlossen' LIMIT 1");
$r = $res ? $res->fetch_object() : null;
return $r ? (int) $r->id : 0;
}
protected function amountMatches(string $expected, string $paid, string $expCur, string $paidCur): bool
{
return \number_format((float) $expected, 2, '.', '') === \number_format((float) $paid, 2, '.', '')
+28
View File
@@ -56,6 +56,34 @@ final class Log
return true;
}
/**
* Verlauf einer Bestellung fuer den Bestell-Editor, aelteste zuerst.
* Payload bleibt draussen (Roh-JSON gehoert nicht in die Maske).
*
* @return list<array{zeit:string,action:string,status:?string,http_status:?string,detail:?string,reference:?string}>
*/
public static function forOrder(int $orderId, int $limit = 40): array
{
$c = self::conn();
if (!$c || $orderId <= 0) {
return [];
}
$host = (string) ($_SERVER['SERVER_NAME'] ?? \gethostname());
$res = @\pg_query_params(
$c,
"SELECT to_char(zeitpunkt, 'DD.MM.YYYY HH24:MI:SS') AS zeit, action, status, http_status, detail, reference
FROM payment_log
WHERE order_id = $1 AND host = $2
ORDER BY id
LIMIT $3",
[$orderId, $host, $limit]
);
if (!$res) {
return [];
}
return \pg_fetch_all($res) ?: [];
}
/** @return resource|\PgSql\Connection|false */
private static function conn()
{
+121
View File
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace Payments;
/**
* Lesesicht auf einen Zahlungsvorgang fuers Backend (Bestell-Editor, Block "Zahlungsvorgang").
*
* Anbieterneutral: liest payment_transactions (MySQL) und den Verlauf aus payment_log (PG).
* Kein Provider-Aufruf, keine API -- nur Anzeige. Faellt still auf null zurueck, wenn die
* Bestellung keinen Vorgang hat (alte Zahlarten, Vorkasse, Rechnung ...).
*/
final class Overview
{
private const STATUS = [
'open' => 'offen -- Kunde ist beim Anbieter',
'pending' => 'in Pruefung beim Anbieter',
'paid' => 'bezahlt',
'cancelled' => 'abgebrochen / storniert',
'mismatch' => 'BETRAG WEICHT AB -- pruefen!',
];
private const SOURCE = [
'return' => 'Rueckkehr des Kunden vom Anbieter',
'express' => 'Express-Kauf aus dem Warenkorb',
'webhook' => 'Meldung des Anbieters (Webhook)',
'reconcile' => 'Abgleicher (Cron)',
];
private const ACTION = [
'create' => 'Vorgang beim Anbieter angelegt',
'start' => 'Kunde zum Anbieter weitergeleitet',
'capture' => 'Einzug (Capture)',
'webhook' => 'Meldung vom Anbieter',
'mail' => 'Bestellbestaetigung an Kunden',
'cancel' => 'Storno',
'mismatch' => 'Betragsabweichung',
'express_create' => 'Express-Kauf gestartet',
'express_get' => 'Express: Freigabe abgeholt',
'express_patch' => 'Express: Betrag angepasst',
'express_reapprove' => 'Express: zweite Freigabe noetig',
];
/**
* @return array<string,mixed>|null Felder fuers Template oder null ohne Vorgang
*/
public static function forOrder(\mysqli $db, int $orderId, ?object $base = null): ?array
{
if ($orderId <= 0) {
return null;
}
$stmt = $db->prepare('SELECT * FROM payment_transactions WHERE order_id=? ORDER BY id DESC LIMIT 1');
if (!$stmt) {
return null;
}
$stmt->bind_param('i', $orderId);
$stmt->execute();
$tx = $stmt->get_result()->fetch_assoc();
$stmt->close();
if (!$tx) {
return null;
}
$module = (string) $tx['provider'];
$label = $module;
if ($base !== null && Registry::has($module)) {
$p = Registry::get($module, $base);
if ($p) {
$label = $p->label();
}
}
$status = (string) $tx['status'];
$detail = (string) ($tx['detail'] ?? '');
if ($status === 'paid') {
// markPaid() legt in detail nur die Quelle ab (return/express/webhook/reconcile).
$detail = 'bezahlt, bestaetigt ueber: ' . (self::SOURCE[$detail] ?? $detail);
}
return [
'provider' => $module,
'provider_label' => $label,
'status' => $status,
'status_label' => self::STATUS[$status] ?? $status,
'reference' => (string) $tx['reference'],
'capture_id' => (string) ($tx['capture_id'] ?? ''),
'expected_amount' => \number_format((float) $tx['expected_amount'], 2, ',', '.') . ' ' . $tx['currency'],
'paid_amount' => $tx['paid_amount'] !== null ? \number_format((float) $tx['paid_amount'], 2, ',', '.') . ' ' . $tx['currency'] : '',
'created_at' => self::de((string) $tx['created_at']),
'paid_at' => self::de((string) ($tx['paid_at'] ?? '')),
'updated_at' => self::de((string) $tx['updated_at']),
'detail' => $detail,
'history' => self::history($orderId),
];
}
/** @return list<array{zeit:string,aktion:string,status:string,http:string,detail:string}> */
private static function history(int $orderId): array
{
$out = [];
foreach (Log::forOrder($orderId, 40) as $row) {
$a = (string) $row['action'];
$out[] = [
'zeit' => (string) $row['zeit'],
'aktion' => self::ACTION[$a] ?? $a,
'status' => (string) ($row['status'] ?? ''),
'http' => $row['http_status'] !== null && $row['http_status'] !== '' ? 'HTTP ' . $row['http_status'] : '',
'detail' => (string) ($row['detail'] ?? ''),
];
}
return $out;
}
private static function de(string $dt): string
{
if ($dt === '' || \str_starts_with($dt, '0000')) {
return '';
}
$t = \strtotime($dt);
return $t ? \date('d.m.Y H:i:s', $t) : $dt;
}
}
+1 -1
View File
@@ -60,7 +60,7 @@ final class Registry
public static function requireCore(): void
{
foreach (['ProviderInterface', 'Result', 'Secrets', 'Log', 'AbstractProvider'] as $c) {
foreach (['ProviderInterface', 'Result', 'Secrets', 'Log', 'Overview', 'AbstractProvider'] as $c) {
require_once __DIR__ . '/' . $c . '.php';
}
}
+19
View File
@@ -30,3 +30,22 @@ require_once __DIR__ . '/Registry.php';
$base_object = new base();
Registry::set('base', $base_object);
// 27.09.2026 -- Layout-Objekt (Smarty) auch hier: die Bestellbestaetigung rendert die
// Positionen ueber DeliveryItemsHTML, das Registry::get('layout_object') holt. Ohne das
// scheiterte die Mail, wenn der WEBHOOK (nicht die Rueckkehr) die Zahlung abschloss
// ("Call to a member function assign() on null", Mollie-Test 290616). Der Kunde
// bekam dann keine Bestaetigung. start() braucht SERVER_NAME (CLI: aus MAIN_URL).
if (empty($_SERVER['SERVER_NAME']) && \defined('MAIN_URL')) {
$_SERVER['SERVER_NAME'] = (string) \parse_url((string) MAIN_URL, PHP_URL_HOST);
}
$_SERVER['REQUEST_URI'] = $_SERVER['REQUEST_URI'] ?? '/'; // layout.class.php liest sie beim Start
try {
require_once $docroot . '/libs/smarty/libs/Smarty.class.php';
require_once $docroot . '/core/layout.class.php';
$layout_object = new layout();
$layout_object->start($base_object);
Registry::set('layout_object', $layout_object);
} catch (\Throwable $e) {
\error_log('payments bootstrap: Layout nicht initialisiert -- ' . $e->getMessage());
}
+8 -4
View File
@@ -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";
}
+6
View File
@@ -47,6 +47,12 @@ INSERT IGNORE INTO `configuration` (`name`, `text`, `group_id`, `setting`, `type
('paypal_v2_express', '', 'paypal_v2', '1', 1, '1 = Button "Direkt mit PayPal bezahlen" im Warenkorb, 0 = aus'),
('paypal_v2_express_countries', '', 'paypal_v2', 'DE', 1, 'Laender (ISO-2, kommagetrennt), deren PayPal-Adresse im Express-Kauf angenommen wird');
-- 2a2) Stornogrund fuer automatische Stornos (Kunde hat beim Anbieter nicht bezahlt).
-- type 0 = Kunden Storno. markCancelled() sucht ihn ueber den Namen.
INSERT INTO `cancellation_reasons` (`name`, `type`, `active`)
SELECT 'Zahlung beim Anbieter nicht abgeschlossen', 0, 1
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `cancellation_reasons` WHERE `name` = 'Zahlung beim Anbieter nicht abgeschlossen');
-- 2b) Zahlart anlegen. active=1, aber noch KEINER Kundengruppe zugeordnet => im Shop unsichtbar.
-- helperjoinkey 'pp' = derselbe PayPal-Aufschlag wie die alte Zahlart (shipping_area.add_pp).
INSERT INTO `payment_methods`
+108
View File
@@ -0,0 +1,108 @@
-- ============================================================================
-- 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 2,
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 2,
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 2,
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 2,
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,
ADD COLUMN IF NOT EXISTS `active_mollie_paybybank` TINYINT(2) NOT NULL DEFAULT 2,
ADD COLUMN IF NOT EXISTS `add_mollie_paybybank` DECIMAL(9,2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `account_type_mollie_paybybank` TINYINT(4) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `free_payment_mollie_paybybank` 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');
-- Google Pay: Mollie lehnt method=googlepay im Redirect-Verfahren ab (HTTP 500, 27.09.2026) --
-- gibt es nur als Direkt-Integration mit Token. Zeile bleibt angelegt, aber inaktiv.
UPDATE `payment_methods` SET active=0 WHERE module='mollie' AND argument='googlepay';
-- "Online-Banking" = Mollie Pay by Bank (Open Banking, Nachfolger von Sofortueberweisung).
-- NICHT "Sofortueberweisung" nennen: eingetragene Marke der Sofort GmbH (Klarna).
-- Status nach Freigabe erst pending, paid kommt per Webhook (Echtzeit: Minuten, sonst 2-3 Werktage).
INSERT INTO `payment_methods`
(`name`, `module`, `argument`, `active`, `rang`, `invoice_text`, `order_text`, `order_email_text`, `helperjoinkey`, `configuration_group_id`)
SELECT 'Online-Banking', 'mollie', 'paybybank', 1, 7,
'Bezahlung erfolgte per Online-Banking (Pay by Bank)',
'Sie zahlen direkt von Ihrem Bankkonto: Bank waehlen, im Online-Banking freigeben, fertig.',
'Der zu zahlende Betrag wurde per Online-Banking ueberwiesen.',
'mollie_paybybank', 'mollie'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `payment_methods` WHERE `module`='mollie' AND `argument`='paybybank');
-- 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]];
}
}
+609
View File
@@ -0,0 +1,609 @@
<?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_...)',
'webhook_secret' => 'Webhook-Signing-Secret (nur fuer Next-Gen-Webhooks aus Dashboard > Entwickler > Webhooks; leer = klassische Webhooks)',
];
}
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
/**
* Zwei Webhook-Formen, ein Endpunkt (27.09.2026, docs.mollie.com/reference/webhooks-new):
*
* klassisch application/x-www-form-urlencoded, genau ein Feld id=tr_... , unsigniert.
* Haengt an jeder Zahlung (webhookUrl). Mollie empfiehlt diese Form fuer
* Zahlungsstatus weiterhin.
* next-gen JSON {resource:"event", type:"payment.paid", entityId:"tr_..."}, signiert
* mit HMAC-SHA256 ueber den rohen Body im Header X-Mollie-Signature
* ("sha256=<hex>"), Schluessel = Signing-Secret der Subscription (Dashboard >
* Entwickler > Webhooks). Ist ein Secret hinterlegt, MUSS die Signatur passen;
* ohne Secret werden JSON-Events abgelehnt (kein blindes Vertrauen).
*
* In beiden Faellen ist die Wahrheit der GET auf die Zahlung -- der Webhook ist nur der
* Anstoss. Antwort 200 = angenommen, alles andere wiederholt Mollie bis zu 10x in 26 h.
*/
public function onWebhook(array $headers, string $rawBody): int
{
$id = '';
$body = \ltrim($rawBody);
if ($body !== '' && $body[0] === '{') {
$event = \json_decode($body, true);
if (!\is_array($event) || ($event['resource'] ?? '') !== 'event') {
Log::write($this->module(), 'webhook', null, null, null, 'rejected', null, ['body' => \mb_substr($rawBody, 0, 500)], 'JSON ohne resource=event');
return 400;
}
$check = $this->verifySignature($headers, $rawBody);
if ($check !== 'ok') {
Log::write($this->module(), 'webhook', null, null, (string) ($event['entityId'] ?? ''), 'rejected', null,
['type' => $event['type'] ?? '', 'header' => $headers['x-mollie-signature'] ?? ''], 'Signatur: ' . $check);
return 400;
}
$type = (string) ($event['type'] ?? '');
$id = (string) ($event['entityId'] ?? '');
if (!\str_starts_with($type, 'payment.')) {
// andere Ereignisse (Auszahlungen, Rechnungen ...) nur protokollieren
Log::write($this->module(), 'webhook', null, null, $id, 'ignored', null, ['type' => $type], 'kein Zahlungsereignis');
return 200;
}
} else {
$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;
}
/**
* HMAC-SHA256 ueber den unveraenderten Body mit dem Signing-Secret. Bei Secret-Rotation
* schickt Mollie 24 h lang zwei Signaturen (kommagetrennt) -- eine muss passen.
* @param array<string,string> $headers
*/
private function verifySignature(array $headers, string $rawBody): string
{
$secret = \Payments\Secrets::get($this->db, $this->module(), 'webhook_secret') ?? '';
if ($secret === '') {
return 'kein webhook_secret hinterlegt';
}
$header = (string) ($headers['x-mollie-signature'] ?? '');
if ($header === '') {
return 'Header X-Mollie-Signature fehlt';
}
$expected = \hash_hmac('sha256', $rawBody, $secret);
foreach (\explode(',', $header) as $sig) {
$sig = \strtolower(\trim($sig));
if (\str_starts_with($sig, 'sha256=')) {
$sig = \substr($sig, 7);
}
if ($sig !== '' && \hash_equals($expected, $sig)) {
return 'ok';
}
}
return 'Signatur passt nicht';
}
// -------------------------------------------------------------- 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;
}
}
@@ -540,7 +540,10 @@ final class Provider extends AbstractProvider
if ($status === 404) {
$out[] = "$ref (Order {$tx->order_id}): bei PayPal unbekannt -> storniert" . ($dryRun ? ' [dry-run]' : '');
if (!$dryRun) {
$this->markCancelled($ref, 'PayPal kennt den Vorgang nicht (404)', 'reconcile');
// Beobachtet auf Live 19.-22.09.2026: nie freigegebene Orders (CREATED) liefern
// nach ~6 h 404 -- der Kunde ist bei PayPal nicht bis zur Freigabe gekommen.
$age = \round((\time() - \strtotime((string) $tx->created_at)) / 3600, 1);
$this->markCancelled($ref, "Kunde hat den Vorgang bei PayPal nicht abgeschlossen (nach $age h bei PayPal unbekannt, 404)", 'reconcile');
}
continue;
}
+4 -2
View File
@@ -155,10 +155,12 @@ class website {
function main_widget_menu_2() {
$structure_object = new Structure($this->base_object);
$selected_ids = array();
if (isset($_GET['menu_id'])) {
// 22.09.2026: !empty statt isset -- das Kundencenter postet auf "...&parent_id=" (leer),
// isset war dann wahr und get_all_tree() baute "parent_id in(10636,)" -> SQL-Fatal.
if (!empty($_GET['menu_id'])) {
$selected_ids[] = $_GET['menu_id'];
}
if (isset($_GET['parent_id'])) {
if (!empty($_GET['parent_id'])) {
$selected_ids[] = $_GET['parent_id'];
}
$data = $structure_object->get_all_tree(5, $selected_ids, $this->base_object->customer_group->id);
+5
View File
@@ -152,6 +152,11 @@ class admin_order_editor {
$this->layout_object->assign('order', $order_data);
$this->layout_object->assign('order_items', $order_items);
$this->layout_object->assign('footer', $order_footer);
// 22.09.2026 -- Zahlungsvorgang aus core/payments (PayPal v2, Mollie ...):
// Status, Referenz, Transaktions-ID, Grund und Verlauf. Null bei alten Zahlarten.
include_once './core/payments/Registry.php';
$this->layout_object->assign('payment_tx', \Payments\Overview::forOrder($this->base_object->db, (int)$id, $this->base_object));
} catch (Exception $e) {
$this->layout_object->assign('error_message', $e->getMessage());
}
+51 -6
View File
@@ -67,6 +67,10 @@ class website_cashdesk {
if (isset($_GET['order'])) {
$this->layout_object->assign('order_number', $_GET['order']);
}
// 27.09.2026: Erfolgsseite ohne Navigation, wie Warenkorb und Kasse der neuen Ansicht
if (!$this->alte_ansicht()) {
$this->layout_object->assign('site_type', 'full_width');
}
// Success-Template anzeigen - auch bei Reload!
return $this->layout_object->_fetch('content_cash_desk/completed.tpl');
}
@@ -157,6 +161,19 @@ class website_cashdesk {
if (count($this->errors) == 0) {
if (isset($_GET['guest_order']) && $_GET['guest_order'] == 1) {
$this->setup_customer_data();
// 27.09.2026 -- Express ohne Anrede: die Anrede ist im Express-Kauf keine Pflicht,
// CustomerAddress::validate() verlangt sie aber. Leere Anrede liess die Adress-Anlage
// still scheitern, die Bestellung wurde OHNE Adresse gespeichert (312153, 312217).
// Leer => Firma (2) bei Firmenname, sonst Herr (0) -- wie customers.honorific es
// ohnehin speichert. Neutrale Anrede bleibt eigenes Thema.
foreach (array('billing', 'shipping') as $blk) {
if (isset($_POST[$blk]) && is_array($_POST[$blk])
&& (!isset($_POST[$blk]['honorific']) || (string)$_POST[$blk]['honorific'] === '')) {
$_POST[$blk]['honorific'] = !empty($_POST[$blk]['company']) ? 2 : 0;
}
}
// check customer already exists by email
$customer_id = 0;//$this->base_object->customer->check_email($_POST['billing']['email']);
@@ -194,6 +211,11 @@ class website_cashdesk {
'customer_id' => $customer_id
);
$res = $customer_address_object->create($billing_address);
if (!$res) {
// bisher stumm -> Bestellung ohne Adresse; jetzt wenigstens sichtbar im PHP-Log
error_log('Checkout: Gast-Rechnungsadresse nicht angelegt (Kunde '.$customer_id.'): '
. json_encode($customer_address_object->get_validation_errors()));
}
$this->billing_address['address'] = $res;
$shipping_address = array (
'honorific' => $_POST['shipping']['honorific'],
@@ -209,6 +231,10 @@ class website_cashdesk {
);
$res = $customer_address_object->create($shipping_address);
if (!$res) {
error_log('Checkout: Gast-Lieferadresse nicht angelegt (Kunde '.$customer_id.'): '
. json_encode($customer_address_object->get_validation_errors()));
}
$this->shipping_address['address'] = $res;
}
@@ -362,12 +388,13 @@ class website_cashdesk {
// "Bestellen" als denselben Vorgang erkennbar (siehe save_order()).
$this->layout_object->assign('checkout_nonce', $this->checkout_nonce_holen());
// 18.09.2026 -- Entwurf der neuen Kassenansicht zum Anschauen: /Kasse/?ansicht=neu
if (isset($_GET['ansicht']) && $_GET['ansicht'] === 'neu') {
$this->layout_object->assign('site_type', 'full_width'); // ohne Navigation, mehr Breite (Tom, 18.09.)
return $this->layout_object->_fetch('content_cash_desk_neu.tpl');
// 27.09.2026 -- die neue Kasse (content_cash_desk_neu.tpl) ist Standard.
// Rueckfall auf das alte Template: /Kasse/?ansicht=alt (siehe alte_ansicht()).
if ($this->alte_ansicht()) {
return $this->layout_object->_fetch('content_cash_desk.tpl');
}
return $this->layout_object->_fetch('content_cash_desk.tpl');
$this->layout_object->assign('site_type', 'full_width'); // ohne Navigation, mehr Breite (Tom, 18.09.)
return $this->layout_object->_fetch('content_cash_desk_neu.tpl');
} else if (isset($_GET['token']) && $_GET['token']) {
return $this->change_order('paypal');
@@ -382,6 +409,18 @@ class website_cashdesk {
// end run
/**
* Rueckfall auf die alte Kassen-/Warenkorbansicht (27.09.2026, Go-Live der neuen Ansicht):
* ?ansicht=alt schaltet fuer die Sitzung auf die alten Templates, ?ansicht=neu hebt das auf.
* Dieselbe Weiche steht in website_shoppingcart.php.
*/
private function alte_ansicht() {
if (isset($_GET['ansicht'])) {
$_SESSION['easyshop']['ansicht_alt'] = ($_GET['ansicht'] === 'alt') ? 1 : 0;
}
return !empty($_SESSION['easyshop']['ansicht_alt']);
}
// ------------------------------------------------------------ Express
/** Start aus dem Warenkorb: /Kasse/?payment_express_start=<modul> */
@@ -694,6 +733,10 @@ class website_cashdesk {
if (isset($_POST['shipping']) && $_POST['shipping']['address'] == -1) {
$this->shipping_address = $_POST['billing'];
if (empty($this->shipping_address['country'])) {
// 22.09.2026: Land nicht gewaehlt -> Standardland fuer die Versandgebiet-Suche, sonst SQL-Fatal
$this->shipping_address['country'] = $this->customer_data->default_address_country_id;
}
$_POST['shipping'] = $_POST['billing'];
} else if (isset($_POST['shipping'])) {
if (!$this->shipping_address['country']) {
@@ -882,8 +925,10 @@ class website_cashdesk {
$this->has_gift_certificate = false;
}
// 27.09.2026: Korbsumme 0 (Artikel ohne Preis) teilte hier durch null -> HTTP 500 in der Kasse
$sum_gross = (float)$this->shoppingcart_data['price_sum_gross'];
foreach ($this->shoppingcart_data['tax_values'] as $key => $value) {
$this->shoppingcart_data['tax_values'][$key]['gift']['percent'] = $percent = round(($value['gross'] / $this->shoppingcart_data['price_sum_gross'] * 100), 2);
$this->shoppingcart_data['tax_values'][$key]['gift']['percent'] = $percent = $sum_gross > 0 ? round(($value['gross'] / $sum_gross * 100), 2) : 0;
$this->shoppingcart_data['tax_values'][$key]['gift']['gross'] = $new_gross = $this->shoppingcart_data['price_sum_gross'] / 100 * $percent;
$this->shoppingcart_data['tax_values'][$key]['gift']['net'] = $new_net = $new_gross / (100 + $key) * 100;
$this->shoppingcart_data['tax_values'][$key]['gift']['vat'] = $new_gross - $new_net;
+8 -1
View File
@@ -250,6 +250,8 @@ class website_customercenter {
}
header('location: ' . $_SERVER["HTTP_REFERER"]);
// 22.09.2026: nach dem Redirect nicht weiterrendern (lief sonst in die Widgets und den SQL-Fatal).
exit();
}
private function edit_base_data($customer_id) {
@@ -332,7 +334,12 @@ class website_customercenter {
$base_url .= ':' . $_SERVER['SERVER_PORT'];
}
return $base_url . '/index.php?menu_id=' . $_GET['menu_id'] . '&parent_id=' . $_GET['parent_id'];
// 22.09.2026: leere Parameter nicht anhaengen (Seite kommt per short_uri /Kundencenter/ ohne parent_id).
$url = $base_url . '/index.php?menu_id=' . (int) ($_GET['menu_id'] ?? 0);
if (!empty($_GET['parent_id'])) {
$url .= '&parent_id=' . (int) $_GET['parent_id'];
}
return $url;
}
private function get_invoice_pdf() {
+48
View File
@@ -392,6 +392,12 @@ class website_dyncontent {
$var_data = array();
$var_data['[#HIDDEN_TEXT_START]'] = '<span class="text_box_hidden_text">';
$var_data['[#HIDDEN_TEXT_END]'] = ' </span> <a class="text_box_link_show_text" href="#">mehr&gt;&gt;</a><a class="text_box_link_hide_text" href="#">&lt;&lt;weniger</a>';
// 27.09.2026 -- [#zahlarten]: Liste der aktiven Zahlarten der Kundengruppe (Tom: die
// CMS-Seite "Bezahlarten" soll nicht mehr von Hand gepflegt werden, sondern dem
// Aktiv-Schalter im Backend folgen).
if (strpos($block->content, '[#zahlarten]') !== false) {
$var_data['[#zahlarten]'] = $this->zahlarten_liste();
}
$block->content = strtr($block->content, $var_data);
@@ -401,6 +407,48 @@ class website_dyncontent {
return $this->layout_object->_fetch('content_box_textbox.tpl');
}
/**
* Zahlarten, die der Besucher in der Kasse bekaeme -- dieselbe Quelle wie die Kasse
* (Payment_method::getMethodsForGroup: payment_methods.active, shipping_area.active_<key>,
* Kundengruppen-Freischaltung). Besucher (Gruppe 100) sehen die Gast-Zahlarten, weil sie
* als Gast bestellen; Haendler ihr B2B-Versandgebiet. Bankeinzug/Rechnung nur, wenn der
* angemeldete Kunde dafuer freigeschaltet ist (wie in content_cash_desk_neu.tpl).
*/
private function zahlarten_liste() {
try {
include_once './core/payment_method.class.php';
$group = (int)$this->base_object->customer_group->id;
if ($group <= 0 || $group == 100) {
$group = 103; // Gast
}
$area = ($group == 101) ? 30 : 1; // Deutschland-B2B / Deutschland-B2C
$pm_object = new Payment_method($this->base_object);
$all = $pm_object->getMethodsForGroup($group, -1, $area);
$customer = $this->base_object->customer;
$list = array();
foreach ((array)$all as $pm) {
if (!isset($pm->activeType) || $pm->activeType <= 0) {
continue;
}
if ($pm->helperjoinkey == 'be' && empty($customer->direct_debit_active)) {
continue;
}
if ($pm->helperjoinkey == 'r' && empty($customer->rechnung_active)) {
continue;
}
$list[] = $pm;
}
$this->layout_object->assign('zahlarten', $list);
$this->layout_object->assign('zahlarten_gruppe', $group);
// Platzhalter im Bestelltext (z.B. "Kundenspezifisch" bei Haendlern), wie content_cash_desk_neu.tpl
$this->layout_object->assign('zahlarten_freitext3', isset($customer->freetextfield3) ? (string)$customer->freetextfield3 : '');
return $this->layout_object->_fetch('content_box_zahlarten.tpl');
} catch (Throwable $e) {
error_log('zahlarten_liste: '.$e->getMessage());
return '';
}
}
private function banner($structure_id, $block) {
$config = array();
eval($block->configuration);
+28 -7
View File
@@ -59,6 +59,12 @@ class website_shoppingcart {
header('Location: /Kasse/');
exit;
}
// 27.09.2026: "Weiter einkaufen" geht ueber das Formular (Mengen werden mitgespeichert),
// festes Ziel Startseite -- kein offener Redirect.
if ($goto === 'weiter') {
header('Location: /');
exit;
}
if (preg_match('/^express:([a-z0-9_]{1,40})$/', $goto, $m)) {
header('Location: /Kasse/?payment_express_start='.$m[1]);
exit;
@@ -66,8 +72,8 @@ class website_shoppingcart {
}
// Post-Redirect-Get fuer die neue Ansicht: nach Mengen-/Anmerkungsaenderung per GET neu
// laden, sonst fragt ein Reload "Formular erneut senden" und schickt die Mengen doppelt.
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['cart_goto']) && isset($_GET['ansicht']) && $_GET['ansicht'] === 'neu') {
header('Location: /Warenkorb/?ansicht=neu');
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['cart_goto']) && !$this->alte_ansicht()) {
header('Location: /Warenkorb/');
exit;
}
@@ -84,15 +90,30 @@ class website_shoppingcart {
// Angezeigt, wenn der Anbieter Express kann und seine Zahlart aktiv ist.
$this->layout_object->assign('express_providers', $this->express_providers());
// 18.09.2026 -- Entwurf der neuen Warenkorb-Ansicht zum Anschauen: /Warenkorb/?ansicht=neu
if (isset($_GET['ansicht']) && $_GET['ansicht'] === 'neu') {
$this->layout_object->assign('site_type', 'full_width'); // ohne Navigation, mehr Breite (Tom, 18.09.)
return $this->layout_object->_fetch('content_shoppingcart_neu.tpl');
// 27.09.2026 -- die neue Ansicht (content_shoppingcart_neu.tpl) ist Standard.
// Rueckfall auf das alte Template: /Warenkorb/?ansicht=alt (siehe alte_ansicht()).
if ($this->alte_ansicht()) {
return $this->layout_object->_fetch('content_shoppingcart.tpl');
}
return $this->layout_object->_fetch('content_shoppingcart.tpl');
$this->layout_object->assign('site_type', 'full_width'); // ohne Navigation, mehr Breite (Tom, 18.09.)
return $this->layout_object->_fetch('content_shoppingcart_neu.tpl');
} // end show_shoppingcart
/**
* Rueckfall auf die alte Warenkorb-/Kassenansicht (27.09.2026, Go-Live der neuen Ansicht):
* ?ansicht=alt schaltet fuer die Sitzung auf die alten Templates, ?ansicht=neu hebt das auf.
* Haelt ueber die Formular-POSTs hinweg, weil die alten Templates keinen Parameter mitschicken.
* Dieselbe Weiche steht in website_cashdesk.php.
*/
private function alte_ansicht() {
if (isset($_GET['ansicht'])) {
$_SESSION['easyshop']['ansicht_alt'] = ($_GET['ansicht'] === 'alt') ? 1 : 0;
}
return !empty($_SESSION['easyshop']['ansicht_alt']);
}
/** @return array<int,array{module:string,label:string}> */
private function express_providers() {
$out = array();
@@ -483,6 +483,56 @@
<input style="width:300px;" type="text" readonly="readonly" value="{$order->paymorrow_response_result_code}" />
{/if}
</div>
{if $payment_tx}
{* 22.09.2026 -- Zahlungsvorgang aus core/payments (anbieterneutral: PayPal v2, spaeter Mollie) *}
<div class="info_block" id="payment_tx_block">
<div class="edit_title">Zahlungsvorgang {$payment_tx.provider_label|escape}</div>
<label>Status</label>
<input style="width:300px;{if $payment_tx.status == 'paid'}color:green;{elseif $payment_tx.status == 'mismatch'}color:red;font-weight:bold;{elseif $payment_tx.status == 'cancelled'}color:#990000;{/if}" type="text" readonly="readonly" value="{$payment_tx.status_label|escape}" />
{if $payment_tx.detail}
<label>Grund / Quelle</label>
<textarea style="width:300px;" rows="3" readonly="readonly">{$payment_tx.detail|escape}</textarea>
{/if}
<label>Transaktions-ID (Anbieter)</label>
<input style="width:300px;" type="text" readonly="readonly" value="{$payment_tx.capture_id|escape}" onclick="this.select();" />
<label>Vorgangs-Referenz (Anbieter)</label>
<input style="width:300px;" type="text" readonly="readonly" value="{$payment_tx.reference|escape}" onclick="this.select();" />
<label>Sollbetrag / gezahlt</label>
<input style="width:300px;" type="text" readonly="readonly" value="{$payment_tx.expected_amount|escape}{if $payment_tx.paid_amount} / {$payment_tx.paid_amount|escape}{/if}" />
<label>Gestartet</label>
<input style="width:300px;" type="text" readonly="readonly" value="{$payment_tx.created_at|escape}" />
{if $payment_tx.paid_at}
<label>Bezahlt am</label>
<input style="width:300px;" type="text" readonly="readonly" value="{$payment_tx.paid_at|escape}" />
{else}
<label>Letzte &Auml;nderung</label>
<input style="width:300px;" type="text" readonly="readonly" value="{$payment_tx.updated_at|escape}" />
{/if}
{if $payment_tx.history}
<label>Verlauf</label>
<div style="width:300px;max-height:220px;overflow-y:auto;background:#fff;border:1px solid #ddd;font-size:11px;line-height:1.35;">
{foreach $payment_tx.history as $h}
<div style="margin:0;padding:3px 5px;border-bottom:1px solid #eee;">
<span style="color:#666;margin:0;">{$h.zeit|escape}</span><br />
<span style="margin:0;">{$h.aktion|escape}</span>
{if $h.status}<span style="color:#003580;margin:0;"> &middot; {$h.status|escape}</span>{/if}
{if $h.http}<span style="color:#999;margin:0;"> &middot; {$h.http|escape}</span>{/if}
{if $h.detail}<br /><span style="color:#990000;margin:0;">{$h.detail|escape}</span>{/if}
</div>
{/foreach}
</div>
{/if}
</div>
{/if}
<div class="info_block">
<div class="edit_title">Rechnung</div>
@@ -52,8 +52,12 @@ if (strlen($obj->number) >= 8 || !empty($obj->attribute_7)) {
$ersatzteil_ids = array();
// Query 1A: Legacy Items (über attribute_7)
if (!empty($obj->attribute_7)) {
$sql1a = "SELECT id FROM krempl_passendwie WHERE navisionid = ".$this->db->real_escape_string($obj->attribute_7);
// 27.09.2026: navisionid ist bigint -- nur eine echte Ziffernfolge in die SQL. Artikel 832247
// hatte ein einzelnes Leerzeichen in attribute_7 (truthy, aber "navisionid = " => SQL-Fehler,
// Artikelseite HTTP 500, 14x im fatal.log seit 22.09.).
$attr7 = trim((string)$obj->attribute_7);
if ($attr7 !== '' && ctype_digit($attr7)) {
$sql1a = "SELECT id FROM krempl_passendwie WHERE navisionid = ".$attr7;
$result1a = $this->db->query($sql1a);
if ($result1a) {
while ($row = $result1a->fetch_assoc()) {
@@ -229,11 +233,32 @@ $int = $this->createIntWithTableData($html);
// base price calc
if ($obj->attribute_11 && $obj->attribute_15) {
$obj->sample_unit = $obj->attribute_15;
if (strstr($obj->sample_unit, '100')) {
$obj->sample_price = $obj->shop_price_1 / ($obj->attribute_11 * 10);
//
// 25.08.2026 gehaertet. Vorher wurde nur auf "truthy" geprueft -- und zwei Sorten
// gepflegter Werte sind truthy, ohne rechenbar zu sein:
//
// " " (Artikel 831966, beide Felder ein Leerzeichen)
// PHP 8: float / " " => TypeError => Artikelseite mit HTTP 500 tot.
// Unter PHP 7 lief das als Warnung durch -- ein Migrationsueberbleibsel.
//
// "2,5" (24 aktive Artikel, Komma statt Punkt)
// PHP 8 nimmt den fuehrenden Teil und rechnet mit 2 statt 2,5. Kein Fehler,
// aber ein um 25 % zu hoher Grundpreis -- und wegen error_reporting(0) in
// index.php sieht das niemand.
//
// Deshalb: Komma normalisieren, numerisch pruefen, und erst dann rechnen. Ist der
// Teiler unbrauchbar, bleibt sample_price schlicht ungesetzt -- besser kein Grundpreis
// als ein falscher (Preisangabenverordnung).
$grundpreis_teiler = str_replace(',', '.', trim((string)$obj->attribute_11));
$grundpreis_einheit = trim((string)$obj->attribute_15);
if (is_numeric($grundpreis_teiler) && (float)$grundpreis_teiler > 0 && $grundpreis_einheit !== '') {
$grundpreis_teiler = (float)$grundpreis_teiler;
$obj->sample_unit = $obj->attribute_15;
if (strstr($grundpreis_einheit, '100')) {
$obj->sample_price = $obj->shop_price_1 / ($grundpreis_teiler * 10);
} else {
$obj->sample_price = $obj->shop_price_1 / $obj->attribute_11;
$obj->sample_price = $obj->shop_price_1 / $grundpreis_teiler;
}
}
@@ -0,0 +1,63 @@
{* =====================================================================================
content_box_zahlarten.tpl -- Liste der aktiven Zahlarten fuer die Kundengruppe des
Besuchers. Wird ueber den Platzhalter [#zahlarten] in einer CMS-Textbox eingefuegt
(website_dyncontent.php::zahlarten_liste, 27.09.2026). Quelle: payment_methods +
customer_group_payment_method_configuration + shipping_area (dieselbe Logik wie die
Kasse) -- was im Backend aktiv ist, steht hier; nichts mehr von Hand pflegen.
Variablen: zahlarten (Liste), zahlarten_gruppe (Gruppen-id), zahlarten_mwst (show_tax)
===================================================================================== *}
<div class="zl">
{if $zahlarten}
{foreach from=$zahlarten item=pm}
<div class="zl-row zl-{$pm->module|default:'std'}{if $pm->module == 'mollie'} zl-{$pm->argument|escape}{/if}">
<div class="zl-logo">
{if $pm->module == 'mollie' && $pm->argument}
<img src="https://www.mollie.com/external/icons/payment-methods/{$pm->argument|escape:'url'}.svg" alt="{$pm->name|escape}" />
{elseif $pm->module == 'paypal_v2' || $pm->module == 'paypal'}
<img src="https://www.paypalobjects.com/webstatic/de_DE/i/de-pp-logo-100px.png" alt="PayPal" />
{elseif $pm->module == 'prepayment'}
<span class="zl-icon">&#8644;</span>
{elseif $pm->module == 'collect_on_delivery'}
<span class="zl-icon">&#128230;</span>
{elseif $pm->module == 'direct_debit'}
<span class="zl-icon">&#8645;</span>
{else}
<span class="zl-icon">&#8226;</span>
{/if}
</div>
<div class="zl-body">
<div class="zl-name">{$pm->name|escape}
{if $pm->module == 'mollie' && $pm->argument == 'applepay'}<span class="zl-muted">(auf Apple-Ger&auml;ten)</span>{/if}
{if $pm->module == 'mollie' && $pm->argument == 'creditcard'}<span class="zl-muted">Visa, Mastercard, American Express</span>{/if}
</div>
{* [#customer_freetextfield3] wie in der Kasse: individueller Text je Kunde (Haendler "Kundenspezifisch") *}
{if $pm->order_text}<div class="zl-text">{$pm->order_text|replace:'[#customer_freetextfield3]':$zahlarten_freitext3}</div>{/if}
{if isset($pm->paymentAddShown) && $pm->paymentAddShown != 0}
<div class="zl-fee">
{if $pm->accountType == '1' || $pm->accountType == '3'}Abzug{else}Aufschlag{/if}:
{if $pm->accountType == '2' || $pm->accountType == '3'}{$pm->rawAdd|number_format:"2":",":"."} %{else}{$pm->paymentAddShown|abs|number_format:"2":",":"."} &euro;{/if}
{if $pm->freePayment > 0} (entf&auml;llt ab {$pm->freePayment|number_format:"2":",":"."} &euro; Warenwert){/if}
</div>
{/if}
</div>
</div>
{/foreach}
{else}
<p>Derzeit sind keine Zahlarten hinterlegt.</p>
{/if}
</div>
{literal}
<style>
.zl { margin: 10px 0 18px; }
.zl *, .zl *:before, .zl *:after { box-sizing: border-box; }
.zl-row { display: flex; gap: 14px; align-items: flex-start; padding: 12px 14px; margin-bottom: 8px; background: #fff; border: 1px solid #e3e3e3; border-radius: 8px; }
.zl-logo { flex: 0 0 56px; display: flex; align-items: center; justify-content: center; height: 32px; }
.zl-logo img { max-width: 56px; max-height: 32px; width: auto; height: auto; }
.zl-icon { font-size: 22px; color: #FF6700; line-height: 32px; }
.zl-body { flex: 1 1 auto; min-width: 0; }
.zl-name { font-weight: 700; font-size: 15px; }
.zl-muted { font-weight: 400; color: #777; font-size: 13px; margin-left: 6px; }
.zl-text { color: #444; font-size: 13px; margin-top: 3px; }
.zl-fee { color: #a44a00; font-size: 12px; margin-top: 4px; }
</style>
{/literal}
@@ -1,21 +1,59 @@
<!-- content_cash_desk/completed.tpl -->
<div class="full_box">
<div class="title_wrapper">
<div class="title"><h2>Bestellung erfolgreich &uuml;bermittelt</h2></div>
<div class="clear"></div>
{* =====================================================================================
content_cash_desk/completed.tpl (theme2) -- Erfolgsseite "Bestellung uebermittelt".
Seit 27.09.2026 im Stil der neuen Kasse (Schrittleiste, Karte). Ziel des Redirects
/Kasse/?success=1&order=<nr> aus website_cashdesk.php (Vorkasse/Rechnung direkt,
PayPal v2 nach dem Capture). Der Text kommt aus dem Backend
(Konfiguration cash_desk_order_completed, Platzhalter [#order_number]).
===================================================================================== *}
{$ok_nr = (isset($order_number) && $order_number) ? $order_number : ''}
<div class="ok">
<div class="ok-steps">
<span class="ok-step ok-step-done">1&nbsp;Warenkorb</span>
<span class="ok-step ok-step-done">2&nbsp;Kasse</span>
<span class="ok-step ok-step-now">3&nbsp;Fertig</span>
</div>
<div class="box_content">
<div class="completed_box">
{$system_configuration.cash_desk_order_completed|replace:"[#order_number]":$order_number}
<div class="ok-card">
<div class="ok-icon" aria-hidden="true">&#10003;</div>
<h1 class="ok-title">Vielen Dank f&uuml;r Ihre Bestellung</h1>
{if $ok_nr}
<div class="ok-nr">Bestellnummer <strong>{$ok_nr|escape}</strong></div>
{/if}
<div class="ok-text completed_box">
{$system_configuration.cash_desk_order_completed|replace:"[#order_number]":$ok_nr}
</div>
<br><br>
{if $system_configuration.trusted_shop_buyer_protection == 1}
<div class="protection_box">
{include file='content_cash_desk/trusted_shop_buyer_protection.tpl'}
</div>
{/if}
<div class="ok-actions">
<a href="/" class="ok-btn ok-btn-primary">Weiter einkaufen</a>
</div>
</div>
</div>
{literal}
<style>
.ok { font-family: Arial, Helvetica, sans-serif; color: #222; font-size: 14px; line-height: 1.45; padding-bottom: 150px; max-width: 720px; }
.ok *, .ok *:before, .ok *:after { box-sizing: border-box; }
.ok-steps { display: flex; gap: 6px; margin-bottom: 14px; font-size: 12px; }
.ok-step { padding: 4px 10px; border-radius: 12px; background: #eee; color: #777; }
.ok-step-done { background: #e8f3e0; color: #3a6b1f; }
.ok-step-now { background: #FF6700; color: #fff; font-weight: 700; }
.ok-card { border: 1px solid #e3e3e3; border-radius: 8px; padding: 28px 24px; background: #fff; text-align: center; }
.ok-icon { width: 56px; height: 56px; margin: 0 auto 12px; border-radius: 50%; background: #e8f3e0; color: #3a6b1f; font-size: 30px; line-height: 56px; font-weight: 700; }
.ok-title { font-size: 24px; font-weight: 700; margin: 0 0 6px; }
.ok-nr { font-size: 16px; color: #555; margin-bottom: 18px; }
.ok-nr strong { color: #222; }
.ok-text { text-align: left; border-top: 1px solid #eee; padding-top: 16px; }
.ok-text p { margin: 0 0 10px; }
.ok-actions { margin-top: 20px; }
.ok-btn { display: inline-flex; align-items: center; justify-content: center; height: 44px; padding: 0 26px; border-radius: 6px; font-size: 15px; font-weight: 700; text-decoration: none; border: 1px solid transparent; }
.ok-btn-primary { background: #FF6700; color: #fff; }
.ok-btn-primary:hover { background: #e45a00; color: #fff; }
</style>
{/literal}
@@ -0,0 +1,81 @@
{* =====================================================================================
content_cash_desk/payment_result.tpl (theme2) -- Rueckmeldung der Zahlungsanbieter
(core/payments): Weiterleitung zum Anbieter, Abbruch, ausstehend, Fehler.
Seit 27.09.2026 im Stil der neuen Kasse; ueberlagert die Fassung in easyshop_advanced.
Variablen wie dort: call, provider_label, payment_link, message, order_number.
===================================================================================== *}
{$pr_label = (isset($provider_label) && $provider_label) ? $provider_label : 'Zahlung'}
<div class="ok">
<div class="ok-steps">
<span class="ok-step ok-step-done">1&nbsp;Warenkorb</span>
<span class="ok-step ok-step-now">2&nbsp;Kasse</span>
<span class="ok-step">3&nbsp;Fertig</span>
</div>
<div class="ok-card cashdesk_payment_info">
{if $call == 'payment_open'}
<div class="ok-icon ok-icon-wait" aria-hidden="true">&rarr;</div>
<h1 class="ok-title">Weiter zu {$pr_label|escape}</h1>
<p>Vielen Dank f&uuml;r Ihre Bestellung. Sie werden jetzt zur Bezahlung weitergeleitet.<br />
Falls das nicht automatisch geschieht, klicken Sie bitte auf den Knopf.</p>
<meta http-equiv="refresh" content="0;url={$payment_link|escape}">
<div class="ok-actions">
<a href="{$payment_link|escape}" class="ok-btn ok-btn-primary">Weiter zu {$pr_label|escape}</a>
</div>
{elseif $call == 'payment_cancelled'}
<div class="ok-icon ok-icon-warn" aria-hidden="true">&times;</div>
<h1 class="ok-title">Bezahlung abgebrochen</h1>
<p>Ihr Warenkorb ist unver&auml;ndert. Sie k&ouml;nnen die Bestellung jederzeit
mit einer anderen Zahlart abschlie&szlig;en.</p>
<div class="ok-actions">
<a href="/Kasse/" class="ok-btn ok-btn-primary">Zur&uuml;ck zur Kasse</a>
<a href="/Warenkorb/" class="ok-btn ok-btn-ghost">Zum Warenkorb</a>
</div>
{elseif $call == 'payment_pending'}
<div class="ok-icon ok-icon-wait" aria-hidden="true">&hellip;</div>
<h1 class="ok-title">Zahlung wird gepr&uuml;ft</h1>
<p>{$message}</p>
{if $order_number}<div class="ok-nr">Ihre Bestellnummer: <strong>{$order_number|escape}</strong></div>{/if}
<div class="ok-actions">
<a href="/" class="ok-btn ok-btn-ghost">Weiter einkaufen</a>
</div>
{else}
<div class="ok-icon ok-icon-warn" aria-hidden="true">!</div>
<h1 class="ok-title">Das hat leider nicht geklappt</h1>
<p>{$message}</p>
{if $order_number}<div class="ok-nr">Bestellnummer: <strong>{$order_number|escape}</strong></div>{/if}
<div class="ok-actions">
<a href="/Kasse/" class="ok-btn ok-btn-primary">Zur&uuml;ck zur Kasse</a>
<a href="/Warenkorb/" class="ok-btn ok-btn-ghost">Zum Warenkorb</a>
</div>
{/if}
</div>
</div>
{literal}
<style>
.ok { font-family: Arial, Helvetica, sans-serif; color: #222; font-size: 14px; line-height: 1.45; padding-bottom: 150px; max-width: 720px; }
.ok *, .ok *:before, .ok *:after { box-sizing: border-box; }
.ok-steps { display: flex; gap: 6px; margin-bottom: 14px; font-size: 12px; }
.ok-step { padding: 4px 10px; border-radius: 12px; background: #eee; color: #777; }
.ok-step-done { background: #e8f3e0; color: #3a6b1f; }
.ok-step-now { background: #FF6700; color: #fff; font-weight: 700; }
.ok-card { border: 1px solid #e3e3e3; border-radius: 8px; padding: 28px 24px; background: #fff; text-align: center; }
.ok-card p { margin: 0 0 10px; }
.ok-icon { width: 56px; height: 56px; margin: 0 auto 12px; border-radius: 50%; background: #e8f3e0; color: #3a6b1f; font-size: 30px; line-height: 56px; font-weight: 700; }
.ok-icon-wait { background: #fff3e8; color: #a44a00; }
.ok-icon-warn { background: #fdecec; color: #8a1c1c; }
.ok-title { font-size: 24px; font-weight: 700; margin: 0 0 10px; }
.ok-nr { font-size: 16px; color: #555; margin: 8px 0 14px; }
.ok-nr strong { color: #222; }
.ok-actions { margin-top: 20px; display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; }
.ok-btn { display: inline-flex; align-items: center; justify-content: center; height: 44px; padding: 0 26px; border-radius: 6px; font-size: 15px; font-weight: 700; text-decoration: none; border: 1px solid transparent; }
.ok-btn-primary { background: #FF6700; color: #fff; }
.ok-btn-primary:hover { background: #e45a00; color: #fff; }
.ok-btn-ghost { background: #fff; color: #555; border-color: #cfcfcf; font-weight: 400; }
.ok-btn-ghost:hover { border-color: #999; color: #222; }
</style>
{/literal}
@@ -1,7 +1,6 @@
{* =====================================================================================
Kasse NEU (theme2) -- Entwurf 18.09.2026, liegt neben content_cash_desk.tpl.
Ansehen: /Kasse/?guest_order=1&ansicht=neu (Gast) bzw. /Kasse/?ansicht=neu (eingeloggt).
Weiche in website_cashdesk.php.
Kasse NEU (theme2) -- seit 27.09.2026 die Standardansicht von /Kasse/ (Gast: ?guest_order=1).
Rueckfall auf content_cash_desk.tpl: /Kasse/?ansicht=alt (Weiche in website_cashdesk.php).
Nur Darstellung ist neu. Alle Feldnamen, IDs und Klassen, an denen Modul und
content_cash_desk.js haengen, sind unveraendert: order_form, cashdesk_action,
@@ -19,14 +18,14 @@
{$ck_guest = (isset($guest_order) && $guest_order == 1)}
{$ck_express = (isset($express_active) && $express_active)}
{$ck_merchant = (isset($customer) && $customer->group_id == 101)}
{if $ck_guest}{$ck_url = "/Kasse/?guest_order=1&ansicht=neu"}{else}{$ck_url = "/Kasse/?ansicht=neu"}{/if}
{if $ck_guest}{$ck_url = "/Kasse/?guest_order=1"}{else}{$ck_url = "/Kasse/"}{/if}
<div class="ck">
<div class="ck-steps">
<a href="/Warenkorb/?ansicht=neu" class="ck-step ck-step-done">1&nbsp;Warenkorb</a>
<a href="/Warenkorb/" class="ck-step ck-step-done">1&nbsp;Warenkorb</a>
<span class="ck-step ck-step-now">2&nbsp;Kasse</span>
<span class="ck-step">3&nbsp;Fertig</span>
<a href="/Warenkorb/?ansicht=neu" class="ck-back">&larr; Zur&uuml;ck zum Warenkorb</a>
<a href="/Warenkorb/" class="ck-back">&larr; Zur&uuml;ck zum Warenkorb</a>
</div>
<h1 class="ck-title">Kasse{if $ck_guest} <span class="ck-muted">als Gast</span>{/if}</h1>
@@ -119,7 +118,7 @@
<input type="text" id="billing_surname" class="formatted obligatory" name="billing[surname]" autocomplete="family-name" value="{if isset($personal_data.billing.surname)}{$personal_data.billing.surname|escape}{/if}" data-req="person" data-label="Nachname" />
</div>
<div class="ck-f ck-f-1">
<label for="billing_company" class="address_company">Firma <span class="ck-muted ck-company-hint">(bei Anrede „Firma" Pflicht)</span></label>
<label for="billing_company" class="address_company">Firma <span class="ck-muted ck-company-hint">(bei Anrede „Firma“ Pflicht)</span></label>
<input type="text" id="billing_company" class="formatted" name="billing[company]" autocomplete="organization" value="{if isset($personal_data.billing.company)}{$personal_data.billing.company|escape}{/if}" data-req="company" data-label="Firma" />
</div>
<div class="ck-f ck-f-2w">
@@ -198,7 +197,7 @@
<input type="text" id="shipping_surname" class="formatted obligatory" name="shipping[surname]" value="{if isset($personal_data.shipping.surname)}{$personal_data.shipping.surname|escape}{/if}" data-req="person" data-label="Nachname (Lieferadresse)" />
</div>
<div class="ck-f ck-f-1">
<label for="shipping_company" class="address_company">Firma <span class="ck-muted ck-company-hint">(bei Anrede „Firma" Pflicht)</span></label>
<label for="shipping_company" class="address_company">Firma <span class="ck-muted ck-company-hint">(bei Anrede „Firma“ Pflicht)</span></label>
<input type="text" id="shipping_company" class="formatted" name="shipping[company]" value="{if isset($personal_data.shipping.company)}{$personal_data.shipping.company|escape}{/if}" data-req="company" data-label="Firma (Lieferadresse)" />
</div>
<div class="ck-f ck-f-2w">
@@ -260,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>
@@ -273,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":",":"."} &euro;
{elseif $pMethod->accountType == '1'}&minus; {$pMethod->paymentAdd|number_format:"2":",":"."} &euro;
@@ -424,7 +425,9 @@
.ck-grid { display: flex; gap: 20px; align-items: flex-start; }
.ck-main { flex: 1 1 auto; min-width: 0; }
.ck-side { flex: 0 0 280px; position: sticky; top: 16px; }
/* top = Hoehe des fixierten Theme-Kopfes (#header_sticky.fixed, 152px) + Luft. Der Kopf ist unten
ausgeblendet, faengt dort aber Klicks ab -- darunter darf nichts Klickbares liegen (Tom, 18./27.09.) */
.ck-side { flex: 0 0 280px; position: sticky; top: 168px; }
.ck-sec { border: 1px solid #e3e3e3; border-radius: 8px; padding: 16px 18px; margin-bottom: 14px; background: #fff; }
.ck-h { display: flex; align-items: center; gap: 10px; font-size: 17px; font-weight: 700; margin: 0 0 12px; }
@@ -459,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; }
@@ -546,7 +553,10 @@ $(document).ready(function() {
if (fehler.length) {
$box.html('<strong>Bitte noch ausfüllen:</strong> ' + fehler.join(', ')).removeAttr('hidden').show();
var $first = $('.ck .ck-invalid').first();
if ($first.length) { $('html, body').animate({ scrollTop: $first.offset().top - 90 }, 250); $first.find('input,select').add($first.filter('input,select')).first().focus(); // kein .addBack() in jQuery 1.6 }
if ($first.length) {
$('html, body').animate({ scrollTop: $first.offset().top - 90 }, 250);
$first.find('input,select').add($first.filter('input,select')).first().focus(); // kein .addBack() in jQuery 1.6
}
return false;
}
$box.attr('hidden', 'hidden').hide();
@@ -582,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}
@@ -1,6 +1,6 @@
{* =====================================================================================
Warenkorb NEU (theme2) -- Entwurf 18.09.2026, liegt neben content_shoppingcart.tpl.
Aufruf zum Anschauen: /Warenkorb/?ansicht=neu (Weiche in website_shoppingcart.php).
Warenkorb NEU (theme2) -- seit 27.09.2026 die Standardansicht von /Warenkorb/.
Rueckfall auf content_shoppingcart.tpl: /Warenkorb/?ansicht=alt (Weiche in website_shoppingcart.php).
Kein Tabellen-Markup mehr: jede Position ist eine Zeile (Bild, Name + Art.-Nr., Menge
mit Stepper, Preis, Entfernen), rechts ein Kasten mit Zwischensumme, MwSt, Gesamt und
@@ -22,7 +22,7 @@
<h1 class="wk-title">Warenkorb{if $shoppingcart.items} <span class="wk-count">{$shoppingcart.items|@count} {if $shoppingcart.items|@count == 1}Artikel{else}Artikel{/if}</span>{/if}</h1>
{if $shoppingcart.items}
<form method="post" action="/Warenkorb/?ansicht=neu" id="cart_form" class="wk-grid">
<form method="post" action="/Warenkorb/" id="cart_form" class="wk-grid">
<input type="hidden" name="cart_goto" id="cart_goto" value="" />
<div class="wk-list">
@@ -130,7 +130,9 @@
.wk { container-type: inline-size; }
.wk-grid { display: flex; gap: 20px; align-items: flex-start; }
.wk-list { flex: 1 1 auto; min-width: 0; }
.wk-side { flex: 0 0 260px; position: sticky; top: 16px; }
/* top = Hoehe des fixierten Theme-Kopfes (#header_sticky.fixed, 152px) + Luft. Der Kopf ist unten
ausgeblendet, faengt dort aber Klicks ab -- darunter darf nichts Klickbares liegen (Tom, 18./27.09.) */
.wk-side { flex: 0 0 260px; position: sticky; top: 168px; }
.wk-box { background: #fafafa; border: 1px solid #e3e3e3; border-radius: 8px; padding: 16px; }
/* Gewaehrleistungs-Label ueber dem Kasten: volle Breite der Spalte, Hoehe folgt dem Seitenverhaeltnis
(Tom, 18.09.), Klick oeffnet das Label gross in neuem Tab */
@@ -187,8 +189,11 @@
.wk-btn-ghost:hover { border-color: #999; }
.wk-trust { text-align: center; font-size: 12px; margin-top: 12px; }
.wk-empty { padding: 30px 0; }
.wk-empty p { font-size: 16px; margin-bottom: 14px; }
/* Rechtsbuendig: links reicht der fixierte Theme-Kopf (Zahlarten-Fenster) tiefer und schluckt Klicks,
rechts ist der Bereich frei (Tom, 27.09.). Rand rechts 0 = Kante der Inhaltsspalte. */
.wk-empty { padding: 30px 0; text-align: right; }
.wk-empty p { font-size: 16px; margin: 0 0 14px; }
.wk-empty .wk-btn-inline { margin-right: 0; }
.wk-quick { margin: 0 0 22px; padding-bottom: 16px; border-bottom: 1px solid #e6e6e6; }
.wk-quick-title { font-weight: 700; margin-bottom: 6px; }
@@ -230,21 +235,47 @@ $(document).ready(function() {
}
// Scrollposition ueber den Submit retten: ein POST rendert die Seite neu und der
// Browser faengt oben an. Vor dem Absenden merken, nach dem Laden wiederherstellen.
var gesendet = false;
function absenden(ziel) {
if (!mengenOk()) { return; }
if (gesendet) { return; } // Formular ist schon unterwegs, kein zweiter Submit
if (!mengenOk()) {
if (ziel === 'weiter') { window.location.href = '/'; } // Mengenfehler haelt niemanden im Laden fest
return;
}
if (ziel === '') { try { sessionStorage.setItem('wk_scroll', String(window.scrollY || window.pageYOffset || 0)); } catch (e) {} }
gesendet = true;
$('#cart_goto').val(ziel);
$('#cart_form').submit();
}
try {
var y = sessionStorage.getItem('wk_scroll');
if (y !== null) { sessionStorage.removeItem('wk_scroll'); window.scrollTo(0, parseInt(y) || 0); }
if (y !== null) {
sessionStorage.removeItem('wk_scroll');
// Nur wiederherstellen, solange der Korb noch Positionen hat. Wird die letzte entfernt, ist die
// Seite kurz: die alte Scrollposition klemmt dann am Seitenende und schiebt den Knopf
// "Weiter einkaufen" unter den fixierten Theme-Kopf, dessen unterer Rand durchsichtig ist,
// aber Klicks schluckt (Tom, 27.09.).
if ($('#cart_form').length) { window.scrollTo(0, parseInt(y) || 0); } else { window.scrollTo(0, 0); }
}
} catch (e) {}
// Getippte Menge: beim Verlassen des Feldes speichern (kein extra Knopf mehr)
$('.input_quantity').change(function() { absenden(''); });
$('#cashdesk_button').click(function(e) { e.preventDefault(); absenden('kasse'); });
$('.express_start').click(function(e) { e.preventDefault(); absenden('express:' + $(this).data('module')); });
$('.item_delete').click(function(e) { e.preventDefault(); $('.input_quantity', $(this).closest('.wk-row')).val(0); absenden(''); });
// Getippte Menge: beim Verlassen des Feldes speichern -- aber erst nach einer kurzen Wartezeit.
// Sonst gewinnt das Auto-Speichern gegen den Klick, der das Feld verlassen hat ("Weiter einkaufen",
// "Zur Kasse"): das Formular geht ab, der Klick verpufft, die Seite laedt sich nur neu (Tom, 27.09.).
var autoTimer = null;
function autoSpeichern() {
if (autoTimer) { clearTimeout(autoTimer); }
autoTimer = setTimeout(function() { autoTimer = null; absenden(''); }, 300);
}
// Eine Aktion bricht ein wartendes Auto-Speichern ab; ihr eigener Submit traegt die Mengen mit.
function aktion(ziel) {
if (autoTimer) { clearTimeout(autoTimer); autoTimer = null; }
absenden(ziel);
}
$('.input_quantity').change(function() { autoSpeichern(); });
$('#cashdesk_button').click(function(e) { e.preventDefault(); aktion('kasse'); });
$('.express_start').click(function(e) { e.preventDefault(); aktion('express:' + $(this).data('module')); });
$('.wk-continue a').click(function(e) { e.preventDefault(); aktion('weiter'); });
$('.item_delete').click(function(e) { e.preventDefault(); $('.input_quantity', $(this).closest('.wk-row')).val(0); aktion(''); });
// Stepper: Menge in Staffelschritten aendern und sofort speichern
$('.wk-step').click(function(e) {
@@ -258,10 +289,10 @@ $(document).ready(function() {
q = q + step * buy;
if (q < min) { q = (step < 0) ? 0 : min; } // unter Mindestmenge = entfernen
input.val(q);
absenden('');
aktion('');
});
// Enter im Mengenfeld = aktualisieren, nicht das Formular blind abschicken
$('.input_quantity').keydown(function(e) { if (e.keyCode == 13) { e.preventDefault(); absenden(''); } });
$('.input_quantity').keydown(function(e) { if (e.keyCode == 13) { e.preventDefault(); aktion(''); } });
});
</script>
{/literal}