'Problema a resolver', 'clients' => 'Público-alvo e clientes nacionais/estrangeiros', 'value' => 'Proposta de valor', 'revenues' => 'Modelo de receitas', 'services' => 'Serviços pretendidos', 'channels' => 'Canais de prestação', 'currencies' => 'Moedas de operação declaradas', 'location' => 'Localização efetiva e instalações', 'coverage' => 'Abrangência de operação', 'technology' => 'Tecnologia e arquitetura', 'infrastructure' => 'Infraestrutura', 'suppliers' => 'Fornecedores críticos', 'security' => 'Segurança e resposta a incidentes', 'continuity' => 'Continuidade e recuperação', 'partners' => 'Instituições financeiras e parceiros', 'team' => 'Equipa e capacidade operacional', 'maturity' => 'Maturidade declarada', 'dependencies' => 'Dependências concretas', 'schedule' => 'Calendário de implementação', ]; public const ACTIVITIES = ['moves_funds' => 'Movimentar fundos', 'deposits' => 'Receber depósitos', 'custody' => 'Custodiar fundos', 'credit' => 'Conceder crédito', 'payments' => 'Serviços de pagamento', 'investment_intermediation' => 'Intermediar investimentos', 'other' => 'Outra atividade potencialmente regulada']; public const DOCUMENTS = ['business_plan' => ['S04', 'Modelo de negócio, mercado e plano de investimento'], 'entity' => ['S05', 'Entidade, representação e participação'], 'architecture' => ['S06', 'Arquitetura tecnológica e fluxos de dados'], 'security' => ['S16', 'Segurança, continuidade e fornecedores'], 'fund_flows' => ['S08', 'Fluxos de fundos e enquadramento da atividade'], 'customers' => ['S17', 'Atendimento, reclamações e proteção do utilizador'], 'external' => ['S08', 'Pedido ou decisão externa e respetivo âmbito'], 'tests' => ['S17', 'Testes e preparação operacional']]; public function __construct(private readonly VirtualDesk $desk) {} public function save(User $actor, string $id, array $input): void { $keys = implode(',', array_keys(self::FIELDS)); $activities = implode(',', array_keys(self::ACTIVITIES)); $rules = ['version' => 'required|integer|min:1', 'profile' => ['required', 'array:model,fields,operation_countries,activities,activity_basis,documents'], 'profile.model' => ['nullable', Rule::in(['technology_provider', 'financial_operator', 'undetermined'])], 'profile.fields' => ['nullable', 'array:'.$keys], 'profile.operation_countries' => 'nullable|array|max:249', 'profile.operation_countries.*' => ['required', 'distinct', app(CountryCatalogue::class)->rule()], 'profile.activities' => ['nullable', 'array:'.$activities], 'profile.activity_basis' => 'nullable|string|max:3000', 'profile.documents' => 'nullable|array|max:8', 'profile.documents.*' => 'nullable|uuid']; foreach (self::FIELDS as $key => $label) { $rules['profile.fields.'.$key] = 'nullable|string|max:4000'; } foreach (self::ACTIVITIES as $key => $label) { $rules['profile.activities.'.$key] = ['nullable', Rule::in(['yes', 'no', 'unknown'])]; } $rules['profile.documents'] = ['nullable', 'array:'.implode(',', array_keys(self::DOCUMENTS))]; $data = Validator::make($input, $rules)->validate(); DB::transaction(function () use ($actor, $id, $data) { $case = $this->desk->case($actor, $id, true, true); abort_if($case->closed_at, 409, 'O dossier está encerrado.'); abort_unless($this->desk->owns($actor, $case), 403, 'Sem autorização para preparar este dossier.'); $payload = $this->desk->decode($case->payload); $profile = $data['profile']; $profile['model'] = $profile['model'] ?? 'undetermined'; $profile['fields'] = array_map(fn ($v) => trim((string) $v), $profile['fields'] ?? []); $profile['activities'] = array_replace(array_fill_keys(array_keys(self::ACTIVITIES), 'unknown'), $profile['activities'] ?? []); $profile['operation_countries'] = $profile['operation_countries'] ?? []; sort($profile['operation_countries']); $profile['documents'] = array_filter($profile['documents'] ?? []); $profile['activity_basis'] = trim($profile['activity_basis'] ?? ''); foreach ($profile['documents'] as $key => $document) { if (! DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')->where('l.case_id', $id)->where('l.document_id', $document)->whereNull('d.deleted_at')->exists()) { throw ValidationException::withMessages(['profile.documents.'.$key => 'O documento deixou de estar disponível neste dossier. Selecione um anexo associado e disponível; os campos preenchidos foram preservados.']); } } $hash = $this->fingerprint($profile); $old = $payload['investor_path']['fintech'] ?? null; if (($old['sha256'] ?? null) === $hash) { return; } abort_unless((int) $case->version === $data['version'], 409, 'O dossier foi alterado; recupere a versão atual antes de guardar.'); if ($old) { $payload['investor_path']['fintech_history'][] = $old; } $payload['investor_path']['fintech'] = ['schema' => self::VERSION, 'sha256' => $hash, 'profile' => $profile, 'actor_id' => $actor->id, 'saved_at' => now()->toIso8601String()]; DB::table('desk_cases')->where('id', $id)->update(['payload' => $this->desk->encode($payload), 'version' => $case->version + 1, 'updated_at' => now()]); $this->desk->event($this->desk->case($actor, $id), $actor, 'fintech_characterization_saved', ['schema' => self::VERSION, 'sha256' => $hash, 'previous_sha256' => $old['sha256'] ?? null]); }); } private function fingerprint(array $value): string { $canonical = function ($v) use (&$canonical) { if (! is_array($v)) { return $v; } if (! array_is_list($v)) { ksort($v); } return array_map($canonical, $v); }; return hash('sha256', json_encode($canonical($value), JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)); } public function reviewFingerprints(array $record): array { $profile = $record['profile'] ?? []; $fields = $profile['fields'] ?? []; $project = static fn (array $keys) => array_replace(array_fill_keys($keys, ''), array_intersect_key($fields, array_flip($keys))); $countries = $profile['operation_countries'] ?? []; sort($countries); // Country order and an omitted versus empty optional field are not material changes. return [ 'S02' => $this->fingerprint($project(['problem', 'clients', 'value', 'revenues'])), 'S08' => $this->fingerprint([$profile['model'] ?? 'undetermined', $profile['activities'] ?? [], $countries, $fields['services'] ?? '', $profile['activity_basis'] ?? '']), 'S17' => $this->fingerprint($project(['technology', 'infrastructure', 'suppliers', 'security', 'continuity', 'channels', 'location'])), ]; } public function adjustSteps(array $payload, array $steps): array { $record = $payload['investor_path']['fintech'] ?? null; if (! $record) { return $steps; } $fingerprints = $this->reviewFingerprints($record); foreach ($steps as &$step) { $hash = $fingerprints[$step['code']] ?? null; if (! $hash) { continue; } $reviewed = in_array($step['saved_state'], ['approved', 'reviewing', 'completed', 'ready_to_submit', 'submitted'], true); if ($reviewed && ($step['detail']['fintech_fingerprint'] ?? null) !== $hash) { $step['fintech_review_required'] = true; $step['state'] = 'correction_required'; $step['reason'] = 'Pressupostos Fintech alterados ou ainda não abrangidos por esta revisão. O parecer anterior está preservado; é necessária conferência deste âmbito.'; } } unset($step); return $steps; } public function view(array $payload, array $steps): array { $record = $payload['investor_path']['fintech'] ?? null; $p = $record['profile'] ?? []; $declared = $p['activities'] ?? []; $basis = $p['activity_basis'] ?? ''; $requirements = []; foreach (self::ACTIVITIES as $key => $label) { $value = $declared[$key] ?? 'unknown'; $requirements[] = ['code' => $key, 'activity' => $label, 'declared' => $value, 'state' => $value === 'yes' ? 'applicable' : ($value === 'no' && mb_strlen($basis) >= 3 ? 'dispensed' : 'undetermined'), 'action' => 'Preparar o enquadramento documental desta atividade', 'preparation_allowed' => true, 'basis' => $value === 'no' && mb_strlen($basis) >= 3 ? $basis : 'Declaração do promotor; a atividade e o enquadramento jurídico concreto ainda precisam de correspondência documental.', 'source' => ['nature' => 'investor_declaration', 'reference' => $record['sha256'] ?? null, 'provision' => null, 'version' => null, 'valid_period' => null], 'legal_license_state' => 'undetermined', 'limit' => 'Dispensa apenas desta preparação declarada; não é dispensa de licença ou confirmação externa.']; } $docs = []; foreach (self::DOCUMENTS as $key => [$stage,$label]) { $conditional = in_array($key, ['fund_flows', 'external'], true); $needed = $conditional ? (in_array('yes', $declared, true) ? 'applicable' : 'undetermined') : 'support'; $docs[] = ['code' => $key, 'stage' => $stage, 'label' => $label, 'applicability' => $needed, 'document_id' => $p['documents'][$key] ?? null, 'required_at_creation' => false, 'action' => $key === 'external' ? 'Documentar o ato quando existir; usar a etapa e a conferência independente existentes.' : 'Preparar e associar ao dossier sem duplicar originais.']; } $facts = ['project_summary' => $p['fields']['problem'] ?? '', 'activity' => $p['fields']['services'] ?? '', 'target_customers' => $p['fields']['clients'] ?? '', 'location' => $p['fields']['location'] ?? '', 'resource_plan' => $p['fields']['technology'] ?? '', 'schedule' => $p['fields']['schedule'] ?? '']; $guidance = app(InvestorGuidanceRules::class)->evaluate($facts); $map = ['idea' => 'S01', 'market' => 'S02', 'location' => 'S09', 'resources' => 'S06', 'finance' => 'S12', 'execution' => 'S16', 'support' => 'S19']; $guidance = array_map(fn ($item) => ['stage' => $map[$item['id']], 'recommendations' => $item['recommendations']], $guidance); return ['guidance' => $guidance, 'guidance_version' => InvestorGuidanceRules::VERSION, 'schema' => self::VERSION, 'record' => $record, 'fields' => self::FIELDS, 'activities' => self::ACTIVITIES, 'countries' => app(CountryCatalogue::class)->all(), 'requirements' => $requirements, 'documents' => $docs, 'review_fingerprints' => $record ? $this->reviewFingerprints($record) : [], 'affected_reviews' => array_values(array_column(array_filter($steps,fn ($s) => $s['fintech_review_required'] ?? false), 'code')), 'limits' => ['Fintech é caracterização do investimento, não nova categoria jurídica ou autorização.', 'Preparação de orçamento, tecnologia, documentos e parceiros pode avançar em paralelo.', 'Países de operação não alteram país mestre, origem, nacionalidade ou residências.', 'Capitais mínimos, taxas, prazos e licenças específicos permanecem por determinar sem disposição e período aplicáveis.']]; } }