20-02-2026
This commit is contained in:
parent
a8b395e20d
commit
a00c42e770
252 changed files with 28785 additions and 8907 deletions
571
dev/2026-01-28/payment-status-legend-and-race-condition-fix.md
Normal file
571
dev/2026-01-28/payment-status-legend-and-race-condition-fix.md
Normal file
|
|
@ -0,0 +1,571 @@
|
|||
# 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:
|
||||
|
||||
```php
|
||||
$shopping_order->txaction = $data['txaction']; // IMMER überschrieben!
|
||||
$shopping_payment->txaction = $data['txaction']; // IMMER überschrieben!
|
||||
```
|
||||
|
||||
### Lösung
|
||||
|
||||
Prioritätsprüfung vor dem Update:
|
||||
|
||||
```php
|
||||
$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:
|
||||
|
||||
```php
|
||||
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:
|
||||
|
||||
```php
|
||||
// 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:
|
||||
|
||||
```php
|
||||
class ShoppingInstance extends Model
|
||||
{
|
||||
protected $primaryKey = 'identifier';
|
||||
public $incrementing = false;
|
||||
protected $keyType = 'string';
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementierung
|
||||
|
||||
### 1. OrderPaymentService - Zentrale Status-Definition
|
||||
|
||||
**Datei:** `app/Services/OrderPaymentService.php`
|
||||
|
||||
```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
|
||||
|
||||
### 2. Payment Links View - Dynamische Legende
|
||||
|
||||
**Datei:** `resources/views/user/order/payment/index.blade.php`
|
||||
|
||||
```blade
|
||||
{{-- 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`
|
||||
|
||||
```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:**
|
||||
|
||||
```php
|
||||
// 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:**
|
||||
|
||||
```php
|
||||
// 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`
|
||||
|
||||
```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`
|
||||
|
||||
```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
|
||||
|
||||
```php
|
||||
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
|
||||
|
||||
```php
|
||||
'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)
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```php
|
||||
// In FixPaymentLinkStatus Command:
|
||||
if (!$instance && $payment->identifier) {
|
||||
$payment->identifier = null;
|
||||
$payment->save();
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Monitoring
|
||||
|
||||
Status-Änderungen in separater Log-Tabelle tracken:
|
||||
|
||||
```php
|
||||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue