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

141 lines
10 KiB
PHP

<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use stdClass;
class PartnershipAgreements
{
private const ACTIONS = ['record', 'confirm', 'start', 'complete', 'cancel', 'renew'];
/** @param array{partnership:stdClass,interest:stdClass,room:stdClass} $context */
public function party(User $actor, array $context): ?string
{
$exchange = app(PartnershipExchange::class);
return $exchange->owns($actor, $context['partnership']) ? 'proponent' : ($exchange->owns($actor, $context['interest']) ? 'candidate' : null);
}
/** @param array{partnership:stdClass,interest:stdClass,room:stdClass} $context
* @return list<array{key:string,action:string,version:int,status:string,party:string|null}>
*/
public function commands(User $actor, array $context): array
{
if ($this->party($actor, $context) === null) {
return [];
}
$rows = DB::table('partnership_events')->where('partnership_id', $context['partnership']->id)->where('actor_id', $actor->id)->where('event_type', 'partnership_agreement.changed.v1')->whereRaw("payload->'payload'->>'room_id' = ?", [(string) $context['room']->id])->whereRaw("payload->'payload'->>'client_key' IS NOT NULL")->orderBy('aggregate_version')->selectRaw("payload->'payload'->>'client_key' AS key,payload->'payload'->>'action' AS action,payload->'payload'->'version' AS version,payload->'payload'->>'status' AS status,payload->'payload'->>'confirmation_party' AS party")->get();
$commands = [];
foreach ($rows as $row) {
$version = is_string($row->version) ? json_decode($row->version, true, 512, JSON_THROW_ON_ERROR) : $row->version;
if (is_string($row->key) && Str::isUuid($row->key) && in_array($row->action, self::ACTIONS, true) && is_int($version) && $version > 0 && is_string($row->status)) {
$commands[] = ['key' => $row->key, 'action' => $row->action, 'version' => $version, 'status' => $row->status, 'party' => in_array($row->party, ['proponent', 'candidate'], true) ? $row->party : null];
}
}
return $commands;
}
/** @param array<string,mixed> $input
* @return array{recorded:bool,replayed:bool,version:int,status:string,saved_version:int}
*/
public function act(User $actor, int $id, array $input): array
{
$data = Validator::make($input, ['client_key' => 'nullable|uuid', 'version' => 'required|integer|min:1', 'partnership_version' => 'nullable|integer|min:1', 'interest_version' => 'nullable|integer|min:1', 'action' => 'required|in:'.implode(',', self::ACTIONS), 'title' => 'required_if:action,record|nullable|string|max:200', 'terms' => 'required_if:action,record|nullable|string|max:20000', 'reason' => 'required|string|max:2000'])->validate();
$key = is_string($data['client_key'] ?? null) ? strtolower($data['client_key']) : null;
unset($data['client_key']);
if ($data['action'] !== 'record') {
$data = Arr::except($data, ['title', 'terms']);
}
$hash = hash('sha256', json_encode(Arr::sortRecursive($data), JSON_THROW_ON_ERROR));
return DB::transaction(function () use ($actor, $id, $data, $key, $hash) {
$exchange = app(PartnershipExchange::class);
$context = $exchange->room($actor, $id);
$record = $exchange->find($context['partnership']->id, true);
$interest = DB::table('partnership_interests')->where('id', $context['interest']->id)->lockForUpdate()->first();
$room = DB::table('partnership_rooms')->where('id', $id)->lockForUpdate()->first();
abort_unless($interest && $room && (int) $interest->partnership_id === (int) $record->id && (int) $room->interest_id === (int) $interest->id, 409, 'O contexto da sala foi alterado.');
$context = ['partnership' => $record, 'interest' => $interest, 'room' => $room];
$party = $this->party($actor, $context);
abort_unless($party !== null, 403, 'A representação actual não permite alterar o acordo.');
if ($key !== null) {
$event = DB::table('partnership_events')->where('partnership_id', $record->id)->where('actor_id', $actor->id)->where('event_type', 'partnership_agreement.changed.v1')->whereRaw("payload->'payload'->>'room_id' = ?", [(string) $id])->whereRaw("payload->'payload'->>'client_key' = ?", [$key])->selectRaw("payload->'payload'->>'command_hash' AS hash,payload->'payload'->'version' AS version")->first();
if ($event) {
abort_unless($event->hash === $hash, 409, 'Chave já utilizada com outro conteúdo.');
$version = is_string($event->version) ? json_decode($event->version, true, 512, JSON_THROW_ON_ERROR) : $event->version;
abort_unless(is_int($version) && $version > 0, 409, 'Versão anterior não identificada.');
return ['recorded' => true, 'replayed' => true, 'version' => (int) $room->version, 'status' => $room->status, 'saved_version' => $version];
}
}
abort_unless((int) $room->version === (int) $data['version'], 409, 'Acordo alterado. Compare antes de continuar.');
foreach (['partnership_version' => $record->version, 'interest_version' => $interest->version] as $field => $version) {
abort_if(isset($data[$field]) && (int) $data[$field] !== (int) $version, 409, 'A proposta ou candidatura foi alterada. Reveja o contexto antes de decidir.');
}
$agreement = $room->agreement ? json_decode($room->agreement, true, 512, JSON_THROW_ON_ERROR) : null;
abort_unless($agreement === null || is_array($agreement), 409, 'O instrumento guardado não tem uma estrutura reconhecida.');
$status = $room->status;
$before = $this->snapshot($room, $agreement);
$action = $data['action'];
if ($action === 'record') {
abort_unless(in_array($status, ['negotiation', 'agreement_recorded'], true), 409);
$previous = $agreement['previous_versions'] ?? [];
abort_unless(is_array($previous), 409, 'O histórico do instrumento não tem uma estrutura reconhecida.');
if ($agreement) {
$previous[] = Arr::except($agreement, ['previous_versions']);
}
$agreement = ['title' => $data['title'], 'terms' => $data['terms'], 'recorded_by' => $actor->id, 'recorded_at' => now()->toIso8601String(), 'confirmations' => [], 'parties' => [$record->user_id, $interest->user_id], 'legal_signature_status' => 'not_verified', 'previous_versions' => $previous,
'recorded_context' => ['partnership_version' => (int) $record->version, 'interest_version' => (int) $interest->version, 'proponent' => ['user_id' => (int) $record->user_id, 'organization_id' => $record->organization_id], 'candidate' => ['user_id' => (int) $interest->user_id, 'organization_id' => $interest->organization_id]]];
$status = 'agreement_recorded';
} elseif ($action === 'confirm') {
abort_unless($status === 'agreement_recorded' && $agreement, 409);
abort_unless(! isset($agreement['confirmations']) || is_array($agreement['confirmations']), 409, 'As confirmações guardadas não têm uma estrutura reconhecida.');
$agreement['confirmations'][$party] = ['actor_id' => $actor->id, 'date' => now()->toIso8601String(), 'reason' => $data['reason']];
if ($this->confirmation($agreement['confirmations']['proponent'] ?? null) && $this->confirmation($agreement['confirmations']['candidate'] ?? null)) {
$status = 'agreed';
}
} else {
$allowed = ['start' => ['agreed'], 'complete' => ['execution'], 'cancel' => ['negotiation', 'agreement_recorded', 'agreed', 'execution'], 'renew' => ['agreed', 'execution']];
abort_unless(in_array($status, $allowed[$action] ?? [], true), 409);
if ($action === 'complete') {
abort_if(DB::table('partnership_milestones')->where('room_id', $id)->where('status', '!=', 'completed')->exists(), 409, 'Existem marcos pendentes.');
}
$status = ['start' => 'execution', 'complete' => 'closed', 'cancel' => 'cancelled', 'renew' => 'negotiation'][$action];
if ($action === 'renew') {
abort_unless(is_array($agreement) && is_array($agreement['previous_versions'] ?? []), 409, 'O instrumento ou o seu histórico não tem uma estrutura reconhecida.');
$agreement['previous_versions'][] = Arr::except($agreement, ['previous_versions']);
$agreement['confirmations'] = [];
}
}
DB::table('partnership_rooms')->where('id', $id)->update(['agreement' => $agreement ? json_encode($agreement, JSON_THROW_ON_ERROR) : null, 'status' => $status, 'version' => $room->version + 1, 'updated_at' => now()]);
$saved = DB::table('partnership_rooms')->where('id', $id)->first();
$metadata = ['room_id' => $id, 'status' => $status, 'version' => (int) $saved->version, 'reason' => $data['reason'], 'action' => $action, 'confirmation_party' => $action === 'confirm' ? $party : null, 'partnership_version' => (int) $record->version, 'interest_version' => (int) $interest->version, 'previous' => $before, 'applied' => $this->snapshot($saved, $agreement)];
if ($key !== null) {
$metadata += ['client_key' => $key, 'command_hash' => $hash];
}
$exchange->event($record, $actor, 'partnership_agreement.changed.v1', $metadata, 'Acordo: '.$status, [$record->user_id, $interest->user_id]);
return ['recorded' => true, 'replayed' => false, 'version' => (int) $saved->version, 'status' => $status, 'saved_version' => (int) $saved->version];
});
}
private function confirmation(mixed $value): bool
{
return is_array($value) && is_int($value['actor_id'] ?? null) && $value['actor_id'] > 0;
}
/** @param array<string,mixed>|null $agreement
* @return array<string,mixed>
*/
private function snapshot(stdClass $room, ?array $agreement): array
{
return ['id' => (int) $room->id, 'version' => (int) $room->version, 'status' => $room->status, 'agreement' => $agreement];
}
}