1, 'country_id' => 1]); $shopOwner = User::forceCreate([ 'email' => 'sync-owner-'.uniqid('', true).'@example.com', 'password' => bcrypt('secret'), 'lang' => 'de', ]); $userShop = UserShop::first() ?? UserShop::create([ 'user_id' => $shopOwner->id, 'name' => 'Sync Shop', 'slug' => 'sync-shop-'.uniqid(), 'domain' => 'sync-test.local', ]); $shoppingUser = ShoppingUser::create([ 'auth_user_id' => $shopOwner->id, 'billing_firstname' => 'Sync', 'billing_lastname' => 'Kunde', 'billing_email' => 'sync-'.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', 'clearingtype' => 'stripe', 'reference' => 'sync-ref-'.uniqid(), 'external_reference' => 'pi_sync_'.uniqid(), 'amount' => 8900, 'currency' => 'EUR', 'mode' => 'test', ], $paymentOverrides)); return [$order, $payment]; } function syncStatusMockStripe(array $paymentIntent): void { $client = Mockery::mock(StripeClient::class.'[request]', [['api_key' => 'sk_test_fake']]); $client->shouldReceive('request')->andReturn(PaymentIntent::constructFrom($paymentIntent)); $wrapper = Mockery::mock(StripeClientWrapper::class); $wrapper->shouldReceive('isConfigured')->andReturn(true); $wrapper->shouldReceive('client')->andReturn($client); app()->instance(StripeClientWrapper::class, $wrapper); } it('recovers a payment whose webhook never arrived', function () { [$order, $payment] = syncStatusBootstrap(); syncStatusMockStripe([ 'id' => $payment->external_reference, 'object' => 'payment_intent', 'status' => 'succeeded', 'amount' => 8900, 'amount_received' => 8900, 'livemode' => false, 'metadata' => ['shopping_payment_id' => (string) $payment->id], 'latest_charge' => [ 'id' => 'ch_sync_1', 'object' => 'charge', 'payment_intent' => $payment->external_reference, 'payment_method_details' => ['type' => 'card', 'card' => ['brand' => 'visa', 'last4' => '4242']], ], ]); $this->artisan('stripe:sync-payment-status', ['target' => $payment->id]) ->assertSuccessful(); expect((bool) $order->fresh()->paid)->toBeTrue() ->and($order->fresh()->txaction)->toBe('paid') ->and($payment->fresh()->txaction)->toBe('paid') ->and($payment->fresh()->carddata['last4'])->toBe('4242'); }); it('changes nothing in dry-run mode', function () { [$order, $payment] = syncStatusBootstrap(); syncStatusMockStripe([ 'id' => $payment->external_reference, 'object' => 'payment_intent', 'status' => 'succeeded', 'amount' => 8900, 'amount_received' => 8900, 'livemode' => false, 'metadata' => ['shopping_payment_id' => (string) $payment->id], ]); $this->artisan('stripe:sync-payment-status', ['target' => $payment->id, '--dry-run' => true]) ->assertSuccessful(); expect((bool) $order->fresh()->paid)->toBeFalse() ->and($payment->fresh()->txaction)->toBeNull(); }); it('does not process an already paid order twice', function () { [$order, $payment] = syncStatusBootstrap(); $order->update(['paid' => true, 'txaction' => 'paid']); $payment->forceFill(['txaction' => 'paid'])->save(); syncStatusMockStripe([ 'id' => $payment->external_reference, 'object' => 'payment_intent', 'status' => 'succeeded', 'amount' => 8900, 'amount_received' => 8900, 'livemode' => false, 'metadata' => ['shopping_payment_id' => (string) $payment->id], ]); $this->artisan('stripe:sync-payment-status', ['target' => $payment->id]) ->assertSuccessful(); // Der Paid-Pfad läuft nicht erneut: keine zweite Bestätigungsmail. Mail::assertNothingSent(); }); it('refuses a mismatching amount', function () { [$order, $payment] = syncStatusBootstrap(); syncStatusMockStripe([ 'id' => $payment->external_reference, 'object' => 'payment_intent', 'status' => 'succeeded', 'amount' => 999, 'amount_received' => 999, 'livemode' => false, 'metadata' => ['shopping_payment_id' => (string) $payment->id], ]); $this->artisan('stripe:sync-payment-status', ['target' => $payment->id]) ->assertSuccessful(); expect((bool) $order->fresh()->paid)->toBeFalse(); }); it('finds open payments with --pending and skips settled ones', function () { [, $open] = syncStatusBootstrap(); [$paidOrder, $paidPayment] = syncStatusBootstrap(); $paidOrder->update(['paid' => true, 'txaction' => 'paid']); $paidPayment->forceFill(['txaction' => 'paid'])->save(); syncStatusMockStripe([ 'id' => $open->external_reference, 'object' => 'payment_intent', 'status' => 'processing', 'amount' => 8900, 'livemode' => false, 'metadata' => ['shopping_payment_id' => (string) $open->id], ]); $this->artisan('stripe:sync-payment-status', ['--pending' => true]) ->expectsOutputToContain('Zahlung #'.$open->id) ->doesntExpectOutputToContain('Zahlung #'.$paidPayment->id) ->assertSuccessful(); expect($open->fresh()->txaction)->toBe('pending'); }); it('requires a target or --pending', function () { $this->artisan('stripe:sync-payment-status') ->expectsOutputToContain('Bitte eine Zahlung angeben oder --pending nutzen.') ->assertSuccessful(); }); it('skips expired checkout sessions in --pending', function () { [, $expired] = syncStatusBootstrap(['external_reference' => 'cs_test_expired_'.uniqid()]); $expired->forceFill(['status' => 'expired'])->save(); syncStatusMockStripe([ 'id' => 'pi_never_called', 'object' => 'payment_intent', 'status' => 'succeeded', 'amount' => 8900, 'livemode' => false, ]); $this->artisan('stripe:sync-payment-status', ['--pending' => true]) ->expectsOutputToContain('Keine passenden offenen Stripe-Zahlungen gefunden.') ->assertSuccessful(); expect($expired->fresh()->txaction)->toBeNull(); });