- 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>
177 lines
6.4 KiB
PHP
177 lines
6.4 KiB
PHP
<?php
|
|
|
|
use App\Models\ShoppingUser;
|
|
use App\Models\UserAbo;
|
|
use App\Services\AboPaymentMethodService;
|
|
use App\Services\Payment\Payone\DTOs\CreditCardCheckData;
|
|
use App\Services\Payment\Payone\PayonePaymentService;
|
|
use GuzzleHttp\Client;
|
|
use GuzzleHttp\Handler\MockHandler;
|
|
use GuzzleHttp\HandlerStack;
|
|
use GuzzleHttp\Psr7\Response;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Tests\TestCase;
|
|
|
|
uses(TestCase::class, RefreshDatabase::class);
|
|
|
|
/**
|
|
* Builds a Guzzle client that returns the given PAYONE responses in order.
|
|
* Each entry is a key=>value map that must contain a 'status'.
|
|
*
|
|
* @param array<int, array<string, string|int>> $responses
|
|
*/
|
|
function makePayoneClientSequence(array $responses): Client
|
|
{
|
|
$queue = array_map(function (array $pairs) {
|
|
$body = implode("\n", array_map(fn ($v, $k) => "$k=$v", $pairs, array_keys($pairs)));
|
|
|
|
return new Response(200, ['Content-Type' => 'text/plain;charset=UTF-8'], $body);
|
|
}, $responses);
|
|
|
|
return new Client(['handler' => HandlerStack::create(new MockHandler($queue))]);
|
|
}
|
|
|
|
function makeCardService(Client $client): AboPaymentMethodService
|
|
{
|
|
$payone = new PayonePaymentService(
|
|
defaults: ['mid' => '1', 'aid' => '1', 'portalid' => '1', 'key' => 'abc', 'api_version' => '3.11'],
|
|
client: $client,
|
|
);
|
|
|
|
return new AboPaymentMethodService($payone);
|
|
}
|
|
|
|
function makeCcAbo(int $status = 2): UserAbo
|
|
{
|
|
$shoppingUser = ShoppingUser::create([
|
|
'auth_user_id' => 1,
|
|
'billing_firstname' => 'Max',
|
|
'billing_lastname' => 'Mustermann',
|
|
'billing_address' => 'Musterstraße 1',
|
|
'billing_zipcode' => '12345',
|
|
'billing_city' => 'Musterstadt',
|
|
'billing_country_id' => 1,
|
|
'shipping_country_id' => 1,
|
|
'billing_email' => 'max@example.com',
|
|
'same_as_billing' => true,
|
|
'mode' => 'test',
|
|
'wp_order_number' => 0,
|
|
]);
|
|
|
|
return UserAbo::create([
|
|
'user_id' => 1,
|
|
'shopping_user_id' => $shoppingUser->id,
|
|
'clearingtype' => 'cc',
|
|
'wallettype' => '',
|
|
'payone_userid' => 111,
|
|
'carddata' => ['pseudocardpan' => 'OLD_PSEUDO', 'cardexpiredate' => '2501'],
|
|
'abo_interval' => 5,
|
|
'status' => $status,
|
|
]);
|
|
}
|
|
|
|
function cardForm(): CreditCardCheckData
|
|
{
|
|
return CreditCardCheckData::fromFormData([
|
|
'cc_cardholder_first' => 'Max',
|
|
'cc_cardholder_last' => 'Mustermann',
|
|
'cc_cardpan' => '4111111111111111',
|
|
'cc_cardexpiremonth' => '12',
|
|
'cc_cardexpireyear' => '2030',
|
|
'cc_cardtype' => 'V',
|
|
'cc_cardcvc2' => '123',
|
|
]);
|
|
}
|
|
|
|
it('updates the stored token on a successful preauthorization without charging', function () {
|
|
$client = makePayoneClientSequence([
|
|
['status' => 'VALID', 'pseudocardpan' => 'NEW_PSEUDO', 'cardexpiredate' => '3012', 'truncatedcardpan' => '411111XXXXXX1111'],
|
|
['status' => 'APPROVED', 'txid' => 'TX-PRE', 'userid' => '555'],
|
|
['status' => 'APPROVED', 'txid' => 'TX-CAP'], // release: capture amount=0
|
|
]);
|
|
$abo = makeCcAbo();
|
|
|
|
$result = makeCardService($client)->updateCreditCard($abo, cardForm(), [], 'portal');
|
|
|
|
expect($result->isApproved())->toBeTrue();
|
|
|
|
$abo->refresh();
|
|
expect($abo->carddata['pseudocardpan'])->toBe('NEW_PSEUDO')
|
|
->and($abo->payone_userid)->toEqual(555)
|
|
->and($abo->card_update_pending_at)->toBeNull();
|
|
|
|
// Änderungs-Historie wird protokolliert (maskierte Karte + Quelle).
|
|
expect($abo->card_changes()->count())->toBe(1);
|
|
$change = $abo->card_changes()->first();
|
|
expect($change->masked_card)->toBe('411111XXXXXX1111 (12/30)')
|
|
->and($change->source)->toBe('portal');
|
|
});
|
|
|
|
it('surfaces a 3-D Secure redirect without touching the stored card', function () {
|
|
$client = makePayoneClientSequence([
|
|
['status' => 'VALID', 'pseudocardpan' => 'NEW_PSEUDO', 'cardexpiredate' => '3012'],
|
|
['status' => 'REDIRECT', 'txid' => 'TX-3DS', 'redirecturl' => 'https://acs.example/3ds'],
|
|
]);
|
|
$abo = makeCcAbo();
|
|
|
|
$result = makeCardService($client)->updateCreditCard($abo, cardForm(), [
|
|
'successurl' => 'https://shop/return/success',
|
|
'errorurl' => 'https://shop/return/error',
|
|
'backurl' => 'https://shop/return/cancel',
|
|
]);
|
|
|
|
expect($result->isRedirect())->toBeTrue()
|
|
->and($result->redirectUrl)->toBe('https://acs.example/3ds')
|
|
->and($result->carddata['pseudocardpan'])->toBe('NEW_PSEUDO');
|
|
|
|
$abo->refresh();
|
|
// Karte unverändert; Pending-Spur bleibt für die 3DS-Rückkehr gesetzt.
|
|
expect($abo->carddata['pseudocardpan'])->toBe('OLD_PSEUDO')
|
|
->and($abo->payone_userid)->toEqual(111)
|
|
->and($abo->card_update_pending_at)->not->toBeNull();
|
|
});
|
|
|
|
it('leaves the stored card untouched when the credit card check fails', function () {
|
|
$client = makePayoneClientSequence([
|
|
['status' => 'ERROR', 'errorcode' => '863', 'errormessage' => 'Card declined', 'customermessage' => 'Karte abgelehnt'],
|
|
]);
|
|
$abo = makeCcAbo();
|
|
|
|
$result = makeCardService($client)->updateCreditCard($abo, cardForm(), []);
|
|
|
|
expect($result->isError())->toBeTrue()
|
|
->and($result->errorMessage)->toBe('Karte abgelehnt');
|
|
|
|
$abo->refresh();
|
|
expect($abo->carddata['pseudocardpan'])->toBe('OLD_PSEUDO')
|
|
->and($abo->payone_userid)->toEqual(111)
|
|
->and($abo->card_update_pending_at)->toBeNull();
|
|
});
|
|
|
|
it('leaves the stored card untouched when the preauthorization is declined', function () {
|
|
$client = makePayoneClientSequence([
|
|
['status' => 'VALID', 'pseudocardpan' => 'NEW_PSEUDO', 'cardexpiredate' => '3012'],
|
|
['status' => 'ERROR', 'errorcode' => '307', 'errormessage' => 'Declined', 'customermessage' => 'Abgelehnt'],
|
|
]);
|
|
$abo = makeCcAbo();
|
|
|
|
$result = makeCardService($client)->updateCreditCard($abo, cardForm(), []);
|
|
|
|
expect($result->isError())->toBeTrue();
|
|
|
|
$abo->refresh();
|
|
expect($abo->carddata['pseudocardpan'])->toBe('OLD_PSEUDO')
|
|
->and($abo->card_update_pending_at)->toBeNull();
|
|
});
|
|
|
|
it('refuses to update a non-credit-card abo', function () {
|
|
$client = makePayoneClientSequence([['status' => 'VALID']]); // must not be reached
|
|
$abo = makeCcAbo();
|
|
$abo->forceFill(['clearingtype' => 'wlt'])->save();
|
|
|
|
$result = makeCardService($client)->updateCreditCard($abo, cardForm(), []);
|
|
|
|
expect($result->isError())->toBeTrue();
|
|
$abo->refresh();
|
|
expect($abo->card_update_pending_at)->toBeNull();
|
|
});
|