Phase S4 – Abos über Stripe (E2E komplett im Testmodus): - Abo-/Recovery-Checkout: customer_creation=always + setup_future_usage=off_session - StripePaymentService::chargeAbo (off-session PaymentIntent, Idempotency-Key), StripeGateway, Provider-Dispatch im Abbuchungs-Cron (PAYONE-Pfad unverändert) - Webhook: Zahlungsmittel-Capture vor Paid-Pfad, 503 payment_not_yet_visible bei Cron-Transaktions-Race; Command stripe:process-pending-events (Cron-Ende + 10-min Scheduler) verarbeitet liegengebliebene Events nach - createNewAbo/advanceAboAfterRecoveryPayment Stripe-aware inkl. Race-Absicherung; Recovery-Orders nutzen advanceAboAfterRecoveryPayment (latenter PAYONE-Duplikat-Bug) - Migrationen: Stripe-Abo-Spalten auf shopping_payments; clearingtype varchar(3)→(20) inkl. Reparatur der zu 'str' gekürzten Live-Zeilen Phase S5 – Zahlungsart ändern (Scope: jede Zahlart, nicht nur Kreditkarte): - Stripe Checkout mode=setup ohne Abbuchung; PAYONE-Abos ziehen beim Wechsel auf Stripe um (Auslaufmodell §7.2) - AboPaymentMethodService: beginStripeChange/finalize/applyStripePaymentMethod (idempotent, detacht alte PM, card_changes-Log); Webhook setup_intent.succeeded als Sicherheitsnetz; Einstiege Portal/CRM/Berater-Kunden-Link flow-abhängig - Views/Mails flow-abhängig („Zahlungsweise" statt „Kreditkarte") Phase S7.6 – Zahlarten-Kommunikation: - Einheitliche Zahlarten-Logos (SVG) + wiederverwendbares Partial payment_logos; Checkout/Footer zeigen Kern-Zahlarten + „···"-Icon, /zahlungsarten komplett neu mit allen aktiven Stripe-Zahlarten (gegen Account-Konfiguration verifiziert) und Vorkasse (PAYONE); Sofort/SEPA entfernt Tests: StripeAboTest (15), StripePaymentMethodChangeTest (10), PaymentMethodsCommunicationTest (28), PaymentProviderManagerTest angepasst. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
304 lines
12 KiB
PHP
304 lines
12 KiB
PHP
<?php
|
|
|
|
use App\Mail\AboCardUpdateLinkMail;
|
|
use App\Models\Setting;
|
|
use App\Models\UserAbo;
|
|
use App\Services\AboPaymentMethodService;
|
|
use App\Services\Payment\PaymentProviderManager;
|
|
use App\Services\Payment\Stripe\StripeClientWrapper;
|
|
use App\Services\Payment\Stripe\StripePaymentService;
|
|
use App\Services\Payment\Stripe\StripeWebhookHandler;
|
|
use App\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Stripe\Checkout\Session;
|
|
use Stripe\Customer;
|
|
use Stripe\StripeClient;
|
|
use Tests\TestCase;
|
|
|
|
uses(TestCase::class, RefreshDatabase::class);
|
|
|
|
beforeEach(function (): void {
|
|
// Der Setup-Flow gilt, wenn der Anbieter-Schalter auf Stripe steht.
|
|
Setting::setContentBySlug(PaymentProviderManager::SETTING_SLUG, 'stripe', 'text');
|
|
});
|
|
|
|
function pmChangeAbo(array $overrides = []): UserAbo
|
|
{
|
|
$owner = User::forceCreate([
|
|
'email' => 'pmc-'.uniqid('', true).'@example.com',
|
|
'password' => bcrypt('secret'),
|
|
'lang' => 'de',
|
|
]);
|
|
|
|
return UserAbo::create(array_merge([
|
|
'user_id' => $owner->id,
|
|
'shopping_user_id' => 1,
|
|
'is_for' => 'me',
|
|
'email' => $owner->email,
|
|
'payone_userid' => 900700,
|
|
'payment_provider' => 'payone',
|
|
'clearingtype' => 'cc',
|
|
'active' => true,
|
|
'status' => 2,
|
|
'abo_interval' => 5,
|
|
'next_date' => now()->addDays(5),
|
|
], $overrides));
|
|
}
|
|
|
|
/**
|
|
* Stripe-Client-Mock für n request()-Aufrufe; Argumente aller Aufrufe werden
|
|
* zur Assertion gesammelt (Muster: StripeAboTest).
|
|
*
|
|
* @param array<int, mixed> $results
|
|
*/
|
|
function pmChangeMockClient(array $results, ?array &$calls = null): void
|
|
{
|
|
$calls = [];
|
|
$client = Mockery::mock(StripeClient::class.'[request]', [['api_key' => 'sk_test_fake']]);
|
|
$client->shouldReceive('request')
|
|
->times(count($results))
|
|
->withArgs(function (...$args) use (&$calls) {
|
|
$calls[] = $args;
|
|
|
|
return true;
|
|
})
|
|
->andReturnValues($results);
|
|
|
|
$wrapper = Mockery::mock(StripeClientWrapper::class);
|
|
$wrapper->shouldReceive('client')->andReturn($client);
|
|
app()->instance(StripeClientWrapper::class, $wrapper);
|
|
}
|
|
|
|
function pmChangeWebhookEvent(string $type, array $object, string $eventId): void
|
|
{
|
|
$payload = json_encode([
|
|
'id' => $eventId,
|
|
'object' => 'event',
|
|
'api_version' => '2026-06-30',
|
|
'type' => $type,
|
|
'data' => ['object' => $object],
|
|
]);
|
|
$timestamp = time();
|
|
$signed = hash_hmac('sha256', $timestamp.'.'.$payload, config('stripe.webhook_secret'));
|
|
|
|
app(StripeWebhookHandler::class)->handle($payload, "t={$timestamp},v1={$signed}");
|
|
}
|
|
|
|
// --- Invariante: freie Zahlartenwahl nur im Stripe-Flow ---
|
|
|
|
it('allows changing the payment method of non-card abos only while the stripe flow is active', function () {
|
|
$abo = pmChangeAbo(['clearingtype' => 'wlt', 'wallettype' => 'PPE']);
|
|
$service = app(AboPaymentMethodService::class);
|
|
|
|
expect($service->canStartChange($abo))->toBeTrue()
|
|
// Model-Invariante bleibt pur: PAYONE-Legacy-Flow nur für CC-Abos
|
|
->and($abo->canUpdatePaymentMethod())->toBeFalse();
|
|
|
|
Setting::setContentBySlug(PaymentProviderManager::SETTING_SLUG, 'payone', 'text');
|
|
|
|
expect($service->canStartChange($abo->fresh()))->toBeFalse();
|
|
});
|
|
|
|
it('never allows changing terminated abos regardless of the flow', function () {
|
|
$abo = pmChangeAbo(['status' => 4]);
|
|
|
|
expect(app(AboPaymentMethodService::class)->canStartChange($abo))->toBeFalse();
|
|
});
|
|
|
|
// --- S5.1: Setup-Session ---
|
|
|
|
it('creates a setup session with abo metadata and sets the pending trace', function () {
|
|
$abo = pmChangeAbo(['payment_provider' => 'stripe', 'stripe_customer_id' => 'cus_existing']);
|
|
|
|
$calls = null;
|
|
pmChangeMockClient([Session::constructFrom([
|
|
'id' => 'cs_setup_1',
|
|
'object' => 'checkout.session',
|
|
'url' => 'https://checkout.stripe.com/c/setup/cs_setup_1',
|
|
'livemode' => false,
|
|
])], $calls);
|
|
|
|
$result = app(AboPaymentMethodService::class)->beginStripeChange(
|
|
$abo,
|
|
'https://portal.test/return/success?session_id={CHECKOUT_SESSION_ID}',
|
|
'https://portal.test/return/cancel',
|
|
'portal',
|
|
);
|
|
|
|
$params = $calls[0][2];
|
|
|
|
expect($result->isRedirect())->toBeTrue()
|
|
->and($result->redirectUrl)->toBe('https://checkout.stripe.com/c/setup/cs_setup_1')
|
|
->and($params['mode'])->toBe('setup')
|
|
->and($params['currency'])->toBe('eur')
|
|
->and($params['customer'])->toBe('cus_existing')
|
|
->and($params['setup_intent_data']['metadata']['user_abo_id'])->toBe((string) $abo->id)
|
|
->and($params['success_url'])->toContain('{CHECKOUT_SESSION_ID}')
|
|
->and($abo->fresh()->card_update_pending_at)->not->toBeNull();
|
|
});
|
|
|
|
it('creates a stripe customer first for payone legacy abos and persists it', function () {
|
|
$abo = pmChangeAbo();
|
|
|
|
pmChangeMockClient([
|
|
Customer::constructFrom(['id' => 'cus_fresh_1', 'object' => 'customer', 'livemode' => false]),
|
|
Session::constructFrom([
|
|
'id' => 'cs_setup_2',
|
|
'object' => 'checkout.session',
|
|
'url' => 'https://checkout.stripe.com/c/setup/cs_setup_2',
|
|
'livemode' => false,
|
|
]),
|
|
]);
|
|
|
|
$result = app(AboPaymentMethodService::class)->beginStripeChange($abo, 'https://x.test/s?session_id={CHECKOUT_SESSION_ID}', 'https://x.test/c');
|
|
|
|
$fresh = $abo->fresh();
|
|
|
|
expect($result->isRedirect())->toBeTrue()
|
|
->and($fresh->stripe_customer_id)->toBe('cus_fresh_1')
|
|
// Provider bleibt PAYONE, bis die neue Zahlungsart wirklich verankert ist
|
|
->and($fresh->payment_provider)->toBe('payone');
|
|
});
|
|
|
|
it('blocks the setup flow while abo payments are frozen', function () {
|
|
Setting::setContentBySlug(PaymentProviderManager::SETTING_ABO_PAYMENTS_FROZEN, 1, 'bool');
|
|
$abo = pmChangeAbo();
|
|
|
|
$result = app(AboPaymentMethodService::class)->beginStripeChange($abo, 'https://x.test/s', 'https://x.test/c');
|
|
|
|
expect($result->isError())->toBeTrue()
|
|
->and($result->errorMessage)->toBe(__('payment.abo_payments_frozen'))
|
|
->and($abo->fresh()->card_update_pending_at)->toBeNull();
|
|
});
|
|
|
|
// --- S5.2: Verankern der neuen Zahlungsart ---
|
|
|
|
it('applies a new payment method and moves a payone abo to stripe', function () {
|
|
$abo = pmChangeAbo(['card_update_pending_at' => now()]);
|
|
|
|
$stripeService = Mockery::mock(StripePaymentService::class);
|
|
$stripeService->shouldReceive('retrievePaymentMethodSummary')
|
|
->once()->with('pm_new_1')
|
|
->andReturn(['type' => 'paypal']);
|
|
$stripeService->shouldNotReceive('detachPaymentMethod');
|
|
app()->instance(StripePaymentService::class, $stripeService);
|
|
|
|
app(AboPaymentMethodService::class)->applyStripePaymentMethod($abo, 'cus_apply_1', 'pm_new_1', 'portal');
|
|
|
|
$fresh = $abo->fresh();
|
|
|
|
expect($fresh->payment_provider)->toBe('stripe')
|
|
->and($fresh->stripe_customer_id)->toBe('cus_apply_1')
|
|
->and($fresh->stripe_payment_method_id)->toBe('pm_new_1')
|
|
->and($fresh->clearingtype)->toBe('stripe')
|
|
->and($fresh->carddata)->toBe(['type' => 'paypal'])
|
|
->and($fresh->card_update_pending_at)->toBeNull()
|
|
->and($fresh->getPaymentType())->toBe(__('payment.paypal'))
|
|
->and($fresh->card_changes()->count())->toBe(1)
|
|
->and($fresh->card_changes()->first()->source)->toBe('portal');
|
|
});
|
|
|
|
it('is idempotent and detaches the previous stripe payment method on a real change', function () {
|
|
$abo = pmChangeAbo([
|
|
'payment_provider' => 'stripe',
|
|
'clearingtype' => 'stripe',
|
|
'stripe_customer_id' => 'cus_apply_2',
|
|
'stripe_payment_method_id' => 'pm_old',
|
|
]);
|
|
|
|
$stripeService = Mockery::mock(StripePaymentService::class);
|
|
$stripeService->shouldReceive('retrievePaymentMethodSummary')
|
|
->once()->with('pm_new_2')
|
|
->andReturn(['type' => 'card', 'brand' => 'visa', 'last4' => '4242', 'exp' => '04/2031', 'wallet' => null]);
|
|
$stripeService->shouldReceive('detachPaymentMethod')->once()->with('pm_old');
|
|
app()->instance(StripePaymentService::class, $stripeService);
|
|
|
|
$service = app(AboPaymentMethodService::class);
|
|
$service->applyStripePaymentMethod($abo, 'cus_apply_2', 'pm_new_2', 'link');
|
|
// Zweiter Aufruf (Webhook nach Return-Pfad): keine zweite Verankerung
|
|
$service->applyStripePaymentMethod($abo->fresh(), 'cus_apply_2', 'pm_new_2', 'stripe-webhook');
|
|
|
|
$fresh = $abo->fresh();
|
|
|
|
expect($fresh->stripe_payment_method_id)->toBe('pm_new_2')
|
|
->and($fresh->card_changes()->count())->toBe(1)
|
|
->and(UserAbo::formatMaskedCard($fresh->carddata))->toBe('Visa •••• 4242 (04/2031)');
|
|
});
|
|
|
|
it('anchors the payment method from the setup_intent succeeded webhook', function () {
|
|
$abo = pmChangeAbo(['card_update_pending_at' => now()]);
|
|
|
|
$stripeService = Mockery::mock(StripePaymentService::class);
|
|
$stripeService->shouldReceive('retrievePaymentMethodSummary')
|
|
->once()->with('pm_hook_1')
|
|
->andReturn(['type' => 'sepa_debit', 'last4' => '3000']);
|
|
app()->instance(StripePaymentService::class, $stripeService);
|
|
|
|
pmChangeWebhookEvent('setup_intent.succeeded', [
|
|
'id' => 'seti_1',
|
|
'object' => 'setup_intent',
|
|
'status' => 'succeeded',
|
|
'customer' => 'cus_hook_1',
|
|
'payment_method' => 'pm_hook_1',
|
|
'livemode' => false,
|
|
'metadata' => ['user_abo_id' => (string) $abo->id, 'source' => 'portal'],
|
|
], 'evt_seti_1');
|
|
|
|
$fresh = $abo->fresh();
|
|
|
|
expect($fresh->payment_provider)->toBe('stripe')
|
|
->and($fresh->stripe_payment_method_id)->toBe('pm_hook_1')
|
|
->and($fresh->card_update_pending_at)->toBeNull()
|
|
->and($fresh->card_changes()->first()->source)->toBe('portal');
|
|
});
|
|
|
|
// --- Kunden-Link-Mail: Texte je Flow ---
|
|
|
|
it('uses payment method wording in the customer link mail while the stripe flow is active', function () {
|
|
$abo = pmChangeAbo();
|
|
|
|
$stripeMail = (new AboCardUpdateLinkMail($abo, 'token-x'))->build();
|
|
|
|
expect($stripeMail->subject)->toBe(__('abo.payment_method_change_link_mail_subject'))
|
|
->and($stripeMail->viewData['mailButton'])->toBe(__('abo.payment_method_change_link_mail_button'));
|
|
|
|
Setting::setContentBySlug(PaymentProviderManager::SETTING_SLUG, 'payone', 'text');
|
|
|
|
$payoneMail = (new AboCardUpdateLinkMail($abo, 'token-x'))->build();
|
|
|
|
expect($payoneMail->subject)->toBe(__('abo.card_update_link_mail_subject'));
|
|
});
|
|
|
|
// --- Return-Pfad ---
|
|
|
|
it('finalizes the change from the return session and rejects foreign sessions', function () {
|
|
$abo = pmChangeAbo(['card_update_pending_at' => now()]);
|
|
|
|
$ownSession = Session::constructFrom([
|
|
'id' => 'cs_ret_1',
|
|
'object' => 'checkout.session',
|
|
'customer' => 'cus_ret_1',
|
|
'metadata' => ['user_abo_id' => (string) $abo->id],
|
|
'setup_intent' => ['id' => 'seti_ret', 'object' => 'setup_intent', 'status' => 'succeeded', 'payment_method' => 'pm_ret_1'],
|
|
]);
|
|
$foreignSession = Session::constructFrom([
|
|
'id' => 'cs_ret_2',
|
|
'object' => 'checkout.session',
|
|
'customer' => 'cus_other',
|
|
'metadata' => ['user_abo_id' => '999999'],
|
|
'setup_intent' => ['id' => 'seti_x', 'object' => 'setup_intent', 'status' => 'succeeded', 'payment_method' => 'pm_x'],
|
|
]);
|
|
|
|
$stripeService = Mockery::mock(StripePaymentService::class);
|
|
$stripeService->shouldReceive('retrieveSetupSession')->with('cs_ret_1')->andReturn($ownSession);
|
|
$stripeService->shouldReceive('retrieveSetupSession')->with('cs_ret_2')->andReturn($foreignSession);
|
|
$stripeService->shouldReceive('retrievePaymentMethodSummary')->once()->andReturn(['type' => 'card', 'brand' => 'visa', 'last4' => '4242']);
|
|
app()->instance(StripePaymentService::class, $stripeService);
|
|
|
|
$service = app(AboPaymentMethodService::class);
|
|
|
|
expect($service->finalizeStripeChangeFromSession($abo, 'cs_ret_2', 'portal'))->toBeFalse()
|
|
->and($abo->fresh()->payment_provider)->toBe('payone')
|
|
->and($service->finalizeStripeChangeFromSession($abo->fresh(), 'cs_ret_1', 'portal'))->toBeTrue()
|
|
->and($abo->fresh()->stripe_payment_method_id)->toBe('pm_ret_1');
|
|
});
|