Accept isolated invoice issuance and reproducible PDF delivery

This commit is contained in:
2026-10-27 08:56:13 +02:00
parent 9f480045fa
commit 4276aca5c2
25 changed files with 1072 additions and 1 deletions
@@ -0,0 +1,89 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
$old=substr(DB::selectOne("SELECT pg_get_constraintdef(oid) AS d FROM pg_constraint WHERE conrelid='permission_grants'::regclass AND conname='permission_grants_scope_check'")->d,6);
DB::statement('ALTER TABLE permission_grants DROP CONSTRAINT permission_grants_scope_check');
DB::statement("ALTER TABLE permission_grants ADD CONSTRAINT permission_grants_scope_check CHECK (($old) OR (permission IN ('invoice_series.view','invoice_series.propose','invoice_series.approve') AND unit_id IS NULL) OR (permission IN ('service_invoices.view','service_invoices.issue','service_invoices.correction_request') AND unit_id IS NOT NULL))");
Schema::create('si_proposals',function(Blueprint $t) {
$t->id(); $t->unsignedBigInteger('tenant_id'); $t->unsignedBigInteger('company_id'); $t->unsignedBigInteger('unit_id');
$t->string('kind'); $t->string('identifier'); $t->unsignedInteger('version'); $t->jsonb('data');
$t->foreignId('previous_id')->nullable()->constrained('si_proposals'); $t->foreignId('actor_id')->constrained('users'); $t->timestampTz('created_at');
$t->unique(['company_id','kind','identifier','version']);
$t->foreign(['tenant_id','company_id','unit_id'])->references(['tenant_id','company_id','id'])->on('units');
});
Schema::create('si_decisions',function(Blueprint $t) {
$t->id(); $t->foreignId('proposal_id')->unique()->constrained('si_proposals'); $t->foreignId('actor_id')->constrained('users'); $t->string('decision'); $t->text('reason'); $t->timestampTz('created_at');
});
Schema::create('si_counters',function(Blueprint $t) {
$t->id(); $t->unsignedBigInteger('tenant_id'); $t->unsignedBigInteger('company_id'); $t->unsignedBigInteger('unit_id'); $t->string('code'); $t->unsignedBigInteger('last_number');
$t->unique(['company_id','code']);
$t->foreign(['tenant_id','company_id','unit_id'])->references(['tenant_id','company_id','id'])->on('units');
});
Schema::create('si_invoices',function(Blueprint $t) {
$t->id(); $t->unsignedBigInteger('tenant_id'); $t->unsignedBigInteger('company_id'); $t->unsignedBigInteger('unit_id');
$t->foreignId('draft_id')->unique()->constrained('a_billing_drafts'); $t->foreignId('source_id')->unique()->constrained('a_service_drafts');
$t->foreignId('series_id')->constrained('si_proposals'); $t->foreignId('rule_id')->constrained('si_proposals');
$t->string('series_code'); $t->unsignedBigInteger('number'); $t->jsonb('snapshot'); $t->text('pdf_base64'); $t->string('pdf_sha256',64);
$t->foreignId('actor_id')->constrained('users'); $t->timestampTz('issued_at');
$t->unique(['company_id','series_code','number']);
$t->foreign(['tenant_id','company_id','unit_id'])->references(['tenant_id','company_id','id'])->on('units');
});
Schema::create('si_events',function(Blueprint $t) {
$t->id(); $t->foreignId('invoice_id')->unique()->constrained('si_invoices'); $t->string('type'); $t->jsonb('payload'); $t->timestampTz('created_at');
});
Schema::create('si_outbox',function(Blueprint $t) {
$t->foreignId('event_id')->primary()->constrained('si_events'); $t->string('state')->default('captured_no_processing');
});
Schema::create('si_commands',function(Blueprint $t) {
$t->id(); $t->unsignedBigInteger('tenant_id'); $t->unsignedBigInteger('company_id'); $t->unsignedBigInteger('unit_id');
$t->foreignId('actor_id')->constrained('users'); $t->string('command_key',100); $t->string('request_hash',64); $t->jsonb('result'); $t->timestampTz('created_at');
$t->unique(['company_id','command_key']);
$t->foreign(['tenant_id','company_id','unit_id'])->references(['tenant_id','company_id','id'])->on('units');
});
Schema::create('si_correction_requests',function(Blueprint $t) {
$t->id(); $t->foreignId('invoice_id')->constrained('si_invoices'); $t->foreignId('actor_id')->constrained('users'); $t->text('reason'); $t->timestampTz('created_at');
});
DB::unprepared(<<<'SQL'
CREATE OR REPLACE FUNCTION sgf_si_proposal_guard() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NEW.kind NOT IN ('series','temporal') OR (NEW.previous_id IS NULL AND NEW.version<>1)
OR (NEW.previous_id IS NOT NULL AND NOT EXISTS(SELECT 1 FROM si_proposals p WHERE p.id=NEW.previous_id AND p.tenant_id=NEW.tenant_id AND p.company_id=NEW.company_id AND p.unit_id=NEW.unit_id AND p.kind=NEW.kind AND p.identifier=NEW.identifier AND p.version=NEW.version-1)) THEN RAISE EXCEPTION 'SGF_SI_PROPOSAL_SCOPE'; END IF;
IF NEW.kind='temporal' AND NOT EXISTS(SELECT 1 FROM a_billing_drafts b WHERE b.id=(NEW.data->>'draft_id')::bigint AND b.tenant_id=NEW.tenant_id AND b.company_id=NEW.company_id AND b.unit_id=NEW.unit_id) THEN RAISE EXCEPTION 'SGF_SI_DRAFT_SCOPE'; END IF;
RETURN NEW;
END; $$;
CREATE TRIGGER si_proposal_guard BEFORE INSERT ON si_proposals FOR EACH ROW EXECUTE FUNCTION sgf_si_proposal_guard();
CREATE OR REPLACE FUNCTION sgf_si_decision_guard() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NEW.decision NOT IN ('approved','refused') OR EXISTS(SELECT 1 FROM si_proposals WHERE id=NEW.proposal_id AND actor_id=NEW.actor_id) THEN RAISE EXCEPTION 'SGF_SI_SELF_DECISION'; END IF;
RETURN NEW;
END; $$;
CREATE TRIGGER si_decision_guard BEFORE INSERT ON si_decisions FOR EACH ROW EXECUTE FUNCTION sgf_si_decision_guard();
CREATE OR REPLACE FUNCTION sgf_si_counter_guard() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP='DELETE' THEN RAISE EXCEPTION 'SGF_SI_COUNTER_DELETE'; END IF;
IF ROW(NEW.tenant_id,NEW.company_id,NEW.unit_id,NEW.code,NEW.id) IS DISTINCT FROM ROW(OLD.tenant_id,OLD.company_id,OLD.unit_id,OLD.code,OLD.id) OR NEW.last_number<>OLD.last_number+1 THEN RAISE EXCEPTION 'SGF_SI_COUNTER_CHANGE'; END IF;
RETURN NEW;
END; $$;
CREATE TRIGGER si_counter_guard BEFORE UPDATE OR DELETE ON si_counters FOR EACH ROW EXECUTE FUNCTION sgf_si_counter_guard();
CREATE OR REPLACE FUNCTION sgf_si_invoice_guard() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NOT EXISTS(SELECT 1 FROM a_billing_drafts b WHERE b.id=NEW.draft_id AND b.source_id=NEW.source_id AND b.tenant_id=NEW.tenant_id AND b.company_id=NEW.company_id AND b.unit_id=NEW.unit_id)
OR NOT EXISTS(SELECT 1 FROM si_proposals p JOIN si_decisions d ON d.proposal_id=p.id AND d.decision='approved' WHERE p.id=NEW.series_id AND p.kind='series' AND p.tenant_id=NEW.tenant_id AND p.company_id=NEW.company_id AND p.unit_id=NEW.unit_id AND p.data->>'code'=NEW.series_code)
OR NOT EXISTS(SELECT 1 FROM si_proposals p JOIN si_decisions d ON d.proposal_id=p.id AND d.decision='approved' WHERE p.id=NEW.rule_id AND p.kind='temporal' AND p.tenant_id=NEW.tenant_id AND p.company_id=NEW.company_id AND p.unit_id=NEW.unit_id AND (p.data->>'draft_id')::bigint=NEW.draft_id)
OR NOT EXISTS(SELECT 1 FROM si_counters c WHERE c.tenant_id=NEW.tenant_id AND c.company_id=NEW.company_id AND c.unit_id=NEW.unit_id AND c.code=NEW.series_code AND c.last_number=NEW.number)
OR NEW.snapshot->>'notice' IS DISTINCT FROM 'TESTE — SEM VALIDADE FISCAL'
THEN RAISE EXCEPTION 'SGF_SI_INVOICE_SCOPE'; END IF;
RETURN NEW;
END; $$;
CREATE TRIGGER si_invoice_guard BEFORE INSERT ON si_invoices FOR EACH ROW EXECUTE FUNCTION sgf_si_invoice_guard();
SQL);
foreach(['si_proposals','si_decisions','si_invoices','si_events','si_outbox','si_commands','si_correction_requests'] as $table) DB::statement("CREATE TRIGGER {$table}_immutable BEFORE UPDATE OR DELETE ON {$table} FOR EACH ROW EXECUTE FUNCTION sgf_append_only_audit()");
}
public function down(): void { throw new RuntimeException('Document history requires a reviewed preservation procedure.'); }
};