Files
Investidor-V2/app/Services/CompanyPreparation.php
T

286 lines
17 KiB
PHP

<?php
namespace App\Services;
use App\Models\User;
use Brick\Math\BigDecimal;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
/** Factual preparation only. Actual contractual exemplars have not been supplied. */
final class CompanyPreparation
{
public const EVENT = 'company_preparation_saved';
public const VERSION = 'company-preparation-1';
public function __construct(private readonly VirtualDesk $desk) {}
/** @return array<string,mixed> */
public function definition(): array
{
return ['version' => self::VERSION, 'forms' => config('company-comparison.forms'),
'roles' => ['subscriber' => 'Titular, sócio ou accionista', 'administrator' => 'Administrador ou gerente', 'representative' => 'Representante', 'beneficial_owner' => 'Beneficiário efectivo declarado'],
'source' => config('company-comparison.source'),
'template_status' => 'source_pending', 'template_notice' => 'Os sete exemplares referidos no complemento ainda não foram fornecidos. Esta ficha prepara dados para revisão; não é uma minuta contratual, assinatura, registo ou licença.'];
}
/** Reuse only the already-authorized, frozen dossier values, never a fresh master lookup.
* @return array<string,array<string,mixed>> */
private function sources(\stdClass $case): array
{
$payload = $this->desk->decode($case->payload);
$sources = [];
if (! empty($payload['name'])) {
$sources['case_proponent'] = ['name' => $payload['name'], 'kind' => 'person', 'reference' => $case->id,
'version' => $payload['reuse_consent']['date'] ?? 'creation', 'origin' => 'Proponente conservado na criação do dossier'];
}
if ($case->company_id && ! empty($payload['company']['name'])) {
$sources['case_company'] = ['name' => $payload['company']['name'], 'kind' => 'entity', 'reference' => $case->company_id,
'version' => $payload['company_version'] ?? null, 'origin' => 'Declaração de empresa conservada no dossier'];
}
return $sources;
}
/** @param array<string,mixed> $input
* @param array<string,mixed>|null $definition
* @return array<string,mixed> */
public function facts(array $input, ?array $definition = null): array
{
$definition ??= $this->definition();
$text = ['nullable', 'string', 'max:2000'];
$number = ['nullable', 'string', 'regex:/^\d{1,18}(?:[.,]\d{1,6})?$/D'];
$rules = ['facts' => ['present', 'array:form,proposed_name,seat,activities,regulated,currency,capital,nominal_value,management,binding,notes,parties'],
'facts.form' => ['nullable', Rule::in(array_keys($definition['forms']))],
'facts.proposed_name' => ['nullable', 'string', 'max:250'], 'facts.seat' => $text, 'facts.activities' => $text,
'facts.regulated' => ['nullable', Rule::in(['unknown', 'yes', 'no'])],
'facts.currency' => ['nullable', 'string', 'regex:/^[A-Z]{3}$/D'], 'facts.capital' => $number, 'facts.nominal_value' => $number,
'facts.management' => $text, 'facts.binding' => $text, 'facts.notes' => $text,
'facts.parties' => ['sometimes', 'array', 'list', 'max:100'],
'facts.parties.*' => ['array:id,name,kind,source,roles,represented_id,cash,in_kind,paid,share_count,work,commitment'],
'facts.parties.*.id' => ['required', 'uuid', 'distinct'], 'facts.parties.*.name' => ['nullable', 'string', 'max:250'],
'facts.parties.*.kind' => ['required', Rule::in(['person', 'entity'])],
'facts.parties.*.source' => ['nullable', Rule::in(['case_proponent', 'case_company'])],
'facts.parties.*.roles' => ['present', 'array', 'list', 'max:4'],
'facts.parties.*.roles.*' => ['string', Rule::in(array_keys($definition['roles']))],
'facts.parties.*.represented_id' => ['nullable', 'uuid'],
'facts.parties.*.cash' => $number, 'facts.parties.*.in_kind' => $number, 'facts.parties.*.paid' => $number,
'facts.parties.*.share_count' => ['nullable', 'string', 'regex:/^\d{1,18}$/D'],
'facts.parties.*.work' => $text, 'facts.parties.*.commitment' => $text];
$v = Validator::make($input, $rules)->validate()['facts'];
$facts = [];
foreach (['form', 'proposed_name', 'seat', 'activities', 'currency', 'management', 'binding', 'notes'] as $key) {
$facts[$key] = $v[$key] ?? null;
}
$facts['regulated'] = $v['regulated'] ?? 'unknown';
foreach (['capital', 'nominal_value'] as $key) {
$facts[$key] = $this->decimal($v[$key] ?? null);
}
$facts['parties'] = [];
foreach ($v['parties'] ?? [] as $party) {
$p = [];
foreach (['id', 'name', 'kind', 'source', 'represented_id', 'work', 'commitment'] as $key) {
$p[$key] = $party[$key] ?? null;
}
$p['roles'] = array_values(array_unique($party['roles']));
sort($p['roles']);
foreach (['cash', 'in_kind', 'paid', 'share_count'] as $key) {
$p[$key] = $this->decimal($party[$key] ?? null);
}
$facts['parties'][] = $p;
}
return $facts;
}
private function decimal(?string $value): ?string
{
return $value === null ? null : (string) BigDecimal::of(str_replace(',', '.', $value))->strippedOfTrailingZeros();
}
/** @param array<string,mixed> $facts
* @param array<string,array<string,mixed>> $sources
* @param array<string,mixed>|null $previous
* @return array<string,mixed> */
private function resolve(array $facts, array $sources, ?array $previous): array
{
foreach ($facts['parties'] as &$party) {
if ($party['source'] !== null) {
$old = null;
foreach ($previous['facts']['parties'] ?? [] as $candidate) {
if ($candidate['id'] === $party['id'] && $candidate['source'] === $party['source']) {
$old = $candidate;
break;
}
}
$source = $old['source_snapshot'] ?? $sources[$party['source']] ?? null;
abort_unless($source !== null, 422, 'A origem seleccionada não existe neste dossier.');
$party['name'] = $source['name'];
$party['kind'] = $source['kind'];
$party['source_snapshot'] = $source;
}
}
return $facts;
}
/** @param array<string,mixed> $facts
* @return array<string,mixed> */
public function review(array $facts): array
{
$issues = [];
foreach (['form' => 'Definir a forma a preparar.', 'proposed_name' => 'Indicar a denominação proposta.', 'seat' => 'Indicar a sede proposta.', 'activities' => 'Descrever as actividades.', 'management' => 'Descrever a administração pretendida.', 'binding' => 'Indicar a forma de obrigar pretendida.'] as $key => $message) {
if (! $facts[$key]) {
$issues[] = $message;
}
}
$subscribers = array_values(array_filter($facts['parties'], fn ($p) => in_array('subscriber', $p['roles'], true)));
if ($subscribers === []) {
$issues[] = 'Identificar os titulares, sócios ou accionistas.';
}
if ($facts['form'] === 'collective' && count($subscribers) < 2) {
$issues[] = 'A forma colectiva exige pelo menos dois sócios (artigo 264).';
}
if ($facts['form'] === 'individual' && count($subscribers) > 1) {
$issues[] = 'A titularidade individual não corresponde a vários titulares.';
}
$corporate = $facts['form'] !== null && $facts['form'] !== 'individual';
$shares = in_array($facts['form'], ['anonymous', 'simplified'], true);
$total = BigDecimal::zero();
$paid = BigDecimal::zero();
$units = BigDecimal::zero();
$complete = $subscribers !== [];
$paymentsComplete = $complete;
$unitsComplete = $complete;
$ids = array_column($facts['parties'], 'id');
$usedSources = [];
foreach ($facts['parties'] as $party) {
if ($party['source'] && in_array($party['source'], $usedSources, true)) {
$issues[] = 'A mesma origem foi seleccionada em mais de uma linha; conferir duplicação sem fundir pessoas por nome.';
}
$usedSources[] = $party['source'];
if (! $party['name'] || $party['roles'] === []) {
$issues[] = 'Completar o nome e os papéis de cada interveniente.';
}
if (in_array('representative', $party['roles'], true) && (! $party['represented_id'] || $party['represented_id'] === $party['id'] || ! in_array($party['represented_id'], $ids, true))) {
$issues[] = 'Identificar quem cada representante representa, numa linha distinta.';
}
if (! in_array('subscriber', $party['roles'], true) || ! $corporate) {
continue;
}
$complete = $complete && $party['cash'] !== null && $party['in_kind'] !== null;
$subscription = BigDecimal::of($party['cash'] ?? '0')->plus($party['in_kind'] ?? '0');
$total = $total->plus($subscription);
$paymentsComplete = $paymentsComplete && $party['paid'] !== null;
$paid = $paid->plus($party['paid'] ?? '0');
if ($party['paid'] !== null && $party['cash'] !== null && $party['in_kind'] !== null && BigDecimal::of($party['paid'])->compareTo($subscription) > 0) {
$issues[] = 'Uma realização declarada excede a subscrição desse participante.';
}
$unitsComplete = $unitsComplete && $party['share_count'] !== null;
$units = $units->plus($party['share_count'] ?? '0');
if ($shares && $party['share_count'] !== null && $facts['nominal_value'] !== null && $party['cash'] !== null && $party['in_kind'] !== null && BigDecimal::of($party['share_count'])->multipliedBy($facts['nominal_value'])->compareTo($subscription) !== 0) {
$issues[] = 'O valor nominal das acções de um participante difere da sua subscrição.';
}
if ($party['work'] && $facts['form'] !== 'collective') {
$issues[] = 'Conferir a admissibilidade da contribuição em trabalho para a forma escolhida.';
}
}
$totals = null;
if ($corporate) {
if (! $facts['currency'] || $facts['capital'] === null || ! $complete) {
$issues[] = 'Completar moeda, capital e entradas em dinheiro/espécie; valores desconhecidos não são zero.';
}
if ($complete && $facts['capital'] !== null && $total->compareTo($facts['capital']) !== 0) {
$issues[] = 'A soma das subscrições difere do capital declarado.';
}
if (! $paymentsComplete) {
$issues[] = 'Completar a realização declarada de cada subscrição; não equivale a prova de pagamento.';
}
if ($shares && ($facts['nominal_value'] === null || ! $unitsComplete)) {
$issues[] = 'Completar o número de acções e o valor nominal para reconciliar as participações.';
}
$totals = ['currency' => $facts['currency'], 'subscribed' => $complete && $facts['currency'] ? (string) $total : null,
'paid' => $paymentsComplete && $facts['currency'] ? (string) $paid : null,
'unpaid' => $complete && $paymentsComplete && $facts['currency'] ? (string) $total->minus($paid) : null,
'shares_nominal' => $shares && $unitsComplete && $facts['nominal_value'] !== null && $facts['currency'] ? (string) $units->multipliedBy($facts['nominal_value']) : null];
}
if ($facts['regulated'] !== 'no') {
$issues[] = 'Conferir os requisitos sectoriais ou profissionais da actividade; o objecto não concede licença.';
}
return ['issues' => array_values(array_unique($issues)), 'totals' => $totals, 'status' => 'preparation',
'note' => $facts['form'] === 'collective' ? 'As contribuições em trabalho são conservadas separadamente e não integram o capital (artigo 266).' : 'Valores declarados para preparação; não comprovam entradas, poderes ou registo.'];
}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function view(User $actor, string $id, array $input): array
{
$case = $this->desk->case($actor, $id);
$payload = $this->desk->decode($case->payload);
$v = Validator::make($input, ['revision' => 'nullable|uuid', 'history_before' => 'nullable|integer|min:1'])->validate();
$saved = $payload['company_preparation'] ?? null;
if (! empty($v['revision'])) {
$event = DB::table('desk_events')->where('case_id', $id)->where('action', self::EVENT)->where('id', $v['revision'])->first();
abort_unless($event !== null, 404);
$saved = $this->desk->decode($event->payload)['snapshot'];
}
$facts = $saved['facts'] ?? $this->facts(['facts' => []]);
$events = DB::table('desk_events')->where('case_id', $id)->where('action', self::EVENT)
->when(! empty($v['history_before']), fn ($q) => $q->where('event_order', '<', $v['history_before']))
->orderByDesc('event_order')->limit(26)->get(['id', 'payload', 'event_order']);
return ['case' => ['id' => $id, 'title' => $case->title, 'version' => (int) $case->version],
'canWrite' => ! $case->closed_at && $this->desk->owns($actor, $case), 'historical' => ! empty($v['revision']),
'definition' => $saved['definition'] ?? $this->definition(), 'facts' => $facts, 'review' => $saved['review'] ?? $this->review($facts), 'saved' => $saved,
'sources' => $this->sources($case), 'history' => $events->take(25)->map(function ($e) {
$s = $this->desk->decode($e->payload)['snapshot'];
return ['id' => $e->id, 'revision' => $s['revision'], 'at' => $s['at']];
})->all(), 'historyNext' => $events->count() > 25 ? $events[24]->event_order : null];
}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function save(User $actor, string $id, array $input): array
{
$this->desk->case($actor, $id, true);
$v = Validator::make($input, ['version' => 'required|integer|min:1', 'operation_key' => 'required|uuid'])->validate();
return DB::transaction(function () use ($actor, $id, $v, $input) {
$case = $this->desk->case($actor, $id, true, true);
foreach (DB::table('desk_events')->where('case_id', $id)->where('action', self::EVENT)->orderByDesc('event_order')->cursor() as $event) {
$data = $this->desk->decode($event->payload);
if ($data['operation_key'] === $v['operation_key']) {
$facts = $this->facts($input, $data['snapshot']['definition']);
$hash = hash('sha256', json_encode([(int) $v['version'], $facts], JSON_THROW_ON_ERROR));
abort_unless(hash_equals($data['request_hash'], $hash), 409, 'A operação já guardou outros dados.');
return ['saved' => true, 'replayed' => true, 'revision' => $data['snapshot']['revision']];
}
}
$facts = $this->facts($input);
$hash = hash('sha256', json_encode([(int) $v['version'], $facts], JSON_THROW_ON_ERROR));
abort_if($case->closed_at || (int) $case->version !== (int) $v['version'], 409, 'O dossier mudou ou foi fechado. Os dados continuam no formulário; recupere a versão antes de guardar.');
$payload = $this->desk->decode($case->payload);
$previous = $payload['company_preparation'] ?? null;
$resolved = $this->resolve($facts, $this->sources($case), $previous);
$definition = $this->definition();
$review = $this->review($resolved);
if ($previous && $previous['facts'] === $resolved && $previous['definition'] === $definition && $previous['review'] === $review) {
return ['saved' => true, 'unchanged' => true, 'revision' => $previous['revision']];
}
$snapshot = ['revision' => (int) ($previous['revision'] ?? 0) + 1, 'at' => now()->toIso8601String(), 'actor_id' => $actor->id,
'facts' => $resolved, 'definition' => $definition, 'review' => $review];
$payload['company_preparation'] = $snapshot;
$case->version++;
DB::table('desk_cases')->where('id', $id)->update(['payload' => $this->desk->encode($payload), 'version' => $case->version, 'updated_at' => now()]);
$this->desk->event($case, $actor, self::EVENT, ['operation_key' => $v['operation_key'], 'request_hash' => $hash, 'snapshot' => $snapshot]);
return ['saved' => true, 'replayed' => false, 'revision' => $snapshot['revision']];
});
}
}