diff --git a/README.md b/README.md
index f3ae4a6..51649ad 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/app/Http/Controllers/ExternalPaymentController.php b/app/Http/Controllers/ExternalPaymentController.php
new file mode 100644
index 0000000..6a3e4a8
--- /dev/null
+++ b/app/Http/Controllers/ExternalPaymentController.php
@@ -0,0 +1,57 @@
+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();
+ }
+}
diff --git a/app/Models/ExternalPayment.php b/app/Models/ExternalPayment.php
new file mode 100644
index 0000000..e295541
--- /dev/null
+++ b/app/Models/ExternalPayment.php
@@ -0,0 +1,42 @@
+ $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.'));
+ }
+}
diff --git a/app/Services/ConflictGuard.php b/app/Services/ConflictGuard.php
index 71931c5..1badcef 100644
--- a/app/Services/ConflictGuard.php
+++ b/app/Services/ConflictGuard.php
@@ -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 = [];
diff --git a/app/Services/ExternalPaymentWorkflow.php b/app/Services/ExternalPaymentWorkflow.php
new file mode 100644
index 0000000..d96ca88
--- /dev/null
+++ b/app/Services/ExternalPaymentWorkflow.php
@@ -0,0 +1,136 @@
+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 $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]);
+ }
+}
diff --git a/bootstrap/app.php b/bootstrap/app.php
index e6ddabf..2bfa639 100644
--- a/bootstrap/app.php
+++ b/bootstrap/app.php
@@ -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(),
);
diff --git a/database/migrations/2026_10_28_000012_create_external_payments.php b/database/migrations/2026_10_28_000012_create_external_payments.php
new file mode 100644
index 0000000..8766f0d
--- /dev/null
+++ b/database/migrations/2026_10_28_000012_create_external_payments.php
@@ -0,0 +1,38 @@
+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.');
+ }
+};
diff --git a/docs/compatibilidade.md b/docs/compatibilidade.md
index 2b354fe..90fa248 100644
--- a/docs/compatibilidade.md
+++ b/docs/compatibilidade.md
@@ -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.
diff --git a/docs/desenvolvimento.md b/docs/desenvolvimento.md
index 841ad97..adbc17b 100644
--- a/docs/desenvolvimento.md
+++ b/docs/desenvolvimento.md
@@ -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.
diff --git a/docs/orientacoes-continuidade.md b/docs/orientacoes-continuidade.md
index 2f0d7f5..0667ae2 100644
--- a/docs/orientacoes-continuidade.md
+++ b/docs/orientacoes-continuidade.md
@@ -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
diff --git a/docs/pagamentos-concorrencia-postgresql.json b/docs/pagamentos-concorrencia-postgresql.json
new file mode 100644
index 0000000..f156974
--- /dev/null
+++ b/docs/pagamentos-concorrencia-postgresql.json
@@ -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
+}
diff --git a/docs/pagamentos-migracao-000012.json b/docs/pagamentos-migracao-000012.json
new file mode 100644
index 0000000..795a222
--- /dev/null
+++ b/docs/pagamentos-migracao-000012.json
@@ -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
+}
diff --git a/docs/proposta-regras-financeiras.md b/docs/proposta-regras-financeiras.md
index 357ed90..32e76c2 100644
--- a/docs/proposta-regras-financeiras.md
+++ b/docs/proposta-regras-financeiras.md
@@ -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
diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx
index 7db9c7f..6e206cb 100644
--- a/resources/js/components/app-sidebar.tsx
+++ b/resources/js/components/app-sidebar.tsx
@@ -141,6 +141,11 @@ export function AppSidebar() {
href: '/facturas',
icon: FileCheck2,
},
+ {
+ title: 'Pagamentos externos',
+ href: '/pagamentos',
+ icon: FileCheck2,
+ },
]}
/>
)}
diff --git a/resources/js/lib/preview-navigation.ts b/resources/js/lib/preview-navigation.ts
index 61d71b8..81ac3cd 100644
--- a/resources/js/lib/preview-navigation.ts
+++ b/resources/js/lib/preview-navigation.ts
@@ -11,5 +11,6 @@ export const previewPaths: Record = {
equipment: '/consulta/equipamentos',
invoices: '/consulta/facturas',
bank: '/consulta/dados-bancarios',
+ payments: '/consulta/pagamentos',
appearance: '/consulta/aparencia',
};
diff --git a/resources/js/pages/budgets/index.tsx b/resources/js/pages/budgets/index.tsx
index 6c819e7..5c829b3 100644
--- a/resources/js/pages/budgets/index.tsx
+++ b/resources/js/pages/budgets/index.tsx
@@ -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.
+ 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.
+
+ Factura fictícia de 1.000,00 MZN. Um
+ pagamento externo de 600,00 MZN é
+ registado com referência e evidência.
+
+
+ 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.
+
+
+ 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.
+
+
+ Os valores são apenas exemplos. Cada
+ empresa tem as suas facturas e
+ montantes; o SGC não efectua a operação
+ no banco.
+