diff --git a/app/Modules/Purchasing/SupplierPayables.php b/app/Modules/Purchasing/SupplierPayables.php index 0a4a379..75928e6 100644 --- a/app/Modules/Purchasing/SupplierPayables.php +++ b/app/Modules/Purchasing/SupplierPayables.php @@ -66,6 +66,7 @@ final class SupplierPayables { $d = $this->engine($u)->listing(DB::table('pa_imported_sources')->where($this->scope($u))->pluck('id')->all()); $d['permissions'] = ['prepare' => $this->can($u, 'prepare'), 'validate' => $this->can($u, 'validate')]; + $d['payments_available'] = app()->environment('payable-testing') && DB::connection()->getSchemaBuilder()->hasTable('ps_payments') && $this->can($u, 'payments_view'); return $d; } diff --git a/app/Modules/Purchasing/SupplierPaymentsController.php b/app/Modules/Purchasing/SupplierPaymentsController.php new file mode 100644 index 0000000..3c72c17 --- /dev/null +++ b/app/Modules/Purchasing/SupplierPaymentsController.php @@ -0,0 +1,12 @@ + e SGF . Seleccionar preparação, validação ou consulta; reconhecer primeiro a obrigação existente, com outra identidade, e seguir a ligação «Pagamentos e afectações». Não é uma autorização de utilização real nem aceitação da entrega. + +## Responsabilidade e código + +O código comum reside em `packages/payables/src/SupplierSettlements.php`, `SettlementSchema.php`, `SettlementHttp.php` e `ui/settlements.php`. Carregamento e apresentação em `bootstrap.php` e `src/Workspace.php`. `src/Payables.php` conserva a responsabilidade única pelo saldo: principal menos histórico confirmado existente e afectações novas confirmadas. Não se escrevem pagamentos externos legados para representar novamente o mesmo facto. + +Os adaptadores são `app/Modules/Purchasing/SupplierPaymentsController.php` no SGF e `app/Http/Controllers/SupplierPaymentsController.php` em Compras, apoiados nos respectivos `SupplierPayables.php`. Contexto, pessoa, organização e direitos são revalidados no servidor pelos adaptadores existentes; o motor recebe contexto explícito. Rotas em `routes/web.php` de cada aplicação. A página anterior de obrigações apresenta a ligação quando disponível. No SGF, `resources/js/WorkspaceNavigation.jsx` e `app.jsx` incluem a entrada; nenhuma concessão real foi adicionada. + +Não se equiparam tenant, empresa e unidade. Referências factuais são únicas dentro da empresa, mesmo entre unidades; a consulta e execução continuam limitadas pelo contexto autorizado. Não há dependência de vendas ou de um SGF remoto. + +## Percurso e montantes + +1. A obrigação reconhecida mantém 10 000 MZN de base, imposto documental de 1 600 e principal de **11 600 MZN**, sem recálculo fiscal. +2. Preparação propõe um pagamento sintético de **4 000 MZN** com data factual e referências artificiais de facto/evidência `SIM-…`. +3. Outra pessoa confirma com motivo e referência de evidência. O saldo da obrigação continua **11 600 MZN**; o pagamento confirmado tem **4 000 MZN por afectar**. +4. Preparação propõe a afectação de 4 000 MZN à obrigação; outra pessoa confirma. Saldo **7 600 MZN**, por afectar zero. Origem, versão, decisões e histórico permanecem consultáveis. + +O vencimento continua desconhecido, atraso indeterminado e dias de atraso sem valor. A data factual do pagamento não preenche o vencimento. Montantes são cadeias decimais convertidas exactamente em cêntimos inteiros, sem floats ou arredondamento novo. Não houve substituição ou actualização de dependências. + +Pagamento proposto, confirmação e afectação são acontecimentos distintos. Rejeições não alteram dívida. Há afectações parciais, vários pagamentos por obrigação e um pagamento distribuído por vários documentos compatíveis. Excesso fica por afectar. Referências estáveis e conteúdo normalizado impedem duplicações por novas chaves, versões ou pedidos simultâneos; incompatibilidades geram conflito. Comandos, decisões, movimentos, auditoria e eventos/outbox são atómicos e duráveis. A revogação e a separação por actor e pessoa são verificadas também após espera pelo bloqueio. + +## Provas e alcance + +- `docs/proofs/supplier-settlements.json`: ambas as composições aprovadas, mesmo SHA-256 comum `156bd40f3080de66a73c0befd975990e9d7010c2a232a86d751d4db3af4fae93`. Factura realmente conferida pelo percurso existente de Compras; sequência 11 600 → confirmação sem abatimento → afectação → 7 600, com vencimento/dias nulos. +- Repetição, referências/evidências duplicadas, conteúdo incompatível, insuficiência, rejeição, identidade/pessoa, isolamento, versão alterada, informação insuficiente, revogação, imutabilidade, falha injectada e recuperação aprovados. Doze corridas PostgreSQL entre processos, com esperas de bloqueio observadas; apenas um efeito por facto. +- A prova multidocumento usa um adaptador de origem sintético explícito do mesmo núcleo: dois documentos liquidados, terceiro de outro fornecedor intacto, excesso de 400 MZN por afectar e duplicação entre unidades recusada. Não equivale a outro percurso completo de facturas na interface de cada host. +- Regressões anteriores de obrigação e histórico aprovadas (`payables-http.json`, `payables-history.json`). Compras: **526 testes aprovados, 2 ignorados, 4 383 asserções**, SQLite em memória. Ignorados preexistentes: `users with two factor enabled are redirected to two factor challenge` e `security page is displayed`. Cobertura MFA permanece pendente; não são testes do efeito monetário, comprovado separadamente. +- Builds de SGF e Compras, TypeScript de Compras, PHPStan da aplicação e das novas classes comuns e Pint aprovados. JavaScript da interface conferido sintacticamente. +- `supplier-settlements-demo-access.json`: páginas/API e ligação das obrigações verificadas por HTTP/HTML nas três funções de cada demonstração. Sem comandos monetários nessas bases. **Não foi realizada revisão visual nem ensaio manual da equipa.** A prova do adaptador não substitui essa revisão. + +Automação: `scripts/payables-demo.ps1 proof -Settlements`, `settlements-http-proof.mjs`, `settlements-probe.php`. Bases e identidades de prova descartáveis, separadas das demonstrações e bases habituais. A revisão do navegador continua pendente; não se contornou o bloqueio da ferramenta. + +## Habilitação e preservação + +`scripts/settlements-install.php` instala nove tabelas `ps_*`, com restrições e triggers imutáveis, apenas após guarda efectiva de base QA local. Em cada demonstração foram adicionados sete direitos sintéticos às três identidades existentes, sem criar contas, documentos ou pagamentos. `settlements-demo-state.php` e os recibos `supplier-settlements-demo-compras.json` / `supplier-settlements-demo-sgf.json` comprovam por hashes a preservação dos documentos, histórico, contas e acessos anteriores. A demonstração orçamental não foi alterada. + +Estas tabelas **não constituem uma migração aplicada a desenvolvimento ou produção**. O instalador está limitado ao ensaio. Uma futura disponibilização fora de QA exige migração própria, contexto operacional, permissões, backup/restauro e tratamento aprovado dos históricos. Nenhuma base habitual, acesso real, dependência, publicação ou push foi alterado neste incremento. + +## Limites e próximos passos + +- Novos pagamentos sobre obrigações provenientes de importação histórica são recusados neste recorte. O circuito de confirmação/rejeição dos pendentes importados continua separado e preservado; não se incorporam liquidações antigas como dívida integral. +- Uma ligação pagamento/obrigação é imutável e única. Aumentar posteriormente essa mesma ligação ou reverter uma confirmação exige um futuro percurso explícito de correcção; não está entregue. Outros pagamentos podem afectar a mesma obrigação. +- Não há execução de banco/caixa, reconciliação, reembolsos, crédito automático, cobranças, documento fiscal/PDF, contabilidade, stock ou efeitos orçamentais adicionais. Outbox durável não significa ligação bancária ou contabilística entregue. +- Fazer o ensaio visual curto nas duas demonstrações e recolher dificuldades da equipa. Depois decidir aceitação e eventual checkpoint exclusivamente local. Não foi criado checkpoint de encerramento. + +Esta entrega complementa [obrigações a fornecedores](SUPPLIER_PAYABLE_DELIVERY.md); não substitui as aceitações e limitações anteriores. diff --git a/docs/proofs/navigation-live.json b/docs/proofs/navigation-live.json new file mode 100644 index 0000000..8af8c05 --- /dev/null +++ b/docs/proofs/navigation-live.json @@ -0,0 +1,98 @@ +{ + "checked_at": "2026-10-29T21:13:28.393Z", + "host": "http://127.0.0.1:8002", + "current_bundle_served": true, + "results": [ + { + "role": "proprietario", + "topics": [ + "Início", + "Comercial", + "Compras", + "Stock", + "Oficinas", + "Financeiro", + "Contabilidade", + "Configuração" + ], + "destinations": 51, + "purchasing_visible": true, + "stock_separate": true, + "accounting_separate": true, + "all_destinations_registered": true + }, + { + "role": "preparador", + "topics": [ + "Início", + "Comercial", + "Compras", + "Stock", + "Oficinas", + "Financeiro", + "Contabilidade", + "Configuração" + ], + "destinations": 50, + "purchasing_visible": true, + "stock_separate": true, + "accounting_separate": true, + "all_destinations_registered": true + }, + { + "role": "validador", + "topics": [ + "Início", + "Comercial", + "Compras", + "Stock", + "Oficinas", + "Financeiro", + "Contabilidade", + "Configuração" + ], + "destinations": 49, + "purchasing_visible": true, + "stock_separate": true, + "accounting_separate": true, + "all_destinations_registered": true + }, + { + "role": "tecnico", + "topics": [ + "Início", + "Compras", + "Stock", + "Oficinas", + "Contabilidade", + "Configuração" + ], + "destinations": 27, + "purchasing_visible": true, + "stock_separate": true, + "accounting_separate": true, + "all_destinations_registered": true + }, + { + "role": "verificador", + "topics": [ + "Início", + "Compras", + "Stock", + "Oficinas", + "Contabilidade", + "Configuração" + ], + "destinations": 27, + "purchasing_visible": true, + "stock_separate": true, + "accounting_separate": true, + "all_destinations_registered": true + } + ], + "business_commands": 0, + "accounts_created": 0, + "permissions_changed": 0, + "type": "HTTP/session plus static render; not interactive browser review", + "visual_review": false +} diff --git a/docs/proofs/payables-history.json b/docs/proofs/payables-history.json index a86eec1..1143592 100644 --- a/docs/proofs/payables-history.json +++ b/docs/proofs/payables-history.json @@ -1,14 +1,14 @@ { "version": "COMMON-PAYABLE-SOURCE-SIM-v1", "passed": true, - "checked_at": "2026-10-29T12:47:50.740Z", + "checked_at": "2026-10-29T21:06:44.192Z", "team_budget_preserved": true, "hosts": [ { "host": "compras", "passed": true, - "core_sha256": "5ec2a8c0b55b2982b41faaa6870243834a3b2f894795a5ef13c7406f87286962", - "source_revision": "4a34d4256cf35402f407d4cb8d1f4a26441f8aa2be1a7b30312fdb2d631a22eb", + "core_sha256": "dafc76371767b0a9794f0822553e60392a8aa2c270dea8d78ff701a56f5f5bc9", + "source_revision": "1d789ffd6432c59d771d86e417b99f380037a941ec417d0844fe81c234fe07c7", "recognised_obligations": 0, "checks": [ "previous_settlement_blocks_recognition", @@ -20,8 +20,8 @@ { "host": "sgf", "passed": true, - "core_sha256": "5ec2a8c0b55b2982b41faaa6870243834a3b2f894795a5ef13c7406f87286962", - "source_revision": "4a34d4256cf35402f407d4cb8d1f4a26441f8aa2be1a7b30312fdb2d631a22eb", + "core_sha256": "dafc76371767b0a9794f0822553e60392a8aa2c270dea8d78ff701a56f5f5bc9", + "source_revision": "1d789ffd6432c59d771d86e417b99f380037a941ec417d0844fe81c234fe07c7", "recognised_obligations": 0, "checks": [ "previous_settlement_blocks_recognition", diff --git a/docs/proofs/payables-http.json b/docs/proofs/payables-http.json index 3967e7a..2a2768b 100644 --- a/docs/proofs/payables-http.json +++ b/docs/proofs/payables-http.json @@ -1,14 +1,14 @@ { "version": "COMMON-PAYABLE-SOURCE-SIM-v1", "passed": true, - "checked_at": "2026-10-29T19:29:20.603Z", + "checked_at": "2026-10-29T21:06:23.635Z", "team_budget_preserved": true, "hosts": [ { "host": "compras", "passed": true, - "core_sha256": "6c130ee1dda38e4fec4c55b974b0140e857dbd8bf4beb357906826f537d3a235", - "source_revision": "9bbb4a50fa1743e68b1968cb9a4a32341a8d8911f69afe929efa0065be1e1f9c", + "core_sha256": "dafc76371767b0a9794f0822553e60392a8aa2c270dea8d78ff701a56f5f5bc9", + "source_revision": "6b3bbd64b35fc5d53cb45828731114d713788604859509b70d3343ca6e612a01", "principal_minor": 1160000, "due_on": null, "days_overdue": null, @@ -19,12 +19,12 @@ { "status": 200, "state": "prepared", - "pid": 12724 + "pid": 14440 }, { "status": 200, "state": "prepared", - "pid": 17280 + "pid": 13316 } ] }, @@ -33,11 +33,11 @@ "results": [ { "status": 403, - "pid": 14704 + "pid": 12108 }, { "status": 403, - "pid": 14100 + "pid": 14640 } ] }, @@ -47,12 +47,12 @@ { "status": 200, "state": "recognised", - "pid": 13020 + "pid": 7468 }, { "status": 200, "state": "recognised", - "pid": 12336 + "pid": 26732 } ] } @@ -80,8 +80,8 @@ { "host": "sgf", "passed": true, - "core_sha256": "6c130ee1dda38e4fec4c55b974b0140e857dbd8bf4beb357906826f537d3a235", - "source_revision": "9bbb4a50fa1743e68b1968cb9a4a32341a8d8911f69afe929efa0065be1e1f9c", + "core_sha256": "dafc76371767b0a9794f0822553e60392a8aa2c270dea8d78ff701a56f5f5bc9", + "source_revision": "6b3bbd64b35fc5d53cb45828731114d713788604859509b70d3343ca6e612a01", "principal_minor": 1160000, "due_on": null, "days_overdue": null, @@ -92,12 +92,12 @@ { "status": 200, "state": "prepared", - "pid": 10808 + "pid": 5220 }, { "status": 200, "state": "prepared", - "pid": 11504 + "pid": 25768 } ] }, @@ -106,11 +106,11 @@ "results": [ { "status": 403, - "pid": 7596 + "pid": 13440 }, { "status": 403, - "pid": 21800 + "pid": 25336 } ] }, @@ -120,12 +120,12 @@ { "status": 200, "state": "recognised", - "pid": 4572 + "pid": 16848 }, { "status": 200, "state": "recognised", - "pid": 11468 + "pid": 13872 } ] } diff --git a/docs/proofs/supplier-settlements-demo-access.json b/docs/proofs/supplier-settlements-demo-access.json new file mode 100644 index 0000000..e2cbed5 --- /dev/null +++ b/docs/proofs/supplier-settlements-demo-access.json @@ -0,0 +1,42 @@ +{ + "checked_at": "2026-10-29T21:11:14.340Z", + "kind": "HTTP and HTML only; no visual review or monetary commands", + "results": [ + { + "host": "compras", + "role": "preparacao", + "status": 200, + "payments": 0 + }, + { + "host": "compras", + "role": "validacao", + "status": 200, + "payments": 0 + }, + { + "host": "compras", + "role": "consulta", + "status": 200, + "payments": 0 + }, + { + "host": "sgf", + "role": "preparacao", + "status": 200, + "payments": 0 + }, + { + "host": "sgf", + "role": "validacao", + "status": 200, + "payments": 0 + }, + { + "host": "sgf", + "role": "consulta", + "status": 200, + "payments": 0 + } + ] +} diff --git a/docs/proofs/supplier-settlements-demo-compras.json b/docs/proofs/supplier-settlements-demo-compras.json new file mode 100644 index 0000000..14f01ad --- /dev/null +++ b/docs/proofs/supplier-settlements-demo-compras.json @@ -0,0 +1,59 @@ +{ + "host": "compras", + "hashes_before": { + "pa_sources": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "pa_proposals": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "pa_obligations": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "pa_events": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "pa_commands": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "pa_outbox": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "pa_history_imports": null, + "pa_imported_payments": null, + "pa_payment_decisions": null, + "pa_imported_sources": null, + "permission_grants": null, + "invoices": "de991ffdccdf629bac13a679b22e00cd703906a381c2f1a70305ef9e478d71cf", + "invoice_items": "e7cc1cd4807f1bc273f18941f3aec37cbdd293dcbfb52185fc8da9ae36ffb52a", + "purchase_orders": "cdaaa2eda1fa8fc629da5f8b273ef9f90d4382c843e1afe7bffd78ba1a03d371", + "goods_receipts": "5c64459b22785eefc4a0b91ad9d440dd37fbfa247e8be334b9490c0845109dc4", + "goods_receipt_items": "9c502cbd9c3b5ed60c06a681826e27cbd3c6e5204e6c27a55357b903fa36ac19", + "stock_movements": "edd57e41daab547bfbc413ef0b5fad3ba0603c6c7e14a3c40ade1858fc90017a", + "budget_entries": "fa08dd19cb433da749ce7e3cc7ba61818fe5fa3b902f9f7c75f24958747877e0", + "budget_consumptions": "03f17be4d36a70454c986471ed407a86567e7686d2099c291d32c4bc38a1e289", + "external_payments": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "accounts_without_credentials": "3cfae4461cbb31833631da51d47e798c6470ab5e7a4b78c4cba2d54821e17d0e", + "existing_access": "d68857b0c06da1c7eb4c50d681f77504307c17fd8121c0a7b31001e7eaac9ac0" + }, + "counts_before": { + "pa_sources": 0, + "pa_proposals": 0, + "pa_obligations": 0, + "pa_events": 0, + "pa_commands": 0, + "pa_outbox": 0, + "invoices": 1, + "invoice_items": 1, + "purchase_orders": 1, + "goods_receipts": 1, + "goods_receipt_items": 1, + "stock_movements": 1, + "budget_entries": 3, + "budget_consumptions": 1, + "external_payments": 0, + "users": 12 + }, + "checked_at": "2026-10-29T21:07:08+00:00", + "preserved": true, + "schema_counts": { + "ps_payments": 0, + "ps_payment_decisions": 0, + "ps_proposals": 0, + "ps_allocation_aliases": 0, + "ps_allocation_decisions": 0, + "ps_allocations": 0, + "ps_commands": 0, + "ps_events": 0, + "ps_outbox": 0 + }, + "checked_after": "2026-10-29T21:10:40+00:00" +} diff --git a/docs/proofs/supplier-settlements-demo-sgf.json b/docs/proofs/supplier-settlements-demo-sgf.json new file mode 100644 index 0000000..3f79a22 --- /dev/null +++ b/docs/proofs/supplier-settlements-demo-sgf.json @@ -0,0 +1,43 @@ +{ + "host": "sgf", + "hashes_before": { + "pa_sources": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "pa_proposals": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "pa_obligations": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "pa_events": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "pa_commands": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "pa_outbox": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "pa_history_imports": null, + "pa_imported_payments": null, + "pa_payment_decisions": null, + "pa_imported_sources": "237c82de39d5f5d75b6c8d5f9e5aa81d4fd692e216eec386e27a96d9e2884261", + "permission_grants": "00eca880a0d034c0016ae456fdc6260963ba4260ae3492e924a17833064fd9a6", + "accounts_without_credentials": "fa565c3aad2a788a3f9f523ced5b6fd0be89ec09b190b1e1d3a57358c5546b84", + "existing_access": "41bc65cf9459f806cc2c84c6b583debcf27835456f556ae20ba78915718620af" + }, + "counts_before": { + "pa_sources": 0, + "pa_proposals": 0, + "pa_obligations": 0, + "pa_events": 0, + "pa_commands": 0, + "pa_outbox": 0, + "pa_imported_sources": 1, + "permission_grants": 9, + "users": 3 + }, + "checked_at": "2026-10-29T21:07:09+00:00", + "preserved": true, + "schema_counts": { + "ps_payments": 0, + "ps_payment_decisions": 0, + "ps_proposals": 0, + "ps_allocation_aliases": 0, + "ps_allocation_decisions": 0, + "ps_allocations": 0, + "ps_commands": 0, + "ps_events": 0, + "ps_outbox": 0 + }, + "checked_after": "2026-10-29T21:10:41+00:00" +} diff --git a/docs/proofs/supplier-settlements.json b/docs/proofs/supplier-settlements.json new file mode 100644 index 0000000..605bb18 --- /dev/null +++ b/docs/proofs/supplier-settlements.json @@ -0,0 +1,299 @@ +{ + "version": "COMMON-SUPPLIER-SETTLEMENT-SIM-v1", + "checked_at": "2026-10-29T21:08:21.464Z", + "hosts": [ + { + "host": "compras", + "passed": true, + "core_sha256": "156bd40f3080de66a73c0befd975990e9d7010c2a232a86d751d4db3af4fae93", + "principal_minor": 1160000, + "balance_after_first_allocation_minor": 760000, + "due_on": null, + "days_overdue": null, + "multi_document_core": { + "balances": [ + 0, + 0, + 100000 + ], + "unallocated_minor": 40000, + "foreign_supplier_refused": true, + "other_unit_reference_refused": true, + "kind": "core explicit synthetic source adapter; not another host invoice workflow" + }, + "races": [ + { + "waits": 2, + "results": [ + { + "status": 403 + }, + { + "status": 403 + } + ] + }, + { + "waits": 2, + "results": [ + { + "status": 200, + "result": { + "payment_id": 1, + "decision_id": 1, + "replayed": false + } + }, + { + "status": 200, + "result": { + "payment_id": 1, + "decision_id": 1, + "replayed": false + } + } + ] + }, + { + "waits": 2, + "results": [ + { + "status": 200, + "result": { + "proposal_id": 1, + "replayed": false + } + }, + { + "status": 200, + "result": { + "proposal_id": 1, + "replayed": false + } + } + ] + }, + { + "waits": 2, + "results": [ + { + "status": 403 + }, + { + "status": 403 + } + ] + }, + { + "waits": 2, + "results": [ + { + "status": 200, + "result": { + "proposal_id": 1, + "decision_id": 2, + "replayed": false + } + }, + { + "status": 200, + "result": { + "proposal_id": 1, + "decision_id": 2, + "replayed": false + } + } + ] + }, + { + "waits": 2, + "results": [ + { + "status": 409, + "message": "Saldo insuficiente ou ligação pagamento/obrigação já confirmada; sem edição silenciosa." + }, + { + "status": 200, + "result": { + "proposal_id": 3, + "decision_id": 3, + "replayed": false + } + } + ] + } + ], + "checks": [ + "payment_confirmation_does_not_reduce_debt", + "explicit_allocations_only", + "new_request_keys_recover_same_fact", + "duplicate_evidence_conflicts", + "partial_and_excess", + "overallocation_refused", + "rejected_payment_or_allocation_has_no_effect", + "separate_actor_and_person", + "revocation_while_waiting", + "atomic_fault_rollback_and_retry", + "source_revision_conflict", + "cross_company_refused", + "cross_supplier_refused", + "history_not_ignored", + "immutable_confirmed_facts", + "legacy_balance_uses_common_position", + "unchanged_stock_budget_invoice_legacy_payments", + "csrf_and_context", + "logout_reentry_history", + "durable_outbox" + ], + "visual_review": false + }, + { + "host": "sgf", + "passed": true, + "core_sha256": "156bd40f3080de66a73c0befd975990e9d7010c2a232a86d751d4db3af4fae93", + "principal_minor": 1160000, + "balance_after_first_allocation_minor": 760000, + "due_on": null, + "days_overdue": null, + "multi_document_core": { + "balances": [ + 0, + 0, + 100000 + ], + "unallocated_minor": 40000, + "foreign_supplier_refused": true, + "other_unit_reference_refused": true, + "kind": "core explicit synthetic source adapter; not another host invoice workflow" + }, + "races": [ + { + "waits": 2, + "results": [ + { + "status": 403 + }, + { + "status": 403 + } + ] + }, + { + "waits": 2, + "results": [ + { + "status": 200, + "result": { + "payment_id": 1, + "decision_id": 1, + "replayed": false + } + }, + { + "status": 200, + "result": { + "payment_id": 1, + "decision_id": 1, + "replayed": false + } + } + ] + }, + { + "waits": 2, + "results": [ + { + "status": 200, + "result": { + "proposal_id": 1, + "replayed": false + } + }, + { + "status": 200, + "result": { + "proposal_id": 1, + "replayed": false + } + } + ] + }, + { + "waits": 2, + "results": [ + { + "status": 403 + }, + { + "status": 403 + } + ] + }, + { + "waits": 2, + "results": [ + { + "status": 200, + "result": { + "proposal_id": 1, + "decision_id": 2, + "replayed": false + } + }, + { + "status": 200, + "result": { + "proposal_id": 1, + "decision_id": 2, + "replayed": false + } + } + ] + }, + { + "waits": 2, + "results": [ + { + "status": 409, + "message": "Saldo insuficiente ou ligação pagamento/obrigação já confirmada; sem edição silenciosa." + }, + { + "status": 200, + "result": { + "proposal_id": 3, + "decision_id": 3, + "replayed": false + } + } + ] + } + ], + "checks": [ + "payment_confirmation_does_not_reduce_debt", + "explicit_allocations_only", + "new_request_keys_recover_same_fact", + "duplicate_evidence_conflicts", + "partial_and_excess", + "overallocation_refused", + "rejected_payment_or_allocation_has_no_effect", + "separate_actor_and_person", + "revocation_while_waiting", + "atomic_fault_rollback_and_retry", + "source_revision_conflict", + "cross_company_refused", + "cross_supplier_refused", + "history_not_ignored", + "immutable_confirmed_facts", + "legacy_balance_uses_common_position", + "unchanged_stock_budget_invoice_legacy_payments", + "csrf_and_context", + "logout_reentry_history", + "durable_outbox" + ], + "visual_review": false + } + ], + "bank_execution": false, + "accounting": false, + "real_migration": false +} diff --git a/package.json b/package.json index 9445d8f..e7e4687 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "type": "module", "scripts": { "dev": "vite --host 127.0.0.1 --port 5173 --strictPort", - "build": "vite build" + "check:navigation": "node scripts/home-navigation-check.mjs", + "build": "node scripts/home-navigation-check.mjs && vite build" }, "dependencies": { "react": "19.2.9", @@ -15,4 +16,4 @@ "vite": "8.3.5", "@vitejs/plugin-react": "6.1.2" } -} \ No newline at end of file +} diff --git a/packages/payables/bootstrap.php b/packages/payables/bootstrap.php index 2473279..32cba0c 100644 --- a/packages/payables/bootstrap.php +++ b/packages/payables/bootstrap.php @@ -6,3 +6,6 @@ require_once __DIR__.'/src/HistoricalPayments.php'; require_once __DIR__.'/src/HistorySchema.php'; require_once __DIR__.'/src/PaymentDecisions.php'; require_once __DIR__.'/src/PaymentDecisionSchema.php'; +require_once __DIR__.'/src/SupplierSettlements.php'; +require_once __DIR__.'/src/SettlementSchema.php'; +require_once __DIR__.'/src/SettlementHttp.php'; diff --git a/packages/payables/src/Payables.php b/packages/payables/src/Payables.php index 2cd0d27..5d5d341 100644 --- a/packages/payables/src/Payables.php +++ b/packages/payables/src/Payables.php @@ -4,7 +4,7 @@ namespace Tempo\Finance\Payables; use Illuminate\Database\ConnectionInterface; -/** One payable per document identity. No session, money rounding, settlement or budget effects. */ +/** One payable per document identity. No session, money rounding or budget effects. */ final class Payables { public const VERSION = 'COMMON-PAYABLE-SOURCE-SIM-v1'; @@ -160,14 +160,42 @@ final class Payables } } $confirmed = $current['confirmed_minor'] ?? ($import ? (int) $import->confirmed_minor : 0); + $allocated = SupplierSettlements::allocated($this->db, $this->scope, (int) $obligation->id); + $balance = (int) $obligation->principal_minor - $confirmed - $allocated; + if ($balance < 0) { + $compatible = false; + } - return (array) $obligation + ['state' => 'recognised', 'balance_minor' => $compatible ? (int) $obligation->principal_minor - $confirmed : null, + return (array) $obligation + ['state' => 'recognised', 'balance_minor' => $compatible ? $balance : null, 'source_conflict' => ! $compatible, 'history_imported' => $import !== null, 'confirmed_payments_minor' => $confirmed, + 'allocated_payments_minor' => $allocated, 'payment_state' => ! $compatible ? 'indeterminate' : ($balance === 0 ? 'settled' : ($balance === (int) $obligation->principal_minor ? 'unpaid' : 'partial')), 'pending_payments_minor' => $current['pending_minor'] ?? ($import ? (int) $import->pending_minor : 0), 'rejected_payments_minor' => $current['rejected_minor'] ?? ($import ? (int) $import->rejected_minor : 0), 'due_status' => 'unknown', 'days_overdue' => null, 'documentary_pending' => array_merge(['Vencimento desconhecido; atraso indeterminado.'], $compatible ? [] : ['Origem ou histórico divergente; saldo não presumido.'])]; } + public function settlements(): SupplierSettlements + { + return new SupplierSettlements($this->db, $this->actor, $this->person, $this->scope, $this->authorize, function (int $id): array { + $o = $this->q('obligations')->where('id', $id)->lockForUpdate()->first() ?? throw new \DomainException('Obrigação fora do contexto.'); + $row = $this->q('sources')->where('id', $o->source_id)->lockForUpdate()->first() ?? throw new \DomainException('Origem ausente.'); + $source = $this->source((int) $row->local_source_id); + if (self::identity($source) !== $row->source_key) { + throw new \DomainException('Identidade documental alterada.'); + } + $position = $this->position($o, $source); + if ($position['source_conflict'] || $position['balance_minor'] === null) { + throw new \DomainException('Origem ou histórico divergente; afectação suspensa.'); + } + if ($position['history_imported']) { + throw new \DomainException('Novos pagamentos sobre história importada exigem recorte de compatibilidade próprio; nenhum histórico ignorado.'); + } + + return $position + ['supplier_key' => hash('sha256', self::canonical([$source['instance'], $source['company'], $source['supplier_id']])), + 'supplier_name' => $source['supplier_name']]; + }); + } + public function positionForSource(int $local): ?array { return $this->db->transaction(function () use ($local) { diff --git a/packages/payables/src/SettlementHttp.php b/packages/payables/src/SettlementHttp.php new file mode 100644 index 0000000..27709ac --- /dev/null +++ b/packages/payables/src/SettlementHttp.php @@ -0,0 +1,82 @@ +environment('payable-testing') && $db->getDriverName() === 'pgsql' && $db->getConfig('host') === '127.0.0.1' + && preg_match('/^'.$prefix.'[a-f0-9]{12}$/D', $db->getDatabaseName()) + && $db->selectOne('select current_database() as d')->d === $db->getDatabaseName(), 403, 'Pagamentos apenas no ensaio isolado.'); + foreach (SettlementSchema::TABLES as $table) { + abort_unless($db->getSchemaBuilder()->hasTable($table), 403, 'Ensaio de pagamentos ainda não instalado nesta base.'); + } + } + + /** @return array */ + private function data(Request $r): array + { + $this->guard(); + + return DB::transaction(function () use ($r): array { + $s = app(static::SERVICE); + $user = $r->user(); + $settlements = $s->engine($user)->settlements()->listing(); + $base = $s->listing($user); + foreach ($base['obligations'] as &$o) { + $proposal = $base['proposals']->first(fn ($p) => (int) $p->id === (int) $o['proposal_id']); + $o['origin_snapshot'] = $proposal ? json_decode($proposal->snapshot, true, 64, JSON_THROW_ON_ERROR) : null; + } + unset($o); + $permissions = []; + foreach (['payment_prepare', 'payment_confirm', 'allocation_prepare', 'allocation_confirm'] as $action) { + $permissions[$action] = $s->can($user, $action); + } + + return ['synthetic_only' => true, 'obligations' => $base['obligations'], 'payments' => $settlements['payments'], + 'allocation_proposals' => $settlements['proposals'], 'allocations' => $settlements['allocations'], 'events' => $settlements['events'], + 'permissions' => $permissions, 'current_actor' => $user->id]; + }); + } + + public function index(Request $r): JsonResponse + { + return response()->json($this->data($r))->header('Cache-Control', 'no-store'); + } + + public function page(Request $r): Response + { + $data = $this->data($r); + $settings = ['host' => static::MODE === 'compras' ? 'Compras autónomo' : 'ERP SGF', 'actor' => $r->user()->name, + 'csrf' => csrf_token(), 'context' => $r->session()->get('company_context.token'), 'api' => '/api/supplier-payments', + 'obligations_url' => static::MODE === 'compras' ? '/obrigacoes-fornecedores' : '/supplier-payables']; + + return response(Workspace::settlements($data, $settings))->header('Cache-Control', 'no-store'); + } + + public function command(Request $r, string $action): JsonResponse + { + $this->guard(); + try { + return response()->json(app(static::SERVICE)->engine($r->user())->settlements()->command($action, $r->all(), (string) $r->header('Idempotency-Key'))); + } catch (\DomainException $e) { + abort(409, $e->getMessage()); + } catch (\InvalidArgumentException $e) { + abort(422, $e->getMessage()); + } + } +} diff --git a/packages/payables/src/SettlementSchema.php b/packages/payables/src/SettlementSchema.php new file mode 100644 index 0000000..c267f0d --- /dev/null +++ b/packages/payables/src/SettlementSchema.php @@ -0,0 +1,136 @@ +getDriverName() !== 'pgsql' || ! preg_match('/^(compras_receipts_test_|payable_sgf_)[a-f0-9]{12}$/D', $db->getDatabaseName()) + || $db->selectOne('select current_database() as d')->d !== $db->getDatabaseName()) { + throw new \LogicException('Settlement schema requires isolated QA.'); + } + if (array_filter(self::TABLES, fn ($t) => Schema::hasTable($t))) { + throw new \LogicException('Settlement schema already exists; no automatic repair.'); + } + $scope = static function (Blueprint $t): void { + foreach (['tenant_id', 'company_id', 'unit_id'] as $k) { + $t->unsignedBigInteger($k); + } + }; + DB::transaction(function () use ($scope): void { + Schema::create('ps_payments', function (Blueprint $t) use ($scope) { + $t->id(); + $scope($t); + $t->foreignId('obligation_id')->constrained('pa_obligations'); + $t->string('fact_key', 64)->unique(); + $t->string('evidence_key', 64)->unique(); + $t->string('supplier_key', 64); + $t->string('currency', 3); + $t->bigInteger('amount_minor'); + $t->date('paid_on'); + $t->string('fact_reference'); + $t->string('evidence_reference'); + $t->string('payload_hash', 64); + $t->jsonb('snapshot'); + $t->unsignedBigInteger('prepared_by'); + $t->string('prepared_person'); + $t->text('reason'); + $t->timestamp('created_at'); + }); + Schema::create('ps_payment_decisions', function (Blueprint $t) use ($scope) { + $t->id(); + $scope($t); + $t->foreignId('payment_id')->unique()->constrained('ps_payments'); + $t->string('decision'); + $t->unsignedBigInteger('actor_id'); + $t->string('person'); + $t->string('evidence_reference'); + $t->text('reason'); + $t->timestamp('created_at'); + }); + Schema::create('ps_proposals', function (Blueprint $t) use ($scope) { + $t->id(); + $scope($t); + $t->foreignId('payment_id')->constrained('ps_payments'); + $t->string('reference_key', 64)->unique(); + $t->string('reference'); + $t->string('plan_hash', 64); + $t->jsonb('plan'); + $t->unsignedBigInteger('prepared_by'); + $t->string('prepared_person'); + $t->text('reason'); + $t->timestamp('created_at'); + $t->unique(['payment_id', 'plan_hash']); + }); + Schema::create('ps_allocation_decisions', function (Blueprint $t) use ($scope) { + $t->id(); + $scope($t); + $t->foreignId('proposal_id')->unique()->constrained('ps_proposals'); + $t->string('decision'); + $t->unsignedBigInteger('actor_id'); + $t->string('person'); + $t->text('reason'); + $t->timestamp('created_at'); + }); + Schema::create('ps_allocation_aliases', function (Blueprint $t) use ($scope) { + $t->id(); + $scope($t); + $t->string('reference_key', 64)->unique(); + $t->string('reference'); + $t->foreignId('proposal_id')->constrained('ps_proposals'); + $t->unsignedBigInteger('actor_id'); + $t->timestamp('created_at'); + }); + Schema::create('ps_allocations', function (Blueprint $t) use ($scope) { + $t->id(); + $scope($t); + $t->foreignId('payment_id')->constrained('ps_payments'); + $t->foreignId('obligation_id')->constrained('pa_obligations'); + $t->foreignId('decision_id')->constrained('ps_allocation_decisions'); + $t->bigInteger('amount_minor'); + $t->string('source_revision', 64); + $t->timestamp('created_at'); + $t->unique(['payment_id', 'obligation_id']); + }); + Schema::create('ps_commands', function (Blueprint $t) use ($scope) { + $t->id(); + $scope($t); + $t->unsignedBigInteger('actor_id'); + $t->uuid('command_key'); + $t->string('request_hash', 64); + $t->jsonb('result'); + $t->timestamp('created_at'); + $t->unique(['tenant_id', 'company_id', 'unit_id', 'actor_id', 'command_key']); + }); + Schema::create('ps_events', function (Blueprint $t) use ($scope) { + $t->id(); + $scope($t); + $t->foreignId('payment_id')->constrained('ps_payments'); + $t->unsignedBigInteger('actor_id'); + $t->string('person'); + $t->string('kind'); + $t->jsonb('payload'); + $t->timestamp('created_at'); + }); + Schema::create('ps_outbox', function (Blueprint $t) { + $t->foreignId('event_id')->primary()->constrained('ps_events'); + $t->string('state'); + }); + foreach (self::TABLES as $table) { + DB::unprepared("CREATE TRIGGER ps_history_immutable BEFORE UPDATE OR DELETE ON {$table} FOR EACH ROW EXECUTE FUNCTION pa_append_only()"); + } + foreach (['ps_payments', 'ps_allocations'] as $table) { + DB::unprepared("ALTER TABLE {$table} ADD CONSTRAINT {$table}_positive CHECK (amount_minor > 0)"); + } + }); + } +} diff --git a/packages/payables/src/SupplierSettlements.php b/packages/payables/src/SupplierSettlements.php new file mode 100644 index 0000000..afa53d4 --- /dev/null +++ b/packages/payables/src/SupplierSettlements.php @@ -0,0 +1,383 @@ + $scope + * @param \Closure(string):void $authorize + * @param \Closure(int):array $obligation + */ + public function __construct(private ConnectionInterface $db, private int $actor, private string $person, + private array $scope, private \Closure $authorize, private \Closure $obligation) {} + + private function q(string $table): Builder + { + return $this->db->table('ps_'.$table)->where($this->scope); + } + + private function stamp(): string + { + return date('Y-m-d H:i:s'); + } + + /** @param array $v */ + private function hash(array $v): string + { + return hash('sha256', Payables::canonical($v)); + } + + private function key(string $ref): string + { + // One payment fact per organisation, even if the request arrives through another unit. + return $this->hash([array_intersect_key($this->scope, array_flip(['tenant_id', 'company_id'])), $ref]); + } + + private function referenceScope(string $table, string $column, string $key): void + { + $row = $this->db->table('ps_'.$table)->where($column, $key)->first(array_keys($this->scope)); + if ($row) { + foreach ($this->scope as $field => $value) { + if ((int) $row->$field !== $value) { + throw new \DomainException('Referência já associada noutro contexto da organização; nenhuma duplicação permitida.'); + } + } + } + } + + public static function minor(mixed $value): int + { + if (! is_string($value) || ! preg_match('/^(0|[1-9][0-9]{0,11})(?:\.([0-9]{1,2}))?$/D', $value, $m)) { + throw new \InvalidArgumentException('Montante decimal positivo, com até duas casas; sem arredondamento.'); + } + $minor = (int) $m[1] * 100 + (int) str_pad($m[2] ?? '', 2, '0'); + if ($minor < 1) { + throw new \InvalidArgumentException('O montante deve ser positivo.'); + } + + return $minor; + } + + private function reference(mixed $v): string + { + if (! is_string($v) || ! preg_match('/^SIM-[A-Z0-9][A-Z0-9._-]{2,95}$/D', $v)) { + throw new \InvalidArgumentException('Referência sintética estável SIM-… obrigatória.'); + } + + return $v; + } + + private function reason(mixed $v): string + { + if (! is_string($v) || mb_strlen(trim($v)) < 3 || mb_strlen($v) > 2000) { + throw new \InvalidArgumentException('Fundamento obrigatório, entre 3 e 2000 caracteres.'); + } + + return trim($v); + } + + private function id(mixed $v): int + { + if (! is_int($v) || $v < 1) { + throw new \InvalidArgumentException('Identificador inteiro positivo obrigatório.'); + } + + return $v; + } + + /** @param array $data + * @param list $keys + */ + private function exact(array $data, array $keys): void + { + if (array_diff(array_keys($data), $keys) || array_diff($keys, array_keys($data))) { + throw new \InvalidArgumentException('Campos ausentes ou não permitidos.'); + } + } + + private function payment(int $id): \stdClass + { + $row = $this->q('payments')->where('id', $id)->lockForUpdate()->first(); + if (! $row instanceof \stdClass) { + throw new \DomainException('Pagamento fora do contexto.'); + } + + return $row; + } + + private function independent(\stdClass $prepared): void + { + if ((int) $prepared->prepared_by === $this->actor || $prepared->prepared_person === $this->person) { + throw new \DomainException('Confirmação exige outra identidade e outra pessoa sintética.'); + } + } + + /** @param array $payload */ + private function event(int $payment, string $kind, array $payload): void + { + $id = $this->db->table('ps_events')->insertGetId($this->scope + ['payment_id' => $payment, 'actor_id' => $this->actor, + 'person' => $this->person, 'kind' => $kind, 'payload' => Payables::canonical($payload), 'created_at' => $this->stamp()]); + $this->db->table('ps_outbox')->insert(['event_id' => $id, 'state' => 'captured_no_bank_or_accounting']); + } + + /** @param array $scope */ + public static function allocated(ConnectionInterface $db, array $scope, int $obligation): int + { + if (! method_exists($db, 'getSchemaBuilder')) { + throw new \LogicException('A ligação exige consulta explícita do esquema financeiro.'); + } + if (! $db->getSchemaBuilder()->hasTable('ps_allocations')) { + return 0; + } + + return (int) $db->table('ps_allocations')->where($scope)->where('obligation_id', $obligation)->sum('amount_minor'); + } + + private function unallocated(\stdClass $payment): int + { + return (int) $payment->amount_minor - (int) $this->q('allocations')->where('payment_id', $payment->id)->sum('amount_minor'); + } + + /** @param array $data + * @return array + */ + public function command(string $action, array $data, string $key): array + { + $permissions = ['payment-prepare' => 'payment_prepare', 'payment-decide' => 'payment_confirm', 'allocation-prepare' => 'allocation_prepare', 'allocation-decide' => 'allocation_confirm']; + if (! isset($permissions[$action]) || ! preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/Di', $key)) { + throw new \InvalidArgumentException('Acção ou chave inválida.'); + } + + return $this->db->transaction(function () use ($action, $data, $key, $permissions): array { + ($this->authorize)('payments_view'); + ($this->authorize)($permissions[$action]); // Host locks organisation and revalidates actor/grants before replay too. + $hash = $this->hash([$action, $data]); + $old = $this->q('commands')->where(['actor_id' => $this->actor, 'command_key' => $key])->first(); + if ($old && $old->request_hash !== $hash) { + throw new \DomainException('Chave repetida com conteúdo diferente.'); + } + $result = match ($action) { + 'payment-prepare' => $this->preparePayment($data), 'payment-decide' => $this->decidePayment($data), + 'allocation-prepare' => $this->prepareAllocation($data), 'allocation-decide' => $this->decideAllocation($data), + }; + if ($old && Payables::canonical(json_decode($old->result, true, 32, JSON_THROW_ON_ERROR)) !== Payables::canonical($result)) { + throw new \DomainException('Resultado anterior incompatível; não repetir o efeito.'); + } + if (! $old) { + $this->db->table('ps_commands')->insert($this->scope + ['actor_id' => $this->actor, 'command_key' => $key, + 'request_hash' => $hash, 'result' => Payables::canonical($result), 'created_at' => $this->stamp()]); + } + + return $result + ['replayed' => $old !== null]; + }); + } + + /** @param array $d + * @return array + */ + private function preparePayment(array $d): array + { + $this->exact($d, ['obligation_id', 'source_revision', 'amount', 'currency', 'paid_on', 'fact_reference', 'evidence_reference', 'reason']); + $o = ($this->obligation)($this->id($d['obligation_id'])); + if ($o['source_revision'] !== $d['source_revision'] || $d['currency'] !== 'MZN' || $o['currency'] !== $d['currency']) { + throw new \DomainException('Versão ou moeda incompatível.'); + } + if (! is_string($d['paid_on']) || ! preg_match('/^(\d{4})-(\d{2})-(\d{2})$/D', $d['paid_on'], $date) + || ! checkdate((int) $date[2], (int) $date[3], (int) $date[1]) || $d['paid_on'] > date('Y-m-d')) { + throw new \InvalidArgumentException('Data factual de pagamento válida obrigatória, sem data futura.'); + } + $fact = $this->reference($d['fact_reference']); + $evidence = $this->reference($d['evidence_reference']); + $reason = $this->reason($d['reason']); + $snapshot = ['obligation_id' => $o['id'], 'source_revision' => $o['source_revision'], 'supplier_key' => $o['supplier_key'], + 'amount_minor' => self::minor($d['amount']), 'currency' => $d['currency'], 'paid_on' => $d['paid_on'], 'fact_reference' => $fact, 'evidence_reference' => $evidence, 'reason' => $reason, 'synthetic_only' => true]; + $hash = $this->hash($snapshot); + $this->referenceScope('payments', 'fact_key', $this->key($fact)); + $this->referenceScope('payments', 'evidence_key', $this->key($evidence)); + $existing = $this->q('payments')->where(fn ($q) => $q->where('fact_key', $this->key($fact))->orWhere('evidence_key', $this->key($evidence)))->get(); + if ($existing->isNotEmpty()) { + if ($existing->count() !== 1 || $existing->first()->payload_hash !== $hash) { + throw new \DomainException('Referência ou evidência já usada com conteúdo incompatível.'); + } + + return ['payment_id' => (int) $existing->first()->id]; + } + $id = $this->db->table('ps_payments')->insertGetId($this->scope + ['obligation_id' => $o['id'], 'fact_key' => $this->key($fact), + 'evidence_key' => $this->key($evidence), 'supplier_key' => $o['supplier_key'], 'currency' => $d['currency'], 'amount_minor' => $snapshot['amount_minor'], + 'paid_on' => $d['paid_on'], 'fact_reference' => $fact, 'evidence_reference' => $evidence, 'payload_hash' => $hash, 'snapshot' => Payables::canonical($snapshot), + 'prepared_by' => $this->actor, 'prepared_person' => $this->person, 'reason' => $reason, 'created_at' => $this->stamp()]); + $this->event($id, 'supplier_payment.proposed', ['payment_id' => $id, 'amount_minor' => $snapshot['amount_minor']]); + + return ['payment_id' => $id]; + } + + /** @param array $d + * @return array + */ + private function decidePayment(array $d): array + { + $this->exact($d, ['payment_id', 'decision', 'evidence_reference', 'reason']); + if (! in_array($d['decision'], ['confirmed', 'rejected'], true)) { + throw new \InvalidArgumentException('Decisão inválida.'); + } + $p = $this->payment($this->id($d['payment_id'])); + $this->independent($p); + $o = ($this->obligation)((int) $p->obligation_id); + $snapshot = json_decode($p->snapshot, true, 32, JSON_THROW_ON_ERROR); + if ($o['source_revision'] !== $snapshot['source_revision'] || $o['supplier_key'] !== $p->supplier_key) { + throw new \DomainException('Origem do pagamento alterada; revisão necessária.'); + } + $evidence = $this->reference($d['evidence_reference']); + $reason = $this->reason($d['reason']); + $old = $this->q('payment_decisions')->where('payment_id', $p->id)->first(); + if ($old) { + if ($old->decision !== $d['decision'] || $old->evidence_reference !== $evidence || $old->reason !== $reason) { + throw new \DomainException('Decisão final já registada; correcção exige fluxo próprio.'); + } + + return ['payment_id' => (int) $p->id, 'decision_id' => (int) $old->id]; + } + $id = $this->db->table('ps_payment_decisions')->insertGetId($this->scope + ['payment_id' => $p->id, 'decision' => $d['decision'], + 'evidence_reference' => $evidence, 'reason' => $reason, 'actor_id' => $this->actor, 'person' => $this->person, 'created_at' => $this->stamp()]); + $this->event((int) $p->id, 'supplier_payment.'.$d['decision'], ['payment_id' => $p->id, 'decision_id' => $id]); + + return ['payment_id' => (int) $p->id, 'decision_id' => $id]; + } + + /** @param array $d + * @return array + */ + private function prepareAllocation(array $d): array + { + $this->exact($d, ['payment_id', 'reference', 'allocations', 'reason']); + $p = $this->payment($this->id($d['payment_id'])); + if ($this->q('payment_decisions')->where(['payment_id' => $p->id, 'decision' => 'confirmed'])->doesntExist()) { + throw new \DomainException('Pagamento ainda não confirmado.'); + } + $reference = $this->reference($d['reference']); + $reason = $this->reason($d['reason']); + if (! is_array($d['allocations']) || ! array_is_list($d['allocations']) || count($d['allocations']) < 1 || count($d['allocations']) > 50) { + throw new \InvalidArgumentException('Indique entre uma e cinquenta afectações.'); + } + $plan = []; + foreach ($d['allocations'] as $line) { + if (! is_array($line)) { + throw new \InvalidArgumentException('Linha de afectação inválida.'); + } + $this->exact($line, ['obligation_id', 'source_revision', 'amount']); + $id = $this->id($line['obligation_id']); + if (isset($plan[$id])) { + throw new \InvalidArgumentException('Obrigação repetida na mesma proposta.'); + } + $plan[$id] = ['obligation_id' => $id, 'source_revision' => $line['source_revision'], 'amount_minor' => self::minor($line['amount'])]; + } + ksort($plan); + $plan = array_values($plan); + $hash = $this->hash($plan); + $this->referenceScope('proposals', 'reference_key', $this->key($reference)); + $this->referenceScope('allocation_aliases', 'reference_key', $this->key($reference)); + // Semantic recovery precedes balance checks: an already-applied plan remains recoverable. + $alias = $this->q('allocation_aliases')->where('reference_key', $this->key($reference))->value('proposal_id'); + $old = $this->q('proposals')->where(fn ($q) => $q->where('reference_key', $this->key($reference))->orWhere('id', $alias ?? 0)->orWhere(fn ($q) => $q->where('payment_id', $p->id)->where('plan_hash', $hash)))->get(); + if ($old->isNotEmpty()) { + if ($old->count() !== 1 || (int) $old->first()->payment_id !== (int) $p->id || $old->first()->plan_hash !== $hash) { + throw new \DomainException('Referência de afectação incompatível.'); + } + $this->checkPlan($p, $plan, false); + $this->db->table('ps_allocation_aliases')->insertOrIgnore($this->scope + ['reference_key' => $this->key($reference), 'reference' => $reference, + 'proposal_id' => $old->first()->id, 'actor_id' => $this->actor, 'created_at' => $this->stamp()]); + + return ['proposal_id' => (int) $old->first()->id]; + } + $this->checkPlan($p, $plan, true); + $id = $this->db->table('ps_proposals')->insertGetId($this->scope + ['payment_id' => $p->id, 'reference_key' => $this->key($reference), + 'reference' => $reference, 'plan_hash' => $hash, 'plan' => Payables::canonical($plan), 'prepared_by' => $this->actor, + 'prepared_person' => $this->person, 'reason' => $reason, 'created_at' => $this->stamp()]); + $this->event((int) $p->id, 'supplier_allocation.proposed', ['proposal_id' => $id, 'plan_hash' => $hash]); + + return ['proposal_id' => $id]; + } + + /** @param list> $plan */ + private function checkPlan(\stdClass $p, array $plan, bool $checkBalances): void + { + $total = 0; + foreach ($plan as $line) { + $o = ($this->obligation)($line['obligation_id']); + if ($o['supplier_key'] !== $p->supplier_key || $o['currency'] !== $p->currency || $o['source_revision'] !== $line['source_revision']) { + throw new \DomainException('Fornecedor, moeda ou versão incompatível na afectação.'); + } + if ($line['amount_minor'] > PHP_INT_MAX - $total) { + throw new \DomainException('Montante fora dos limites.'); + } + $total += $line['amount_minor']; + if ($checkBalances && ($line['amount_minor'] > $o['balance_minor'] || $this->q('allocations')->where(['payment_id' => $p->id, 'obligation_id' => $o['id']])->exists())) { + throw new \DomainException('Saldo insuficiente ou ligação pagamento/obrigação já confirmada; sem edição silenciosa.'); + } + } + if ($checkBalances && $total > $this->unallocated($p)) { + throw new \DomainException('Valor proposto superior ao pagamento ainda por afectar.'); + } + } + + /** @param array $d + * @return array + */ + private function decideAllocation(array $d): array + { + $this->exact($d, ['proposal_id', 'decision', 'reason']); + if (! in_array($d['decision'], ['confirmed', 'rejected'], true)) { + throw new \InvalidArgumentException('Decisão inválida.'); + } + $proposal = $this->q('proposals')->where('id', $this->id($d['proposal_id']))->first() ?? throw new \DomainException('Proposta fora do contexto.'); + $p = $this->payment((int) $proposal->payment_id); + $this->independent($proposal); + if ($this->q('payment_decisions')->where(['payment_id' => $p->id, 'decision' => 'confirmed'])->doesntExist()) { + throw new \DomainException('Pagamento não confirmado.'); + } + $reason = $this->reason($d['reason']); + $plan = json_decode($proposal->plan, true, 64, JSON_THROW_ON_ERROR); + $old = $this->q('allocation_decisions')->where('proposal_id', $proposal->id)->first(); + $this->checkPlan($p, $plan, ! $old && $d['decision'] === 'confirmed'); + if ($old) { + if ($old->decision !== $d['decision'] || $old->reason !== $reason) { + throw new \DomainException('Decisão de afectação já final; exige correcção própria.'); + } + + return ['proposal_id' => (int) $proposal->id, 'decision_id' => (int) $old->id]; + } + $id = $this->db->table('ps_allocation_decisions')->insertGetId($this->scope + ['proposal_id' => $proposal->id, 'decision' => $d['decision'], + 'actor_id' => $this->actor, 'person' => $this->person, 'reason' => $reason, 'created_at' => $this->stamp()]); + if ($d['decision'] === 'confirmed') { + foreach ($plan as $line) { + $this->db->table('ps_allocations')->insert($this->scope + $line + ['payment_id' => $p->id, 'decision_id' => $id, 'created_at' => $this->stamp()]); + } + } + $this->event((int) $p->id, 'supplier_allocation.'.$d['decision'], ['proposal_id' => $proposal->id, 'decision_id' => $id, 'plan_hash' => $proposal->plan_hash]); + + return ['proposal_id' => (int) $proposal->id, 'decision_id' => $id]; + } + + /** @return array */ + public function listing(): array + { + return $this->db->transaction(function (): array { + ($this->authorize)('payments_view'); + $payments = $this->q('payments')->orderBy('id')->get()->map(function ($p): array { + $decision = $this->q('payment_decisions')->where('payment_id', $p->id)->first(); + + return (array) $p + ['decision' => $decision, 'state' => $decision->decision ?? 'proposed', + 'unallocated_minor' => $decision?->decision === 'confirmed' ? $this->unallocated($p) : null, 'reconciliation' => 'not_implemented']; + })->all(); + $proposals = $this->q('proposals')->orderBy('id')->get()->map(fn ($p) => (array) $p + ['decision' => $this->q('allocation_decisions')->where('proposal_id', $p->id)->first()])->all(); + + return ['synthetic_only' => true, 'payments' => $payments, 'proposals' => $proposals, 'allocations' => $this->q('allocations')->orderBy('id')->get()->all(), + 'events' => $this->q('events')->orderBy('id')->get()->all(), 'current_actor' => $this->actor]; + }); + } +} diff --git a/packages/payables/src/Workspace.php b/packages/payables/src/Workspace.php index 01d1141..122d038 100644 --- a/packages/payables/src/Workspace.php +++ b/packages/payables/src/Workspace.php @@ -4,6 +4,14 @@ namespace Tempo\Finance\Payables; final class Workspace { + public static function settlements(array $data, array $settings): string + { + ob_start(); + require __DIR__.'/../ui/settlements.php'; + + return ob_get_clean(); + } + public static function render(array $data, array $settings): string { ob_start(); diff --git a/packages/payables/ui/page.php b/packages/payables/ui/page.php index 7c747b7..be67509 100644 --- a/packages/payables/ui/page.php +++ b/packages/payables/ui/page.php @@ -2,7 +2,7 @@ Obrigações a fornecedores — ensaio
get('payables_demo_entry')): ?>

