id() === $user->id && $user->tenant_id === $tenantId && $user->purchasing_role === PurchasingRole::Finance, 403); } private function invalid(string $message): never { throw ValidationException::withMessages(['payment' => $message]); } public function outstanding(Invoice $invoice): int { $canonical = app(SupplierPayables::class)->balance($invoice); if ($canonical !== null) { return $canonical; } return (int) $invoice->total_minor - (int) ExternalPayment::query()->where('invoice_id', $invoice->id)->where('status', 'confirmed')->sum('amount_minor'); } private function bank(User $user, Invoice $invoice, int $bankId, int $amount): SupplierBankDetail { if ($invoice->status !== 'validated' || $amount <= 0 || $amount > $this->outstanding($invoice)) { $this->invalid('Factura não validada ou montante superior ao saldo pendente.'); } $order = PurchaseOrder::query()->findOrFail($invoice->purchase_order_id); $request = Requisition::query()->findOrFail($order->requisition_id); app(ConflictGuard::class)->assertProcess($request, $order->supplier_id, $user); $supplier = Supplier::query()->lockForUpdate()->findOrFail($order->supplier_id); $bank = SupplierBankDetail::query()->where('supplier_id', $supplier->id)->latest('revision')->first(); if ($bank) { User::query()->where('tenant_id', $user->tenant_id)->whereIn('id', SupplierBankReview::query()->where('supplier_bank_detail_id', $bank->id)->select('reviewed_by'))->orderBy('id')->lockForUpdate()->get(); } if (! $bank || $bank->id !== $bankId || app(SupplierBankWorkflow::class)->state($supplier) !== 'approved') { $this->invalid('A versão bancária actual deve ter duas validações elegíveis. Actualize a ficha; não reutilize uma versão substituída.'); } if ($bank->currency !== $order->currency || $order->currency !== 'MZN') { $this->invalid('A moeda da conta deve coincidir com a factura. Câmbio não suportado.'); } return $bank; } /** @param array $data */ public function register(User $user, Invoice $invoice, array $data): ExternalPayment { abort_if(app(SupplierPayables::class)->principal($invoice) !== null, 403, 'Pagamento de obrigação do novo ensaio fora do âmbito.'); $this->actor($user, $invoice->tenant_id); return DB::transaction(function () use ($user, $invoice, $data) { app(ConflictGuard::class)->lockCompany($user); $user = User::query()->where('tenant_id', $user->tenant_id)->lockForUpdate()->findOrFail($user->id); $this->actor($user, $invoice->tenant_id); $invoice = Invoice::query()->lockForUpdate()->findOrFail($invoice->id); abort_if(app(SupplierPayables::class)->principal($invoice) !== null, 403, 'Pagamento de obrigação do novo ensaio fora do âmbito.'); $reference = Str::upper(Str::squish($data['external_reference'])); $amount = BigDecimal::of((string) $data['amount'])->multipliedBy(100)->toScale(0)->toInt(); $canonical = ['invoice_id' => $invoice->id, 'bank_id' => (int) $data['supplier_bank_detail_id'], 'amount_minor' => $amount, 'paid_on' => $data['paid_on'], 'evidence_reference' => trim($data['evidence_reference'])]; $hash = hash_hmac('sha256', json_encode($canonical, JSON_THROW_ON_ERROR), config('app.key')); $referenceHash = hash_hmac('sha256', $reference, config('app.key')); $existing = ExternalPayment::query()->where('reference_hash', $referenceHash)->first(); if ($existing) { if ($existing->created_by !== $user->id || $existing->payload_hash !== $hash) { $this->invalid('Referência externa já registada. Consulte a conferência existente.'); } return $existing; } if ($reference === '' || trim($data['evidence_reference']) === '' || $data['paid_on'] < (string) $invoice->issued_on || $data['paid_on'] > today()->format('Y-m-d') || substr($data['paid_on'], 0, 4) !== now()->format('Y')) { $this->invalid('Referência, evidência e data válida do exercício actual são obrigatórias.'); } $bank = $this->bank($user, $invoice, (int) $data['supplier_bank_detail_id'], $amount); $payment = ExternalPayment::query()->create(['tenant_id' => $user->tenant_id, 'invoice_id' => $invoice->id, 'supplier_bank_detail_id' => $bank->id, 'created_by' => $user->id, 'amount_minor' => $amount, 'currency' => $bank->currency, 'paid_on' => $data['paid_on'], 'external_reference' => $reference, 'reference_hash' => $referenceHash, 'payload_hash' => $hash, 'evidence_reference' => trim($data['evidence_reference']), 'bank_snapshot' => ['bank_id' => $bank->id, 'revision' => $bank->revision, 'bank_name' => $bank->bank_name, 'country' => $bank->country, 'currency' => $bank->currency, 'beneficiary' => $bank->beneficiary, 'account_identifier' => $bank->account_identifier, 'reviewer_ids' => SupplierBankReview::query()->where('supplier_bank_detail_id', $bank->id)->pluck('reviewed_by')->all(), 'development_only' => true, 'bank_transfer_executed' => false]]); $this->audit($user, $invoice, $payment, 'payment.registered'); return $payment; }); } public function review(User $user, ExternalPayment $payment, string $decision, string $reason): void { $this->actor($user, $payment->tenant_id); abort_if($payment->created_by === $user->id, 403); if (! in_array($decision, ['confirmed', 'rejected']) || trim($reason) === '') { $this->invalid('Decisão e fundamento obrigatórios.'); } DB::transaction(function () use ($user, $payment, $decision, $reason) { app(ConflictGuard::class)->lockCompany($user); $user = User::query()->where('tenant_id', $user->tenant_id)->lockForUpdate()->findOrFail($user->id); $this->actor($user, $payment->tenant_id); $invoice = Invoice::query()->lockForUpdate()->findOrFail($payment->invoice_id); $historical = app(SupplierPayables::class)->assertHistoricalReview($user, $invoice); $payment = ExternalPayment::query()->lockForUpdate()->findOrFail($payment->id); abort_if($payment->created_by === $user->id, 403); if ($payment->status !== 'pending') { if ($payment->status !== $decision) { $this->invalid('Conferência já decidida; não pode ser reescrita.'); } return; } $order = PurchaseOrder::query()->findOrFail($invoice->purchase_order_id); app(ConflictGuard::class)->assertProcess(Requisition::query()->findOrFail($order->requisition_id), $order->supplier_id, $user); if ($decision === 'confirmed') { if (substr((string) $payment->paid_on, 0, 4) !== now()->format('Y')) { $this->invalid('Conferência entre exercícios não suportada.'); } $this->bank($user, $invoice, $payment->supplier_bank_detail_id, $payment->amount_minor); $author = User::query()->where('tenant_id', $user->tenant_id)->lockForUpdate()->find($payment->created_by); if (! $author || $author->purchasing_role !== PurchasingRole::Finance) { $this->invalid('O autor do registo deixou de ser elegível.'); } } $write = function () use ($payment, $decision, $user, $reason, $invoice) { $payment->update(['status' => $decision, 'reviewed_by' => $user->id, 'review_reason' => $reason, 'reviewed_at' => now()]); $this->audit($user, $invoice, $payment, 'payment.'.$decision); }; if ($historical) { try { app(SupplierPayables::class)->engine($user)->decideImportedPayment($invoice->id, $payment->id, $write); } catch (\DomainException $e) { abort(409, $e->getMessage()); } } else { $write(); } }); } private function audit(User $user, Invoice $invoice, ExternalPayment $payment, string $action): void { $order = PurchaseOrder::query()->findOrFail($invoice->purchase_order_id); $request = Requisition::query()->findOrFail($order->requisition_id); app(RequisitionWorkflow::class)->audit($user, $action, $request, null, ['payment_id' => $payment->id, 'invoice_id' => $invoice->id, 'bank_detail_id' => $payment->supplier_bank_detail_id, 'amount_minor' => $payment->amount_minor, 'status' => $payment->status, 'bank_transfer_executed' => false]); } }