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>
136 lines
5.1 KiB
PHP
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');
|