Lay the foundation for self-issued invoices: series, numbers, frozen documents
An invoice is a tax document, so two things drive the shape of this. It is frozen at issue. Everything the finished document says — issuer address, VAT number, bank details, customer address, every line, the rate, the totals — is copied into the invoice row when the number is assigned. The PDF is rendered from that and never stored, which is what was asked for; rendering it from today's settings instead would have been simpler and wrong, because an address changed in 2028 would rewrite an invoice from 2026. The width of this table IS the feature. Numbers are gapless and ascending, which is a legal requirement rather than a preference, and that is why none of it is derived from the invoices table. "Highest number 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 already printed on a document somebody holds. The counter is a column, read and advanced under a row lock inside the caller's transaction, and taking one outside a transaction throws rather than quietly working. Four Rechnungskreise seeded — Rechnung, Gutschrift, Storno, Anzahlung — each with its own prefix and its own counter, because a credit note is not an invoice with a minus sign in front of it. Seeded rather than left to the operator: an installation with no series cannot issue anything, and discovering that at the first sale is the worst possible moment. TCPDF added, for the renderer that comes next. There is deliberately no test for the outside-a-transaction guard, and a note where it would have been: RefreshDatabase opens a transaction around every test, so the guard cannot fire and a green test for it would be green for a reason that has nothing to do with the guard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feat/granted-plans
parent
864126ec7f
commit
735daf46a4
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\HasUuid;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* An issued invoice, frozen.
|
||||
*
|
||||
* `snapshot` holds everything the document says — issuer, customer, lines, rate,
|
||||
* totals — as it was on the day it was issued. The PDF is rendered from that and
|
||||
* never stored, so an address changed in 2028 cannot rewrite an invoice from
|
||||
* 2026. Nothing here is ever edited: a wrong invoice is corrected by issuing a
|
||||
* cancellation and a new one, which is the only lawful way to correct one.
|
||||
*/
|
||||
class Invoice extends Model
|
||||
{
|
||||
use HasUuid;
|
||||
|
||||
protected $fillable = [
|
||||
'invoice_series_id', 'customer_id', 'order_id', 'number', 'number_year', 'number_sequence',
|
||||
'issued_on', 'due_on', 'snapshot', 'net_cents', 'tax_cents', 'gross_cents', 'currency',
|
||||
'cancels_invoice_id', 'sent_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'snapshot' => 'array',
|
||||
'issued_on' => 'date',
|
||||
'due_on' => 'date',
|
||||
'sent_at' => 'datetime',
|
||||
'net_cents' => 'integer',
|
||||
'tax_cents' => 'integer',
|
||||
'gross_cents' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function series(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InvoiceSeries::class, 'invoice_series_id');
|
||||
}
|
||||
|
||||
public function customer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Customer::class);
|
||||
}
|
||||
|
||||
public function order(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Order::class);
|
||||
}
|
||||
|
||||
/** What this document cancels, if it is a cancellation. */
|
||||
public function cancels(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Invoice::class, 'cancels_invoice_id');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\HasUuid;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* A Rechnungskreis: invoice, credit note, cancellation, deposit.
|
||||
*
|
||||
* Each keeps its own prefix and its own gapless counter, because they are
|
||||
* separate number ranges in law as much as in practice — a credit note is not
|
||||
* an invoice with a minus sign in front of it.
|
||||
*/
|
||||
class InvoiceSeries extends Model
|
||||
{
|
||||
use HasUuid;
|
||||
|
||||
protected $table = 'invoice_series';
|
||||
|
||||
protected $fillable = [
|
||||
'kind', 'name', 'prefix', 'yearly_reset', 'digits', 'next_number', 'counter_year', 'active',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'yearly_reset' => 'boolean',
|
||||
'active' => 'boolean',
|
||||
'digits' => 'integer',
|
||||
'next_number' => 'integer',
|
||||
'counter_year' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function invoices(): HasMany
|
||||
{
|
||||
return $this->hasMany(Invoice::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@
|
|||
"livewire/livewire": "^3.0",
|
||||
"phpseclib/phpseclib": "^3.0",
|
||||
"spatie/laravel-permission": "^8.3",
|
||||
"tecnickcom/tcpdf": "^7.0",
|
||||
"wire-elements/modal": "^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,135 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Invoices, frozen at the moment they are issued.
|
||||
*
|
||||
* The PDF is never stored. It is rendered on demand — but never from today's
|
||||
* settings. Everything a finished invoice says is copied into `snapshot` when
|
||||
* the number is assigned: the issuer's address, its VAT number and bank
|
||||
* details, the customer's address, every line, the rate and the totals. Change
|
||||
* the company address in 2028 and an invoice from 2026 still shows the address
|
||||
* it was issued under, because that is what a tax document is.
|
||||
*
|
||||
* Rendering from live settings would have been simpler and wrong. It is the
|
||||
* reason this table is wide: the width IS the feature.
|
||||
*
|
||||
* Numbers come from `invoice_series` — one row per Rechnungskreis (invoice,
|
||||
* credit note, cancellation, deposit), each with its own prefix and its own
|
||||
* gapless counter. Gapless is a legal requirement, not a preference, which is
|
||||
* why the counter lives in the database under a row lock rather than being
|
||||
* derived from whatever rows happen to exist.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('invoice_series', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->uuid()->unique();
|
||||
|
||||
// 'invoice' | 'credit_note' | 'cancellation' | 'deposit'. A string
|
||||
// rather than an enum column: adding a kind must not be a migration
|
||||
// on a table that holds legal records.
|
||||
$table->string('kind', 32);
|
||||
$table->string('name');
|
||||
|
||||
// "RE", "GS", "ST" — what the number starts with.
|
||||
$table->string('prefix', 12);
|
||||
|
||||
// The year is part of the number and resets the counter, or it is
|
||||
// not and does not. Both are lawful; mixing them is not, so it is
|
||||
// recorded per series rather than assumed.
|
||||
$table->boolean('yearly_reset')->default(true);
|
||||
$table->unsignedSmallInteger('digits')->default(4);
|
||||
|
||||
// The counter itself. Never derived from the invoices table: a
|
||||
// deleted or failed row would silently reuse a number that has
|
||||
// already been on a document somebody has.
|
||||
$table->unsignedInteger('next_number')->default(1);
|
||||
$table->unsignedSmallInteger('counter_year')->nullable();
|
||||
|
||||
$table->boolean('active')->default(true);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['kind', 'prefix']);
|
||||
});
|
||||
|
||||
Schema::create('invoices', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->uuid()->unique();
|
||||
|
||||
$table->foreignId('invoice_series_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignId('order_id')->nullable()->constrained()->nullOnDelete();
|
||||
|
||||
// The number as it appears on the document, and its parts, so a
|
||||
// list can be sorted and filtered without parsing the string back.
|
||||
$table->string('number')->unique();
|
||||
$table->unsignedSmallInteger('number_year')->nullable();
|
||||
$table->unsignedInteger('number_sequence');
|
||||
|
||||
$table->date('issued_on');
|
||||
$table->date('due_on')->nullable();
|
||||
|
||||
// Everything the document says. See the class comment: this is what
|
||||
// makes the PDF reproducible without storing the PDF.
|
||||
$table->json('snapshot');
|
||||
|
||||
// Denormalised out of the snapshot, because a list of invoices has
|
||||
// to be sortable by money without opening every JSON column.
|
||||
$table->integer('net_cents');
|
||||
$table->integer('tax_cents');
|
||||
$table->integer('gross_cents');
|
||||
$table->string('currency', 3);
|
||||
|
||||
// A cancellation points at what it cancels. Nothing is ever
|
||||
// deleted or edited — a wrong invoice is corrected by issuing
|
||||
// another document, which is the only lawful way to correct one.
|
||||
$table->foreignId('cancels_invoice_id')->nullable()->constrained('invoices')->nullOnDelete();
|
||||
|
||||
$table->timestamp('sent_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['customer_id', 'issued_on']);
|
||||
});
|
||||
|
||||
// The four an Austrian business needs on day one. Seeded rather than
|
||||
// left to the operator: an installation with no series cannot issue an
|
||||
// invoice at all, and discovering that at the moment of the first sale
|
||||
// is the worst possible time.
|
||||
$now = now();
|
||||
|
||||
foreach ([
|
||||
['invoice', 'Rechnung', 'RE'],
|
||||
['credit_note', 'Gutschrift', 'GS'],
|
||||
['cancellation', 'Storno', 'ST'],
|
||||
['deposit', 'Anzahlung', 'AZ'],
|
||||
] as [$kind, $name, $prefix]) {
|
||||
DB::table('invoice_series')->insert([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'kind' => $kind,
|
||||
'name' => $name,
|
||||
'prefix' => $prefix,
|
||||
'yearly_reset' => true,
|
||||
'digits' => 4,
|
||||
'next_number' => 1,
|
||||
'counter_year' => null,
|
||||
'active' => true,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('invoices');
|
||||
Schema::dropIfExists('invoice_series');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
<?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',
|
||||
]);
|
||||
});
|
||||
Loading…
Reference in New Issue