Implementa registo e conferencia de pagamentos externos
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\ExternalPayment;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\SupplierBankDetail;
|
||||
use App\Services\ExternalPaymentWorkflow;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use App\Services\SupplierBankWorkflow;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ExternalPaymentController extends Controller
|
||||
{
|
||||
public function index(Request $request, ExternalPaymentWorkflow $workflow): Response
|
||||
{
|
||||
abort_unless(in_array($request->user()->purchasing_role, [PurchasingRole::Admin, PurchasingRole::Finance]), 403);
|
||||
$canManage = $request->user()->purchasing_role === PurchasingRole::Finance;
|
||||
$payments = ExternalPayment::query()->latest('id')->paginate(15)->through(fn ($payment) => [...$payment->toArray(), 'external_reference' => $canManage ? $payment->external_reference : null, 'evidence_reference' => $canManage ? $payment->evidence_reference : null]);
|
||||
if ($canManage && $payments->count() > 0) {
|
||||
app(RequisitionWorkflow::class)->audit($request->user(), 'payment.viewed', null, null, ['payment_ids' => $payments->pluck('id')->all()]);
|
||||
}
|
||||
$invoices = Invoice::query()->where('status', 'validated')->latest('id')->get()->map(function (Invoice $invoice) use ($workflow) {
|
||||
$supplier = Supplier::query()->findOrFail($invoice->supplier_id);
|
||||
$bank = SupplierBankDetail::query()->where('supplier_id', $supplier->id)->latest('revision')->first();
|
||||
|
||||
return ['id' => $invoice->id, 'number' => $invoice->number, 'supplier_id' => $supplier->id, 'supplier_name' => $supplier->name, 'total_minor' => $invoice->total_minor, 'outstanding_minor' => $workflow->outstanding($invoice), 'bank_id' => $bank->id ?? 0, 'bank_revision' => $bank->revision ?? 0, 'bank_state' => app(SupplierBankWorkflow::class)->state($supplier)];
|
||||
});
|
||||
Inertia::encryptHistory();
|
||||
$response = Inertia::render('payments/index', ['invoices' => $invoices, 'payments' => $payments, 'canManage' => $canManage])->toResponse($request);
|
||||
$response->headers->set('Cache-Control', 'private, no-store, max-age=0');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function store(Request $request, Invoice $invoice, ExternalPaymentWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->purchasing_role === PurchasingRole::Finance, 403);
|
||||
$data = $request->validate(['supplier_bank_detail_id' => 'required|integer|min:1', 'amount' => ['required', 'regex:/^\d{1,12}(\.\d{1,2})?$/'], 'paid_on' => 'required|date_format:Y-m-d', 'external_reference' => 'required|string|max:150', 'evidence_reference' => 'required|string|max:1000']);
|
||||
$workflow->register($request->user(), $invoice, $data);
|
||||
|
||||
return to_route('payments.index');
|
||||
}
|
||||
|
||||
public function review(Request $request, ExternalPayment $payment, ExternalPaymentWorkflow $workflow): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['decision' => 'required|in:confirmed,rejected', 'reason' => 'required|string|max:2000', 'independently_verified' => 'accepted']);
|
||||
$workflow->review($request->user(), $payment, $data['decision'], $data['reason']);
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/** @property int $id
|
||||
* @property int $tenant_id
|
||||
* @property int $invoice_id
|
||||
* @property int $supplier_bank_detail_id
|
||||
* @property int $created_by
|
||||
* @property int $amount_minor
|
||||
* @property string $status
|
||||
* @property string $payload_hash
|
||||
* @property array<string,mixed> $bank_snapshot
|
||||
*/
|
||||
class ExternalPayment extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected $attributes = ['status' => 'pending'];
|
||||
|
||||
protected $hidden = ['external_reference', 'reference_hash', 'payload_hash', 'evidence_reference', 'bank_snapshot', 'review_reason'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['external_reference' => 'encrypted', 'evidence_reference' => 'encrypted', 'bank_snapshot' => 'encrypted:array', 'review_reason' => 'encrypted'];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::updating(function (ExternalPayment $payment) {
|
||||
if ($payment->getRawOriginal('status') !== 'pending' || array_diff(array_keys($payment->getDirty()), ['status', 'reviewed_by', 'reviewed_at', 'review_reason', 'updated_at']) !== []) {
|
||||
throw new \LogicException('Preservar o registo externo e a conferência.');
|
||||
}
|
||||
});
|
||||
static::deleting(fn () => throw new \LogicException('Preservar o histórico de pagamentos externos.'));
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,7 @@ class ConflictGuard
|
||||
'award.created', 'award.approved', 'award.rejected', 'purchase_order.issued',
|
||||
'equipment.technical_approved', 'equipment.technical_rejected',
|
||||
'invoice.created', 'invoice.validated',
|
||||
'payment.registered', 'payment.confirmed', 'payment.rejected',
|
||||
])->pluck('user_id'))
|
||||
->unique()->values()->all();
|
||||
$ids = [];
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\ExternalPayment;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\PurchaseOrder;
|
||||
use App\Models\Requisition;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\SupplierBankDetail;
|
||||
use App\Models\SupplierBankReview;
|
||||
use App\Models\User;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ExternalPaymentWorkflow
|
||||
{
|
||||
private function actor(User $user, int $tenantId): void
|
||||
{
|
||||
abort_unless(auth()->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
|
||||
{
|
||||
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<string,mixed> $data */
|
||||
public function register(User $user, Invoice $invoice, array $data): ExternalPayment
|
||||
{
|
||||
$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);
|
||||
$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);
|
||||
$payment = ExternalPayment::query()->lockForUpdate()->findOrFail($payment->id);
|
||||
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.');
|
||||
}
|
||||
}
|
||||
$payment->update(['status' => $decision, 'reviewed_by' => $user->id, 'review_reason' => $reason, 'reviewed_at' => now()]);
|
||||
$this->audit($user, $invoice, $payment, 'payment.'.$decision);
|
||||
});
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user