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, ?string $linkId = null): \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') ->when($linkId, fn ($q) => $q->where('l.id', $linkId)) ->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.'); try { app(VerifiedDocumentBinary::class)->respond($doc); } catch (HttpExceptionInterface) { abort(422, 'O original, a sua autorização ou 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) { $desk->case($request->user(), $id); $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; $case = $desk->case($request->user(), $id, false, true); abort_unless(! $case->closed_at && (int) $case->version === (int) $data['version'], 409); abort_if($row && $row->case_id !== $id, 422); $this->reviewer($request, $desk, $case, $row); $doc = $this->cleanDocument($id, $link->document_id, $requestId, $link->id); 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', 'document_link_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, true, true); abort_if($case->closed_at || in_array($row->status, ['closed', 'cancelled'], true), 409); $evidence = app(DeskPaymentEvidence::class)->snapshot($case->id, $id, $data); // References are supplied by the investor; no bank reference or fee is generated. DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', ['virtual-desk-payment:'.$data['reference']]); $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'); $saved = app(DeskPaymentEvidence::class)->forRequest($id)[$existing->id] ?? null; abort_unless($existing->request_id === $id && (string) $existing->amount === $amount && $existing->currency === $data['currency'] && $existing->source_reference === $data['source_reference'] && $existing->document_id === ($evidence['document_id'] ?? null) && (! ($data['document_link_id'] ?? null) || ($saved['link_id'] ?? null) === $data['document_link_id']), 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' => $evidence['document_id'] ?? null, 'status' => 'pending', 'created_at' => now(), 'updated_at' => now()]); $desk->event($case, $request->user(), 'payment_declared', ['payment_id' => $payment, 'payment_version' => 1, 'document_evidence' => $evidence, 'consent' => true, 'scope' => 'manual_declaration_no_bank_confirmation'], $row); }); return $this->result($request); } public function paymentDocument(Request $request, string $id, string $payment, VirtualDesk $desk): JsonResponse|RedirectResponse { $data = $request->validate(['version' => 'required|integer|min:1', 'document_link_id' => 'required|uuid', 'reason' => 'required|string|min:3|max:3000', 'consent' => 'accepted', 'client_key' => 'required|uuid']); $data['version'] = (int) $data['version']; $data['consent'] = true; DB::transaction(function () use ($request, $id, $payment, $desk, $data) { $row = $desk->request($request->user(), $id, true, true); $case = $desk->case($request->user(), $row->case_id, true, true); abort_if($case->closed_at || in_array($row->status, ['closed', 'cancelled'], true), 409); $record = DB::table('desk_payments')->where('id', $payment)->where('request_id', $id)->lockForUpdate()->first(); abort_unless($record !== null, 404); foreach (DB::table('desk_events')->where('request_id', $id)->where('actor_id', $request->user()->id)->where('action', 'payment_document_linked')->get(['payload']) as $event) { $saved = $desk->decode($event->payload); if (($saved['client_key'] ?? null) === $data['client_key']) { abort_unless(($saved['payment_id'] ?? null) === $payment && ($saved['operation'] ?? null) === $data, 409, 'A operação já foi utilizada com outros dados. Inicie uma nova associação.'); return; } } abort_unless((int) $record->version === (int) $data['version'] && in_array($record->status, ['pending', 'internal_issue'], true), 409, 'O registo mudou ou já está conferido. Actualize antes de associar outro comprovativo.'); $evidence = app(DeskPaymentEvidence::class)->snapshot($case->id, $id, $data); abort_unless($evidence !== null, 422); $previous = app(DeskPaymentEvidence::class)->forRequest($id)[$payment] ?? null; DB::table('desk_payments')->where('id', $payment)->update(['document_id' => $evidence['document_id'], 'status' => 'pending', 'reason' => null, 'verified_by' => null, 'version' => $record->version + 1, 'updated_at' => now()]); $desk->event($case, $request->user(), 'payment_document_linked', ['payment_id' => $payment, 'payment_version' => $record->version + 1, 'previous_document_id' => $record->document_id, 'previous_document_evidence' => $previous, 'document_evidence' => $evidence, 'client_key' => $data['client_key'], 'operation' => $data, 'reason' => $data['reason'], 'consent' => true, 'scope' => 'documentary_correction_no_amount_change_or_payment_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, false, true); $this->reviewer($request, $desk, $case, $row); abort_if($case->closed_at || 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); $evidence = app(DeskPaymentEvidence::class)->forRequest($id)[$payment] ?? null; $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, $evidence['link_id'] ?? null); abort_if($evidence && ($evidence['document_id'] !== $doc->id || $evidence['document_version'] !== (int) $doc->version_no || ! hash_equals($evidence['sha256'], trim($doc->sha256))), 422, 'O original já não corresponde ao comprovativo associado. Registe a correcção antes de conferir.'); } 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, 'payment_version' => $record->version + 1, 'document_id' => $record->document_id, 'document_evidence' => $evidence, '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, false, true); abort_unless($desk->owns($request->user(), $case) || $request->user()->can('manage-portal') || (int) $row->analyst_id === $request->user()->id, 403); abort_if($case->closed_at || 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); } }