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);
|
||||
$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, '.', '')
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
foreach (['ProviderInterface', 'Result', 'Secrets', 'Log', 'AbstractProvider'] as $c) {
|
||||
foreach (['ProviderInterface', 'Result', 'Secrets', 'Log', 'Overview', 'AbstractProvider'] as $c) {
|
||||
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_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;
|
||||
}
|
||||
|
||||
@@ -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 Ä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="edit_title">Rechnung</div>
|
||||
|
||||
Reference in New Issue
Block a user