CluPilotCloud/app/Services/Billing/InvoiceNumbers.php

78 lines
3.0 KiB
PHP

<?php
namespace App\Services\Billing;
use App\Models\InvoiceSeries;
use Illuminate\Support\Facades\DB;
/**
* Handing out invoice numbers, one at a time, without gaps.
*
* Gapless and ascending is a legal requirement in Austria, not a preference,
* and it is the reason none of this is derived from the invoices table. Taking
* "the highest number that exists plus one" hands the same number out twice the
* moment two sales land together, and hands a used number back out after a row
* is removed — a number that is already printed on a document somebody has.
*
* So the counter is a column, and it is read and advanced under a row lock
* inside the caller's transaction. Two concurrent sales queue; neither gets the
* other's number.
*/
final class InvoiceNumbers
{
/**
* Take the next number in a series.
*
* MUST be called inside a transaction — the lock is worthless otherwise,
* and so is the invoice row that was supposed to consume the number.
*
* @return array{string, int, ?int} the formatted number, its sequence, its year
*/
public function next(InvoiceSeries $series, ?int $year = null): array
{
if (! DB::transactionLevel()) {
throw new \LogicException(
'Invoice numbers must be taken inside a transaction: the number and the invoice have to commit together, or the series has a gap in it.'
);
}
$year ??= (int) now()->format('Y');
// lockForUpdate, not an atomic increment: the year check below has to
// see a value nobody else can change between the read and the write.
$locked = InvoiceSeries::query()
->whereKey($series->getKey())
->lockForUpdate()
->firstOrFail();
$sequence = (int) $locked->next_number;
$counterYear = $locked->counter_year;
// A yearly series starts at 1 in a new year. Checked against the stored
// year rather than against "is this January": an installation that
// issued nothing for fourteen months must still restart, and one that
// issues on New Year's Eve at 23:59 must not restart for the invoice
// that follows a minute later under the old year.
if ($locked->yearly_reset && $counterYear !== null && (int) $counterYear !== $year) {
$sequence = 1;
}
$locked->forceFill([
'next_number' => $sequence + 1,
'counter_year' => $locked->yearly_reset ? $year : null,
])->save();
return [$this->format($locked, $sequence, $year), $sequence, $locked->yearly_reset ? $year : null];
}
/** "RE-2026-0001", or "RE-0001" for a series that does not reset. */
private function format(InvoiceSeries $series, int $sequence, int $year): string
{
$padded = str_pad((string) $sequence, (int) $series->digits, '0', STR_PAD_LEFT);
return $series->yearly_reset
? sprintf('%s-%d-%s', $series->prefix, $year, $padded)
: sprintf('%s-%s', $series->prefix, $padded);
}
}