From b77714b15675ec85cb90597e8a767711f9f56553 Mon Sep 17 00:00:00 2001 From: nala Date: Tue, 29 Sep 2026 08:40:43 +0200 Subject: [PATCH] Add scoped private business verification evidence and expiry --- .../ExistingBusinessController.php | 8 + app/Services/ExistingBusinessDrafts.php | 5 +- app/Services/ExistingBusinessVerification.php | 78 +++++++ .../existing-business/browser-validation.json | 2 +- .../implementation-status.md | 2 + docs/investmoz/existing-business/openapi.json | 210 +++++++++++++++++- .../verification-evidence.md | 32 +++ .../investment/business-verifications.tsx | 176 +++++++++++++++ resources/js/pages/investment/business.tsx | 15 ++ routes/existing-business.php | 1 + tests/Feature/ExistingBusinessIntakeTest.php | 30 +++ tools/existing-business-openapi.mjs | 14 +- 12 files changed, 568 insertions(+), 5 deletions(-) create mode 100644 app/Services/ExistingBusinessVerification.php create mode 100644 docs/investmoz/existing-business/verification-evidence.md create mode 100644 resources/js/pages/investment/business-verifications.tsx diff --git a/app/Http/Controllers/ExistingBusinessController.php b/app/Http/Controllers/ExistingBusinessController.php index 45521070..4a738bd6 100644 --- a/app/Http/Controllers/ExistingBusinessController.php +++ b/app/Http/Controllers/ExistingBusinessController.php @@ -7,6 +7,7 @@ use App\Models\ExistingBusinessObject; use App\Services\ExistingBusinessDrafts; use App\Services\ExistingBusinessIntake; use App\Services\ExistingBusinessObjects; +use App\Services\ExistingBusinessVerification; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Crypt; @@ -41,6 +42,8 @@ class ExistingBusinessController extends Controller 'owner' => $request->user()->can('owns', $business), 'canReview' => $request->user()->can('review', $business), 'modalities' => ExistingBusinessDrafts::MODALITIES, 'objectKinds' => ExistingBusinessObjects::KINDS, + 'verificationChecks' => ExistingBusinessVerification::CHECKS, + 'verifications' => app(ExistingBusinessVerification::class)->findings($business), '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) @@ -66,6 +69,11 @@ class ExistingBusinessController extends Controller return response()->json($this->record($business)); } + public function verify(Request $request, ExistingBusinessVerification $verification, string $id): JsonResponse + { + return response()->json($this->record($verification->save($request->user(), $id, $request->all()))); + } + public function saveObject(Request $request, ExistingBusinessObjects $objects, string $id, ?string $object = null): JsonResponse { $saved = $objects->save($request->user(), $id, $request->all(), $object); diff --git a/app/Services/ExistingBusinessDrafts.php b/app/Services/ExistingBusinessDrafts.php index 02657c05..1ddba478 100644 --- a/app/Services/ExistingBusinessDrafts.php +++ b/app/Services/ExistingBusinessDrafts.php @@ -70,9 +70,10 @@ final class ExistingBusinessDrafts }); } - public function snapshot(ExistingBusiness $business, User $actor, string $action, string $reason): void + /** @param array $context */ + public function snapshot(ExistingBusiness $business, User $actor, string $action, string $reason, array $context = []): void { - $snapshot = json_encode(['record' => $business->attributesToArray(), 'payload' => $business->payload, + $snapshot = json_encode(['context' => $context, '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); diff --git a/app/Services/ExistingBusinessVerification.php b/app/Services/ExistingBusinessVerification.php new file mode 100644 index 00000000..43fd1263 --- /dev/null +++ b/app/Services/ExistingBusinessVerification.php @@ -0,0 +1,78 @@ +where('business_id', $id) + ->whereIn('action', ['created', 'updated', 'object_added', 'object_updated'])->max('version_no'); + } + + /** @param array $input */ + public function save(User $actor, string $id, array $input): ExistingBusiness + { + $data = Validator::make($input, [ + 'version_no' => ['required', 'integer', 'min:1'], + 'object_id' => ['nullable', 'uuid'], + 'check' => ['required', 'in:'.implode(',', self::CHECKS)], + 'outcome' => ['required', 'in:confirmed,rejected,inconclusive,revoked'], + 'evidence_reference' => ['required', 'string', 'min:10', 'max:2000'], + 'note' => ['required', 'string', 'min:10', 'max:2000'], + 'valid_until' => ['required_if:outcome,confirmed', 'nullable', 'date_format:Y-m-d', 'after_or_equal:today'], + ])->validate(); + + return DB::transaction(function () use ($actor, $id, $data) { + $business = ExistingBusiness::whereKey($id)->lockForUpdate()->firstOrFail(); + Gate::forUser($actor)->authorize('review', $business); + abort_unless($business->status === 'in_review' && $business->version_no === $data['version_no'], 409, 'Reabra a versão actual em revisão.'); + if (! empty($data['object_id'])) { + ExistingBusinessObject::where('business_id', $id)->whereKey($data['object_id'])->firstOrFail(); + } + $data['content_version'] = $this->contentVersion($id); + $business->version_no++; + $business->save(); + app(ExistingBusinessDrafts::class)->snapshot($business, $actor, 'verification', $data['note'], $data); + + return $business; + }); + } + + /** Latest finding for each check/subject; complete evidence remains in immutable revisions. + * @return array> + */ + public function findings(ExistingBusiness $business): array + { + $contentVersion = $this->contentVersion($business->id); + $findings = []; + foreach (DB::table('existing_business_revisions')->where('business_id', $business->id) + ->where('action', 'verification')->orderByDesc('version_no')->cursor() as $revision) { + $data = json_decode(Crypt::decryptString($revision->snapshot), true, 512, JSON_THROW_ON_ERROR)['context']; + $key = ($data['object_id'] ?? 'business').':'.$data['check']; + if (isset($findings[$key])) { + continue; + } + $effective = $data['outcome']; + if ($data['content_version'] !== $contentVersion) { + $effective = 'stale'; + } elseif (! empty($data['valid_until']) && $data['valid_until'] < today()->toDateString()) { + $effective = 'expired'; + } + $findings[$key] = [...$data, 'effective_outcome' => $effective, + 'version_no' => $revision->version_no, 'created_at' => $revision->created_at]; + } + + return array_values($findings); + } +} diff --git a/docs/investmoz/existing-business/browser-validation.json b/docs/investmoz/existing-business/browser-validation.json index c654435c..b8814759 100644 --- a/docs/investmoz/existing-business/browser-validation.json +++ b/docs/investmoz/existing-business/browser-validation.json @@ -1,5 +1,5 @@ { - "date": "2026-09-28T18:25:03.759Z", + "date": "2026-09-29T06:39:38.960Z", "scope": "Synthetic private create/save and responsive layout in isolated QA; institutional review covered by integration tests", "observations": [ { diff --git a/docs/investmoz/existing-business/implementation-status.md b/docs/investmoz/existing-business/implementation-status.md index 57796cc1..500a74f3 100644 --- a/docs/investmoz/existing-business/implementation-status.md +++ b/docs/investmoz/existing-business/implementation-status.md @@ -1,5 +1,7 @@ # Estado actual — Mercado de Negócios Existentes +Actualização 29/09/2026: [revisão granular com evidência, validade e invalidação por alteração](verification-evidence.md) implementada na ficha privada. Suite: 173 testes / 1785 verificações aprovadas. Os limites e contagens abaixo descrevem a entrega anterior; o módulo integral continua incompleto. + 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 diff --git a/docs/investmoz/existing-business/openapi.json b/docs/investmoz/existing-business/openapi.json index 6dc86a8c..2da38d39 100644 --- a/docs/investmoz/existing-business/openapi.json +++ b/docs/investmoz/existing-business/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "InvestMoz — private existing-business intake", - "version": "1.0.0", + "version": "1.1.0", "description": "Internal preparation and review only. No publication, certification, legal transfer, NDA, payment or external CRM operation." }, "servers": [ @@ -320,6 +320,90 @@ } } } + }, + "verifications": { + "type": "array", + "items": { + "type": "object", + "required": [ + "version_no", + "check", + "outcome", + "evidence_reference", + "note" + ], + "properties": { + "version_no": { + "type": "integer", + "minimum": 1 + }, + "object_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "check": { + "enum": [ + "identity", + "representation", + "declared_ownership", + "documentary_ownership", + "document_received", + "document_validated", + "independent_valuation", + "land_right_documented", + "license_confirmed", + "validity", + "legal_transfer", + "prior_authorization" + ] + }, + "outcome": { + "enum": [ + "confirmed", + "rejected", + "inconclusive", + "revoked" + ] + }, + "evidence_reference": { + "type": "string", + "minLength": 10, + "maxLength": 2000 + }, + "note": { + "type": "string", + "minLength": 10, + "maxLength": 2000 + }, + "valid_until": { + "type": [ + "string", + "null" + ], + "format": "date", + "description": "Required for confirmed findings; must be today or later." + }, + "effective_outcome": { + "enum": [ + "confirmed", + "rejected", + "inconclusive", + "revoked", + "stale", + "expired" + ] + }, + "content_version": { + "type": "integer" + }, + "created_at": { + "type": "string" + } + } + } } } } @@ -1088,6 +1172,130 @@ }, "description": "Session authentication and Laravel CSRF/origin protection apply. Use X-XSRF-TOKEN for browser clients." } + }, + "/listings/{id}/verifications": { + "post": { + "summary": "Record independent finding while in_review; immutable encrypted evidence, optimistic version and scoped object. Does not publish or certify the business.", + "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", + "check", + "outcome", + "evidence_reference", + "note" + ], + "properties": { + "version_no": { + "type": "integer", + "minimum": 1 + }, + "object_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "check": { + "enum": [ + "identity", + "representation", + "declared_ownership", + "documentary_ownership", + "document_received", + "document_validated", + "independent_valuation", + "land_right_documented", + "license_confirmed", + "validity", + "legal_transfer", + "prior_authorization" + ] + }, + "outcome": { + "enum": [ + "confirmed", + "rejected", + "inconclusive", + "revoked" + ] + }, + "evidence_reference": { + "type": "string", + "minLength": 10, + "maxLength": 2000 + }, + "note": { + "type": "string", + "minLength": 10, + "maxLength": 2000 + }, + "valid_until": { + "type": [ + "string", + "null" + ], + "format": "date", + "description": "Required for confirmed findings; must be today or later." + } + } + } + } + } + }, + "description": "Session authentication and Laravel CSRF/origin protection apply. Use X-XSRF-TOKEN for browser clients." + } } }, "components": { diff --git a/docs/investmoz/existing-business/verification-evidence.md b/docs/investmoz/existing-business/verification-evidence.md new file mode 100644 index 00000000..26844d0b --- /dev/null +++ b/docs/investmoz/existing-business/verification-evidence.md @@ -0,0 +1,32 @@ +# Revisão granular — 29/09/2026 + +## Entrega e diagnóstico + +A ficha privada passa a mostrar verificações separadas de identidade, representação, titularidade declarada e documental, recepção e validação documental, avaliação independente, direito fundiário, licença, validade, transmissibilidade e autorização prévia. A ausência anterior deste registo era uma lacuna funcional, não falta de autorização genérica do proprietário do projecto. + +## Implementação e rastreabilidade + +`ExistingBusinessVerification` aplica a política de revisor designado com MFA, impede auto-revisão, exige estado `in_review`, versão actual, referência da evidência e fundamentação. Uma conclusão confirmada exige validade. Elementos de outro negócio são recusados. `ExistingBusinessController`, `routes/existing-business.php` e `business-verifications.tsx` disponibilizam o formulário e a leitura na ficha existente. + +O endpoint `POST /api/v1/existing-business/listings/{id}/verifications` tem contrato em `openapi.json`. A leitura inclui a última conclusão por aspecto/elemento. O histórico completo permanece nas revisões imutáveis; evidência, conclusão e contexto são encriptados. Cada registo incrementa a versão do agregado e identifica a versão dos dados analisados. Alterações aos dados invalidam conservadoramente todas as conclusões anteriores; expiração é calculada em cada leitura. É possível registar conclusão revogada sem apagar a anterior. + +Não há migration, importação ou alteração dos dados de negócio locais. Reutiliza-se a tabela de revisões e o seu bloqueio de UPDATE/DELETE. A evolução acrescenta `context` aos novos snapshots; snapshots antigos continuam legíveis. Os estados declarativos dos objectos não são convertidos em certificação global. + +## Validação executada + +- Testes focados MNE: 10 testes, 86 verificações. +- Suite completa em PostgreSQL isolado: 173 testes, 1785 verificações aprovadas. +- PHPStan, Pint, TypeScript e lint dos componentes aprovados. +- `npm run build`: 2371 módulos, 9,16 s. +- Navegador QA: criação, gravação, submissão e layout a 390/1440 px; quatro observações, zero erros JavaScript. O formulário do revisor ainda não tem teste de navegador específico; a autorização e a gravação são verificadas por integração. +- Casos novos: auto-revisão, estado inválido, versão desactualizada, objecto fora do negócio, validade obrigatória, cifragem, expiração, invalidação após edição e acesso de terceiro. + +## Operação, riscos e decisão + +**Go condicionado para revisão privada; No-Go para o módulo completo.** O formulário está na ficha `/investidor/negocios/{id}` para revisores já designados, depois de iniciarem a revisão pela fila `/management/negocios`. Não foram atribuídos papéis a contas reais. O proprietário vê as conclusões mas não pode emitir verificações sobre o seu negócio. + +Referências são texto reservado, não anexos carregados nem prova de autenticação externa. Ainda faltam upload/scanner, mandatos verificáveis, duas aprovações de publicação, catálogo anonimizado, compradores/interesses, CRM MNE, sala documental/diligência, negociação e validação operacional das fases seguintes. Esta entrega não satisfaz, por si só, o prompt integral. + +Para suspensão, usar a flag existente `INVESTMOZ_MNE_ENABLED=false` e limpar a cache de configuração, preservando revisões. Não reverter a base nem apagar evidências. Uma reversão de código deve conservar a leitura dos novos contextos para não ocultar o histórico. + +Próxima dependência funcional: recepção privada de documentos, quarentena e análise de ficheiros, vinculadas ao negócio e à revisão. A autorização geral para continuar mantém-se; informação institucional ausente deve permanecer pendente e não ser inventada. diff --git a/resources/js/pages/investment/business-verifications.tsx b/resources/js/pages/investment/business-verifications.tsx new file mode 100644 index 00000000..546e92f3 --- /dev/null +++ b/resources/js/pages/investment/business-verifications.tsx @@ -0,0 +1,176 @@ +import { router } from "@inertiajs/react"; +import { useState } from "react"; +import { api, button, card, Field, input } from "./shared"; +import type { BusinessObject } from "./business-objects"; + +const labels: Record = { + identity: "Identidade", + representation: "Representação", + declared_ownership: "Titularidade declarada", + documentary_ownership: "Titularidade documental", + document_received: "Documento recebido", + document_validated: "Documento validado", + independent_valuation: "Avaliação independente", + land_right_documented: "Direito fundiário documentado", + license_confirmed: "Licença confirmada", + validity: "Validade", + legal_transfer: "Transmissibilidade jurídica", + prior_authorization: "Autorização prévia", + confirmed: "Confirmado pelo revisor", + rejected: "Não confirmado", + inconclusive: "Inconclusivo", + revoked: "Revogado", + stale: "Rever após alteração dos dados", + expired: "Expirado", +}; +export type BusinessVerification = { + check: string; + object_id?: string | null; + outcome: string; + effective_outcome: string; + evidence_reference: string; + note: string; + valid_until?: string | null; + version_no: number; +}; +export default function BusinessVerifications({ + id, + version, + editable, + checks, + findings, + objects, +}: { + id: string; + version: number; + editable: boolean; + checks: string[]; + findings: BusinessVerification[]; + objects: BusinessObject[]; +}) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [outcome, setOutcome] = useState("inconclusive"); + return ( +
+

Verificações e evidências

+

+ Cada conclusão corresponde apenas ao aspecto e à versão analisados. Não constitui + certificação global, autorização de publicação ou garantia de transmissão. + Alterações aos dados exigem nova revisão. +

+ {findings.length === 0 &&

Ainda não existem verificações registadas.

} +
    + {findings.map((finding) => ( +
  • + + {labels[finding.check]} · {labels[finding.effective_outcome]} + +

    + {objects.find((object) => object.id === finding.object_id)?.payload + .label ?? "Negócio"}{" "} + · versão {finding.version_no} +

    +

    Evidência: {finding.evidence_reference}

    +

    {finding.note}

    + {finding.valid_until &&

    Válido até {finding.valid_until}

    } +
  • + ))} +
+ {editable && ( +
{ + event.preventDefault(); + const data = Object.fromEntries(new FormData(event.currentTarget)); + setBusy(true); + setError(""); + try { + await api(`/api/v1/existing-business/listings/${id}/verifications`, { + ...data, + version_no: version, + }); + router.reload(); + } catch (failure) { + setError( + failure instanceof Error + ? failure.message + : "Não foi possível registar a verificação.", + ); + } finally { + setBusy(false); + } + }} + > +
+ + + + + + + + + + +