136 lines
5.5 KiB
PHP
136 lines
5.5 KiB
PHP
<?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');
|
|
}
|
|
};
|