mivita/dev/2026-01-28/payment-status-legend-and-race-condition-fix.md
2026-02-20 17:55:06 +01:00

15 KiB

Payment Status Legende & Race Condition Fix

Datum: 28.01.2026
Bereich: Payment System / Payone Integration
Status: Abgeschlossen


Übersicht

Umfassende Verbesserung des Payment-Link-Systems mit folgenden Komponenten:

  1. Dynamische mehrsprachige Status-Legende
  2. Race Condition Fix für Payone Requests
  3. Status Update für alle Bestellungstypen
  4. ShoppingInstance Model Korrektur
  5. Artisan Command für Datenbank-Cleanup

Problem 1: Fehlende Status-Übersicht

Ausgangslage

  • Benutzer sahen Payment Status-Badges in der Payment Links Übersicht
  • Keine Erklärung, was die verschiedenen Status/Farben bedeuten
  • Keine Legende zur Orientierung

Lösung

Dynamische Status-Legende oberhalb der Tabelle mit allen verfügbaren Status.


Problem 2: Race Condition bei Payone Requests

Ausgangslage

Payone sendet mehrere Status-Updates für eine Zahlung:

  1. appointed - Zahlung autorisiert
  2. paid - Zahlung abgeschlossen

Problem: Wenn die Requests in falscher Reihenfolge ankommen:

1. paid kommt zuerst     → txaction = 'paid' ✓
2. appointed kommt später → txaction = 'appointed' ✗ (überschreibt paid!)

Dies führte zu:

  • Falschen Status in shopping_orders.txaction und shopping_payments.txaction
  • ShoppingInstance Status blieb teilweise auf 4 (appointed) statt 10 (paid)
  • Verwirrung bei Benutzern und Support

Root Cause

In PayoneController.php wurden die txaction Felder immer überschrieben:

$shopping_order->txaction = $data['txaction'];  // IMMER überschrieben!
$shopping_payment->txaction = $data['txaction']; // IMMER überschrieben!

Lösung

Prioritätsprüfung vor dem Update:

$txaction_priority = [
    'appointed' => 1,
    'pending' => 2,
    'failed' => 3,
    'paid' => 10, // höchste Priorität - finaler Status
];

// Nur updaten wenn neue Priorität höher ist
if ($new_priority > $current_priority) {
    $shopping_order->txaction = $data['txaction'];
    $shopping_payment->txaction = $data['txaction'];
}

Problem 3: Status nur für Abo-Bestellungen

Ausgangslage

In Payment::paymentStatusPaidAction() wurde Status 10 (link_paid) nur gesetzt wenn:

if ($shopping_order->is_abo) {
    Util::setInstanceStatusByPayment($shopping_payment, 10);
}

Resultat:

  • Abo-Bestellungen: Status korrekt auf 10
  • Normale Bestellungen: Status blieb auf 4 (appointed)

Lösung

Status 10 wird jetzt immer bei erfolgreicher Zahlung gesetzt:

// Set payment link status to paid for all orders
if ($shopping_payment) {
    Util::setInstanceStatusByPayment($shopping_payment, 10);
}

// Abo-spezifische Logik separat
if ($shopping_order->is_abo) {
    AboHelper::setAboActive($shopping_order, 2, true);
}

Problem 4: ShoppingInstance Model

Ausgangslage

  • Tabelle shopping_instances hat identifier (VARCHAR) als Primary Key
  • Laravel Model hatte keine Primary Key Konfiguration
  • Eloquent verwendete standardmäßig id (nicht existent)
  • save() schlug fehl mit: "Unknown column 'id' in 'where clause'"

Lösung

Primary Key im Model korrekt konfiguriert:

class ShoppingInstance extends Model
{
    protected $primaryKey = 'identifier';
    public $incrementing = false;
    protected $keyType = 'string';
    // ...
}

Implementierung

1. OrderPaymentService - Zentrale Status-Definition

Datei: app/Services/OrderPaymentService.php

