Abo: Kreditkarte aktualisieren (CRM, Portal, Berater-Link) + Admin-Abo-Löschung
- Karten-Update ohne Abbuchung via PAYONE creditcardcheck/pseudocardpan (AboPaymentMethodService, CardUpdateResult/CreditCardCheckData-DTOs) - Eingabe nur durch Abo-Inhaber: CRM- und Portal-Formular + zeitlich begrenzter Berater→Kunde-Link (AboCardUpdateLink + Mail) - Änderungshistorie in user_abo_card_changes, Anzeige im Admin-Abo-Detail - Admin: Abo löschen (destroy) inkl. Test - Portal-Fix: myOrderCreate wirft 404 statt TypeError bei ungültiger ID - VIP-Sichtbarkeitstests für Einmalartikel-Fenster ergänzt - Enthält bereits die Freeze-Guards für den Stripe-Notbetrieb (Folge-Commit) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
63c36555a5
commit
671022a28d
36 changed files with 2366 additions and 26 deletions
131
app/Services/AboPaymentMethodService.php
Normal file
131
app/Services/AboPaymentMethodService.php
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\UserAbo;
|
||||
use App\Services\Payment\PaymentProviderManager;
|
||||
use App\Services\Payment\Payone\DTOs\CardUpdateResult;
|
||||
use App\Services\Payment\Payone\DTOs\CreditCardCheckData;
|
||||
use App\Services\Payment\Payone\PayonePaymentService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Orchestrates updating the credit-card token of an existing Abo via PAYONE
|
||||
* (creditcardcheck + preauthorization, no charge — see the Phase-1 plan
|
||||
* dev/2026-06-25-kreditkarte-update/). The card data is entered exclusively by the
|
||||
* Abo owner; guard-/owner-specific authorization happens in the calling controller,
|
||||
* the card-only/active invariant in {@see UserAbo::canUpdatePaymentMethod()}.
|
||||
*/
|
||||
class AboPaymentMethodService
|
||||
{
|
||||
public function __construct(private readonly PayonePaymentService $payone) {}
|
||||
|
||||
/**
|
||||
* Runs the card update. On success the new token is written atomically; a 3-D Secure
|
||||
* challenge is surfaced as a REDIRECT outcome (finalized after return via
|
||||
* {@see applyNewCard()}); failures leave the stored card untouched.
|
||||
*
|
||||
* @param array<string, string> $returnUrls successurl/errorurl/backurl for the 3DS redirect.
|
||||
*/
|
||||
public function updateCreditCard(UserAbo $abo, CreditCardCheckData $card, array $returnUrls, ?string $source = null): CardUpdateResult
|
||||
{
|
||||
if (app(PaymentProviderManager::class)->aboPaymentsFrozen()) {
|
||||
return CardUpdateResult::error(__('payment.abo_payments_frozen'));
|
||||
}
|
||||
|
||||
if (! $abo->canUpdatePaymentMethod()) {
|
||||
return CardUpdateResult::error(__('abo.card_update_not_allowed'));
|
||||
}
|
||||
|
||||
$shoppingUser = $abo->shopping_user;
|
||||
if (! $shoppingUser) {
|
||||
return CardUpdateResult::error(__('abo.card_update_failed'));
|
||||
}
|
||||
|
||||
$mode = $this->resolveMode($abo);
|
||||
|
||||
// Konsistenz-Spur: vor externem PAYONE-Call + DB-Write setzen, damit ein Crash
|
||||
// zwischen erfolgreichem PAYONE-Call und DB-Write auffindbar bleibt.
|
||||
$abo->forceFill(['card_update_pending_at' => now()])->save();
|
||||
|
||||
$result = $this->payone->refreshAboCardToken($shoppingUser, $card, $returnUrls, $mode);
|
||||
|
||||
if ($result->isApproved()) {
|
||||
$this->applyNewCard($abo, $result->carddata, $result->userid, $source);
|
||||
// 1-Cent-Reservierung freigeben; Fehler hier ist unkritisch (verfällt sonst).
|
||||
$this->safelyReleasePreauth($result->txid, $mode);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
if ($result->isRedirect()) {
|
||||
// Pending-Spur bleibt bestehen; Finalisierung nach 3DS-Rückkehr (Controller).
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Fehler: Spur zurücknehmen, kein DB-Update.
|
||||
$abo->forceFill(['card_update_pending_at' => null])->save();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the new card token atomically and idempotently and clears the pending trace.
|
||||
* Also used by the 3-D-Secure return path after successful authentication.
|
||||
*
|
||||
* @param array<string, mixed> $carddata
|
||||
*/
|
||||
public function applyNewCard(UserAbo $abo, array $carddata, int $userid, ?string $source = null): void
|
||||
{
|
||||
try {
|
||||
DB::transaction(function () use ($abo, $carddata, $userid, $source) {
|
||||
$abo->forceFill([
|
||||
'carddata' => $carddata,
|
||||
'payone_userid' => $userid,
|
||||
'card_update_pending_at' => null,
|
||||
])->save();
|
||||
|
||||
$abo->card_changes()->create([
|
||||
'masked_card' => UserAbo::formatMaskedCard($carddata),
|
||||
'source' => $source,
|
||||
]);
|
||||
});
|
||||
} catch (Throwable $e) {
|
||||
// PAYONE hat die neue Karte, der lokale DB-Write schlug fehl → Inkonsistenz:
|
||||
// card_update_pending_at bleibt gesetzt (auffindbare Spur), CRITICAL-Alert.
|
||||
MyLog::writeLog(
|
||||
'payment',
|
||||
'critical',
|
||||
'Kreditkarten-Update: DB-Write nach erfolgreichem PAYONE-Call fehlgeschlagen (Abo '.$abo->id.')',
|
||||
['user_abo_id' => $abo->id, 'payone_userid' => $userid, 'exception' => $e->getMessage()],
|
||||
);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function safelyReleasePreauth(string $txid, string $mode): void
|
||||
{
|
||||
if ($txid === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->payone->releasePreauth($txid, $mode);
|
||||
} catch (Throwable $e) {
|
||||
MyLog::writeLog(
|
||||
'payone',
|
||||
'warning',
|
||||
'Preauth-Freigabe (capture amount=0) fehlgeschlagen, Reservierung verfällt',
|
||||
['txid' => $txid, 'error' => $e->getMessage()],
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveMode(UserAbo $abo): string
|
||||
{
|
||||
return $abo->initialShoppingPayment()?->mode ?: config('payone.defaults.mode');
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue