201 lines
13 KiB
PHP
201 lines
13 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) {}
|
|
|
|
/**
|
|
* @param array<array-key, mixed> $input
|
|
*/
|
|
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]);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param array<array-key, mixed> $value
|
|
*/
|
|
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));
|
|
}
|
|
|
|
/**
|
|
* @param array<array-key, mixed> $record
|
|
* @return array<array-key, mixed>
|
|
*/
|
|
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'])),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<array-key, mixed> $payload
|
|
* @param array<array-key, mixed> $steps
|
|
* @return array<array-key, mixed>
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* @param array<array-key, mixed> $payload
|
|
* @param array<array-key, mixed> $steps
|
|
* @return array<array-key, mixed>
|
|
*/
|
|
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_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.']];
|
|
}
|
|
}
|