$id, 'type' => 'checkout.session.completed', 'data' => ['object' => [ 'id' => 'cs_'.$id, // checkout session id = idempotency key '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', 'cs_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', 'cs_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' => 'payout.paid', 'data' => ['object' => []]]) ->assertOk() ->assertJson(['ignored' => true]); Queue::assertNothingPushed(); }); it('answers a billing event for a subscription it does not have, without retrying forever', function () { Queue::fake(); // Stripe delivers events for objects created by hand in the dashboard, and // for other environments pointed at the same endpoint. A non-2xx here would // have them retry something that can never succeed. $this->postJson(route('webhooks.stripe'), [ 'id' => 'evt_orphan', 'type' => 'invoice.paid', 'data' => ['object' => ['id' => 'in_1', 'subscription' => 'sub_unknown', 'amount_paid' => 21480]], ])->assertOk()->assertJson(['handled' => 'invoice.paid', 'applied' => false]); 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('provisions an async payment that succeeds later', function () { Queue::fake(); $event = stripeEvent('evt_async'); $event['type'] = 'checkout.session.async_payment_succeeded'; unset($event['data']['object']['payment_status']); // async success is inherently paid $this->postJson(route('webhooks.stripe'), $event)->assertOk(); expect(Order::query()->where('stripe_event_id', 'cs_evt_async')->exists())->toBeTrue(); Queue::assertPushed(AdvanceRunJob::class, 1); }); 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('still provisions a paid order when the customer email already belongs to an operator, but withholds the portal login', function () { // R21: one address must not represent both an operator and a customer. // The payment already happened, so — same principle as openContract()'s // own "the order is the record that money changed hands" — the order and // run are still created; only the colliding portal LOGIN is withheld. Queue::fake(); Operator::factory()->create(['email' => 'kunde@example.com']); $this->postJson(route('webhooks.stripe'), stripeEvent('evt_opcollide'))->assertOk(); $order = Order::query()->where('stripe_event_id', 'cs_evt_opcollide')->first(); expect($order)->not->toBeNull() ->and(ProvisioningRun::query()->where('subject_id', $order->id)->where('pipeline', 'customer')->exists())->toBeTrue() ->and(User::query()->where('email', 'kunde@example.com')->exists())->toBeFalse(); Queue::assertPushed(AdvanceRunJob::class, 1); }); /** * A purchase whose worker was killed after the order committed. * * The order commits before the contract is opened and long before the invoice is * issued, so that a failure in either can never erase the record of a payment. The * window that leaves is real: a paid order, and nothing else. Built here through * the same OpenSubscription the webhook itself calls, so what these tests start * from is the interrupted state and not an approximation of it. */ function interruptedPurchase(string $sessionId, bool $withContract = true): Order { CompanyProfile::put([ 'name' => 'CluPilot Cloud e.U.', 'address' => 'Dreherstraße 66/1/8', 'postcode' => '1110', 'city' => 'Wien', 'vat_id' => 'ATU00000000', ]); $customer = Customer::factory()->create(['email' => 'kunde@example.com', 'name' => 'Kanzlei Berger']); $factory = Order::factory(); return ($withContract ? $factory->withSubscription() : $factory)->create([ 'customer_id' => $customer->id, 'plan' => 'team', 'amount_cents' => 21480, 'currency' => 'EUR', 'status' => 'paid', 'stripe_event_id' => $sessionId, ]); } it('finishes the invoice a crash interrupted, once, however often Stripe asks', function () { Mail::fake(); Queue::fake(); // A paid order with its contract open and NO invoice: the state a worker // killed between the order committing and confirmByMail() leaves behind. // Nothing anywhere sweeps for that, so Stripe's own retry is the only chance // this customer has of ever getting the invoice they paid for — and resume() // used to see the contract, decide there was nothing to do, and return. $order = interruptedPurchase('cs_evt_crash'); expect(Invoice::query()->count())->toBe(0); $this->postJson(route('webhooks.stripe'), stripeEvent('evt_crash'))->assertOk(); $invoice = Invoice::query()->sole(); expect($invoice->order_id)->toBe($order->id) ->and($invoice->gross_cents)->toBe(21480) ->and($invoice->number)->toBe('RE-'.now()->year.'-0001') // Stamped, which is what makes the mail idempotent as well as the number. ->and($invoice->sent_at)->not->toBeNull(); Mail::assertQueued(InvoiceMail::class, 1); // The invoice mail says everything the confirmation said and carries the // document, so it replaces it. Two mails for one purchase is the defect this // whole path was written around. Mail::assertNotQueued(OrderConfirmationMail::class); // Two more redeliveries change nothing. An invoice number can never be handed // out twice, and nobody may receive the same invoice three times. $this->postJson(route('webhooks.stripe'), stripeEvent('evt_crash'))->assertOk(); $this->postJson(route('webhooks.stripe'), stripeEvent('evt_crash'))->assertOk(); expect(Invoice::query()->count())->toBe(1) ->and(Invoice::query()->sole()->number)->toBe($invoice->number); Mail::assertQueued(InvoiceMail::class, 1); }); it('opens the contract and invoices when the crash came even earlier', function () { Mail::fake(); Queue::fake(); // Killed before openContract() this time: a paid order with no contract at // all. Both halves of the repair have to happen on the redelivery. $order = interruptedPurchase('cs_evt_crash_early', withContract: false); expect($order->subscription()->exists())->toBeFalse(); $this->postJson(route('webhooks.stripe'), stripeEvent('evt_crash_early'))->assertOk(); expect($order->fresh()->subscription()->exists())->toBeTrue() ->and(Invoice::query()->sole()->order_id)->toBe($order->id); Mail::assertQueued(InvoiceMail::class, 1); }); 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', 'cs_evt_ok')->exists())->toBeTrue(); });