Trocar função ou sair da demonstração

←

·

Obrigações a fornecedores

ENSAIO SINTÉTICO — SEM UTILIZAÇÃO REAL. Documento, dívida, pagamento e contabilidade são factos distintos. Este percurso reconhece o principal e, quando activado, preserva o histórico existente.

Fornecedor, montantes e conferência são reutilizados da factura. Vencimento desconhecido não é substituído por uma data ou zero dias de atraso.

-

+

Pagamentos e afectações sintéticas — sem execução bancária

Facturas de origem

DocumentoFornecedorBaseImposto documentalTotalConferência / revisão
Origem e pendências
diff --git a/packages/payables/ui/settlements.php b/packages/payables/ui/settlements.php new file mode 100644 index 0000000..5492f85 --- /dev/null +++ b/packages/payables/ui/settlements.php @@ -0,0 +1,32 @@ + htmlspecialchars((string) $v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); +$money = static fn ($v) => $v === null ? 'Indeterminado' : number_format(intdiv((int) $v, 100), 0, '.', ' ').'.'.str_pad((string) abs((int) $v % 100), 2, '0', STR_PAD_LEFT).' MZN'; +$eligible = array_values(array_filter($data['obligations'], static fn ($o) => ! $o['source_conflict'] && ! $o['history_imported'])); +$confirmed = array_values(array_filter($data['payments'], static fn ($p) => $p['state'] === 'confirmed')); +$options = static function () use ($eligible, $e, $money): void { + foreach ($eligible as $o) echo ''; +}; +?> +Pagamentos e afectações a fornecedores — ensaio
+
← Obrigações a fornecedores · Trocar função ou sair

