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>
241 lines
8.8 KiB
PHP
241 lines
8.8 KiB
PHP
<?php
|
|
|
|
use App\Models\PaymentTransaction;
|
|
use App\Models\ShippingCountry;
|
|
use App\Models\ShoppingOrder;
|
|
use App\Models\ShoppingPayment;
|
|
use App\Models\ShoppingUser;
|
|
use App\Models\StripeWebhookEvent;
|
|
use App\Models\UserShop;
|
|
use App\Services\Payment\Stripe\StripeClientWrapper;
|
|
use App\Services\Payment\Stripe\StripeCustomerService;
|
|
use App\Services\Payment\Stripe\StripeTransactionRecorder;
|
|
use App\Services\Payment\Stripe\StripeWebhookHandler;
|
|
use App\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Stripe\Customer;
|
|
use Stripe\StripeClient;
|
|
use Tests\TestCase;
|
|
|
|
uses(TestCase::class, RefreshDatabase::class);
|
|
|
|
// --- Helpers ---
|
|
|
|
function stripeEventPayload(string $type, array $object, string $eventId = 'evt_test_1'): string
|
|
{
|
|
return json_encode([
|
|
'id' => $eventId,
|
|
'object' => 'event',
|
|
'api_version' => '2026-06-30',
|
|
'type' => $type,
|
|
'data' => ['object' => $object],
|
|
]);
|
|
}
|
|
|
|
function stripeSignature(string $payload, ?string $secret = null): string
|
|
{
|
|
$secret = $secret ?? config('stripe.webhook_secret');
|
|
$timestamp = time();
|
|
$signed = hash_hmac('sha256', $timestamp.'.'.$payload, $secret);
|
|
|
|
return "t={$timestamp},v1={$signed}";
|
|
}
|
|
|
|
function makeStripeShoppingPayment(array $overrides = []): ShoppingPayment
|
|
{
|
|
$owner = User::forceCreate([
|
|
'email' => 'stripe-wh-owner-'.uniqid('', true).'@example.com',
|
|
'password' => bcrypt('secret'),
|
|
'lang' => 'de',
|
|
]);
|
|
|
|
$country = ShippingCountry::first()
|
|
?? ShippingCountry::create(['shipping_id' => 1, 'country_id' => 1]);
|
|
|
|
$shoppingUser = ShoppingUser::create([
|
|
'auth_user_id' => $owner->id,
|
|
'billing_firstname' => 'Stripe',
|
|
'billing_lastname' => 'Test',
|
|
'billing_email' => 'stripe-wh-'.uniqid().'@example.com',
|
|
'billing_country_id' => $country->id,
|
|
'shipping_country_id' => $country->id,
|
|
]);
|
|
|
|
$userShop = UserShop::first() ?? UserShop::create([
|
|
'user_id' => $owner->id,
|
|
'name' => 'WH Stripe Shop',
|
|
'slug' => 'wh-stripe-'.uniqid(),
|
|
'domain' => 'wh-stripe.local',
|
|
]);
|
|
|
|
$order = ShoppingOrder::create([
|
|
'shopping_user_id' => $shoppingUser->id,
|
|
'auth_user_id' => $owner->id,
|
|
'country_id' => $country->country_id,
|
|
'user_shop_id' => $userShop->id,
|
|
'total' => 99.00,
|
|
'paid' => false,
|
|
'mode' => 'test',
|
|
]);
|
|
|
|
return ShoppingPayment::create(array_merge([
|
|
'shopping_order_id' => $order->id,
|
|
'payment_provider' => 'stripe',
|
|
'reference' => 'test-'.uniqid(),
|
|
'amount' => 9900,
|
|
'currency' => 'EUR',
|
|
'clearingtype' => 'stripe',
|
|
'mode' => 'test',
|
|
], $overrides));
|
|
}
|
|
|
|
// --- Signatur & Idempotenz ---
|
|
|
|
it('rejects a webhook with an invalid signature and stores nothing', function () {
|
|
$payload = stripeEventPayload('payment_intent.succeeded', ['id' => 'pi_1', 'object' => 'payment_intent']);
|
|
|
|
$result = app(StripeWebhookHandler::class)->handle($payload, stripeSignature($payload, 'whsec_wrong'));
|
|
|
|
expect($result->statusCode)->toBe(400)
|
|
->and($result->state)->toBe('invalid_signature')
|
|
->and(StripeWebhookEvent::count())->toBe(0);
|
|
});
|
|
|
|
it('answers with 500 when the webhook secret is not configured so stripe retries', function () {
|
|
config(['stripe.webhook_secret' => '']);
|
|
$payload = stripeEventPayload('payment_intent.succeeded', ['id' => 'pi_1', 'object' => 'payment_intent']);
|
|
|
|
$result = app(StripeWebhookHandler::class)->handle($payload, 't=1,v1=abc');
|
|
|
|
expect($result->statusCode)->toBe(500)
|
|
->and($result->state)->toBe('not_configured');
|
|
});
|
|
|
|
it('acknowledges but ignores a duplicate event id', function () {
|
|
$object = ['id' => 'pi_dup', 'object' => 'payment_intent', 'status' => 'succeeded'];
|
|
$payload = stripeEventPayload('payment_intent.succeeded', $object, 'evt_dup');
|
|
$handler = app(StripeWebhookHandler::class);
|
|
|
|
$first = $handler->handle($payload, stripeSignature($payload));
|
|
$second = $handler->handle($payload, stripeSignature($payload));
|
|
|
|
expect($first->state)->toBe('processed')
|
|
->and($second->state)->toBe('duplicate')
|
|
->and($second->statusCode)->toBe(200)
|
|
->and(StripeWebhookEvent::count())->toBe(1);
|
|
});
|
|
|
|
// --- Verarbeitung & Protokollierung ---
|
|
|
|
it('stores a valid event of an unhandled type and acknowledges it', function () {
|
|
$payload = stripeEventPayload('customer.created', ['id' => 'cus_1', 'object' => 'customer'], 'evt_unhandled');
|
|
|
|
$result = app(StripeWebhookHandler::class)->handle($payload, stripeSignature($payload));
|
|
|
|
expect($result->state)->toBe('ignored')
|
|
->and($result->statusCode)->toBe(200)
|
|
->and(StripeWebhookEvent::whereEventId('evt_unhandled')->exists())->toBeTrue();
|
|
});
|
|
|
|
it('records a payment transaction when the payment is resolvable via metadata', function () {
|
|
$payment = makeStripeShoppingPayment();
|
|
$object = [
|
|
'id' => 'pi_meta',
|
|
'object' => 'payment_intent',
|
|
'status' => 'succeeded',
|
|
'livemode' => false,
|
|
'client_secret' => 'pi_meta_secret_abc',
|
|
'metadata' => ['shopping_payment_id' => (string) $payment->id],
|
|
];
|
|
$payload = stripeEventPayload('payment_intent.succeeded', $object, 'evt_meta');
|
|
|
|
$result = app(StripeWebhookHandler::class)->handle($payload, stripeSignature($payload));
|
|
|
|
$transaction = PaymentTransaction::where('shopping_payment_id', $payment->id)->first();
|
|
|
|
expect($result->state)->toBe('processed')
|
|
->and($transaction)->not->toBeNull()
|
|
->and($transaction->provider)->toBe('stripe')
|
|
->and($transaction->txid)->toBe('pi_meta')
|
|
->and($transaction->status)->toBe('succeeded')
|
|
->and($transaction->mode)->toBe('test')
|
|
->and($transaction->transmitted_data)->not->toHaveKey('client_secret');
|
|
});
|
|
|
|
it('resolves the payment via external_reference and marks the event processed', function () {
|
|
$payment = makeStripeShoppingPayment(['external_reference' => 'pi_ext_1']);
|
|
$object = ['id' => 'pi_ext_1', 'object' => 'payment_intent', 'status' => 'processing', 'livemode' => false];
|
|
$payload = stripeEventPayload('payment_intent.processing', $object, 'evt_ext');
|
|
|
|
app(StripeWebhookHandler::class)->handle($payload, stripeSignature($payload));
|
|
|
|
expect(PaymentTransaction::where('shopping_payment_id', $payment->id)->count())->toBe(1)
|
|
->and(StripeWebhookEvent::whereEventId('evt_ext')->first()->processed_at)->not->toBeNull();
|
|
});
|
|
|
|
it('redacts client_secret recursively from stored payloads', function () {
|
|
$redacted = StripeTransactionRecorder::redactPayload([
|
|
'id' => 'pi_1',
|
|
'client_secret' => 'geheim',
|
|
'charges' => ['data' => [['id' => 'ch_1', 'client_secret' => 'auch-geheim']]],
|
|
]);
|
|
|
|
expect($redacted)->not->toHaveKey('client_secret')
|
|
->and($redacted['charges']['data'][0])->not->toHaveKey('client_secret')
|
|
->and($redacted['id'])->toBe('pi_1');
|
|
});
|
|
|
|
// --- Client-Wrapper: verschobener Hard-Fail ---
|
|
|
|
it('fails hard on first use when the stripe secret is missing', function () {
|
|
$wrapper = new StripeClientWrapper(null);
|
|
|
|
expect($wrapper->isConfigured())->toBeFalse()
|
|
->and(fn () => $wrapper->client())->toThrow(RuntimeException::class, 'STRIPE_SECRET');
|
|
});
|
|
|
|
it('builds a stripe client lazily when a secret is configured', function () {
|
|
$wrapper = new StripeClientWrapper('sk_test_fake');
|
|
|
|
expect($wrapper->isConfigured())->toBeTrue()
|
|
->and($wrapper->client())->toBeInstanceOf(StripeClient::class)
|
|
->and($wrapper->client())->toBe($wrapper->client());
|
|
});
|
|
|
|
// --- Customer-Service ---
|
|
|
|
it('returns an existing stripe customer id without calling the api', function () {
|
|
$user = User::forceCreate([
|
|
'email' => 'stripe-'.uniqid('', true).'@example.com',
|
|
'password' => bcrypt('secret'),
|
|
'lang' => 'de',
|
|
]);
|
|
$user->forceFill(['stripe_customer_id' => 'cus_existing'])->save();
|
|
|
|
$wrapper = Mockery::mock(StripeClientWrapper::class);
|
|
$wrapper->shouldNotReceive('client');
|
|
|
|
expect((new StripeCustomerService($wrapper))->customerIdFor($user->fresh()))
|
|
->toBe('cus_existing');
|
|
});
|
|
|
|
it('creates a stripe customer once and persists its id', function () {
|
|
$user = User::forceCreate([
|
|
'email' => 'stripe-'.uniqid('', true).'@example.com',
|
|
'password' => bcrypt('secret'),
|
|
'lang' => 'de',
|
|
]);
|
|
|
|
$client = Mockery::mock(StripeClient::class.'[request]', [['api_key' => 'sk_test_fake']]);
|
|
$client->shouldReceive('request')
|
|
->once()
|
|
->andReturn(Customer::constructFrom(['id' => 'cus_new_1', 'object' => 'customer']));
|
|
|
|
$wrapper = Mockery::mock(StripeClientWrapper::class);
|
|
$wrapper->shouldReceive('client')->andReturn($client);
|
|
|
|
$customerId = (new StripeCustomerService($wrapper))->customerIdFor($user);
|
|
|
|
expect($customerId)->toBe('cus_new_1')
|
|
->and($user->fresh()->stripe_customer_id)->toBe('cus_new_1');
|
|
});
|