feat: add private existing business draft foundation

This commit is contained in:
2026-09-28 17:01:10 +02:00
parent 24a0ef1db3
commit 519b256ffd
10 changed files with 398 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Models;
class ExistingBusiness extends DomainRecord
{
protected $hidden = ['payload'];
protected $casts = ['payload' => 'encrypted:array', 'version_no' => 'integer'];
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
class ExistingBusinessMandate extends Model
{
use HasUuids;
protected $guarded = ['id'];
protected $hidden = ['payload'];
protected $casts = ['payload' => 'encrypted:array', 'valid_from' => 'date', 'valid_until' => 'date'];
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
class ExistingBusinessObject extends Model
{
use HasUuids;
protected $guarded = ['id'];
protected $hidden = ['payload'];
protected $casts = ['payload' => 'encrypted:array'];
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Policies;
use App\Models\ExistingBusiness;
use App\Models\User;
class ExistingBusinessPolicy
{
public function create(User $user): bool
{
return $user->hasVerifiedEmail();
}
public function update(User $user, ExistingBusiness $business): bool
{
return $user->hasVerifiedEmail() && $business->owner_user_id === $user->id && ! $business->trashed();
}
public function view(User $user, ExistingBusiness $business): bool
{
return $this->update($user, $business);
}
public function delete(User $user, ExistingBusiness $business): bool
{
return $this->update($user, $business);
}
}
+79
View File
@@ -0,0 +1,79 @@
<?php
namespace App\Services;
use App\Models\ExistingBusiness;
use App\Models\User;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
final class ExistingBusinessDrafts
{
public const MODALITIES = ['company_sale', 'participation_sale', 'capital_increase', 'financial_investor', 'strategic_partner', 'buyer_search', 'position_assignment', 'franchise', 'asset_sale', 'asset_lease', 'business_unit_transfer', 'joint_venture', 'succession', 'recovery', 'expansion', 'internationalization', 'real_estate', 'agricultural_investment', 'land_right', 'mining_license', 'forestry_license'];
/** @param array<string,mixed> $input */
public function save(User $actor, array $input, ?string $id = null): ExistingBusiness
{
$data = Validator::make($input, [
'modality' => ['required', 'in:'.implode(',', self::MODALITIES)],
'classification' => ['required', 'in:internal,confidential,restricted'],
'version_no' => [$id ? 'required' : 'prohibited', 'integer', 'min:1'],
'reason' => ['required', 'string', 'min:10', 'max:1000'],
'payload' => ['required', 'array:title,description,declared_reason,source_reference,valid_until'],
'payload.title' => ['required', 'string', 'max:200'],
'payload.description' => ['nullable', 'string', 'max:10000'],
'payload.declared_reason' => ['nullable', 'string', 'max:2000'],
'payload.source_reference' => ['nullable', 'string', 'max:2000'],
'payload.valid_until' => ['nullable', 'date_format:Y-m-d'],
])->validate();
return DB::transaction(function () use ($actor, $id, $data) {
if ($id) {
$business = ExistingBusiness::whereKey($id)->lockForUpdate()->firstOrFail();
Gate::forUser($actor)->authorize('update', $business);
abort_unless($business->version_no === $data['version_no'], 409, 'O rascunho foi alterado. Recarregue antes de gravar.');
$business->version_no++;
} else {
Gate::forUser($actor)->authorize('create', ExistingBusiness::class);
if (! $actor->exists || $actor->id < 1) {
abort(403, 'A conta deve estar registada.');
}
$business = new ExistingBusiness;
$business->code = 'MNE-'.Str::uuid();
$business->owner_user_id = $actor->id;
$business->version_no = 1;
$business->status = 'draft';
}
$business->fill(['modality' => $data['modality'], 'classification' => $data['classification'], 'payload' => $data['payload']]);
$business->save();
$this->snapshot($business, $actor, $id ? 'updated' : 'created', $data['reason']);
return $business;
});
}
public function archive(User $actor, string $id, int $version, string $reason): void
{
Validator::make(['reason' => $reason], ['reason' => ['required', 'string', 'min:10', 'max:1000']])->validate();
DB::transaction(function () use ($actor, $id, $version, $reason) {
$business = ExistingBusiness::whereKey($id)->lockForUpdate()->firstOrFail();
Gate::forUser($actor)->authorize('delete', $business);
abort_unless($business->version_no === $version, 409);
$business->version_no++;
$business->save();
$business->delete();
$this->snapshot($business, $actor, 'archived', $reason);
});
}
private function snapshot(ExistingBusiness $business, User $actor, string $action, string $reason): void
{
$snapshot = json_encode(['record' => $business->attributesToArray(), 'payload' => $business->payload], JSON_THROW_ON_ERROR);
DB::table('existing_business_revisions')->insert(['id' => (string) Str::uuid(), 'business_id' => $business->id,
'version_no' => $business->version_no, 'actor_id' => $actor->id, 'action' => $action,
'reason' => Crypt::encryptString($reason), 'snapshot' => Crypt::encryptString($snapshot), 'sha256' => hash('sha256', $snapshot)]);
}
}
@@ -0,0 +1,84 @@
<?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
{
if (DB::connection()->getDriverName() !== 'pgsql') {
throw new RuntimeException('PostgreSQL required.');
}
Schema::create('existing_businesses', function (Blueprint $t) {
$t->uuid('id')->primary();
$t->string('code')->unique();
$t->string('source_system')->nullable();
$t->string('legacy_id')->nullable();
$t->unique(['source_system', 'legacy_id']);
$t->foreignId('owner_user_id')->constrained('users')->restrictOnDelete();
$t->foreignUuid('organization_id')->nullable()->constrained('organizations')->restrictOnDelete();
$t->foreignUuid('investment_item_id')->nullable()->unique()->constrained('investment_items')->restrictOnDelete();
$t->string('modality');
$t->string('status')->default('draft');
$t->string('classification')->default('confidential');
$t->unsignedInteger('version_no')->default(1);
$t->text('payload');
$t->timestampsTz();
$t->softDeletesTz();
});
// Phase 1 deliberately cannot publish. Future workflow must replace this constraint explicitly.
DB::statement("ALTER TABLE existing_businesses ADD CHECK(status='draft'), ADD CHECK(version_no>0), ADD CHECK(classification IN ('internal','confidential','restricted')), ADD CHECK((source_system IS NULL)=(legacy_id IS NULL)), ADD CHECK(modality IN ('company_sale','participation_sale','capital_increase','financial_investor','strategic_partner','buyer_search','position_assignment','franchise','asset_sale','asset_lease','business_unit_transfer','joint_venture','succession','recovery','expansion','internationalization','real_estate','agricultural_investment','land_right','mining_license','forestry_license'))");
Schema::create('existing_business_objects', function (Blueprint $t) {
$t->uuid('id')->primary();
$t->foreignUuid('business_id')->constrained('existing_businesses')->restrictOnDelete();
$t->string('kind');
$t->string('classification')->default('restricted');
$t->string('verification_status')->default('pending');
$t->text('payload');
$t->timestampsTz();
});
DB::statement("ALTER TABLE existing_business_objects ADD CHECK(kind IN ('company','participation','asset','property','unit','improvement','agricultural_investment','land_right','contract','license','authorization','encumbrance','valuation','capital_request')), ADD CHECK(classification IN ('confidential','restricted')), ADD CHECK(verification_status='pending')");
Schema::create('existing_business_mandates', function (Blueprint $t) {
$t->uuid('id')->primary();
$t->foreignUuid('business_id')->constrained('existing_businesses')->restrictOnDelete();
$t->foreignId('representative_user_id')->constrained('users')->restrictOnDelete();
$t->foreignUuid('document_id')->constrained('documents')->restrictOnDelete();
$t->date('valid_from');
$t->date('valid_until');
$t->string('verification_status')->default('pending');
$t->text('payload');
$t->timestampsTz();
});
DB::statement("ALTER TABLE existing_business_mandates ADD CHECK(valid_until>=valid_from), ADD CHECK(verification_status='pending')");
Schema::create('existing_business_revisions', function (Blueprint $t) {
$t->uuid('id')->primary();
$t->foreignUuid('business_id')->constrained('existing_businesses')->restrictOnDelete();
$t->unsignedInteger('version_no');
$t->foreignId('actor_id')->constrained('users')->restrictOnDelete();
$t->string('action');
$t->text('reason');
$t->text('snapshot');
$t->char('sha256', 64);
$t->timestampTz('created_at')->useCurrent();
$t->unique(['business_id', 'version_no']);
});
DB::unprepared("CREATE OR REPLACE FUNCTION reject_business_revision_change() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'Business revisions are immutable'; END $$; CREATE TRIGGER immutable_business_revision BEFORE UPDATE OR DELETE ON existing_business_revisions FOR EACH ROW EXECUTE FUNCTION reject_business_revision_change()");
}
public function down(): void
{
$tables = ['existing_business_revisions', 'existing_business_mandates', 'existing_business_objects', 'existing_businesses'];
foreach ($tables as $table) {
if (DB::table($table)->exists()) {
throw new RuntimeException('Business data exists; destructive rollback refused.');
}
}
foreach ($tables as $table) {
Schema::drop($table);
}
DB::statement('DROP FUNCTION reject_business_revision_change()');
}
};
@@ -1,5 +1,7 @@
# Mercado de Negócios Existentes — Fase 0
Actualização posterior: o utilizador solicitou continuação. Foi implementado e testado o [núcleo privado da Fase 1](phase-1.md). A presente página conserva o diagnóstico e a decisão histórica da Fase 0; não é o estado mais recente da implementação.
Data: 28/09/2026. Branch: `feature/existing-business-phase-0`.
## Execução e decisão
@@ -0,0 +1,60 @@
# Fase 1 — núcleo privado do modelo canónico
28/09/2026. Continuação expressamente solicitada após a Fase 0. Estado: **implementação parcial; Go condicionado para revisão técnica, No-Go para publicação/piloto real**. Não se declara concluído todo o modelo solicitado. Fase 2 não iniciada.
## Plano aplicado e modelo
Migration aditiva `2026_09_28_130000_existing_business_foundation.php`, três modelos, `ExistingBusinessPolicy`, serviço `ExistingBusinessDrafts` e testes de integração. Reutiliza users, organizations, investment_items e documents, sem converter os registos existentes. Nenhuma API, ecrã, integração ou contrato anterior foi modificado. O serviço é interno; não há rota para submissão pública nesta fase.
```mermaid
erDiagram
users ||--o{ existing_businesses : owns
organizations o|--o{ existing_businesses : identifies
investment_items o|--o| existing_businesses : maps
existing_businesses ||--o{ existing_business_objects : describes
existing_businesses ||--o{ existing_business_mandates : receives
documents ||--o{ existing_business_mandates : evidences
users ||--o{ existing_business_mandates : represents
existing_businesses ||--o{ existing_business_revisions : versions
```
## Dicionário implementado
| Tabela | Campos e invariantes |
| --- | --- |
| existing_businesses | UUID, code único permanente, source_system/legacy_id conjuntamente nulos ou presentes e únicos, owner_user_id obrigatório, organization_id opcional, investment_item_id opcional e único, modality, status exclusivamente draft, classification internal/confidential/restricted, version_no positivo, payload cifrado, timestamps e deleted_at |
| existing_business_objects | UUID, business_id, kind distinguindo company/participation/asset/property/unit/improvement/agricultural_investment/land_right/contract/license/authorization/encumbrance/valuation/capital_request, classification confidential/restricted, verification_status exclusivamente pending, payload cifrado, timestamps |
| existing_business_mandates | UUID, business_id, representative_user_id, document_id, valid_from/valid_until coerentes, verification_status exclusivamente pending, payload cifrado, timestamps. Documento anexado não comprova legitimidade. Nenhum acesso é concedido pelo mandato pendente. |
| existing_business_revisions | UUID, business_id/version_no únicos, actor_id, action, reason cifrada, snapshot cifrado, SHA-256 do snapshot e created_at. Trigger rejeita UPDATE/DELETE. |
Modalidades implementadas como vocabulário técnico, sem declarar admissibilidade: venda de empresa/participação, aumento de capital, investidor financeiro, parceiro estratégico, comprador, cessão, franchise, venda/arrendamento de activo, unidade, joint venture, sucessão, recuperação, expansão, internacionalização, imobiliário, investimento agrícola, direito fundiário e licenças mineira/florestal. As modalidades não fazem aprovação jurídica automática.
Payload do rascunho aceita apenas title, description, declared_reason, source_reference e valid_until. Todos permanecem privados e declarativos; podem estar incompletos. Não aceita identidade empresarial, mandato, valor, direito ou status arbitrários para os apresentar como verificados. Os modelos de objecto e mandato são suportes de persistência sem endpoints ou workflows ainda. Não os expor directamente antes de implementar validação por tipo e autorização por agregado.
## Autorização, histórico e confidencialidade
Conta com email verificado pode criar rascunho. Só o proprietário verificado pode ler/alterar/arquivar pelo policy. Gestor genérico não ganha acesso aos dados reservados. Os privilégios de revisão, MFA e segregação em dois níveis serão introduzidos explicitamente no workflow; ainda não há papel privilegiado MNE.
O serviço bloqueia a linha, compara versão, grava alteração e revisão na mesma transacção. Motivo é obrigatório e cifrado. Arquivo usa soft delete e incrementa versão. Payload não participa na serialização normal do modelo. Nenhum log transporta o conteúdo. A constraint de status permite apenas draft: não há caminho de publicação até uma migration futura acompanhada do workflow aprovado.
Limite: serviços/models não substituem privilégios de administração da BD. O histórico implementado cobre alterações de rascunho feitas pelo serviço; objectos e mandatos ainda precisam dos seus próprios serviços e snapshots antes de disponibilizar edição. SHA-256 verifica integridade de conteúdo; não é assinatura jurídica.
## Retenção e lacunas
Sem prazos inventados nem purga automática. Soft delete conserva evidências; duração, base legal, eliminação, litígio e gestão de chaves continuam pendentes da política. A não eliminação automática é uma precaução técnica temporária, não uma política de retenção homologada.
Faltam normalização detalhada de participações/valores/moedas, titularidade e cadeias documentais, emissor, área/unidades, contratos, ónus/restrições, transmissibilidade, relações territoriais, autorização editorial, consentimentos por finalidade, diligência, propostas e encerramento. Os payloads de objectos não são substituto final dessas entidades. Política/base legal, equipa nominal e carteira piloto continuam pendentes.
## Testes e reversão
Suite completa após integração: **153 testes, 1597 verificações aprovadas (34741 ms)**. PHPStan e Pint aprovados. A primeira execução completa identificou a nova dependência do catálogo no teste de rollback das zonas; o teste foi actualizado para remover/recriar a migration MNE na ordem correcta. Não se usou CASCADE. Resultado final sem falhas.
Base isolada `portal_services_test` em localhost:55441, APP_ENV=testing. `php vendor/bin/phpunit tests/Feature/ExistingBusinessFoundationTest.php --do-not-cache-result`: **5 testes, 17 verificações aprovadas**. Verifica cifragem e ocultação, proprietário versus outro gestor, email não verificado, versões desactualizadas, soft delete, histórico imutável, bloqueio de publicação e down/up vazio seguido de recusa de down com dados.
Migration executada apenas pelos testes na base isolada; não aplicada à base local da aplicação. Nenhum seeder ou dado real inserido. `down()` verifica as quatro tabelas antes de alterar qualquer uma, recusa dados, remove por dependência e não usa CASCADE nem DROP EXTENSION. Em ambiente com dados, manter o módulo inacessível e corrigir progressivamente; qualquer restauro exige backup verificado e reconciliação.
## Rastreabilidade deste incremento
RF-MNE-001: negócio/proprietário/modalidades/modelo/policy/testes implementados parcialmente; legitimidade não comprovada. RF-MNE-002/003: distinção tipológica de objectos e estado pending, sem validação jurídica. RF-MNE-004: cifragem, versão, histórico e publicação bloqueada; dupla aprovação ainda não implementada. RF-MNE-007: referência documental de mandato, sem sala segura. RF-MNE-009: rollback vazio testado e protecção contra perda de dados; migração de anúncios reais não executada.
Próximo incremento da própria Fase 1: contratos tipados e normalização dos objectos/valores, associação de fontes e governação por agregado, sem abrir publicação. Fase 2 só após modelo e invariantes completos.
@@ -0,0 +1,97 @@
<?php
namespace Tests\Feature;
use App\Models\ExistingBusiness;
use App\Models\User;
use App\Services\ExistingBusinessDrafts;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\TestCase;
class ExistingBusinessFoundationTest extends TestCase
{
use RefreshDatabase;
/** @return array<string,mixed> */
private function draft(): array
{
return ['modality' => 'company_sale', 'classification' => 'confidential', 'reason' => 'Synthetic draft created', 'payload' => ['title' => 'Private synthetic business']];
}
public function test_draft_is_encrypted_and_history_is_versioned_with_stale_writes_rejected(): void
{
$owner = User::factory()->create();
$service = app(ExistingBusinessDrafts::class);
$business = $service->save($owner, $this->draft());
$this->assertSame('draft', $business->status);
$this->assertArrayNotHasKey('payload', $business->toArray());
$raw = DB::table('existing_businesses')->where('id', $business->id)->value('payload');
$this->assertStringNotContainsString('Private synthetic', $raw);
$this->assertStringContainsString('Private synthetic', Crypt::decryptString($raw));
$service->save($owner, [...$this->draft(), 'version_no' => 1], $business->id);
$this->assertDatabaseCount('existing_business_revisions', 2);
try {
$service->save($owner, [...$this->draft(), 'version_no' => 1], $business->id);
$this->fail('Stale update accepted');
} catch (HttpException $e) {
$this->assertSame(409, $e->getStatusCode());
}
$this->assertDatabaseCount('existing_business_revisions', 2);
$service->archive($owner, $business->id, 2, 'Synthetic archive reason');
$this->assertNull(ExistingBusiness::find($business->id));
$this->assertSame(3, ExistingBusiness::withTrashed()->findOrFail($business->id)->version_no);
$this->assertDatabaseCount('existing_business_revisions', 3);
}
public function test_other_user_including_manager_cannot_edit_reserved_business(): void
{
$service = app(ExistingBusinessDrafts::class);
$business = $service->save(User::factory()->create(), $this->draft());
$other = User::factory()->create();
config(['portal.managers' => [$other->email]]);
$this->expectException(AuthorizationException::class);
$service->save($other, [...$this->draft(), 'version_no' => 1], $business->id);
}
public function test_unverified_account_cannot_create_business(): void
{
$this->expectException(AuthorizationException::class);
app(ExistingBusinessDrafts::class)->save(User::factory()->unverified()->create(), $this->draft());
}
public function test_database_prevents_publication_and_history_changes(): void
{
$business = app(ExistingBusinessDrafts::class)->save(User::factory()->create(), $this->draft());
foreach (['publish', 'history'] as $operation) {
DB::statement('SAVEPOINT business_constraint');
try {
if ($operation === 'publish') {
DB::table('existing_businesses')->where('id', $business->id)->update(['status' => 'published']);
} else {
DB::table('existing_business_revisions')->where('business_id', $business->id)->delete();
}
$this->fail('Database invariant was not enforced');
} catch (QueryException) {
DB::statement('ROLLBACK TO SAVEPOINT business_constraint');
$this->assertTrue(true);
}
}
$this->assertDatabaseCount('existing_business_revisions', 1);
}
public function test_empty_rollback_is_reversible_and_populated_rollback_is_refused(): void
{
$migration = require database_path('migrations/2026_09_28_130000_existing_business_foundation.php');
$migration->down();
$migration->up();
app(ExistingBusinessDrafts::class)->save(User::factory()->create(), $this->draft());
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('destructive rollback refused');
$migration->down();
}
}
+3
View File
@@ -66,12 +66,15 @@ class InvestmentZoneTest extends TestCase
$migration = require database_path('migrations/2026_09_28_100000_investment_zone_foundation.php');
$originMigration = require database_path('migrations/2026_09_28_120000_investment_submission_zones.php');
$geometryMigration = require database_path('migrations/2026_09_28_110000_zone_geometry_review.php');
$businessMigration = require database_path('migrations/2026_09_28_130000_existing_business_foundation.php');
$businessMigration->down();
$originMigration->down();
$geometryMigration->down();
$migration->down();
$migration->up();
$geometryMigration->up();
$originMigration->up();
$businessMigration->up();
$this->assertTrue(Schema::hasTable('investment_zone_links'));
$this->actingAs($this->manager())->postJson('/api/v2/investment/admin/zones', ['code' => 'QA', 'name' => 'Synthetic', 'category' => 'pending'])->assertCreated();
try {