mivita/tests/Feature/AboPaymentMethodServiceTest.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

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