Implementa registo e conferencia de pagamentos externos
This commit is contained in:
@@ -23,6 +23,12 @@ Crie um pedido, guarde o rascunho, confira os valores e submeta. Entre com as co
|
||||
|
||||
## Entrega actual
|
||||
|
||||
- Registo de pagamentos externos por empresa, com conferência independente,
|
||||
liquidação parcial/total de facturas e revalidação da versão bancária. Sem
|
||||
consumo duplicado do orçamento ou execução de transferências. Migração 000012
|
||||
aplicada após backup/restauro verificados; três cenários concorrentes PostgreSQL
|
||||
aprovados. Interface `/pagamentos`; demonstração `/consulta/pagamentos`.
|
||||
|
||||
- Dados bancários por fornecedor/empresa: versões propostas por Procurement,
|
||||
duas validações distintas por Finanças, dados sensíveis cifrados, consultas
|
||||
restritas e auditoria sem números de conta. Alterações exigem duas novas
|
||||
@@ -40,7 +46,7 @@ Crie um pedido, guarde o rascunho, confira os valores e submeta. Entre com as co
|
||||
Migração 000009 aplicada após backup e restauro verificados; cinco cenários
|
||||
concorrentes PostgreSQL aprovados. Sem dotações fictícias na base real.
|
||||
Centros sem dotação mantêm o circuito de desenvolvimento sem controlo.
|
||||
Reforços e pagamentos continuam pendentes; não constitui validação
|
||||
Reforços e estornos continuam pendentes; não constitui validação
|
||||
operacional integral do orçamento. Interface em `/orcamentos`.
|
||||
|
||||
- Equipamentos e peças ligados aos artigos existentes, compatibilidades, criticidade configurada, mínimos explícitos e alertas, saídas rastreáveis e validação técnica. Finanças é obrigatória para equipamento em qualquer valor; a adjudicação/emissão de investimento continua bloqueada até definição do limite próprio. Migração 000008 aplicada no lote 9 após backup recuperável e verificação da preservação. Demonstração em `/consulta/equipamentos`.
|
||||
@@ -75,7 +81,7 @@ O IVA é informado pelo utilizador e calculado no servidor. A taxa inicial de 0%
|
||||
|
||||
## Próximas etapas
|
||||
|
||||
Revisão visual dos módulos entregues; reposição de stock; registo de pagamentos externos; reforços orçamentais, correcções de facturas e pagamentos; moedas e câmbio; subscrições, licenças e Docker. Equipamentos, peças, validação técnica e alertas de mínimo já estão implementados; a adjudicação de equipamento aguarda a definição da regra de investimento. Periodicidade dos inventários, tolerâncias e investigação de divergências exigem decisão da empresa. A política de periodicidade, validade e levantamento de conflitos e os procedimentos de revisão/cancelamento de adjudicações continuam por definir.
|
||||
Revisão visual dos módulos entregues; reposição de stock; conciliação de pagamentos externos; reforços orçamentais, correcções de facturas e estornos; moedas e câmbio; subscrições, licenças e Docker. Equipamentos, peças, validação técnica e alertas de mínimo já estão implementados; a adjudicação de equipamento aguarda a definição da regra de investimento. Periodicidade dos inventários, tolerâncias e investigação de divergências exigem decisão da empresa. A política de periodicidade, validade e levantamento de conflitos e os procedimentos de revisão/cancelamento de adjudicações continuam por definir.
|
||||
|
||||
O cadastro guarda referências e ficheiros anexos no disco privado `supplier_documents`, em `storage/app/supplier-documents`, fora da pasta pública e sem URLs públicas. As fichas abrem pelo directório **Fornecedores → Ver ficha → Abrir ficha, edição e documentos**. As versões antigas e documentos existentes são preservados na auditoria e no armazenamento; nesta entrega não há eliminação de documentos, antivírus, quotas acumuladas ou política de retenção. A validação do NUIT é de formato e unicidade, sem consulta a uma autoridade fiscal. Além das declarações por operação, existem declarações pessoais por fornecedor e processo; a periodicidade anual e a política de validade/levantamento ainda não foram definidas. As propostas e avaliações registadas não são editáveis nesta etapa. Os pesos 40/20/15/15/10 são provisórios e ficam guardados por proposta. A comparação liga à fase de adjudicação e emissão. A ordem exige novas aprovações do valor final e revalidação no servidor. As propostas usam MZN; a multimoeda fica para a fase correspondente.
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -24,7 +24,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
$exceptions->dontFlash(['account_identifier', 'beneficiary', 'evidence_reference', 'reason']);
|
||||
$exceptions->dontFlash(['account_identifier', 'beneficiary', 'external_reference', 'evidence_reference', 'reason']);
|
||||
$exceptions->shouldRenderJsonWhen(
|
||||
fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('external_payments', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('invoice_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('supplier_bank_detail_id')->constrained()->restrictOnDelete();
|
||||
$table->foreignId('created_by')->constrained('users')->restrictOnDelete();
|
||||
$table->foreignId('reviewed_by')->nullable()->constrained('users')->restrictOnDelete();
|
||||
$table->string('status')->default('pending');
|
||||
$table->bigInteger('amount_minor');
|
||||
$table->string('currency', 3);
|
||||
$table->date('paid_on');
|
||||
$table->text('external_reference');
|
||||
$table->string('reference_hash', 64);
|
||||
$table->string('payload_hash', 64);
|
||||
$table->text('evidence_reference');
|
||||
$table->text('bank_snapshot');
|
||||
$table->text('review_reason')->nullable();
|
||||
$table->timestamp('reviewed_at')->nullable();
|
||||
$table->timestamps();
|
||||
$table->unique(['tenant_id', 'reference_hash']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
throw new LogicException('Preservar registos e conferências de pagamentos externos.');
|
||||
}
|
||||
};
|
||||
+12
-2
@@ -6,7 +6,7 @@ Estado actualizado em 28 de Outubro de 2026. Projecto activo: C:\laragon\www\com
|
||||
| ------------------------ | --------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| Acesso e organização | Fortify, conta, empresa, equipa, departamentos e centros de custo | MFA obrigatório e ciclo completo de acessos |
|
||||
| Requisições e aprovações | Rascunhos, cálculos exactos, etapas, segregação e snapshots | Validação empresarial das regras provisórias |
|
||||
| Fornecedores | Cadastro, anexos privados e versões bancárias com duas validações | Integração futura com pagamentos |
|
||||
| Fornecedores | Cadastro, anexos privados e versões bancárias com duas validações | Múltiplas contas e verificação bancária externa |
|
||||
| Cotações | Propostas, avaliações e matriz comparativa | Multimoeda e revisão das propostas |
|
||||
| RF-AF-01 | Declarações pessoais, histórico e ConflictGuard no servidor | Política de periodicidade, validade e levantamento |
|
||||
| Adjudicação | Escolha fundamentada, revalidação e aprovação do valor final | Excepções, revisão e cancelamento sem política ficam bloqueados |
|
||||
@@ -15,7 +15,7 @@ Estado actualizado em 28 de Outubro de 2026. Projecto activo: C:\laragon\www\com
|
||||
| Stock | Artigos, armazéns, entradas, saídas fundamentadas, transferências atómicas, contagens e ajustes auditados por empresa | Revisão visual, reposição e conversões |
|
||||
| Equipamentos e peças | Cadastro, compatibilidade, mínimos, alertas, validação técnica e rastreabilidade | Regra de investimento; adjudicação/emissão de equipamento bloqueadas |
|
||||
| Orçamento | Dotação por empresa/centro/exercício, aprovação independente, reservas e compromissos | Revisão visual, reforços e validação operacional integral |
|
||||
| Facturas e pagamentos | Registo por empresa, conferência, validação independente e consumo orçamental | Pagamentos, notas de crédito e anexos |
|
||||
| Facturas e pagamentos | Facturas, consumo e registo/conferência de pagamentos externos | Estornos, notas de crédito e anexos |
|
||||
| Início e navegação | Página inicial própria, menus por área/perfil e consulta pública por módulo | Revisão visual |
|
||||
| Auditoria | Histórico das operações implementadas | Exportações, retenção e protecção contra SQL administrativo |
|
||||
|
||||
@@ -23,6 +23,16 @@ Preservar enums, políticas, modelos relacionais, Brick Math e isolamento por em
|
||||
|
||||
## Verificação actual
|
||||
|
||||
Registo e conferência de pagamentos externos entregues, com liquidação parcial,
|
||||
independência, revalidação bancária e consumo orçamental sem duplicação.
|
||||
Suite actual: **347 aprovados, 3 ignorados e 2.630 asserções**.
|
||||
Migração 000012 aplicada no lote 13 após backup e restauro verificados de 44
|
||||
tabelas. Três cenários concorrentes PostgreSQL e 33 testes específicos aprovados.
|
||||
Tabela de pagamentos externos vazia na base real. Não executa transferências.
|
||||
Reforços, estornos, investimento e revisão visual continuam pendentes.
|
||||
|
||||
### Histórico da entrega de dados bancários
|
||||
|
||||
Dados bancários entregues: versões por empresa/fornecedor, duas decisões
|
||||
independentes de Finanças, campos sensíveis cifrados e consultas restritas.
|
||||
Migração 000011 no lote 12 após backup e restauro verificados de 42 tabelas.
|
||||
|
||||
@@ -1,5 +1,77 @@
|
||||
# Desenvolvimento do SGC
|
||||
|
||||
## Registo e conferência de pagamentos externos
|
||||
|
||||
Implementado `/pagamentos`, por empresa. Finanças regista pagamentos já
|
||||
efectuados fora do SGC, com factura validada, montante explícito, data efectiva,
|
||||
referência externa e evidência. Outra pessoa de Finanças confirma ou rejeita a
|
||||
conferência. O sistema **não executa transferências, remessas ou ordens bancárias**.
|
||||
|
||||
- Apenas os registos confirmados abatem a dívida da factura. Liquidação parcial
|
||||
ou total é calculada a partir desse histórico; não reescreve a factura validada.
|
||||
Pendentes/rejeitados não abatem o saldo. A confirmação não lança novamente
|
||||
consumo ou compromisso orçamental: o orçamento foi consumido na factura.
|
||||
- Montante deve ser positivo e caber no saldo confirmado ainda pendente. Duas
|
||||
confirmações concorrentes não podem exceder a factura. Referência normalizada
|
||||
única por empresa; repetição idêntica pelo autor devolve o registo existente,
|
||||
enquanto dados diferentes para a mesma referência são recusados.
|
||||
- Registo e confirmação revalidam factura, fornecedor, conflitos, moeda e
|
||||
versão bancária mais recente com duas validações elegíveis. Versão alterada,
|
||||
fornecedor suspenso, perda de perfil ou conflito posterior bloqueiam confirmação.
|
||||
Registos históricos já confirmados não são reescritos por mudanças posteriores.
|
||||
- Uma operação externa corresponde a uma factura nesta etapa. Datas entre
|
||||
exercícios, câmbio, adiantamentos, estornos, reembolsos, anulação de confirmados
|
||||
e repartição da mesma operação por várias facturas não estão implementados.
|
||||
Uma referência de evidência não é upload de comprovativo nem verificação bancária.
|
||||
- Referência externa, evidência, fundamento e snapshot bancário são cifrados.
|
||||
Snapshot preserva versão, conta e validadores do momento do registo; não é
|
||||
exposto na listagem. Administração consulta metadados; Finanças consulta
|
||||
referências/evidência com auditoria. Outros perfis não acedem ao módulo.
|
||||
Auditoria contém IDs, montantes e estados, sem referências externas nem números
|
||||
bancários. Histórico Inertia cifrado, resposta sem cache e dados sensíveis
|
||||
excluídos de input antigo de sessão. Preservar a chave de aplicação no restauro.
|
||||
- Modelos impedem edição do registo e eliminação. Decisão final é imutável;
|
||||
falha na auditoria reverte registo ou confirmação. Protecção contra SQL
|
||||
administrativo directo continua dependente das permissões da base de dados.
|
||||
- `/consulta/pagamentos` contém apenas exemplos fictícios. Valores operacionais
|
||||
vêm das facturas da própria empresa e do montante introduzido; não há valores
|
||||
financeiros predefinidos para clientes. Moeda suportada nesta etapa: MZN,
|
||||
coincidente com a conta; sem autorização implícita para conversões.
|
||||
|
||||
### Verificação e migração
|
||||
|
||||
Suite completa actual: **347 aprovados, 3 ignorados, 2.630 asserções**.
|
||||
PHPStan, Pint, TypeScript, compilação e formatação/lint frontend aprovados.
|
||||
O serviço recarrega o perfil do operador depois do bloqueio da empresa e
|
||||
bloqueia os registos dos validadores bancários durante a conferência.
|
||||
|
||||
33 testes específicos aprovados, 107 asserções: liquidação parcial/total, limites, idempotência,
|
||||
versões bancárias, perfis, conflitos, moedas, isolamento, cifragem, privacidade,
|
||||
input não guardado, ausência de consumo duplicado, auditoria e rollback.
|
||||
Três cenários concorrentes PostgreSQL aprovados com duas ligações independentes
|
||||
observadas simultaneamente à espera de bloqueios: excesso, mesma confirmação
|
||||
e pagamentos complementares. Saldos e manutenção do consumo orçamental conferidos;
|
||||
base temporária eliminada. São testes de serviço, não de carga ou HTTP.
|
||||
[Evidência](pagamentos-concorrencia-postgresql.json).
|
||||
|
||||
Migração 000012 aplicada no **lote 13**, após backup custom PostgreSQL 17 de
|
||||
**239.843 bytes** e restauro verificado das **44 tabelas** anteriores numa base
|
||||
temporária, depois eliminada. Snapshot consistente para dump e manifesto.
|
||||
Registos de negócio, restrições e `.env` preservados. Tabela de pagamentos
|
||||
externos vazia; sem seeds, contas novas, alterações de credenciais ou pagamentos
|
||||
fictícios na base real. Backup e manifesto privados, fora do Git.
|
||||
[Evidência sem dados privados](pagamentos-migracao-000012.json).
|
||||
|
||||
### Pendências e próximo incremento
|
||||
|
||||
O navegador continua indisponível (`The browser access host is unavailable`).
|
||||
**Revisão visual pública, autenticada e de impressão permanece pendente.**
|
||||
Este incremento não constitui integração bancária, confirmação bancária automática
|
||||
ou validação operacional integral do orçamento. Documentos continuam de desenvolvimento.
|
||||
Próximo incremento: reforços orçamentais auditados, propostos e aprovados por
|
||||
pessoas distintas, com valores definidos por cada empresa. Regras de investimento
|
||||
também continuam por definir por cliente; equipamento permanece bloqueado.
|
||||
|
||||
## Dados bancários de fornecedores com dupla validação
|
||||
|
||||
Implementado no mesmo projecto, com dados isolados por empresa/fornecedor.
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Continuidade do desenvolvimento do SGC
|
||||
|
||||
## Próxima etapa após pagamentos externos
|
||||
|
||||
Registo e conferência independente de pagamentos externos já implementados,
|
||||
com migração 000012 aplicada. Não repetir as etapas históricas abaixo.
|
||||
Prosseguir com reforços orçamentais por empresa/centro/exercício, sem reescrever
|
||||
a dotação inicial, com proposta e aprovação por pessoas distintas e auditoria.
|
||||
Valores definidos por cada empresa. Não implementar transferências bancárias
|
||||
ou desbloquear investimento sem regra própria aprovada. Revisão visual e
|
||||
validação operacional integral continuam pendentes; consultar o relatório actual.
|
||||
|
||||
## Próxima etapa após dados bancários
|
||||
|
||||
Versões bancárias por empresa/fornecedor, cifragem e duas validações distintas
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"tested_at_utc": "2026-10-28T12:49:13+00:00",
|
||||
"separate_database": true,
|
||||
"transport": "Independent PHP service transactions and PostgreSQL connections",
|
||||
"scenarios": [
|
||||
{
|
||||
"name": "over_payment",
|
||||
"passed": true,
|
||||
"overlapping_lock_waits": 2,
|
||||
"confirmed_minor": 4000,
|
||||
"outstanding_minor": 2000,
|
||||
"budget_consumption_unchanged": true
|
||||
},
|
||||
{
|
||||
"name": "same_confirmation",
|
||||
"passed": true,
|
||||
"overlapping_lock_waits": 2,
|
||||
"confirmed_minor": 4000,
|
||||
"outstanding_minor": 2000,
|
||||
"budget_consumption_unchanged": true
|
||||
},
|
||||
{
|
||||
"name": "complementary_payments",
|
||||
"passed": true,
|
||||
"overlapping_lock_waits": 2,
|
||||
"confirmed_minor": 6000,
|
||||
"outstanding_minor": 0,
|
||||
"budget_consumption_unchanged": true
|
||||
}
|
||||
],
|
||||
"passed": true,
|
||||
"test_database_dropped": true
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"verified_at_utc": "2026-10-28T12:46:38+00:00",
|
||||
"migration": "2026_10_28_000012_create_external_payments",
|
||||
"batch": 13,
|
||||
"backup_created": true,
|
||||
"backup_bytes": 239843,
|
||||
"restore_verified": true,
|
||||
"restored_tables": 44,
|
||||
"restore_database_dropped": true,
|
||||
"business_records_unchanged": true,
|
||||
"existing_constraints_preserved": true,
|
||||
"env_unchanged": true,
|
||||
"external_payments_count": 0,
|
||||
"operational_validation": false
|
||||
}
|
||||
@@ -7,7 +7,9 @@ Actualização: o utilizador autorizou avançar com a abordagem. Dotações, res
|
||||
compromissos e conferência de facturas/consumo foram implementados e migrados.
|
||||
A base de IVA é uma decisão explícita de cada empresa. As secções seguintes
|
||||
conservam a proposta original; não comprovam aprovação de limites monetários,
|
||||
tolerâncias ou regras de investimento. Reforços e pagamentos continuam pendentes.
|
||||
tolerâncias ou regras de investimento. Reforços continuam pendentes. Registo e
|
||||
conferência de pagamentos externos foram acrescentados depois; não executam
|
||||
transferências nem substituem a confirmação humana da evidência.
|
||||
Estado actual em `docs/desenvolvimento.md`.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
@@ -141,6 +141,11 @@ export function AppSidebar() {
|
||||
href: '/facturas',
|
||||
icon: FileCheck2,
|
||||
},
|
||||
{
|
||||
title: 'Pagamentos externos',
|
||||
href: '/pagamentos',
|
||||
icon: FileCheck2,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -11,5 +11,6 @@ export const previewPaths: Record<string, string> = {
|
||||
equipment: '/consulta/equipamentos',
|
||||
invoices: '/consulta/facturas',
|
||||
bank: '/consulta/dados-bancarios',
|
||||
payments: '/consulta/pagamentos',
|
||||
appearance: '/consulta/aparencia',
|
||||
};
|
||||
|
||||
@@ -51,8 +51,9 @@ export default function Budgets({
|
||||
Desenvolvimento. O orçamento continua sem validação
|
||||
operacional. Centros sem dotação continuam no circuito
|
||||
anterior, sem controlo orçamental. Facturas validadas
|
||||
convertem compromisso em consumo. Pagamentos ainda não
|
||||
implementados.
|
||||
convertem compromisso em consumo. Pagamentos externos têm
|
||||
registo e conferência próprios; o SGC não executa
|
||||
transferências.
|
||||
</p>
|
||||
<Panel
|
||||
title="Preparar dotação"
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import { Head, Link, useForm, usePage } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
FormErrors,
|
||||
Panel,
|
||||
PageTitle,
|
||||
controlClass,
|
||||
} from '@/components/procurement';
|
||||
import { InventoryPagination } from '@/components/inventory';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { Page } from '@/types/inventory';
|
||||
import { money } from '@/types/procurement';
|
||||
|
||||
type Invoice = {
|
||||
id: number;
|
||||
number: string;
|
||||
supplier_id: number;
|
||||
supplier_name: string;
|
||||
total_minor: number;
|
||||
outstanding_minor: number;
|
||||
bank_id: number;
|
||||
bank_revision: number;
|
||||
bank_state: string;
|
||||
};
|
||||
type Payment = {
|
||||
id: number;
|
||||
invoice_id: number;
|
||||
created_by: number;
|
||||
status: string;
|
||||
amount_minor: number;
|
||||
currency: string;
|
||||
paid_on: string;
|
||||
external_reference: string | null;
|
||||
evidence_reference: string | null;
|
||||
supplier_bank_detail_id: number;
|
||||
};
|
||||
function Review({ id }: { id: number }) {
|
||||
const form = useForm({
|
||||
decision: 'confirmed',
|
||||
reason: '',
|
||||
independently_verified: false,
|
||||
});
|
||||
return (
|
||||
<form
|
||||
className="mt-4 grid gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.post(`/pagamentos/${id}/conferir`);
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={form.errors} />
|
||||
<label>
|
||||
Resultado da conferência
|
||||
<select
|
||||
className={controlClass}
|
||||
value={form.data.decision}
|
||||
onChange={(e) => form.setData('decision', e.target.value)}
|
||||
>
|
||||
<option value="confirmed">Confirmar registo externo</option>
|
||||
<option value="rejected">Rejeitar conferência</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Fundamento
|
||||
<textarea
|
||||
required
|
||||
className="w-full rounded border p-3"
|
||||
value={form.data.reason}
|
||||
onChange={(e) => form.setData('reason', e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex gap-3">
|
||||
<input
|
||||
required
|
||||
type="checkbox"
|
||||
checked={form.data.independently_verified}
|
||||
onChange={(e) =>
|
||||
form.setData('independently_verified', e.target.checked)
|
||||
}
|
||||
/>
|
||||
Conferi a evidência, os valores e os dados bancários de forma
|
||||
independente.
|
||||
</label>
|
||||
<Button disabled={form.processing}>Guardar conferência</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
export default function Payments({
|
||||
invoices,
|
||||
payments,
|
||||
canManage,
|
||||
}: {
|
||||
invoices: Invoice[];
|
||||
payments: Page<Payment>;
|
||||
canManage: boolean;
|
||||
}) {
|
||||
const { auth } = usePage().props;
|
||||
const [invoiceId, setInvoiceId] = useState('');
|
||||
const form = useForm({
|
||||
supplier_bank_detail_id: 0,
|
||||
amount: '',
|
||||
paid_on: '',
|
||||
external_reference: '',
|
||||
evidence_reference: '',
|
||||
});
|
||||
const selected = invoices.find((i) => String(i.id) === invoiceId);
|
||||
return (
|
||||
<>
|
||||
<Head title="Pagamentos externos" />
|
||||
<div className="mx-auto grid w-full max-w-6xl gap-6 p-6">
|
||||
<PageTitle
|
||||
eyebrow="SGC · Finanças"
|
||||
title="Pagamentos externos e conferência"
|
||||
description="Registe pagamentos já efectuados fora do SGC. O sistema não executa transferências, remessas ou ordens bancárias."
|
||||
/>
|
||||
<p className="rounded-lg border border-amber-300 p-4 text-sm">
|
||||
Desenvolvimento. Apenas a confirmação independente abate o
|
||||
saldo da factura; não volta a consumir o orçamento. Dados
|
||||
bancários e evidências devem ser conferidos pelas pessoas
|
||||
autorizadas.
|
||||
</p>
|
||||
<Panel title="Saldo das facturas validadas">
|
||||
<div className="space-y-4 text-sm">
|
||||
{invoices.map((i) => (
|
||||
<article key={i.id} className="rounded border p-3">
|
||||
<p className="font-semibold">
|
||||
{i.number} · {i.supplier_name}
|
||||
</p>
|
||||
<p>
|
||||
Total {money(i.total_minor)} · Confirmado{' '}
|
||||
{money(i.total_minor - i.outstanding_minor)}{' '}
|
||||
· Pendente {money(i.outstanding_minor)}
|
||||
</p>
|
||||
<p>
|
||||
{i.outstanding_minor === 0
|
||||
? 'Liquidada por registos externos confirmados'
|
||||
: 'Saldo por conferir/liquidar'}{' '}
|
||||
· Versão bancária{' '}
|
||||
{i.bank_revision || 'não registada'} ·{' '}
|
||||
{i.bank_state === 'approved'
|
||||
? 'Duas validações elegíveis'
|
||||
: 'Dados bancários indisponíveis para confirmação'}
|
||||
</p>
|
||||
{canManage && (
|
||||
<Link
|
||||
href={`/fornecedores/${i.supplier_id}/dados-bancarios`}
|
||||
className="font-semibold text-teal-700"
|
||||
>
|
||||
Consultar dados bancários →
|
||||
</Link>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
{invoices.length === 0 && (
|
||||
<p>Sem facturas validadas nesta empresa.</p>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
{canManage && (
|
||||
<Panel
|
||||
title="Registar pagamento externo para conferência"
|
||||
description="Uma referência externa identifica um registo por empresa. Use a referência da operação e a evidência do pagamento; não introduza números de conta nestes campos."
|
||||
>
|
||||
<form
|
||||
className="grid gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.post(`/facturas/${invoiceId}/pagamentos`, {
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
setInvoiceId('');
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<FormErrors errors={form.errors} />
|
||||
<label>
|
||||
Factura
|
||||
<select
|
||||
required
|
||||
className={controlClass}
|
||||
value={invoiceId}
|
||||
onChange={(e) => {
|
||||
setInvoiceId(e.target.value);
|
||||
form.setData(
|
||||
'supplier_bank_detail_id',
|
||||
invoices.find(
|
||||
(i) =>
|
||||
String(i.id) ===
|
||||
e.target.value,
|
||||
)?.bank_id ?? 0,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option value="">Seleccionar</option>
|
||||
{invoices
|
||||
.filter((i) => i.outstanding_minor > 0)
|
||||
.map((i) => (
|
||||
<option key={i.id} value={i.id}>
|
||||
{i.number} · {i.supplier_name} ·{' '}
|
||||
{money(i.outstanding_minor)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{selected && (
|
||||
<p className="text-sm">
|
||||
Versão bancária seleccionada:{' '}
|
||||
{selected.bank_revision}. Se os dados
|
||||
mudarem, a confirmação será bloqueada.
|
||||
</p>
|
||||
)}
|
||||
<label>
|
||||
Montante do pagamento (MZN)
|
||||
<Input
|
||||
required
|
||||
inputMode="decimal"
|
||||
value={form.data.amount}
|
||||
onChange={(e) =>
|
||||
form.setData('amount', e.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Data efectiva do pagamento
|
||||
<Input
|
||||
required
|
||||
type="date"
|
||||
value={form.data.paid_on}
|
||||
onChange={(e) =>
|
||||
form.setData('paid_on', e.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Referência externa da operação
|
||||
<Input
|
||||
required
|
||||
autoComplete="off"
|
||||
maxLength={150}
|
||||
value={form.data.external_reference}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'external_reference',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Referência da evidência
|
||||
<Input
|
||||
required
|
||||
autoComplete="off"
|
||||
maxLength={1000}
|
||||
value={form.data.evidence_reference}
|
||||
onChange={(e) =>
|
||||
form.setData(
|
||||
'evidence_reference',
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
disabled={
|
||||
!selected ||
|
||||
selected.bank_state !== 'approved' ||
|
||||
form.processing
|
||||
}
|
||||
>
|
||||
Registar para conferência independente
|
||||
</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
)}
|
||||
{payments.data.map((p) => (
|
||||
<Panel
|
||||
key={p.id}
|
||||
title={`Registo externo #${p.id} · ${invoices.find((i) => i.id === p.invoice_id)?.number ?? `Factura #${p.invoice_id}`}`}
|
||||
>
|
||||
<p>
|
||||
{money(p.amount_minor)} · {p.paid_on} ·{' '}
|
||||
{{
|
||||
pending: 'Pendente de conferência',
|
||||
confirmed: 'Confirmado',
|
||||
rejected: 'Conferência rejeitada',
|
||||
}[p.status] ?? p.status}
|
||||
</p>
|
||||
{p.external_reference && (
|
||||
<p className="mt-2 text-sm break-all">
|
||||
Referência: {p.external_reference}
|
||||
</p>
|
||||
)}
|
||||
{p.evidence_reference && (
|
||||
<p className="text-sm break-all">
|
||||
Evidência: {p.evidence_reference}
|
||||
</p>
|
||||
)}
|
||||
{canManage &&
|
||||
p.status === 'pending' &&
|
||||
p.created_by !== auth.user.id && (
|
||||
<Review id={p.id} />
|
||||
)}
|
||||
</Panel>
|
||||
))}
|
||||
<InventoryPagination page={payments} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -120,6 +120,7 @@ const navigation = [
|
||||
{ id: 'equipment', title: 'Equipamentos e peças', icon: Warehouse },
|
||||
{ id: 'invoices', title: 'Facturas e conferência', icon: FileText },
|
||||
{ id: 'bank', title: 'Dados bancários', icon: ShieldCheck },
|
||||
{ id: 'payments', title: 'Pagamentos externos', icon: FileText },
|
||||
{ id: 'appearance', title: 'Cores e componentes', icon: Palette },
|
||||
];
|
||||
type Detail = { title: string; subtitle: string; rows: [string, string][] };
|
||||
@@ -128,7 +129,7 @@ const navigationGroups = [
|
||||
{ title: 'Compras', ids: ['requests', 'suppliers', 'quotes', 'orders'] },
|
||||
{ title: 'Armazém', ids: ['receipts', 'stock', 'operations', 'equipment'] },
|
||||
{ title: 'Controlo', ids: ['conflicts'] },
|
||||
{ title: 'Finanças', ids: ['invoices', 'bank'] },
|
||||
{ title: 'Finanças', ids: ['invoices', 'bank', 'payments'] },
|
||||
{ title: 'Apresentação', ids: ['appearance'] },
|
||||
];
|
||||
export default function Preview({
|
||||
@@ -664,6 +665,38 @@ export default function Preview({
|
||||
)}
|
||||
{screen === 'operations' && <OperationsDemo />}
|
||||
{screen === 'equipment' && <EquipmentDemo />}
|
||||
{screen === 'payments' && (
|
||||
<Panel
|
||||
title="Pagamento externo · exemplo fictício"
|
||||
description="Sem registos reais, contas bancárias ou execução de transferências."
|
||||
>
|
||||
<div className="space-y-3 text-sm">
|
||||
<p>
|
||||
Factura fictícia de 1.000,00 MZN. Um
|
||||
pagamento externo de 600,00 MZN é
|
||||
registado com referência e evidência.
|
||||
</p>
|
||||
<p>
|
||||
Enquanto pendente de conferência, o
|
||||
saldo continua em 1.000,00 MZN. Após
|
||||
confirmação por outra pessoa de
|
||||
Finanças, fica em 400,00 MZN.
|
||||
</p>
|
||||
<p>
|
||||
A versão bancária e as duas validações
|
||||
são novamente verificadas. Repetir a
|
||||
confirmação não duplica a liquidação; o
|
||||
orçamento não é consumido outra vez.
|
||||
</p>
|
||||
<p>
|
||||
Os valores são apenas exemplos. Cada
|
||||
empresa tem as suas facturas e
|
||||
montantes; o SGC não efectua a operação
|
||||
no banco.
|
||||
</p>
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
{screen === 'bank' && (
|
||||
<Panel
|
||||
title="Dupla validação bancária · exemplo fictício"
|
||||
|
||||
+2
-1
@@ -7,7 +7,7 @@ use Inertia\Inertia;
|
||||
// Public visual consultation. No business records or authenticated identities.
|
||||
Route::get('consulta', function (Request $request) {
|
||||
$screen = $request->query('ecra', 'overview');
|
||||
$allowed = ['overview', 'requests', 'suppliers', 'quotes', 'conflicts', 'orders', 'receipts', 'stock', 'operations', 'equipment', 'invoices', 'bank', 'appearance'];
|
||||
$allowed = ['overview', 'requests', 'suppliers', 'quotes', 'conflicts', 'orders', 'receipts', 'stock', 'operations', 'equipment', 'invoices', 'bank', 'payments', 'appearance'];
|
||||
|
||||
return Inertia::render('preview/index', [
|
||||
'auth' => ['user' => null],
|
||||
@@ -21,6 +21,7 @@ Route::get('consulta/{screen}', function (string $screen) {
|
||||
$screens['equipamentos'] = 'equipment';
|
||||
$screens['facturas'] = 'invoices';
|
||||
$screens['dados-bancarios'] = 'bank';
|
||||
$screens['pagamentos'] = 'payments';
|
||||
abort_unless(isset($screens[$screen]), 404);
|
||||
|
||||
return Inertia::render('preview/index', ['auth' => ['user' => null], 'initialScreen' => $screens[$screen]]);
|
||||
|
||||
@@ -4,6 +4,7 @@ use App\Http\Controllers\BudgetController;
|
||||
use App\Http\Controllers\CompanyController;
|
||||
use App\Http\Controllers\ConflictDeclarationController;
|
||||
use App\Http\Controllers\EquipmentController;
|
||||
use App\Http\Controllers\ExternalPaymentController;
|
||||
use App\Http\Controllers\GoodsReceiptController;
|
||||
use App\Http\Controllers\InvoiceController;
|
||||
use App\Http\Controllers\PurchaseController;
|
||||
@@ -26,6 +27,9 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::post('empresa/iniciar', [CompanyController::class, 'store'])->name('company.store');
|
||||
|
||||
Route::middleware(EnsureCompanyIsConfigured::class)->group(function () {
|
||||
Route::get('pagamentos', [ExternalPaymentController::class, 'index'])->name('payments.index');
|
||||
Route::post('facturas/{invoice}/pagamentos', [ExternalPaymentController::class, 'store'])->name('payments.store');
|
||||
Route::post('pagamentos/{payment}/conferir', [ExternalPaymentController::class, 'review'])->name('payments.review');
|
||||
Route::get('fornecedores/{supplier}/dados-bancarios', [SupplierBankController::class, 'show'])->name('suppliers.bank');
|
||||
Route::post('fornecedores/{supplier}/dados-bancarios', [SupplierBankController::class, 'store'])->name('suppliers.bank.store');
|
||||
Route::post('fornecedores/{supplier}/dados-bancarios/{detail}/validar', [SupplierBankController::class, 'review'])->name('suppliers.bank.review');
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use App\Models\ExternalPayment;
|
||||
use App\Models\User;
|
||||
use App\Services\ExternalPaymentWorkflow;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
require __DIR__.'/../../vendor/autoload.php';
|
||||
$database = $argv[1] ?? '';
|
||||
if (! preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', $database)) {
|
||||
throw new LogicException('Base de teste inválida.');
|
||||
}
|
||||
$app = require __DIR__.'/../../bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
if (! $app->environment('testing') || DB::connection()->getDatabaseName() !== $database) {
|
||||
throw new LogicException('Worker não isolado.');
|
||||
}
|
||||
DB::select('select set_config(?, ?, false)', ['application_name', 'sgc_payment_'.$argv[4]]);
|
||||
$pid = DB::selectOne('select pg_backend_pid() as pid')->pid;
|
||||
$user = User::query()->findOrFail($argv[2]);
|
||||
auth()->guard()->setUser($user);
|
||||
try {
|
||||
app(ExternalPaymentWorkflow::class)->review($user, ExternalPayment::query()->findOrFail($argv[3]), 'confirmed', 'Conferência isolada de teste');
|
||||
$status = 200;
|
||||
} catch (ValidationException) {
|
||||
$status = 422;
|
||||
}
|
||||
echo json_encode(['status' => $status, 'backend_pid' => $pid], JSON_THROW_ON_ERROR);
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
use App\Models\BudgetConsumption;
|
||||
use App\Models\BudgetEntry;
|
||||
use App\Models\ExternalPayment;
|
||||
use App\Services\ExternalPaymentWorkflow;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
require __DIR__.'/../../vendor/autoload.php';
|
||||
foreach (['APP_ENV' => 'testing', 'CACHE_STORE' => 'array', 'SESSION_DRIVER' => 'array', 'QUEUE_CONNECTION' => 'sync', 'MAIL_MAILER' => 'array'] as $key => $value) {
|
||||
putenv($key.'='.$value);
|
||||
$_ENV[$key] = $_SERVER[$key] = $value;
|
||||
}
|
||||
$app = require __DIR__.'/../../bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
require __DIR__.'/../Support/PaymentFixture.php';
|
||||
$original = DB::connection();
|
||||
if ($original->getDriverName() !== 'pgsql') {
|
||||
throw new LogicException('Exige PostgreSQL.');
|
||||
}
|
||||
$config = $original->getConfig();
|
||||
$admin = $original->getPdo();
|
||||
$database = 'compras_receipts_test_'.bin2hex(random_bytes(6));
|
||||
$created = false;
|
||||
$processes = [];
|
||||
$report = ['tested_at_utc' => gmdate('c'), 'separate_database' => true, 'transport' => 'Independent PHP service transactions and PostgreSQL connections', 'scenarios' => []];
|
||||
try {
|
||||
$admin->exec('CREATE DATABASE "'.$database.'"');
|
||||
$created = true;
|
||||
config(['database.connections.pgsql' => [...$config, 'database' => $database, 'url' => null]]);
|
||||
DB::purge('pgsql');
|
||||
DB::setDefaultConnection('pgsql');
|
||||
Schema::clearResolvedInstance('db.schema');
|
||||
app()->forgetInstance('db.schema');
|
||||
if (DB::connection()->getDatabaseName() !== $database || Schema::getConnection()->getDatabaseName() !== $database) {
|
||||
throw new LogicException('Migração fora do isolamento recusada.');
|
||||
}
|
||||
Artisan::call('migrate', ['--database' => 'pgsql', '--force' => true]);
|
||||
$workerEnv = ['APP_ENV' => 'testing', 'DB_CONNECTION' => 'pgsql', 'DB_URL' => '', 'DB_DATABASE' => $database, 'DB_HOST' => $config['host'], 'DB_PORT' => (string) $config['port'], 'DB_USERNAME' => $config['username'], 'DB_PASSWORD' => $config['password'], 'CACHE_STORE' => 'array', 'SESSION_DRIVER' => 'array'];
|
||||
foreach (['over_payment', 'same_confirmation', 'complementary_payments'] as $scenario) {
|
||||
$f = paymentFixture();
|
||||
$first = app(ExternalPaymentWorkflow::class)->register($f['author'], $f['invoice'], $f['payment_data']);
|
||||
$second = $first;
|
||||
if ($scenario !== 'same_confirmation') {
|
||||
$data = $f['payment_data'];
|
||||
$data['external_reference'] = 'TEST-EXT-02';
|
||||
if ($scenario === 'complementary_payments') {
|
||||
$data['amount'] = '20.00';
|
||||
}
|
||||
$second = app(ExternalPaymentWorkflow::class)->register($f['author'], $f['invoice'], $data);
|
||||
}
|
||||
$connection = DB::connection();
|
||||
$connection->beginTransaction();
|
||||
$connection->table('tenants')->where('id', $f['tenant']->id)->lockForUpdate()->first();
|
||||
$processes = [];
|
||||
foreach ([$first, $second] as $index => $payment) {
|
||||
$process = new Process([PHP_BINARY, __DIR__.'/payment-worker.php', $database, (string) $f['validator']->id, (string) $payment->id, $index === 0 ? 'A' : 'B'], base_path(), $workerEnv);
|
||||
$process->setTimeout(40);
|
||||
$process->start();
|
||||
$processes[] = $process;
|
||||
}
|
||||
$statement = $admin->prepare("SELECT count(*) FROM pg_stat_activity WHERE datname = ? AND application_name IN ('sgc_payment_A','sgc_payment_B') AND wait_event_type = 'Lock'");
|
||||
$deadline = microtime(true) + 20;
|
||||
do {
|
||||
$statement->execute([$database]);
|
||||
$waits = (int) $statement->fetchColumn();
|
||||
if ($waits === 2) {
|
||||
break;
|
||||
}
|
||||
foreach ($processes as $process) {
|
||||
if (! $process->isRunning()) {
|
||||
throw new RuntimeException('Worker terminou antes do bloqueio: '.$process->getErrorOutput());
|
||||
}
|
||||
}
|
||||
usleep(20000);
|
||||
} while (microtime(true) < $deadline);
|
||||
$connection->commit();
|
||||
if ($waits !== 2) {
|
||||
throw new RuntimeException('Sem sobreposição de bloqueios.');
|
||||
}
|
||||
$results = [];
|
||||
foreach ($processes as $process) {
|
||||
$process->wait();
|
||||
if (! $process->isSuccessful()) {
|
||||
throw new RuntimeException('Falha do worker: '.$process->getErrorOutput());
|
||||
}
|
||||
$results[] = json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
$statuses = array_column($results, 'status');
|
||||
sort($statuses);
|
||||
$consumed = (int) BudgetConsumption::query()->sum('amount_minor');
|
||||
$committed = (int) BudgetEntry::query()->sum('committed_minor');
|
||||
$confirmed = (int) ExternalPayment::query()->where('status', 'confirmed')->sum('amount_minor');
|
||||
$count = $scenario === 'complementary_payments' ? 2 : 1;
|
||||
$expected = $scenario === 'complementary_payments' ? 6000 : 4000;
|
||||
if ($statuses !== ($scenario === 'over_payment' ? [200, 422] : [200, 200]) || $confirmed !== $expected || $consumed !== 6000 || $committed !== 4000 || ExternalPayment::query()->where('status', 'confirmed')->count() !== $count || count(array_unique(array_column($results, 'backend_pid'))) !== 2) {
|
||||
throw new RuntimeException('Resultado concorrente de pagamentos inesperado.');
|
||||
}
|
||||
$report['scenarios'][] = ['name' => $scenario, 'passed' => true, 'overlapping_lock_waits' => $waits, 'confirmed_minor' => $confirmed, 'outstanding_minor' => 6000 - $confirmed, 'budget_consumption_unchanged' => true];
|
||||
}
|
||||
$report['passed'] = true;
|
||||
} finally {
|
||||
foreach ($processes as $process) {
|
||||
if ($process->isRunning()) {
|
||||
$process->stop();
|
||||
}
|
||||
}
|
||||
if (isset($connection) && $connection->transactionLevel() > 0) {
|
||||
$connection->rollBack();
|
||||
}
|
||||
DB::disconnect('pgsql');
|
||||
if ($created && $database !== $original->getDatabaseName() && preg_match('/^compras_receipts_test_[a-f0-9]{12}$/', $database)) {
|
||||
$admin->exec('DROP DATABASE "'.$database.'"');
|
||||
$report['test_database_dropped'] = true;
|
||||
}
|
||||
}
|
||||
file_put_contents(base_path('docs/pagamentos-concorrencia-postgresql.json'), json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL);
|
||||
echo json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL;
|
||||
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PurchasingRole;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\BudgetConsumption;
|
||||
use App\Models\BudgetEntry;
|
||||
use App\Models\ExternalPayment;
|
||||
use App\Models\User;
|
||||
use App\Services\ExternalPaymentWorkflow;
|
||||
use App\Services\RequisitionWorkflow;
|
||||
use App\Services\SupplierBankWorkflow;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
require_once __DIR__.'/../Support/PaymentFixture.php';
|
||||
|
||||
beforeEach(function () {
|
||||
$this->f = paymentFixture();
|
||||
$this->data = $this->f['payment_data'];
|
||||
$this->actingAs($this->f['author']);
|
||||
});
|
||||
|
||||
function registerTestPayment(object $test): ExternalPayment
|
||||
{
|
||||
$test->actingAs($test->f['author']);
|
||||
|
||||
return app(ExternalPaymentWorkflow::class)->register($test->f['author'], $test->f['invoice'], $test->data);
|
||||
}
|
||||
|
||||
function confirmTestPayment(object $test, ExternalPayment $payment, string $decision = 'confirmed'): void
|
||||
{
|
||||
$test->actingAs($test->f['validator']);
|
||||
app(ExternalPaymentWorkflow::class)->review($test->f['validator'], $payment, $decision, 'Conferência independente de teste');
|
||||
}
|
||||
|
||||
test('external payment reduces invoice debt only after independent confirmation without consuming budget twice', function () {
|
||||
$before = (int) BudgetConsumption::query()->sum('amount_minor');
|
||||
$commitment = (int) BudgetEntry::query()->sum('committed_minor');
|
||||
$payment = registerTestPayment($this);
|
||||
expect(app(ExternalPaymentWorkflow::class)->outstanding($this->f['invoice']))->toBe(6000);
|
||||
confirmTestPayment($this, $payment);
|
||||
confirmTestPayment($this, $payment);
|
||||
expect(app(ExternalPaymentWorkflow::class)->outstanding($this->f['invoice']))->toBe(2000)
|
||||
->and((int) BudgetConsumption::query()->sum('amount_minor'))->toBe($before)
|
||||
->and((int) BudgetEntry::query()->sum('committed_minor'))->toBe($commitment)
|
||||
->and(ExternalPayment::query()->where('status', 'confirmed')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('external reference retries are idempotent but altered data are refused', function () {
|
||||
$first = registerTestPayment($this);
|
||||
$this->data['external_reference'] = ' test-ext-01 ';
|
||||
expect(registerTestPayment($this)->id)->toBe($first->id);
|
||||
$this->data['amount'] = '30.00';
|
||||
expect(fn () => registerTestPayment($this))->toThrow(ValidationException::class);
|
||||
expect(ExternalPayment::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('payment author cannot confirm own record', function () {
|
||||
$payment = registerTestPayment($this);
|
||||
$this->post('/pagamentos/'.$payment->id.'/conferir', ['decision' => 'confirmed', 'reason' => 'Teste', 'independently_verified' => true])->assertForbidden();
|
||||
});
|
||||
|
||||
test('invalid dates amounts and bank versions cannot register a payment', function (string $case) {
|
||||
match ($case) {
|
||||
'over' => $this->data['amount'] = '60.01',
|
||||
'zero' => $this->data['amount'] = '0.00',
|
||||
'negative' => $this->data['amount'] = '-1.00',
|
||||
'future' => $this->data['paid_on'] = today()->addDay()->format('Y-m-d'),
|
||||
'before_invoice' => $this->data['paid_on'] = today()->subDay()->format('Y-m-d'),
|
||||
'old_year' => $this->data['paid_on'] = today()->subYear()->format('Y-m-d'),
|
||||
'bank' => $this->data['supplier_bank_detail_id'] = PHP_INT_MAX,
|
||||
'reference' => $this->data['external_reference'] = ' ',
|
||||
'evidence' => $this->data['evidence_reference'] = ' ',
|
||||
};
|
||||
expect(fn () => registerTestPayment($this))->toThrow(ValidationException::class);
|
||||
expect(ExternalPayment::query()->count())->toBe(0);
|
||||
})->with(['over', 'zero', 'negative', 'future', 'before_invoice', 'old_year', 'bank', 'reference', 'evidence']);
|
||||
|
||||
test('competing pending payments cannot overpay an invoice when confirmed', function () {
|
||||
$first = registerTestPayment($this);
|
||||
$this->data['external_reference'] = 'TEST-EXT-02';
|
||||
$second = registerTestPayment($this);
|
||||
confirmTestPayment($this, $first);
|
||||
expect(fn () => confirmTestPayment($this, $second))->toThrow(ValidationException::class);
|
||||
expect($second->refresh()->status)->toBe('pending')
|
||||
->and(app(ExternalPaymentWorkflow::class)->outstanding($this->f['invoice']))->toBe(2000);
|
||||
});
|
||||
|
||||
test('complementary payments settle invoice exactly', function () {
|
||||
confirmTestPayment($this, registerTestPayment($this));
|
||||
$this->data['external_reference'] = 'TEST-EXT-02';
|
||||
$this->data['amount'] = '20.00';
|
||||
confirmTestPayment($this, registerTestPayment($this));
|
||||
expect(app(ExternalPaymentWorkflow::class)->outstanding($this->f['invoice']))->toBe(0);
|
||||
});
|
||||
|
||||
test('bank revision changed after registration blocks confirmation and preserves encrypted snapshot', function () {
|
||||
$payment = registerTestPayment($this);
|
||||
$this->actingAs($this->f['issuer']);
|
||||
app(SupplierBankWorkflow::class)->propose($this->f['issuer'], $this->f['supplier'], [...$this->f['bank_data'], 'previous_id' => $this->f['bank']->id, 'account_identifier' => 'TEST-NEW-ACCOUNT']);
|
||||
expect(fn () => confirmTestPayment($this, $payment))->toThrow(ValidationException::class);
|
||||
expect($payment->refresh()->bank_snapshot['account_identifier'])->toBe('TEST-PAY-ACCOUNT');
|
||||
});
|
||||
|
||||
test('loss of reviewer eligibility or supplier eligibility blocks confirmation', function (string $case) {
|
||||
$payment = registerTestPayment($this);
|
||||
if ($case === 'reviewer') {
|
||||
$this->f['author']->forceFill(['purchasing_role' => PurchasingRole::Warehouse])->save();
|
||||
} else {
|
||||
$this->f['supplier']->update(['status' => 'suspended']);
|
||||
}
|
||||
expect(fn () => confirmTestPayment($this, $payment))->toThrow(ValidationException::class);
|
||||
})->with(['reviewer', 'supplier']);
|
||||
|
||||
test('rejected evidence leaves invoice outstanding and decision cannot be changed', function () {
|
||||
$payment = registerTestPayment($this);
|
||||
confirmTestPayment($this, $payment, 'rejected');
|
||||
expect(app(ExternalPaymentWorkflow::class)->outstanding($this->f['invoice']))->toBe(6000);
|
||||
expect(fn () => confirmTestPayment($this, $payment))->toThrow(ValidationException::class);
|
||||
});
|
||||
|
||||
test('payment audit failure rolls back registration and confirmation', function () {
|
||||
$payment = registerTestPayment($this);
|
||||
$this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->andThrow(new RuntimeException('audit failed'));
|
||||
expect(fn () => confirmTestPayment($this, $payment))->toThrow(RuntimeException::class);
|
||||
expect($payment->refresh()->status)->toBe('pending');
|
||||
$this->data['external_reference'] = 'TEST-EXT-02';
|
||||
expect(fn () => registerTestPayment($this))->toThrow(RuntimeException::class);
|
||||
expect(ExternalPayment::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('payment references and bank snapshot are encrypted hidden and absent from audit', function () {
|
||||
$payment = registerTestPayment($this);
|
||||
expect(DB::table('external_payments')->where('id', $payment->id)->value('external_reference'))->not->toBe($this->data['external_reference']);
|
||||
expect($payment->toArray())->not->toHaveKey('bank_snapshot')->not->toHaveKey('external_reference');
|
||||
$audit = json_encode(AuditLog::query()->get()->toArray());
|
||||
expect($audit)->not->toContain('TEST-PAY-ACCOUNT')->not->toContain('TEST-EXT-01');
|
||||
expect(fn () => $payment->update(['amount_minor' => 1]))->toThrow(LogicException::class);
|
||||
expect(fn () => $payment->refresh()->delete())->toThrow(LogicException::class);
|
||||
});
|
||||
|
||||
test('foreign finance cannot inspect or confirm payment', function () {
|
||||
$payment = registerTestPayment($this);
|
||||
$other = receiptFixture();
|
||||
$user = User::factory()->create(['tenant_id' => $other['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]);
|
||||
$this->actingAs($user)->post('/pagamentos/'.$payment->id.'/conferir', ['decision' => 'confirmed', 'reason' => 'Teste', 'independently_verified' => true])->assertNotFound();
|
||||
$this->get('/pagamentos')->assertOk()->assertInertia(fn ($page) => $page->has('payments.data', 0));
|
||||
});
|
||||
|
||||
test('non finance roles cannot register payments', function (PurchasingRole $role) {
|
||||
$user = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'purchasing_role' => $role]);
|
||||
$this->actingAs($user)->post('/facturas/'.$this->f['invoice']->id.'/pagamentos', $this->data)->assertForbidden();
|
||||
})->with([PurchasingRole::Admin, PurchasingRole::Warehouse, PurchasingRole::Procurement, PurchasingRole::Requester]);
|
||||
|
||||
test('administration sees payment metadata without external evidence or banking snapshots', function () {
|
||||
registerTestPayment($this);
|
||||
$admin = User::factory()->create(['tenant_id' => $this->f['tenant']->id, 'purchasing_role' => PurchasingRole::Admin]);
|
||||
$this->actingAs($admin)->get('/pagamentos')->assertOk()->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn ($page) => $page->where('payments.data.0.external_reference', null)->where('payments.data.0.evidence_reference', null)->missing('payments.data.0.bank_snapshot'));
|
||||
});
|
||||
|
||||
test('invalid submission does not flash external references', function () {
|
||||
$this->post('/facturas/'.$this->f['invoice']->id.'/pagamentos', [...$this->data, 'paid_on' => 'invalid'])->assertSessionHasErrors('paid_on');
|
||||
expect(session()->getOldInput('external_reference'))->toBeNull()->and(session()->getOldInput('evidence_reference'))->toBeNull();
|
||||
});
|
||||
|
||||
test('payment review requires explicit independent verification', function () {
|
||||
$payment = registerTestPayment($this);
|
||||
$this->actingAs($this->f['validator'])->post('/pagamentos/'.$payment->id.'/conferir', ['decision' => 'confirmed', 'reason' => 'Teste'])->assertSessionHasErrors('independently_verified');
|
||||
expect($payment->refresh()->status)->toBe('pending');
|
||||
});
|
||||
|
||||
test('conflict declared after payment registration blocks confirmation', function () {
|
||||
$payment = registerTestPayment($this);
|
||||
$this->post('/conflitos', ['scope' => 'supplier', 'supplier_id' => $this->f['supplier']->id, 'has_conflict' => true, 'known_since' => today()->format('Y-m-d'), 'reason' => 'Conflito fictício posterior ao registo externo.'])->assertSessionHasNoErrors();
|
||||
expect(fn () => confirmTestPayment($this, $payment))->toThrow(ValidationException::class);
|
||||
expect($payment->refresh()->status)->toBe('pending');
|
||||
});
|
||||
|
||||
test('bank account in another currency does not imply permission to convert money', function () {
|
||||
$this->actingAs($this->f['issuer']);
|
||||
$bank = app(SupplierBankWorkflow::class)->propose($this->f['issuer'], $this->f['supplier'], [...$this->f['bank_data'], 'previous_id' => $this->f['bank']->id, 'currency' => 'USD']);
|
||||
foreach ([$this->f['author'], $this->f['validator']] as $user) {
|
||||
$this->actingAs($user);
|
||||
app(SupplierBankWorkflow::class)->review($user, $bank, 'approved', 'Conferência de teste');
|
||||
}
|
||||
$this->data['supplier_bank_detail_id'] = $bank->id;
|
||||
expect(fn () => registerTestPayment($this))->toThrow(ValidationException::class);
|
||||
});
|
||||
|
||||
test('browser cannot forge a confirmed payment or its encrypted banking snapshot', function () {
|
||||
$this->post('/facturas/'.$this->f['invoice']->id.'/pagamentos', [...$this->data, 'status' => 'confirmed', 'reviewed_by' => $this->f['author']->id, 'tenant_id' => PHP_INT_MAX, 'bank_snapshot' => ['account_identifier' => 'FORGED']])->assertRedirect('/pagamentos');
|
||||
$payment = ExternalPayment::query()->firstOrFail();
|
||||
expect($payment->status)->toBe('pending')->and($payment->tenant_id)->toBe($this->f['tenant']->id)
|
||||
->and($payment->bank_snapshot['account_identifier'])->toBe('TEST-PAY-ACCOUNT');
|
||||
});
|
||||
|
||||
test('public payment example exposes no company payment data', function () {
|
||||
auth()->forgetGuards();
|
||||
$this->get('/consulta/pagamentos')->assertOk()->assertInertia(fn ($page) => $page->where('initialScreen', 'payments')->where('auth.user', null)->missing('payments')->missing('invoices'));
|
||||
});
|
||||
|
||||
test('financial role is reloaded after the company lock rather than trusting a stale session user', function () {
|
||||
DB::table('users')->where('id', $this->f['author']->id)->update(['purchasing_role' => 'warehouse']);
|
||||
$this->post('/facturas/'.$this->f['invoice']->id.'/pagamentos', $this->data)->assertForbidden();
|
||||
expect(ExternalPayment::query()->count())->toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Supplier;
|
||||
use App\Services\InvoiceWorkflow;
|
||||
use App\Services\SupplierBankWorkflow;
|
||||
|
||||
require_once __DIR__.'/InvoiceFixture.php';
|
||||
|
||||
/** invoiceFixture enforces a synthetic, isolated test database. */
|
||||
function paymentFixture(): array
|
||||
{
|
||||
$f = invoiceFixture();
|
||||
auth()->guard()->setUser($f['validator']);
|
||||
app(InvoiceWorkflow::class)->validate($f['validator'], $f['invoice']);
|
||||
$supplier = Supplier::query()->findOrFail($f['order']->supplier_id);
|
||||
auth()->guard()->setUser($f['issuer']);
|
||||
$bankData = ['previous_id' => 0, 'bank_name' => 'Banco fictício', 'country' => 'MZ', 'currency' => 'MZN', 'beneficiary' => 'Titular de teste', 'account_identifier' => 'TEST-PAY-ACCOUNT', 'evidence_reference' => 'Evidência de teste', 'reason' => 'Somente teste isolado'];
|
||||
$bank = app(SupplierBankWorkflow::class)->propose($f['issuer'], $supplier, $bankData);
|
||||
foreach ([$f['author'], $f['validator']] as $reviewer) {
|
||||
auth()->guard()->setUser($reviewer);
|
||||
app(SupplierBankWorkflow::class)->review($reviewer, $bank, 'approved', 'Conferência de teste');
|
||||
}
|
||||
auth()->guard()->setUser($f['author']);
|
||||
$paymentData = ['supplier_bank_detail_id' => $bank->id, 'amount' => '40.00', 'paid_on' => today()->format('Y-m-d'), 'external_reference' => 'TEST-EXT-01', 'evidence_reference' => 'Evidência externa fictícia'];
|
||||
|
||||
return [...$f, 'supplier' => $supplier, 'bank' => $bank, 'bank_data' => $bankData, 'payment_data' => $paymentData];
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
// Explicit operational entry point: backup, restore verification, then only migration 000012.
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
require __DIR__.'/../../vendor/autoload.php';
|
||||
$app = require __DIR__.'/../../bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
$connection = DB::connection();
|
||||
if ($connection->getDriverName() !== 'pgsql' || $connection->getDatabaseName() !== 'compras_dev' || ! $app->environment('local')) {
|
||||
throw new LogicException('Aplicação recusada fora de compras_dev/local.');
|
||||
}
|
||||
$pdo = $connection->getPdo();
|
||||
$migration = '2026_10_28_000012_create_external_payments';
|
||||
if ($pdo->query("SELECT count(*) FROM migrations WHERE migration = '$migration'")->fetchColumn() > 0) {
|
||||
throw new LogicException('Migração já aplicada; não repetir.');
|
||||
}
|
||||
$config = $connection->getConfig();
|
||||
$bin = 'C:/Program Files/PostgreSQL/17/bin/';
|
||||
foreach (['pg_dump.exe', 'pg_restore.exe'] as $tool) {
|
||||
if (! is_file($bin.$tool)) {
|
||||
throw new LogicException('Ferramentas PostgreSQL 17 ausentes.');
|
||||
}
|
||||
}
|
||||
$envHash = hash_file('sha256', base_path('.env'));
|
||||
$directory = storage_path('app/private/backups/payment-000012-'.gmdate('Ymd-His').'-'.bin2hex(random_bytes(3)));
|
||||
if (! mkdir($directory, 0700, true)) {
|
||||
throw new RuntimeException('Não foi possível criar backup privado.');
|
||||
}
|
||||
$env = ['PGHOST' => $config['host'], 'PGPORT' => (string) $config['port'], 'PGUSER' => $config['username'], 'PGPASSWORD' => $config['password'], 'PGSSLMODE' => $config['sslmode'] ?? 'prefer'];
|
||||
$quote = fn (string $name) => '"'.str_replace('"', '""', $name).'"';
|
||||
$fingerprints = function (PDO $db, array $tables) use ($quote): array {
|
||||
$result = [];
|
||||
foreach ($tables as $table) {
|
||||
$rows = $db->query('SELECT md5(row_to_json(t)::text) FROM public.'.$quote($table).' t')->fetchAll(PDO::FETCH_COLUMN);
|
||||
sort($rows, SORT_STRING);
|
||||
$result[$table] = ['count' => count($rows), 'sha256' => hash('sha256', implode('\n', $rows))];
|
||||
}
|
||||
|
||||
return $result;
|
||||
};
|
||||
$tables = $pdo->query("SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename")->fetchAll(PDO::FETCH_COLUMN);
|
||||
$restoreDatabase = 'compras_restore_check_'.bin2hex(random_bytes(6));
|
||||
$restoreCreated = false;
|
||||
try {
|
||||
// pg_dump and the manifest read exactly the same committed snapshot.
|
||||
$pdo->beginTransaction();
|
||||
$pdo->exec('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
|
||||
$snapshot = $pdo->query('SELECT pg_export_snapshot()')->fetchColumn();
|
||||
$backupState = $fingerprints($pdo, $tables);
|
||||
$dump = new Process([$bin.'pg_dump.exe', '--format=custom', '--no-owner', '--no-acl', '--snapshot='.$snapshot, '--file='.$directory.'/database.dump', '--dbname='.$config['database']], base_path(), $env);
|
||||
$dump->setTimeout(120);
|
||||
$dump->run();
|
||||
if (! $dump->isSuccessful()) {
|
||||
throw new RuntimeException('Falha no backup PostgreSQL; migração não executada.');
|
||||
}
|
||||
$pdo->commit();
|
||||
$pdo->exec('CREATE DATABASE '.$quote($restoreDatabase));
|
||||
$restoreCreated = true;
|
||||
$restore = new Process([$bin.'pg_restore.exe', '--exit-on-error', '--no-owner', '--no-acl', '--dbname='.$restoreDatabase, $directory.'/database.dump'], base_path(), $env);
|
||||
$restore->setTimeout(120);
|
||||
$restore->run();
|
||||
if (! $restore->isSuccessful()) {
|
||||
throw new RuntimeException('Restauro de verificação falhou; migração não executada.');
|
||||
}
|
||||
config(['database.connections.budget_restore' => [...$config, 'url' => null, 'database' => $restoreDatabase]]);
|
||||
$restored = DB::connection('budget_restore')->getPdo();
|
||||
if ($fingerprints($restored, $tables) !== $backupState) {
|
||||
throw new RuntimeException('Restauro não corresponde ao backup; migração recusada.');
|
||||
}
|
||||
file_put_contents($directory.'/manifest.json', json_encode(['tables' => $backupState, 'dump_sha256' => hash_file('sha256', $directory.'/database.dump')], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||
file_put_contents($directory.'/RECUPERACAO.txt', "Backup custom PostgreSQL 17 verificado por restauro numa base separada.\nUsar pg_restore --exit-on-error --no-owner --no-acl --dbname=NOVA_BASE database.dump.\nAs credenciais pertencem ao ambiente local e não estão neste manifesto.\nNão restaurar por cima da base existente.\n");
|
||||
DB::disconnect('budget_restore');
|
||||
unset($restored);
|
||||
$pdo->exec('DROP DATABASE '.$quote($restoreDatabase));
|
||||
$restoreCreated = false;
|
||||
$businessTables = array_values(array_diff($tables, ['migrations', 'sessions', 'cache', 'cache_locks', 'jobs', 'failed_jobs', 'job_batches']));
|
||||
$before = $fingerprints($pdo, $businessTables);
|
||||
$oldMigrations = $pdo->query('SELECT id, migration, batch FROM migrations ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
|
||||
$constraints = $pdo->query("SELECT conname, pg_get_constraintdef(oid) AS definition FROM pg_constraint WHERE connamespace = 'public'::regnamespace ORDER BY conname, definition")->fetchAll(PDO::FETCH_ASSOC);
|
||||
$exit = Artisan::call('migrate', ['--path' => 'database/migrations/'.$migration.'.php', '--force' => true]);
|
||||
if ($exit !== 0) {
|
||||
throw new RuntimeException('Migração falhou; consultar backup privado.');
|
||||
}
|
||||
$after = $fingerprints($pdo, $businessTables);
|
||||
$newMigrations = $pdo->query('SELECT id, migration, batch FROM migrations ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
|
||||
$newConstraints = $pdo->query("SELECT conname, pg_get_constraintdef(oid) AS definition FROM pg_constraint WHERE connamespace = 'public'::regnamespace ORDER BY conname, definition")->fetchAll(PDO::FETCH_ASSOC);
|
||||
if ($before !== $after || array_slice($newMigrations, 0, count($oldMigrations)) !== $oldMigrations || count($newMigrations) !== count($oldMigrations) + 1 || array_diff(array_map('serialize', $constraints), array_map('serialize', $newConstraints)) !== [] || hash_file('sha256', base_path('.env')) !== $envHash) {
|
||||
throw new RuntimeException('Verificação de preservação falhou; não executar mais escritas.');
|
||||
}
|
||||
$report = ['verified_at_utc' => gmdate('c'), 'migration' => $migration, 'batch' => (int) end($newMigrations)['batch'], 'backup_created' => true, 'backup_bytes' => filesize($directory.'/database.dump'), 'restore_verified' => true, 'restored_tables' => count($tables), 'restore_database_dropped' => true, 'business_records_unchanged' => true, 'existing_constraints_preserved' => true, 'env_unchanged' => true, 'external_payments_count' => (int) $pdo->query('SELECT count(*) FROM external_payments')->fetchColumn(), 'operational_validation' => false];
|
||||
file_put_contents(base_path('docs/pagamentos-migracao-000012.json'), json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL);
|
||||
echo json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL;
|
||||
} finally {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
DB::disconnect('budget_restore');
|
||||
if ($restoreCreated && preg_match('/^compras_restore_check_[a-f0-9]{12}$/', $restoreDatabase) && $restoreDatabase !== $connection->getDatabaseName()) {
|
||||
$pdo->exec('DROP DATABASE '.$quote($restoreDatabase));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user