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>
154 lines
5.9 KiB
PHP
154 lines
5.9 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 App\Services\Payment\StripeGateway;
|
|
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('resolves the stripe gateway adapter for the stripe provider', function () {
|
|
$gateway = app(PaymentProviderManager::class)->gateway(PaymentProvider::Stripe);
|
|
|
|
expect($gateway)->toBeInstanceOf(StripeGateway::class)
|
|
->and($gateway)->toBeInstanceOf(PaymentGatewayInterface::class)
|
|
->and($gateway->provider())->toBe(PaymentProvider::Stripe);
|
|
});
|
|
|
|
// --- 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-Dispatch: Stripe-Abos laufen NICHT durch den PAYONE-Pfad (S4.4) ---
|
|
|
|
it('routes stripe abos in the cron to the stripe gateway instead of payone', function () {
|
|
$abo = makeMeAbo();
|
|
$abo->update(['payment_provider' => 'stripe']);
|
|
|
|
$userOrder = new UserMakeOrder($abo->fresh());
|
|
$reflection = new ReflectionClass($userOrder);
|
|
foreach (['shopping_user' => new ShoppingUser, 'shopping_order' => new ShoppingOrder] as $property => $value) {
|
|
$prop = $reflection->getProperty($property);
|
|
$prop->setValue($userOrder, $value);
|
|
}
|
|
|
|
// Ohne gespeicherte Stripe-PaymentMethod liefert das Gateway ein
|
|
// ERROR-Ergebnis (kein PAYONE-Aufruf, keine Exception).
|
|
$response = $userOrder->makePayment();
|
|
|
|
expect($response['status'])->toBe('ERROR')
|
|
->and($response['provider'])->toBe('stripe')
|
|
->and($response['customermessage'])->toBe(__('payment.stripe_abo_payment_method_missing'));
|
|
});
|