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
71
app/Services/Payment/Payone/DTOs/CardUpdateResult.php
Normal file
71
app/Services/Payment/Payone/DTOs/CardUpdateResult.php
Normal 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;
|
||||
}
|
||||
}
|
||||
63
app/Services/Payment/Payone/DTOs/CreditCardCheckData.php
Normal file
63
app/Services/Payment/Payone/DTOs/CreditCardCheckData.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue