feat: add private business intake review and reserved objects

This commit is contained in:
2026-09-28 20:26:26 +02:00
parent 519b256ffd
commit 2640dd6109
26 changed files with 2548 additions and 5 deletions
@@ -0,0 +1,82 @@
<?php
namespace App\Http\Controllers;
use App\Models\ExistingBusiness;
use App\Models\ExistingBusinessObject;
use App\Services\ExistingBusinessDrafts;
use App\Services\ExistingBusinessIntake;
use App\Services\ExistingBusinessObjects;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Inertia\Inertia;
use Inertia\Response;
class ExistingBusinessController extends Controller
{
public function index(Request $request): Response|JsonResponse
{
$management = $request->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<string,mixed> */
private function record(ExistingBusiness $business): array
{
return [...$business->only(['id', 'code', 'modality', 'status', 'classification', 'version_no']), 'payload' => $business->payload];
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ExistingBusinessEnabled
{
/** @param Closure(Request): Response $next */
public function handle(Request $request, Closure $next): Response
{
abort_unless(config('existing-business.enabled'), 404);
return $next($request);
}
}
@@ -40,6 +40,7 @@ class HandleInertiaRequests extends Middleware
'name' => 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(),
+20 -2
View File
@@ -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);
}
}
+6 -2
View File
@@ -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)]);
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Services;
use App\Models\ExistingBusiness;
use App\Models\User;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
final class ExistingBusinessIntake
{
public function transition(User $actor, string $id, int $version, string $action, string $note, bool $consent = false): ExistingBusiness
{
Validator::make(compact('action', 'note'), [
'action' => ['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;
});
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace App\Services;
use App\Models\ExistingBusiness;
use App\Models\ExistingBusinessObject;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
final class ExistingBusinessObjects
{
public const KINDS = ['company', 'participation', 'asset', 'property', 'unit', 'improvement', 'agricultural_investment', 'land_right', 'contract', 'license', 'authorization', 'encumbrance', 'valuation', 'capital_request'];
/** @return array<string,array<int,string>> */
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<string,mixed> $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;
});
}
}
+7
View File
@@ -0,0 +1,7 @@
<?php
return [
'enabled' => (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', '')))))),
];
@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
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 IN ('draft','submitted','in_review','needs_information','withdrawn'))");
Schema::create('existing_business_reviews', function (Blueprint $t) {
$t->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')");
}
};
@@ -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`.
@@ -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": []
}
@@ -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 <manifesto-privado>`: 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.
File diff suppressed because it is too large Load Diff
+12 -1
View File
@@ -17,6 +17,8 @@ export const portalLinks = [
["/apoio", "Apoio ao Investidor"],
] as const;
const names: Record<string, string> = {
"/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}
</Link>
))}
{existingBusinessEnabled && (
<Link href="/investidor/negocios" className="underline">
Os meus negócios
</Link>
)}
<Link href="/parcerias" className="underline">
Bolsa de Parcerias
</Link>
@@ -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<string, string>;
};
const kinds: Record<string, string> = {
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<string, string> = {
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<string | null>(null);
const [payload, setPayload] = useState<Record<string, string>>({});
const [reason, setReason] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
return (
<section className={`${card} space-y-4`}>
<h2 className="text-xl font-bold">Activos, contratos e direitos declarados</h2>
<p>
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.
</p>
{!objects.length && <p>Nenhum elemento registado.</p>}
{objects.map((o) => (
<article key={o.id} className="rounded border p-4">
<h3 className="font-bold">
{kinds[o.kind]} · {o.payload.label}
</h3>
<p>Pendente de verificação</p>
<dl>
{Object.entries(o.payload)
.filter(([key, value]) => key !== "label" && value)
.map(([key, value]) => (
<div key={key}>
<dt className="font-semibold">
{fields[key] ??
(
{
area_unit: "Unidade da área",
value_kind: "Natureza do valor",
contract_type: "Tipo de contrato",
} as Record<string, string>
)[key] ??
key}
</dt>
<dd className="break-words">
{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<string, string>
)[value] ?? value)
: value}
</dd>
</div>
))}
</dl>
{editable && (
<button
type="button"
className={button}
onClick={() => {
setObjectId(o.id);
setKind(o.kind);
setPayload(o.payload);
setReason("");
}}
>
Editar elemento
</button>
)}
</article>
))}
{editable && (
<form
className="space-y-4"
onSubmit={async (e) => {
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);
}
}}
>
<h3 className="font-bold">
{objectId ? "Editar elemento reservado" : "Acrescentar elemento reservado"}
</h3>
<Field label="Natureza">
<select
className={input}
value={kind}
disabled={!!objectId || busy}
onChange={(e) => setKind(e.target.value)}
>
{objectKinds.map((k) => (
<option key={k} value={k}>
{kinds[k]}
</option>
))}
</select>
</Field>
<div className="grid gap-4 md:grid-cols-2">
{Object.entries(fields).map(([key, label]) => (
<Field key={key} label={label}>
<input
className={input}
type={key.startsWith("valid_") ? "date" : "text"}
value={payload[key] ?? ""}
maxLength={key === "label" ? 200 : 2000}
required={key === "label" || key === "source_reference"}
onChange={(e) =>
setPayload({ ...payload, [key]: e.target.value })
}
/>
</Field>
))}
<Field label="Unidade da área">
<select
className={input}
value={payload.area_unit ?? ""}
onChange={(e) =>
setPayload({ ...payload, area_unit: e.target.value })
}
>
<option value="">Não indicada</option>
{["m2", "ha", "km2"].map((x) => (
<option key={x}>{x}</option>
))}
</select>
</Field>
<Field label="Natureza do valor">
<select
className={input}
value={payload.value_kind ?? ""}
onChange={(e) =>
setPayload({ ...payload, value_kind: e.target.value })
}
>
<option value="">Não indicada</option>
{Object.entries({
asking: "Pedido",
indicative: "Indicativo",
appraised: "Avaliado (declaração por verificar)",
proposed: "Proposto",
negotiated: "Negociado (declaração por verificar)",
confirmed: "Confirmado (declaração por verificar)",
}).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
</Field>
<Field label="Tipo de contrato">
<select
className={input}
value={payload.contract_type ?? ""}
onChange={(e) =>
setPayload({ ...payload, contract_type: e.target.value })
}
>
<option value="">Não indicado</option>
{Object.entries({
purchase: "Compra",
sale: "Venda",
lease: "Arrendamento",
operation: "Exploração",
management: "Gestão",
other: "Outro",
}).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
</Field>
</div>
<Field label="Motivo do registo ou alteração">
<textarea
className={input}
value={reason}
minLength={10}
maxLength={1000}
required
onChange={(e) => setReason(e.target.value)}
/>
</Field>
{error && <p role="alert">{error}</p>}
<button className={button} disabled={busy}>
{busy ? "A guardar…" : "Guardar elemento reservado"}
</button>
{objectId && (
<button
type="button"
className="ml-4 underline"
onClick={() => {
setObjectId(null);
setPayload({});
setReason("");
}}
>
Cancelar edição
</button>
)}
</form>
)}
</section>
);
}
@@ -0,0 +1,45 @@
export type Business = {
id: string;
code: string;
modality: string;
status: string;
classification: string;
version_no: number;
payload: {
title: string;
description?: string;
declared_reason?: string;
source_reference?: string;
valid_until?: string;
};
};
export const businessStates: Record<string, string> = {
draft: "Rascunho",
submitted: "Submetido",
in_review: "Em revisão",
needs_information: "Pendente de informação",
withdrawn: "Retirado",
};
export const businessModalities: Record<string, string> = {
company_sale: "Venda da empresa",
participation_sale: "Venda de participação",
capital_increase: "Aumento de capital",
financial_investor: "Investidor financeiro",
strategic_partner: "Sócio estratégico",
buyer_search: "Procura de comprador",
position_assignment: "Cessão de posição",
franchise: "Franchising",
asset_sale: "Venda de activos",
asset_lease: "Arrendamento de activos",
business_unit_transfer: "Transferência de unidade de negócio",
joint_venture: "Joint venture",
succession: "Sucessão empresarial",
recovery: "Recuperação",
expansion: "Expansão",
internationalization: "Internacionalização",
real_estate: "Imobiliário",
agricultural_investment: "Investimento agrícola",
land_right: "Direito fundiário",
mining_license: "Licença mineira",
forestry_license: "Licença florestal",
};
+237
View File
@@ -0,0 +1,237 @@
import { Link, router } from "@inertiajs/react";
import { useState } from "react";
import { Frame, Field, input, button, card, api } from "./shared";
import { businessModalities, businessStates } from "./business-types";
import type { Business } from "./business-types";
import BusinessObjects from "./business-objects";
import type { BusinessObject } from "./business-objects";
type Props = {
record: Business;
canEdit: boolean;
owner: boolean;
canReview: boolean;
modalities: string[];
objects: BusinessObject[];
objectKinds: string[];
history: { version_no: number; action: string; created_at: string; note: string }[];
};
export default function BusinessPage(props: Props) {
return <Editor key={`${props.record.id}:${props.record.version_no}`} {...props} />;
}
function Editor({
record,
canEdit,
owner,
canReview,
modalities,
history,
objects,
objectKinds,
}: Props) {
const [payload, setPayload] = useState(record.payload);
const [modality, setModality] = useState(record.modality);
const [note, setNote] = useState("");
const [consent, setConsent] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const dirty =
JSON.stringify(payload) !== JSON.stringify(record.payload) || modality !== record.modality;
const run = async (action?: string) => {
setBusy(true);
setError("");
try {
await api(
`/api/v1/existing-business/listings/${record.id}${action ? "/transitions" : ""}`,
action
? { action, version_no: record.version_no, note, review_consent: consent }
: {
_method: "PUT",
modality,
classification: record.classification,
payload,
reason: note,
version_no: record.version_no,
},
);
router.reload();
} catch (err) {
setError(
err instanceof Error
? err.message
: "Não foi possível guardar. Recarregue se a versão mudou.",
);
} finally {
setBusy(false);
}
};
return (
<Frame title={record.payload.title}>
<Link
className="underline"
href={owner ? "/investidor/negocios" : "/management/negocios"}
>
Voltar à lista
</Link>
<p>
{businessStates[record.status]} · versão {record.version_no} · informação reservada
</p>
<p>
Informação declarada, pendente de validação. Não constitui anúncio público,
certificação, avaliação ou autorização de transmissão.
</p>
<form
className={`${card} space-y-4`}
onSubmit={(e) => {
e.preventDefault();
void run();
}}
>
<fieldset disabled={!canEdit || busy} className="space-y-4">
<Field label="Modalidade">
<select
className={input}
value={modality}
onChange={(e) => setModality(e.target.value)}
>
{modalities.map((m) => (
<option value={m} key={m}>
{businessModalities[m] ?? m}
</option>
))}
</select>
</Field>
{(
[
["title", "Título privado"],
["description", "Descrição reservada"],
["declared_reason", "Motivo declarado"],
["source_reference", "Referência da fonte"],
["valid_until", "Validade declarada"],
] as const
).map(([key, label]) => (
<Field key={key} label={label}>
{key === "description" ? (
<textarea
className={input}
rows={5}
maxLength={10000}
value={payload[key] ?? ""}
onChange={(e) =>
setPayload({ ...payload, [key]: e.target.value })
}
/>
) : (
<input
className={input}
type={key === "valid_until" ? "date" : "text"}
maxLength={key === "title" ? 200 : 2000}
value={payload[key] ?? ""}
onChange={(e) =>
setPayload({ ...payload, [key]: e.target.value })
}
/>
)}
</Field>
))}
</fieldset>
<Field label="Motivo da alteração ou nota para a revisão">
<textarea
className={input}
value={note}
onChange={(e) => setNote(e.target.value)}
minLength={10}
maxLength={1000}
required
/>
</Field>
{error && <p role="alert">{error}</p>}
{canEdit && (
<button className={button} disabled={busy || note.trim().length < 10}>
Guardar rascunho
</button>
)}
{dirty && <p role="status">Guarde as alterações antes de mudar de estado.</p>}
</form>
{owner && canEdit && (
<label className="flex gap-3">
<input
type="checkbox"
checked={consent}
onChange={(e) => setConsent(e.target.checked)}
/>
Autorizo a análise interna desta submissão pelos revisores designados. Esta
autorização não permite publicação.
</label>
)}
<div className="flex flex-wrap gap-3">
{owner && canEdit && (
<button
className={button}
disabled={busy || dirty || !consent || note.trim().length < 10}
onClick={() => void run("submitted")}
>
Submeter para análise
</button>
)}
{canReview && record.status === "submitted" && (
<button
className={button}
disabled={busy || note.trim().length < 10}
onClick={() => void run("in_review")}
>
Iniciar revisão
</button>
)}
{canReview && record.status === "in_review" && (
<button
className={button}
disabled={busy || note.trim().length < 10}
onClick={() => void run("needs_information")}
>
Pedir informação
</button>
)}
{owner && record.status !== "withdrawn" && (
<button
className={button}
disabled={busy || dirty || note.trim().length < 10}
onClick={() => void run("withdrawn")}
>
Retirar submissão
</button>
)}
</div>
<BusinessObjects
businessId={record.id}
version={record.version_no}
editable={canEdit && !dirty && !busy}
objects={objects}
objectKinds={objectKinds}
/>
<section className={card}>
<h2 className="text-xl font-bold">Histórico</h2>
<ol>
{history.map((h) => (
<li key={h.version_no}>
Versão {h.version_no} ·{" "}
{businessStates[h.action] ??
(
{
created: "Criado",
updated: "Alterado",
archived: "Arquivado",
object_added: "Elemento declarado acrescentado",
object_updated: "Elemento declarado alterado",
} as Record<string, string>
)[h.action] ??
h.action}{" "}
· {new Date(h.created_at).toLocaleString("pt-MZ")}
<p>{h.note}</p>
</li>
))}
</ol>
</section>
</Frame>
);
}
@@ -0,0 +1,99 @@
import { Link, router } from "@inertiajs/react";
import { useState } from "react";
import { Frame, Field, input, button, card, api } from "./shared";
import { businessModalities, businessStates } from "./business-types";
import type { Business } from "./business-types";
export default function Businesses({
records,
management,
modalities,
}: {
records: { data: Business[]; prev_page_url: string | null; next_page_url: string | null };
management: boolean;
modalities: string[];
}) {
const [title, setTitle] = useState("");
const [modality, setModality] = useState(modalities[0]);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
return (
<Frame title={management ? "Revisão de negócios" : "Os meus negócios"}>
<p>
Área privada de preparação e revisão. A submissão não confirma titularidade,
validade ou transmissibilidade do negócio, activo ou direito.
</p>
{!management && (
<form
className={`${card} space-y-4`}
onSubmit={async (e) => {
e.preventDefault();
setBusy(true);
setError("");
try {
const result = await api("/api/v1/existing-business/listings", {
modality,
classification: "confidential",
reason: "Criação de rascunho pelo proprietário",
payload: { title },
});
router.visit(`/investidor/negocios/${result.id}`);
} catch (err) {
setError(
err instanceof Error
? err.message
: "Não foi possível criar o rascunho.",
);
} finally {
setBusy(false);
}
}}
>
<h2 className="text-xl font-bold">Preparar negócio</h2>
<Field label="Título privado">
<input
className={input}
value={title}
onChange={(e) => setTitle(e.target.value)}
required
maxLength={200}
/>
</Field>
<Field label="Modalidade">
<select
className={input}
value={modality}
onChange={(e) => setModality(e.target.value)}
>
{modalities.map((m) => (
<option key={m} value={m}>
{businessModalities[m] ?? m}
</option>
))}
</select>
</Field>
{error && <p role="alert">{error}</p>}
<button className={button} disabled={busy}>
{busy ? "A criar…" : "Criar rascunho privado"}
</button>
</form>
)}
{!records.data.length && <p role="status">Não existem negócios nesta lista.</p>}
{records.data.map((r) => (
<article className={card} key={r.id}>
<Link className="font-bold underline" href={`/investidor/negocios/${r.id}`}>
{r.payload.title}
</Link>
<p>
{businessModalities[r.modality]} · {businessStates[r.status]} · versão{" "}
{r.version_no}
</p>
</article>
))}
<nav aria-label="Paginação dos negócios" className="flex gap-4">
{records.prev_page_url && <Link href={records.prev_page_url}>Anterior</Link>}
{records.next_page_url && <Link href={records.next_page_url}>Seguinte</Link>}
</nav>
</Frame>
);
}
+22
View File
@@ -0,0 +1,22 @@
<?php
use App\Http\Controllers\ExistingBusinessController;
use App\Http\Middleware\ExistingBusinessEnabled;
use App\Http\Middleware\PrivateInvestmentResponse;
use Illuminate\Support\Facades\Route;
Route::middleware(['auth', 'verified', PrivateInvestmentResponse::class, 'throttle:30,1', ExistingBusinessEnabled::class])->group(function () {
Route::get('/investidor/negocios', [ExistingBusinessController::class, 'index']);
Route::get('/management/negocios', [ExistingBusinessController::class, 'index']);
Route::get('/investidor/negocios/{id}', [ExistingBusinessController::class, 'show'])->whereUuid('id');
Route::prefix('api/v1/existing-business')->group(function () {
Route::get('/reviews', [ExistingBusinessController::class, 'index']);
Route::get('/listings', [ExistingBusinessController::class, 'index']);
Route::post('/listings', [ExistingBusinessController::class, 'save']);
Route::get('/listings/{id}', [ExistingBusinessController::class, 'show'])->whereUuid('id');
Route::put('/listings/{id}', [ExistingBusinessController::class, 'save'])->whereUuid('id');
Route::post('/listings/{id}/transitions', [ExistingBusinessController::class, 'transition'])->whereUuid('id');
Route::post('/listings/{id}/objects', [ExistingBusinessController::class, 'saveObject'])->whereUuid('id');
Route::put('/listings/{id}/objects/{object}', [ExistingBusinessController::class, 'saveObject'])->whereUuid(['id', 'object']);
});
});
+2
View File
@@ -1,5 +1,7 @@
<?php
require __DIR__.'/existing-business.php';
require __DIR__.'/investment-zones.php';
use App\Http\Controllers\AiController;
@@ -87,8 +87,11 @@ class ExistingBusinessFoundationTest extends TestCase
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');
$intake = require database_path('migrations/2026_09_28_140000_existing_business_intake.php');
$intake->down();
$migration->down();
$migration->up();
$intake->up();
app(ExistingBusinessDrafts::class)->save(User::factory()->create(), $this->draft());
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('destructive rollback refused');
@@ -0,0 +1,114 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class ExistingBusinessIntakeTest extends TestCase
{
use RefreshDatabase;
private string $base = '/api/v1/existing-business/listings';
protected function setUp(): void
{
parent::setUp();
config(['existing-business.enabled' => true]);
}
private function reviewer(): User
{
$user = User::factory()->create();
$user->forceFill(['two_factor_secret' => Crypt::encryptString('SYNTHETIC'), 'two_factor_confirmed_at' => now()])->save();
config(['portal.managers' => [$user->email], 'existing-business.reviewers' => [$user->email]]);
return $user;
}
/** @return array<string,mixed> */
private function draft(): array
{
return ['modality' => 'participation_sale', 'classification' => 'restricted', 'reason' => 'Synthetic initial draft',
'payload' => ['title' => 'Private synthetic business', 'description' => 'Synthetic reserved description for the internal review only.',
'source_reference' => 'Synthetic source reference', 'valid_until' => today()->addMonth()->toDateString()]];
}
public function test_private_intake_review_information_and_withdrawal_preserve_isolation(): void
{
$owner = User::factory()->create();
$reviewer = $this->reviewer();
$this->actingAs($owner);
$id = $this->postJson($this->base, $this->draft())->assertCreated()->json('id');
$url = $this->base.'/'.$id;
$this->getJson($url)->assertOk()->assertHeader('Cache-Control', 'no-store, private');
$this->actingAs($reviewer)->getJson($url)->assertForbidden();
$this->actingAs($owner)->postJson($url.'/transitions', ['version_no' => 1, 'action' => 'submitted', 'note' => 'Synthetic review requested'])->assertUnprocessable();
$this->postJson($url.'/transitions', ['version_no' => 1, 'action' => 'submitted', 'note' => 'Synthetic review requested', 'review_consent' => true])->assertOk()->assertJsonPath('version_no', 2);
$this->putJson($url, [...$this->draft(), 'version_no' => 2])->assertForbidden();
$this->postJson($url.'/transitions', ['version_no' => 2, 'action' => 'in_review', 'note' => 'Attempted self review'])->assertForbidden();
$this->actingAs($reviewer)->getJson('/api/v1/existing-business/reviews')->assertOk()->assertJsonPath('records.total', 1);
$this->postJson($url.'/transitions', ['version_no' => 1, 'action' => 'in_review', 'note' => 'Synthetic review started'])->assertConflict();
$this->postJson($url.'/transitions', ['version_no' => 2, 'action' => 'in_review', 'note' => 'Synthetic review started'])->assertOk()->assertJsonPath('version_no', 3);
$this->postJson($url.'/transitions', ['version_no' => 3, 'action' => 'needs_information', 'note' => 'Please clarify synthetic source'])->assertOk();
$this->actingAs($owner)->getJson($url)->assertOk()->assertJsonPath('history.0.note', 'Please clarify synthetic source');
$this->postJson($url, [...$this->draft(), '_method' => 'PUT', 'version_no' => 4])->assertOk()->assertJsonPath('version_no', 5);
$this->postJson($url.'/transitions', ['version_no' => 5, 'action' => 'withdrawn', 'note' => 'Synthetic withdrawal requested'])->assertOk();
$this->actingAs($reviewer)->getJson($url)->assertForbidden();
$this->getJson('/api/v1/existing-business/reviews')->assertJsonPath('records.total', 0);
$this->assertDatabaseCount('existing_business_reviews', 4);
$this->assertDatabaseCount('existing_business_revisions', 6);
$this->assertStringNotContainsString('Please clarify', DB::table('existing_business_reviews')->where('action', 'needs_information')->value('note'));
}
public function test_flag_mfa_and_current_designation_are_required(): void
{
$this->getJson($this->base)->assertUnauthorized();
$reviewer = $this->reviewer();
$this->actingAs($reviewer)->getJson('/api/v1/existing-business/reviews')->assertOk();
config(['existing-business.reviewers' => []]);
$this->getJson('/api/v1/existing-business/reviews')->assertForbidden();
config(['existing-business.reviewers' => [$reviewer->email]]);
$reviewer->forceFill(['two_factor_confirmed_at' => null])->save();
$this->getJson('/api/v1/existing-business/reviews')->assertForbidden();
config(['existing-business.enabled' => false]);
$this->getJson($this->base)->assertNotFound();
}
public function test_arbitrary_states_and_incomplete_or_expired_submission_are_rejected(): void
{
$this->actingAs(User::factory()->create());
$draft = $this->draft();
$draft['payload']['valid_until'] = today()->subDay()->toDateString();
$id = $this->postJson($this->base, $draft)->assertCreated()->json('id');
foreach (['published', 'approved', 'submitted'] as $action) {
$this->postJson($this->base.'/'.$id.'/transitions', ['version_no' => 1, 'action' => $action, 'note' => 'Synthetic invalid transition', 'review_consent' => true])->assertUnprocessable();
}
$this->assertDatabaseCount('existing_business_reviews', 0);
}
public function test_reserved_objects_are_scoped_versioned_and_never_verified_by_declaration(): void
{
$this->actingAs(User::factory()->create());
$first = $this->postJson($this->base, $this->draft())->assertCreated()->json('id');
$second = $this->postJson($this->base, $this->draft())->assertCreated()->json('id');
$object = ['kind' => 'license', 'version_no' => 1, 'reason' => 'Synthetic license declaration',
'payload' => ['label' => 'Private synthetic license', 'source_reference' => 'Synthetic source only',
'declared_holder' => 'Synthetic holder', 'valid_from' => '2026-01-01', 'valid_until' => '2027-01-01',
'amount' => '1200.25', 'currency' => 'MZN', 'value_kind' => 'asking']];
$id = $this->postJson($this->base.'/'.$first.'/objects', $object)->assertCreated()->assertJsonPath('verification_status', 'pending')->assertJsonPath('version_no', 2)->json('id');
$this->postJson($this->base.'/'.$first.'/objects', $object)->assertConflict();
$this->putJson($this->base.'/'.$second.'/objects/'.$id, $object)->assertNotFound();
$this->putJson($this->base.'/'.$first.'/objects/'.$id, [...$object, 'version_no' => 2, 'kind' => 'asset'])->assertUnprocessable();
$object['payload']['currency'] = null;
$this->putJson($this->base.'/'.$first.'/objects/'.$id, [...$object, 'version_no' => 2])->assertUnprocessable();
$this->getJson($this->base.'/'.$first)->assertOk()->assertJsonPath('objects.0.payload.amount', '1200.25');
$this->assertStringNotContainsString('Synthetic holder', DB::table('existing_business_objects')->where('id', $id)->value('payload'));
$snapshot = Crypt::decryptString(DB::table('existing_business_revisions')->where('business_id', $first)->where('version_no', 2)->value('snapshot'));
$this->assertStringContainsString('Synthetic holder', $snapshot);
$this->actingAs(User::factory()->create())->getJson($this->base.'/'.$first)->assertForbidden();
}
}
+3
View File
@@ -67,6 +67,8 @@ class InvestmentZoneTest extends TestCase
$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');
$intakeMigration = require database_path('migrations/2026_09_28_140000_existing_business_intake.php');
$intakeMigration->down();
$businessMigration->down();
$originMigration->down();
$geometryMigration->down();
@@ -75,6 +77,7 @@ class InvestmentZoneTest extends TestCase
$geometryMigration->up();
$originMigration->up();
$businessMigration->up();
$intakeMigration->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 {
+71
View File
@@ -0,0 +1,71 @@
// Isolated QA only. Synthetic account is prepared separately, never against the application DB.
import fs from 'node:fs';
const base = 'http://127.0.0.1:18027';
const target = await (await fetch('http://127.0.0.1:9224/json/new?about:blank', { method: 'PUT' })).json();
const ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise(resolve => ws.addEventListener('open', resolve, { once: true }));
let seq = 0;
const pending = new Map();
const errors = [];
ws.onmessage = event => {
const m = JSON.parse(event.data);
if (m.method === 'Runtime.exceptionThrown') errors.push(m.params.exceptionDetails.text);
if (m.id) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.reject(m.error) : p.resolve(m.result); }
};
const send = (method, params = {}) => new Promise((resolve, reject) => { const id = ++seq; pending.set(id, { resolve, reject }); ws.send(JSON.stringify({ id, method, params })); });
const evaluate = async expression => {
const r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) throw Error('Browser expression failed');
return r.result?.value;
};
const wait = async expression => { for (let n = 0; n < 100; n++) { if (await evaluate(expression)) return; await new Promise(r => setTimeout(r, 100)); } throw Error('Browser condition timed out: ' + expression); };
const fill = async (selector, value) => {
await evaluate(`document.querySelector(${JSON.stringify(selector)}).focus()`);
await send('Input.insertText', { text: value });
};
const observations = [];
try {
await send('Page.enable'); await send('Runtime.enable');
await send('Emulation.setDeviceMetricsOverride', { width: 390, height: 900, deviceScaleFactor: 1, mobile: true });
await send('Page.navigate', { url: base + '/login' });
await wait(`document.readyState==='complete' && (location.pathname!='/login' || !!document.querySelector('input[type="password"]'))`);
if (await evaluate(`location.pathname==='/login'`)) {
await fill('input[type="email"]', 'mne-browser@example.test');
await fill('input[type="password"]', 'SyntheticMneOnly-2026!');
await evaluate(`document.querySelector('button[type="submit"]').click()`);
await wait(`!location.pathname.startsWith('/login')`);
}
await send('Page.navigate', { url: base + '/investidor/negocios' });
await wait(`!!document.querySelector('h1') && document.body.innerText.includes('Preparar neg')`);
await fill('main form input', 'Synthetic browser business');
await evaluate(`document.querySelector('main form button').click()`);
await wait(`location.pathname.startsWith('/investidor/negocios/') && !!document.querySelector('textarea')`);
await fill('fieldset textarea', 'Synthetic private description for review of a browser-only business.');
await fill('fieldset input[maxlength="2000"]', 'Synthetic declared reason');
await fill('fieldset input[maxlength="2000"]:last-of-type', 'Synthetic source evidence');
// Find source input by its wrapping label; field labels are accessible label elements.
await evaluate(`(()=>{const labels=[...document.querySelectorAll('label')];const field=labels.find(l=>l.textContent.includes('Referência da fonte'))?.querySelector('input'); if(field) field.focus();})()`);
await send('Input.insertText', { text: 'Synthetic source evidence' });
await evaluate(`(()=>{const el=document.querySelector('input[type="date"]'); const setter=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set; setter.call(el,'2027-12-31');el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('change',{bubbles:true}));})()`);
await fill('main form > label textarea', 'Synthetic save for browser QA');
await evaluate(`document.querySelector('main form button').click()`);
await wait(`document.body.innerText.includes('versão 2')`);
observations.push({ scenario: 'private_create_and_save', passed: true });
await fill('main form > label textarea', 'Synthetic submission for browser QA');
await evaluate(`document.querySelector('input[type="checkbox"]').click()`);
await evaluate(`[...document.querySelectorAll('button')].find(b=>b.textContent==='Submeter para análise').click()`);
await wait(`document.body.innerText.includes('versão 3') && document.body.innerText.includes('Submetido')`);
observations.push({ scenario: 'private_submit_with_review_consent', passed: true });
for (const width of [390, 1440]) {
await send('Emulation.setDeviceMetricsOverride', { width, height: 900, deviceScaleFactor: 1, mobile: width < 600 });
const result = await evaluate(`(()=>{const a=[...document.querySelectorAll('a')].find(a=>getComputedStyle(a).position==='fixed'&&a.href.endsWith('/apoio'));const b=a?.getBoundingClientRect();return {width:innerWidth,breadcrumb:!!document.querySelector('nav[aria-label="Caminho de navegação"]'),supportVisible:!!b&&b.bottom<=innerHeight&&b.left>=0,overflow:document.documentElement.scrollWidth>innerWidth+1};})()`);
if (!result.breadcrumb || !result.supportVisible || result.overflow) throw Error('Layout failure');
observations.push(result);
}
if (errors.length) throw Error('JavaScript errors observed');
fs.writeFileSync('docs/investmoz/existing-business/browser-validation.json', JSON.stringify({ date: new Date().toISOString(), scope: 'Synthetic private create/save and responsive layout in isolated QA; institutional review covered by integration tests', observations, errors }, null, 2) + '\n');
console.log(JSON.stringify({ passed: observations.length, javascript_errors: errors.length }));
} catch (error) {
console.log(await evaluate(`({path:location.pathname,title:document.title,body:document.body.innerText.slice(0,1200)})`));
throw error;
} finally { await send('Page.close'); ws.close(); }
+39
View File
@@ -0,0 +1,39 @@
import fs from 'node:fs';
const ref = name => ({ $ref: `#/components/schemas/${name}` });
const id = { name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } };
const responses = schema => Object.fromEntries(['200', '401', '403', '404', '409', '422', '429'].map(status => [status, {
description: { '200': 'Private response; Cache-Control private, no-store', '401': 'Authentication required', '403': 'Account, ownership or current reviewer/MFA policy rejected', '404': 'Module disabled or resource unavailable', '409': 'Stale version or invalid transition', '422': 'Validation error', '429': 'Rate limited' }[status],
...(status === '200' ? { content: { 'application/json': { schema } } } : {}),
}]));
const operation = (summary, response, body, parameters = []) => ({ summary, parameters, security: [{ session: [] }], responses: responses(response), ...(body ? { requestBody: { required: true, content: { 'application/json': { schema: body } } }, description: 'Session authentication and Laravel CSRF/origin protection apply. Use X-XSRF-TOKEN for browser clients.' } : {}) });
const text = { type: 'string' };
const 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' } } };
const input = { type: 'object', required: ['modality', 'classification', 'reason', 'payload'], properties: { modality: text, classification: { enum: ['internal', 'confidential', 'restricted'] }, reason: { type: 'string', minLength: 10, maxLength: 1000 }, payload } };
const document = {
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: operation('List own active records, 20 per page', { type: 'object', required: ['records', 'management', 'modalities'], properties: { records: { type: 'object', properties: { data: { type: 'array', items: ref('Business') }, total: { type: 'integer' } } }, management: { type: 'boolean' }, modalities: { type: 'array', items: text } } }, null, [{ name: 'page', in: 'query', schema: { type: 'integer', minimum: 1 } }]),
post: operation('Create private draft', ref('Business'), input),
},
'/reviews': { get: operation('List submitted records for designated reviewers with MFA; excludes own records', { type: 'object', properties: { records: { type: 'object', properties: { data: { type: 'array', items: ref('Business') } } }, management: { type: 'boolean' } } }) },
'/listings/{id}': {
get: operation('Read own record or currently reviewable submission', { type: 'object', properties: { record: ref('Business'), canEdit: { type: 'boolean' }, owner: { type: 'boolean' }, canReview: { type: 'boolean' }, history: { type: 'array', items: { type: 'object', properties: { version_no: { type: 'integer' }, action: text, created_at: text, note: text } } } } }, null, [id]),
put: operation('Update owned draft or request for information; optimistic version required', ref('Business'), { ...input, required: [...input.required, 'version_no'], properties: { ...input.properties, version_no: { type: 'integer', minimum: 1 } } }, [id]),
},
'/listings/{id}/transitions': { post: operation('Submit, begin review, request information or withdraw', ref('Business'), { 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.' } } }, [id]) },
},
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: text, modality: text, status: { enum: ['draft', 'submitted', 'in_review', 'needs_information', 'withdrawn'] }, classification: { enum: ['internal', 'confidential', 'restricted'] }, version_no: { type: 'integer', minimum: 1 }, payload } } } },
};
document.paths['/listings'].post.responses['201'] = document.paths['/listings'].post.responses['200'];
delete document.paths['/listings'].post.responses['200'];
const objectPayload = { type: 'object', additionalProperties: false, required: ['label', 'source_reference'], properties: Object.fromEntries(['label','declared_holder','document_reference','source_reference','competent_entity','purpose','location_description','boundaries_description','occupancy','condition','encumbrances','restrictions','required_authorizations','transfer_condition','contract_type','valid_from','valid_until','area','area_unit','amount','currency','value_kind','participation_percent'].map(key => [key, { type: ['string','null'] }])) };
const objectInput = { 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: objectPayload } };
const objectResponse = { type: 'object', properties: { id: { type: 'string', format: 'uuid' }, version_no: { type: 'integer' }, verification_status: { const: 'pending' } } };
document.paths['/listings/{id}/objects'] = { post: operation('Declare a separate restricted object; increments aggregate version without granting verification', objectResponse, objectInput, [id]) };
document.paths['/listings/{id}/objects'].post.responses['201'] = document.paths['/listings/{id}/objects'].post.responses['200'];
delete document.paths['/listings/{id}/objects'].post.responses['200'];
document.paths['/listings/{id}/objects/{object}'] = { put: operation('Update scoped object with immutable kind; amount requires currency and value kind; area requires unit', objectResponse, objectInput, [id, { ...id, name: 'object' }]) };
fs.writeFileSync('docs/investmoz/existing-business/openapi.json', JSON.stringify(document, null, 2) + '\n');
console.log('OpenAPI written: 8 operations, private session API.');
+35
View File
@@ -0,0 +1,35 @@
<?php
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\DB;
require __DIR__.'/../vendor/autoload.php';
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
if (! $app->environment('local') || DB::connection()->getDriverName() !== 'pgsql') {
throw new RuntimeException('Local PostgreSQL only.');
}
$manifest = $argv[1] ?? '';
if (! is_file($manifest)) {
throw new RuntimeException('Provide the private backup manifest path.');
}
$before = json_decode(file_get_contents($manifest), true, 512, JSON_THROW_ON_ERROR)['row_counts'];
$result = DB::transaction(function () use ($before): array {
DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
$changes = [];
foreach ($before as $table => $count) {
$after = DB::table($table)->count();
if ($after !== $count) {
$changes[$table] = ['before' => $count, 'after' => $after];
}
}
$new = [];
foreach (['existing_businesses', 'existing_business_objects', 'existing_business_mandates', 'existing_business_revisions', 'existing_business_reviews'] as $table) {
$new[$table] = DB::table($table)->count();
}
return ['previous_tables' => count($before), 'changes' => $changes, 'new_tables' => $new,
'private_module_enabled' => (bool) config('existing-business.enabled'),
'designated_reviewer_count' => count(config('existing-business.reviewers', []))];
});
echo json_encode($result, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL;