feat: add private business intake review and reserved objects
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]);
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(); }
|
||||
@@ -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.');
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user