diff --git a/core/payments/AbstractProvider.php b/core/payments/AbstractProvider.php index 8cb125b..6659d68 100644 --- a/core/payments/AbstractProvider.php +++ b/core/payments/AbstractProvider.php @@ -275,13 +275,36 @@ abstract class AbstractProvider implements ProviderInterface } $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, '.', '') diff --git a/core/payments/Log.php b/core/payments/Log.php index c720cb7..958716d 100644 --- a/core/payments/Log.php +++ b/core/payments/Log.php @@ -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 + */ + 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() { diff --git a/core/payments/Overview.php b/core/payments/Overview.php new file mode 100644 index 0000000..ea20dad --- /dev/null +++ b/core/payments/Overview.php @@ -0,0 +1,121 @@ + '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|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 */ + 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; + } +} diff --git a/core/payments/Registry.php b/core/payments/Registry.php index 2e2f19f..4dbcc2c 100644 --- a/core/payments/Registry.php +++ b/core/payments/Registry.php @@ -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'; } } diff --git a/core/payments/install.sql b/core/payments/install.sql index 095d271..bfcf66f 100644 --- a/core/payments/install.sql +++ b/core/payments/install.sql @@ -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` diff --git a/core/payments/providers/paypal_v2/Provider.php b/core/payments/providers/paypal_v2/Provider.php index a513934..5a71035 100644 --- a/core/payments/providers/paypal_v2/Provider.php +++ b/core/payments/providers/paypal_v2/Provider.php @@ -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; } diff --git a/modules/admin_order_editor.php b/modules/admin_order_editor.php index c0a4828..b4769d8 100644 --- a/modules/admin_order_editor.php +++ b/modules/admin_order_editor.php @@ -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()); } diff --git a/themes/admin/templates/admin_order_editor.tpl b/themes/admin/templates/admin_order_editor.tpl index ed0fde4..3151721 100644 --- a/themes/admin/templates/admin_order_editor.tpl +++ b/themes/admin/templates/admin_order_editor.tpl @@ -483,6 +483,56 @@ {/if} + + {if $payment_tx} + {* 22.09.2026 -- Zahlungsvorgang aus core/payments (anbieterneutral: PayPal v2, spaeter Mollie) *} +
+
Zahlungsvorgang {$payment_tx.provider_label|escape}
+ + + + + {if $payment_tx.detail} + + + {/if} + + + + + + + + + + + + + + {if $payment_tx.paid_at} + + + {else} + + + {/if} + + {if $payment_tx.history} + +
+ {foreach $payment_tx.history as $h} +
+ {$h.zeit|escape}
+ {$h.aktion|escape} + {if $h.status} · {$h.status|escape}{/if} + {if $h.http} · {$h.http|escape}{/if} + {if $h.detail}
{$h.detail|escape}{/if} +
+ {/foreach} +
+ {/if} +
+ {/if}
Rechnung