diff --git a/app/Http/Controllers/ExistingBusinessController.php b/app/Http/Controllers/ExistingBusinessController.php new file mode 100644 index 00000000..45521070 --- /dev/null +++ b/app/Http/Controllers/ExistingBusinessController.php @@ -0,0 +1,82 @@ +is('management/*', 'api/v1/existing-business/review*'); + $query = ExistingBusiness::query(); + if ($management) { + Gate::authorize('reviewQueue', ExistingBusiness::class); + $query->where('owner_user_id', '<>', $request->user()->id)->whereIn('status', ['submitted', 'in_review', 'needs_information']); + } else { + $query->where('owner_user_id', $request->user()->id); + } + $props = ['records' => $query->orderByDesc('updated_at')->paginate(20)->through(fn ($row) => $this->record($row)), + 'management' => $management, 'modalities' => ExistingBusinessDrafts::MODALITIES]; + + return $request->expectsJson() ? response()->json($props) : Inertia::render('investment/businesses', $props); + } + + public function show(Request $request, string $id): Response|JsonResponse + { + $business = ExistingBusiness::findOrFail($id); + Gate::authorize('view', $business); + $props = ['record' => $this->record($business), 'canEdit' => $request->user()->can('update', $business), + 'owner' => $request->user()->can('owns', $business), 'canReview' => $request->user()->can('review', $business), + 'modalities' => ExistingBusinessDrafts::MODALITIES, + 'objectKinds' => ExistingBusinessObjects::KINDS, + 'objects' => ExistingBusinessObject::where('business_id', $id)->orderBy('created_at')->get()->map(fn ($object) => [ + ...$object->only(['id', 'kind', 'verification_status']), 'payload' => $object->payload]), + 'history' => DB::table('existing_business_revisions')->where('business_id', $id)->orderByDesc('version_no')->limit(50) + ->get(['version_no', 'action', 'created_at', 'reason'])->map(fn ($row) => ['version_no' => $row->version_no, + 'action' => $row->action, 'created_at' => $row->created_at, 'note' => Crypt::decryptString($row->reason)])]; + + return $request->expectsJson() ? response()->json($props) : Inertia::render('investment/business', $props); + } + + public function save(Request $request, ExistingBusinessDrafts $drafts, ?string $id = null): JsonResponse + { + $business = $drafts->save($request->user(), $request->all(), $id); + + return response()->json($this->record($business), $id ? 200 : 201); + } + + public function transition(Request $request, ExistingBusinessIntake $intake, string $id): JsonResponse + { + $data = $request->validate(['version_no' => ['required', 'integer', 'min:1'], 'action' => ['required', 'string'], + 'note' => ['required', 'string'], 'review_consent' => ['nullable', 'boolean']]); + $business = $intake->transition($request->user(), $id, $data['version_no'], $data['action'], $data['note'], (bool) ($data['review_consent'] ?? false)); + + return response()->json($this->record($business)); + } + + public function saveObject(Request $request, ExistingBusinessObjects $objects, string $id, ?string $object = null): JsonResponse + { + $saved = $objects->save($request->user(), $id, $request->all(), $object); + + return response()->json(['id' => $saved->id, 'verification_status' => 'pending', + 'version_no' => ExistingBusiness::findOrFail($id)->version_no], $object ? 200 : 201); + } + + /** @return array */ + private function record(ExistingBusiness $business): array + { + return [...$business->only(['id', 'code', 'modality', 'status', 'classification', 'version_no']), 'payload' => $business->payload]; + } +} diff --git a/app/Http/Middleware/ExistingBusinessEnabled.php b/app/Http/Middleware/ExistingBusinessEnabled.php new file mode 100644 index 00000000..cc0ed36d --- /dev/null +++ b/app/Http/Middleware/ExistingBusinessEnabled.php @@ -0,0 +1,18 @@ + config('app.name'), 'educationEnabled' => (bool) config('education.enabled', false), 'aiEnabled' => (bool) config('investmoz_ai.enabled', false), + 'existingBusinessEnabled' => (bool) config('existing-business.enabled', false), 'flash' => ['success' => fn () => $request->session()->get('success')], 'auth' => [ 'user' => $request->user(), diff --git a/app/Policies/ExistingBusinessPolicy.php b/app/Policies/ExistingBusinessPolicy.php index ddcfb022..3bc5c1cd 100644 --- a/app/Policies/ExistingBusinessPolicy.php +++ b/app/Policies/ExistingBusinessPolicy.php @@ -14,16 +14,34 @@ class ExistingBusinessPolicy public function update(User $user, ExistingBusiness $business): bool { - return $user->hasVerifiedEmail() && $business->owner_user_id === $user->id && ! $business->trashed(); + return $this->owns($user, $business) && in_array($business->status, ['draft', 'needs_information'], true); } public function view(User $user, ExistingBusiness $business): bool { - return $this->update($user, $business); + return $this->owns($user, $business) || $this->review($user, $business); } public function delete(User $user, ExistingBusiness $business): bool { return $this->update($user, $business); } + + public function owns(User $user, ExistingBusiness $business): bool + { + return $user->hasVerifiedEmail() && $business->owner_user_id === $user->id && ! $business->trashed(); + } + + public function review(User $user, ExistingBusiness $business): bool + { + return ! $business->trashed() && $business->owner_user_id !== $user->id + && in_array($business->status, ['submitted', 'in_review', 'needs_information'], true) + && $this->reviewQueue($user); + } + + public function reviewQueue(User $user): bool + { + return $user->can('manage-portal') && (bool) $user->two_factor_confirmed_at && (bool) $user->two_factor_secret + && in_array(strtolower($user->email), config('existing-business.reviewers', []), true); + } } diff --git a/app/Services/ExistingBusinessDrafts.php b/app/Services/ExistingBusinessDrafts.php index 0024159d..02657c05 100644 --- a/app/Services/ExistingBusinessDrafts.php +++ b/app/Services/ExistingBusinessDrafts.php @@ -3,6 +3,7 @@ namespace App\Services; use App\Models\ExistingBusiness; +use App\Models\ExistingBusinessObject; use App\Models\User; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\DB; @@ -69,9 +70,12 @@ final class ExistingBusinessDrafts }); } - private function snapshot(ExistingBusiness $business, User $actor, string $action, string $reason): void + public function snapshot(ExistingBusiness $business, User $actor, string $action, string $reason): void { - $snapshot = json_encode(['record' => $business->attributesToArray(), 'payload' => $business->payload], JSON_THROW_ON_ERROR); + $snapshot = json_encode(['record' => $business->attributesToArray(), 'payload' => $business->payload, + 'objects' => ExistingBusinessObject::where('business_id', $business->id)->orderBy('id')->get() + ->map(fn ($object) => [...$object->attributesToArray(), 'payload' => $object->payload])->all(), + ], 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/app/Services/ExistingBusinessIntake.php b/app/Services/ExistingBusinessIntake.php new file mode 100644 index 00000000..bec5dbe8 --- /dev/null +++ b/app/Services/ExistingBusinessIntake.php @@ -0,0 +1,46 @@ + ['required', 'in:submitted,in_review,needs_information,withdrawn'], + 'note' => ['required', 'string', 'min:10', 'max:2000'], + ])->validate(); + + return DB::transaction(function () use ($actor, $id, $version, $action, $note, $consent) { + $business = ExistingBusiness::whereKey($id)->lockForUpdate()->firstOrFail(); + Gate::forUser($actor)->authorize(in_array($action, ['submitted', 'withdrawn'], true) ? 'owns' : 'review', $business); + abort_unless($business->version_no === $version, 409, 'Versão desactualizada.'); + $allowed = ['submitted' => ['draft', 'needs_information'], 'in_review' => ['submitted'], + 'needs_information' => ['in_review'], 'withdrawn' => ['draft', 'submitted', 'in_review', 'needs_information']]; + abort_unless(in_array($business->status, $allowed[$action], true), 409, 'Transição indisponível neste estado.'); + if ($action === 'submitted') { + Validator::make([...$business->payload, 'consent' => $consent], [ + 'description' => ['required', 'string', 'min:30'], 'source_reference' => ['required', 'string', 'min:10'], + 'valid_until' => ['required', 'date_format:Y-m-d', 'after_or_equal:today'], 'consent' => ['accepted'], + ])->validate(); + } + $business->status = $action; + $business->version_no++; + $business->save(); + DB::table('existing_business_reviews')->insert(['id' => (string) Str::uuid(), 'business_id' => $id, 'actor_id' => $actor->id, + 'version_no' => $business->version_no, 'action' => $action, + 'note' => Crypt::encryptString(json_encode(['note' => $note, 'review_consent' => $action === 'submitted' ? $consent : null], JSON_THROW_ON_ERROR))]); + app(ExistingBusinessDrafts::class)->snapshot($business, $actor, $action, $note); + + return $business; + }); + } +} diff --git a/app/Services/ExistingBusinessObjects.php b/app/Services/ExistingBusinessObjects.php new file mode 100644 index 00000000..e5170c80 --- /dev/null +++ b/app/Services/ExistingBusinessObjects.php @@ -0,0 +1,78 @@ +> */ + public static function fields(): array + { + return [ + 'label' => ['required', 'string', 'max:200'], + 'declared_holder' => ['nullable', 'string', 'max:500'], + 'document_reference' => ['nullable', 'string', 'max:1000'], + 'source_reference' => ['required', 'string', 'min:10', 'max:2000'], + 'competent_entity' => ['nullable', 'string', 'max:500'], + 'purpose' => ['nullable', 'string', 'max:2000'], + 'location_description' => ['nullable', 'string', 'max:2000'], + 'boundaries_description' => ['nullable', 'string', 'max:2000'], + 'occupancy' => ['nullable', 'string', 'max:2000'], + 'condition' => ['nullable', 'string', 'max:2000'], + 'encumbrances' => ['nullable', 'string', 'max:4000'], + 'restrictions' => ['nullable', 'string', 'max:4000'], + 'required_authorizations' => ['nullable', 'string', 'max:4000'], + 'transfer_condition' => ['nullable', 'string', 'max:4000'], + 'contract_type' => ['nullable', 'in:purchase,sale,lease,operation,management,other'], + 'valid_from' => ['nullable', 'date_format:Y-m-d'], + 'valid_until' => ['nullable', 'date_format:Y-m-d'], + 'area' => ['nullable', 'string', 'regex:/^\d{1,12}(\.\d{1,4})?$/', 'numeric', 'gt:0'], + 'area_unit' => ['nullable', 'required_with:area', 'in:m2,ha,km2'], + 'amount' => ['nullable', 'string', 'regex:/^\d{1,15}(\.\d{1,2})?$/', 'numeric', 'min:0'], + 'currency' => ['nullable', 'required_with:amount', 'regex:/^[A-Z]{3}$/'], + 'value_kind' => ['nullable', 'required_with:amount', 'in:asking,indicative,appraised,proposed,negotiated,confirmed'], + 'participation_percent' => ['nullable', 'numeric', 'between:0,100'], + ]; + } + + /** @param array $input */ + public function save(User $actor, string $businessId, array $input, ?string $objectId = null): ExistingBusinessObject + { + $rules = self::fields(); + if (! empty($input['payload']['valid_from'])) { + $rules['valid_until'][] = 'after_or_equal:valid_from'; + } + $data = Validator::make($input, ['version_no' => ['required', 'integer', 'min:1'], + 'kind' => ['required', 'in:'.implode(',', self::KINDS)], 'reason' => ['required', 'string', 'min:10', 'max:1000'], + 'payload' => ['required', 'array:'.implode(',', array_keys($rules))], + ])->validate(); + // Cross-field rules operate on the payload itself, rather than sibling root fields. + Validator::make($data['payload'], $rules)->validate(); + + return DB::transaction(function () use ($actor, $businessId, $objectId, $data) { + $business = ExistingBusiness::whereKey($businessId)->lockForUpdate()->firstOrFail(); + Gate::forUser($actor)->authorize('update', $business); + abort_unless($business->version_no === $data['version_no'], 409, 'Versão desactualizada.'); + abort_if(! $objectId && ExistingBusinessObject::where('business_id', $businessId)->count() >= 100, 422, 'Máximo de 100 elementos por negócio.'); + $object = $objectId ? ExistingBusinessObject::where('business_id', $businessId)->whereKey($objectId)->firstOrFail() : new ExistingBusinessObject; + if ($objectId) { + abort_unless($object->kind === $data['kind'], 422, 'Crie outro elemento para uma natureza diferente.'); + } + $object->fill(['business_id' => $businessId, 'kind' => $data['kind'], 'classification' => 'restricted', 'verification_status' => 'pending', 'payload' => $data['payload']]); + $object->save(); + $business->version_no++; + $business->save(); + app(ExistingBusinessDrafts::class)->snapshot($business, $actor, $objectId ? 'object_updated' : 'object_added', $data['reason']); + + return $object; + }); + } +} diff --git a/config/existing-business.php b/config/existing-business.php new file mode 100644 index 00000000..e1fe211c --- /dev/null +++ b/config/existing-business.php @@ -0,0 +1,7 @@ + (bool) env('INVESTMOZ_MNE_ENABLED', false), + // Designations do not establish legal competence or an institutional mandate. + 'reviewers' => array_values(array_filter(array_map('strtolower', array_map('trim', explode(',', (string) env('INVESTMOZ_MNE_REVIEWERS', '')))))), +]; diff --git a/database/migrations/2026_09_28_140000_existing_business_intake.php b/database/migrations/2026_09_28_140000_existing_business_intake.php new file mode 100644 index 00000000..e2d6b159 --- /dev/null +++ b/database/migrations/2026_09_28_140000_existing_business_intake.php @@ -0,0 +1,37 @@ +uuid('id')->primary(); + $t->foreignUuid('business_id')->constrained('existing_businesses')->restrictOnDelete(); + $t->foreignId('actor_id')->constrained('users')->restrictOnDelete(); + $t->unsignedInteger('version_no'); + $t->string('action'); + $t->text('note'); + $t->timestampTz('created_at')->useCurrent(); + $t->unique(['business_id', 'version_no']); + }); + DB::statement("ALTER TABLE existing_business_reviews ADD CHECK(action IN ('submitted','in_review','needs_information','withdrawn'))"); + DB::unprepared('CREATE TRIGGER immutable_business_review BEFORE UPDATE OR DELETE ON existing_business_reviews FOR EACH ROW EXECUTE FUNCTION reject_business_revision_change()'); + } + + public function down(): void + { + if (DB::table('existing_business_reviews')->exists() || DB::table('existing_businesses')->where('status', '<>', 'draft')->exists()) { + throw new RuntimeException('Intake evidence exists; destructive rollback refused.'); + } + Schema::drop('existing_business_reviews'); + DB::statement('ALTER TABLE existing_businesses DROP CONSTRAINT existing_businesses_status_check'); + DB::statement("ALTER TABLE existing_businesses ADD CONSTRAINT existing_businesses_status_check CHECK(status='draft')"); + } +}; diff --git a/docs/investmoz/existing-business/README.md b/docs/investmoz/existing-business/README.md index d5575711..0322eb66 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 +Estado mais recente: [implementação e operação local do núcleo privado](implementation-status.md). A progressão entre fases foi posteriormente autorizada pelo utilizador; as paragens históricas abaixo não representam a autorização actual. + 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`. diff --git a/docs/investmoz/existing-business/browser-validation.json b/docs/investmoz/existing-business/browser-validation.json new file mode 100644 index 00000000..c654435c --- /dev/null +++ b/docs/investmoz/existing-business/browser-validation.json @@ -0,0 +1,27 @@ +{ + "date": "2026-09-28T18:25:03.759Z", + "scope": "Synthetic private create/save and responsive layout in isolated QA; institutional review covered by integration tests", + "observations": [ + { + "scenario": "private_create_and_save", + "passed": true + }, + { + "scenario": "private_submit_with_review_consent", + "passed": true + }, + { + "width": 390, + "breadcrumb": true, + "supportVisible": true, + "overflow": false + }, + { + "width": 1440, + "breadcrumb": true, + "supportVisible": true, + "overflow": false + } + ], + "errors": [] +} diff --git a/docs/investmoz/existing-business/implementation-status.md b/docs/investmoz/existing-business/implementation-status.md new file mode 100644 index 00000000..57796cc1 --- /dev/null +++ b/docs/investmoz/existing-business/implementation-status.md @@ -0,0 +1,66 @@ +# Estado actual — Mercado de Negócios Existentes + +28/09/2026. A instrução mais recente autoriza executar todas as fases e avançar automaticamente. Substitui a paragem entre fases do prompt inicial. Não presume evidências jurídicas, nomeação de revisores, publicação ou integração externa efectiva. + +## Entrega utilizável localmente + +- `/investidor/negocios`: lista própria, criação privada e formulário reservado. +- `/investidor/negocios/{uuid}`: edição, elementos separados, fonte/validade, submissão com consentimento de análise interna, histórico e retirada. +- `/management/negocios`: fila reservada aos gestores designados para revisão e com MFA confirmado; exclui os próprios negócios. +- O revisor inicia análise e pede informação; o proprietário consulta a nota, corrige e pode ressubmeter. Submissão não significa identidade, representação ou titularidade verificadas. +- O negócio fica bloqueado para edição enquanto submetido/em revisão. Todas as alterações exigem versão actual; revisões e decisões são transaccionais e imutáveis. A retirada remove imediatamente o acesso do revisor e a entrada da fila. +- Cada activo, imóvel, unidade, benfeitoria, investimento agrícola, direito, contrato, licença, autorização, encargo, participação, avaliação ou pedido de capital tem identificador próprio, natureza imutável e payload cifrado. Referências documentais são declarações, não ficheiros validados. +- Campos reservados incluem titular, entidade competente declarada, fonte, referência documental, finalidade, localização/limites descritivos, ocupação/conservação, encargos, restrições, autorizações e condição de transmissão. Não se inferem coordenadas nem direitos. +- Área exige unidade; montante exige moeda e natureza do valor. Datas indicadas em conjunto têm de ser coerentes. Nenhum montante é receita; todos os elementos permanecem `pending`. +- O serviço de objectos aplica autorização do agregado, controla versão e grava o snapshot completo cifrado. Edição cruzada por UUID é recusada. A lista/ficha só é entregue após verificação de acesso. + +Não existe publicação neste incremento. A constraint da BD permite apenas `draft`, `submitted`, `in_review`, `needs_information`, `withdrawn`; `approved` e `published` são recusados. Não há endpoint público MNE nem documentos descarregáveis, contrato emitido, cobrança ou decisão de transmissibilidade. + +## Matriz das fases e decisões + +| Fase | Executado | Lacunas / decisão | +| --- | --- | --- | +| 0 — descoberta | Diagnóstico, ameaças, RACI, matriz jurídica, baseline | Política/base/equipa/piloto não homologados; No-Go institucional mantém-se | +| 1 — modelo | Negócio, objectos tipados separados, mandato pendente, classificação, versões, histórico, rollback | Relações documentais/territoriais detalhadas, titularidade comprovada, revisão de mandatos e retenção homologada pendentes; Go condicionado técnico | +| 2 — submissão/administração | UI e API de rascunho, submissão, consentimento, revisão independente, pedido de informação, retirada | Upload/scanner, verificações documentais, dois níveis de aprovação e publicação ainda não implementados; núcleo privado utilizável, fase parcial | +| 3 — catálogo público | Nenhuma ficha reservada enviada ao catálogo público | Projecção anonimizada, revisão de reidentificação, filtros e mapas MNE pendentes; No-Go para divulgação | +| 4 — CRM/matching | CRM existente preservado; nenhum lead MNE enviado | Deduplicação interessado/negócio/modalidade/período, matching revisto e integrações MNE pendentes | +| 5 — sala segura | Referências declarativas e cifragem privadas | NDA juridicamente aprovado, grants temporais, malware, watermark e diligência não implementados; sem sala segura anunciada | +| 6 — negociação/monetização | Valores reservados rotulados por natureza | Workflow negocial, contrato/regra financeira, facturação e reconciliação pendentes; nenhuma cobrança | +| 7 — piloto/operação | Backup/restauro, migrations locais, reconciliação, testes e build | Piloto real, UAT institucional, carga, recuperação MNE completa, PT/EN completo e decisão formal de produção pendentes | + +Não se declara conclusão integral do prompt. Aprovação para desenvolver não torna disponíveis os documentos, mandatos ou regras que faltam. O próximo trabalho técnico é a evidência documental e a governação da validação, antes da publicação; não é necessário repetir autorização de desenvolvimento. + +## Ficheiros e contratos + +Novos: `ExistingBusinessController`, `ExistingBusinessEnabled`, `ExistingBusinessIntake`, `ExistingBusinessObjects`, `config/existing-business.php`, `routes/existing-business.php`, migration `2026_09_28_140000_existing_business_intake`, teste `ExistingBusinessIntakeTest`, React `businesses`, `business`, `business-types`, `business-objects`; ferramentas `existing-business-openapi.mjs`, `existing-business-browser.mjs` e `existing-business-reconcile.php`. + +Adaptados: policy e snapshots MNE, shared props Inertia, navegação, web routes e testes de rollback em ordem inversa das dependências. Nenhum ficheiro removido. + +[OpenAPI](openapi.json): oito operações API v1 privadas de listagem, criação, consulta, edição, transição e objectos. Contrato preserva as APIs anteriores. Sessão, email verificado, CSRF/origin Laravel, limite de 30 pedidos/minuto e `Cache-Control: private, no-store`; nomes de cookie dependem do ambiente. Não existe bearer token inventado. Erros de validação 422, autorização 403, versão/estado 409; módulo desactivado 404. Contrato de eventos externos e correlation_id transversal ainda por implementar; não fingir integração completa. + +## Activação e reconciliação local + +Backup antes das migrations: `storage/app/private/zones-backup-20260928-151000/`, SHA-256 `f7b56bfcf76d1937ba6a1e58142e8a85c25c69c2d1d21418703c9ed54f70c851`. Restauro isolado verificado e 103 tabelas reconciliadas. Configuração anterior guardada privadamente no mesmo directório; contém segredos e não deve ser publicada. + +Migrations 130000 e 140000 aplicadas localmente. Reconciliação posterior com `php tools/existing-business-reconcile.php `: só migrations mudou de 38 para 40; 5 tabelas MNE novas, todas vazias. Total 108 tabelas. Nenhum anúncio ou documento real importado. Fixtures do navegador residem apenas em `portal_services_test`, nunca na base da aplicação. + +`INVESTMOZ_MNE_ENABLED=true` activado apenas na `.env` local; configuração de origem mantém default false. `INVESTMOZ_MNE_REVIEWERS` é uma lista de emails de gestores previamente autorizados e com MFA. Nenhum revisor foi nomeado automaticamente (contagem local: zero). O proprietário pode preparar/submeter; a fila exige designação administrativa explícita, que não prova competência jurídica. + +## Validação + +Resultado final após integrar os objectos: **157 testes / 1647 verificações aprovadas**, 47369 ms. Build final: 2363 módulos, 16,29 s. Navegador repetido sobre o build final: quatro observações aprovadas, zero erros JavaScript. Ajuste posterior de limites/representação decimal verificado novamente nos testes focados. + +- Suite antes de acrescentar objectos: 156 testes / 1633 verificações aprovadas. +- Testes focados após objectos: 9 testes / 67 verificações aprovadas; isolamento entre negócios, natureza imutável, valores/moeda, cifragem e snapshot incluídos. +- Áreas e montantes usam strings decimais (sem conversão para float); máximo de 100 elementos por negócio para limitar tamanho de histórico/respostas. +- PHPStan, TypeScript, lint e Pint aprovados nos ficheiros verificados. +- Build inicial das páginas: 2362 módulos, 8,46 s. Validação final após objectos registada abaixo. +- Navegador QA em 390 e 1440 px: criar/gravar/submeter com consentimento, breadcrumbs, apoio fixo, ausência de overflow e zero erros JavaScript. [Evidência](browser-validation.json). Não é UAT institucional, pentest ou certificação WCAG. +- `tools/existing-business-browser.mjs` usa exclusivamente servidor QA local na porta 18027 e Edge de teste em 9224. Conta sintética preparada na base isolada; não executar contra produção. O teste nunca publica negócios. + +## Suspensão e rollback + +Para suspender acesso: `INVESTMOZ_MNE_ENABLED=false` e `php artisan config:clear`; conservar dados e evidências. Novas tabelas vazias permitem down de 140000 antes de 130000. Os testes confirmam recusa de rollback destrutivo quando há negócios ou decisões. Não usar CASCADE, DROP EXTENSION ou `migrate:fresh` na aplicação. Preferir correcção progressiva quando existam dados; qualquer restauro substitutivo exige plano de reconciliação para alterações posteriores ao backup. + +Riscos residuais: mandatos ainda declarativos, ausência de scanner e fluxo documental, revisão editorial/jurídica não implementada, objectos com metadados tipados mas conteúdo declarativo, ausência de duas aprovações e política final de retenção. Estes limites impedem abertura pública, sala segura e transacções; não impedem a preparação privada identificada como pendente. diff --git a/docs/investmoz/existing-business/openapi.json b/docs/investmoz/existing-business/openapi.json new file mode 100644 index 00000000..6dc86a8c --- /dev/null +++ b/docs/investmoz/existing-business/openapi.json @@ -0,0 +1,1190 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "InvestMoz — private existing-business intake", + "version": "1.0.0", + "description": "Internal preparation and review only. No publication, certification, legal transfer, NDA, payment or external CRM operation." + }, + "servers": [ + { + "url": "/api/v1/existing-business" + } + ], + "paths": { + "/listings": { + "get": { + "summary": "List own active records, 20 per page", + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1 + } + } + ], + "security": [ + { + "session": [] + } + ], + "responses": { + "200": { + "description": "Private response; Cache-Control private, no-store", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "records", + "management", + "modalities" + ], + "properties": { + "records": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Business" + } + }, + "total": { + "type": "integer" + } + } + }, + "management": { + "type": "boolean" + }, + "modalities": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Account, ownership or current reviewer/MFA policy rejected" + }, + "404": { + "description": "Module disabled or resource unavailable" + }, + "409": { + "description": "Stale version or invalid transition" + }, + "422": { + "description": "Validation error" + }, + "429": { + "description": "Rate limited" + } + } + }, + "post": { + "summary": "Create private draft", + "parameters": [], + "security": [ + { + "session": [] + } + ], + "responses": { + "201": { + "description": "Private response; Cache-Control private, no-store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Business" + } + } + } + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Account, ownership or current reviewer/MFA policy rejected" + }, + "404": { + "description": "Module disabled or resource unavailable" + }, + "409": { + "description": "Stale version or invalid transition" + }, + "422": { + "description": "Validation error" + }, + "429": { + "description": "Rate limited" + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "modality", + "classification", + "reason", + "payload" + ], + "properties": { + "modality": { + "type": "string" + }, + "classification": { + "enum": [ + "internal", + "confidential", + "restricted" + ] + }, + "reason": { + "type": "string", + "minLength": 10, + "maxLength": 1000 + }, + "payload": { + "type": "object", + "additionalProperties": false, + "required": [ + "title" + ], + "properties": { + "title": { + "type": "string", + "maxLength": 200 + }, + "description": { + "type": [ + "string", + "null" + ], + "maxLength": 10000 + }, + "declared_reason": { + "type": [ + "string", + "null" + ], + "maxLength": 2000 + }, + "source_reference": { + "type": [ + "string", + "null" + ], + "maxLength": 2000 + }, + "valid_until": { + "type": [ + "string", + "null" + ], + "format": "date" + } + } + } + } + } + } + } + }, + "description": "Session authentication and Laravel CSRF/origin protection apply. Use X-XSRF-TOKEN for browser clients." + } + }, + "/reviews": { + "get": { + "summary": "List submitted records for designated reviewers with MFA; excludes own records", + "parameters": [], + "security": [ + { + "session": [] + } + ], + "responses": { + "200": { + "description": "Private response; Cache-Control private, no-store", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "records": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Business" + } + } + } + }, + "management": { + "type": "boolean" + } + } + } + } + } + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Account, ownership or current reviewer/MFA policy rejected" + }, + "404": { + "description": "Module disabled or resource unavailable" + }, + "409": { + "description": "Stale version or invalid transition" + }, + "422": { + "description": "Validation error" + }, + "429": { + "description": "Rate limited" + } + } + } + }, + "/listings/{id}": { + "get": { + "summary": "Read own record or currently reviewable submission", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "session": [] + } + ], + "responses": { + "200": { + "description": "Private response; Cache-Control private, no-store", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "record": { + "$ref": "#/components/schemas/Business" + }, + "canEdit": { + "type": "boolean" + }, + "owner": { + "type": "boolean" + }, + "canReview": { + "type": "boolean" + }, + "history": { + "type": "array", + "items": { + "type": "object", + "properties": { + "version_no": { + "type": "integer" + }, + "action": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "note": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Account, ownership or current reviewer/MFA policy rejected" + }, + "404": { + "description": "Module disabled or resource unavailable" + }, + "409": { + "description": "Stale version or invalid transition" + }, + "422": { + "description": "Validation error" + }, + "429": { + "description": "Rate limited" + } + } + }, + "put": { + "summary": "Update owned draft or request for information; optimistic version required", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "session": [] + } + ], + "responses": { + "200": { + "description": "Private response; Cache-Control private, no-store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Business" + } + } + } + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Account, ownership or current reviewer/MFA policy rejected" + }, + "404": { + "description": "Module disabled or resource unavailable" + }, + "409": { + "description": "Stale version or invalid transition" + }, + "422": { + "description": "Validation error" + }, + "429": { + "description": "Rate limited" + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "modality", + "classification", + "reason", + "payload", + "version_no" + ], + "properties": { + "modality": { + "type": "string" + }, + "classification": { + "enum": [ + "internal", + "confidential", + "restricted" + ] + }, + "reason": { + "type": "string", + "minLength": 10, + "maxLength": 1000 + }, + "payload": { + "type": "object", + "additionalProperties": false, + "required": [ + "title" + ], + "properties": { + "title": { + "type": "string", + "maxLength": 200 + }, + "description": { + "type": [ + "string", + "null" + ], + "maxLength": 10000 + }, + "declared_reason": { + "type": [ + "string", + "null" + ], + "maxLength": 2000 + }, + "source_reference": { + "type": [ + "string", + "null" + ], + "maxLength": 2000 + }, + "valid_until": { + "type": [ + "string", + "null" + ], + "format": "date" + } + } + }, + "version_no": { + "type": "integer", + "minimum": 1 + } + } + } + } + } + }, + "description": "Session authentication and Laravel CSRF/origin protection apply. Use X-XSRF-TOKEN for browser clients." + } + }, + "/listings/{id}/transitions": { + "post": { + "summary": "Submit, begin review, request information or withdraw", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "session": [] + } + ], + "responses": { + "200": { + "description": "Private response; Cache-Control private, no-store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Business" + } + } + } + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Account, ownership or current reviewer/MFA policy rejected" + }, + "404": { + "description": "Module disabled or resource unavailable" + }, + "409": { + "description": "Stale version or invalid transition" + }, + "422": { + "description": "Validation error" + }, + "429": { + "description": "Rate limited" + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "version_no", + "action", + "note" + ], + "properties": { + "version_no": { + "type": "integer", + "minimum": 1 + }, + "action": { + "enum": [ + "submitted", + "in_review", + "needs_information", + "withdrawn" + ] + }, + "note": { + "type": "string", + "minLength": 10, + "maxLength": 2000 + }, + "review_consent": { + "type": "boolean", + "description": "Must be true for submission. Stored in encrypted review evidence. Does not authorize publication." + } + } + } + } + } + }, + "description": "Session authentication and Laravel CSRF/origin protection apply. Use X-XSRF-TOKEN for browser clients." + } + }, + "/listings/{id}/objects": { + "post": { + "summary": "Declare a separate restricted object; increments aggregate version without granting verification", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "session": [] + } + ], + "responses": { + "201": { + "description": "Private response; Cache-Control private, no-store", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "version_no": { + "type": "integer" + }, + "verification_status": { + "const": "pending" + } + } + } + } + } + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Account, ownership or current reviewer/MFA policy rejected" + }, + "404": { + "description": "Module disabled or resource unavailable" + }, + "409": { + "description": "Stale version or invalid transition" + }, + "422": { + "description": "Validation error" + }, + "429": { + "description": "Rate limited" + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "kind", + "version_no", + "reason", + "payload" + ], + "properties": { + "kind": { + "enum": [ + "company", + "participation", + "asset", + "property", + "unit", + "improvement", + "agricultural_investment", + "land_right", + "contract", + "license", + "authorization", + "encumbrance", + "valuation", + "capital_request" + ] + }, + "version_no": { + "type": "integer", + "minimum": 1 + }, + "reason": { + "type": "string", + "minLength": 10, + "maxLength": 1000 + }, + "payload": { + "type": "object", + "additionalProperties": false, + "required": [ + "label", + "source_reference" + ], + "properties": { + "label": { + "type": [ + "string", + "null" + ] + }, + "declared_holder": { + "type": [ + "string", + "null" + ] + }, + "document_reference": { + "type": [ + "string", + "null" + ] + }, + "source_reference": { + "type": [ + "string", + "null" + ] + }, + "competent_entity": { + "type": [ + "string", + "null" + ] + }, + "purpose": { + "type": [ + "string", + "null" + ] + }, + "location_description": { + "type": [ + "string", + "null" + ] + }, + "boundaries_description": { + "type": [ + "string", + "null" + ] + }, + "occupancy": { + "type": [ + "string", + "null" + ] + }, + "condition": { + "type": [ + "string", + "null" + ] + }, + "encumbrances": { + "type": [ + "string", + "null" + ] + }, + "restrictions": { + "type": [ + "string", + "null" + ] + }, + "required_authorizations": { + "type": [ + "string", + "null" + ] + }, + "transfer_condition": { + "type": [ + "string", + "null" + ] + }, + "contract_type": { + "type": [ + "string", + "null" + ] + }, + "valid_from": { + "type": [ + "string", + "null" + ] + }, + "valid_until": { + "type": [ + "string", + "null" + ] + }, + "area": { + "type": [ + "string", + "null" + ] + }, + "area_unit": { + "type": [ + "string", + "null" + ] + }, + "amount": { + "type": [ + "string", + "null" + ] + }, + "currency": { + "type": [ + "string", + "null" + ] + }, + "value_kind": { + "type": [ + "string", + "null" + ] + }, + "participation_percent": { + "type": [ + "string", + "null" + ] + } + } + } + } + } + } + } + }, + "description": "Session authentication and Laravel CSRF/origin protection apply. Use X-XSRF-TOKEN for browser clients." + } + }, + "/listings/{id}/objects/{object}": { + "put": { + "summary": "Update scoped object with immutable kind; amount requires currency and value kind; area requires unit", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "object", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "session": [] + } + ], + "responses": { + "200": { + "description": "Private response; Cache-Control private, no-store", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "version_no": { + "type": "integer" + }, + "verification_status": { + "const": "pending" + } + } + } + } + } + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Account, ownership or current reviewer/MFA policy rejected" + }, + "404": { + "description": "Module disabled or resource unavailable" + }, + "409": { + "description": "Stale version or invalid transition" + }, + "422": { + "description": "Validation error" + }, + "429": { + "description": "Rate limited" + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "kind", + "version_no", + "reason", + "payload" + ], + "properties": { + "kind": { + "enum": [ + "company", + "participation", + "asset", + "property", + "unit", + "improvement", + "agricultural_investment", + "land_right", + "contract", + "license", + "authorization", + "encumbrance", + "valuation", + "capital_request" + ] + }, + "version_no": { + "type": "integer", + "minimum": 1 + }, + "reason": { + "type": "string", + "minLength": 10, + "maxLength": 1000 + }, + "payload": { + "type": "object", + "additionalProperties": false, + "required": [ + "label", + "source_reference" + ], + "properties": { + "label": { + "type": [ + "string", + "null" + ] + }, + "declared_holder": { + "type": [ + "string", + "null" + ] + }, + "document_reference": { + "type": [ + "string", + "null" + ] + }, + "source_reference": { + "type": [ + "string", + "null" + ] + }, + "competent_entity": { + "type": [ + "string", + "null" + ] + }, + "purpose": { + "type": [ + "string", + "null" + ] + }, + "location_description": { + "type": [ + "string", + "null" + ] + }, + "boundaries_description": { + "type": [ + "string", + "null" + ] + }, + "occupancy": { + "type": [ + "string", + "null" + ] + }, + "condition": { + "type": [ + "string", + "null" + ] + }, + "encumbrances": { + "type": [ + "string", + "null" + ] + }, + "restrictions": { + "type": [ + "string", + "null" + ] + }, + "required_authorizations": { + "type": [ + "string", + "null" + ] + }, + "transfer_condition": { + "type": [ + "string", + "null" + ] + }, + "contract_type": { + "type": [ + "string", + "null" + ] + }, + "valid_from": { + "type": [ + "string", + "null" + ] + }, + "valid_until": { + "type": [ + "string", + "null" + ] + }, + "area": { + "type": [ + "string", + "null" + ] + }, + "area_unit": { + "type": [ + "string", + "null" + ] + }, + "amount": { + "type": [ + "string", + "null" + ] + }, + "currency": { + "type": [ + "string", + "null" + ] + }, + "value_kind": { + "type": [ + "string", + "null" + ] + }, + "participation_percent": { + "type": [ + "string", + "null" + ] + } + } + } + } + } + } + } + }, + "description": "Session authentication and Laravel CSRF/origin protection apply. Use X-XSRF-TOKEN for browser clients." + } + } + }, + "components": { + "securitySchemes": { + "session": { + "type": "apiKey", + "in": "cookie", + "name": "session_cookie", + "description": "Actual Laravel cookie name comes from deployment configuration; no bearer token support." + } + }, + "schemas": { + "Business": { + "type": "object", + "required": [ + "id", + "code", + "modality", + "status", + "classification", + "version_no", + "payload" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "code": { + "type": "string" + }, + "modality": { + "type": "string" + }, + "status": { + "enum": [ + "draft", + "submitted", + "in_review", + "needs_information", + "withdrawn" + ] + }, + "classification": { + "enum": [ + "internal", + "confidential", + "restricted" + ] + }, + "version_no": { + "type": "integer", + "minimum": 1 + }, + "payload": { + "type": "object", + "additionalProperties": false, + "required": [ + "title" + ], + "properties": { + "title": { + "type": "string", + "maxLength": 200 + }, + "description": { + "type": [ + "string", + "null" + ], + "maxLength": 10000 + }, + "declared_reason": { + "type": [ + "string", + "null" + ], + "maxLength": 2000 + }, + "source_reference": { + "type": [ + "string", + "null" + ], + "maxLength": 2000 + }, + "valid_until": { + "type": [ + "string", + "null" + ], + "format": "date" + } + } + } + } + } + } + } +} diff --git a/resources/js/layouts/portal-navigation.tsx b/resources/js/layouts/portal-navigation.tsx index 9801d559..6c4360aa 100644 --- a/resources/js/layouts/portal-navigation.tsx +++ b/resources/js/layouts/portal-navigation.tsx @@ -17,6 +17,8 @@ export const portalLinks = [ ["/apoio", "Apoio ao Investidor"], ] as const; const names: Record = { + "/investidor/negocios": "Os meus negócios", + "/management/negocios": "Revisão de negócios", ...Object.fromEntries(portalLinks), "/parcerias": "Bolsa de Parcerias", "/simulador-fiscal": "Simulador Fiscal", @@ -47,13 +49,16 @@ export default function PortalNavigation({ children }: { children: ReactNode }) .filter((p) => p !== "/" && path.startsWith(`${p}/`)) .sort((a, b) => b.length - a.length)[0]; const pageProps = props as { - record?: { title?: string }; + record?: { title?: string; name?: string }; + zone?: { name?: string }; sector?: { name?: string }; initialOpportunity?: { title?: string }; }; const title = names[path] ?? pageProps.record?.title ?? + pageProps.record?.name ?? + pageProps.zone?.name ?? pageProps.sector?.name ?? pageProps.initialOpportunity?.title ?? (path.includes("/edit") @@ -109,6 +114,7 @@ export default function PortalNavigation({ children }: { children: ReactNode }) } export function PortalMenu() { + const { existingBusinessEnabled } = usePage<{ existingBusinessEnabled?: boolean }>().props; const [expanded, setExpanded] = useState(false); const menuId = useId(); return ( @@ -139,6 +145,11 @@ export function PortalMenu() { {label} ))} + {existingBusinessEnabled && ( + + Os meus negócios + + )} Bolsa de Parcerias diff --git a/resources/js/pages/investment/business-objects.tsx b/resources/js/pages/investment/business-objects.tsx new file mode 100644 index 00000000..28e617e8 --- /dev/null +++ b/resources/js/pages/investment/business-objects.tsx @@ -0,0 +1,286 @@ +import { router } from "@inertiajs/react"; +import { useState } from "react"; +import { Field, input, button, card, api } from "./shared"; + +export type BusinessObject = { + id: string; + kind: string; + verification_status: string; + payload: Record; +}; +const kinds: Record = { + company: "Empresa", + participation: "Participação", + asset: "Activo físico", + property: "Imóvel/propriedade", + unit: "Unidade imobiliária", + improvement: "Benfeitoria", + agricultural_investment: "Investimento agrícola", + land_right: "Direito fundiário", + contract: "Contrato", + license: "Licença", + authorization: "Autorização", + encumbrance: "Ónus ou encargo", + valuation: "Avaliação declarada", + capital_request: "Pedido de capital", +}; +const fields: Record = { + label: "Designação reservada", + declared_holder: "Titular declarado", + document_reference: "Referência documental", + source_reference: "Fonte da informação", + competent_entity: "Entidade emissora/competente declarada", + purpose: "Finalidade autorizada declarada", + location_description: "Localização reservada", + boundaries_description: "Limites declarados", + occupancy: "Ocupação/utilização", + condition: "Conservação/operação", + encumbrances: "Ónus, garantias ou litígios declarados", + restrictions: "Restrições declaradas", + required_authorizations: "Autorizações necessárias declaradas", + transfer_condition: "Condição de cessão/transmissão declarada", + valid_from: "Início de validade", + valid_until: "Fim de validade", + area: "Área (decimal com ponto)", + participation_percent: "Participação (%)", + amount: "Valor (decimal com ponto)", + currency: "Moeda (código de três letras)", +}; +export default function BusinessObjects({ + businessId, + version, + editable, + objects, + objectKinds, +}: { + businessId: string; + version: number; + editable: boolean; + objects: BusinessObject[]; + objectKinds: string[]; +}) { + const [kind, setKind] = useState(objectKinds[0]); + const [objectId, setObjectId] = useState(null); + const [payload, setPayload] = useState>({}); + const [reason, setReason] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + return ( +
+

Activos, contratos e direitos declarados

+

+ Registe separadamente cada elemento. Todos ficam pendentes de verificação; a + declaração não confirma titularidade, validade ou transmissibilidade. Não use este + formulário para anexar ficheiros. +

+ {!objects.length &&

Nenhum elemento registado.

} + {objects.map((o) => ( +
+

+ {kinds[o.kind]} · {o.payload.label} +

+

Pendente de verificação

+
+ {Object.entries(o.payload) + .filter(([key, value]) => key !== "label" && value) + .map(([key, value]) => ( +
+
+ {fields[key] ?? + ( + { + area_unit: "Unidade da área", + value_kind: "Natureza do valor", + contract_type: "Tipo de contrato", + } as Record + )[key] ?? + key} +
+
+ {key === "value_kind" + ? (( + { + asking: "Pedido", + indicative: "Indicativo", + appraised: "Avaliado — declaração pendente", + proposed: "Proposto", + negotiated: "Negociado — declaração pendente", + confirmed: "Confirmado — declaração pendente", + } as Record + )[value] ?? value) + : value} +
+
+ ))} +
+ {editable && ( + + )} +
+ ))} + {editable && ( +
{ + e.preventDefault(); + setBusy(true); + setError(""); + try { + await api( + `/api/v1/existing-business/listings/${businessId}/objects${objectId ? `/${objectId}` : ""}`, + { + ...(objectId ? { _method: "PUT" } : {}), + kind, + payload, + reason, + version_no: version, + }, + ); + router.reload(); + } catch (err) { + setError( + err instanceof Error + ? err.message + : "Não foi possível guardar o elemento.", + ); + } finally { + setBusy(false); + } + }} + > +

+ {objectId ? "Editar elemento reservado" : "Acrescentar elemento reservado"} +

+ + + +
+ {Object.entries(fields).map(([key, label]) => ( + + + setPayload({ ...payload, [key]: e.target.value }) + } + /> + + ))} + + + + + + + + + +
+ +