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:
Kevin 2026-07-08 15:56:19 +00:00
parent 63c36555a5
commit 671022a28d
36 changed files with 2366 additions and 26 deletions

View 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');
}
}

View file

@ -0,0 +1,71 @@
<?php
namespace App\Services\Payment\Payone\DTOs;
/**
* Outcome of an Abo credit-card token refresh (creditcardcheck + preauthorization).
*
* Three states:
* - approved: card validated and SCA mandate established; carries the new card token
* data ({@see $carddata}), the freshly minted PAYONE $userid and the preauth $txid.
* - redirect: 3-D Secure challenge required; $redirectUrl points to the ACS. The
* $carddata/$txid are carried so the caller can stash them and finalize after return.
* - error: creditcardcheck or preauthorization failed; $errorMessage holds a
* customer-facing message.
*/
class CardUpdateResult
{
public const APPROVED = 'approved';
public const REDIRECT = 'redirect';
public const ERROR = 'error';
/**
* @param array<string, mixed> $carddata New card token data (pseudocardpan, cardexpiredate, …).
*/
private function __construct(
public readonly string $status,
public readonly array $carddata = [],
public readonly int $userid = 0,
public readonly string $txid = '',
public readonly ?string $redirectUrl = null,
public readonly ?string $errorMessage = null,
) {}
/**
* @param array<string, mixed> $carddata
*/
public static function approved(array $carddata, int $userid, string $txid): self
{
return new self(self::APPROVED, carddata: $carddata, userid: $userid, txid: $txid);
}
/**
* @param array<string, mixed> $carddata
*/
public static function redirect(string $redirectUrl, array $carddata, string $txid, int $userid = 0): self
{
return new self(self::REDIRECT, carddata: $carddata, userid: $userid, txid: $txid, redirectUrl: $redirectUrl);
}
public static function error(string $errorMessage): self
{
return new self(self::ERROR, errorMessage: $errorMessage);
}
public function isApproved(): bool
{
return $this->status === self::APPROVED;
}
public function isRedirect(): bool
{
return $this->status === self::REDIRECT;
}
public function isError(): bool
{
return $this->status === self::ERROR;
}
}

View file

@ -0,0 +1,63 @@
<?php
namespace App\Services\Payment\Payone\DTOs;
use App\Http\Requests\Abo\UpdateAboCreditCardRequest;
use App\Services\Payment\Payone\PayonePaymentService;
use App\Services\Payone;
/**
* Immutable, validated credit-card input for a card-on-file update (Abo).
*
* Built from {@see UpdateAboCreditCardRequest} so the payment
* service never reads unvalidated request arrays. Raw PAN/CVC exist only inside this
* object on the way to the PAYONE creditcardcheck request and must never be logged
* (redaction is enforced in {@see Payone::sendRequest()}).
*/
class CreditCardCheckData
{
public function __construct(
public readonly string $cardholderFirstName,
public readonly string $cardholderLastName,
public readonly string $cardPan,
public readonly string $cardExpireMonth,
public readonly string $cardExpireYear,
public readonly string $cardType,
public readonly string $cardCvc2,
) {}
/**
* @param array<string, mixed> $data Keys as posted by the checkout/abo CC form (cc_*).
*/
public static function fromFormData(array $data): self
{
return new self(
cardholderFirstName: (string) ($data['cc_cardholder_first'] ?? ''),
cardholderLastName: (string) ($data['cc_cardholder_last'] ?? ''),
cardPan: (string) ($data['cc_cardpan'] ?? ''),
cardExpireMonth: (string) ($data['cc_cardexpiremonth'] ?? ''),
cardExpireYear: (string) ($data['cc_cardexpireyear'] ?? ''),
cardType: (string) ($data['cc_cardtype'] ?? ''),
cardCvc2: (string) ($data['cc_cardcvc2'] ?? ''),
);
}
/**
* Maps back to the cc_* parameter keys expected by
* {@see PayonePaymentService::checkCreditCard()}.
*
* @return array<string, string>
*/
public function toCheckArray(): array
{
return [
'cc_cardholder_first' => $this->cardholderFirstName,
'cc_cardholder_last' => $this->cardholderLastName,
'cc_cardpan' => $this->cardPan,
'cc_cardexpiremonth' => $this->cardExpireMonth,
'cc_cardexpireyear' => $this->cardExpireYear,
'cc_cardtype' => $this->cardType,
'cc_cardcvc2' => $this->cardCvc2,
];
}
}

