78 lines
3.0 KiB
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);
|
|
}
|
|
}
|