Files
Investidor-V2/app/Services/FintechInvestment.php
T
nala 6a76aac80f
tests / ci (push) Canceled after 0s
versao compilada 1
2026-10-16 09:17:28 +02:00

181 lines
12 KiB
PHP

<?php
namespace App\Services;
use App\Models\User;
use App\Services\Ai\InvestorGuidanceRules;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
/** Private characterization in the existing dossier; no legal classification or new workflow. */
final class FintechInvestment
{
public const VERSION = 'fintech-preparation-v1';
public const FIELDS = [
'problem' => '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.']];
}
}