View file

@ -6,12 +6,21 @@ use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Services\Payment\Payone\DTOs\CardUpdateResult;
use App\Services\Payment\Payone\DTOs\CreditCardCheckData;
use App\Services\Payment\Payone\DTOs\PayonePaymentResult;
use App\Services\Payone;
use GuzzleHttp\Client;
class PayonePaymentService
{
/**
* Verification reservation amount (in cents) for a card-on-file preauthorization.
* Per PAYONE guidance for "initial transaction, followed by recurring payment" when
* the later amount is unknown: preauthorize 1 cent over 3-D Secure; never captured.
*/
public const CARD_VERIFICATION_AMOUNT = 1;
/**
* @param array<string, mixed> $defaults PAYONE merchant defaults from config('payone.defaults')
* @param Client|null $client Optional Guzzle client; inject a MockHandler client in tests.
@ -125,4 +134,94 @@ class PayonePaymentService
return PayonePaymentResult::fromResponse($response, $shoppingPayment);
}
/**
* Refreshes the stored credit-card token for an existing Abo without charging.
*
* Step 1: creditcardcheck (storecarddata=yes) → new pseudocardpan.
* Step 2: preauthorization as a customer-initiated transaction (customer_is_present=yes,
* recurrence=recurring, amount=1) → runs 3-D Secure and establishes a fresh
* recurring mandate. The 1 cent is reserved only and never captured (release via
* {@see releasePreauth()}).
*
* Returns an APPROVED result with the new card token + minted userid, a REDIRECT result
* (3-D Secure challenge) carrying the card token to stash for finalization, or an ERROR.
*
* @param array<string, string> $returnUrls successurl/errorurl/backurl for the 3DS redirect.
*/
public function refreshAboCardToken(
ShoppingUser $shoppingUser,
CreditCardCheckData $card,
array $returnUrls,
string $mode,
): CardUpdateResult {
$check = $this->checkCreditCard($card->toCheckArray(), $mode);
if (! $check->isValid()) {
return CardUpdateResult::error(
$check->customerMessage ?? $check->errorMessage ?? __('abo.card_update_failed')
);
}
$carddata = $check->rawResponse;
$defaults = array_merge($this->defaults, ['mode' => $mode]);
$personalData = PayoneRequestBuilder::buildPersonalData($shoppingUser);
$method = [
'clearingtype' => 'cc',
'wallettype' => '',
'pseudocardpan' => $carddata['pseudocardpan'] ?? '',
'cardexpiredate' => $carddata['cardexpiredate'] ?? '',
'onlinebanktransfertype' => '',
'request' => 'preauthorization',
];
$verification = [
'recurrence' => 'recurring',
'customer_is_present' => 'yes',
'request' => 'preauthorization',
'amount' => self::CARD_VERIFICATION_AMOUNT,
];
$prepayment = [
'reference' => substr(uniqid('cu', false), 0, 16),
'amount' => self::CARD_VERIFICATION_AMOUNT,
'currency' => 'EUR',
];
$request = PayoneRequestBuilder::buildRequest(
$defaults, $personalData, [], $method, $prepayment, $verification, $returnUrls,
);
$result = PayonePaymentResult::fromResponse(Payone::sendRequest($request, '', $this->client));
if ($result->isApproved()) {
return CardUpdateResult::approved($carddata, $result->userid, $result->txid);
}
if ($result->isRedirect()) {
return CardUpdateResult::redirect($result->redirectUrl ?? '', $carddata, $result->txid, $result->userid);
}
return CardUpdateResult::error(
$result->customerMessage ?? $result->errorMessage ?? __('abo.card_update_failed')
);
}
/**
* Releases a card-verification preauthorization by capturing amount=0, which cancels
* the reserved funds at the acquirer. Alternatively the reservation expires by itself.
*/
public function releasePreauth(string $txid, string $mode, string $currency = 'EUR'): PayonePaymentResult
{
$request = array_merge($this->defaults, ['mode' => $mode], [
'request' => 'capture',
'txid' => $txid,
'sequencenumber' => 1,
'amount' => 0,
'currency' => $currency,
]);
return PayonePaymentResult::fromResponse(Payone::sendRequest($request, '', $this->client));
}
}