- 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>
227 lines
8.5 KiB
PHP
227 lines
8.5 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Payment\Payone;
|
|
|
|
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.
|
|
*/
|
|
public function __construct(
|
|
private readonly array $defaults,
|
|
private readonly ?Client $client = null,
|
|
) {}
|
|
|
|
public function checkCreditCard(array $data, string $mode): PayonePaymentResult
|
|
{
|
|
$request = array_merge($this->defaults, ['mode' => $mode], [
|
|
'request' => 'creditcardcheck',
|
|
'cardholder' => $data['cc_cardholder_first'].' '.$data['cc_cardholder_last'],
|
|
'cardpan' => $data['cc_cardpan'],
|
|
'cardexpiredate' => substr($data['cc_cardexpireyear'], -2).$data['cc_cardexpiremonth'],
|
|
'cardtype' => $data['cc_cardtype'],
|
|
'cardcvc2' => $data['cc_cardcvc2'],
|
|
'storecarddata' => 'yes',
|
|
'language' => 'de',
|
|
]);
|
|
|
|
return PayonePaymentResult::fromResponse(Payone::sendRequest($request, '', $this->client));
|
|
}
|
|
|
|
public function checkBankAccount(
|
|
array $data,
|
|
int $amount,
|
|
string $currency,
|
|
ShoppingUser $shoppingUser,
|
|
string $mode,
|
|
): PayonePaymentResult {
|
|
$defaults = array_merge($this->defaults, ['mode' => $mode]);
|
|
$personalData = PayoneRequestBuilder::buildPersonalData($shoppingUser);
|
|
$methodParams = [
|
|
'clearingtype' => 'elv',
|
|
'request' => 'managemandate',
|
|
'bankaccountholder' => $data['elv_bankaccountholder'],
|
|
'iban' => $data['elv_iban'],
|
|
'bic' => $data['elv_bic'],
|
|
];
|
|
$prepayment = [
|
|
'amount' => $amount,
|
|
'currency' => $currency,
|
|
];
|
|
|
|
$request = PayoneRequestBuilder::buildRequest($defaults, $personalData, [], $methodParams, $prepayment);
|
|
|
|
return PayonePaymentResult::fromResponse(Payone::sendRequest($request, '', $this->client));
|
|
}
|
|
|
|
/**
|
|
* Charges a recurring abo payment: creates the ShoppingPayment record, builds the
|
|
* PAYONE authorization request, and returns the result with the ShoppingPayment attached.
|
|
*/
|
|
public function chargeAbo(
|
|
UserAbo $userAbo,
|
|
ShoppingOrder $shoppingOrder,
|
|
ShoppingUser $shoppingUser,
|
|
int $amountCents,
|
|
string $currency,
|
|
): PayonePaymentResult {
|
|
$reference = substr(uniqid('m', false), 0, 16);
|
|
|
|
$method = [
|
|
'clearingtype' => $userAbo->clearingtype,
|
|
'wallettype' => $userAbo->wallettype ?? '',
|
|
'pseudocardpan' => $userAbo->carddata['pseudocardpan'] ?? '',
|
|
'cardexpiredate' => $userAbo->carddata['cardexpiredate'] ?? '',
|
|
'userid' => $userAbo->payone_userid,
|
|
'onlinebanktransfertype' => '',
|
|
'request' => 'authorization',
|
|
];
|
|
|
|
$aboInitPayment = [
|
|
'recurrence' => 'recurring',
|
|
'customer_is_present' => 'no',
|
|
'request' => 'authorization',
|
|
'amount' => $amountCents,
|
|
];
|
|
|
|
$prepayment = [
|
|
'reference' => $reference,
|
|
'amount' => $amountCents,
|
|
'currency' => $currency,
|
|
'param' => $shoppingOrder->id,
|
|
];
|
|
|
|
$shoppingPayment = ShoppingPayment::create([
|
|
'shopping_order_id' => $shoppingOrder->id,
|
|
'clearingtype' => $method['clearingtype'],
|
|
'wallettype' => $method['wallettype'],
|
|
'onlinebanktransfertype' => $method['onlinebanktransfertype'],
|
|
'carddata' => $userAbo->carddata,
|
|
'reference' => $reference,
|
|
'amount' => $amountCents,
|
|
'currency' => $currency,
|
|
'is_abo' => $shoppingOrder->is_abo,
|
|
'abo_interval' => 0,
|
|
'mode' => $shoppingOrder->mode,
|
|
]);
|
|
|
|
$defaults = array_merge($this->defaults, ['mode' => $shoppingOrder->mode]);
|
|
$personalData = PayoneRequestBuilder::buildPersonalData($shoppingUser);
|
|
|
|
$request = PayoneRequestBuilder::buildRequest(
|
|
$defaults, $personalData, [], $method, $prepayment, $aboInitPayment,
|
|
);
|
|
|
|
$response = Payone::sendRequest($request, '', $this->client);
|
|
|
|
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));
|
|
}
|
|
}
|