From 519b256ffd38043f29a9a780a594501fcf4dd482 Mon Sep 17 00:00:00 2001 From: nala Date: Mon, 28 Sep 2026 17:01:10 +0200 Subject: [PATCH] feat: add private existing business draft foundation --- app/Models/ExistingBusiness.php | 10 ++ app/Models/ExistingBusinessMandate.php | 17 ++++ app/Models/ExistingBusinessObject.php | 17 ++++ app/Policies/ExistingBusinessPolicy.php | 29 ++++++ app/Services/ExistingBusinessDrafts.php | 79 +++++++++++++++ ...28_130000_existing_business_foundation.php | 84 ++++++++++++++++ docs/investmoz/existing-business/README.md | 2 + docs/investmoz/existing-business/phase-1.md | 60 ++++++++++++ .../ExistingBusinessFoundationTest.php | 97 +++++++++++++++++++ tests/Feature/InvestmentZoneTest.php | 3 + 10 files changed, 398 insertions(+) create mode 100644 app/Models/ExistingBusiness.php create mode 100644 app/Models/ExistingBusinessMandate.php create mode 100644 app/Models/ExistingBusinessObject.php create mode 100644 app/Policies/ExistingBusinessPolicy.php create mode 100644 app/Services/ExistingBusinessDrafts.php create mode 100644 database/migrations/2026_09_28_130000_existing_business_foundation.php create mode 100644 docs/investmoz/existing-business/phase-1.md create mode 100644 tests/Feature/ExistingBusinessFoundationTest.php diff --git a/app/Models/ExistingBusiness.php b/app/Models/ExistingBusiness.php new file mode 100644 index 00000000..8b9b02f8 --- /dev/null +++ b/app/Models/ExistingBusiness.php @@ -0,0 +1,10 @@ + 'encrypted:array', 'version_no' => 'integer']; +} diff --git a/app/Models/ExistingBusinessMandate.php b/app/Models/ExistingBusinessMandate.php new file mode 100644 index 00000000..15323bec --- /dev/null +++ b/app/Models/ExistingBusinessMandate.php @@ -0,0 +1,17 @@ + 'encrypted:array', 'valid_from' => 'date', 'valid_until' => 'date']; +} diff --git a/app/Models/ExistingBusinessObject.php b/app/Models/ExistingBusinessObject.php new file mode 100644 index 00000000..422ae6c9 --- /dev/null +++ b/app/Models/ExistingBusinessObject.php @@ -0,0 +1,17 @@ + 'encrypted:array']; +} diff --git a/app/Policies/ExistingBusinessPolicy.php b/app/Policies/ExistingBusinessPolicy.php new file mode 100644 index 00000000..ddcfb022 --- /dev/null +++ b/app/Policies/ExistingBusinessPolicy.php @@ -0,0 +1,29 @@ +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); + } +} diff --git a/app/Services/ExistingBusinessDrafts.php b/app/Services/ExistingBusinessDrafts.php new file mode 100644 index 00000000..0024159d --- /dev/null +++ b/app/Services/ExistingBusinessDrafts.php @@ -0,0 +1,79 @@ + $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)]); + } +} diff --git a/database/migrations/2026_09_28_130000_existing_business_foundation.php b/database/migrations/2026_09_28_130000_existing_business_foundation.php new file mode 100644 index 00000000..f2f44afe --- /dev/null +++ b/database/migrations/2026_09_28_130000_existing_business_foundation.php @@ -0,0 +1,84 @@ +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()'); + } +}; diff --git a/docs/investmoz/existing-business/README.md b/docs/investmoz/existing-business/README.md index 2214ccb1..d5575711 100644 --- a/docs/investmoz/existing-business/README.md +++ b/docs/investmoz/existing-business/README.md @@ -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 diff --git a/docs/investmoz/existing-business/phase-1.md b/docs/investmoz/existing-business/phase-1.md new file mode 100644 index 00000000..1c9bc1b8 --- /dev/null +++ b/docs/investmoz/existing-business/phase-1.md @@ -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. diff --git a/tests/Feature/ExistingBusinessFoundationTest.php b/tests/Feature/ExistingBusinessFoundationTest.php new file mode 100644 index 00000000..c7ce264c --- /dev/null +++ b/tests/Feature/ExistingBusinessFoundationTest.php @@ -0,0 +1,97 @@ + */ + 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(); + } +} diff --git a/tests/Feature/InvestmentZoneTest.php b/tests/Feature/InvestmentZoneTest.php index dc18b70e..a23dc60c 100644 --- a/tests/Feature/InvestmentZoneTest.php +++ b/tests/Feature/InvestmentZoneTest.php @@ -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 {