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
This commit is contained in:
Tom Bartelt
2026-09-22 09:30:52 +02:00
parent fe868641d2
commit 85bc1d8c9a
8 changed files with 240 additions and 4 deletions
+25 -2
View File
@@ -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, '.', '')
+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';
}
}
+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`
@@ -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;
}
+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());
}
@@ -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>