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:
@@ -275,13 +275,36 @@ abstract class AbstractProvider implements ProviderInterface
|
|||||||
}
|
}
|
||||||
$this->setTransactionStatus($reference, 'cancelled', $why);
|
$this->setTransactionStatus($reference, 'cancelled', $why);
|
||||||
$orderId = (int) $tx->order_id;
|
$orderId = (int) $tx->order_id;
|
||||||
// 6 = storniert (wie v1-PayPal im Cancel-Zweig)
|
// 6 = storniert (wie v1-PayPal im Cancel-Zweig).
|
||||||
$this->db->query("UPDATE orders SET order_status=6 WHERE id=$orderId AND payment_status<>2");
|
// 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->pendingClear();
|
||||||
$this->event('cancelled', $orderId, (string) $tx->order_number, (float) $tx->expected_amount, $reference, null, (int) $tx->customer_id, $why . ' (' . $source . ')');
|
$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);
|
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
|
protected function amountMatches(string $expected, string $paid, string $expCur, string $paidCur): bool
|
||||||
{
|
{
|
||||||
return \number_format((float) $expected, 2, '.', '') === \number_format((float) $paid, 2, '.', '')
|
return \number_format((float) $expected, 2, '.', '') === \number_format((float) $paid, 2, '.', '')
|
||||||
|
|||||||
@@ -56,6 +56,34 @@ final class Log
|
|||||||
return true;
|
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 */
|
/** @return resource|\PgSql\Connection|false */
|
||||||
private static function conn()
|
private static function conn()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,7 +60,7 @@ final class Registry
|
|||||||
|
|
||||||
public static function requireCore(): void
|
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';
|
require_once __DIR__ . '/' . $c . '.php';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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', '', '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');
|
('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.
|
-- 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).
|
-- helperjoinkey 'pp' = derselbe PayPal-Aufschlag wie die alte Zahlart (shipping_area.add_pp).
|
||||||
INSERT INTO `payment_methods`
|
INSERT INTO `payment_methods`
|
||||||
|
|||||||
@@ -540,7 +540,10 @@ final class Provider extends AbstractProvider
|
|||||||
if ($status === 404) {
|
if ($status === 404) {
|
||||||
$out[] = "$ref (Order {$tx->order_id}): bei PayPal unbekannt -> storniert" . ($dryRun ? ' [dry-run]' : '');
|
$out[] = "$ref (Order {$tx->order_id}): bei PayPal unbekannt -> storniert" . ($dryRun ? ' [dry-run]' : '');
|
||||||
if (!$dryRun) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,6 +152,11 @@ class admin_order_editor {
|
|||||||
$this->layout_object->assign('order', $order_data);
|
$this->layout_object->assign('order', $order_data);
|
||||||
$this->layout_object->assign('order_items', $order_items);
|
$this->layout_object->assign('order_items', $order_items);
|
||||||
$this->layout_object->assign('footer', $order_footer);
|
$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) {
|
} catch (Exception $e) {
|
||||||
$this->layout_object->assign('error_message', $e->getMessage());
|
$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}" />
|
<input style="width:300px;" type="text" readonly="readonly" value="{$order->paymorrow_response_result_code}" />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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 Ä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;"> · {$h.status|escape}</span>{/if}
|
||||||
|
{if $h.http}<span style="color:#999;margin:0;"> · {$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="info_block">
|
||||||
<div class="edit_title">Rechnung</div>
|
<div class="edit_title">Rechnung</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user