CluPilotCloud/tests/Feature/Billing/RenewalInvoiceTest.php

238 lines
9.5 KiB
PHP

<?php
use App\Mail\InvoiceMail;
use App\Models\Customer;
use App\Models\Invoice;
use App\Models\InvoiceSeries;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use App\Support\CompanyProfile;
use App\Support\Settings;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
/**
* The invoice a renewal owes, which for a long time nobody sent.
*
* InvoiceMail went out from exactly one place — the first purchase — so a
* customer paying every month for a year received one invoice, for month one.
* Stripe already says when the money landed: `invoice.paid` with
* `billing_reason: subscription_cycle`. What was missing was the document.
*/
beforeEach(function () {
// Fixed, because the document states a service period and a number that
// carries the year, and both are the point of these tests.
Carbon::setTestNow('2026-08-01 09:00:00');
CompanyProfile::put([
'name' => '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);
});