Implementa registo e conferencia de pagamentos externos

This commit is contained in:
2026-10-28 14:51:53 +02:00
parent f0f7b8ce9d
commit c41628d738
25 changed files with 1279 additions and 10 deletions
+8 -2
View File
@@ -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();
}
}
+42
View File
@@ -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.'));
}
}
+1
View File
@@ -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 = [];
+136
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+72
View File
@@ -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.
+10
View File
@@ -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
}
+15
View File
@@ -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
}
+3 -1
View File
@@ -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
+5
View File
@@ -141,6 +141,11 @@ export function AppSidebar() {
href: '/facturas',
icon: FileCheck2,
},
{
title: 'Pagamentos externos',
href: '/pagamentos',
icon: FileCheck2,
},
]}
/>
)}
+1
View File
@@ -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',
};
+3 -2
View File
@@ -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"
+312
View File
@@ -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>
</>
);
}
+34 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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');
+30
View File
@@ -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);
+122
View File
@@ -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;
+206
View File
@@ -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);
});
+27
View File
@@ -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];
}
+105
View File
@@ -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));
}
}