CluPilotCloud/tests/Feature/Billing/InvoiceNumberTest.php

99 lines
4.0 KiB
PHP

<?php
use App\Models\InvoiceSeries;
use App\Services\Billing\InvoiceNumbers;
use Illuminate\Support\Facades\DB;
/**
* Gapless and ascending, which is a legal requirement rather than a preference.
*
* All of this exists because the obvious implementation — "the highest number
* that exists, plus one" — hands the same number to two sales that land
* together, and hands a used number back out after a row is removed. A number
* that has already been printed on a document somebody holds.
*/
it('hands out the seeded series in order', function () {
$s = InvoiceSeries::query()->where('kind', 'invoice')->firstOrFail();
$numbers = [];
foreach (range(1, 3) as $ignored) {
DB::transaction(function () use ($s, &$numbers) {
[$number] = app(InvoiceNumbers::class)->next($s, 2026);
$numbers[] = $number;
});
}
expect($numbers)->toBe(['RE-2026-0001', 'RE-2026-0002', 'RE-2026-0003']);
});
it('restarts a yearly series in a new year, and only then', function () {
// Checked against the stored year, not against "is it January": an
// installation that issued nothing for fourteen months must still restart,
// and one issuing at 23:59 on New Year's Eve must not restart the invoice
// that follows a minute later under the old year.
$s = InvoiceSeries::query()->where('kind', 'invoice')->firstOrFail();
DB::transaction(fn () => app(InvoiceNumbers::class)->next($s, 2026));
DB::transaction(fn () => app(InvoiceNumbers::class)->next($s, 2026));
[$next] = DB::transaction(fn () => app(InvoiceNumbers::class)->next($s->refresh(), 2027));
expect($next)->toBe('RE-2027-0001');
[$again] = DB::transaction(fn () => app(InvoiceNumbers::class)->next($s->refresh(), 2027));
expect($again)->toBe('RE-2027-0002');
});
it('never restarts a series that does not reset yearly', function () {
$s = InvoiceSeries::query()->where('kind', 'invoice')->firstOrFail();
$s->update(['yearly_reset' => false, 'next_number' => 41]);
[$number] = DB::transaction(fn () => app(InvoiceNumbers::class)->next($s->refresh(), 2026));
expect($number)->toBe('RE-0041');
[$across] = DB::transaction(fn () => app(InvoiceNumbers::class)->next($s->refresh(), 2027));
expect($across)->toBe('RE-0042');
});
it('keeps every Rechnungskreis on its own counter', function () {
// A credit note is not an invoice with a minus sign in front of it, and
// sharing a counter would put gaps in both.
$invoice = InvoiceSeries::query()->where('kind', 'invoice')->firstOrFail();
$credit = InvoiceSeries::query()->where('kind', 'credit_note')->firstOrFail();
[$a] = DB::transaction(fn () => app(InvoiceNumbers::class)->next($invoice, 2026));
[$b] = DB::transaction(fn () => app(InvoiceNumbers::class)->next($credit, 2026));
[$c] = DB::transaction(fn () => app(InvoiceNumbers::class)->next($invoice->refresh(), 2026));
expect($a)->toBe('RE-2026-0001')
->and($b)->toBe('GS-2026-0001')
->and($c)->toBe('RE-2026-0002');
});
/*
* There is deliberately no test for the "must be inside a transaction" guard.
*
* RefreshDatabase wraps every test in one, so DB::transactionLevel() is already
* above zero before the first line of any test here runs — the guard cannot
* fire, and a test written to prove it does would be green for a reason that
* has nothing to do with the guard. A test that passes for the wrong reason is
* worse than no test, so this note stands in its place.
*/
it('starts every seeded Rechnungskreis at one', function () {
// An installation with no series cannot issue an invoice at all, and
// finding that out at the moment of the first sale is the worst possible
// time.
//
// toEqual, not toBe: pluck returns them in whatever order the database
// hands them back, and the order of four seeded rows is not the assertion.
expect(InvoiceSeries::query()->pluck('prefix', 'kind')->all())->toEqual([
'invoice' => 'RE',
'credit_note' => 'GS',
'cancellation' => 'ST',
'deposit' => 'AZ',
]);
});