mivita/tests/Feature/PaymentProviderManagerTest.php
Kevin e1da90ff91 Stripe-Integration (S0–S3): Einmalzahlungen über Stripe Checkout + Notfall-Go-Live
Wegen PAYONE-Ausfall vorgezogen live geschaltet: alle Einmalzahlungen
(Shop + Salescenter) laufen über Stripe Hosted Checkout, Abos sind bis
Phase S4 eingefroren.

- Provider-Abstraktion: PaymentProvider-Enum, PaymentGatewayInterface,
  PaymentProviderManager (Settings-Schalter, provider-treu je Bestand),
  PayoneGateway als unveränderter Adapter
- StripePaymentService: Checkout Session (eine Gesamtposition, Metadata,
  success/cancel-Redirects), StripeClientWrapper (lazy, Dark Launch),
  StripeCustomerService, StripeTransactionRecorder (client_secret-Redaction)
- Webhook /stripe/webhook: Signaturprüfung, Idempotenz über
  stripe_webhook_events (Crash-Retry-Reprocessing), txaction-Prioritäten,
  Betrags-Guard, lockForUpdate-Paid-Pfad, genau eine Bestätigungsmail
- Zahlungsmethoden-Anzeige aus charge.succeeded (carddata inkl. Wallet,
  auftragsunabhängiges Backfill aus dem Event-Store)
- Checkout: Wartungsmodus mit Admin-Bypass, Abo-Freeze-Hinweis,
  Stripe-Option (Online-Zahlung) statt Zahlartenwahl, Vorkasse ausblendbar
- Abo-Freeze-Guards: Cron user:make_abo_order, Retry, Kartenupdate
- Admin-Settings-Karte Zahlungen (Provider, Wartung, Freeze, Vorkasse)
- Hotfix: 500 bei Vorkasse ohne Clearing-Daten in admin/sales/_detail
  und checkout-final (PAYONE-Ausfall)
- Doku dev/2026-07-08-stripe/ (Plan S0–S8, Konzept, IST-Analyse)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 15:56:41 +00:00

136 lines
5.1 KiB
PHP

<?php
use App\Cron\UserMakeOrder;
use App\Enums\PaymentProvider;
use App\Models\Setting;
use App\Models\ShoppingOrder;
use App\Models\ShoppingPayment;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Services\Payment\Contracts\PaymentGatewayInterface;
use App\Services\Payment\DTOs\AboChargeResult;
use App\Services\Payment\PaymentProviderManager;
use App\Services\Payment\Payone\DTOs\PayonePaymentResult;
use App\Services\Payment\Payone\PayonePaymentService;
use App\Services\Payment\PayoneGateway;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
// --- Schalter: Anbieter für neue Vorgänge ---
it('defaults to payone when no provider switch is configured', function () {
expect(app(PaymentProviderManager::class)->providerForNewCheckout())
->toBe(PaymentProvider::Payone);
});
it('reads the active provider for new checkouts from the settings switch', function () {
Setting::setContentBySlug(PaymentProviderManager::SETTING_SLUG, 'stripe', 'text');
expect(app(PaymentProviderManager::class)->providerForNewCheckout())
->toBe(PaymentProvider::Stripe);
});
it('falls back to payone for unknown switch values', function () {
Setting::setContentBySlug(PaymentProviderManager::SETTING_SLUG, 'klarna', 'text');
expect(app(PaymentProviderManager::class)->providerForNewCheckout())
->toBe(PaymentProvider::Payone);
});
// --- Bestandsvorgänge: Anbieter aus der Spalte, schalterunabhängig ---
it('backfills existing abos with payone as their provider', function () {
$abo = makeMeAbo();
expect($abo->fresh()->payment_provider)->toBe('payone');
});
it('resolves the provider of an existing abo from its column even when the switch says stripe', function () {
Setting::setContentBySlug(PaymentProviderManager::SETTING_SLUG, 'stripe', 'text');
$abo = makeMeAbo();
expect(app(PaymentProviderManager::class)->providerFor($abo->fresh()))
->toBe(PaymentProvider::Payone);
});
it('resolves a stripe abo and a stripe shopping payment from their columns', function () {
$abo = makeMeAbo();
$abo->update(['payment_provider' => 'stripe']);
$payment = new ShoppingPayment(['payment_provider' => 'stripe']);
$manager = app(PaymentProviderManager::class);
expect($manager->providerFor($abo->fresh()))->toBe(PaymentProvider::Stripe)
->and($manager->providerFor($payment))->toBe(PaymentProvider::Stripe);
});
// --- Gateway-Auflösung ---
it('resolves the payone gateway adapter for the payone provider', function () {
$gateway = app(PaymentProviderManager::class)->gateway(PaymentProvider::Payone);
expect($gateway)->toBeInstanceOf(PayoneGateway::class)
->and($gateway)->toBeInstanceOf(PaymentGatewayInterface::class)
->and($gateway->provider())->toBe(PaymentProvider::Payone);
});
it('rejects the stripe gateway until it is implemented', function () {
app(PaymentProviderManager::class)->gateway(PaymentProvider::Stripe);
})->throws(RuntimeException::class);
// --- AboChargeResult-Mapping ---
it('maps an approved payone result to a successful charge result', function () {
$result = AboChargeResult::fromPayoneResult(
PayonePaymentResult::fromResponse(['status' => 'APPROVED', 'txid' => 'tx-123'])
);
expect($result->success)->toBeTrue()
->and($result->provider)->toBe(PaymentProvider::Payone)
->and($result->status)->toBe('APPROVED')
->and($result->transactionId)->toBe('tx-123')
->and($result->errorMessage)->toBeNull();
});
it('maps a declined payone result with its error messages', function () {
$result = AboChargeResult::fromPayoneResult(PayonePaymentResult::fromResponse([
'status' => 'ERROR',
'errormessage' => 'Card declined',
'customermessage' => 'Bitte andere Karte verwenden.',
]));
expect($result->success)->toBeFalse()
->and($result->status)->toBe('ERROR')
->and($result->errorMessage)->toBe('Card declined')
->and($result->customerMessage)->toBe('Bitte andere Karte verwenden.');
});
// --- PayoneGateway delegiert 1:1 an den bestehenden Service ---
it('delegates abo charges to the payone payment service', function () {
$abo = new UserAbo;
$order = new ShoppingOrder;
$shoppingUser = new ShoppingUser;
$service = Mockery::mock(PayonePaymentService::class);
$service->shouldReceive('chargeAbo')
->once()
->with($abo, $order, $shoppingUser, 1999, 'EUR')
->andReturn(PayonePaymentResult::fromResponse(['status' => 'APPROVED', 'txid' => 'tx-9']));
$result = (new PayoneGateway($service))->chargeAbo($abo, $order, $shoppingUser, 1999, 'EUR');
expect($result->success)->toBeTrue()
->and($result->transactionId)->toBe('tx-9');
});
// --- Cron-Guard: kein Fremd-Provider durch den PAYONE-Pfad ---
it('blocks abo charging in the cron for non-payone providers', function () {
$abo = makeMeAbo();
$abo->update(['payment_provider' => 'stripe']);
(new UserMakeOrder($abo->fresh()))->makePayment();
})->throws(RuntimeException::class, 'noch nicht implementiert');