mivita/app/Services/Payment/Payone/PayonePaymentService.php
Kevin 671022a28d 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>
2026-07-08 15:56:19 +00:00

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