Rueckmeldung der Buchhaltung und des Stripe-Calls (dev/2026-08-26-stripe/): Stripe kannte bisher nur die E-Mail des Kunden, Zahlungen waren im Dashboard weder ueber die Bestell- noch ueber die Rechnungsnummer auffindbar, und ein Zahlungseingang mit falschem Verwendungszweck zwang die Buchhaltung dazu, die Rechnung von Hand zu schreiben. - SE1: Der Checkout haengt einen Stripe-Customer mit Name, E-Mail und Rechnungsanschrift an die Session (customer statt customer_email); die ID liegt an shopping_users und wird wiederverwendet. Namensformat "Vorname Nachname (Firma)", reine Firmenkunden unter der Firma. Faellt die Customer-Anlage aus, laeuft der Checkout auf dem bisherigen Weg weiter. DSGVO-Loeschungen entfernen jetzt auch das Customer-Objekt; die Charges bleiben als Buchungsbeleg bestehen. - SE2: StripeMetadataBuilder als einzige Quelle fuer Metadaten und die description (Bestellnummer, Referenz, Kundenname) - damit greift die Dashboard-Suche, die bisher nichts Lesbares zu fassen bekam. - SE3: Die Rechnungsnummer entsteht erst nach der Zahlung und wird daher per Job am PaymentIntent nachgetragen (Vorab-Reservierung wuerde Luecken im Nummernkreis erzeugen). stripe:backfill-invoice-metadata traegt sie rueckwirkend ab dem Go-Live 2026-07-10 nach. - SE4: Neuer Admin-Button "Zahlungseingang erfassen" an jeder unbezahlten Bestellung - bisher gab es ihn nur bei Rechnungskauf, und er setzte nur paid, ohne Rechnung, Punkte, Abo-Aktivierung oder Mail. Der neue Weg laeuft durch paymentStatusPaidAction, verlangt den Verwendungszweck als Beleg und laesst einen abweichenden Betrag nur mit ausdruecklicher Bestaetigung zu. - S6.4 (aus dem Juli-Plan vorgezogen): stripe:sync-payment-status gleicht offene Zahlungen aktiv gegen die API ab. Aufgefallen beim Testlauf - ein nie zugestellter Webhook war bisher nicht reparierbar, das Geld lag bei Stripe waehrend die Bestellung auf offen stand. Laeuft stuendlich im Scheduler. - 110 Tests (Pest) fuer alle Pfade, Stripe-Client durchgehend gemockt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
647 lines
24 KiB
PHP
647 lines
24 KiB
PHP
<?php
|
|
|
|
use App\Mail\MailCheckout;
|
|
use App\Models\PaymentMethod;
|
|
use App\Models\PaymentTransaction;
|
|
use App\Models\Setting;
|
|
use App\Models\ShippingCountry;
|
|
use App\Models\ShoppingOrder;
|
|
use App\Models\ShoppingPayment;
|
|
use App\Models\ShoppingUser;
|
|
use App\Models\StripeWebhookEvent;
|
|
use App\Models\UserAboOrder;
|
|
use App\Models\UserShop;
|
|
use App\Repositories\CheckoutRepository;
|
|
use App\Services\AboPaymentMethodService;
|
|
use App\Services\AboRetryPaymentService;
|
|
use App\Services\Payment\PaymentProviderManager;
|
|
use App\Services\Payment\Payone\DTOs\CardUpdateResult;
|
|
use App\Services\Payment\Payone\DTOs\CreditCardCheckData;
|
|
use App\Services\Payment\Stripe\StripeClientWrapper;
|
|
use App\Services\Payment\Stripe\StripeCustomerService;
|
|
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\Customer;
|
|
use Stripe\Exception\ApiConnectionException;
|
|
use Stripe\StripeClient;
|
|
use Tests\TestCase;
|
|
|
|
uses(TestCase::class, RefreshDatabase::class);
|
|
|
|
beforeEach(function (): void {
|
|
Mail::fake();
|
|
});
|
|
|
|
/**
|
|
* Vollständige Order-Fixture (Muster: webhookBootstrap aus PayoneWebhookHandlerTest)
|
|
* für eine 89,00-EUR-Stripe-Zahlung.
|
|
*
|
|
* @return array{0: ShoppingOrder, 1: ShoppingPayment, 2: ShoppingUser}
|
|
*/
|
|
function stripeCheckoutBootstrap(array $paymentOverrides = []): array
|
|
{
|
|
$country = ShippingCountry::first() ?? ShippingCountry::create(['shipping_id' => 1, 'country_id' => 1]);
|
|
|
|
$shopOwner = User::forceCreate([
|
|
'email' => 'sc-owner-'.uniqid('', true).'@example.com',
|
|
'password' => bcrypt('secret'),
|
|
'lang' => 'de',
|
|
]);
|
|
|
|
$userShop = UserShop::first() ?? UserShop::create([
|
|
'user_id' => $shopOwner->id,
|
|
'name' => 'SC Shop',
|
|
'slug' => 'sc-shop-'.uniqid(),
|
|
'domain' => 'sc-test.local',
|
|
]);
|
|
|
|
$shoppingUser = ShoppingUser::create([
|
|
'auth_user_id' => $shopOwner->id,
|
|
'billing_firstname' => 'Stripe',
|
|
'billing_lastname' => 'Checkout',
|
|
'billing_email' => 'stripe-co-'.uniqid().'@example.com',
|
|
'billing_country_id' => $country->id,
|
|
'shipping_country_id' => $country->id,
|
|
]);
|
|
|
|
$order = ShoppingOrder::create([
|
|
'shopping_user_id' => $shoppingUser->id,
|
|
'auth_user_id' => $shopOwner->id,
|
|
'country_id' => $country->id,
|
|
'user_shop_id' => $userShop->id,
|
|
'total' => 89.00,
|
|
'paid' => false,
|
|
'txaction' => null,
|
|
'mode' => 'test',
|
|
]);
|
|
|
|
$payment = ShoppingPayment::create(array_merge([
|
|
'shopping_order_id' => $order->id,
|
|
'payment_provider' => 'stripe',
|
|
'reference' => 'sc-ref-'.uniqid(),
|
|
'amount' => 8900,
|
|
'clearingtype' => 'stripe',
|
|
'currency' => 'EUR',
|
|
'mode' => 'test',
|
|
], $paymentOverrides));
|
|
|
|
return [$order, $payment, $shoppingUser];
|
|
}
|
|
|
|
function stripeCheckoutEvent(string $type, array $object, string $eventId): string
|
|
{
|
|
return json_encode([
|
|
'id' => $eventId,
|
|
'object' => 'event',
|
|
'api_version' => '2026-06-30',
|
|
'type' => $type,
|
|
'data' => ['object' => $object],
|
|
]);
|
|
}
|
|
|
|
function stripeCheckoutSignature(string $payload): string
|
|
{
|
|
$timestamp = time();
|
|
$signed = hash_hmac('sha256', $timestamp.'.'.$payload, config('stripe.webhook_secret'));
|
|
|
|
return "t={$timestamp},v1={$signed}";
|
|
}
|
|
|
|
function handleStripeCheckoutEvent(string $type, array $object, string $eventId): void
|
|
{
|
|
$payload = stripeCheckoutEvent($type, $object, $eventId);
|
|
app(StripeWebhookHandler::class)->handle($payload, stripeCheckoutSignature($payload));
|
|
}
|
|
|
|
/**
|
|
* Stripe-Client-Mock für die Session-Erstellung. Seit Phase SE1 legt der
|
|
* Service zuerst einen Customer an (Name/Anschrift für die Buchhaltung) und
|
|
* danach die Checkout Session.
|
|
*
|
|
* @param array<string, mixed>|null $sessionParams Referenz, gefüllt mit den Session-Parametern
|
|
* @param array<string, mixed>|null $customerParams Referenz, gefüllt mit den Customer-Parametern
|
|
*/
|
|
function stripeCheckoutServiceWithMock(&$sessionParams = null, &$customerParams = null): StripePaymentService
|
|
{
|
|
$client = Mockery::mock(StripeClient::class.'[request]', [['api_key' => 'sk_test_fake']]);
|
|
$client->shouldReceive('request')
|
|
->andReturnUsing(function ($method, $path, $params) use (&$sessionParams, &$customerParams) {
|
|
if ($path === '/v1/customers') {
|
|
$customerParams = $params;
|
|
|
|
return Customer::constructFrom(['id' => 'cus_test_se1', 'object' => 'customer']);
|
|
}
|
|
|
|
$sessionParams = $params;
|
|
|
|
return Session::constructFrom([
|
|
'id' => 'cs_test_abc',
|
|
'object' => 'checkout.session',
|
|
'url' => 'https://checkout.stripe.com/c/pay/cs_test_abc',
|
|
'status' => 'open',
|
|
'livemode' => false,
|
|
]);
|
|
});
|
|
|
|
$wrapper = Mockery::mock(StripeClientWrapper::class);
|
|
$wrapper->shouldReceive('client')->andReturn($client);
|
|
|
|
return new StripePaymentService($wrapper, new StripeCustomerService($wrapper));
|
|
}
|
|
|
|
// --- Session-Erstellung (S3.2) ---
|
|
|
|
it('creates a checkout session with payment and initiation transaction', function () {
|
|
[$order, , $shoppingUser] = stripeCheckoutBootstrap();
|
|
$order->shopping_payments()->delete();
|
|
|
|
$capturedParams = null;
|
|
|
|
$result = stripeCheckoutServiceWithMock($capturedParams)->initiateCheckoutPayment(
|
|
$shoppingUser,
|
|
$order,
|
|
8900,
|
|
'EUR',
|
|
'mref123',
|
|
'https://checkout.test/transaction/status/success/mref123',
|
|
'https://checkout.test/transaction/status/cancel/mref123',
|
|
);
|
|
|
|
$payment = $result->shoppingPayment->fresh();
|
|
|
|
expect($result->url)->toBe('https://checkout.stripe.com/c/pay/cs_test_abc')
|
|
->and($payment->payment_provider)->toBe('stripe')
|
|
->and($payment->external_reference)->toBe('cs_test_abc')
|
|
->and($payment->amount)->toBe(8900)
|
|
->and($payment->reference)->toBe('mref123')
|
|
->and($capturedParams['mode'])->toBe('payment')
|
|
->and($capturedParams['line_items'][0]['price_data']['unit_amount'])->toBe(8900)
|
|
->and($capturedParams['line_items'][0]['price_data']['currency'])->toBe('eur')
|
|
->and($capturedParams['metadata']['shopping_payment_id'])->toBe((string) $payment->id)
|
|
->and($capturedParams['payment_intent_data']['metadata']['shopping_order_id'])->toBe((string) $order->id)
|
|
->and($capturedParams['success_url'])->toContain('mref123')
|
|
->and(PaymentTransaction::where('shopping_payment_id', $payment->id)->where('request', 'checkout.session.created')->exists())->toBeTrue();
|
|
});
|
|
|
|
// --- Kundendaten & Metadaten (SE1/SE2, dev/2026-08-26-stripe/) ---
|
|
|
|
it('attaches a named stripe customer to the checkout session', function () {
|
|
[$order, , $shoppingUser] = stripeCheckoutBootstrap();
|
|
$order->shopping_payments()->delete();
|
|
$shoppingUser->update([
|
|
'billing_firstname' => 'Erika',
|
|
'billing_lastname' => 'Mustermann',
|
|
'billing_address' => 'Musterweg 1',
|
|
'billing_zipcode' => '12345',
|
|
'billing_city' => 'Musterstadt',
|
|
]);
|
|
|
|
$sessionParams = null;
|
|
$customerParams = null;
|
|
|
|
stripeCheckoutServiceWithMock($sessionParams, $customerParams)->initiateCheckoutPayment(
|
|
$shoppingUser, $order, 8900, 'EUR', 'mrefse1',
|
|
'https://checkout.test/success/mrefse1',
|
|
'https://checkout.test/cancel/mrefse1',
|
|
);
|
|
|
|
expect($customerParams['name'])->toBe('Erika Mustermann')
|
|
->and($customerParams['email'])->toBe($shoppingUser->billing_email)
|
|
->and($customerParams['address']['line1'])->toBe('Musterweg 1')
|
|
->and($customerParams['address']['postal_code'])->toBe('12345')
|
|
->and($customerParams['metadata']['mivita_type'])->toBe('shopping_user')
|
|
->and($sessionParams['customer'])->toBe('cus_test_se1')
|
|
->and($sessionParams)->not->toHaveKey('customer_email')
|
|
->and($shoppingUser->fresh()->stripe_customer_id)->toBe('cus_test_se1');
|
|
});
|
|
|
|
it('reuses an existing stripe customer instead of creating a second one', function () {
|
|
[$order, , $shoppingUser] = stripeCheckoutBootstrap();
|
|
$order->shopping_payments()->delete();
|
|
$shoppingUser->forceFill(['stripe_customer_id' => 'cus_existing'])->save();
|
|
|
|
$sessionParams = null;
|
|
$customerParams = null;
|
|
|
|
stripeCheckoutServiceWithMock($sessionParams, $customerParams)->initiateCheckoutPayment(
|
|
$shoppingUser, $order, 8900, 'EUR', 'mrefse1b',
|
|
'https://checkout.test/success/mrefse1b',
|
|
'https://checkout.test/cancel/mrefse1b',
|
|
);
|
|
|
|
expect($sessionParams['customer'])->toBe('cus_existing')
|
|
->and($shoppingUser->fresh()->stripe_customer_id)->toBe('cus_existing');
|
|
});
|
|
|
|
it('shows the person name first and the company in brackets', function () {
|
|
[$order, , $shoppingUser] = stripeCheckoutBootstrap();
|
|
$order->shopping_payments()->delete();
|
|
$shoppingUser->update([
|
|
'billing_firstname' => 'Kevin',
|
|
'billing_lastname' => 'Adametz',
|
|
'billing_company' => 'adametz.media',
|
|
]);
|
|
|
|
$sessionParams = null;
|
|
$customerParams = null;
|
|
|
|
stripeCheckoutServiceWithMock($sessionParams, $customerParams)->initiateCheckoutPayment(
|
|
$shoppingUser, $order, 8900, 'EUR', 'mrefse1c',
|
|
'https://checkout.test/success/mrefse1c',
|
|
'https://checkout.test/cancel/mrefse1c',
|
|
);
|
|
|
|
expect($customerParams['name'])->toBe('Kevin Adametz (adametz.media)')
|
|
->and($sessionParams['metadata']['customer_name'])->toBe('Kevin Adametz (adametz.media)');
|
|
});
|
|
|
|
it('falls back to the company when no person name is stored', function () {
|
|
[$order, , $shoppingUser] = stripeCheckoutBootstrap();
|
|
$order->shopping_payments()->delete();
|
|
$shoppingUser->update([
|
|
'billing_firstname' => null,
|
|
'billing_lastname' => null,
|
|
'billing_company' => 'Muster GmbH',
|
|
]);
|
|
|
|
$customerParams = null;
|
|
$sessionParams = null;
|
|
|
|
stripeCheckoutServiceWithMock($sessionParams, $customerParams)->initiateCheckoutPayment(
|
|
$shoppingUser, $order, 8900, 'EUR', 'mrefse1d',
|
|
'https://checkout.test/success/mrefse1d',
|
|
'https://checkout.test/cancel/mrefse1d',
|
|
);
|
|
|
|
expect($customerParams['name'])->toBe('Muster GmbH');
|
|
});
|
|
|
|
it('sends a searchable description and the full metadata set', function () {
|
|
[$order, , $shoppingUser] = stripeCheckoutBootstrap();
|
|
$order->shopping_payments()->delete();
|
|
|
|
$sessionParams = null;
|
|
|
|
$result = stripeCheckoutServiceWithMock($sessionParams)->initiateCheckoutPayment(
|
|
$shoppingUser, $order, 8900, 'EUR', 'mrefse2',
|
|
'https://checkout.test/success/mrefse2',
|
|
'https://checkout.test/cancel/mrefse2',
|
|
);
|
|
|
|
$metadata = $sessionParams['payment_intent_data']['metadata'];
|
|
|
|
expect($sessionParams['payment_intent_data']['description'])
|
|
->toContain('Bestellung #'.$order->id)
|
|
->toContain('mrefse2')
|
|
->toContain('Stripe Checkout')
|
|
->and($metadata['shopping_order_id'])->toBe((string) $order->id)
|
|
->and($metadata['shopping_payment_id'])->toBe((string) $result->shoppingPayment->id)
|
|
->and($metadata['reference'])->toBe('mrefse2')
|
|
->and($metadata['order_type'])->toBe('shop')
|
|
->and($metadata['mode'])->toBe('test');
|
|
});
|
|
|
|
it('marks abo orders as abo in the metadata and keeps setup_future_usage', function () {
|
|
[$order, , $shoppingUser] = stripeCheckoutBootstrap();
|
|
$order->shopping_payments()->delete();
|
|
$order->update(['is_abo' => true, 'abo_interval' => 1]);
|
|
|
|
$sessionParams = null;
|
|
|
|
stripeCheckoutServiceWithMock($sessionParams)->initiateCheckoutPayment(
|
|
$shoppingUser, $order->fresh(), 8900, 'EUR', 'mrefse2b',
|
|
'https://checkout.test/success/mrefse2b',
|
|
'https://checkout.test/cancel/mrefse2b',
|
|
);
|
|
|
|
expect($sessionParams['metadata']['order_type'])->toBe('abo')
|
|
->and($sessionParams['payment_intent_data']['setup_future_usage'])->toBe('off_session')
|
|
->and($sessionParams)->not->toHaveKey('customer_creation')
|
|
->and($sessionParams['customer'])->toBe('cus_test_se1');
|
|
});
|
|
|
|
it('falls back to customer_email when the stripe customer cannot be created', function () {
|
|
[$order, , $shoppingUser] = stripeCheckoutBootstrap();
|
|
$order->shopping_payments()->delete();
|
|
$order->update(['is_abo' => true]);
|
|
|
|
$sessionParams = null;
|
|
$client = Mockery::mock(StripeClient::class.'[request]', [['api_key' => 'sk_test_fake']]);
|
|
$client->shouldReceive('request')
|
|
->andReturnUsing(function ($method, $path, $params) use (&$sessionParams) {
|
|
if ($path === '/v1/customers') {
|
|
throw new ApiConnectionException('Stripe nicht erreichbar');
|
|
}
|
|
|
|
$sessionParams = $params;
|
|
|
|
return Session::constructFrom([
|
|
'id' => 'cs_test_fallback',
|
|
'object' => 'checkout.session',
|
|
'url' => 'https://checkout.stripe.com/c/pay/cs_test_fallback',
|
|
'status' => 'open',
|
|
'livemode' => false,
|
|
]);
|
|
});
|
|
|
|
$wrapper = Mockery::mock(StripeClientWrapper::class);
|
|
$wrapper->shouldReceive('client')->andReturn($client);
|
|
|
|
$result = (new StripePaymentService($wrapper, new StripeCustomerService($wrapper)))->initiateCheckoutPayment(
|
|
$shoppingUser, $order->fresh(), 8900, 'EUR', 'mrefse1f',
|
|
'https://checkout.test/success/mrefse1f',
|
|
'https://checkout.test/cancel/mrefse1f',
|
|
);
|
|
|
|
expect($result->url)->toBe('https://checkout.stripe.com/c/pay/cs_test_fallback')
|
|
->and($sessionParams)->not->toHaveKey('customer')
|
|
->and($sessionParams['customer_email'])->toBe($shoppingUser->billing_email)
|
|
->and($sessionParams['customer_creation'])->toBe('always')
|
|
->and($sessionParams['payment_intent_data']['setup_future_usage'])->toBe('off_session');
|
|
});
|
|
|
|
// --- Paid-Pfad (S3.5) ---
|
|
|
|
it('marks the order paid and sends one mail on payment_intent succeeded', function () {
|
|
[$order, $payment] = stripeCheckoutBootstrap();
|
|
|
|
handleStripeCheckoutEvent('payment_intent.succeeded', [
|
|
'id' => 'pi_paid_1',
|
|
'object' => 'payment_intent',
|
|
'status' => 'succeeded',
|
|
'amount' => 8900,
|
|
'amount_received' => 8900,
|
|
'livemode' => false,
|
|
'metadata' => ['shopping_payment_id' => (string) $payment->id],
|
|
], 'evt_paid_1');
|
|
|
|
$order->refresh();
|
|
|
|
expect($order->paid)->toBeTruthy()
|
|
->and($order->txaction)->toBe('paid')
|
|
->and($payment->fresh()->txaction)->toBe('paid');
|
|
Mail::assertSent(MailCheckout::class, 1);
|
|
});
|
|
|
|
it('does not send a second mail when the session completed event arrives after payment', function () {
|
|
[$order, $payment] = stripeCheckoutBootstrap(['external_reference' => 'cs_double']);
|
|
|
|
handleStripeCheckoutEvent('payment_intent.succeeded', [
|
|
'id' => 'pi_double',
|
|
'object' => 'payment_intent',
|
|
'amount_received' => 8900,
|
|
'livemode' => false,
|
|
'metadata' => ['shopping_payment_id' => (string) $payment->id],
|
|
], 'evt_double_pi');
|
|
|
|
handleStripeCheckoutEvent('checkout.session.completed', [
|
|
'id' => 'cs_double',
|
|
'object' => 'checkout.session',
|
|
'payment_status' => 'paid',
|
|
'amount_total' => 8900,
|
|
'livemode' => false,
|
|
], 'evt_double_cs');
|
|
|
|
expect($order->fresh()->paid)->toBeTruthy();
|
|
Mail::assertSent(MailCheckout::class, 1);
|
|
});
|
|
|
|
it('refuses the paid path when the confirmed amount does not match', function () {
|
|
[$order, $payment] = stripeCheckoutBootstrap();
|
|
|
|
handleStripeCheckoutEvent('payment_intent.succeeded', [
|
|
'id' => 'pi_wrong',
|
|
'object' => 'payment_intent',
|
|
'amount_received' => 999,
|
|
'livemode' => false,
|
|
'metadata' => ['shopping_payment_id' => (string) $payment->id],
|
|
], 'evt_wrong_amount');
|
|
|
|
expect($order->fresh()->paid)->toBeFalsy();
|
|
Mail::assertNothingSent();
|
|
});
|
|
|
|
it('marks appointed and sends the order confirmation for async payments', function () {
|
|
[$order, $payment] = stripeCheckoutBootstrap(['external_reference' => 'cs_async']);
|
|
|
|
handleStripeCheckoutEvent('checkout.session.completed', [
|
|
'id' => 'cs_async',
|
|
'object' => 'checkout.session',
|
|
'payment_status' => 'unpaid',
|
|
'amount_total' => 8900,
|
|
'livemode' => false,
|
|
], 'evt_async_cs');
|
|
|
|
$order->refresh();
|
|
|
|
expect($order->paid)->toBeFalsy()
|
|
->and($order->txaction)->toBe('appointed');
|
|
Mail::assertSent(MailCheckout::class, 1);
|
|
});
|
|
|
|
it('marks failed attempts without sending mail', function () {
|
|
[$order, $payment] = stripeCheckoutBootstrap();
|
|
|
|
handleStripeCheckoutEvent('payment_intent.payment_failed', [
|
|
'id' => 'pi_fail',
|
|
'object' => 'payment_intent',
|
|
'livemode' => false,
|
|
'metadata' => ['shopping_payment_id' => (string) $payment->id],
|
|
], 'evt_failed');
|
|
|
|
expect($order->fresh()->txaction)->toBe('failed');
|
|
Mail::assertNothingSent();
|
|
});
|
|
|
|
it('marks an expired session as cancelled without touching paid orders', function () {
|
|
[, $payment] = stripeCheckoutBootstrap(['external_reference' => 'cs_expired']);
|
|
|
|
handleStripeCheckoutEvent('checkout.session.expired', [
|
|
'id' => 'cs_expired',
|
|
'object' => 'checkout.session',
|
|
'livemode' => false,
|
|
], 'evt_expired');
|
|
|
|
expect($payment->fresh()->status)->toBe('expired');
|
|
});
|
|
|
|
it('reprocesses a stored but unprocessed event on retry', function () {
|
|
[$order, $payment] = stripeCheckoutBootstrap();
|
|
|
|
StripeWebhookEvent::create([
|
|
'event_id' => 'evt_crashed',
|
|
'type' => 'payment_intent.succeeded',
|
|
'payload' => [],
|
|
]);
|
|
|
|
handleStripeCheckoutEvent('payment_intent.succeeded', [
|
|
'id' => 'pi_retry',
|
|
'object' => 'payment_intent',
|
|
'amount_received' => 8900,
|
|
'livemode' => false,
|
|
'metadata' => ['shopping_payment_id' => (string) $payment->id],
|
|
], 'evt_crashed');
|
|
|
|
expect($order->fresh()->paid)->toBeTruthy()
|
|
->and(StripeWebhookEvent::whereEventId('evt_crashed')->first()->processed_at)->not->toBeNull();
|
|
});
|
|
|
|
// --- Zahlart-Anzeige (payment_method_details) ---
|
|
|
|
it('stores the used payment method from charge succeeded and shows it as label', function () {
|
|
[, $payment] = stripeCheckoutBootstrap(['external_reference' => 'pi_details']);
|
|
|
|
handleStripeCheckoutEvent('charge.succeeded', [
|
|
'id' => 'ch_1',
|
|
'object' => 'charge',
|
|
'payment_intent' => 'pi_details',
|
|
'livemode' => false,
|
|
'payment_method_details' => [
|
|
'type' => 'card',
|
|
'card' => ['brand' => 'visa', 'last4' => '4242', 'exp_month' => 4, 'exp_year' => 2031, 'wallet' => null],
|
|
],
|
|
], 'evt_charge_details');
|
|
|
|
$payment->refresh();
|
|
|
|
expect($payment->carddata['type'])->toBe('card')
|
|
->and($payment->carddata['last4'])->toBe('4242')
|
|
->and($payment->getPaymentType())->toContain('Visa')
|
|
->and($payment->getPaymentType())->toContain('4242');
|
|
});
|
|
|
|
it('backfills payment method details when the charge event arrived first', function () {
|
|
[$order, $payment] = stripeCheckoutBootstrap();
|
|
|
|
// charge.succeeded kommt zuerst und ist noch keiner Zahlung zuordenbar
|
|
handleStripeCheckoutEvent('charge.succeeded', [
|
|
'id' => 'ch_2',
|
|
'object' => 'charge',
|
|
'payment_intent' => 'pi_backfill',
|
|
'livemode' => false,
|
|
'payment_method_details' => [
|
|
'type' => 'card',
|
|
'card' => ['brand' => 'mastercard', 'last4' => '4444', 'wallet' => ['type' => 'apple_pay']],
|
|
],
|
|
], 'evt_charge_first');
|
|
|
|
handleStripeCheckoutEvent('payment_intent.succeeded', [
|
|
'id' => 'pi_backfill',
|
|
'object' => 'payment_intent',
|
|
'amount_received' => 8900,
|
|
'livemode' => false,
|
|
'metadata' => ['shopping_payment_id' => (string) $payment->id],
|
|
], 'evt_pi_after_charge');
|
|
|
|
$payment->refresh();
|
|
|
|
expect($order->fresh()->paid)->toBeTruthy()
|
|
->and($payment->external_reference)->toBe('pi_backfill')
|
|
->and($payment->carddata['wallet'])->toBe('apple_pay')
|
|
->and($payment->getPaymentType())->toContain('Apple Pay');
|
|
});
|
|
|
|
it('labels paypal payments from the charge details', function () {
|
|
[, $payment] = stripeCheckoutBootstrap(['external_reference' => 'pi_pp']);
|
|
|
|
handleStripeCheckoutEvent('charge.succeeded', [
|
|
'id' => 'ch_3',
|
|
'object' => 'charge',
|
|
'payment_intent' => 'pi_pp',
|
|
'livemode' => false,
|
|
'payment_method_details' => ['type' => 'paypal', 'paypal' => []],
|
|
], 'evt_charge_pp');
|
|
|
|
expect($payment->fresh()->getPaymentType())->toBe(__('payment.paypal'));
|
|
});
|
|
|
|
// --- Abo-Freeze (Not-Schalter PAYONE-Ausfall) ---
|
|
|
|
it('freezes abo retries while the freeze switch is on', function () {
|
|
Setting::setContentBySlug(PaymentProviderManager::SETTING_ABO_PAYMENTS_FROZEN, 1, 'bool');
|
|
|
|
$abo = makeMeAbo();
|
|
$abo->update(['status' => 3]);
|
|
|
|
$result = app(AboRetryPaymentService::class)->retry($abo->fresh());
|
|
|
|
expect($result['success'])->toBeFalse()
|
|
->and($result['message'])->toBe(__('payment.abo_payments_frozen'));
|
|
});
|
|
|
|
it('skips the abo charge cron entirely while frozen without touching abos', function () {
|
|
Setting::setContentBySlug(PaymentProviderManager::SETTING_ABO_PAYMENTS_FROZEN, 1, 'bool');
|
|
|
|
$abo = makeMeAbo();
|
|
$abo->update(['next_date' => now()->subDay(), 'status' => 2]);
|
|
|
|
$this->artisan('user:make_abo_order')->assertExitCode(0);
|
|
|
|
$fresh = $abo->fresh();
|
|
|
|
expect($fresh->status)->toBe(2)
|
|
->and(UserAboOrder::where('user_abo_id', $abo->id)->count())->toBe(0);
|
|
});
|
|
|
|
it('reports the freeze state from the manager with default off', function () {
|
|
$manager = app(PaymentProviderManager::class);
|
|
|
|
expect($manager->aboPaymentsFrozen())->toBeFalse();
|
|
|
|
Setting::setContentBySlug(PaymentProviderManager::SETTING_ABO_PAYMENTS_FROZEN, 1, 'bool');
|
|
|
|
expect($manager->aboPaymentsFrozen())->toBeTrue();
|
|
});
|
|
|
|
it('freezes credit card updates for abos while the freeze switch is on', function () {
|
|
Setting::setContentBySlug(PaymentProviderManager::SETTING_ABO_PAYMENTS_FROZEN, 1, 'bool');
|
|
|
|
$abo = makeMeAbo();
|
|
$card = new CreditCardCheckData(
|
|
cardholderFirstName: 'Max',
|
|
cardholderLastName: 'Muster',
|
|
cardPan: '4111111111111111',
|
|
cardExpireMonth: '12',
|
|
cardExpireYear: '2031',
|
|
cardType: 'V',
|
|
cardCvc2: '123',
|
|
);
|
|
|
|
$result = app(AboPaymentMethodService::class)
|
|
->updateCreditCard($abo, $card, ['successurl' => 'x', 'errorurl' => 'x', 'backurl' => 'x']);
|
|
|
|
expect($result->status)->toBe(CardUpdateResult::ERROR)
|
|
->and($result->errorMessage)->toBe(__('payment.abo_payments_frozen'));
|
|
});
|
|
|
|
it('reports the checkout maintenance state with default off', function () {
|
|
$manager = app(PaymentProviderManager::class);
|
|
|
|
expect($manager->checkoutMaintenanceActive())->toBeFalse();
|
|
|
|
Setting::setContentBySlug(PaymentProviderManager::SETTING_CHECKOUT_MAINTENANCE, 1, 'bool');
|
|
|
|
expect($manager->checkoutMaintenanceActive())->toBeTrue();
|
|
|
|
Setting::setContentBySlug(PaymentProviderManager::SETTING_CHECKOUT_MAINTENANCE, 0, 'bool');
|
|
|
|
expect($manager->checkoutMaintenanceActive())->toBeFalse();
|
|
});
|
|
|
|
// --- Vorkasse-Schalter (S3.8) ---
|
|
|
|
it('hides prepayment in the checkout when the vorkasse switch is set', function () {
|
|
PaymentMethod::create(['name' => 'Vorkasse', 'short' => 'VOR', 'active' => true, 'default' => true]);
|
|
|
|
$repo = app(CheckoutRepository::class);
|
|
|
|
expect($repo->getPaymentsMethods('shopping')['active'])->toHaveKey('VOR');
|
|
|
|
Setting::setContentBySlug(PaymentProviderManager::SETTING_VORKASSE_HIDDEN, 1, 'bool');
|
|
|
|
expect($repo->getPaymentsMethods('shopping')['active'])->not->toHaveKey('VOR');
|
|
});
|