137 lines
9.8 KiB
PHP
137 lines
9.8 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Services\Ai\InvestorGuidanceEvidence;
|
|
use App\Services\VirtualDesk;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\Rule;
|
|
|
|
/** Manual operations use the same requests, documents and encrypted audit history. */
|
|
class VirtualDeskOperationsController extends Controller
|
|
{
|
|
private function result(Request $request): JsonResponse|RedirectResponse
|
|
{
|
|
return $request->expectsJson() ? response()->json(['ok' => true]) : back()->with('success', 'Registo interno guardado; não constitui confirmação de uma instituição externa.');
|
|
}
|
|
|
|
private function reviewer(Request $request, VirtualDesk $desk, \stdClass $case, ?\stdClass $row): void
|
|
{
|
|
abort_unless(! $desk->owns($request->user(), $case, false) && ($request->user()->can('manage-portal') || ($row && (int) $row->analyst_id === $request->user()->id)), 403);
|
|
}
|
|
|
|
private function cleanDocument(string $caseId, string $documentId, ?string $requestId): \stdClass
|
|
{
|
|
$doc = DB::table('desk_documents as l')->join('documents as d', 'd.id', '=', 'l.document_id')
|
|
->where('l.case_id', $caseId)->where('d.id', $documentId)->whereNull('d.deleted_at')
|
|
->where(fn ($q) => $q->whereNull('l.request_id')->when($requestId, fn ($p) => $p->orWhere('l.request_id', $requestId)))
|
|
->first(['d.*', 'l.id as link_id']);
|
|
abort_unless($doc !== null && $doc->status === 'active' && $doc->malware_scan_status === 'clean' && trim($doc->sha256) === $doc->malware_scanned_sha256, 422, 'Documento indisponível para conferência: verifique autorização, integridade e análise antimalware.');
|
|
$path = realpath(Storage::disk('local')->path($doc->storage_key));
|
|
$root = realpath(Storage::disk('local')->path(''));
|
|
abort_unless($path && $root && str_starts_with($path, $root.DIRECTORY_SEPARATOR) && is_file($path) && is_string($hash = hash_file('sha256', $path)) && hash_equals(trim($doc->sha256), $hash), 422, 'O ficheiro original ou a sua integridade não pôde ser confirmado.');
|
|
|
|
return $doc;
|
|
}
|
|
|
|
public function documentReview(Request $request, string $id, string $document, VirtualDesk $desk): JsonResponse|RedirectResponse
|
|
{
|
|
$data = $request->validate(['version' => 'required|integer|min:1', 'request_id' => 'nullable|uuid', 'outcome' => 'required|in:internal_verified,internal_issue,contradiction_identified', 'what_checked' => 'required|string|min:3|max:3000', 'evidence' => 'required|string|min:3|max:5000', 'supports' => 'nullable|array|max:4', 'supports.*' => ['string', Rule::in(InvestorGuidanceEvidence::FIELDS)]]);
|
|
DB::transaction(function () use ($request, $id, $document, $desk, $data) {
|
|
$case = $desk->case($request->user(), $id, false, true);
|
|
abort_unless(! $case->closed_at && (int) $case->version === (int) $data['version'], 409);
|
|
$link = DB::table('desk_documents')->where('case_id', $id)->where('id', $document)->first();
|
|
abort_unless($link !== null, 404);
|
|
$requestId = $link->request_id ?: ($data['request_id'] ?? null);
|
|
$row = $requestId ? $desk->request($request->user(), $requestId, false, true) : null;
|
|
abort_if($row && $row->case_id !== $id, 422);
|
|
$this->reviewer($request, $desk, $case, $row);
|
|
$doc = $this->cleanDocument($id, $link->document_id, $requestId);
|
|
DB::table('desk_cases')->where('id', $id)->update(['version' => $case->version + 1, 'updated_at' => now()]);
|
|
$desk->event(DB::table('desk_cases')->where('id', $id)->firstOrFail(), $request->user(), 'document_reviewed', [
|
|
...$data, 'document_id' => $doc->id, 'link_id' => $link->id, 'sha256' => trim($doc->sha256), 'document_version' => (int) $doc->version_no, 'reviewer_id' => $request->user()->id,
|
|
'scope' => 'internal_documentary_check_not_official_approval_registration_or_license',
|
|
], $row);
|
|
});
|
|
|
|
return $this->result($request);
|
|
}
|
|
|
|
public function payment(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
|
|
{
|
|
$data = $request->validate(['reference' => 'required|string|max:255', 'amount' => ['required', 'regex:/^\d{1,12}(\.\d{1,2})?$/', 'numeric', 'gt:0'], 'currency' => 'required|in:MZN,USD,EUR', 'source_reference' => 'required|string|min:3|max:3000', 'document_id' => 'nullable|uuid', 'consent' => 'accepted']);
|
|
DB::transaction(function () use ($request, $id, $desk, $data) {
|
|
$row = $desk->request($request->user(), $id, true, true);
|
|
$case = $desk->case($request->user(), $row->case_id);
|
|
abort_if(in_array($row->status, ['closed', 'cancelled'], true), 409);
|
|
if ($data['document_id'] ?? null) {
|
|
abort_unless(DB::table('desk_documents')->where('case_id', $case->id)->where('document_id', $data['document_id'])->where(fn ($q) => $q->whereNull('request_id')->orWhere('request_id', $id))->exists(), 422);
|
|
}
|
|
// References are supplied by the investor; no bank reference or fee is generated.
|
|
$existing = DB::table('desk_payments')->where('reference', $data['reference'])->first();
|
|
if ($existing) {
|
|
$parts = explode('.', (string) $data['amount']);
|
|
$amount = (ltrim($parts[0], '0') ?: '0').'.'.str_pad($parts[1] ?? '', 2, '0');
|
|
abort_unless($existing->request_id === $id && (string) $existing->amount === $amount && $existing->currency === $data['currency'] && $existing->source_reference === $data['source_reference'] && $existing->document_id === ($data['document_id'] ?? null), 409);
|
|
|
|
return;
|
|
}
|
|
$payment = (string) Str::uuid();
|
|
DB::table('desk_payments')->insert(['id' => $payment, 'request_id' => $id, 'reference' => $data['reference'], 'amount' => $data['amount'], 'currency' => $data['currency'], 'source_reference' => $data['source_reference'], 'document_id' => $data['document_id'] ?? null, 'status' => 'pending', 'created_at' => now(), 'updated_at' => now()]);
|
|
$desk->event($case, $request->user(), 'payment_declared', ['payment_id' => $payment, 'consent' => true, 'scope' => 'manual_declaration_no_bank_confirmation'], $row);
|
|
});
|
|
|
|
return $this->result($request);
|
|
}
|
|
|
|
public function paymentReview(Request $request, string $id, string $payment, VirtualDesk $desk): JsonResponse|RedirectResponse
|
|
{
|
|
$data = $request->validate(['version' => 'required|integer|min:1', 'status' => 'required|in:internal_verified,internal_issue', 'reason' => 'required|string|min:3|max:3000', 'evidence' => 'required|string|min:3|max:5000']);
|
|
DB::transaction(function () use ($request, $id, $payment, $desk, $data) {
|
|
$row = $desk->request($request->user(), $id, false, true);
|
|
$case = $desk->case($request->user(), $row->case_id);
|
|
$this->reviewer($request, $desk, $case, $row);
|
|
abort_if(in_array($row->status, ['closed', 'cancelled'], true), 409);
|
|
$record = DB::table('desk_payments')->where('request_id', $id)->where('id', $payment)->lockForUpdate()->first();
|
|
abort_unless($record !== null, 404);
|
|
abort_unless((int) $record->version === (int) $data['version'], 409);
|
|
$doc = null;
|
|
if ($data['status'] === 'internal_verified') {
|
|
abort_unless($record->document_id, 422, 'A conferência exige um comprovativo autorizado.');
|
|
$doc = $this->cleanDocument($case->id, $record->document_id, $id);
|
|
}
|
|
DB::table('desk_payments')->where('id', $payment)->update(['status' => $data['status'], 'reason' => $data['reason'], 'verified_by' => $request->user()->id, 'version' => $record->version + 1, 'updated_at' => now()]);
|
|
$desk->event($case, $request->user(), 'payment_reviewed', [...$data, 'payment_id' => $payment, 'document_id' => $doc?->id, 'sha256' => $doc ? trim($doc->sha256) : null, 'reviewer_id' => $request->user()->id, 'scope' => 'internal_proof_check_no_bank_confirmation'], $row);
|
|
});
|
|
|
|
return $this->result($request);
|
|
}
|
|
|
|
public function communication(Request $request, string $id, VirtualDesk $desk): JsonResponse|RedirectResponse
|
|
{
|
|
$data = $request->validate(['channel' => 'required|in:phone,email,sms,whatsapp,in_person', 'direction' => 'required|in:received,sent', 'occurred_at' => 'required|date|before_or_equal:now', 'summary' => 'required|string|min:3|max:5000', 'evidence' => 'required|string|min:3|max:3000', 'client_key' => 'required|uuid']);
|
|
DB::transaction(function () use ($request, $id, $desk, $data) {
|
|
$row = $desk->request($request->user(), $id, false, true);
|
|
$case = $desk->case($request->user(), $row->case_id);
|
|
abort_unless($desk->owns($request->user(), $case) || $request->user()->can('manage-portal') || (int) $row->analyst_id === $request->user()->id, 403);
|
|
abort_if(in_array($row->status, ['closed', 'cancelled'], true), 409);
|
|
foreach (DB::table('desk_events')->where('request_id', $id)->where('actor_id', $request->user()->id)->where('action', 'manual_communication_recorded')->get(['payload']) as $event) {
|
|
$saved = $desk->decode($event->payload);
|
|
if (($saved['client_key'] ?? null) === $data['client_key']) {
|
|
abort_unless(array_intersect_key($saved, $data) === $data, 409);
|
|
|
|
return;
|
|
}
|
|
}
|
|
$desk->event($case, $request->user(), 'manual_communication_recorded', [...$data, 'scope' => 'manual_record_not_portal_delivery_confirmation'], $row);
|
|
});
|
|
|
|
return $this->result($request);
|
|
}
|
|
}
|