- 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>
167 lines
6 KiB
PHP
167 lines
6 KiB
PHP
<?php
|
|
|
|
use App\Http\Controllers\User\AboController;
|
|
use App\Http\Controllers\Web\AboCardUpdateController;
|
|
use App\Http\Requests\Abo\UpdateAboCreditCardRequest;
|
|
use App\Mail\AboCardUpdateLinkMail;
|
|
use App\Models\AboCardUpdateLink;
|
|
use App\Models\ShoppingUser;
|
|
use App\Models\UserAbo;
|
|
use App\Repositories\AboRepository;
|
|
use App\Services\AboPaymentMethodService;
|
|
use App\Services\Payment\Payone\PayonePaymentService;
|
|
use App\User;
|
|
use GuzzleHttp\Client;
|
|
use GuzzleHttp\Handler\MockHandler;
|
|
use GuzzleHttp\HandlerStack;
|
|
use GuzzleHttp\Psr7\Response;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Mail;
|
|
use Illuminate\Support\Facades\URL;
|
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
|
use Tests\TestCase;
|
|
|
|
uses(TestCase::class, RefreshDatabase::class);
|
|
|
|
function stubLinkRoutes(): void
|
|
{
|
|
URL::resolveMissingNamedRoutesUsing(
|
|
fn (string $name, array $parameters) => '/'.$name.'/'.implode('/', $parameters)
|
|
);
|
|
}
|
|
|
|
function linkCcAbo(string $isFor = 'ot'): UserAbo
|
|
{
|
|
$shoppingUser = ShoppingUser::create([
|
|
'auth_user_id' => 1,
|
|
'billing_firstname' => 'Karl',
|
|
'billing_lastname' => 'Kunde',
|
|
'billing_address' => 'Weg 1',
|
|
'billing_zipcode' => '11111',
|
|
'billing_city' => 'Stadt',
|
|
'billing_country_id' => 1,
|
|
'shipping_country_id' => 1,
|
|
'billing_email' => 'kunde@example.com',
|
|
'same_as_billing' => true,
|
|
'mode' => 'test',
|
|
'wp_order_number' => 0,
|
|
]);
|
|
|
|
return UserAbo::create([
|
|
'user_id' => 1,
|
|
'member_id' => 1,
|
|
'shopping_user_id' => $shoppingUser->id,
|
|
'email' => 'kunde@example.com',
|
|
'is_for' => $isFor,
|
|
'clearingtype' => 'cc',
|
|
'wallettype' => '',
|
|
'payone_userid' => 111,
|
|
'carddata' => ['pseudocardpan' => 'OLD', 'truncatedcardpan' => '411111XXXXXX0000', 'cardexpiredate' => '2501'],
|
|
'abo_interval' => 5,
|
|
'status' => 2,
|
|
]);
|
|
}
|
|
|
|
// --- Model / token hashing ---
|
|
|
|
it('stores only the SHA-256 hash of the token, never the plaintext', function () {
|
|
$abo = linkCcAbo();
|
|
[$link, $token] = AboCardUpdateLink::generateFor($abo, 7);
|
|
|
|
expect($link->token_hash)->toBe(hash('sha256', $token))
|
|
->and($link->token_hash)->not->toBe($token)
|
|
->and($link->created_by_user_id)->toBe(7)
|
|
->and($link->isRedeemable())->toBeTrue();
|
|
|
|
// Plaintext-Token darf nirgends in der DB stehen.
|
|
expect(AboCardUpdateLink::where('token_hash', $token)->exists())->toBeFalse();
|
|
});
|
|
|
|
it('treats expired and used links as not redeemable', function () {
|
|
$abo = linkCcAbo();
|
|
[$expired] = AboCardUpdateLink::generateFor($abo);
|
|
$expired->forceFill(['expires_at' => now()->subHour()])->save();
|
|
expect($expired->fresh()->isRedeemable())->toBeFalse();
|
|
|
|
[$used] = AboCardUpdateLink::generateFor($abo);
|
|
$used->forceFill(['used_at' => now()])->save();
|
|
expect($used->fresh()->isRedeemable())->toBeFalse();
|
|
});
|
|
|
|
// --- Login-free controller ---
|
|
|
|
it('returns 410 Gone for an unknown, expired or used token', function () {
|
|
stubLinkRoutes();
|
|
expect(fn () => (new AboCardUpdateController)->show('does-not-exist'))
|
|
->toThrow(HttpException::class);
|
|
});
|
|
|
|
it('applies the new card and marks the link used on a successful submission', function () {
|
|
stubLinkRoutes();
|
|
$abo = linkCcAbo();
|
|
[$link, $token] = AboCardUpdateLink::generateFor($abo);
|
|
|
|
$ct = ['Content-Type' => 'text/plain;charset=UTF-8'];
|
|
$mock = new MockHandler([
|
|
new Response(200, $ct, "status=VALID\npseudocardpan=NEW_LINK\ncardexpiredate=3012\ntruncatedcardpan=555555XXXXXX9999"),
|
|
new Response(200, $ct, "status=APPROVED\ntxid=TX1\nuserid=999"),
|
|
new Response(200, $ct, 'status=APPROVED'), // release capture amount=0
|
|
]);
|
|
$client = new Client(['handler' => HandlerStack::create($mock)]);
|
|
$service = new AboPaymentMethodService(new PayonePaymentService(
|
|
defaults: ['mid' => '1', 'aid' => '1', 'portalid' => '1', 'key' => 'abc', 'api_version' => '3.11'],
|
|
client: $client,
|
|
));
|
|
|
|
$request = UpdateAboCreditCardRequest::create('/abo/karte/'.$token, 'POST', [
|
|
'cc_cardholder_first' => 'Karl',
|
|
'cc_cardholder_last' => 'Kunde',
|
|
'cc_cardpan' => '4111111111111111',
|
|
'cc_cardexpiremonth' => '12',
|
|
'cc_cardexpireyear' => '2030',
|
|
'cc_cardtype' => 'V',
|
|
'cc_cardcvc2' => '123',
|
|
]);
|
|
$request->setContainer(app());
|
|
$request->validateResolved();
|
|
|
|
(new AboCardUpdateController)->store($request, $token, $service);
|
|
|
|
$abo->refresh();
|
|
expect($abo->carddata['pseudocardpan'])->toBe('NEW_LINK')
|
|
->and($abo->payone_userid)->toEqual(999)
|
|
->and($abo->card_changes()->first()->source)->toBe('link')
|
|
->and($link->fresh()->used_at)->not->toBeNull();
|
|
});
|
|
|
|
// --- CRM: consultant sends link ---
|
|
|
|
it('lets a consultant send a card update link for a customer abo', function () {
|
|
stubLinkRoutes();
|
|
Mail::fake();
|
|
|
|
$berater = User::forceCreate(['email' => 'b-'.uniqid().'@example.com', 'password' => bcrypt('x'), 'lang' => 'de']);
|
|
$abo = linkCcAbo('ot');
|
|
$abo->forceFill(['user_id' => $berater->id, 'member_id' => $berater->id])->save();
|
|
|
|
$this->actingAs($berater);
|
|
(new AboController(app(AboRepository::class)))->sendCardUpdateLink('ot', $abo->id);
|
|
|
|
Mail::assertSent(AboCardUpdateLinkMail::class);
|
|
expect(AboCardUpdateLink::where('user_abo_id', $abo->id)->exists())->toBeTrue();
|
|
});
|
|
|
|
it('does not send a link for the consultants own abo (me)', function () {
|
|
stubLinkRoutes();
|
|
Mail::fake();
|
|
|
|
$berater = User::forceCreate(['email' => 'b-'.uniqid().'@example.com', 'password' => bcrypt('x'), 'lang' => 'de']);
|
|
$abo = linkCcAbo('me');
|
|
$abo->forceFill(['user_id' => $berater->id, 'member_id' => $berater->id])->save();
|
|
|
|
$this->actingAs($berater);
|
|
(new AboController(app(AboRepository::class)))->sendCardUpdateLink('me', $abo->id);
|
|
|
|
Mail::assertNothingSent();
|
|
expect(AboCardUpdateLink::where('user_abo_id', $abo->id)->exists())->toBeFalse();
|
|
});
|