value map that must contain a 'status'. * * @param array> $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(); });