234 lines
9.2 KiB
PHP
234 lines
9.2 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 proration or a charge raised by hand', 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();
|
|
|
|
// Stripe worked that amount out against ITS boundaries and its own view of
|
|
// the account. A document written from our contract snapshot would state a
|
|
// sum that is not the one charged. The money is in the register either way.
|
|
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);
|
|
});
|