·

Pagamentos e afectações a fornecedores

ENSAIO SINTÉTICO — SEM MOVIMENTAÇÃO DE DINHEIRO. Propor não confirma; confirmar um pagamento não abate automaticamente uma dívida. Só uma afectação confirmada reduz o saldo. Não há execução bancária, reconciliação, stock, orçamento adicional ou lançamento contabilístico.

As referências de pagamento e evidência são artificiais e começam por SIM-. Não introduza dados bancários ou comprovativos reais.

+

+

Obrigações e saldo canónico

Origem / fornecedorPrincipalAfectado neste percursoSaldoVencimento
# ·
Versão e decisão
Pendente
Atraso indeterminado
Dias de atraso: sem valor

Reconheça primeiro a obrigação no percurso existente. Não é criada uma dívida por registar um pagamento.

+

1. Propor pagamento sintético

+

2. Pagamentos propostos e decisões

Pagamento # ·

· Data simulada · Estado: 'Proposto','confirmed'=>'Confirmado em ensaio','rejected'=>'Rejeitado'][$p['state']]) ?>

Por afectar: · Reconciliação bancária: não implementada.

Preparação e evidência preservadas
+

Sem pagamentos propostos neste contexto.

+

3. Propor afectações

Mesmo fornecedor e moeda. O excedente fica por afectar. Não existe reembolso/crédito automático nem edição de uma afectação confirmada.

