Payment Phase R: PAYONE-Integration entflechten (R1–R7 + Review)

Strangler-Refactoring der PAYONE-Zahlungslogik in eine testbare
Service-Schicht, ohne Verhalten in Checkout/Cron/IPN zu ändern.

Neue Services (app/Services/Payment/Payone):
- PayonePaymentService (+ PayonePaymentResult-DTO) für CC-/SEPA-Check
  und Abo-Abbuchung
- PayoneRequestBuilder, PaymentMethodResolver, PayoneMethodConfig
- PayoneTransactionRecorder: zentrale PaymentTransaction-Erstellung
  (API-Response, Invoice, Webhook) inkl. PCI-Redaction (cardpan/cardcvc2)
- PayoneWebhookHandler (+ PayoneWebhookResult): IPN/TSOK-Logik aus
  Api\PayoneController; response('TSOK') statt echo/exit
- PayoneTransportException + Handler-Mapping (403) statt abort() im Service

Hotfixes (R1): var_dump/exit & bare exit() entfernt, Log-Redaction.

Security (R-Review):
- PAYONE_KEY_HASH (MD5-Hash, kein Klartext) mit Boot-Hard-Fail; schließt
  zugleich die ''-Webhook-Bypass-Lücke. Artisan-Command payone:hash-key
  (verdeckte Eingabe, optional --write) zur Hash-Erzeugung.
- Webhook: txaction Pflichtfeld, mode-Fallback auf payment->mode.

Pay\PayoneController ist @deprecated-Fassade (614 → 328 Zeilen),
delegiert an Services via Container-Singleton.

Migration: fehlende Spalte shopping_payments.carddata nachgezogen.

Tests: 47 grün (PayonePaymentService, Recorder, Webhook, Resolver,
TransportException, hash-key). End-to-End im Test-System verifiziert
(siehe dev/2026-06-09-payments).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-06-10 13:42:45 +00:00
parent 1668e5a879
commit ccc6f85809
31 changed files with 3420 additions and 717 deletions

View file

@ -0,0 +1,86 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use function Laravel\Prompts\confirm;
use function Laravel\Prompts\password;
class PayoneHashKey extends Command
{
protected $signature = 'payone:hash-key
{--write : Schreibt den erzeugten Hash als PAYONE_KEY_HASH in die .env}';
protected $description = 'Erzeugt aus dem PAYONE-Portal-Key (Klartext) den benötigten MD5-Hash für PAYONE_KEY_HASH';
public function handle(): int
{
$this->components->info('Der Klartext-Key wird nur flüchtig zur Hash-Erzeugung verwendet und nirgends gespeichert oder geloggt.');
$plainKey = trim((string) password(
label: 'PAYONE-Portal-Key (Klartext, verdeckte Eingabe)',
required: true,
));
if ($plainKey === '') {
$this->components->error('Es wurde kein Key eingegeben.');
return self::FAILURE;
}
$hash = hash('md5', $plainKey);
$this->newLine();
$this->components->twoColumnDetail('PAYONE_KEY_HASH', $hash);
$this->newLine();
if (! $this->option('write')) {
$this->line(' Ohne <comment>--write</comment>: Wert manuell als <comment>PAYONE_KEY_HASH</comment> in die .env übernehmen.');
return self::SUCCESS;
}
if (! confirm("PAYONE_KEY_HASH in {$this->envPath()} schreiben/aktualisieren?", default: true)) {
$this->components->warn('Abgebrochen – .env wurde nicht verändert.');
return self::SUCCESS;
}
if (! $this->writeHashToEnv($hash)) {
return self::FAILURE;
}
$this->components->info('PAYONE_KEY_HASH in die .env geschrieben.');
$this->line(' Danach <comment>php artisan config:clear</comment> ausführen (bzw. config neu cachen).');
return self::SUCCESS;
}
private function envPath(): string
{
return base_path('.env');
}
private function writeHashToEnv(string $hash): bool
{
$path = $this->envPath();
if (! is_file($path) || ! is_writable($path)) {
$this->components->error(".env nicht gefunden oder nicht beschreibbar: {$path}");
return false;
}
$contents = (string) file_get_contents($path);
$line = 'PAYONE_KEY_HASH='.$hash;
if (preg_match('/^PAYONE_KEY_HASH=.*$/m', $contents)) {
$contents = preg_replace('/^PAYONE_KEY_HASH=.*$/m', $line, $contents);
} else {
$contents = rtrim($contents, "\n")."\n".$line."\n";
}
return file_put_contents($path, $contents) !== false;
}
}