public static function getStatusBadgeClasses()
{
    return [
        'link_sent' => 'default',
        'link_openly' => 'info',
        'link_paid' => 'secondary',
        'link_check' => 'warning',
        'link_pending' => 'warning',
        'link_appointed' => 'warning',
        'link_failed' => 'danger',
        'link_canceled' => 'danger'
    ];
}

public static function getStatusBadge(ShoppingInstance $shoppingInstance)
{
    $status = $shoppingInstance->getStatus();
    $badgeClasses = self::getStatusBadgeClasses(); // Nutzt zentrale Definition

    if (isset($badgeClasses[$status])) {
        return sprintf(
            ' <span class="badge badge-pill badge-%s">%s</span>',
            $badgeClasses[$status],
            __('payment.' . $status)
        );
    }

    return '';
}

Vorteile:

  • Eine zentrale Stelle für Status-Definitionen
  • DRY Prinzip (Don't Repeat Yourself)
  • Leicht erweiterbar für neue Status

Datei: resources/views/user/order/payment/index.blade.php

{{-- Status-Legende --}}
<div class="card mb-3">
    <div class="card-body">
        <h6 class="card-title mb-3">{{ __('tables.status') }} - {{ __('legend') }}</h6>
        <div class="row">
            @foreach(\App\Services\OrderPaymentService::getStatusBadgeClasses() as $statusKey => $badgeClass)
                <div class="col-md-3 mb-2">
                    <span class="badge badge-pill badge-{{ $badgeClass }}">
                        {{ __('payment.' . $statusKey) }}
                    </span>
                </div>
            @endforeach
        </div>
    </div>
</div>

Vorteile:

  • Vollständig dynamisch
  • Automatisch synchron mit Backend-Logik
  • Mehrsprachig über Laravel Translations
  • Neue Status erscheinen automatisch

3. PayoneController - Race Condition Fix

Datei: app/Http/Controllers/Api/PayoneController.php

// Define txaction priority (higher number = higher priority)
$txaction_priority = [
    'appointed' => 1,
    'pending' => 2,
    'failed' => 3,
    'paid' => 10, // highest priority - final state
];

$current_priority = isset($txaction_priority[$shopping_order->txaction])
    ? $txaction_priority[$shopping_order->txaction] : 0;
$new_priority = isset($txaction_priority[$data['txaction']])
    ? $txaction_priority[$data['txaction']] : 0;

// Only update txaction if new priority is higher than current
if ($new_priority > $current_priority) {
    $shopping_order->txaction = $data['txaction'];
    $shopping_order->save();
    $shopping_payment->txaction = $data['txaction'];
    $shopping_payment->save();
} else {
    MyLog::writeLog(
        'payone',
        'info',
        'txaction not updated (current has higher/equal priority)',
        $data,
        false
    );
}

Szenarien:

Szenario Request 1 Request 2 Resultat Status
Normal appointed (1) paid (10) paid Korrekt
Race paid (10) appointed (1) paid Korrekt (nicht überschrieben)
Failed appointed (1) failed (3) failed Korrekt

4. Payment Service - Status für alle Bestellungen

Datei: app/Services/Payment.php

Vorher:

// the Order is Pay, so we can set the Status in the Abo
if ($shopping_order->is_abo) {
    if ($shopping_payment) {
        Util::setInstanceStatusByPayment($shopping_payment, 10); // link_paid
    }
    AboHelper::setAboActive($shopping_order, 2, true);
}

Nachher:

// Set payment link status to paid for all orders
if ($shopping_payment) {
    Util::setInstanceStatusByPayment($shopping_payment, 10); // link_paid
    $shopping_payment->identifier = null;
    $shopping_payment->save();
}

// the Order is Pay, so we can set the Status in the Abo
if ($shopping_order->is_abo) {
    AboHelper::setAboActive($shopping_order, 2, true);
}

5. ShoppingInstance Model - Primary Key

Datei: app/Models/ShoppingInstance.php

class ShoppingInstance extends Model
{
    protected $primaryKey = 'identifier';
    public $incrementing = false;
    protected $keyType = 'string';

    // Rest des Models...
}

6. Artisan Command - Datenbank Cleanup

Datei: app/Console/Commands/FixPaymentLinkStatus.php

class FixPaymentLinkStatus extends Command
{
    protected $signature = 'payment:fix-link-status {--dry-run : Run without making changes}';

    protected $description = 'Fix payment link status for paid orders';

    public function handle()
    {
        $isDryRun = $this->option('dry-run');

        // Find all paid payments with identifiers
        $paidPayments = ShoppingPayment::whereNotNull('identifier')
            ->whereHas('shopping_order', function ($query) {
                $query->where('paid', 1)
                    ->where('txaction', 'paid');
            })
            ->get();

        foreach ($paidPayments as $payment) {
            $instance = ShoppingInstance::where('identifier', $payment->identifier)->first();

            if ($instance && $instance->status < 10) {
                if (!$isDryRun) {
                    $instance->status = 10;
                    $instance->save();
                }
                $this->line("✅ Updated payment #{$payment->id}");
            }
        }
    }
}

Features:

  • Dry-Run Modus (--dry-run)
  • Detaillierte Ausgabe mit Statistiken
  • Fehlerbehandlung für fehlende Instances
  • Progress-Anzeige

Übersetzungen

Spanische Übersetzungen korrigiert

Datei: resources/lang/es/payment.php

Key Alt Neu Grund
link_paid "pagado" "Pago exitoso" Zu kurz/generisch, inkonsistent mit anderen Sprachen
link_check "Pago en curso" "Pago en revisión" Falsche Bedeutung ("in progress" statt "in review")

Alle Sprachen vollständig:

  • Deutsch (DE) - alle 8 Status korrekt
  • Englisch (EN) - alle 8 Status korrekt
  • Spanisch (ES) - 2 Korrekturen vorgenommen

Status-Hierarchie

ShoppingInstance Status

public $statuses = [
    0 => 'link_sent',      // Link versendet
    1 => 'link_openly',    // Link geöffnet
    2 => 'link_check',     // In Prüfung
    3 => 'link_pending',   // In Bearbeitung
    4 => 'link_appointed', // Angewiesen
    5 => 'link_failed',    // Fehlgeschlagen
    6 => 'link_canceled',  // Abgebrochen
    10 => 'link_paid',     // Bezahlt (FINAL)
];

Badge-Farben

'link_sent' => 'default',     // Grau
'link_openly' => 'info',      // Blau
'link_paid' => 'secondary',   // Dunkelgrau
'link_check' => 'warning',    // Gelb
'link_pending' => 'warning',  // Gelb
'link_appointed' => 'warning',// Gelb
'link_failed' => 'danger',    // Rot
'link_canceled' => 'danger'   // Rot

Datenbank Cleanup Ergebnisse

Command Ausführung (28.01.2026)

php artisan payment:fix-link-status

Statistik:

  • 2.117 Payment Links korrigiert (Status 4 → 10)
  • 490 bereits korrekt (durch neue Logik)
  • ⚠️ 12.754 ShoppingInstances nicht gefunden
  • 📊 15.361 bezahlte Payments total

Warum fehlen 12.754 ShoppingInstances?

Grund: ShoppingInstances werden nach erfolgreicher Zahlung gelöscht (by design).

  1. Kunde bekommt Payment Link → ShoppingInstance wird erstellt
  2. Kunde zahlt → ShoppingOrder.paid = 1
  3. System löscht ShoppingInstance (nicht mehr benötigt)
  4. ShoppingPayment.identifier bleibt erhalten (historische Referenz)

Ist das ein Problem? Nein! Die Zahlung ist abgeschlossen, die temporäre Instance wird nicht mehr benötigt.

Beispiel-Output

🔎 Searching for payment links with incorrect status...

Found 15361 paid payments with identifiers

✅ Payment #41963: link_appointed (4) → link_paid (10) (Order #45027, Amount: 92,60 EUR)
✅ Payment #41967: link_appointed (4) → link_paid (10) (Order #45031, Amount: 115,90 EUR)
✅ Payment #41970: link_appointed (4) → link_paid (10) (Order #45034, Amount: 27,80 EUR)
⚠️  ShoppingInstance not found for identifier: c5cdac250...
⚠️  ShoppingInstance not found for identifier: 0ea47bde4...

📊 Summary:
+-----------------+-------+
| Status          | Count |
+-----------------+-------+
| Fixed/Would fix | 2117  |
| Already correct | 490   |
| Errors          | 12754 |
| Total processed | 15361 |
+-----------------+-------+

✨ Successfully updated 2117 payment link(s)!

Testing

Manuelle Tests durchgeführt

1. Status-Legende

  • Legende wird oberhalb der Tabelle angezeigt
  • Alle 8 Status werden korrekt dargestellt
  • Farben entsprechen den Badges in der Tabelle
  • Mehrsprachigkeit funktioniert (DE, EN, ES)

2. Race Condition Fix

  • Payone Request: paid → appointed → txaction bleibt "paid" ✓
  • Payone Request: appointed → paid → txaction wird "paid" ✓
  • Logging erfolgt bei übersprungenen Updates
  • ShoppingInstance Status bleibt auf 10 bei Race Condition

3. Status für alle Bestellungen

  • Normale Bestellung bezahlt → Status 10 gesetzt
  • Abo-Bestellung bezahlt → Status 10 gesetzt
  • Beide Szenarien funktionieren korrekt

4. Artisan Command

  • Dry-Run zeigt korrekte Anzahl zu korrigierender Einträge
  • Echte Ausführung aktualisiert Datenbank korrekt
  • Fehlerbehandlung für fehlende Instances funktioniert
  • Statistik ist korrekt und übersichtlich

Vorteile der Lösung

Für Benutzer

  • Klare Übersicht über alle möglichen Payment Status
  • Korrekte Status-Anzeige (keine Race Conditions mehr)
  • Mehrsprachige Unterstützung
  • Besseres Verständnis des Zahlungsprozesses

Für Entwickler

  • Zentrale Status-Definition (DRY Prinzip)
  • Automatische Synchronisation zwischen Backend und Frontend
  • Einfache Erweiterbarkeit für neue Status
  • Klare Logging-Informationen bei Problemen
  • Tool zur Datenbereinigung vorhanden

Für das System

  • Konsistente Datenhaltung
  • Keine verlorenen "paid" Status mehr
  • Historische Daten bereinigt
  • Robustere Payone-Integration

Zukünftige Verbesserungen (Optional)

1. Identifier Cleanup

Optional: ShoppingPayment.identifier auf NULL setzen, wenn ShoppingInstance nicht mehr existiert:

// In FixPaymentLinkStatus Command:
if (!$instance && $payment->identifier) {
    $payment->identifier = null;
    $payment->save();
}

2. Monitoring

Status-Änderungen in separater Log-Tabelle tracken:

PaymentStatusLog::create([
    'shopping_payment_id' => $payment->id,
    'old_status' => $oldStatus,
    'new_status' => $newStatus,
    'source' => 'payone',
    'txaction' => $txaction,
]);

3. Webhooks

Benachrichtigungen bei unerwarteten Status-Änderungen:

  • E-Mail an Admin bei "paid" → "appointed" Versuch
  • Slack-Notification bei kritischen Fehlern

Zusammenfassung

Umfassende Verbesserung des Payment-Systems mit:

  • 🎨 Benutzerfreundliche Status-Legende
  • 🐛 Race Condition Bug behoben
  • 🔧 Konsistente Status-Vergabe
  • 🛠️ Model-Korrekturen
  • 🧹 Datenbank-Cleanup Tool
  • 🌍 Mehrsprachigkeit
  • 📊 2.117 historische Einträge korrigiert

Status: Produktionsreif und deployed Datum: 28.01.2026