CluPilotCloud/database/migrations/2026_07_29_130000_create_in...

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');
}
};