+

4. Propostas de afectação

Proposta # · · Pagamento #

Preparador # ·

  • Obrigação #: · versão

Estado: decision === 'confirmed' ? 'Afectação confirmada' : 'Rejeitada') : 'Proposta — sem efeito no saldo' ?>

+
+

Histórico durável

#id) ?> · kind) ?> · actor #actor_id) ?> · created_at) ?>
payload) ?>
+

Correcções, reversões, reconciliação e novos pagamentos sobre documentos com histórico importado ficam para âmbito próprio. Os originais e as decisões confirmadas não são editados ou apagados.

+
diff --git a/resources/js/WorkspaceNavigation.jsx b/resources/js/WorkspaceNavigation.jsx index 3a1cce9..e7cb019 100644 --- a/resources/js/WorkspaceNavigation.jsx +++ b/resources/js/WorkspaceNavigation.jsx @@ -8,7 +8,7 @@ export function navigationGroups(can, unit) { const item=(id,label,permission,scope=null)=>({id,label,visible:can(permission,scope)}); return [ {id:'start',label:'Início',items:[{id:'home',label:'Página inicial',visible:true}]}, - {id:'purchasing',label:'Compras',items:[{id:'purchasing_home',label:'Área de Compras',visible:can('company.view')},item('purchasing_budget','Controlo orçamental sintético','purchasing_budget.view',unit),item('supplier_payables','Obrigações a fornecedores — ensaio','supplier_payables.view',unit)]}, + {id:'purchasing',label:'Compras',items:[{id:'purchasing_home',label:'Área de Compras',visible:can('company.view')},item('purchasing_budget','Controlo orçamental sintético','purchasing_budget.view',unit),item('supplier_payables','Obrigações a fornecedores — ensaio','supplier_payables.view',unit),item('supplier_payments','Pagamentos e afectações — ensaio','supplier_payables.view',unit)]}, {id:'stock',label:'Stock',status:'Em preparação',items:visibleStockSections(can).map(s=>({id:s.id,label:s.label,informational:true,visible:true}))}, {id:'services',label:'Clientes e serviços',items:[item('clients','Clientes','clients.view'),item('services','Catálogo de serviços','services.view'),item('service_drafts','Prestações em rascunho','service_drafts.view',unit)]}, {id:'billing',label:'Facturação',items:visibleBillingSections(can,unit).map(s=>({id:s.id,label:s.label,visible:true}))}, @@ -28,7 +28,8 @@ export function navigationTopics(groups) { {id:'purchasing',label:'Compras',groups:['purchasing']}, {id:'stock',label:'Stock',groups:['stock']}, {id:'workshop',label:'Oficinas',groups:['workshop']}, - {id:'finance',label:'Financeiro',groups:['receivables','accounting','costs']}, + {id:'finance',label:'Financeiro',groups:['receivables','costs']}, + {id:'accounting',label:'Contabilidade',groups:['accounting']}, {id:'settings',label:'Configuração',groups:['billing_support','admin']}, ]; const known=new Set(layout.flatMap(t=>t.groups)); diff --git a/resources/js/app.jsx b/resources/js/app.jsx index ececac6..1017656 100644 --- a/resources/js/app.jsx +++ b/resources/js/app.jsx @@ -97,6 +97,7 @@ function App() { {page === 'purchasing_budget' && } {page === 'purchasing_home' && } {page === 'supplier_payables' &&

Obrigações a fornecedores

Percurso sintético ligado ao núcleo comum. Preparação, validação distinta e consulta, sem pagamentos ou vencimento presumido.

Abrir preparação e consulta de obrigações
} + {page === 'supplier_payments' &&

Pagamentos e afectações a fornecedores

Ensaio sintético: preparação e confirmação distintas, afectação explícita e saldo canónico. Não executa transferências bancárias.

Abrir pagamentos e afectações sintéticas
} {page === 'industrial_costs' && } {page === 'service_receivables' && } {page === 'service_invoice_setup' && } diff --git a/routes/web.php b/routes/web.php index 25f857e..52851d6 100644 --- a/routes/web.php +++ b/routes/web.php @@ -28,6 +28,9 @@ Route::middleware(['auth', RequireValidSession::class])->group(function (): void Route::post('/api/context', [IdentityController::class, 'select']); Route::middleware(RequireCompanyContext::class)->group(function (): void { Route::get('/supplier-payables', [\App\Modules\Purchasing\SupplierPayablesController::class,'page']); + Route::get('/supplier-payments', [\App\Modules\Purchasing\SupplierPaymentsController::class,'page']); + Route::get('/api/supplier-payments', [\App\Modules\Purchasing\SupplierPaymentsController::class,'index']); + Route::post('/api/supplier-payments/{action}', [\App\Modules\Purchasing\SupplierPaymentsController::class,'command'])->whereIn('action',['payment-prepare','payment-decide','allocation-prepare','allocation-decide']); Route::get('/api/supplier-payables', [\App\Modules\Purchasing\SupplierPayablesController::class,'index']); Route::post('/api/supplier-payables/{action}', [\App\Modules\Purchasing\SupplierPayablesController::class,'command'])->whereIn('action',['prepare','validate']); Route::get('/api/purchasing-budget', [\App\Modules\Purchasing\BudgetController::class, 'index']); diff --git a/scripts/home-navigation-check.mjs b/scripts/home-navigation-check.mjs index 878a201..2596159 100644 --- a/scripts/home-navigation-check.mjs +++ b/scripts/home-navigation-check.mjs @@ -26,7 +26,17 @@ try { assert(!reader.html.includes('

A validar

'));assert(!reader.html.includes('

A preparar e acompanhar

')); assert(reader.html.includes('não agrega nem conta tarefas pendentes')); const allGroups=navigationGroups(()=>true,7),topics=navigationTopics(allGroups); - assert.deepEqual(topics.map(t=>t.label),['Início','Comercial','Compras','Stock','Oficinas','Financeiro','Configuração']); + assert.deepEqual(topics.map(t=>t.label),['Início','Comercial','Compras','Stock','Oficinas','Financeiro','Contabilidade','Configuração']); + // Independent inventory: comparing the generated menu with itself cannot detect a removed module. + assert.deepEqual(allGroups.map(g=>g.id).sort(),['start','purchasing','stock','services','billing','billing_support','receivables','workshop','costs','accounting','admin'].sort()); + const accountingTopic=topics.find(t=>t.id==='accounting'); + assert.deepEqual(accountingTopic.sections.map(g=>g.id),['accounting']); + assert.deepEqual(accountingTopic.sections[0].items.map(i=>i.id),['accounting_panel','accounting_daily','accounting_review','accounting_analysis','accounting_closing','accounting_configuration','catalogue_accounting','service_fiscal']); + for(const grants of [[['company.view',null]],[['catalogue_accounting.view',null]],[['service_fiscal.view',null]]]) { + const authorised=navigationTopics(check(grants).groups); + assert(authorised.some(t=>t.id==='accounting'&&t.label==='Contabilidade')); + } + assert(!navigationTopics(noAccess.groups).some(t=>t.id==='accounting')); const before=allGroups.flatMap(g=>g.items.map(i=>i.id)).sort(),after=topics.flatMap(t=>t.sections.flatMap(g=>g.items.map(i=>i.id))).sort(); assert.deepEqual(after,before);assert.equal(new Set(after).size,after.length); const navHtml=renderToStaticMarkup(React.createElement(Navigation,{groups:allGroups,page:'billing_drafts',navigate:()=>{},user:{name:'Avaliador sintético',email:'consulta@example.test'},busy:false,logout:()=>{}})); diff --git a/scripts/navigation-live-check.mjs b/scripts/navigation-live-check.mjs new file mode 100644 index 0000000..d504229 --- /dev/null +++ b/scripts/navigation-live-check.mjs @@ -0,0 +1,50 @@ +// Existing synthetic host only: entry/session/logout and served assets; no business commands. +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import React from 'react'; +import {renderToStaticMarkup} from 'react-dom/server'; +import {createServer} from 'vite'; +const base='http://127.0.0.1:8002'; +const vite=await createServer({server:{middlewareMode:true},appType:'custom'}); +try{ + const {navigationGroups,navigationTopics,default:Navigation}=await vite.ssrLoadModule('/resources/js/WorkspaceNavigation.jsx'); + const {serviceAreas}=await vite.ssrLoadModule('/resources/js/ServicesWorkspace.jsx'); + const registry=new Set(Object.keys(serviceAreas)); + const source=await fs.readFile('resources/js/app.jsx','utf8'); + for(const m of source.matchAll(/page\s*===\s*['"]([^'"]+)['"]/g))registry.add(m[1]); + for(const [file,field] of [['billing-menu.js','billingSections'],['workshop-menu.js','workshopSections'],['stock-menu.js','stockSections'],['accounting-menu.js','accountingSections']]){ + const module=await vite.ssrLoadModule('/resources/js/'+file);for(const item of module[field])registry.add(item.id); + } + const manifest=JSON.parse(await fs.readFile('public/build/manifest.json','utf8'))['resources/js/app.jsx']; + const root=await fetch(base+'/',{signal:AbortSignal.timeout(15000),headers:{Connection:'close'}});assert.equal(root.status,200); + assert((await root.text()).includes(manifest.file)); + const asset=await fetch(base+'/build/'+manifest.file,{signal:AbortSignal.timeout(15000),headers:{Connection:'close'}});assert.equal(asset.status,200); + assert((await asset.text()).includes('purchasing_home')); + const results=[]; + for(const role of ['proprietario','preparador','validador','tecnico','verificador']){ + const cookies=new Map(); + async function request(path,method='GET',body){ + const r=await fetch(base+path,{signal:AbortSignal.timeout(15000),method,redirect:'manual',headers:{Connection:'close',Cookie:[...cookies].map(([k,v])=>`${k}=${v}`).join('; '),...(body?{'Content-Type':'application/x-www-form-urlencoded'}:{})},...(body?{body:new URLSearchParams(body)}:{})}); + for(const c of r.headers.getSetCookie()){const pair=c.split(';',1)[0],i=pair.indexOf('=');cookies.set(pair.slice(0,i),pair.slice(i+1));} + return {status:r.status,text:await r.text()}; + } + const landing=await request('/avaliar');assert.equal(landing.status,200); + const csrf=landing.text.match(/name="_token" value="([^"]+)"/)?.[1];assert(csrf); + assert.equal((await request('/avaliar/entrar','POST',{role,_token:csrf})).status,302,role+' entry'); + const session=JSON.parse((await request('/api/session')).text);assert(session.user&&session.context); + const can=(p,u=null)=>session.context.grants.some(g=>g.permission===p&&g.unit_id===u); + const groups=navigationGroups(can,session.context.unit?.id??null),topics=navigationTopics(groups); + const destinations=groups.flatMap(g=>g.items.map(i=>i.id)); + for(const id of destinations)assert(registry.has(id),role+' unregistered destination '+id); + assert.equal(new Set(destinations).size,destinations.length); + assert(topics.some(t=>t.id==='purchasing'),role+' purchasing visible'); + assert(topics.some(t=>t.id==='stock'),role+' stock separately visible'); + assert(topics.some(t=>t.id==='accounting'&&t.label==='Contabilidade'),role+' accounting separately visible'); + const html=renderToStaticMarkup(React.createElement(Navigation,{groups,page:'purchasing_home',navigate:()=>{},user:{name:'Conta sintética',email:'avaliacao@example.test'},busy:false,logout:()=>{}})); + assert(html.includes('topic-purchasing'));assert(html.includes('topic-stock'));assert(html.includes('topic-accounting'));assert(html.includes('Área de Compras')); + const logout=await fetch(base+'/api/logout',{signal:AbortSignal.timeout(15000),method:'POST',headers:{Connection:'close',Accept:'application/json',Cookie:[...cookies].map(([k,v])=>`${k}=${v}`).join('; '),'X-CSRF-TOKEN':session.csrf}});assert.equal(logout.status,200); + results.push({role,topics:topics.map(t=>t.label),destinations:destinations.length,purchasing_visible:true,stock_separate:true,accounting_separate:true,all_destinations_registered:true}); + } + await fs.writeFile('docs/proofs/navigation-live.json',JSON.stringify({checked_at:new Date().toISOString(),host:base,current_bundle_served:true,results,business_commands:0,accounts_created:0,permissions_changed:0,type:'HTTP/session plus static render; not interactive browser review',visual_review:false},null,2)+'\n'); + console.log('Live navigation passed for five existing synthetic roles: Compras and Stock separate, all destinations registered, current bundle served. No business commands or access changes.'); +}finally{await vite.close();} diff --git a/scripts/payables-demo.ps1 b/scripts/payables-demo.ps1 index 8a701bf..a05d1ae 100644 --- a/scripts/payables-demo.ps1 +++ b/scripts/payables-demo.ps1 @@ -1,4 +1,4 @@ -param([ValidateSet('start','stop','status','proof')][string]$Action='status',[switch]$HistoryOnly) +param([ValidateSet('start','stop','status','proof')][string]$Action='status',[switch]$HistoryOnly,[switch]$Settlements) $ErrorActionPreference='Stop' $payRoot=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) if($payRoot -ne 'C:\laragon\www\sgf-tempo'){throw 'Unexpected project path'} @@ -31,7 +31,7 @@ try{ if(!$payProof){@{pid=$payProcess.Id;started=$payProcess.StartTime.ToUniversalTime().ToString('o');executable=$payWebPhp;port=$payPort}|ConvertTo-Json|Set-Content -LiteralPath "$payRun\payables-$payMode.json" -Encoding UTF8} $payReady=$false;for($payAttempt=0;$payAttempt -lt 30;$payAttempt++){if(Get-NetTCPConnection -LocalPort $payPort -State Listen -ErrorAction SilentlyContinue){$payReady=$true;break};Start-Sleep -Milliseconds 100};if(!$payReady){throw 'Payables server failed to start'} } - if($payProof){Push-Location $payRoot;try{if($HistoryOnly){& 'C:\laragon\tools\sgf-tempo\node-v24.22.1-win-arm64\node.exe' scripts/payables-http-proof.mjs --history-only}else{& 'C:\laragon\tools\sgf-tempo\node-v24.22.1-win-arm64\node.exe' scripts/payables-http-proof.mjs};if($LASTEXITCODE){throw 'Payables HTTP proof failed'}}finally{Pop-Location}} + if($payProof){Push-Location $payRoot;try{if($Settlements){foreach($settlementMode in @('compras','sgf')){& $payPhp scripts/settlements-install.php $settlementMode proof;if($LASTEXITCODE){throw 'Settlement schema failed'}};& 'C:\laragon\tools\sgf-tempo\node-v24.22.1-win-arm64\node.exe' scripts/settlements-http-proof.mjs}elseif($HistoryOnly){& 'C:\laragon\tools\sgf-tempo\node-v24.22.1-win-arm64\node.exe' scripts/payables-http-proof.mjs --history-only}else{& 'C:\laragon\tools\sgf-tempo\node-v24.22.1-win-arm64\node.exe' scripts/payables-http-proof.mjs};if($LASTEXITCODE){throw 'Payables HTTP proof failed'}}finally{Pop-Location}} else{Write-Output 'Synthetic payables: Compras http://127.0.0.1:8005/obrigacoes-fornecedores ; SGF http://127.0.0.1:8006. Budget demonstration preserved.'} }finally{ Remove-Item Env:PAYABLE_SETTINGS_FILE -ErrorAction SilentlyContinue diff --git a/scripts/settlements-demo-access.mjs b/scripts/settlements-demo-access.mjs new file mode 100644 index 0000000..158dfa3 --- /dev/null +++ b/scripts/settlements-demo-access.mjs @@ -0,0 +1,20 @@ +import fs from 'node:fs/promises'; +import assert from 'node:assert/strict'; +const source=await fs.readFile('scripts/settlements-http-proof.mjs','utf8'); +const definition=source.slice(source.indexOf(' function client()'),source.indexOf(' const p=client()')); +const results=[]; +for(const mode of ['compras','sgf']) { + const base=`http://127.0.0.1:${mode==='compras'?8005:8006}`; + const client=eval('('+definition.trim().replace(/^function client/, 'function')+')'); + for(const role of ['preparacao','validacao','consulta']) { + const c=client();await c.login(role); + const html=await c.request('/supplier-payments');assert.equal(html.status,200); + const data=await c.list();assert.equal(data.payments.length,0); + const old=await c.request(mode==='compras'?'/obrigacoes-fornecedores':'/supplier-payables');assert.equal(old.status,200);assert.ok(old.text.includes('/supplier-payments')); + results.push({host:mode,role,status:html.status,payments:data.payments.length}); + await c.request('/logout','POST',{}); + } +} +await fs.writeFile('docs/proofs/supplier-settlements-demo-access.json',JSON.stringify({checked_at:new Date().toISOString(),kind:'HTTP and HTML only; no visual review or monetary commands',results},null,2)+'\n'); +console.log('Both existing demonstrations: all three roles, payments page/API and obligation link passed; no monetary commands.'); + diff --git a/scripts/settlements-demo-state.php b/scripts/settlements-demo-state.php new file mode 100644 index 0000000..8188a5a --- /dev/null +++ b/scripts/settlements-demo-state.php @@ -0,0 +1,50 @@ + $v) { putenv($k.'='.$v); $_ENV[$k] = $_SERVER[$k] = $v; } +require $s['HOST_ROOT'].'/vendor/autoload.php'; $app = require $s['HOST_ROOT'].'/bootstrap/app.php'; $app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap(); +use Illuminate\Support\Facades\DB; +use Tempo\Finance\Payables\Payables; +$db = DB::connection(); +if (! preg_match('/^(compras_receipts_test_|payable_sgf_)[a-f0-9]{12}$/D', $db->getDatabaseName()) || $db->getConfig('host') !== '127.0.0.1' + || $db->selectOne('select current_database() as d')->d !== $s['DB_DATABASE']) throw new LogicException('Synthetic database guard.'); +$newActions = ['payments_view', 'payment_prepare', 'payment_confirm', 'allocation_prepare', 'allocation_confirm']; +$tables = ['pa_sources', 'pa_proposals', 'pa_obligations', 'pa_events', 'pa_commands', 'pa_outbox', 'pa_history_imports', 'pa_imported_payments', 'pa_payment_decisions', 'pa_imported_sources', 'permission_grants']; +if ($mode === 'compras') $tables = [...$tables, 'invoices', 'invoice_items', 'purchase_orders', 'goods_receipts', 'goods_receipt_items', 'stock_movements', 'budget_entries', 'budget_consumptions', 'external_payments']; +$hashes = []; $counts = []; +DB::transaction(function () use ($db, $tables, $newActions, &$hashes, &$counts): void { + $db->statement('SET TRANSACTION READ ONLY'); + foreach ($tables as $table) { + if (! $db->getSchemaBuilder()->hasTable($table)) { $hashes[$table] = null; continue; } + $rows = $db->table($table)->orderBy($table === 'pa_outbox' ? 'event_id' : 'id')->get()->map(fn ($r) => (array) $r)->all(); + $hashes[$table] = hash('sha256', Payables::canonical($rows)); $counts[$table] = count($rows); + } + $users = $db->table('users')->orderBy('id')->get(['id', 'name', 'email'])->map(fn ($r) => (array) $r)->all(); + $hashes['accounts_without_credentials'] = hash('sha256', Payables::canonical($users)); $counts['users'] = count($users); + $access = $db->table('pa_access')->whereNotIn('action', $newActions)->orderBy('id')->get()->map(fn ($r) => (array) $r)->all(); + $hashes['existing_access'] = hash('sha256', Payables::canonical($access)); +}); +$path = __DIR__.'/../docs/proofs/supplier-settlements-demo-'.$mode.'.json'; +if ($phase === 'before') { + file_put_contents($path, json_encode(['host' => $mode, 'hashes_before' => $hashes, 'counts_before' => $counts, 'checked_at' => gmdate('c')], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL); + echo $mode." synthetic baseline recorded.\n"; +} else { + $before = json_decode(file_get_contents($path), true, 32, JSON_THROW_ON_ERROR); + if ($before['hashes_before'] !== $hashes || $before['counts_before'] !== $counts) throw new LogicException('Existing synthetic data changed; investigate without reset.'); + $ps = []; foreach (\Tempo\Finance\Payables\SettlementSchema::TABLES as $t) $ps[$t] = $db->table($t)->count(); + $result = $before + ['preserved' => true, 'schema_counts' => $ps, 'checked_after' => gmdate('c')]; + file_put_contents($path, json_encode($result, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL); + echo $mode." original documents, history, accounts and access preserved; synthetic settlements installed.\n"; +} diff --git a/scripts/settlements-http-proof.mjs b/scripts/settlements-http-proof.mjs new file mode 100644 index 0000000..1995f24 --- /dev/null +++ b/scripts/settlements-http-proof.mjs @@ -0,0 +1,109 @@ +import fs from 'node:fs/promises'; +import assert from 'node:assert/strict'; +import {randomUUID} from 'node:crypto'; +import {spawn} from 'node:child_process'; + +async function probe(mode,job,input={},legacy=false){ + return new Promise((resolve,reject)=>{ + const php=mode==='compras'?'C:/Users/macbook/.config/investmoz/php-8.5.11/php.exe':'C:/laragon/tools/sgf-tempo/php-8.4.27/php.exe'; + const p=spawn(php,[legacy?'scripts/payables-probe.php':'scripts/settlements-probe.php',mode,job],{cwd:process.cwd(),windowsHide:true});let out='',err=''; + p.stdout.on('data',d=>out+=d);p.stderr.on('data',d=>err+=d);p.on('error',reject);p.on('close',code=>{try{if(code)throw Error('Synthetic probe failed '+job+': '+out+err);resolve(JSON.parse(out));}catch(e){reject(e);}});p.stdin.end(JSON.stringify(input)); + }); +} +const hosts=[]; +for(const mode of ['compras','sgf']){ + console.log('Checking supplier settlements in '+mode+'…'); + const fixture=JSON.parse(await fs.readFile(`C:/laragon/data/sgf-tempo/secrets/payables-proof-${mode}-access.json`,'utf8')); + const base=`http://127.0.0.1:${mode==='compras'?8007:8008}`; + function client(){const cookies=new Map();let csrf='',context='';return { + async request(path,method='GET',body,opts={}){ + const r=await fetch(base+path,{signal:AbortSignal.timeout(20000),method,redirect:'manual',headers:{Connection:'close',Accept:'application/json','Content-Type':opts.form?'application/x-www-form-urlencoded':'application/json',Cookie:[...cookies].map(([k,v])=>`${k}=${v}`).join('; '),'X-CSRF-TOKEN':opts.noCsrf?'':csrf,'X-XSRF-TOKEN':opts.noCsrf?'':decodeURIComponent(cookies.get('XSRF-TOKEN')??''),'X-SGF-Context':opts.stale?'stale':context,...(opts.key?{'Idempotency-Key':opts.key}:{})},...(body===undefined?{}:{body:opts.form?new URLSearchParams(body):JSON.stringify(body)})}); + for(const c of r.headers.getSetCookie()){const pair=c.split(';',1)[0],i=pair.indexOf('=');cookies.set(pair.slice(0,i),pair.slice(i+1));} + const text=await r.text();let data={};try{data=JSON.parse(text);}catch{} + csrf=text.match(/name="_token" value="([^"]+)"/)?.[1]??data.csrf??csrf;if(data.context?.token)context=data.context.token; + return {status:r.status,data,text}; + }, + async login(role){assert.equal((await this.request('/demo')).status,200);assert.equal((await this.request('/demo/entrar','POST',{role,_token:csrf},{form:true})).status,302);await this.request('/demo');if(mode==='sgf')await this.request('/api/session');}, + async list(){const r=await this.request('/api/supplier-payments');assert.equal(r.status,200,r.text.slice(0,200));return r.data;}, + async send(action,body,status=200,key=randomUUID(),extra={}){const r=await this.request('/api/supplier-payments/'+action,'POST',body,{key,...extra});assert.equal(r.status,status,mode+' '+action+' '+r.text.slice(0,250));return r.data;}, + };} + const p=client(),v=client(),q=client();await p.login('preparacao');await v.login('validacao');await q.login('consulta'); + assert.equal((await client().request('/api/supplier-payments')).status,401); + const before=await probe(mode,'inspect',{},true); + const proposal=await p.request('/api/supplier-payables/prepare','POST',{id:fixture.source_id,reason:'Synthetic payable source'},{key:randomUUID()});assert.equal(proposal.status,200); + const recognised=await v.request('/api/supplier-payables/validate','POST',{id:proposal.data.proposal_id,reason:'Independent synthetic recognition'},{key:randomUUID()});assert.equal(recognised.status,200); + let data=await q.list();const o=data.obligations[0];assert.equal(o.principal_minor,1160000);assert.equal(o.balance_minor,1160000); + const origin=JSON.parse((await q.request('/api/supplier-payables')).text).documents[0];assert.equal(origin.subtotal_minor,1000000);assert.equal(origin.tax_minor,160000);assert.equal(origin.total_minor,1160000); + const today=new Date().toISOString().slice(0,10); + const paymentBody={obligation_id:o.id,source_revision:o.source_revision,amount:'4000.00',currency:'MZN',paid_on:today,fact_reference:'SIM-PAY-001',evidence_reference:'SIM-EVID-001',reason:'Synthetic payment fact; no bank execution'}; + const key=randomUUID(),first=await p.send('payment-prepare',paymentBody,200,key); + assert.equal((await p.send('payment-prepare',paymentBody,200,key)).replayed,true); + assert.equal((await p.send('payment-prepare',paymentBody)).payment_id,first.payment_id); + await p.send('payment-prepare',{...paymentBody,amount:'4000.01'},409); + await p.send('payment-prepare',{...paymentBody,fact_reference:'SIM-OTHER-REF'},409); + await p.send('payment-prepare',{...paymentBody,reason:'Changed key content'},409,key); + await p.send('payment-prepare',{...paymentBody,fact_reference:'SIM-ZERO',evidence_reference:'SIM-ZERO-EVID',amount:'0.00'},422); + await p.send('payment-prepare',{...paymentBody,amount:'4000.001'},422); + await p.send('payment-prepare',{...paymentBody,currency:'USD'},409); + const incomplete={...paymentBody};delete incomplete.paid_on;await p.send('payment-prepare',incomplete,422); + await q.send('payment-prepare',paymentBody,403); + const decision={payment_id:first.payment_id,decision:'confirmed',evidence_reference:'SIM-CONF-001',reason:'Independent synthetic evidence review'}; + await probe(mode,'grant',{role:'preparacao',action:'payment_confirm',enabled:true});await p.send('payment-decide',decision,409);await probe(mode,'grant',{role:'preparacao',action:'payment_confirm',enabled:false}); + await probe(mode,'person',{same:true});await v.send('payment-decide',decision,409);await probe(mode,'person',{same:false}); + const revoked=await probe(mode,'race',{role:'validacao',action:'payment-decide',body:decision,revoke:'payment_confirm'});assert.equal(revoked.waits,2);assert.deepEqual(revoked.results.map(r=>r.status),[403,403]); + await probe(mode,'grant',{role:'validacao',action:'payment_confirm',enabled:true}); + const paymentRace=await probe(mode,'race',{role:'validacao',action:'payment-decide',body:decision});assert.deepEqual(paymentRace.results.map(r=>r.status),[200,200]);assert.equal(paymentRace.results[0].result.decision_id,paymentRace.results[1].result.decision_id); + data=await q.list();assert.equal(data.obligations[0].balance_minor,1160000);assert.equal(data.payments[0].state,'confirmed');assert.equal(data.payments[0].unallocated_minor,400000); + await v.send('payment-decide',{...decision,decision:'rejected'},409); + const plan={payment_id:first.payment_id,reference:'SIM-ALLOC-001',reason:'Explicit partial allocation',allocations:[{obligation_id:o.id,source_revision:o.source_revision,amount:'4000.00'}]}; + const planRace=await probe(mode,'race',{role:'preparacao',action:'allocation-prepare',body:plan});assert.deepEqual(planRace.results.map(r=>r.status),[200,200]);const proposalId=planRace.results[0].result.proposal_id;assert.equal(planRace.results[1].result.proposal_id,proposalId); + await q.send('allocation-prepare',plan,403); + const allocationDecision={proposal_id:proposalId,decision:'confirmed',reason:'Independent partial allocation review'}; + const allocationRevoke=await probe(mode,'race',{role:'validacao',action:'allocation-decide',body:allocationDecision,revoke:'allocation_confirm'});assert.deepEqual(allocationRevoke.results.map(r=>r.status),[403,403]); + await probe(mode,'grant',{role:'validacao',action:'allocation_confirm',enabled:true}); + const failedKey=randomUUID(),rollbackBefore=await probe(mode,'inspect'); + await probe(mode,'fault',{enabled:true});await v.send('allocation-decide',allocationDecision,500,failedKey);await probe(mode,'fault',{enabled:false}); + const rollbackAfter=await probe(mode,'inspect');assert.deepEqual(rollbackAfter.counts,rollbackBefore.counts); + const allocationRace=await probe(mode,'race',{role:'validacao',action:'allocation-decide',body:allocationDecision});assert.deepEqual(allocationRace.results.map(r=>r.status),[200,200]);assert.equal(allocationRace.results[0].result.decision_id,allocationRace.results[1].result.decision_id); + await v.send('allocation-decide',allocationDecision,200,failedKey);assert.equal((await v.send('allocation-decide',allocationDecision,200,failedKey)).replayed,true); + assert.equal((await p.send('allocation-prepare',{...plan,reference:'SIM-ALIAS-001'})).proposal_id,proposalId); + await p.send('allocation-prepare',{...plan,reference:'SIM-ALIAS-001',allocations:[{...plan.allocations[0],amount:'3999.00'}]},409); + data=await q.list();assert.equal(data.allocations.length,1);assert.equal(data.obligations[0].balance_minor,760000);assert.equal(data.obligations[0].principal_minor,1160000);assert.equal(data.obligations[0].payment_state,'partial');assert.equal(data.payments[0].unallocated_minor,0); + assert.equal(data.obligations[0].due_on,null);assert.equal(data.obligations[0].days_overdue,null); + const afterFirst=await probe(mode,'inspect',{},true);assert.deepEqual(afterFirst.domain_hashes,before.domain_hashes);if(mode==='compras')assert.equal(afterFirst.legacy_balance,760000); + const html=await q.request('/supplier-payments');assert.equal(html.status,200);assert(html.text.includes('7 600.00 MZN'));assert(!html.text.includes('
r.status).sort(),[200,409]); + data=await q.list();assert.equal(data.obligations[0].balance_minor,260000);assert.equal(data.allocations.length,2);assert(data.payments.some(r=>r.unallocated_minor>0)); + const losing=overRace.results.findIndex(r=>r.status===409); + const rejectedPlan={...competing[losing].body,decision:'rejected',reason:'Balance changed; reject the unapplied proposal'}; + await v.send('allocation-decide',rejectedPlan);await v.send('allocation-decide',competing[losing].body,409); + const rejectedPayment=await p.send('payment-prepare',{...paymentBody,fact_reference:'SIM-PAY-REJECT',evidence_reference:'SIM-PAY-REJECT-EVID',amount:'100.00'}); + const reject={payment_id:rejectedPayment.payment_id,decision:'rejected',evidence_reference:'SIM-REJECT-CONF',reason:'Evidence does not sustain confirmation'}; + const rejection=await v.send('payment-decide',reject);assert.equal((await v.send('payment-decide',reject)).decision_id,rejection.decision_id); + await p.send('allocation-prepare',{...plan,payment_id:rejectedPayment.payment_id,reference:'SIM-REJECTED-ALLOC'},409); + data=await q.list();assert.equal(data.obligations[0].balance_minor,260000);assert.equal(data.payments.find(r=>r.id===rejectedPayment.payment_id).unallocated_minor,null); + await probe(mode,'source',{override:{total_minor:1160001}},true);await v.send('payment-decide',decision,409);assert.equal((await q.list()).obligations[0].balance_minor,null);await probe(mode,'source',{},true); + const multi=await probe(mode,'multi');assert.deepEqual(multi.balances,[0,0,100000]);assert.equal(multi.unallocated_minor,40000);assert.equal(multi.foreign_supplier_refused,true);assert.equal(multi.other_unit_reference_refused,true); + const counts=await probe(mode,'inspect');assert.equal(counts.counts.ps_events,counts.counts.ps_outbox); + const effectsBeforeHistory=await probe(mode,'inspect');await probe(mode,'history',{},true); + await p.send('payment-prepare',{...paymentBody,fact_reference:'SIM-HISTORY-REFUSED',evidence_reference:'SIM-HISTORY-EVID'},409); + assert.deepEqual((await probe(mode,'inspect')).counts,effectsBeforeHistory.counts); + await v.send('payment-decide',decision,419,randomUUID(),{noCsrf:true});if(mode==='sgf')await v.send('payment-decide',decision,409,randomUUID(),{stale:true}); + const fresh=client();await fresh.login('consulta');const recovered=await fresh.list();assert.equal(recovered.payments.length,4);assert.equal(recovered.allocations.length,2);assert.equal(recovered.obligations[0].balance_minor,null); + hosts.push({host:mode,passed:true,core_sha256:counts.core_sha256,principal_minor:1160000,balance_after_first_allocation_minor:760000,due_on:null,days_overdue:null,multi_document_core:multi, + races:[revoked,paymentRace,planRace,allocationRevoke,allocationRace,overRace],checks:['payment_confirmation_does_not_reduce_debt','explicit_allocations_only','new_request_keys_recover_same_fact','duplicate_evidence_conflicts','partial_and_excess','overallocation_refused','rejected_payment_or_allocation_has_no_effect','separate_actor_and_person','revocation_while_waiting','atomic_fault_rollback_and_retry','source_revision_conflict','cross_company_refused','cross_supplier_refused','history_not_ignored','immutable_confirmed_facts','legacy_balance_uses_common_position','unchanged_stock_budget_invoice_legacy_payments','csrf_and_context','logout_reentry_history','durable_outbox'],visual_review:false}); +} +assert.equal(hosts[0].core_sha256,hosts[1].core_sha256); +await fs.writeFile('docs/proofs/supplier-settlements.json',JSON.stringify({version:'COMMON-SUPPLIER-SETTLEMENT-SIM-v1',checked_at:new Date().toISOString(),hosts,bank_execution:false,accounting:false,real_migration:false},null,2)+'\n'); +console.log('Both hosts passed: explicit supplier settlements, canonical balance, duplicate/concurrent requests, failure recovery and preserved source/stock/budget/history.'); diff --git a/scripts/settlements-install.php b/scripts/settlements-install.php new file mode 100644 index 0000000..e1a1c59 --- /dev/null +++ b/scripts/settlements-install.php @@ -0,0 +1,33 @@ + $v) { putenv($k.'='.$v); $_ENV[$k] = $_SERVER[$k] = $v; } +require $settings['HOST_ROOT'].'/vendor/autoload.php'; +$app = require $settings['HOST_ROOT'].'/bootstrap/app.php'; $app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap(); +use Illuminate\Support\Facades\DB; +use Tempo\Finance\Payables\SettlementSchema; +$db = DB::connection(); +if (! preg_match('/^(compras_receipts_test_|payable_sgf_)[a-f0-9]{12}$/D', $db->getDatabaseName()) || $db->getConfig('host') !== '127.0.0.1' + || $db->selectOne('select current_database() as d')->d !== $settings['DB_DATABASE'] || ! app()->environment('payable-testing')) throw new LogicException('Synthetic database guard refused.'); +$role = $db->selectOne('select rolsuper,rolcreatedb,rolcreaterole from pg_roles where rolname=current_user'); +if ($role->rolsuper || $role->rolcreatedb || $role->rolcreaterole) throw new LogicException('Restricted QA role required.'); +$existing = array_filter(SettlementSchema::TABLES, fn ($t) => $db->getSchemaBuilder()->hasTable($t)); +if ($existing && count($existing) !== count(SettlementSchema::TABLES)) throw new LogicException('Incomplete schema, nothing repaired.'); +if (! $existing) SettlementSchema::install(); +$fixture = json_decode(file_get_contents($settings['PAYABLE_ACCESS_FILE']), true, 32, JSON_THROW_ON_ERROR); +$grants = 0; +DB::transaction(function () use ($fixture, &$grants): void { + foreach (['preparacao' => ['payments_view', 'payment_prepare', 'allocation_prepare'], 'validacao' => ['payments_view', 'payment_confirm', 'allocation_confirm'], 'consulta' => ['payments_view']] as $role => $actions) { + $account = $fixture['accounts'][$role]; + $user = \App\Models\User::findOrFail($account['id']); + if ($user->email !== $role.'-obrigacoes@example.test') throw new LogicException('Existing synthetic identity required; no accounts created.'); + $person = DB::table('pa_access')->where($fixture['scope'])->where(['user_id' => $user->id, 'action' => 'view'])->value('person'); + if ($person !== 'synthetic-person-'.$role) throw new LogicException('Synthetic person mapping differs.'); + foreach ($actions as $action) $grants += DB::table('pa_access')->insertOrIgnore($fixture['scope'] + ['user_id' => $user->id, 'person' => $person, 'action' => $action]); + } +}); +echo json_encode(['host' => $mode, 'isolated' => true, 'schema_tables' => count(SettlementSchema::TABLES), 'synthetic_grants_added' => $grants, + 'accounts_created' => 0, 'documents_changed' => false, 'payments_created' => 0], JSON_THROW_ON_ERROR).PHP_EOL; diff --git a/scripts/settlements-probe.php b/scripts/settlements-probe.php new file mode 100644 index 0000000..5129445 --- /dev/null +++ b/scripts/settlements-probe.php @@ -0,0 +1,138 @@ + $v) { putenv($k.'='.$v); $_ENV[$k] = $_SERVER[$k] = $v; } +require $s['HOST_ROOT'].'/vendor/autoload.php'; $app = require $s['HOST_ROOT'].'/bootstrap/app.php'; $app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap(); +use Illuminate\Support\Facades\DB; +use Symfony\Component\Process\Process; +use Tempo\Finance\Payables\{Payables, SupplierSettlements, SettlementSchema}; +$db = DB::connection(); +if (! preg_match('/^(compras_receipts_test_|payable_sgf_)[a-f0-9]{12}$/D', $db->getDatabaseName()) || $db->selectOne('select current_database() as d')->d !== $s['DB_DATABASE']) throw new LogicException('QA target refused.'); +$f = json_decode(file_get_contents($s['PAYABLE_ACCESS_FILE']), true, 32, JSON_THROW_ON_ERROR); +$input = json_decode(stream_get_contents(STDIN) ?: '{}', true, 64, JSON_THROW_ON_ERROR); +$class = $mode === 'compras' ? \App\Services\SupplierPayables::class : \App\Modules\Purchasing\SupplierPayables::class; +$select = function (string $role) use ($f, $mode) { + $u = \App\Models\User::findOrFail($f['accounts'][$role]['id']); auth()->guard()->setUser($u); + if ($mode === 'sgf') app(\App\Shared\Context\CompanyContext::class)->selectFor($u->id, $f['scope']['tenant_id'], $f['scope']['company_id'], $f['scope']['unit_id']); + return $u; +}; +if ($job === 'inspect') { + $counts = []; foreach (SettlementSchema::TABLES as $table) $counts[$table] = $db->table($table)->count(); + $out = ['counts' => $counts, 'allocation_events' => $db->table('ps_events')->where('kind', 'supplier_allocation.confirmed')->count(), + 'core_sha256' => hash_file('sha256', __DIR__.'/../packages/payables/src/SupplierSettlements.php')]; +} elseif ($job === 'worker') { + $u = $select($input['role']); $db->select('select set_config(?, ?, false)', ['application_name', $input['label']]); + try { $out = ['status' => 200, 'result' => app($class)->engine($u)->settlements()->command($input['action'], $input['body'], $input['key'])]; } + catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) { $out = ['status' => $e->getStatusCode()]; } + catch (\DomainException $e) { $out = ['status' => 409, 'message' => $e->getMessage()]; } + catch (\InvalidArgumentException $e) { $out = ['status' => 422]; } +} elseif ($job === 'race') { + $db->beginTransaction(); $db->table($mode === 'compras' ? 'tenants' : 'companies')->where('id', $f['scope'][$mode === 'compras' ? 'tenant_id' : 'company_id'])->lockForUpdate()->firstOrFail(); + $processes = []; + try { + foreach ([0, 1] as $i) { + $command = $input['commands'][$i] ?? $input; + $p = new Process([PHP_BINARY, __FILE__, $mode, 'worker'], $s['HOST_ROOT']); + $p->setInput(json_encode($command + ['key' => \Illuminate\Support\Str::uuid()->toString(), 'label' => 'ps_proof_'.$i], JSON_THROW_ON_ERROR)); + $p->setTimeout(35); $p->start(); $processes[] = $p; + } + config(['database.connections.ps_monitor' => $db->getConfig()]); $monitor = DB::connection('ps_monitor'); $deadline = microtime(true) + 18; $waits = 0; + do { + $waits = (int) $monitor->selectOne("select count(*) as n from pg_stat_activity where datname=? and application_name in ('ps_proof_0','ps_proof_1') and wait_event_type='Lock'", [$db->getDatabaseName()])->n; + if ($waits === 2) break; + foreach ($processes as $p) if (! $p->isRunning()) throw new RuntimeException('Worker ended before overlap: '.$p->getOutput().$p->getErrorOutput()); + usleep(20000); + } while (microtime(true) < $deadline); + if ($waits !== 2) throw new RuntimeException('Two lock waits required.'); + if (isset($input['revoke'])) $db->table('pa_access')->where($f['scope'])->where(['user_id' => $f['accounts']['validacao']['id'], 'action' => $input['revoke']])->delete(); + $db->commit(); $results = []; + foreach ($processes as $p) { $p->wait(); if (! $p->isSuccessful()) throw new RuntimeException($p->getOutput().$p->getErrorOutput()); $results[] = json_decode($p->getOutput(), true, 32, JSON_THROW_ON_ERROR); } + $out = ['waits' => $waits, 'results' => $results]; + } finally { if ($db->transactionLevel()) $db->rollBack(); foreach ($processes as $p) if ($p->isRunning()) $p->stop(); } +} elseif ($job === 'fault') { + if ($input['enabled']) $db->unprepared("CREATE FUNCTION ps_qa_fault() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.kind='supplier_allocation.confirmed' THEN RAISE EXCEPTION 'PS_QA_FAULT'; END IF; RETURN NEW; END $$; CREATE TRIGGER ps_qa_fault BEFORE INSERT ON ps_events FOR EACH ROW EXECUTE FUNCTION ps_qa_fault()"); + else $db->unprepared('DROP TRIGGER ps_qa_fault ON ps_events; DROP FUNCTION ps_qa_fault()'); + $out = ['configured' => true]; +} elseif ($job === 'grant') { + if (! in_array($input['action'], ['payments_view', 'payment_prepare', 'payment_confirm', 'allocation_prepare', 'allocation_confirm'], true)) throw new LogicException('Invalid QA action.'); + $role = $input['role']; $match = $f['scope'] + ['user_id' => $f['accounts'][$role]['id'], 'action' => $input['action']]; + if ($input['enabled']) $db->table('pa_access')->insertOrIgnore($match + ['person' => 'synthetic-person-'.$role]); else $db->table('pa_access')->where($match)->delete(); + $out = ['changed' => true]; +} elseif ($job === 'person') { + $db->table('pa_access')->where($f['scope'])->where(['user_id' => $f['accounts']['validacao']['id'], 'action' => 'view'])->update(['person' => $input['same'] ? 'synthetic-person-preparacao' : 'synthetic-person-validacao']); + $out = ['changed' => true]; +} elseif ($job === 'immutable') { + $refused = 0; + foreach (['ps_payments', 'ps_payment_decisions', 'ps_allocations'] as $table) { + try { DB::transaction(fn () => $db->table($table)->where('id', $db->table($table)->min('id'))->delete()); } + catch (\Illuminate\Database\QueryException) { $refused++; } + } + $out = ['refused' => $refused]; +} elseif ($job === 'foreign') { + $now = now(); $tenant = $db->table('tenants')->insertGetId(['name' => 'Outro contexto sintético', 'created_at' => $now, 'updated_at' => $now]); + if ($mode === 'compras') { + $u = \App\Models\User::factory()->create(['tenant_id' => $tenant, 'purchasing_role' => 'finance']); $scope = ['tenant_id' => $tenant, 'company_id' => 0, 'unit_id' => 0]; + } else { + $company = $db->table('companies')->insertGetId(['tenant_id' => $tenant, 'name' => 'Outra empresa sintética', 'created_at' => $now, 'updated_at' => $now]); + $unit = $db->table('units')->insertGetId(['tenant_id' => $tenant, 'company_id' => $company, 'name' => 'Outra unidade', 'created_at' => $now, 'updated_at' => $now]); + $scope = ['tenant_id' => $tenant, 'company_id' => $company, 'unit_id' => $unit]; + $u = \App\Models\User::create(['name' => 'Outro actor sintético', 'email' => 'foreign-settlement@example.test', 'password' => bin2hex(random_bytes(20))]); + $db->table('company_memberships')->insert(['user_id' => $u->id, 'tenant_id' => $tenant, 'company_id' => $company]); + foreach (['company.view' => null, 'unit.view' => $unit, 'supplier_payables.view' => $unit] as $permission => $which) $db->table('permission_grants')->insert(['tenant_id' => $tenant, 'company_id' => $company, 'unit_id' => $which, 'permission' => $permission, 'user_id' => $u->id, 'created_at' => $now, 'updated_at' => $now]); + } + foreach (['view', 'payments_view', 'payment_confirm', 'allocation_confirm'] as $action) $db->table('pa_access')->insert($scope + ['user_id' => $u->id, 'action' => $action, 'person' => 'foreign-synthetic-person']); + auth()->guard()->setUser($u); if ($mode === 'sgf') app(\App\Shared\Context\CompanyContext::class)->selectFor($u->id, $tenant, $company, $unit); + try { app($class)->engine($u)->settlements()->command('payment-decide', $input['body'], \Illuminate\Support\Str::uuid()->toString()); $out = ['refused' => false]; } + catch (\DomainException) { $out = ['refused' => true]; } +} elseif ($job === 'multi') { + // Core contract test with explicit synthetic source adapter, distinct from the real host source proof. + $base = json_decode(file_get_contents($s['PAYABLE_BUNDLE']), true, 64, JSON_THROW_ON_ERROR); + $scope = ['tenant_id' => 900001, 'company_id' => 900002, 'unit_id' => 900003]; $sources = []; + foreach ([1 => 1160000, 2 => 500000, 3 => 100000] as $id => $minor) { + $sources[$id] = array_replace($base, ['instance' => 'synthetic-core-multi', 'company' => 900002, 'document' => $id, 'local_id' => $id, + 'number' => 'SIM-CORE-'.$id, 'subtotal_minor' => $minor, 'tax_minor' => 0, 'total_minor' => $minor, 'supplier_id' => $id === 3 ? 999 : $base['supplier_id'], + 'lines' => [['id' => $id, 'order_line' => $id, 'quantity_milli' => 1000, 'subtotal_minor' => $minor, 'tax_minor' => 0, 'snapshot' => ['synthetic_core_fixture' => true]]]]); + } + $sources[4] = array_replace($sources[1], ['document' => 4, 'local_id' => 4, 'number' => 'SIM-CORE-UNIT-4']); + $engine = static function ($actor, ?array $otherScope = null) use ($db, $scope, $sources): Payables { + return new Payables($db, $actor, 'core-person-'.$actor, $otherScope ?? $scope, static function ($action) use ($actor) { + $allowed = $actor === 1 ? ['view', 'prepare', 'payments_view', 'payment_prepare', 'allocation_prepare'] : ['view', 'validate', 'payments_view', 'payment_confirm', 'allocation_confirm']; + if (! in_array($action, $allowed, true)) throw new DomainException('Core role refused.'); + }, static fn ($id) => $sources[$id] ?? throw new DomainException('Unknown core source.')); + }; + $p = $engine(1); $v = $engine(2); $uuid = static fn () => \Illuminate\Support\Str::uuid()->toString(); + $obs = []; + foreach ([1, 2, 3] as $id) { $proposal = $p->command('prepare', $id, 'Core synthetic source', $uuid()); $obs[$id] = $v->command('validate', $proposal['proposal_id'], 'Independent core recognition', $uuid())['obligation_id']; } + $pay = static function ($ref, $amount) use ($p, $v, $obs, $sources, $uuid) { + $r = $p->settlements()->command('payment-prepare', ['obligation_id' => $obs[1], 'source_revision' => Payables::revision($sources[1]), 'amount' => $amount, 'currency' => 'MZN', 'paid_on' => date('Y-m-d'), 'fact_reference' => $ref, 'evidence_reference' => $ref.'-EVID', 'reason' => 'Core synthetic payment'], $uuid()); + $v->settlements()->command('payment-decide', ['payment_id' => $r['payment_id'], 'decision' => 'confirmed', 'evidence_reference' => $ref.'-CONF', 'reason' => 'Independent core confirmation'], $uuid()); return $r['payment_id']; + }; + $payment = $pay('SIM-MULTI-001', '14000.00'); + $plan = ['payment_id' => $payment, 'reference' => 'SIM-MULTI-ALLOC-001', 'reason' => 'Explicit multi-document allocation', 'allocations' => [ + ['obligation_id' => $obs[1], 'source_revision' => Payables::revision($sources[1]), 'amount' => '11600.00'], + ['obligation_id' => $obs[2], 'source_revision' => Payables::revision($sources[2]), 'amount' => '2400.00']]]; + $proposal = $p->settlements()->command('allocation-prepare', $plan, $uuid()); + $v->settlements()->command('allocation-decide', ['proposal_id' => $proposal['proposal_id'], 'decision' => 'confirmed', 'reason' => 'Independent multi allocation'], $uuid()); + $payment2 = $pay('SIM-MULTI-002', '3000.00'); + $proposal2 = $p->settlements()->command('allocation-prepare', ['payment_id' => $payment2, 'reference' => 'SIM-MULTI-ALLOC-002', 'reason' => 'Second payment allocation', 'allocations' => [['obligation_id' => $obs[2], 'source_revision' => Payables::revision($sources[2]), 'amount' => '2600.00']]], $uuid()); + $v->settlements()->command('allocation-decide', ['proposal_id' => $proposal2['proposal_id'], 'decision' => 'confirmed', 'reason' => 'Independent second allocation'], $uuid()); + $foreign = false; + try { $p->settlements()->command('allocation-prepare', ['payment_id' => $payment2, 'reference' => 'SIM-MULTI-FOREIGN', 'reason' => 'Foreign supplier refusal', 'allocations' => [['obligation_id' => $obs[3], 'source_revision' => Payables::revision($sources[3]), 'amount' => '100.00']]], $uuid()); } catch (DomainException) { $foreign = true; } + $positions = $p->listing([1, 2, 3])['obligations']; $payments = $p->settlements()->listing()['payments']; + $otherScope = array_replace($scope, ['unit_id' => 900004]); $op = $engine(1, $otherScope); $ov = $engine(2, $otherScope); + $otherProposal = $op->command('prepare', 4, 'Other unit source', $uuid()); + $otherObligation = $ov->command('validate', $otherProposal['proposal_id'], 'Independent other unit recognition', $uuid())['obligation_id']; + $otherUnitRefused = false; + try { $op->settlements()->command('payment-prepare', ['obligation_id' => $otherObligation, 'source_revision' => Payables::revision($sources[4]), 'amount' => '14000.00', 'currency' => 'MZN', 'paid_on' => date('Y-m-d'), 'fact_reference' => 'SIM-MULTI-001', 'evidence_reference' => 'SIM-OTHER-UNIT-EVID', 'reason' => 'Duplicate through another unit'], $uuid()); } + catch (DomainException) { $otherUnitRefused = true; } + $out = ['balances' => array_column($positions, 'balance_minor'), 'unallocated_minor' => $payments[1]['unallocated_minor'], 'foreign_supplier_refused' => $foreign, + 'other_unit_reference_refused' => $otherUnitRefused, + 'kind' => 'core explicit synthetic source adapter; not another host invoice workflow']; +} else throw new LogicException('Unknown probe job.'); +echo json_encode($out, JSON_THROW_ON_ERROR).PHP_EOL;