$id, 'type' => 'checkout.session.completed', 'data' => ['object' => [ 'customer' => 'cus_1', 'payment_status' => 'paid', 'customer_details' => ['email' => 'kunde@example.com', 'name' => 'Kanzlei Berger'], 'amount_total' => 4900, 'currency' => 'eur', 'metadata' => ['plan' => $plan, 'datacenter' => 'fsn'], ]], ]; } it('creates a customer, order and run from a paid checkout', function () { Queue::fake(); $this->postJson(route('webhooks.stripe'), stripeEvent())->assertOk(); $order = Order::query()->where('stripe_event_id', 'evt_1')->first(); expect($order)->not->toBeNull() ->and($order->plan)->toBe('team') ->and($order->status)->toBe('paid') ->and(ProvisioningRun::query()->where('subject_id', $order->id)->where('pipeline', 'customer')->exists())->toBeTrue(); Queue::assertPushed(AdvanceRunJob::class, 1); }); it('is idempotent — a duplicate webhook starts only one run', function () { Queue::fake(); $this->postJson(route('webhooks.stripe'), stripeEvent('evt_dup'))->assertOk(); $this->postJson(route('webhooks.stripe'), stripeEvent('evt_dup'))->assertOk(); expect(Order::query()->where('stripe_event_id', 'evt_dup')->count())->toBe(1); Queue::assertPushed(AdvanceRunJob::class, 1); }); it('ignores unrelated event types', function () { Queue::fake(); $this->postJson(route('webhooks.stripe'), ['id' => 'evt_x', 'type' => 'invoice.paid', 'data' => ['object' => []]]) ->assertOk() ->assertJson(['ignored' => true]); Queue::assertNothingPushed(); }); it('ignores a paid checkout without a customer email', function () { Queue::fake(); $event = stripeEvent('evt_noemail'); unset($event['data']['object']['customer_details'], $event['data']['object']['customer_email'], $event['data']['object']['metadata']['email']); $this->postJson(route('webhooks.stripe'), $event)->assertOk()->assertJson(['ignored' => 'no_customer_email']); expect(Order::query()->where('stripe_event_id', 'evt_noemail')->exists())->toBeFalse(); Queue::assertNothingPushed(); }); it('ignores a completed but unpaid checkout session', function () { Queue::fake(); $event = stripeEvent('evt_unpaid'); $event['data']['object']['payment_status'] = 'unpaid'; $this->postJson(route('webhooks.stripe'), $event)->assertOk()->assertJson(['ignored' => true]); expect(Order::query()->where('stripe_event_id', 'evt_unpaid')->exists())->toBeFalse(); Queue::assertNothingPushed(); }); it('rejects an invalid signature when a secret is configured', function () { config()->set('services.stripe.webhook_secret', 'whsec_test'); $this->postJson(route('webhooks.stripe'), stripeEvent('evt_sig'), ['Stripe-Signature' => 't=1,v1=deadbeef']) ->assertStatus(400); }); it('accepts a valid signature', function () { Queue::fake(); config()->set('services.stripe.webhook_secret', 'whsec_test'); $payload = json_encode(stripeEvent('evt_ok')); $timestamp = time(); // within Stripe's replay tolerance $signature = hash_hmac('sha256', $timestamp.'.'.$payload, 'whsec_test'); $this->call( 'POST', route('webhooks.stripe'), [], [], [], ['CONTENT_TYPE' => 'application/json', 'HTTP_STRIPE_SIGNATURE' => "t={$timestamp},v1={$signature}"], $payload, )->assertOk(); expect(Order::query()->where('stripe_event_id', 'evt_ok')->exists())->toBeTrue(); });