mivita/tests/Feature/StripeCheckoutTest.php
Kevin 68d230d4e5 Stripe: Kundendaten, Rechnungsnummer, manueller Zahlungseingang
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>
2026-08-28 09:04:28 +00:00

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');
});