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>
606 lines
21 KiB
PHP
606 lines
21 KiB
PHP
<?php
|
|
|
|
use App\Cron\UserMakeOrder;
|
|
use App\Models\PaymentTransaction;
|
|
use App\Models\ShippingCountry;
|
|
use App\Models\ShoppingOrder;
|
|
use App\Models\ShoppingPayment;
|
|
use App\Models\ShoppingUser;
|
|
use App\Models\StripeWebhookEvent;
|
|
use App\Models\UserAbo;
|
|
use App\Models\UserAboOrder;
|
|
use App\Models\UserShop;
|
|
use App\Services\AboHelper;
|
|
use App\Services\Payment\Stripe\DTOs\StripeWebhookResult;
|
|
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 Illuminate\Support\Facades\Mail;
|
|
use Stripe\Checkout\Session;
|
|
use Stripe\Exception\CardException;
|
|
use Stripe\PaymentIntent;
|
|
use Stripe\StripeClient;
|
|
use Tests\TestCase;
|
|
|
|
uses(TestCase::class, RefreshDatabase::class);
|
|
|
|
beforeEach(function (): void {
|
|
Mail::fake();
|
|
});
|
|
|
|
/**
|
|
* Order-Fixture für eine 89,00-EUR-Abo-Bestellung (Plan S4).
|
|
*
|
|
* @return array{0: ShoppingOrder, 1: ShoppingUser}
|
|
*/
|
|
function stripeAboOrderBootstrap(array $orderOverrides = []): array
|
|
{
|
|
$country = ShippingCountry::first() ?? ShippingCountry::create(['shipping_id' => 1, 'country_id' => 1]);
|
|
|
|
$shopOwner = User::forceCreate([
|
|
'email' => 'sa-owner-'.uniqid('', true).'@example.com',
|
|
'password' => bcrypt('secret'),
|
|
'lang' => 'de',
|
|
]);
|
|
|
|
$userShop = UserShop::first() ?? UserShop::create([
|
|
'user_id' => $shopOwner->id,
|
|
'name' => 'SA Shop',
|
|
'slug' => 'sa-shop-'.uniqid(),
|
|
'domain' => 'sa-test.local',
|
|
]);
|
|
|
|
$shoppingUser = ShoppingUser::create([
|
|
'auth_user_id' => $shopOwner->id,
|
|
'is_for' => 'me',
|
|
'billing_firstname' => 'Stripe',
|
|
'billing_lastname' => 'Abo',
|
|
'billing_email' => 'stripe-abo-'.uniqid().'@example.com',
|
|
'billing_country_id' => $country->id,
|
|
'shipping_country_id' => $country->id,
|
|
]);
|
|
|
|
$order = ShoppingOrder::create(array_merge([
|
|
'shopping_user_id' => $shoppingUser->id,
|
|
'auth_user_id' => $shopOwner->id,
|
|
'country_id' => $country->id,
|
|
'user_shop_id' => $userShop->id,
|
|
'total' => 89.00,
|
|
'total_shipping' => 89.00,
|
|
'paid' => false,
|
|
'txaction' => null,
|
|
'mode' => 'test',
|
|
'is_abo' => 1,
|
|
'abo_interval' => 5,
|
|
], $orderOverrides));
|
|
|
|
return [$order, $shoppingUser];
|
|
}
|
|
|
|
function stripeAboUserAbo(array $overrides = []): UserAbo
|
|
{
|
|
$consultant = User::forceCreate([
|
|
'email' => 'sa-abo-'.uniqid('', true).'@example.com',
|
|
'password' => bcrypt('secret'),
|
|
'lang' => 'de',
|
|
]);
|
|
|
|
return UserAbo::create(array_merge([
|
|
'user_id' => $consultant->id,
|
|
'shopping_user_id' => 1,
|
|
'is_for' => 'me',
|
|
'email' => $consultant->email,
|
|
'payone_userid' => 0,
|
|
'payment_provider' => 'stripe',
|
|
'stripe_customer_id' => 'cus_abo_1',
|
|
'stripe_payment_method_id' => 'pm_abo_1',
|
|
'clearingtype' => 'stripe',
|
|
'active' => true,
|
|
'status' => 2,
|
|
'abo_interval' => 5,
|
|
'next_date' => now()->addDays(3),
|
|
], $overrides));
|
|
}
|
|
|
|
/**
|
|
* Stripe-Client-Mock, der genau einen request()-Aufruf beantwortet und die
|
|
* Argumente zur Assertion herausreicht (Muster: StripeCheckoutTest).
|
|
*/
|
|
function stripeAboMockClient(mixed $result, ?array &$captured = null): void
|
|
{
|
|
$client = Mockery::mock(StripeClient::class.'[request]', [['api_key' => 'sk_test_fake']]);
|
|
$expectation = $client->shouldReceive('request')
|
|
->once()
|
|
->withArgs(function (...$args) use (&$captured) {
|
|
$captured = $args;
|
|
|
|
return true;
|
|
});
|
|
|
|
if ($result instanceof Throwable) {
|
|
$expectation->andThrow($result);
|
|
} else {
|
|
$expectation->andReturn($result);
|
|
}
|
|
|
|
$wrapper = Mockery::mock(StripeClientWrapper::class);
|
|
$wrapper->shouldReceive('client')->andReturn($client);
|
|
app()->instance(StripeClientWrapper::class, $wrapper);
|
|
}
|
|
|
|
function handleStripeAboEvent(string $type, array $object, string $eventId): StripeWebhookResult
|
|
{
|
|
$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'));
|
|
|
|
return app(StripeWebhookHandler::class)->handle($payload, "t={$timestamp},v1={$signed}");
|
|
}
|
|
|
|
// --- S4.1: Abo-Erstzahlung speichert das Zahlungsmittel ---
|
|
|
|
it('requests off-session saving and customer creation for abo checkout sessions', function () {
|
|
[$order, $shoppingUser] = stripeAboOrderBootstrap();
|
|
|
|
$captured = null;
|
|
stripeAboMockClient(Session::constructFrom([
|
|
'id' => 'cs_abo_1',
|
|
'object' => 'checkout.session',
|
|
'url' => 'https://checkout.stripe.com/c/pay/cs_abo_1',
|
|
'status' => 'open',
|
|
'livemode' => false,
|
|
]), $captured);
|
|
|
|
app(StripePaymentService::class)->initiateCheckoutPayment(
|
|
$shoppingUser,
|
|
$order,
|
|
8900,
|
|
'EUR',
|
|
'aref123',
|
|
'https://checkout.test/transaction/status/success/aref123',
|
|
'https://checkout.test/transaction/status/cancel/aref123',
|
|
);
|
|
|
|
$params = $captured[2];
|
|
|
|
expect($params['customer_creation'])->toBe('always')
|
|
->and($params['payment_intent_data']['setup_future_usage'])->toBe('off_session');
|
|
});
|
|
|
|
it('does not request off-session saving for one-time checkout sessions', function () {
|
|
[$order, $shoppingUser] = stripeAboOrderBootstrap(['is_abo' => 0, 'abo_interval' => 0]);
|
|
|
|
$captured = null;
|
|
stripeAboMockClient(Session::constructFrom([
|
|
'id' => 'cs_once_1',
|
|
'object' => 'checkout.session',
|
|
'url' => 'https://checkout.stripe.com/c/pay/cs_once_1',
|
|
'status' => 'open',
|
|
'livemode' => false,
|
|
]), $captured);
|
|
|
|
app(StripePaymentService::class)->initiateCheckoutPayment(
|
|
$shoppingUser,
|
|
$order,
|
|
8900,
|
|
'EUR',
|
|
'oref123',
|
|
'https://s.test/ok',
|
|
'https://s.test/cancel',
|
|
);
|
|
|
|
$params = $captured[2];
|
|
|
|
expect($params)->not->toHaveKey('customer_creation')
|
|
->and($params['payment_intent_data'])->not->toHaveKey('setup_future_usage');
|
|
});
|
|
|
|
// --- S4.2: Abo-Anlage über den Webhook-Paid-Pfad ---
|
|
|
|
it('creates the user abo with stripe fields from the webhook paid path', function () {
|
|
[$order] = stripeAboOrderBootstrap();
|
|
$payment = ShoppingPayment::create([
|
|
'shopping_order_id' => $order->id,
|
|
'payment_provider' => 'stripe',
|
|
'clearingtype' => 'stripe',
|
|
'reference' => 'sa-ref-'.uniqid(),
|
|
'amount' => 8900,
|
|
'currency' => 'EUR',
|
|
'is_abo' => true,
|
|
'abo_interval' => 5,
|
|
'mode' => 'test',
|
|
]);
|
|
|
|
handleStripeAboEvent('payment_intent.succeeded', [
|
|
'id' => 'pi_abo_paid',
|
|
'object' => 'payment_intent',
|
|
'status' => 'succeeded',
|
|
'amount_received' => 8900,
|
|
'customer' => 'cus_new_1',
|
|
'payment_method' => 'pm_new_1',
|
|
'livemode' => false,
|
|
'metadata' => ['shopping_payment_id' => (string) $payment->id],
|
|
], 'evt_abo_paid');
|
|
|
|
$abo = $order->fresh()->getUserAbo();
|
|
|
|
expect($order->fresh()->paid)->toBeTruthy()
|
|
->and($abo)->not->toBeNull()
|
|
->and($abo->payment_provider)->toBe('stripe')
|
|
->and($abo->stripe_customer_id)->toBe('cus_new_1')
|
|
->and($abo->stripe_payment_method_id)->toBe('pm_new_1')
|
|
->and((int) $abo->payone_userid)->toBe(0)
|
|
->and($payment->fresh()->stripe_customer_id)->toBe('cus_new_1');
|
|
});
|
|
|
|
it('defers abo creation on browser return until stripe ids arrived via webhook', function () {
|
|
[$order] = stripeAboOrderBootstrap();
|
|
$payment = ShoppingPayment::create([
|
|
'shopping_order_id' => $order->id,
|
|
'payment_provider' => 'stripe',
|
|
'clearingtype' => 'stripe',
|
|
'reference' => 'sa-ref-'.uniqid(),
|
|
'amount' => 8900,
|
|
'currency' => 'EUR',
|
|
'is_abo' => true,
|
|
'abo_interval' => 5,
|
|
'mode' => 'test',
|
|
]);
|
|
|
|
// Browser-Return läuft vor dem Webhook: noch keine Stripe-IDs an der Zahlung
|
|
AboHelper::createNewAbo($payment);
|
|
|
|
expect(UserAboOrder::where('shopping_order_id', $order->id)->exists())->toBeFalse();
|
|
|
|
// Webhook liefert die IDs und legt das Abo vollständig an
|
|
handleStripeAboEvent('payment_intent.succeeded', [
|
|
'id' => 'pi_abo_late',
|
|
'object' => 'payment_intent',
|
|
'status' => 'succeeded',
|
|
'amount_received' => 8900,
|
|
'customer' => 'cus_late_1',
|
|
'payment_method' => 'pm_late_1',
|
|
'livemode' => false,
|
|
'metadata' => ['shopping_payment_id' => (string) $payment->id],
|
|
], 'evt_abo_late');
|
|
|
|
$abo = $order->fresh()->getUserAbo();
|
|
|
|
expect($abo)->not->toBeNull()
|
|
->and($abo->stripe_payment_method_id)->toBe('pm_late_1');
|
|
});
|
|
|
|
it('never creates a new abo for recurring cron charges even when the webhook wins the race', function () {
|
|
$userAbo = stripeAboUserAbo();
|
|
[$order] = stripeAboOrderBootstrap();
|
|
$payment = ShoppingPayment::create([
|
|
'shopping_order_id' => $order->id,
|
|
'payment_provider' => 'stripe',
|
|
'clearingtype' => 'stripe',
|
|
'reference' => 'sa-ref-'.uniqid(),
|
|
'amount' => 8900,
|
|
'currency' => 'EUR',
|
|
'is_abo' => true,
|
|
'abo_interval' => 5,
|
|
'mode' => 'test',
|
|
'user_abo_id' => $userAbo->id,
|
|
]);
|
|
|
|
$abosBefore = UserAbo::count();
|
|
|
|
handleStripeAboEvent('payment_intent.succeeded', [
|
|
'id' => 'pi_recurring',
|
|
'object' => 'payment_intent',
|
|
'status' => 'succeeded',
|
|
'amount_received' => 8900,
|
|
'customer' => 'cus_abo_1',
|
|
'payment_method' => 'pm_abo_1',
|
|
'livemode' => false,
|
|
'metadata' => ['shopping_payment_id' => (string) $payment->id, 'user_abo_id' => (string) $userAbo->id],
|
|
], 'evt_recurring');
|
|
|
|
expect($order->fresh()->paid)->toBeTruthy()
|
|
->and(UserAbo::count())->toBe($abosBefore);
|
|
});
|
|
|
|
// --- S4 / §7.2 Nr. 3: Recovery-Zahlung zieht das Abo auf Stripe um ---
|
|
|
|
it('advances a payone abo to stripe after a recovery payment via webhook', function () {
|
|
$userAbo = stripeAboUserAbo([
|
|
'payment_provider' => 'payone',
|
|
'payone_userid' => 900500,
|
|
'clearingtype' => 'cc',
|
|
'stripe_customer_id' => null,
|
|
'stripe_payment_method_id' => null,
|
|
'status' => 3,
|
|
]);
|
|
|
|
[$order] = stripeAboOrderBootstrap(['recovery_user_abo_id' => $userAbo->id]);
|
|
$payment = ShoppingPayment::create([
|
|
'shopping_order_id' => $order->id,
|
|
'payment_provider' => 'stripe',
|
|
'clearingtype' => 'stripe',
|
|
'reference' => 'sa-ref-'.uniqid(),
|
|
'amount' => 8900,
|
|
'currency' => 'EUR',
|
|
'is_abo' => true,
|
|
'abo_interval' => 5,
|
|
'mode' => 'test',
|
|
]);
|
|
|
|
handleStripeAboEvent('payment_intent.succeeded', [
|
|
'id' => 'pi_recovery',
|
|
'object' => 'payment_intent',
|
|
'status' => 'succeeded',
|
|
'amount_received' => 8900,
|
|
'customer' => 'cus_rec_1',
|
|
'payment_method' => 'pm_rec_1',
|
|
'livemode' => false,
|
|
'metadata' => ['shopping_payment_id' => (string) $payment->id],
|
|
], 'evt_recovery');
|
|
|
|
$fresh = $userAbo->fresh();
|
|
|
|
expect($order->fresh()->paid)->toBeTruthy()
|
|
->and($fresh->payment_provider)->toBe('stripe')
|
|
->and($fresh->stripe_customer_id)->toBe('cus_rec_1')
|
|
->and($fresh->stripe_payment_method_id)->toBe('pm_rec_1')
|
|
->and($fresh->status)->toBe(2)
|
|
->and(UserAbo::count())->toBe(1)
|
|
->and(UserAboOrder::where('shopping_order_id', $order->id)->where('paid', true)->exists())->toBeTrue();
|
|
});
|
|
|
|
// --- S4.3/S4.5: Wiederkehrende Abbuchung off-session ---
|
|
|
|
it('charges a stripe abo off-session with an idempotency key and records the transaction', function () {
|
|
$userAbo = stripeAboUserAbo();
|
|
[$order, $shoppingUser] = stripeAboOrderBootstrap();
|
|
|
|
$captured = null;
|
|
stripeAboMockClient(PaymentIntent::constructFrom([
|
|
'id' => 'pi_charge_1',
|
|
'object' => 'payment_intent',
|
|
'status' => 'succeeded',
|
|
'amount' => 8900,
|
|
'customer' => 'cus_abo_1',
|
|
'payment_method' => 'pm_abo_1',
|
|
'livemode' => false,
|
|
]), $captured);
|
|
|
|
$result = app(StripePaymentService::class)->chargeAbo($userAbo, $order, $shoppingUser, 8900, 'EUR');
|
|
|
|
$params = $captured[2];
|
|
$opts = $captured[3] ?? null;
|
|
$payment = $result->shoppingPayment->fresh();
|
|
|
|
expect($result->success)->toBeTrue()
|
|
->and($result->status)->toBe('APPROVED')
|
|
->and($result->transactionId)->toBe('pi_charge_1')
|
|
->and($params['customer'])->toBe('cus_abo_1')
|
|
->and($params['payment_method'])->toBe('pm_abo_1')
|
|
->and($params['off_session'])->toBeTrue()
|
|
->and($params['confirm'])->toBeTrue()
|
|
->and($params['metadata']['user_abo_id'])->toBe((string) $userAbo->id)
|
|
->and($opts['idempotency_key'] ?? null)->toBe('abo-'.$userAbo->id.'-order-'.$order->id)
|
|
->and($payment->payment_provider)->toBe('stripe')
|
|
->and($payment->user_abo_id)->toBe($userAbo->id)
|
|
->and($payment->external_reference)->toBe('pi_charge_1')
|
|
->and(PaymentTransaction::where('shopping_payment_id', $payment->id)->where('request', 'authorization')->exists())->toBeTrue();
|
|
});
|
|
|
|
it('maps a card decline to an error result with customer message', function () {
|
|
$userAbo = stripeAboUserAbo();
|
|
[$order, $shoppingUser] = stripeAboOrderBootstrap();
|
|
|
|
stripeAboMockClient(CardException::factory(
|
|
'Your card was declined.',
|
|
402,
|
|
null,
|
|
['error' => ['code' => 'card_declined', 'message' => 'Your card was declined.', 'decline_code' => 'generic_decline', 'type' => 'card_error']],
|
|
));
|
|
|
|
$result = app(StripePaymentService::class)->chargeAbo($userAbo, $order, $shoppingUser, 8900, 'EUR');
|
|
|
|
expect($result->success)->toBeFalse()
|
|
->and($result->status)->toBe('ERROR')
|
|
->and($result->errorMessage)->toContain('card_declined')
|
|
->and($result->customerMessage)->toBe(__('payment.payment_error_reasons.card_declined'))
|
|
->and(PaymentTransaction::where('shopping_payment_id', $result->shoppingPayment->id)->where('errorcode', 'card_declined')->exists())->toBeTrue();
|
|
});
|
|
|
|
it('fails without touching stripe when the abo has no stored payment method', function () {
|
|
$userAbo = stripeAboUserAbo(['stripe_payment_method_id' => null]);
|
|
[$order, $shoppingUser] = stripeAboOrderBootstrap();
|
|
|
|
$result = app(StripePaymentService::class)->chargeAbo($userAbo, $order, $shoppingUser, 8900, 'EUR');
|
|
|
|
expect($result->success)->toBeFalse()
|
|
->and($result->status)->toBe('ERROR')
|
|
->and($result->customerMessage)->toBe(__('payment.stripe_abo_payment_method_missing'))
|
|
->and($result->shoppingPayment)->toBeNull();
|
|
});
|
|
|
|
// --- S4.4: Provider-Dispatch im Cron ---
|
|
|
|
it('dispatches the cron payment of a stripe abo through the stripe gateway', function () {
|
|
$userAbo = stripeAboUserAbo();
|
|
[$order, $shoppingUser] = stripeAboOrderBootstrap();
|
|
|
|
stripeAboMockClient(PaymentIntent::constructFrom([
|
|
'id' => 'pi_cron_1',
|
|
'object' => 'payment_intent',
|
|
'status' => 'succeeded',
|
|
'livemode' => false,
|
|
]));
|
|
|
|
$userOrder = new UserMakeOrder($userAbo);
|
|
$reflection = new ReflectionClass($userOrder);
|
|
foreach (['shopping_user' => $shoppingUser, 'shopping_order' => $order] as $property => $value) {
|
|
$prop = $reflection->getProperty($property);
|
|
$prop->setAccessible(true);
|
|
$prop->setValue($userOrder, $value);
|
|
}
|
|
|
|
$response = $userOrder->makePayment();
|
|
|
|
expect($response['status'])->toBe('APPROVED')
|
|
->and($response['provider'])->toBe('stripe')
|
|
->and($response['txid'])->toBe('pi_cron_1')
|
|
->and($userOrder->getShoppingPayment()->payment_provider)->toBe('stripe');
|
|
});
|
|
|
|
it('returns an error response for a declined cron charge so the cron sets abo status 3', function () {
|
|
$userAbo = stripeAboUserAbo();
|
|
[$order, $shoppingUser] = stripeAboOrderBootstrap();
|
|
|
|
stripeAboMockClient(CardException::factory(
|
|
'Authentication required.',
|
|
402,
|
|
null,
|
|
['error' => ['code' => 'authentication_required', 'message' => 'Authentication required.', 'type' => 'card_error']],
|
|
));
|
|
|
|
$userOrder = new UserMakeOrder($userAbo);
|
|
$reflection = new ReflectionClass($userOrder);
|
|
foreach (['shopping_user' => $shoppingUser, 'shopping_order' => $order] as $property => $value) {
|
|
$prop = $reflection->getProperty($property);
|
|
$prop->setAccessible(true);
|
|
$prop->setValue($userOrder, $value);
|
|
}
|
|
|
|
$response = $userOrder->makePayment();
|
|
|
|
expect($response['status'])->toBe('ERROR')
|
|
->and($response['customermessage'])->toBe(__('payment.stripe_abo_authentication_required'));
|
|
});
|
|
|
|
// --- Webhook-Race: Cron-Transaktion noch nicht committet ---
|
|
|
|
it('requests a stripe retry when our payment is not yet visible and processes it later', function () {
|
|
[$order] = stripeAboOrderBootstrap();
|
|
|
|
$object = [
|
|
'id' => 'pi_race',
|
|
'object' => 'payment_intent',
|
|
'status' => 'succeeded',
|
|
'amount_received' => 8900,
|
|
'customer' => 'cus_abo_1',
|
|
'payment_method' => 'pm_abo_1',
|
|
'livemode' => false,
|
|
'metadata' => ['shopping_payment_id' => '999999'],
|
|
];
|
|
|
|
$first = handleStripeAboEvent('payment_intent.succeeded', $object, 'evt_race');
|
|
|
|
expect($first->statusCode)->toBe(503)
|
|
->and($first->state)->toBe('payment_not_yet_visible')
|
|
->and(StripeWebhookEvent::whereEventId('evt_race')->first()->processed_at)->toBeNull();
|
|
|
|
// Zahlung wird sichtbar (Cron-Commit), Stripe stellt erneut zu
|
|
ShoppingPayment::forceCreate([
|
|
'id' => 999999,
|
|
'shopping_order_id' => $order->id,
|
|
'payment_provider' => 'stripe',
|
|
'clearingtype' => 'stripe',
|
|
'reference' => 'sa-ref-race',
|
|
'amount' => 8900,
|
|
'currency' => 'EUR',
|
|
'mode' => 'test',
|
|
'user_abo_id' => stripeAboUserAbo()->id,
|
|
]);
|
|
|
|
$second = handleStripeAboEvent('payment_intent.succeeded', $object, 'evt_race');
|
|
|
|
expect($second->statusCode)->toBe(200)
|
|
->and($second->state)->toBe('processed')
|
|
->and($order->fresh()->paid)->toBeTruthy();
|
|
});
|
|
|
|
it('processes pending stored events via the maintenance command once the payment is visible', function () {
|
|
[$order] = stripeAboOrderBootstrap();
|
|
$payment = ShoppingPayment::create([
|
|
'shopping_order_id' => $order->id,
|
|
'payment_provider' => 'stripe',
|
|
'clearingtype' => 'stripe',
|
|
'reference' => 'sa-ref-'.uniqid(),
|
|
'amount' => 8900,
|
|
'currency' => 'EUR',
|
|
'is_abo' => true,
|
|
'abo_interval' => 5,
|
|
'mode' => 'test',
|
|
'user_abo_id' => stripeAboUserAbo()->id,
|
|
]);
|
|
|
|
// Wie nach einem 503: Event liegt gespeichert, aber unverarbeitet im Store
|
|
StripeWebhookEvent::create([
|
|
'event_id' => 'evt_pending_cmd',
|
|
'type' => 'payment_intent.succeeded',
|
|
'payload' => [
|
|
'id' => 'pi_pending_cmd',
|
|
'object' => 'payment_intent',
|
|
'status' => 'succeeded',
|
|
'amount_received' => 8900,
|
|
'customer' => 'cus_abo_1',
|
|
'payment_method' => 'pm_abo_1',
|
|
'livemode' => false,
|
|
'metadata' => ['shopping_payment_id' => (string) $payment->id],
|
|
],
|
|
]);
|
|
|
|
// Nicht zuordenbares Event bleibt liegen
|
|
StripeWebhookEvent::create([
|
|
'event_id' => 'evt_still_missing',
|
|
'type' => 'payment_intent.succeeded',
|
|
'payload' => [
|
|
'id' => 'pi_still_missing',
|
|
'object' => 'payment_intent',
|
|
'status' => 'succeeded',
|
|
'livemode' => false,
|
|
'metadata' => ['shopping_payment_id' => '888888'],
|
|
],
|
|
]);
|
|
|
|
$this->artisan('stripe:process-pending-events')->assertExitCode(0);
|
|
|
|
expect($order->fresh()->paid)->toBeTruthy()
|
|
->and(StripeWebhookEvent::whereEventId('evt_pending_cmd')->first()->processed_at)->not->toBeNull()
|
|
->and(StripeWebhookEvent::whereEventId('evt_still_missing')->first()->processed_at)->toBeNull();
|
|
});
|
|
|
|
it('still acknowledges foreign events without our metadata', function () {
|
|
$result = handleStripeAboEvent('payment_intent.succeeded', [
|
|
'id' => 'pi_foreign',
|
|
'object' => 'payment_intent',
|
|
'status' => 'succeeded',
|
|
'livemode' => false,
|
|
'metadata' => [],
|
|
], 'evt_foreign');
|
|
|
|
expect($result->statusCode)->toBe(200)
|
|
->and(StripeWebhookEvent::whereEventId('evt_foreign')->first()->processed_at)->not->toBeNull();
|
|
});
|
|
|
|
// --- S4.6: Anzeige ---
|
|
|
|
it('labels stripe abos from the stored payment method summary', function () {
|
|
$userAbo = stripeAboUserAbo([
|
|
'carddata' => ['type' => 'card', 'brand' => 'visa', 'last4' => '4242', 'exp' => '04/2031', 'wallet' => null],
|
|
]);
|
|
|
|
expect($userAbo->getPaymentType())->toContain('Visa')
|
|
->and($userAbo->getPaymentType())->toContain('4242')
|
|
->and(UserAbo::formatMaskedCard($userAbo->carddata))->toBe('Visa •••• 4242 (04/2031)');
|
|
});
|
|
|
|
it('falls back to the stripe type label for masked cards without last4', function () {
|
|
expect(UserAbo::formatMaskedCard(['type' => 'paypal']))->toBe(__('payment.paypal'))
|
|
->and(UserAbo::formatMaskedCard(null))->toBeNull();
|
|
});
|