'CluPilot Cloud e.U.', 'address' => 'Dreherstraße 66/1/8', 'postcode' => '1110', 'city' => 'Wien', 'vat_id' => 'ATU00000000', ]); }); afterEach(function () { Carbon::setTestNow(); }); /** A contract Stripe bills, belonging to somebody with an address to write to. */ function renewingContract(string $stripeId = 'sub_ren'): Subscription { $customer = Customer::factory()->create([ 'name' => 'Berger GmbH', 'email' => 'kundin@example.com', ]); return Subscription::factory()->plan('team')->create([ 'customer_id' => $customer->id, 'stripe_subscription_id' => $stripeId, 'current_period_start' => now()->subMonth(), 'current_period_end' => now(), ]); } /** The event Stripe raises when a term rolls over and is paid. */ function renewalPaid(string $invoiceId = 'in_ren'): array { return [ 'id' => 'evt_'.$invoiceId, 'type' => 'invoice.paid', 'data' => ['object' => [ 'id' => $invoiceId, 'subscription' => 'sub_ren', 'amount_paid' => 21480, 'billing_reason' => 'subscription_cycle', 'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp, ]], ]; } it('issues exactly one invoice for a renewal and sends exactly one mail', function () { Mail::fake(); $contract = renewingContract(); $this->postJson(route('webhooks.stripe'), renewalPaid())->assertOk(); $invoice = Invoice::query()->sole(); expect($invoice->stripe_invoice_id)->toBe('in_ren') // The contract is what a renewal renews. There is no order behind it, // and inventing one would put a purchase nobody made in the shop's own // records. ->and($invoice->subscription_id)->toBe($contract->id) ->and($invoice->order_id)->toBeNull() // The contract's frozen NET price, not Stripe's gross total: what the // customer is owed is what they agreed to. ->and($invoice->net_cents)->toBe(17900) ->and($invoice->tax_cents)->toBe(3580) ->and($invoice->number)->toBe('RE-2026-0001') ->and($invoice->sent_at)->not->toBeNull(); // One line, saying which term it covers. Stripe's period_end is the first // moment of the NEXT term, so printing it would put the same day on two // consecutive invoices. expect($invoice->snapshot['lines'])->toHaveCount(1) ->and($invoice->snapshot['lines'][0]['details'][0]) ->toBe(__('invoice.line_period', ['from' => '01.08.2026', 'to' => '31.08.2026'])); Mail::assertQueuedCount(1); Mail::assertQueued(InvoiceMail::class, fn (InvoiceMail $mail) => $mail->hasTo('kundin@example.com') && $mail->invoice->is($invoice)); // And the payment is still in the register, where it always was. expect(SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_RENEWAL)->count())->toBe(1); }); it('issues one document and consumes one number however often Stripe delivers the invoice', function () { Mail::fake(); renewingContract(); $this->postJson(route('webhooks.stripe'), renewalPaid())->assertOk(); $this->postJson(route('webhooks.stripe'), renewalPaid())->assertOk(); // An invoice number comes out of a gapless series and can never be handed // out twice — so the second delivery must not take one, not even one it // then throws away. expect(Invoice::query()->count())->toBe(1) ->and(InvoiceSeries::query()->where('kind', 'invoice')->value('next_number'))->toBe(2); Mail::assertQueuedCount(1); }); it('does not fail the webhook when the invoice cannot be issued, and keeps the payment', function () { Mail::fake(); renewingContract(); // An invoice without a VAT number is not a valid invoice here, so issuing // refuses — which is correct, and must not cost Stripe its 2xx. Settings::forget('company.vat_id'); $this->postJson(route('webhooks.stripe'), renewalPaid())->assertOk(); expect(Invoice::query()->count())->toBe(0) // A retry would re-enter this and fail again; the money is what must // not be lost. ->and(SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_RENEWAL)->count())->toBe(1) ->and(Subscription::query()->sole()->current_period_end->timestamp)->toBe(now()->addMonth()->timestamp); Mail::assertNothingQueued(); }); it('does not fail the webhook when the mail cannot be sent, and leaves the invoice unsent', function () { renewingContract(); // A mail server that is down, mid-send. Mail::shouldReceive('to')->andThrow(new RuntimeException('Connection refused')); $this->postJson(route('webhooks.stripe'), renewalPaid())->assertOk(); $invoice = Invoice::query()->sole(); // The document is issued and the payment recorded; only the sending failed, // and it says so rather than claiming to have gone out. expect($invoice->sent_at)->toBeNull() ->and(SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_RENEWAL)->count())->toBe(1); }); it('sends the invoice a second delivery finds unsent, without issuing a second document', function () { renewingContract(); $mailer = Mail::getFacadeRoot(); Mail::shouldReceive('to')->once()->andThrow(new RuntimeException('Connection refused')); $this->postJson(route('webhooks.stripe'), renewalPaid())->assertOk(); // Stripe redelivers, and the mail server is back. The register entry is // already there, so tying the paperwork to it would mean this customer // never got their invoice. Mail::swap($mailer); Mail::fake(); $this->postJson(route('webhooks.stripe'), renewalPaid())->assertOk(); expect(Invoice::query()->count())->toBe(1) ->and(Invoice::query()->sole()->sent_at)->not->toBeNull(); Mail::assertQueuedCount(1); }); it('invoices nothing for a payment that arrives with nothing to describe it', function () { Mail::fake(); renewingContract(); $this->postJson(route('webhooks.stripe'), [ 'id' => 'evt_prorata', 'type' => 'invoice.paid', 'data' => ['object' => [ 'id' => 'in_prorata', 'subscription' => 'sub_ren', 'amount_paid' => 4200, 'billing_reason' => 'subscription_update', 'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp, ]], ])->assertOk(); // A proration DOES get a document now — StripeAddonBillingTest walks one — // and it is built from Stripe's own lines, because Stripe worked the amount // out against ITS boundaries and its own view of the account. This invoice // carries no lines at all, so there is nothing honest to put on a document: // the contract's own price describes a whole term, which is exactly what // this charge is not. The money is in the register either way, which is the // trail for whoever has to raise the paper. expect(Invoice::query()->count())->toBe(0) ->and(SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_INVOICE_PAID)->sole()->gross_cents) ->toBe(4200); Mail::assertNothingQueued(); }); it('still gives the first purchase exactly one invoice and one mail', function () { Mail::fake(); Queue::fake(); $this->postJson(route('webhooks.stripe'), [ 'id' => 'evt_checkout', 'type' => 'checkout.session.completed', 'data' => ['object' => [ 'id' => 'cs_first', 'payment_status' => 'paid', 'subscription' => 'sub_first', 'customer_details' => ['email' => 'neu@example.com', 'name' => 'Neu GmbH'], 'amount_total' => 21480, 'currency' => 'eur', 'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'], ]], ])->assertOk(); // Stripe then sends the checkout's OWN invoice. It is not a renewal, and // invoicing it here would send the same customer a second document for one // purchase in the same minute. $this->postJson(route('webhooks.stripe'), [ 'id' => 'evt_first_invoice', 'type' => 'invoice.paid', 'data' => ['object' => [ 'id' => 'in_first', 'subscription' => 'sub_first', 'amount_paid' => 21480, 'billing_reason' => 'subscription_create', 'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp, ]], ])->assertOk(); expect(Invoice::query()->count())->toBe(1) ->and(Invoice::query()->sole()->order_id)->not->toBeNull() ->and(Invoice::query()->sole()->stripe_invoice_id)->toBeNull(); Mail::assertQueuedCount(1); Mail::assertQueued(InvoiceMail::class); });